Compare commits
No commits in common. "2b14234d6c051a90ae2d62e64b3293f8db626e25" and "33114847122813ccc7d63141495744b2a0013a40" have entirely different histories.
2b14234d6c
...
3311484712
37 changed files with 9352 additions and 2460 deletions
2
.mono/config
Normal file
2
.mono/config
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[project]
|
||||||
|
name = "code-analysis"
|
||||||
Binary file not shown.
1
.mono/refs/HEAD
Normal file
1
.mono/refs/HEAD
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
c813292f1400c21462beea82e2f3bcb5a3b7cc8dc8ca26010cd530d6f9166658
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
# cstat Grounding Plan
|
|
||||||
|
|
||||||
## 1. Project Intent
|
|
||||||
|
|
||||||
`cstat` is a Rust codebase-shape analysis tool. It exists to make messy Rust codebases easier to inspect, reason about, and improve in bounded steps.
|
|
||||||
|
|
||||||
The project should prove three linked ideas:
|
|
||||||
|
|
||||||
1. **Codebase shape exploration** — existing probes expose different structural views of a Rust codebase.
|
|
||||||
2. **Verified complexity function** — those probes can feed a deterministic, explainable complexity/cleanup function.
|
|
||||||
3. **RLVF harness / environment** — the tool can support baseline, attempted cleanup, verification, re-measurement, and structured feedback for human or agent-driven codebase fixing.
|
|
||||||
|
|
||||||
This project is not trying to become a general-purpose static analyzer, automatic refactoring engine, full RL training system, architecture platform, or web product.
|
|
||||||
|
|
||||||
## 2. End-State Goals
|
|
||||||
|
|
||||||
### 2.1 Codebase Shape Exploration
|
|
||||||
|
|
||||||
`cstat` should let a user explore the shape of a Rust codebase through multiple static lenses. Existing probes are valuable because each one describes a different part of codebase shape.
|
|
||||||
|
|
||||||
Relevant shape views include:
|
|
||||||
|
|
||||||
- line/file size and distribution;
|
|
||||||
- symbol counts and concentration;
|
|
||||||
- module dependencies and coupling;
|
|
||||||
- per-function and per-file complexity;
|
|
||||||
- static dead-code candidates;
|
|
||||||
- static test/benchmark reachability;
|
|
||||||
- call-flow or call-trace views where useful;
|
|
||||||
- coverage or cluster views where they directly support exploration or verification.
|
|
||||||
|
|
||||||
The goal is not to hide these probes. The goal is to make clear why they exist, when to use them, and how they contribute to the larger project purpose.
|
|
||||||
|
|
||||||
### 2.2 Verified Complexity Function
|
|
||||||
|
|
||||||
`cstat` should define one deterministic complexity or cleanup function derived from existing metrics.
|
|
||||||
|
|
||||||
This function should be:
|
|
||||||
|
|
||||||
- deterministic for the same input codebase;
|
|
||||||
- explainable from its component metrics;
|
|
||||||
- stable enough to consume from JSON output;
|
|
||||||
- tested against fixtures and invariants;
|
|
||||||
- honest about what it measures.
|
|
||||||
|
|
||||||
“Verified” does not mean the function proves true code quality. It means the function has a stated contract, stable inputs and outputs, and tests showing that obvious structural changes move the score in expected directions.
|
|
||||||
|
|
||||||
Example invariants:
|
|
||||||
|
|
||||||
- adding branches should not reduce the complexity component;
|
|
||||||
- adding large functions should not improve the size/complexity result;
|
|
||||||
- adding unnecessary dependencies should not improve coupling;
|
|
||||||
- reducing test reachability should not improve the verification signal;
|
|
||||||
- preserving behavior while lowering measured complexity should improve the cleanup signal.
|
|
||||||
|
|
||||||
### 2.3 RLVF Harness / Environment
|
|
||||||
|
|
||||||
`cstat` should support an RLVF-style loop for codebase-fixing attempts.
|
|
||||||
|
|
||||||
The minimal loop is:
|
|
||||||
|
|
||||||
1. establish a baseline shape/complexity measurement;
|
|
||||||
2. allow a human or agent to make a bounded cleanup attempt;
|
|
||||||
3. run the project’s verification command, such as tests or build;
|
|
||||||
4. re-measure shape and complexity;
|
|
||||||
5. emit structured feedback describing whether the attempt improved the measured objective while preserving required behavior.
|
|
||||||
|
|
||||||
The harness does not need to train a model. It does not need to call an LLM. It does not need to apply patches automatically. For this project, the harness is the environment and feedback mechanism around a codebase-changing attempt.
|
|
||||||
|
|
||||||
## 3. Boundaries / Non-Goals
|
|
||||||
|
|
||||||
The MVP stays bounded by these rules:
|
|
||||||
|
|
||||||
- Rust-only for now.
|
|
||||||
- Static-first analysis.
|
|
||||||
- Existing probes should be organized before new probes are added.
|
|
||||||
- New analysis is only in scope if it directly supports shape exploration, the complexity function, or the RLVF harness.
|
|
||||||
- No automatic refactoring.
|
|
||||||
- No RL training loop.
|
|
||||||
- No LLM integration.
|
|
||||||
- No promise of perfect macro expansion, dynamic dispatch resolution, or runtime truth.
|
|
||||||
- No web UI.
|
|
||||||
- No plugin system.
|
|
||||||
- No multi-language support.
|
|
||||||
- No broad benchmark suite before the core loop works.
|
|
||||||
- No feature is justified merely because it is interesting.
|
|
||||||
|
|
||||||
A change is in scope only if it improves one of these:
|
|
||||||
|
|
||||||
- codebase shape exploration;
|
|
||||||
- the verified complexity function;
|
|
||||||
- the RLVF harness/environment;
|
|
||||||
- the demo proving those concepts;
|
|
||||||
- the documentation needed to make the project understandable.
|
|
||||||
|
|
||||||
## 4. Current Assets
|
|
||||||
|
|
||||||
The project already has substantial useful material:
|
|
||||||
|
|
||||||
- multiple CLI probes for structural codebase analysis;
|
|
||||||
- focused root commands for each maintained analysis path;
|
|
||||||
- JSON output modes;
|
|
||||||
- tests around several CLI behaviors;
|
|
||||||
- static analysis modules for size, symbols, dependencies, complexity, dead-code candidates, reachability, call flow, coverage, clustering, and related views;
|
|
||||||
- `cstat-agent-use.md`, which already points toward agent-oriented tool use;
|
|
||||||
- enough real code for dogfooding the tool on itself.
|
|
||||||
|
|
||||||
These assets should be treated as the foundation, not as clutter to discard by default.
|
|
||||||
|
|
||||||
## 5. Current Gaps
|
|
||||||
|
|
||||||
The missing pieces are mostly framing, contracts, and end-to-end proof.
|
|
||||||
|
|
||||||
### 5.1 Purpose and CLI Organization
|
|
||||||
|
|
||||||
The CLI exposes many capabilities, but the project purpose is not yet clearly organized around shape exploration, complexity scoring, and RLVF feedback.
|
|
||||||
|
|
||||||
The probes work, but they need to be presented as intentional views of codebase shape rather than as unrelated commands.
|
|
||||||
|
|
||||||
### 5.2 Verified Complexity Function
|
|
||||||
|
|
||||||
The project does not yet define a single named complexity/cleanup function with:
|
|
||||||
|
|
||||||
- explicit metric inputs;
|
|
||||||
- a deterministic formula;
|
|
||||||
- a stable JSON output shape;
|
|
||||||
- stated invariants;
|
|
||||||
- tests proving those invariants.
|
|
||||||
|
|
||||||
### 5.3 RLVF Harness Flow
|
|
||||||
|
|
||||||
The project does not yet demonstrate the full loop:
|
|
||||||
|
|
||||||
- baseline measurement;
|
|
||||||
- bounded cleanup attempt;
|
|
||||||
- project verification;
|
|
||||||
- after measurement;
|
|
||||||
- structured feedback/reward evidence.
|
|
||||||
|
|
||||||
Without this loop, the RLVF claim is only conceptual.
|
|
||||||
|
|
||||||
### 5.4 Demo
|
|
||||||
|
|
||||||
The project needs a concrete demo showing that the tool works end to end.
|
|
||||||
|
|
||||||
The demo should show:
|
|
||||||
|
|
||||||
- human exploration of a messy codebase;
|
|
||||||
- machine-readable output suitable for an agent;
|
|
||||||
- a before/after verification path;
|
|
||||||
- how the complexity function changes after a bounded cleanup.
|
|
||||||
|
|
||||||
### 5.5 Public Explanation
|
|
||||||
|
|
||||||
The README is too thin to communicate what the project is, why it exists, or how the existing pieces fit together.
|
|
||||||
|
|
||||||
The project needs enough surface-level polish that another developer can understand:
|
|
||||||
|
|
||||||
- what problem `cstat` solves;
|
|
||||||
- what commands or profiles to start with;
|
|
||||||
- what the metrics mean;
|
|
||||||
- what the limitations are;
|
|
||||||
- how the demo proves the core claims.
|
|
||||||
|
|
||||||
## 6. Gap-Fill Plan
|
|
||||||
|
|
||||||
### 6.1 Organize Existing Probes
|
|
||||||
|
|
||||||
Document the existing probes as codebase-shape views.
|
|
||||||
|
|
||||||
For each probe, clarify:
|
|
||||||
|
|
||||||
- what shape dimension it exposes;
|
|
||||||
- whether it is mainly useful for humans, agents, RLVF feedback, or all three;
|
|
||||||
- what command produces it;
|
|
||||||
- whether the output is stable enough for machine use.
|
|
||||||
|
|
||||||
This should make the existing breadth feel intentional without requiring every probe to become part of the core proof.
|
|
||||||
|
|
||||||
### 6.2 Define Tool-Use Profiles
|
|
||||||
|
|
||||||
Define limited profiles that combine existing probes for specific workflows.
|
|
||||||
|
|
||||||
Candidate profiles:
|
|
||||||
|
|
||||||
- **human exploration profile** — emphasizes readable summaries and cleanup starting points;
|
|
||||||
- **agent steering profile** — emphasizes compact JSON, hotspots, constraints, and suggested drilldowns;
|
|
||||||
- **RLVF profile** — emits stable fields needed for before/after feedback and reward calculation.
|
|
||||||
|
|
||||||
Profiles should be thin orchestration over existing analysis where possible.
|
|
||||||
|
|
||||||
### 6.3 Add the Complexity Function
|
|
||||||
|
|
||||||
Define a named complexity or cleanup cost function.
|
|
||||||
|
|
||||||
The implementation should reuse existing metrics first. The first version should prioritize determinism, explainability, and tests over sophistication.
|
|
||||||
|
|
||||||
The output should include:
|
|
||||||
|
|
||||||
- score or cost value;
|
|
||||||
- score version;
|
|
||||||
- component breakdown;
|
|
||||||
- top contributors/hotspots;
|
|
||||||
- enough metadata to compare before/after results.
|
|
||||||
|
|
||||||
### 6.4 Verify the Complexity Function
|
|
||||||
|
|
||||||
Add tests that prove the function’s contract.
|
|
||||||
|
|
||||||
The tests should focus on observable properties, not incidental implementation details.
|
|
||||||
|
|
||||||
Useful test cases include:
|
|
||||||
|
|
||||||
- a small/simple fixture has lower cost than a deliberately messy fixture;
|
|
||||||
- adding control-flow branches increases or preserves complexity cost;
|
|
||||||
- adding dead private functions worsens the relevant component;
|
|
||||||
- reducing test reachability does not improve the RLVF signal;
|
|
||||||
- JSON output contains the expected stable fields.
|
|
||||||
|
|
||||||
### 6.5 Build the RLVF Harness Flow
|
|
||||||
|
|
||||||
Create the minimal harness path for codebase-fixing attempts.
|
|
||||||
|
|
||||||
The harness should produce an artifact that records:
|
|
||||||
|
|
||||||
- task or attempt metadata;
|
|
||||||
- baseline profile output;
|
|
||||||
- verification command and result;
|
|
||||||
- after profile output;
|
|
||||||
- complexity delta;
|
|
||||||
- feedback/reward signal;
|
|
||||||
- reasons for acceptance or rejection.
|
|
||||||
|
|
||||||
The harness should remain separate from model training or patch generation.
|
|
||||||
|
|
||||||
### 6.6 Add an End-to-End Demo
|
|
||||||
|
|
||||||
The demo should prove the project’s core claims without becoming a second product.
|
|
||||||
|
|
||||||
A good demo path is dogfooding `cstat` on itself:
|
|
||||||
|
|
||||||
1. run shape exploration on the current repo;
|
|
||||||
2. identify an obvious cleanup target from the output;
|
|
||||||
3. make or describe a bounded cleanup attempt;
|
|
||||||
4. run project verification;
|
|
||||||
5. re-run the relevant profile;
|
|
||||||
6. show the feedback artifact.
|
|
||||||
|
|
||||||
If dogfooding is too noisy, add a small fixture crate that intentionally contains a few simple forms of messiness.
|
|
||||||
|
|
||||||
### 6.7 Polish Public Explanation
|
|
||||||
|
|
||||||
Update the public explanation after the core loop exists.
|
|
||||||
|
|
||||||
The README should explain:
|
|
||||||
|
|
||||||
- the three project goals;
|
|
||||||
- the basic commands or profiles;
|
|
||||||
- one short demo;
|
|
||||||
- what the metrics do and do not mean;
|
|
||||||
- how humans and agents use the tool differently;
|
|
||||||
- the non-goals that keep the project bounded.
|
|
||||||
|
|
||||||
## 7. Completion Criteria
|
|
||||||
|
|
||||||
The MVP is complete when all of the following are true:
|
|
||||||
|
|
||||||
- The project can be explained in one paragraph.
|
|
||||||
- Existing probes are framed as codebase-shape views.
|
|
||||||
- A deterministic complexity function exists.
|
|
||||||
- The complexity function has stated inputs, output, and invariants.
|
|
||||||
- Tests verify the complexity function’s basic contract.
|
|
||||||
- An RLVF-style harness flow exists.
|
|
||||||
- The harness produces structured feedback for a before/after cleanup attempt.
|
|
||||||
- A demo shows shape exploration, complexity scoring, verification, and feedback.
|
|
||||||
- The README explains the project clearly enough for a new developer to try it.
|
|
||||||
- Non-goals are documented and used to reject unrelated scope.
|
|
||||||
|
|
||||||
## 8. Scope Guardrail
|
|
||||||
|
|
||||||
The project should not expand just because another metric, command, or dashboard would be interesting.
|
|
||||||
|
|
||||||
The guardrail is:
|
|
||||||
|
|
||||||
> If a change does not support codebase shape exploration, the verified complexity function, the RLVF harness, the demo, or the public explanation of those pieces, it is outside the MVP.
|
|
||||||
|
|
||||||
## 9. Current CLI Polish Pass
|
|
||||||
|
|
||||||
We are currently going through the existing CLI commands and probes one by one. The goal is to polish, clarify, and prune the extant surface before designing RLVF targets or higher-level harness behavior.
|
|
||||||
|
|
||||||
This pass is not about adding new analyzers. It is about deciding, for each existing command:
|
|
||||||
|
|
||||||
- what codebase-shape concept it measures;
|
|
||||||
- whether the measure is useful for humans, agents, and later RLVF workflows;
|
|
||||||
- whether the output is granular enough;
|
|
||||||
- whether the behavior is tested well enough;
|
|
||||||
- whether the CLI/help/docs explain it clearly;
|
|
||||||
- whether any parts should be renamed, folded into another concept, demoted, or dropped.
|
|
||||||
|
|
||||||
Only after this CLI/probe pass is complete should we design the RLVF target or targets, because the RLVF harness needs stable, well-understood measurement primitives.
|
|
||||||
|
|
||||||
## 10. Current Branch / Worktree Status
|
|
||||||
|
|
||||||
- Main line now includes the accepted `loc` polish work.
|
|
||||||
- `loc` is a first-class line/size-shape probe.
|
|
||||||
- The `loc` branch/worktree was folded in and removed.
|
|
||||||
- Main line now includes the accepted `symbols` polish work.
|
|
||||||
- `symbols` is a first-class symbol-shape probe.
|
|
||||||
- It clarifies impl/trait-impl semantics, selected-file behavior, JSON output, and symbol granularity.
|
|
||||||
- The `symbols` branch/worktree was folded in and removed.
|
|
||||||
- `-complexity` branch:
|
|
||||||
- The old broad `complexity` command has been sharded.
|
|
||||||
- The sharding direction is accepted.
|
|
||||||
- Each new individual item still needs review before it is ready to fold in.
|
|
||||||
- Current stopping point: complexity concept split exists, but the resulting probes are not yet accepted as polished.
|
|
||||||
|
|
||||||
## 11. Stopping Point
|
|
||||||
|
|
||||||
- We resumed the CLI polish/pruning pass long enough to fold in the accepted `loc` and `symbols` work.
|
|
||||||
- `loc` and `symbols` are now the first accepted polished probes on the main line.
|
|
||||||
- We agreed that the old `complexity` command was too broad and vague.
|
|
||||||
- We narrowed the intended complexity split to specific probes from the existing complexity behavior:
|
|
||||||
- branching/control-flow path complexity;
|
|
||||||
- signature/type-boundary complexity;
|
|
||||||
- span/function-body size.
|
|
||||||
- We agreed not to treat all codebase complexity as one vague command.
|
|
||||||
- Current next review point is the sharded `-complexity` worktree/branch.
|
|
||||||
|
|
||||||
## 12. Next Steps
|
|
||||||
|
|
||||||
1. Review the sharded `-complexity` items individually.
|
|
||||||
2. Accept, revise, or reject each complexity shard based on whether it is granular, objective, tested, and useful for human/agent/RLVF workflows.
|
|
||||||
3. Fold in only the accepted complexity shards.
|
|
||||||
4. Continue the same polish/pruning review for the remaining existing CLI commands.
|
|
||||||
5. After the measurement primitives are stable, design the RLVF target or targets around those accepted probes.
|
|
||||||
|
|
||||||
## 13. Extreme Bare Minimum
|
|
||||||
|
|
||||||
Before the broader MVP work, the project needs a narrow, polished, resume-linkable slice that proves `cstat` is already usable.
|
|
||||||
|
|
||||||
This slice is not the full verified complexity function or RLVF harness. It is the smallest public path that shows a clean CLI and a credible human/agent demo.
|
|
||||||
|
|
||||||
### 13.1 Polished CLI MVP
|
|
||||||
|
|
||||||
The CLI MVP should expose a small accepted command surface rather than every existing probe.
|
|
||||||
|
|
||||||
For this slice:
|
|
||||||
|
|
||||||
- `loc` is folded in as an accepted line/size-shape probe;
|
|
||||||
- `symbols` is folded in as an accepted symbol-shape probe;
|
|
||||||
- present both as first-class codebase-shape probes;
|
|
||||||
- make command names, help text, examples, and output modes clear enough for a new user to run without project context;
|
|
||||||
- keep JSON output stable enough for agent consumption;
|
|
||||||
- hide, demote, or leave out unfinished rough commands from the public happy path.
|
|
||||||
|
|
||||||
The goal is a CLI that feels intentional and usable, not a complete analysis platform.
|
|
||||||
|
|
||||||
### 13.2 Human Demo
|
|
||||||
|
|
||||||
Add a short demo showing how a person uses the polished CLI on a Rust repo.
|
|
||||||
|
|
||||||
The demo should show:
|
|
||||||
|
|
||||||
- the exact commands to run;
|
|
||||||
- how to read file/size shape output;
|
|
||||||
- how to read symbol concentration output;
|
|
||||||
- how those outputs point to likely cleanup targets;
|
|
||||||
- one brief investigation or before/after walkthrough.
|
|
||||||
|
|
||||||
### 13.3 AI-Agent Demo
|
|
||||||
|
|
||||||
Add a short demo showing how an AI agent should consume the polished CLI.
|
|
||||||
|
|
||||||
The demo should show:
|
|
||||||
|
|
||||||
- the exact commands an agent should run;
|
|
||||||
- the JSON output path or mode it should consume;
|
|
||||||
- how to identify hotspots from structured output;
|
|
||||||
- how to choose a bounded next probe or cleanup target from that evidence.
|
|
||||||
|
|
||||||
This demo should stop at agent steering. It does not need the full RLVF harness, model training, automatic patching, or reward loop.
|
|
||||||
|
|
||||||
### 13.4 Completion Criteria
|
|
||||||
|
|
||||||
The extreme bare minimum is complete when:
|
|
||||||
|
|
||||||
- the polished CLI path is narrow, documented, and runnable;
|
|
||||||
- `loc` and `symbols` are folded into the main line as accepted probes;
|
|
||||||
- rough unfinished commands are not part of the primary public path;
|
|
||||||
- a human demo explains how to use the output;
|
|
||||||
- an AI-agent demo explains how to consume the JSON and pick a bounded next action;
|
|
||||||
- the README can support a resume link without implying the broader MVP is finished.
|
|
||||||
|
|
@ -4,7 +4,6 @@ Terminal CLI tool to examine a codebase through a statistical lens
|
||||||
## helpful commands
|
## helpful commands
|
||||||
```bash
|
```bash
|
||||||
cstat --help # cli options
|
cstat --help # cli options
|
||||||
cstat scorecard --path . --json # machine-readable code complexity cost
|
cstat -v # basic dashboard plus explanation
|
||||||
cstat loc --path . -v # size-shape analysis plus explanations
|
cstat <any> -v # runs the command and gives an explanation of the metrics
|
||||||
cstat test-reachability --path . --json # structured static test/bench reachability
|
|
||||||
```
|
```
|
||||||
|
|
@ -9,19 +9,28 @@ and tests preserve behavior.
|
||||||
|
|
||||||
## Starting point
|
## Starting point
|
||||||
|
|
||||||
Start with the focused commands that answer the current cleanup question:
|
Run the focused report first:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cstat scorecard --json --path <project_root>
|
cstat report --json --path <project_root>
|
||||||
cstat loc --json --path <project_root>
|
|
||||||
cstat symbols --json --path <project_root>
|
|
||||||
cstat deps --json --path <project_root>
|
|
||||||
cstat dead-code --json --path <project_root>
|
|
||||||
cstat test-reachability --json --path <project_root>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The stale `summary`, `report`, and `advanced` entry points have been removed.
|
Omitting the command is equivalent to `cstat summary`, which is the same focused
|
||||||
Use focused root commands directly.
|
report. The JSON contains:
|
||||||
|
|
||||||
|
- `line_counts`: total code lines, physical lines, and per-file counts.
|
||||||
|
- `symbols`: total and per-file counts for functions, structs, enums, traits,
|
||||||
|
`trait_impls`, consts, statics, and parse-error state. `trait_impls` means
|
||||||
|
`impl Trait for Type` blocks only, not inherent `impl Type` blocks.
|
||||||
|
- `dependencies`: module list, module interdependency matrix, edge count, and
|
||||||
|
fan-in/fan-out degrees.
|
||||||
|
- `dead_code`: functions not statically reachable from main, tests, or
|
||||||
|
benchmarks.
|
||||||
|
- `test_reachability`: static test/benchmark reachability, step counts, max
|
||||||
|
depth, and per-function/per-edge reaching-entry counts.
|
||||||
|
|
||||||
|
The focused report intentionally excludes older exploratory dashboards. They
|
||||||
|
remain available under `cstat advanced ...`.
|
||||||
|
|
||||||
## Focused drilldowns
|
## Focused drilldowns
|
||||||
|
|
||||||
|
|
@ -39,16 +48,28 @@ Each accepts `--json` for structured output.
|
||||||
- `cstat dead-code --json --path .` — static cold-function candidates.
|
- `cstat dead-code --json --path .` — static cold-function candidates.
|
||||||
- `cstat test-reachability --json --path .` — static test/benchmark
|
- `cstat test-reachability --json --path .` — static test/benchmark
|
||||||
reachability and reaching-entry counts.
|
reachability and reaching-entry counts.
|
||||||
- `cstat branching --json --path .` — per-function decision/path complexity.
|
- `cstat complexity --json --path .` — per-function cyclomatic, cognitive,
|
||||||
- `cstat signature --json --path .` — per-function API boundary complexity.
|
nesting, parameter, and line metrics.
|
||||||
- `cstat span --json --path .` — per-function implementation span metrics.
|
|
||||||
- `cstat scorecard --json --path .` — deterministic structural complexity cost
|
|
||||||
for agent optimization, with component costs, scope breakdown, and hotspots.
|
|
||||||
|
|
||||||
|
## Advanced / legacy commands
|
||||||
|
|
||||||
|
Use these only when the focused report points to a question they answer:
|
||||||
|
|
||||||
|
- `cstat advanced summary --json --path .` — previous broad dashboard.
|
||||||
|
- `cstat advanced flow --json --path .` — raw static call graph.
|
||||||
|
- `cstat advanced graph --json --path .` — graph centrality, SCCs, PageRank.
|
||||||
|
- `cstat advanced flow-heatmap --json --path .` — previous random-walk heatmap.
|
||||||
|
- `cstat advanced circuits --json --path .` — call-graph community detection.
|
||||||
|
- `cstat advanced redundancy --json --path .` — similar/duplicate function
|
||||||
|
candidates.
|
||||||
|
- `cstat advanced dist --json --path .` — metric histograms/correlation views.
|
||||||
|
- `cstat advanced map --json --path .` — terminal architecture map.
|
||||||
|
- `cstat advanced dump --path .` — previous diagnostic-score dump.
|
||||||
|
- `cstat advanced guide <topic>` — metric reference material.
|
||||||
|
|
||||||
## Cleanup workflow
|
## Cleanup workflow
|
||||||
|
|
||||||
1. Run the focused `cstat` commands that match the cleanup target.
|
1. Run `cstat report --json --path .`.
|
||||||
2. Read the focused sections in this order:
|
2. Read the focused sections in this order:
|
||||||
- line counts: find oversized files first;
|
- line counts: find oversized files first;
|
||||||
- symbols: find files with too many definitions;
|
- symbols: find files with too many definitions;
|
||||||
|
|
@ -56,8 +77,8 @@ Each accepts `--json` for structured output.
|
||||||
- dead code: review cold candidates before deleting;
|
- dead code: review cold candidates before deleting;
|
||||||
- test reachability: compare code surface against what tests/benches can statically reach.
|
- test reachability: compare code surface against what tests/benches can statically reach.
|
||||||
3. Plan one cleanup stage at a time.
|
3. Plan one cleanup stage at a time.
|
||||||
4. After each stage, rerun the same focused `cstat` commands and the project's
|
4. After each stage, rerun `cstat report --json --path .` and the project's own
|
||||||
own build/tests. cstat metrics do not prove correctness.
|
build/tests. cstat metrics do not prove correctness.
|
||||||
|
|
||||||
## Safety rules
|
## Safety rules
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,11 @@ fn type_complexity(ty: &Type) -> usize {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a Type to a readable string (public wrapper for cross-module use).
|
||||||
|
pub fn type_to_string_pub(ty: &Type) -> String {
|
||||||
|
type_to_string(ty)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a Type to a readable string.
|
/// Convert a Type to a readable string.
|
||||||
fn type_to_string(ty: &Type) -> String {
|
fn type_to_string(ty: &Type) -> String {
|
||||||
match ty {
|
match ty {
|
||||||
|
|
|
||||||
|
|
@ -762,16 +762,14 @@ fn render_entry_listing(listing: &EntryListing) {
|
||||||
|
|
||||||
fn render_human(output: &CallTraceJson, verbose: bool) {
|
fn render_human(output: &CallTraceJson, verbose: bool) {
|
||||||
println!("cstat call trace");
|
println!("cstat call trace");
|
||||||
println!("static possible-call expansion; not a runtime execution trace");
|
|
||||||
if verbose {
|
if verbose {
|
||||||
render::verbose_block(&[
|
render::verbose_block(&[
|
||||||
"Built from cstat's Rust source call graph.",
|
"Static call graph rendered from one selected root.",
|
||||||
"Branches are not evaluated; every statically resolved callee is shown.",
|
|
||||||
"Cycles stop expansion; shared functions expand in each stack.",
|
"Cycles stop expansion; shared functions expand in each stack.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("resolved entry:");
|
println!("resolved:");
|
||||||
match output.resolved.source {
|
match output.resolved.source {
|
||||||
EntrySource::CargoRun => {
|
EntrySource::CargoRun => {
|
||||||
println!(" cargo command: cargo run --");
|
println!(" cargo command: cargo run --");
|
||||||
|
|
@ -803,22 +801,19 @@ fn render_human(output: &CallTraceJson, verbose: bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("static expansion summary:");
|
println!();
|
||||||
|
render_trace_node(&output.tree);
|
||||||
|
println!();
|
||||||
|
println!("summary:");
|
||||||
println!(" functions reached: {}", output.functions_reached);
|
println!(" functions reached: {}", output.functions_reached);
|
||||||
println!(" max depth: {}", output.max_depth);
|
println!(" max depth: {}", output.max_depth);
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("tree:");
|
|
||||||
render_trace_node(&output.tree);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_file_call_trace_report(report: &FileCallTraceReport, verbose: bool) {
|
fn render_file_call_trace_report(report: &FileCallTraceReport, verbose: bool) {
|
||||||
println!("cstat file call trace ({})", report.file);
|
println!("cstat file call trace ({})", report.file);
|
||||||
println!("static possible-call expansion per non-test function; not runtime execution traces");
|
|
||||||
if verbose {
|
if verbose {
|
||||||
render::verbose_block(&[
|
render::verbose_block(&[
|
||||||
"Built from cstat's Rust source call graph.",
|
"Static call graph rendered for every non-test function in the selected file.",
|
||||||
"Branches are not evaluated; every statically resolved callee is shown.",
|
|
||||||
"Cycles stop expansion; shared functions expand in each stack.",
|
"Cycles stop expansion; shared functions expand in each stack.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
687
src/circuits.rs
Normal file
687
src/circuits.rs
Normal file
|
|
@ -0,0 +1,687 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::flow;
|
||||||
|
use crate::render;
|
||||||
|
|
||||||
|
// ── Label Propagation Community Detection ────────────────────────
|
||||||
|
|
||||||
|
/// Detect communities in the call graph using label propagation.
|
||||||
|
/// Each function gets a label (initially its own index). Iteratively,
|
||||||
|
/// each node adopts the most frequent label among its neighbors.
|
||||||
|
|
||||||
|
/// Collect label frequency counts from a node's neighbors in both directions.
|
||||||
|
/// Returns a map of label -> occurrence count across all adjacent nodes.
|
||||||
|
fn collect_neighbor_labels(
|
||||||
|
node: &str,
|
||||||
|
adj: &HashMap<String, Vec<String>>,
|
||||||
|
rev_adj: &HashMap<String, Vec<String>>,
|
||||||
|
node_idx: &HashMap<&str, usize>,
|
||||||
|
labels: &[usize],
|
||||||
|
) -> HashMap<usize, usize> {
|
||||||
|
let mut label_counts: HashMap<usize, usize> = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(succs) = adj.get(node) {
|
||||||
|
for s in succs {
|
||||||
|
if let Some(&idx) = node_idx.get(s.as_str()) {
|
||||||
|
*label_counts.entry(labels[idx]).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(preds) = rev_adj.get(node) {
|
||||||
|
for p in preds {
|
||||||
|
if let Some(&idx) = node_idx.get(p.as_str()) {
|
||||||
|
*label_counts.entry(labels[idx]).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label_counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect communities in the call graph using label propagation.
|
||||||
|
/// Each function gets a label (initially its own index). Iteratively,
|
||||||
|
/// each node adopts the most frequent label among its neighbors.
|
||||||
|
/// Converges when labels stabilize.
|
||||||
|
fn label_propagation(
|
||||||
|
nodes: &[String],
|
||||||
|
adj: &HashMap<String, Vec<String>>,
|
||||||
|
rev_adj: &HashMap<String, Vec<String>>,
|
||||||
|
) -> Vec<Vec<String>> {
|
||||||
|
let n = nodes.len();
|
||||||
|
if n == 0 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let node_idx: HashMap<&str, usize> = nodes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, n)| (n.as_str(), i))
|
||||||
|
.collect();
|
||||||
|
let mut labels: Vec<usize> = (0..n).collect();
|
||||||
|
|
||||||
|
// Create a deterministic node ordering for iteration (sorted by name)
|
||||||
|
let mut order: Vec<usize> = (0..n).collect();
|
||||||
|
order.sort_by(|a, b| nodes[*a].cmp(&nodes[*b]));
|
||||||
|
|
||||||
|
// Iterate until convergence or max iterations
|
||||||
|
let max_iter = 50;
|
||||||
|
for _ in 0..max_iter {
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
for &i in &order {
|
||||||
|
let label_counts = collect_neighbor_labels(&nodes[i], adj, rev_adj, &node_idx, &labels);
|
||||||
|
|
||||||
|
if label_counts.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the most frequent label; break ties by smallest label for determinism
|
||||||
|
let max_count = *label_counts.values().max().unwrap();
|
||||||
|
let best_label = label_counts
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, &c)| c == max_count)
|
||||||
|
.map(|(&l, _)| l)
|
||||||
|
.min()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if best_label != labels[i] {
|
||||||
|
labels[i] = best_label;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group nodes by label
|
||||||
|
let mut communities: HashMap<usize, Vec<String>> = HashMap::new();
|
||||||
|
for (i, label) in labels.iter().enumerate() {
|
||||||
|
communities
|
||||||
|
.entry(*label)
|
||||||
|
.or_default()
|
||||||
|
.push(nodes[i].clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<Vec<String>> = communities.into_values().collect();
|
||||||
|
// Sort communities by size (largest first), then by first member name for stability
|
||||||
|
result.sort_by(|a, b| b.len().cmp(&a.len()).then(a[0].cmp(&b[0])));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Circuit Analysis ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct Circuit {
|
||||||
|
members: Vec<String>,
|
||||||
|
boundary_fns: Vec<String>, // called from outside the circuit
|
||||||
|
internal_edges: usize,
|
||||||
|
cohesion: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CircuitAnalysis {
|
||||||
|
circuits: Vec<Circuit>,
|
||||||
|
inter_circuit_edges: Vec<(usize, usize, usize)>, // (from_circuit, to_circuit, weight)
|
||||||
|
modularity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute modularity Q for a given partition.
|
||||||
|
/// Q = (1/m) * Σ_ij [A_ij - (k_out_i * k_in_j / m)] * δ(c_i, c_j)
|
||||||
|
/// where m = total edges, k_out/k_in = degrees, δ = same community
|
||||||
|
fn compute_modularity(
|
||||||
|
nodes: &[String],
|
||||||
|
adj: &HashMap<String, Vec<String>>,
|
||||||
|
partition: &HashMap<String, usize>, // node -> circuit index
|
||||||
|
) -> f64 {
|
||||||
|
let m: usize = adj.values().map(|v| v.len()).sum();
|
||||||
|
if m == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out_deg: HashMap<&str, usize> = HashMap::new();
|
||||||
|
let mut in_deg: HashMap<&str, usize> = HashMap::new();
|
||||||
|
for n in nodes {
|
||||||
|
out_deg.insert(n.as_str(), 0);
|
||||||
|
in_deg.insert(n.as_str(), 0);
|
||||||
|
}
|
||||||
|
for (from, tos) in adj {
|
||||||
|
*out_deg.entry(from.as_str()).or_insert(0) += tos.len();
|
||||||
|
for to in tos {
|
||||||
|
*in_deg.entry(to.as_str()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let m_f = m as f64;
|
||||||
|
let mut q = 0.0;
|
||||||
|
|
||||||
|
for (from, tos) in adj {
|
||||||
|
let c_from = match partition.get(from.as_str()) {
|
||||||
|
Some(&c) => c,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
for to in tos {
|
||||||
|
let c_to = match partition.get(to.as_str()) {
|
||||||
|
Some(&c) => c,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if c_from == c_to {
|
||||||
|
let k_out = *out_deg.get(from.as_str()).unwrap_or(&0) as f64;
|
||||||
|
let k_in = *in_deg.get(to.as_str()).unwrap_or(&0) as f64;
|
||||||
|
q += 1.0 - (k_out * k_in / m_f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q / m_f
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build Circuit structs from detected communities, computing boundary
|
||||||
|
/// functions, internal edges, and cohesion for each circuit.
|
||||||
|
fn build_circuits(
|
||||||
|
communities: &[Vec<String>],
|
||||||
|
cg: &flow::CallGraph,
|
||||||
|
rev_adj: &HashMap<String, Vec<String>>,
|
||||||
|
partition: &HashMap<String, usize>,
|
||||||
|
) -> Vec<Circuit> {
|
||||||
|
let mut circuits: Vec<Circuit> = Vec::new();
|
||||||
|
for (ci, community) in communities.iter().enumerate() {
|
||||||
|
let member_set: HashSet<&str> = community.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
// Boundary functions: called from outside the circuit
|
||||||
|
let mut boundary_fns = Vec::new();
|
||||||
|
for member in community {
|
||||||
|
let is_boundary = rev_adj.get(member.as_str()).map_or(false, |callers| {
|
||||||
|
callers
|
||||||
|
.iter()
|
||||||
|
.any(|c| partition.get(c.as_str()).copied().unwrap_or(ci) != ci)
|
||||||
|
});
|
||||||
|
if is_boundary {
|
||||||
|
boundary_fns.push(member.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boundary_fns.sort();
|
||||||
|
|
||||||
|
// Internal edges: edges where both endpoints are in this circuit
|
||||||
|
let mut internal_edges = 0usize;
|
||||||
|
for member in community {
|
||||||
|
if let Some(callees) = cg.adjacency.get(member.as_str()) {
|
||||||
|
for callee in callees {
|
||||||
|
if member_set.contains(callee.as_str()) {
|
||||||
|
internal_edges += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cohesion: internal edges / possible internal edges
|
||||||
|
let n = community.len();
|
||||||
|
let possible = if n > 1 { n * (n - 1) } else { 1 };
|
||||||
|
let cohesion = internal_edges as f64 / possible as f64;
|
||||||
|
|
||||||
|
circuits.push(Circuit {
|
||||||
|
members: community.clone(),
|
||||||
|
boundary_fns,
|
||||||
|
internal_edges,
|
||||||
|
cohesion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
circuits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute inter-circuit edges: count directed call edges between each
|
||||||
|
/// pair of circuits, returned sorted by weight (heaviest first).
|
||||||
|
fn compute_inter_circuit_edges(
|
||||||
|
cg: &flow::CallGraph,
|
||||||
|
partition: &HashMap<String, usize>,
|
||||||
|
) -> Vec<(usize, usize, usize)> {
|
||||||
|
let mut inter_edges: HashMap<(usize, usize), usize> = HashMap::new();
|
||||||
|
for (caller, callees) in &cg.adjacency {
|
||||||
|
let c_from = match partition.get(caller.as_str()) {
|
||||||
|
Some(&c) => c,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
for callee in callees {
|
||||||
|
let c_to = match partition.get(callee.as_str()) {
|
||||||
|
Some(&c) => c,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if c_from != c_to {
|
||||||
|
*inter_edges.entry((c_from, c_to)).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<(usize, usize, usize)> = inter_edges
|
||||||
|
.into_iter()
|
||||||
|
.map(|((a, b), w)| (a, b, w))
|
||||||
|
.collect();
|
||||||
|
result.sort_by(|a, b| b.2.cmp(&a.2));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run full circuit analysis on the call graph.
|
||||||
|
fn analyze_circuits(cg: &flow::CallGraph, min_size: usize) -> CircuitAnalysis {
|
||||||
|
// Build reverse adjacency
|
||||||
|
let mut rev_adj: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for f in &cg.functions {
|
||||||
|
rev_adj.entry(f.clone()).or_default();
|
||||||
|
}
|
||||||
|
for (caller, callees) in &cg.adjacency {
|
||||||
|
for callee in callees {
|
||||||
|
rev_adj
|
||||||
|
.entry(callee.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(caller.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let communities = label_propagation(&cg.functions, &cg.adjacency, &rev_adj);
|
||||||
|
|
||||||
|
// Build partition map: node -> circuit index
|
||||||
|
let mut partition: HashMap<String, usize> = HashMap::new();
|
||||||
|
for (ci, community) in communities.iter().enumerate() {
|
||||||
|
for node in community {
|
||||||
|
partition.insert(node.clone(), ci);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let circuits = build_circuits(&communities, cg, &rev_adj, &partition);
|
||||||
|
let inter_circuit_edges = compute_inter_circuit_edges(cg, &partition);
|
||||||
|
let modularity = compute_modularity(&cg.functions, &cg.adjacency, &partition);
|
||||||
|
|
||||||
|
// Filter by min_size
|
||||||
|
let filtered_circuits: Vec<Circuit> = circuits
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.members.len() >= min_size)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
CircuitAnalysis {
|
||||||
|
circuits: filtered_circuits,
|
||||||
|
inter_circuit_edges,
|
||||||
|
modularity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_overview(analysis: &CircuitAnalysis, total_functions: usize, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Circuit Overview ──────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Community detection via label propagation on the call graph.",
|
||||||
|
"Each node starts with its own label; iteratively adopts the most frequent",
|
||||||
|
"neighbor label until stable. Produces functional clusters (circuits).",
|
||||||
|
"Modularity Q measures partition quality: Q > 0.3 = well-separated modules,",
|
||||||
|
" Q ≈ 0 = random partition, Q < 0 = worse than random.",
|
||||||
|
"Sparkline shows relative circuit sizes (█ = largest, ▁ = smallest).",
|
||||||
|
]);
|
||||||
|
render::guide_ref("modularity");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizes: Vec<usize> = analysis.circuits.iter().map(|c| c.members.len()).collect();
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" ┌──────────────────────────────────────┐");
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Circuits".cyan(),
|
||||||
|
format!("{}", analysis.circuits.len()).bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Functions covered".cyan(),
|
||||||
|
format!("{}", total_functions).bold()
|
||||||
|
);
|
||||||
|
if !sizes.is_empty() {
|
||||||
|
let max_size = sizes.iter().max().unwrap_or(&0);
|
||||||
|
let min_size = sizes.iter().min().unwrap_or(&0);
|
||||||
|
let avg_size = sizes.iter().sum::<usize>() as f64 / sizes.len() as f64;
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Largest circuit".cyan(),
|
||||||
|
format!("{}", max_size).bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Smallest circuit".cyan(),
|
||||||
|
format!("{}", min_size).bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Average size".cyan(),
|
||||||
|
format!("{:.1}", avg_size).bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Modularity (Q)".cyan(),
|
||||||
|
if analysis.modularity > 0.0 {
|
||||||
|
format!("{:.4}", analysis.modularity)
|
||||||
|
.green()
|
||||||
|
.bold()
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{:.4}", analysis.modularity)
|
||||||
|
.red()
|
||||||
|
.bold()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(" └──────────────────────────────────────┘");
|
||||||
|
|
||||||
|
// Size distribution sparkline
|
||||||
|
if sizes.len() > 1 {
|
||||||
|
let max_s = *sizes.iter().max().unwrap_or(&1) as f64;
|
||||||
|
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
let sparkline: String = sizes
|
||||||
|
.iter()
|
||||||
|
.map(|&s| {
|
||||||
|
let idx = ((s as f64 / max_s) * 7.0).round() as usize;
|
||||||
|
blocks[idx.min(7)]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!(
|
||||||
|
"\n {} {}",
|
||||||
|
"Size distribution:".dimmed(),
|
||||||
|
sparkline.bright_yellow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_circuit_details(analysis: &CircuitAnalysis, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Circuits (by size) ────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Each circuit listed with its members, sorted by size (largest first).",
|
||||||
|
"Cohesion = internal edges / possible internal edges. Higher = tighter cluster.",
|
||||||
|
" 1.0 = every pair of members calls each other; 0.0 = no internal calls.",
|
||||||
|
"◆ (blue) = boundary function: called from outside the circuit = the circuit's",
|
||||||
|
" API surface. These are the functions other circuits depend on.",
|
||||||
|
"· (dim) = internal function: only called from within the same circuit.",
|
||||||
|
"'Connects to' = other circuits this one exchanges calls with (weight = edge count).",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, circuit) in analysis.circuits.iter().enumerate() {
|
||||||
|
println!();
|
||||||
|
let header = format!(
|
||||||
|
" Circuit #{} — {} function{}",
|
||||||
|
i + 1,
|
||||||
|
circuit.members.len(),
|
||||||
|
if circuit.members.len() == 1 { "" } else { "s" },
|
||||||
|
);
|
||||||
|
println!("{}", header.yellow().bold());
|
||||||
|
|
||||||
|
// Stats line
|
||||||
|
println!(
|
||||||
|
" Cohesion: {:.2} Internal edges: {} Boundary fns: {}",
|
||||||
|
circuit.cohesion,
|
||||||
|
circuit.internal_edges,
|
||||||
|
circuit.boundary_fns.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Members, marking boundary functions
|
||||||
|
let boundary_set: HashSet<&str> = circuit.boundary_fns.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
for member in &circuit.members {
|
||||||
|
if boundary_set.contains(member.as_str()) {
|
||||||
|
println!(" {} {}", "◆".bright_blue(), member.bright_white(),);
|
||||||
|
} else {
|
||||||
|
println!(" {} {}", "·".dimmed(), member.dimmed(),);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// External dependencies for this circuit
|
||||||
|
let circuit_deps: Vec<&(usize, usize, usize)> = analysis
|
||||||
|
.inter_circuit_edges
|
||||||
|
.iter()
|
||||||
|
.filter(|(from, to, _)| *from == i || *to == i)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !circuit_deps.is_empty() {
|
||||||
|
let mut dep_summary: Vec<(usize, usize)> = Vec::new(); // (other_circuit, total_weight)
|
||||||
|
let mut seen: HashSet<usize> = HashSet::new();
|
||||||
|
for &&(from, to, _w) in &circuit_deps {
|
||||||
|
let other = if from == i { to } else { from };
|
||||||
|
if seen.insert(other) {
|
||||||
|
// Sum both directions
|
||||||
|
let total: usize = analysis
|
||||||
|
.inter_circuit_edges
|
||||||
|
.iter()
|
||||||
|
.filter(|(a, b, _)| (*a == i && *b == other) || (*a == other && *b == i))
|
||||||
|
.map(|(_, _, w)| w)
|
||||||
|
.sum();
|
||||||
|
dep_summary.push((other, total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dep_summary.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
print!(" {} ", "Connects to:".dimmed());
|
||||||
|
let dep_strs: Vec<String> = dep_summary
|
||||||
|
.iter()
|
||||||
|
.take(5)
|
||||||
|
.map(|(other, w)| format!("#{} ({})", other + 1, w))
|
||||||
|
.collect();
|
||||||
|
println!("{}", dep_strs.join(", ").dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_inter_circuit_flow(analysis: &CircuitAnalysis, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Inter-Circuit Flow ────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Cross-circuit call volume between each pair of circuits.",
|
||||||
|
"⇄ = bidirectional coupling (calls flow both ways between the two circuits).",
|
||||||
|
"→ / ← = unidirectional flow. Number = total call edges between the pair.",
|
||||||
|
"Bar length proportional to total edge count. Color: red = heaviest coupling,",
|
||||||
|
" yellow = moderate, green = light.",
|
||||||
|
"High bidirectional coupling between circuits suggests they may belong together",
|
||||||
|
"or need a cleaner interface boundary.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if analysis.inter_circuit_edges.is_empty() {
|
||||||
|
println!(" {}", "No cross-circuit calls detected.".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate bidirectional: merge (a→b) and (b→a) into one row
|
||||||
|
let mut pairs: HashMap<(usize, usize), (usize, usize)> = HashMap::new(); // (min, max) -> (fwd, bwd)
|
||||||
|
for &(from, to, w) in &analysis.inter_circuit_edges {
|
||||||
|
let key = if from <= to { (from, to) } else { (to, from) };
|
||||||
|
let entry = pairs.entry(key).or_insert((0, 0));
|
||||||
|
if from <= to {
|
||||||
|
entry.0 += w;
|
||||||
|
} else {
|
||||||
|
entry.1 += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pair_list: Vec<((usize, usize), usize, usize)> = pairs
|
||||||
|
.into_iter()
|
||||||
|
.map(|((a, b), (fwd, bwd))| ((a, b), fwd, bwd))
|
||||||
|
.collect();
|
||||||
|
pair_list.sort_by(|a, b| (b.1 + b.2).cmp(&(a.1 + a.2)));
|
||||||
|
|
||||||
|
let term_w = render::terminal_width();
|
||||||
|
let bar_budget = 20.min(term_w.saturating_sub(40));
|
||||||
|
let max_weight = pair_list.iter().map(|(_, f, b)| f + b).max().unwrap_or(1);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
for ((a, b), fwd, bwd) in pair_list.iter().take(15) {
|
||||||
|
let total = fwd + bwd;
|
||||||
|
let bar_len = if max_weight > 0 {
|
||||||
|
(total as f64 / max_weight as f64 * bar_budget as f64).ceil() as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
.max(1);
|
||||||
|
|
||||||
|
let bar = "█".repeat(bar_len);
|
||||||
|
let ratio = total as f64 / max_weight as f64;
|
||||||
|
let colored_bar = if ratio > 0.66 {
|
||||||
|
bar.red().bold().to_string()
|
||||||
|
} else if ratio > 0.33 {
|
||||||
|
bar.yellow().to_string()
|
||||||
|
} else {
|
||||||
|
bar.green().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let direction = if *fwd > 0 && *bwd > 0 {
|
||||||
|
format!("#{} ⇄ #{}", a + 1, b + 1)
|
||||||
|
} else if *fwd > 0 {
|
||||||
|
format!("#{} → #{}", a + 1, b + 1)
|
||||||
|
} else {
|
||||||
|
format!("#{} ← #{}", a + 1, b + 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:<16} {} {}",
|
||||||
|
direction,
|
||||||
|
colored_bar,
|
||||||
|
format!("{} calls", total).dimmed(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair_list.len() > 15 {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
format!("... and {} more connections", pair_list.len() - 15).dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact summary data for embedding in the dashboard.
|
||||||
|
pub struct CircuitSummary {
|
||||||
|
/// Number of circuits.
|
||||||
|
pub circuit_count: usize,
|
||||||
|
/// Size of each circuit (largest first).
|
||||||
|
pub sizes: Vec<usize>,
|
||||||
|
/// Modularity score.
|
||||||
|
pub modularity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute compact circuit summary for dashboard use.
|
||||||
|
pub fn circuit_summary(rs_files: &[PathBuf], project_path: &Path) -> Option<CircuitSummary> {
|
||||||
|
let cg = flow::build_call_graph(rs_files, project_path);
|
||||||
|
if cg.functions.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let analysis = analyze_circuits(&cg, 1);
|
||||||
|
if analysis.circuits.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sizes: Vec<usize> = analysis.circuits.iter().map(|c| c.members.len()).collect();
|
||||||
|
Some(CircuitSummary {
|
||||||
|
circuit_count: analysis.circuits.len(),
|
||||||
|
sizes,
|
||||||
|
modularity: analysis.modularity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public entry point ───────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn render_circuits(rs_files: &[PathBuf], project_path: &Path, min_size: usize, verbose: bool) {
|
||||||
|
let cg = flow::build_call_graph(rs_files, project_path);
|
||||||
|
|
||||||
|
if cg.functions.is_empty() {
|
||||||
|
println!("{}", "No functions found for circuit analysis.".yellow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_functions = cg.functions.len();
|
||||||
|
let analysis = analyze_circuits(&cg, min_size);
|
||||||
|
|
||||||
|
if analysis.circuits.is_empty() {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
"No circuits found (try lowering --min-size).".yellow()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_overview(&analysis, total_functions, verbose);
|
||||||
|
render_circuit_details(&analysis, verbose);
|
||||||
|
render_inter_circuit_flow(&analysis, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CircuitJson {
|
||||||
|
members: Vec<String>,
|
||||||
|
boundary_functions: Vec<String>,
|
||||||
|
internal_edges: usize,
|
||||||
|
cohesion: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct InterCircuitEdgeJson {
|
||||||
|
from_circuit: usize,
|
||||||
|
to_circuit: usize,
|
||||||
|
weight: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CircuitsOutputJson {
|
||||||
|
cstat_version: String,
|
||||||
|
circuits: Vec<CircuitJson>,
|
||||||
|
inter_circuit_edges: Vec<InterCircuitEdgeJson>,
|
||||||
|
modularity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render circuits analysis as JSON.
|
||||||
|
pub fn render_circuits_json(rs_files: &[PathBuf], project_path: &Path, min_size: usize) {
|
||||||
|
let cg = flow::build_call_graph(rs_files, project_path);
|
||||||
|
let analysis = analyze_circuits(&cg, min_size);
|
||||||
|
|
||||||
|
let circuits: Vec<CircuitJson> = analysis
|
||||||
|
.circuits
|
||||||
|
.iter()
|
||||||
|
.map(|c| CircuitJson {
|
||||||
|
members: c.members.clone(),
|
||||||
|
boundary_functions: c.boundary_fns.clone(),
|
||||||
|
internal_edges: c.internal_edges,
|
||||||
|
cohesion: c.cohesion,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let inter_circuit_edges: Vec<InterCircuitEdgeJson> = analysis
|
||||||
|
.inter_circuit_edges
|
||||||
|
.iter()
|
||||||
|
.map(|(from, to, w)| InterCircuitEdgeJson {
|
||||||
|
from_circuit: *from,
|
||||||
|
to_circuit: *to,
|
||||||
|
weight: *w,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let output = CircuitsOutputJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
circuits,
|
||||||
|
inter_circuit_edges,
|
||||||
|
modularity: analysis.modularity,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
108
src/commands.rs
Normal file
108
src/commands.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[path = "dist.rs"]
|
||||||
|
pub(crate) mod dist;
|
||||||
|
#[path = "dump.rs"]
|
||||||
|
pub(crate) mod dump;
|
||||||
|
#[path = "guide.rs"]
|
||||||
|
pub(crate) mod guide;
|
||||||
|
#[path = "map.rs"]
|
||||||
|
pub(crate) mod map;
|
||||||
|
#[path = "summary/mod.rs"]
|
||||||
|
pub(crate) mod summary;
|
||||||
|
|
||||||
|
use crate::{circuits, complexity, datapaths, deps, flow, graph, loc, redundancy};
|
||||||
|
|
||||||
|
pub enum Command {
|
||||||
|
Summary,
|
||||||
|
Loc { top: Option<usize> },
|
||||||
|
Branching,
|
||||||
|
Signature,
|
||||||
|
Span,
|
||||||
|
Deps { top: Option<usize> },
|
||||||
|
Map,
|
||||||
|
Flow,
|
||||||
|
Graph { call_only: bool, dep_only: bool, top: usize },
|
||||||
|
Datapaths { walks: usize, from: Option<String> },
|
||||||
|
Dist { metric: Option<String>, bins: usize },
|
||||||
|
Circuits { min_size: usize },
|
||||||
|
Redundancy,
|
||||||
|
Dump,
|
||||||
|
Guide { topic: Option<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canon(project_path: &Path) -> PathBuf {
|
||||||
|
project_path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| project_path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one of two closures depending on the `json` flag.
|
||||||
|
fn run_json_or_human<F1, F2>(json: bool, json_fn: F1, human_fn: F2)
|
||||||
|
where
|
||||||
|
F1: FnOnce(),
|
||||||
|
F2: FnOnce(),
|
||||||
|
{
|
||||||
|
if json {
|
||||||
|
json_fn();
|
||||||
|
} else {
|
||||||
|
human_fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch(command: Command, rs_files: &[PathBuf], project_path: &Path, json: bool, verbose: bool) {
|
||||||
|
let p = canon(project_path);
|
||||||
|
match command {
|
||||||
|
Command::Summary => run_json_or_human(json,
|
||||||
|
|| summary::render_summary_json(rs_files, &p),
|
||||||
|
|| summary::render_summary(rs_files, &p, verbose),
|
||||||
|
),
|
||||||
|
Command::Loc { top } => run_json_or_human(json,
|
||||||
|
|| loc::render_loc_json(rs_files, &p),
|
||||||
|
|| loc::render_loc(rs_files, &p, top, verbose),
|
||||||
|
),
|
||||||
|
Command::Branching => complexity::run_branching(rs_files, &p, json, verbose),
|
||||||
|
Command::Signature => complexity::run_signature(rs_files, &p, json, verbose),
|
||||||
|
Command::Span => complexity::run_span(rs_files, &p, json, verbose),
|
||||||
|
Command::Deps { top } => run_json_or_human(json,
|
||||||
|
|| deps::render_deps_json(rs_files, &p),
|
||||||
|
|| deps::render_deps(rs_files, &p, top, verbose),
|
||||||
|
),
|
||||||
|
Command::Map => run_json_or_human(json,
|
||||||
|
|| map::render_map_json(rs_files, &p),
|
||||||
|
|| map::render_map(rs_files, &p, verbose),
|
||||||
|
),
|
||||||
|
Command::Flow => run_json_or_human(json,
|
||||||
|
|| flow::render_flow_json(rs_files, &p),
|
||||||
|
|| flow::render_flow(rs_files, &p, verbose),
|
||||||
|
),
|
||||||
|
Command::Graph { call_only, dep_only, top } => run_json_or_human(json,
|
||||||
|
|| graph::render_graph_json(rs_files, &p, call_only, dep_only),
|
||||||
|
|| graph::render_graph(rs_files, &p, call_only, dep_only, top, verbose),
|
||||||
|
),
|
||||||
|
Command::Datapaths { walks, from } => run_json_or_human(json,
|
||||||
|
|| datapaths::render_datapaths_json(rs_files, &p, walks, from.as_deref()),
|
||||||
|
|| datapaths::render_datapaths(rs_files, &p, walks, from.as_deref(), verbose),
|
||||||
|
),
|
||||||
|
Command::Dist { metric, bins } => run_json_or_human(json,
|
||||||
|
|| dist::render_dist_json(rs_files, &p, metric.as_deref(), bins),
|
||||||
|
|| dist::render_dist(rs_files, &p, metric.as_deref(), bins, verbose),
|
||||||
|
),
|
||||||
|
Command::Circuits { min_size } => run_json_or_human(json,
|
||||||
|
|| circuits::render_circuits_json(rs_files, &p, min_size),
|
||||||
|
|| circuits::render_circuits(rs_files, &p, min_size, verbose),
|
||||||
|
),
|
||||||
|
Command::Redundancy => run_json_or_human(json,
|
||||||
|
|| redundancy::render_redundancy_json(rs_files, &p),
|
||||||
|
|| redundancy::render_redundancy(rs_files, &p, verbose),
|
||||||
|
),
|
||||||
|
Command::Dump => {
|
||||||
|
colored::control::set_override(false);
|
||||||
|
dump::render_dump(rs_files, &p);
|
||||||
|
}
|
||||||
|
Command::Guide { topic } => match topic {
|
||||||
|
Some(t) => guide::render_guide_topic(&t, json),
|
||||||
|
None => guide::render_guide_toc(json),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,34 @@
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use syn::visit::Visit;
|
||||||
|
use syn::Expr;
|
||||||
|
|
||||||
use crate::ast_parser::{FunctionInfo, ProjectSymbols};
|
use crate::ast_parser::{FunctionInfo, ProjectSymbols};
|
||||||
use crate::render;
|
use crate::render;
|
||||||
|
|
||||||
|
/// Complexity metrics for a single function.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FunctionComplexity {
|
||||||
|
pub name: String,
|
||||||
|
pub file: String,
|
||||||
|
pub cyclomatic: usize,
|
||||||
|
pub cognitive: usize,
|
||||||
|
pub nesting_depth: usize,
|
||||||
|
pub composite_score: f64,
|
||||||
|
pub line_count: usize,
|
||||||
|
pub param_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate stats across all functions.
|
||||||
|
pub struct ComplexityStats {
|
||||||
|
pub total_functions: usize,
|
||||||
|
pub mean: f64,
|
||||||
|
pub std_dev: f64,
|
||||||
|
pub median: f64,
|
||||||
|
pub max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct BranchingRow {
|
pub struct BranchingRow {
|
||||||
pub function: String,
|
pub function: String,
|
||||||
|
|
@ -61,6 +85,320 @@ struct SpanJson {
|
||||||
functions: Vec<SpanRow>,
|
functions: Vec<SpanRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute complexity metrics for a single function.
|
||||||
|
fn compute_function_complexity(func: &FunctionInfo, source: &str) -> FunctionComplexity {
|
||||||
|
// Cyclomatic = branch_points + 1
|
||||||
|
let cyclomatic = func.branch_points + 1;
|
||||||
|
|
||||||
|
// Cognitive complexity: re-parse the function body for weighted nesting penalties
|
||||||
|
let cognitive = compute_cognitive_complexity(func, source);
|
||||||
|
|
||||||
|
// Generic complexity: type params + trait bounds + where predicates
|
||||||
|
let generic_complexity =
|
||||||
|
func.generic_param_count + func.trait_bound_count + func.where_predicate_count;
|
||||||
|
|
||||||
|
// Parameter complexity: params weighted by return type complexity
|
||||||
|
let param_complexity = func.param_count + func.return_type_complexity;
|
||||||
|
|
||||||
|
// Composite score: weighted combination
|
||||||
|
let composite_score = cyclomatic as f64 * 1.0
|
||||||
|
+ cognitive as f64 * 0.5
|
||||||
|
+ func.nesting_depth as f64 * 0.3
|
||||||
|
+ generic_complexity as f64 * 0.2
|
||||||
|
+ param_complexity as f64 * 0.1;
|
||||||
|
|
||||||
|
let line_count = function_line_count(func);
|
||||||
|
|
||||||
|
FunctionComplexity {
|
||||||
|
name: func.name.clone(),
|
||||||
|
file: func.file.display().to_string(),
|
||||||
|
cyclomatic,
|
||||||
|
cognitive,
|
||||||
|
nesting_depth: func.nesting_depth,
|
||||||
|
composite_score,
|
||||||
|
line_count,
|
||||||
|
param_count: func.param_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute cognitive complexity for a function.
|
||||||
|
/// Increments for each control flow break, with extra penalties for nesting.
|
||||||
|
/// Based on SonarSource's cognitive complexity model.
|
||||||
|
fn compute_cognitive_complexity(func: &FunctionInfo, source: &str) -> usize {
|
||||||
|
// Try to find and re-parse the function to walk its body with cognitive scoring
|
||||||
|
// We use the already-extracted data as a fallback
|
||||||
|
if let Some(cog) = try_cognitive_from_source(func, source) {
|
||||||
|
return cog;
|
||||||
|
}
|
||||||
|
// Fallback: estimate from extracted data
|
||||||
|
// branch_points + nesting_depth penalty
|
||||||
|
func.branch_points + func.nesting_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to compute cognitive complexity by re-parsing the source file and finding the function.
|
||||||
|
fn try_cognitive_from_source(func: &FunctionInfo, source: &str) -> Option<usize> {
|
||||||
|
let ast = syn::parse_file(source).ok()?;
|
||||||
|
let mut finder = CognitiveFinder {
|
||||||
|
target_name: func.name.clone(),
|
||||||
|
result: None,
|
||||||
|
};
|
||||||
|
finder.walk_items(&ast.items);
|
||||||
|
finder.result
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CognitiveFinder {
|
||||||
|
target_name: String,
|
||||||
|
result: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CognitiveFinder {
|
||||||
|
fn walk_items(&mut self, items: &[syn::Item]) {
|
||||||
|
for item in items {
|
||||||
|
match item {
|
||||||
|
syn::Item::Fn(f) => {
|
||||||
|
if f.sig.ident == self.target_name {
|
||||||
|
let mut visitor = CognitiveVisitor::new();
|
||||||
|
visitor.visit_block(&f.block);
|
||||||
|
self.result = Some(visitor.complexity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syn::Item::Impl(imp) => {
|
||||||
|
for impl_item in &imp.items {
|
||||||
|
if let syn::ImplItem::Fn(method) = impl_item {
|
||||||
|
let target_type = crate::ast_parser::type_to_string_pub(&imp.self_ty);
|
||||||
|
let qualified = format!("{}::{}", target_type, method.sig.ident);
|
||||||
|
if qualified == self.target_name {
|
||||||
|
let mut visitor = CognitiveVisitor::new();
|
||||||
|
visitor.visit_block(&method.block);
|
||||||
|
self.result = Some(visitor.complexity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visitor that computes cognitive complexity using SonarSource-style rules:
|
||||||
|
/// - +1 for each control flow break (if, else if, else, match, for, while, loop, break, continue, &&, ||)
|
||||||
|
/// - +1 nesting increment for each level of nesting when encountering a break
|
||||||
|
struct CognitiveVisitor {
|
||||||
|
nesting: usize,
|
||||||
|
complexity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CognitiveVisitor {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
nesting: 0,
|
||||||
|
complexity: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment(&mut self) {
|
||||||
|
// +1 for the construct, +nesting for nesting penalty
|
||||||
|
self.complexity += 1 + self.nesting;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_no_nesting(&mut self) {
|
||||||
|
self.complexity += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_boolean_chains(expr: &Expr) -> usize {
|
||||||
|
match expr {
|
||||||
|
Expr::Binary(bin) => {
|
||||||
|
use syn::BinOp;
|
||||||
|
let is_logical = matches!(bin.op, BinOp::And(_) | BinOp::Or(_));
|
||||||
|
let left = Self::count_boolean_chains(&bin.left);
|
||||||
|
let right = Self::count_boolean_chains(&bin.right);
|
||||||
|
left + right + if is_logical { 1 } else { 0 }
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle `if / else if / else` expressions for cognitive complexity.
|
||||||
|
fn handle_if_expr(&mut self, expr_if: &syn::ExprIf) {
|
||||||
|
self.increment();
|
||||||
|
// Count boolean operators in condition
|
||||||
|
self.complexity += Self::count_boolean_chains(&expr_if.cond);
|
||||||
|
self.nesting += 1;
|
||||||
|
// Visit then branch
|
||||||
|
for stmt in &expr_if.then_branch.stmts {
|
||||||
|
syn::visit::visit_stmt(self, stmt);
|
||||||
|
}
|
||||||
|
self.nesting -= 1;
|
||||||
|
// Visit else branch
|
||||||
|
if let Some((_, else_expr)) = &expr_if.else_branch {
|
||||||
|
self.handle_else_branch(else_expr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the else branch of an if expression.
|
||||||
|
/// Matches original behavior: first else-if is handled inline with no nesting
|
||||||
|
/// penalty, and deeper else branches delegate back to visit_expr.
|
||||||
|
fn handle_else_branch(&mut self, else_expr: &Expr) {
|
||||||
|
match else_expr {
|
||||||
|
Expr::If(inner_if) => {
|
||||||
|
// else if: +1 no nesting penalty (it's a continuation)
|
||||||
|
self.increment_no_nesting();
|
||||||
|
// But the inner if body still gets nesting
|
||||||
|
self.complexity += Self::count_boolean_chains(&inner_if.cond);
|
||||||
|
self.nesting += 1;
|
||||||
|
for stmt in &inner_if.then_branch.stmts {
|
||||||
|
syn::visit::visit_stmt(self, stmt);
|
||||||
|
}
|
||||||
|
self.nesting -= 1;
|
||||||
|
if let Some((_, inner_else)) = &inner_if.else_branch {
|
||||||
|
self.handle_inner_else(inner_else);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Block(block) => {
|
||||||
|
self.increment_no_nesting();
|
||||||
|
self.nesting += 1;
|
||||||
|
for stmt in &block.block.stmts {
|
||||||
|
syn::visit::visit_stmt(self, stmt);
|
||||||
|
}
|
||||||
|
self.nesting -= 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
syn::visit::visit_expr(self, else_expr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle deeper else branches (third level and beyond).
|
||||||
|
/// Delegates Expr::If back to visit_expr for standard increment-with-nesting,
|
||||||
|
/// matching the original behavior.
|
||||||
|
fn handle_inner_else(&mut self, else_expr: &Expr) {
|
||||||
|
match else_expr {
|
||||||
|
Expr::If(_) => {
|
||||||
|
self.visit_expr(else_expr);
|
||||||
|
}
|
||||||
|
Expr::Block(block) => {
|
||||||
|
self.increment_no_nesting();
|
||||||
|
self.nesting += 1;
|
||||||
|
for stmt in &block.block.stmts {
|
||||||
|
syn::visit::visit_stmt(self, stmt);
|
||||||
|
}
|
||||||
|
self.nesting -= 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle `match` expressions for cognitive complexity.
|
||||||
|
fn handle_match_expr(&mut self, m: &syn::ExprMatch) {
|
||||||
|
self.increment();
|
||||||
|
self.nesting += 1;
|
||||||
|
for arm in &m.arms {
|
||||||
|
if arm.guard.is_some() {
|
||||||
|
self.increment_no_nesting();
|
||||||
|
}
|
||||||
|
syn::visit::visit_expr(self, &arm.body);
|
||||||
|
}
|
||||||
|
self.nesting -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle `for`, `while`, and `loop` expressions for cognitive complexity.
|
||||||
|
fn handle_loop_expr(&mut self, expr: &Expr) {
|
||||||
|
self.increment();
|
||||||
|
self.nesting += 1;
|
||||||
|
syn::visit::visit_expr(self, expr);
|
||||||
|
self.nesting -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'ast> Visit<'ast> for CognitiveVisitor {
|
||||||
|
fn visit_expr(&mut self, expr: &'ast Expr) {
|
||||||
|
match expr {
|
||||||
|
Expr::If(expr_if) => {
|
||||||
|
self.handle_if_expr(expr_if);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Expr::Match(m) => {
|
||||||
|
self.handle_match_expr(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Expr::ForLoop(_) | Expr::While(_) | Expr::Loop(_) => {
|
||||||
|
self.handle_loop_expr(expr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Expr::Break(_) | Expr::Continue(_) => {
|
||||||
|
self.increment_no_nesting();
|
||||||
|
}
|
||||||
|
Expr::Closure(_) => {
|
||||||
|
self.nesting += 1;
|
||||||
|
syn::visit::visit_expr(self, expr);
|
||||||
|
self.nesting -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
syn::visit::visit_expr(self, expr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute complexity for all functions in the project.
|
||||||
|
pub fn compute_all(symbols: &ProjectSymbols, project_path: &Path) -> Vec<FunctionComplexity> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for file_syms in &symbols.files {
|
||||||
|
let source = std::fs::read_to_string(&file_syms.path).unwrap_or_default();
|
||||||
|
|
||||||
|
for func in &file_syms.functions {
|
||||||
|
let fc = compute_function_complexity(func, &source);
|
||||||
|
results.push(FunctionComplexity {
|
||||||
|
file: strip_project_path(&fc.file, project_path),
|
||||||
|
..fc
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by composite score descending
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.composite_score
|
||||||
|
.partial_cmp(&a.composite_score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute per-file complexity aggregation.
|
||||||
|
|
||||||
|
/// Compute aggregate stats across all function complexities.
|
||||||
|
pub fn compute_stats(complexities: &[FunctionComplexity]) -> Option<ComplexityStats> {
|
||||||
|
if complexities.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = complexities.len();
|
||||||
|
let scores: Vec<f64> = complexities.iter().map(|c| c.composite_score).collect();
|
||||||
|
let sum: f64 = scores.iter().sum();
|
||||||
|
let mean = sum / total as f64;
|
||||||
|
let variance = scores.iter().map(|s| (s - mean) * (s - mean)).sum::<f64>() / total as f64;
|
||||||
|
let std_dev = variance.sqrt();
|
||||||
|
|
||||||
|
let mut sorted = scores.clone();
|
||||||
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let median = if total % 2 == 0 {
|
||||||
|
(sorted[total / 2 - 1] + sorted[total / 2]) / 2.0
|
||||||
|
} else {
|
||||||
|
sorted[total / 2]
|
||||||
|
};
|
||||||
|
let max = sorted.last().copied().unwrap_or(0.0);
|
||||||
|
|
||||||
|
Some(ComplexityStats {
|
||||||
|
total_functions: total,
|
||||||
|
mean,
|
||||||
|
std_dev,
|
||||||
|
median,
|
||||||
|
max,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn strip_project_path(path: &str, project_path: &Path) -> String {
|
fn strip_project_path(path: &str, project_path: &Path) -> String {
|
||||||
let prefix = project_path.display().to_string();
|
let prefix = project_path.display().to_string();
|
||||||
let stripped = path.strip_prefix(&prefix).unwrap_or(path);
|
let stripped = path.strip_prefix(&prefix).unwrap_or(path);
|
||||||
|
|
|
||||||
160
src/coverage.rs
160
src/coverage.rs
|
|
@ -58,24 +58,14 @@ pub struct ToolStatus {
|
||||||
pub llvm_profdata: Option<String>,
|
pub llvm_profdata: Option<String>,
|
||||||
pub profile_dir: Option<String>,
|
pub profile_dir: Option<String>,
|
||||||
pub export_source: String,
|
pub export_source: String,
|
||||||
pub branch_instrumentation_enabled: Option<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const COVERAGE_VERBOSE_INTRO: &[&str] = &[
|
|
||||||
"Normal mode runs cargo test with Rust source-based coverage flags before reading llvm-cov export data.",
|
|
||||||
"Use coverage --no-run with CSTAT_LLVM_COV_EXPORT_JSON to analyze an existing llvm-cov export.",
|
|
||||||
"Line coverage is dynamic llvm-cov segment data; branch coverage uses branch records when the Rust/LLVM toolchain emits them.",
|
|
||||||
"Production spans exclude tests, benches, test-support helpers, and selected src/bin wrapper main().",
|
|
||||||
"Selected-file static/projected sections are static reachability context, not runtime coverage.",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn render_coverage(
|
pub fn render_coverage(
|
||||||
rs_files: &[PathBuf],
|
rs_files: &[PathBuf],
|
||||||
project_path: &Path,
|
project_path: &Path,
|
||||||
selected_file: Option<&Path>,
|
selected_file: Option<&Path>,
|
||||||
no_run: bool,
|
no_run: bool,
|
||||||
json: bool,
|
json: bool,
|
||||||
verbose: bool,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let symbols = ast_parser::parse_project(rs_files);
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
let graph = flow::build_call_graph(rs_files, project_path);
|
let graph = flow::build_call_graph(rs_files, project_path);
|
||||||
|
|
@ -87,37 +77,24 @@ pub fn render_coverage(
|
||||||
selected_file,
|
selected_file,
|
||||||
);
|
);
|
||||||
|
|
||||||
let export_result = if no_run {
|
let (export_json, mut status) = if no_run {
|
||||||
let export_path = std::env::var("CSTAT_LLVM_COV_EXPORT_JSON").map_err(|_| {
|
let export_path = std::env::var("CSTAT_LLVM_COV_EXPORT_JSON").map_err(|_| {
|
||||||
"CSTAT_LLVM_COV_EXPORT_JSON is required when coverage --no-run is used".to_string()
|
"CSTAT_LLVM_COV_EXPORT_JSON is required when coverage --no-run is used".to_string()
|
||||||
});
|
|
||||||
export_path.and_then(|export_path| {
|
|
||||||
let content = std::fs::read_to_string(&export_path).map_err(|e| {
|
|
||||||
format!("failed to read CSTAT_LLVM_COV_EXPORT_JSON={export_path}: {e}")
|
|
||||||
})?;
|
})?;
|
||||||
Ok((
|
let content = std::fs::read_to_string(&export_path)
|
||||||
|
.map_err(|e| format!("failed to read CSTAT_LLVM_COV_EXPORT_JSON={export_path}: {e}"))?;
|
||||||
|
(
|
||||||
content,
|
content,
|
||||||
ToolStatus {
|
ToolStatus {
|
||||||
ran_tests: false,
|
ran_tests: false,
|
||||||
llvm_cov: None,
|
llvm_cov: None,
|
||||||
llvm_profdata: None,
|
llvm_profdata: None,
|
||||||
profile_dir: None,
|
profile_dir: None,
|
||||||
branch_instrumentation_enabled: None,
|
|
||||||
export_source: export_path,
|
export_source: export_path,
|
||||||
},
|
},
|
||||||
))
|
)
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
collect_coverage_export(project_path, selected_file)
|
collect_coverage_export(project_path, selected_file)?
|
||||||
};
|
|
||||||
let (export_json, mut status) = match export_result {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(message) => {
|
|
||||||
if verbose && !json {
|
|
||||||
render_coverage_verbose_intro();
|
|
||||||
}
|
|
||||||
return Err(message);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut report = analyze_export_json(&export_json, project_path, selected_file, &filter)?;
|
let mut report = analyze_export_json(&export_json, project_path, selected_file, &filter)?;
|
||||||
|
|
@ -154,7 +131,7 @@ pub fn render_coverage(
|
||||||
serde_json::to_string(&report).map_err(|e| e.to_string())?
|
serde_json::to_string(&report).map_err(|e| e.to_string())?
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
render_coverage_report(&report, selected_file.is_some(), &status, verbose);
|
render_coverage_report(&report, selected_file.is_some(), &status);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -322,21 +299,11 @@ pub fn analyze_export_json(
|
||||||
llvm_profdata: None,
|
llvm_profdata: None,
|
||||||
profile_dir: None,
|
profile_dir: None,
|
||||||
export_source: "provided_json".to_string(),
|
export_source: "provided_json".to_string(),
|
||||||
branch_instrumentation_enabled: None,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_coverage_verbose_intro() {
|
fn render_coverage_report(report: &CoverageReport, selected_file: bool, status: &ToolStatus) {
|
||||||
render::verbose_block(COVERAGE_VERBOSE_INTRO);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_coverage_report(
|
|
||||||
report: &CoverageReport,
|
|
||||||
selected_file: bool,
|
|
||||||
status: &ToolStatus,
|
|
||||||
verbose: bool,
|
|
||||||
) {
|
|
||||||
println!(
|
println!(
|
||||||
"\n{} {}",
|
"\n{} {}",
|
||||||
"cstat coverage".bright_cyan().bold(),
|
"cstat coverage".bright_cyan().bold(),
|
||||||
|
|
@ -344,27 +311,19 @@ fn render_coverage_report(
|
||||||
);
|
);
|
||||||
println!(" {} {}", "source".cyan(), status.export_source);
|
println!(" {} {}", "source".cyan(), status.export_source);
|
||||||
|
|
||||||
if verbose {
|
|
||||||
render_coverage_verbose_intro();
|
|
||||||
}
|
|
||||||
|
|
||||||
if selected_file {
|
if selected_file {
|
||||||
|
render::section_header("Static test/benchmark reachability");
|
||||||
if let Some(static_reachability) = &report.static_reachability {
|
if let Some(static_reachability) = &report.static_reachability {
|
||||||
datapaths::render_file_static_reachability_report(static_reachability, verbose);
|
datapaths::render_file_static_reachability_report(static_reachability, false);
|
||||||
}
|
}
|
||||||
|
render::section_header("Projected line reachability");
|
||||||
loc::render_file_projected_line_reachability_report(
|
loc::render_file_projected_line_reachability_report(
|
||||||
&report.projected_line_reachability,
|
&report.projected_line_reachability,
|
||||||
verbose,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render::section_header("Dynamic line coverage");
|
render::section_header("Dynamic line coverage");
|
||||||
if verbose {
|
|
||||||
render::verbose_block(&[
|
|
||||||
"Coverable/executed lines come from llvm-cov segment counts after cstat's production-span filter.",
|
|
||||||
"Missing lines are coverable production lines whose execution count was zero in the export.",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
println!(
|
println!(
|
||||||
" executed lines {} / {} ({:.1}%) missing {}",
|
" executed lines {} / {} ({:.1}%) missing {}",
|
||||||
report
|
report
|
||||||
|
|
@ -393,20 +352,11 @@ fn render_coverage_report(
|
||||||
}
|
}
|
||||||
|
|
||||||
render::section_header("Branch coverage");
|
render::section_header("Branch coverage");
|
||||||
if verbose {
|
|
||||||
render::verbose_block(&[
|
|
||||||
"Branch coverage counts true/false outcomes from llvm-cov branch records.",
|
|
||||||
"When cstat runs coverage itself, Rust branch records require nightly -Z coverage-options=branch.",
|
|
||||||
"When branch data is absent, cstat reports the section as unavailable instead of inventing branch counts.",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if report.branch_coverage.total_branch_outcomes == 0 {
|
if report.branch_coverage.total_branch_outcomes == 0 {
|
||||||
let message = match status.branch_instrumentation_enabled {
|
println!(
|
||||||
Some(false) => "branch data unavailable: this rustc does not expose nightly -Z coverage-options=branch, so cstat collected line/region coverage only",
|
" {}",
|
||||||
Some(true) => "branch data unavailable: llvm-cov export contained no branch records for included production files",
|
"branch data unavailable in llvm-cov export".yellow()
|
||||||
None => "branch data unavailable: llvm-cov export contained no branch records for included production files",
|
);
|
||||||
};
|
|
||||||
println!(" {}", message.yellow());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -505,23 +455,6 @@ fn coverage_profile_dir(target_dir: &Path) -> PathBuf {
|
||||||
target_dir.join("cstat-coverage")
|
target_dir.join("cstat-coverage")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn captured_command_output(stdout: &[u8], stderr: &[u8]) -> String {
|
|
||||||
let stdout = String::from_utf8_lossy(stdout);
|
|
||||||
let stderr = String::from_utf8_lossy(stderr);
|
|
||||||
let mut details = String::new();
|
|
||||||
|
|
||||||
if !stdout.trim().is_empty() {
|
|
||||||
details.push_str("\n\ncargo test stdout:\n");
|
|
||||||
details.push_str(stdout.trim_end());
|
|
||||||
}
|
|
||||||
if !stderr.trim().is_empty() {
|
|
||||||
details.push_str("\n\ncargo test stderr:\n");
|
|
||||||
details.push_str(stderr.trim_end());
|
|
||||||
}
|
|
||||||
|
|
||||||
details
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_coverage_export(
|
fn collect_coverage_export(
|
||||||
project_path: &Path,
|
project_path: &Path,
|
||||||
selected_file: Option<&Path>,
|
selected_file: Option<&Path>,
|
||||||
|
|
@ -543,16 +476,10 @@ fn collect_coverage_export(
|
||||||
}
|
}
|
||||||
|
|
||||||
let cargo_args = cargo_test_args(project_path, selected_file);
|
let cargo_args = cargo_test_args(project_path, selected_file);
|
||||||
let branch_instrumentation_enabled = rustc_supports_branch_coverage();
|
|
||||||
let existing_rustflags = std::env::var("RUSTFLAGS").ok();
|
|
||||||
let rustflags = coverage_rustflags(
|
|
||||||
existing_rustflags.as_deref(),
|
|
||||||
branch_instrumentation_enabled,
|
|
||||||
);
|
|
||||||
let mut cargo = Command::new("cargo");
|
let mut cargo = Command::new("cargo");
|
||||||
cargo.args(&cargo_args);
|
cargo.args(&cargo_args);
|
||||||
let output = cargo
|
let status = cargo
|
||||||
.env("RUSTFLAGS", rustflags)
|
.env("RUSTFLAGS", "-C instrument-coverage -C link-dead-code")
|
||||||
.env(
|
.env(
|
||||||
"LLVM_PROFILE_FILE",
|
"LLVM_PROFILE_FILE",
|
||||||
profile_dir
|
profile_dir
|
||||||
|
|
@ -560,13 +487,11 @@ fn collect_coverage_export(
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
.output()
|
.status()
|
||||||
.map_err(|e| format!("failed to run cargo test for coverage: {e}"))?;
|
.map_err(|e| format!("failed to run cargo test for coverage: {e}"))?;
|
||||||
if !output.status.success() {
|
if !status.success() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"cargo test for coverage failed with status {}{}",
|
"cargo test for coverage failed with status {status}"
|
||||||
output.status,
|
|
||||||
captured_command_output(&output.stdout, &output.stderr),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -628,7 +553,6 @@ fn collect_coverage_export(
|
||||||
llvm_profdata: Some(llvm_profdata.to_string_lossy().to_string()),
|
llvm_profdata: Some(llvm_profdata.to_string_lossy().to_string()),
|
||||||
profile_dir: Some(profile_dir.to_string_lossy().to_string()),
|
profile_dir: Some(profile_dir.to_string_lossy().to_string()),
|
||||||
export_source: format_command("cargo", &cargo_args),
|
export_source: format_command("cargo", &cargo_args),
|
||||||
branch_instrumentation_enabled: Some(branch_instrumentation_enabled),
|
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -771,30 +695,6 @@ fn format_command(program: &str, args: &[String]) -> String {
|
||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn coverage_rustflags(existing_rustflags: Option<&str>, enable_branch_coverage: bool) -> String {
|
|
||||||
let mut flags = existing_rustflags
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|flags| !flags.is_empty())
|
|
||||||
.map(ToString::to_string)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !flags.is_empty() {
|
|
||||||
flags.push(' ');
|
|
||||||
}
|
|
||||||
flags.push_str("-C instrument-coverage -C link-dead-code");
|
|
||||||
if enable_branch_coverage {
|
|
||||||
flags.push_str(" -Z coverage-options=branch");
|
|
||||||
}
|
|
||||||
flags
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rustc_supports_branch_coverage() -> bool {
|
|
||||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| std::ffi::OsString::from("rustc"));
|
|
||||||
let Ok(output) = Command::new(rustc).arg("-Z").arg("help").output() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("coverage-options")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_tool(env_key: &str, binary: &str) -> Result<PathBuf, String> {
|
fn find_tool(env_key: &str, binary: &str) -> Result<PathBuf, String> {
|
||||||
if let Some(value) = std::env::var_os(env_key) {
|
if let Some(value) = std::env::var_os(env_key) {
|
||||||
let path = PathBuf::from(value);
|
let path = PathBuf::from(value);
|
||||||
|
|
@ -820,7 +720,7 @@ fn find_tool(env_key: &str, binary: &str) -> Result<PathBuf, String> {
|
||||||
return Ok(candidate);
|
return Ok(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err("LLVM coverage tools unavailable; install rustup component llvm-tools-preview or set LLVM_COV and LLVM_PROFDATA, or use coverage --no-run with CSTAT_LLVM_COV_EXPORT_JSON".to_string())
|
Err("LLVM coverage tools unavailable; install rustup component llvm-tools-preview or set LLVM_COV and LLVM_PROFDATA".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rustc_sysroot() -> Result<String, String> {
|
fn rustc_sysroot() -> Result<String, String> {
|
||||||
|
|
@ -1324,22 +1224,6 @@ path = "src/bin/mvp_chat.rs"
|
||||||
fs::remove_dir_all(root).unwrap();
|
fs::remove_dir_all(root).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coverage_rustflags_preserve_existing_flags_and_enable_branch_option() {
|
|
||||||
assert_eq!(
|
|
||||||
coverage_rustflags(None, false),
|
|
||||||
"-C instrument-coverage -C link-dead-code"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
coverage_rustflags(Some("-C debuginfo=2"), false),
|
|
||||||
"-C debuginfo=2 -C instrument-coverage -C link-dead-code"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
coverage_rustflags(Some("-C debuginfo=2"), true),
|
|
||||||
"-C debuginfo=2 -C instrument-coverage -C link-dead-code -Z coverage-options=branch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_llvm_export_line_and_branch_sites() {
|
fn parses_llvm_export_line_and_branch_sites() {
|
||||||
let root = temp_project("parser");
|
let root = temp_project("parser");
|
||||||
|
|
|
||||||
583
src/datapaths.rs
583
src/datapaths.rs
|
|
@ -598,6 +598,589 @@ fn truncate(value: &str, max: usize) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run many random walks through the call graph and accumulate visit counts.
|
||||||
|
fn simulate_walks(
|
||||||
|
graph: &CallGraph,
|
||||||
|
entry_points: &[String],
|
||||||
|
num_walks: usize,
|
||||||
|
) -> HashMap<String, usize> {
|
||||||
|
let mut visit_counts: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut rng_state: u64 = 0xdeadbeef_u64;
|
||||||
|
|
||||||
|
for (ep_idx, entry) in entry_points.iter().enumerate() {
|
||||||
|
let walks_per_entry = num_walks / entry_points.len().max(1);
|
||||||
|
for walk_i in 0..walks_per_entry {
|
||||||
|
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(
|
||||||
|
(ep_idx as u64)
|
||||||
|
.wrapping_mul(1442695040888963407)
|
||||||
|
.wrapping_add(walk_i as u64),
|
||||||
|
);
|
||||||
|
let mut current = entry.clone();
|
||||||
|
let mut steps = 0;
|
||||||
|
let max_steps = 200;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
*visit_counts.entry(current.clone()).or_insert(0) += 1;
|
||||||
|
steps += 1;
|
||||||
|
if steps >= max_steps {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let callees = match graph.adjacency.get(¤t) {
|
||||||
|
Some(c) if !c.is_empty() => c,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple LCG random choice
|
||||||
|
rng_state = rng_state
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
let idx = (rng_state >> 33) as usize % callees.len();
|
||||||
|
current = callees[idx].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit_counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run walks from a single entry point.
|
||||||
|
fn simulate_walks_from(graph: &CallGraph, entry: &str, num_walks: usize) -> HashMap<String, usize> {
|
||||||
|
let mut visit_counts: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut rng_state: u64 = 0xcafebabe_u64;
|
||||||
|
|
||||||
|
for walk_i in 0..num_walks {
|
||||||
|
rng_state = rng_state
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(walk_i as u64);
|
||||||
|
let mut current = entry.to_string();
|
||||||
|
let mut steps = 0;
|
||||||
|
let max_steps = 200;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
*visit_counts.entry(current.clone()).or_insert(0) += 1;
|
||||||
|
steps += 1;
|
||||||
|
if steps >= max_steps {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let callees = match graph.adjacency.get(¤t) {
|
||||||
|
Some(c) if !c.is_empty() => c,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
rng_state = rng_state
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
let idx = (rng_state >> 33) as usize % callees.len();
|
||||||
|
current = callees[idx].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit_counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the hot path: greedily follow highest-visit-count callees from entry.
|
||||||
|
fn find_hot_path(
|
||||||
|
graph: &CallGraph,
|
||||||
|
entry: &str,
|
||||||
|
visit_counts: &HashMap<String, usize>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut path = vec![entry.to_string()];
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
visited.insert(entry.to_string());
|
||||||
|
let mut current = entry.to_string();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let callees = match graph.adjacency.get(¤t) {
|
||||||
|
Some(c) if !c.is_empty() => c,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pick callee with highest visit count
|
||||||
|
let best = callees
|
||||||
|
.iter()
|
||||||
|
.filter(|c| !visited.contains(*c))
|
||||||
|
.max_by_key(|c| visit_counts.get(*c).copied().unwrap_or(0));
|
||||||
|
|
||||||
|
match best {
|
||||||
|
Some(next) => {
|
||||||
|
visited.insert(next.clone());
|
||||||
|
path.push(next.clone());
|
||||||
|
current = next.clone();
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find all functions reachable from any entry point via BFS.
|
||||||
|
fn reachable_from_entries(graph: &CallGraph) -> HashSet<String> {
|
||||||
|
let mut reachable = HashSet::new();
|
||||||
|
let mut queue: Vec<String> = graph.entry_points.clone();
|
||||||
|
|
||||||
|
while let Some(node) = queue.pop() {
|
||||||
|
if !reachable.insert(node.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(callees) = graph.adjacency.get(&node) {
|
||||||
|
for callee in callees {
|
||||||
|
if !reachable.contains(callee) {
|
||||||
|
queue.push(callee.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the heatmap ranked list.
|
||||||
|
fn render_heatmap(visit_counts: &HashMap<String, usize>, total_functions: usize, verbose: bool) {
|
||||||
|
let tw = render::terminal_width();
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Activation Heatmap ────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Random walk activation heatmap: N random walks (default 10,000) start from",
|
||||||
|
"entry points and follow random callees at each step.",
|
||||||
|
"Visit count ≈ runtime activation likelihood — how often each function would",
|
||||||
|
"be hit if entry points were called uniformly at random.",
|
||||||
|
"Color: red/bold = hot (>70% of max), yellow = warm (>40%), blue = cool (>15%),",
|
||||||
|
" dim = cold (<15%). Percentage = share of all visits across all walks.",
|
||||||
|
"Coverage = fraction of total functions reached by at least one walk.",
|
||||||
|
]);
|
||||||
|
render::guide_ref("flow");
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_visits: usize = visit_counts.values().sum();
|
||||||
|
if total_visits == 0 {
|
||||||
|
println!(" {}", "No visits recorded.".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ranked: Vec<(&String, &usize)> = visit_counts.iter().collect();
|
||||||
|
ranked.sort_by(|a, b| b.1.cmp(a.1));
|
||||||
|
|
||||||
|
let max_count = *ranked[0].1;
|
||||||
|
let name_width = 40.min(tw.saturating_sub(30));
|
||||||
|
let bar_max = 20.min(tw.saturating_sub(name_width + 20));
|
||||||
|
|
||||||
|
println!();
|
||||||
|
for (name, count) in ranked.iter().take(30) {
|
||||||
|
let pct = **count as f64 / total_visits as f64 * 100.0;
|
||||||
|
let bar_len = (**count as f64 / max_count as f64 * bar_max as f64) as usize;
|
||||||
|
|
||||||
|
let heat = **count as f64 / max_count as f64;
|
||||||
|
let bar = "█".repeat(bar_len);
|
||||||
|
let colored_bar = if heat > 0.7 {
|
||||||
|
bar.red().bold().to_string()
|
||||||
|
} else if heat > 0.4 {
|
||||||
|
bar.yellow().to_string()
|
||||||
|
} else if heat > 0.15 {
|
||||||
|
bar.bright_blue().to_string()
|
||||||
|
} else {
|
||||||
|
bar.dimmed().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let display_name = if name.len() > name_width {
|
||||||
|
format!("…{}", &name[name.len() - name_width + 1..])
|
||||||
|
} else {
|
||||||
|
format!("{:<width$}", name, width = name_width)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} {:>5.1}% {}",
|
||||||
|
display_name.dimmed(),
|
||||||
|
colored_bar,
|
||||||
|
pct,
|
||||||
|
format!("({})", count).dimmed(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ranked.len() > 30 {
|
||||||
|
println!(
|
||||||
|
" {} more functions with lower activation.",
|
||||||
|
format!("… +{}", ranked.len() - 30).dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n {} {} visits across {} functions",
|
||||||
|
"Total:".bold(),
|
||||||
|
total_visits,
|
||||||
|
visit_counts.len(),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} {:.1}%",
|
||||||
|
"Coverage:".bold(),
|
||||||
|
visit_counts.len() as f64 / total_functions.max(1) as f64 * 100.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render per-entry-point breakdown.
|
||||||
|
fn render_per_entry(graph: &CallGraph, num_walks: usize, verbose: bool) {
|
||||||
|
if graph.entry_points.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Per-Entry-Point Breakdown ─────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Same random walks as above, but partitioned by starting entry point.",
|
||||||
|
"Shows how many unique functions each entry point can reach and their",
|
||||||
|
"top-5 most visited callees. Useful for understanding which entry point",
|
||||||
|
"exercises which parts of the codebase.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in &graph.entry_points {
|
||||||
|
let counts = simulate_walks_from(graph, entry, num_walks / graph.entry_points.len().max(1));
|
||||||
|
let total: usize = counts.values().sum();
|
||||||
|
let mut ranked: Vec<(&String, &usize)> = counts.iter().collect();
|
||||||
|
ranked.sort_by(|a, b| b.1.cmp(a.1));
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n {} (reached {} functions, {} total visits)",
|
||||||
|
entry.bold().bright_white(),
|
||||||
|
counts.len(),
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
|
||||||
|
let top5: Vec<_> = ranked.iter().take(5).collect();
|
||||||
|
for (name, count) in &top5 {
|
||||||
|
let pct = **count as f64 / total.max(1) as f64 * 100.0;
|
||||||
|
println!(" {:<40} {:>5.1}%", name.dimmed(), pct);
|
||||||
|
}
|
||||||
|
if ranked.len() > 5 {
|
||||||
|
println!(" {}", format!("… +{} more", ranked.len() - 5).dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render hot path highlighting.
|
||||||
|
fn render_hot_paths(graph: &CallGraph, visit_counts: &HashMap<String, usize>, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Hot Paths ─────────────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Hot paths: greedily follow the highest-visit-count callee from each entry point.",
|
||||||
|
"This traces the most likely execution path through the call graph.",
|
||||||
|
"Arrow chain: entry → most visited callee → its most visited callee → ...",
|
||||||
|
"Color: white = entry, red/bold = hot (>50% of max visits), yellow = warm (>20%),",
|
||||||
|
" plain = moderate, dim = cold. (depth N) = total chain length.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if graph.entry_points.is_empty() {
|
||||||
|
println!(" {}", "No entry points found.".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in &graph.entry_points {
|
||||||
|
let path = find_hot_path(graph, entry, visit_counts);
|
||||||
|
if path.len() <= 1 {
|
||||||
|
println!(
|
||||||
|
"\n {} → {}",
|
||||||
|
entry.bold().bright_white(),
|
||||||
|
"(no callees)".dimmed()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
print!(" ");
|
||||||
|
for (i, node) in path.iter().enumerate() {
|
||||||
|
let count = visit_counts.get(node).copied().unwrap_or(0);
|
||||||
|
let colored_name = if i == 0 {
|
||||||
|
node.bold().bright_white().to_string()
|
||||||
|
} else if count > 0 {
|
||||||
|
let max_count = visit_counts.values().max().copied().unwrap_or(1);
|
||||||
|
let heat = count as f64 / max_count as f64;
|
||||||
|
if heat > 0.5 {
|
||||||
|
node.red().bold().to_string()
|
||||||
|
} else if heat > 0.2 {
|
||||||
|
node.yellow().to_string()
|
||||||
|
} else {
|
||||||
|
node.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.dimmed().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if i > 0 {
|
||||||
|
print!(" → ");
|
||||||
|
}
|
||||||
|
print!("{}", colored_name);
|
||||||
|
}
|
||||||
|
println!(" (depth {})", path.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render cold function detection.
|
||||||
|
fn render_cold_functions(graph: &CallGraph, visit_counts: &HashMap<String, usize>, verbose: bool) {
|
||||||
|
let reachable = reachable_from_entries(graph);
|
||||||
|
|
||||||
|
let cold: Vec<&String> = graph
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !visit_counts.contains_key(*f) && !reachable.contains(*f))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Cold Functions (Unreachable) ──────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Functions never reached from any entry point via call graph traversal.",
|
||||||
|
"These are dead code candidates — they exist in the source but no entry point",
|
||||||
|
"(main, #[test]) can reach them through any chain of calls.",
|
||||||
|
"Note: static analysis may miss dynamic dispatch or macro-generated calls.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cold.is_empty() {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
"All functions are reachable from entry points.".green()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"\n {} functions never reached from any entry point (dead code candidates):\n",
|
||||||
|
format!("{}", cold.len()).red().bold(),
|
||||||
|
);
|
||||||
|
for (i, name) in cold.iter().enumerate() {
|
||||||
|
if i >= 20 {
|
||||||
|
println!(" {}", format!("… +{} more", cold.len() - 20).dimmed());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
println!(" {} {}", "•".red(), name.dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact static entrypoint-reachability data for embedding in the dashboard.
|
||||||
|
pub struct EntrypointReachabilitySummary {
|
||||||
|
/// Percentage of functions statically reachable from entry points.
|
||||||
|
pub reachable_pct: f64,
|
||||||
|
/// Total function count.
|
||||||
|
pub total_functions: usize,
|
||||||
|
/// Number of reachable functions.
|
||||||
|
pub reachable_count: usize,
|
||||||
|
/// Number of cold (unreachable) functions.
|
||||||
|
pub cold_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute compact static entrypoint reachability for dashboard use.
|
||||||
|
pub fn entrypoint_reachability_summary(
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
) -> Option<EntrypointReachabilitySummary> {
|
||||||
|
let graph = build_call_graph(rs_files, project_path);
|
||||||
|
if graph.functions.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let total_functions = graph.functions.len();
|
||||||
|
let reachable = reachable_from_entries(&graph);
|
||||||
|
let reachable_count = reachable.len();
|
||||||
|
let cold_count = total_functions.saturating_sub(reachable_count);
|
||||||
|
let reachable_pct = reachable_count as f64 / total_functions.max(1) as f64 * 100.0;
|
||||||
|
|
||||||
|
Some(EntrypointReachabilitySummary {
|
||||||
|
reachable_pct,
|
||||||
|
total_functions,
|
||||||
|
reachable_count,
|
||||||
|
cold_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public entry point: render the datapaths subcommand.
|
||||||
|
pub fn render_datapaths(
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
num_walks: usize,
|
||||||
|
from_fn: Option<&str>,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
let graph = build_call_graph(rs_files, project_path);
|
||||||
|
|
||||||
|
if graph.functions.is_empty() {
|
||||||
|
println!("{}", "No functions found to analyze.".yellow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_functions = graph.functions.len();
|
||||||
|
|
||||||
|
// Determine entry points
|
||||||
|
let entries: Vec<String> = if let Some(f) = from_fn {
|
||||||
|
// Check if the function exists (exact or suffix match)
|
||||||
|
let matched = graph
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.find(|name| *name == f || name.ends_with(&format!("::{}", f)));
|
||||||
|
match matched {
|
||||||
|
Some(m) => vec![m.clone()],
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Function '{}' not found in call graph.",
|
||||||
|
"Error:".red().bold(),
|
||||||
|
f
|
||||||
|
);
|
||||||
|
eprintln!("Available functions:");
|
||||||
|
for func in graph.functions.iter().take(20) {
|
||||||
|
eprintln!(" {}", func);
|
||||||
|
}
|
||||||
|
if graph.functions.len() > 20 {
|
||||||
|
eprintln!(" … +{} more", graph.functions.len() - 20);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if graph.entry_points.is_empty() {
|
||||||
|
println!(
|
||||||
|
"{} No entry points (main or #[test]) found. Use --from to specify a start function.",
|
||||||
|
"Warning:".yellow().bold()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
graph.entry_points.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"══ Execution Flow Heatmap ═══════════════════════════════"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} random walks from {} entry point(s)",
|
||||||
|
format!("{}", num_walks).bold(),
|
||||||
|
entries.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run global simulation
|
||||||
|
let visit_counts = simulate_walks(&graph, &entries, num_walks);
|
||||||
|
|
||||||
|
// Render sections
|
||||||
|
render_heatmap(&visit_counts, total_functions, verbose);
|
||||||
|
render_per_entry(&graph, num_walks, verbose);
|
||||||
|
render_hot_paths(&graph, &visit_counts, verbose);
|
||||||
|
render_cold_functions(&graph, &visit_counts, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct VisitCountJson {
|
||||||
|
function: String,
|
||||||
|
visits: usize,
|
||||||
|
percentage: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DatapathsJson {
|
||||||
|
cstat_version: String,
|
||||||
|
visit_counts: Vec<VisitCountJson>,
|
||||||
|
coverage_percentage: f64,
|
||||||
|
hot_paths: Vec<Vec<String>>,
|
||||||
|
cold_functions: Vec<String>,
|
||||||
|
total_functions: usize,
|
||||||
|
total_visits: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render datapaths analysis as JSON.
|
||||||
|
pub fn render_datapaths_json(
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
num_walks: usize,
|
||||||
|
from_fn: Option<&str>,
|
||||||
|
) {
|
||||||
|
let graph = build_call_graph(rs_files, project_path);
|
||||||
|
let total_functions = graph.functions.len();
|
||||||
|
|
||||||
|
let entries: Vec<String> = if let Some(f) = from_fn {
|
||||||
|
let matched = graph
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.find(|name| *name == f || name.ends_with(&format!("::{}", f)));
|
||||||
|
match matched {
|
||||||
|
Some(m) => vec![m.clone()],
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
graph.entry_points.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let visit_counts = simulate_walks(&graph, &entries, num_walks);
|
||||||
|
let total_visits: usize = visit_counts.values().sum();
|
||||||
|
|
||||||
|
let mut ranked: Vec<(&String, &usize)> = visit_counts.iter().collect();
|
||||||
|
ranked.sort_by(|a, b| b.1.cmp(a.1));
|
||||||
|
|
||||||
|
let visit_counts_json: Vec<VisitCountJson> = ranked
|
||||||
|
.iter()
|
||||||
|
.map(|(name, count)| VisitCountJson {
|
||||||
|
function: (*name).clone(),
|
||||||
|
visits: **count,
|
||||||
|
percentage: if total_visits > 0 {
|
||||||
|
**count as f64 / total_visits as f64 * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let coverage = visit_counts.len() as f64 / total_functions.max(1) as f64 * 100.0;
|
||||||
|
|
||||||
|
let hot_paths: Vec<Vec<String>> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| find_hot_path(&graph, entry, &visit_counts))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let reachable = reachable_from_entries(&graph);
|
||||||
|
let cold_functions: Vec<String> = graph
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !visit_counts.contains_key(*f) && !reachable.contains(*f))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let output = DatapathsJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
visit_counts: visit_counts_json,
|
||||||
|
coverage_percentage: coverage,
|
||||||
|
hot_paths,
|
||||||
|
cold_functions,
|
||||||
|
total_functions,
|
||||||
|
total_visits,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
153
src/deps/mod.rs
153
src/deps/mod.rs
|
|
@ -113,57 +113,19 @@ fn collect_use_paths(tree: &syn::UseTree, prefix: &mut Vec<String>, results: &mu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Match a use/mod path to a known project module name from a source-module context.
|
/// Match a use path to a known project module name.
|
||||||
fn resolve_to_module(
|
fn resolve_to_module(use_path: &str, known_modules: &HashSet<String>) -> Option<String> {
|
||||||
use_path: &str,
|
let path = if let Some(rest) = use_path.strip_prefix("crate::") {
|
||||||
known_modules: &HashSet<String>,
|
rest
|
||||||
current_module: &str,
|
} else if let Some(rest) = use_path.strip_prefix("self::") {
|
||||||
) -> Option<String> {
|
rest
|
||||||
if use_path == "crate" {
|
} else if let Some(rest) = use_path.strip_prefix("super::") {
|
||||||
return None;
|
rest
|
||||||
}
|
} else {
|
||||||
if use_path == "self" {
|
use_path
|
||||||
return known_modules
|
};
|
||||||
.contains(current_module)
|
|
||||||
.then(|| current_module.to_string());
|
|
||||||
}
|
|
||||||
if use_path == "super" {
|
|
||||||
return parent_module(current_module)
|
|
||||||
.filter(|parent| known_modules.contains(*parent))
|
|
||||||
.map(ToString::to_string);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rest) = use_path.strip_prefix("crate::") {
|
let parts: Vec<&str> = path.split("::").collect();
|
||||||
return resolve_absolute_module_path(rest, known_modules);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rest) = use_path.strip_prefix("self::") {
|
|
||||||
return resolve_relative_module_path(current_module, rest, known_modules).or_else(|| {
|
|
||||||
known_modules
|
|
||||||
.contains(current_module)
|
|
||||||
.then(|| current_module.to_string())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut base = current_module;
|
|
||||||
let mut rest = use_path;
|
|
||||||
let mut saw_super = false;
|
|
||||||
while let Some(stripped) = rest.strip_prefix("super::") {
|
|
||||||
saw_super = true;
|
|
||||||
base = parent_module(base).unwrap_or("");
|
|
||||||
rest = stripped;
|
|
||||||
}
|
|
||||||
if saw_super {
|
|
||||||
return resolve_relative_module_path(base, rest, known_modules)
|
|
||||||
.or_else(|| known_modules.contains(base).then(|| base.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve_relative_module_path(current_module, use_path, known_modules)
|
|
||||||
.or_else(|| resolve_absolute_module_path(use_path, known_modules))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_absolute_module_path(path: &str, known_modules: &HashSet<String>) -> Option<String> {
|
|
||||||
let parts: Vec<&str> = path.split("::").filter(|part| !part.is_empty()).collect();
|
|
||||||
for len in (1..=parts.len()).rev() {
|
for len in (1..=parts.len()).rev() {
|
||||||
let candidate = parts[..len].join("::");
|
let candidate = parts[..len].join("::");
|
||||||
if known_modules.contains(&candidate) {
|
if known_modules.contains(&candidate) {
|
||||||
|
|
@ -173,26 +135,6 @@ fn resolve_absolute_module_path(path: &str, known_modules: &HashSet<String>) ->
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_relative_module_path(
|
|
||||||
base: &str,
|
|
||||||
path: &str,
|
|
||||||
known_modules: &HashSet<String>,
|
|
||||||
) -> Option<String> {
|
|
||||||
if base.is_empty() {
|
|
||||||
return resolve_absolute_module_path(path, known_modules);
|
|
||||||
}
|
|
||||||
let absolute = if path.is_empty() {
|
|
||||||
base.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{base}::{path}")
|
|
||||||
};
|
|
||||||
resolve_absolute_module_path(&absolute, known_modules)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parent_module(module: &str) -> Option<&str> {
|
|
||||||
module.rsplit_once("::").map(|(parent, _)| parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute coupling pairs from the edge set, sorted by strength descending.
|
/// Compute coupling pairs from the edge set, sorted by strength descending.
|
||||||
fn build_coupling_pairs(edges: &HashSet<DepEdge>) -> Vec<(String, String, usize)> {
|
fn build_coupling_pairs(edges: &HashSet<DepEdge>) -> Vec<(String, String, usize)> {
|
||||||
let mut coupling_pairs: Vec<(String, String, usize)> = Vec::new();
|
let mut coupling_pairs: Vec<(String, String, usize)> = Vec::new();
|
||||||
|
|
@ -267,7 +209,7 @@ pub fn analyze_deps(rs_files: &[PathBuf], project_path: &Path) -> DepAnalysis {
|
||||||
let mut seen_targets: HashSet<String> = HashSet::new();
|
let mut seen_targets: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
for target in &use_targets {
|
for target in &use_targets {
|
||||||
if let Some(to_module) = resolve_to_module(target, &known_modules, &from_module) {
|
if let Some(to_module) = resolve_to_module(target, &known_modules) {
|
||||||
if to_module != from_module && seen_targets.insert(to_module.clone()) {
|
if to_module != from_module && seen_targets.insert(to_module.clone()) {
|
||||||
let edge = DepEdge {
|
let edge = DepEdge {
|
||||||
from: from_module.clone(),
|
from: from_module.clone(),
|
||||||
|
|
@ -318,7 +260,7 @@ pub fn analyze_file_deps(
|
||||||
let mut internal = Vec::new();
|
let mut internal = Vec::new();
|
||||||
let mut external = Vec::new();
|
let mut external = Vec::new();
|
||||||
for raw in use_targets {
|
for raw in use_targets {
|
||||||
if let Some(resolved_module) = resolve_to_module(&raw, &known_modules, &module) {
|
if let Some(resolved_module) = resolve_to_module(&raw, &known_modules) {
|
||||||
internal.push(FileDependency {
|
internal.push(FileDependency {
|
||||||
raw,
|
raw,
|
||||||
resolved_module: Some(resolved_module),
|
resolved_module: Some(resolved_module),
|
||||||
|
|
@ -408,15 +350,7 @@ fn relative_path_string(project_path: &Path, path: &Path) -> String {
|
||||||
.replace('\\', "/")
|
.replace('\\', "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn function_graph_name(file_module: &str, function_name: &str) -> String {
|
/// Compute call cohesion: fraction of functions calling another function in the same module.
|
||||||
if file_module == "main" || file_module == "lib" {
|
|
||||||
function_name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{file_module}::{function_name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute call cohesion: fraction of source functions calling another function in the same module.
|
|
||||||
fn compute_call_cohesion(
|
fn compute_call_cohesion(
|
||||||
module_functions: &HashMap<String, Vec<String>>,
|
module_functions: &HashMap<String, Vec<String>>,
|
||||||
call_graph: &flow::CallGraph,
|
call_graph: &flow::CallGraph,
|
||||||
|
|
@ -447,7 +381,7 @@ fn compute_call_cohesion(
|
||||||
call_cohesion_map
|
call_cohesion_map
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute type cohesion: fraction of functions sharing signature type tokens within a module.
|
/// Compute type cohesion: fraction of functions sharing parameter/return types within a module.
|
||||||
fn compute_type_cohesion(
|
fn compute_type_cohesion(
|
||||||
module_func_infos: &HashMap<String, Vec<&ast_parser::FunctionInfo>>,
|
module_func_infos: &HashMap<String, Vec<&ast_parser::FunctionInfo>>,
|
||||||
) -> HashMap<String, f64> {
|
) -> HashMap<String, f64> {
|
||||||
|
|
@ -460,7 +394,7 @@ fn compute_type_cohesion(
|
||||||
|
|
||||||
let func_types: Vec<HashSet<String>> = funcs
|
let func_types: Vec<HashSet<String>> = funcs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| extract_signature_types(&f.file, f.line_start, f.line_end))
|
.map(|f| extract_param_types(&f.file, f.line_start, f.line_end))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut sharing_count = 0usize;
|
let mut sharing_count = 0usize;
|
||||||
|
|
@ -481,20 +415,30 @@ fn compute_type_cohesion(
|
||||||
type_cohesion_map
|
type_cohesion_map
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute per-source-module cohesion metrics.
|
/// Compute per-module cohesion metrics.
|
||||||
pub fn compute_cohesion(rs_files: &[PathBuf], project_path: &Path) -> Vec<ModuleCohesion> {
|
pub fn compute_cohesion(rs_files: &[PathBuf], project_path: &Path) -> Vec<ModuleCohesion> {
|
||||||
let call_graph = flow::build_call_graph(rs_files, project_path);
|
let call_graph = flow::build_call_graph(rs_files, project_path);
|
||||||
let symbols = ast_parser::parse_project(rs_files);
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
|
|
||||||
let mut module_functions: HashMap<String, Vec<String>> = HashMap::new();
|
let mut module_functions: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for func_name in &call_graph.functions {
|
||||||
|
let module = if let Some(pos) = func_name.rfind("::") {
|
||||||
|
func_name[..pos].to_string()
|
||||||
|
} else {
|
||||||
|
"main".to_string()
|
||||||
|
};
|
||||||
|
module_functions
|
||||||
|
.entry(module)
|
||||||
|
.or_default()
|
||||||
|
.push(func_name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let call_cohesion_map = compute_call_cohesion(&module_functions, &call_graph);
|
||||||
|
|
||||||
let mut module_func_infos: HashMap<String, Vec<&ast_parser::FunctionInfo>> = HashMap::new();
|
let mut module_func_infos: HashMap<String, Vec<&ast_parser::FunctionInfo>> = HashMap::new();
|
||||||
for file_syms in &symbols.files {
|
for file_syms in &symbols.files {
|
||||||
let module_name = crate_render::module_name_from_path(&file_syms.path, project_path);
|
let module_name = crate_render::module_name_from_path(&file_syms.path, project_path);
|
||||||
for func in &file_syms.functions {
|
for func in &file_syms.functions {
|
||||||
module_functions
|
|
||||||
.entry(module_name.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(function_graph_name(&module_name, &func.name));
|
|
||||||
module_func_infos
|
module_func_infos
|
||||||
.entry(module_name.clone())
|
.entry(module_name.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
|
|
@ -502,21 +446,31 @@ pub fn compute_cohesion(rs_files: &[PathBuf], project_path: &Path) -> Vec<Module
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let call_cohesion_map = compute_call_cohesion(&module_functions, &call_graph);
|
|
||||||
let type_cohesion_map = compute_type_cohesion(&module_func_infos);
|
let type_cohesion_map = compute_type_cohesion(&module_func_infos);
|
||||||
|
|
||||||
let mut results: Vec<ModuleCohesion> = module_func_infos
|
let all_modules: HashSet<&str> = module_functions
|
||||||
|
.keys()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.chain(module_func_infos.keys().map(|s| s.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut results: Vec<ModuleCohesion> = all_modules
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(module, funcs)| {
|
.map(|&module| {
|
||||||
let call_c = call_cohesion_map.get(module).copied().unwrap_or(0.0);
|
let call_c = call_cohesion_map.get(module).copied().unwrap_or(0.0);
|
||||||
let type_c = type_cohesion_map.get(module).copied().unwrap_or(0.0);
|
let type_c = type_cohesion_map.get(module).copied().unwrap_or(0.0);
|
||||||
|
let func_count = module_functions
|
||||||
|
.get(module)
|
||||||
|
.map(|v| v.len())
|
||||||
|
.or_else(|| module_func_infos.get(module).map(|v| v.len()))
|
||||||
|
.unwrap_or(0);
|
||||||
let combined = (0.6 * call_c + 0.4 * type_c).clamp(0.0, 1.0);
|
let combined = (0.6 * call_c + 0.4 * type_c).clamp(0.0, 1.0);
|
||||||
ModuleCohesion {
|
ModuleCohesion {
|
||||||
module: module.clone(),
|
module: module.to_string(),
|
||||||
call_cohesion: call_c,
|
call_cohesion: call_c,
|
||||||
type_cohesion: type_c,
|
type_cohesion: type_c,
|
||||||
combined,
|
combined,
|
||||||
function_count: funcs.len(),
|
function_count: func_count,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -525,13 +479,12 @@ pub fn compute_cohesion(rs_files: &[PathBuf], project_path: &Path) -> Vec<Module
|
||||||
a.combined
|
a.combined
|
||||||
.partial_cmp(&b.combined)
|
.partial_cmp(&b.combined)
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
.then_with(|| a.module.cmp(&b.module))
|
|
||||||
});
|
});
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract parameter/return type-like tokens from a function signature in a source file.
|
/// Extract parameter/return type names from a function in a source file.
|
||||||
fn extract_signature_types(file: &Path, line_start: usize, line_end: usize) -> HashSet<String> {
|
fn extract_param_types(file: &Path, line_start: usize, line_end: usize) -> HashSet<String> {
|
||||||
let content = match std::fs::read_to_string(file) {
|
let content = match std::fs::read_to_string(file) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return HashSet::new(),
|
Err(_) => return HashSet::new(),
|
||||||
|
|
@ -539,14 +492,10 @@ fn extract_signature_types(file: &Path, line_start: usize, line_end: usize) -> H
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
let start = line_start.saturating_sub(1);
|
let start = line_start.saturating_sub(1);
|
||||||
let end = line_end.min(lines.len());
|
let end = line_end.min(lines.len());
|
||||||
let span_text: String = lines[start..end].join("\n");
|
let sig_text: String = lines[start..end].join("\n");
|
||||||
let signature_text = span_text
|
|
||||||
.split_once('{')
|
|
||||||
.map(|(signature, _)| signature)
|
|
||||||
.unwrap_or(&span_text);
|
|
||||||
|
|
||||||
let mut types = HashSet::new();
|
let mut types = HashSet::new();
|
||||||
for token in signature_text.split(|c: char| !c.is_alphanumeric() && c != '_') {
|
for token in sig_text.split(|c: char| !c.is_alphanumeric() && c != '_') {
|
||||||
let t = token.trim();
|
let t = token.trim();
|
||||||
if t.is_empty() || t.len() < 2 {
|
if t.is_empty() || t.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ struct CouplingPairJson {
|
||||||
module_b: String,
|
module_b: String,
|
||||||
strength: usize,
|
strength: usize,
|
||||||
direction: String,
|
direction: String,
|
||||||
edges: Vec<DepEdgeJson>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -117,7 +116,7 @@ fn render_matrix_rows(
|
||||||
fn render_matrix_legend(display_modules: &[&String]) {
|
fn render_matrix_legend(display_modules: &[&String]) {
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
" {} no dependency {} one-way pair {} bidirectional pair",
|
" {} no dependency {} unidirectional {} bidirectional",
|
||||||
"0".dimmed(),
|
"0".dimmed(),
|
||||||
"1".yellow(),
|
"1".yellow(),
|
||||||
"2".red().bold(),
|
"2".red().bold(),
|
||||||
|
|
@ -139,14 +138,13 @@ fn render_dependency_matrix(analysis: &DepAnalysis, top_n: usize, term_w: usize,
|
||||||
|
|
||||||
if verbose {
|
if verbose {
|
||||||
render::verbose_block(&[
|
render::verbose_block(&[
|
||||||
"Matrix showing coupling strength between module pairs.",
|
"Matrix showing coupling intensity between module pairs.",
|
||||||
"Cells are pair strength, not edge direction: 0 = no dependency,",
|
"Cell [row, col] = 0 (dim, no dependency), 1 (yellow, unidirectional),",
|
||||||
" 1 = exactly one direction exists, 2 = both modules import each other.",
|
" 2 (red/bold, bidirectional — both modules import each other).",
|
||||||
"Diagonal is marked '·' (a module cannot depend on itself).",
|
"Diagonal is marked '·' (a module cannot depend on itself).",
|
||||||
"Modules are sorted by total connectivity (in + out degree).",
|
"Modules are sorted by total connectivity (in + out degree).",
|
||||||
"Row/column indices map to module names shown in the legend below the matrix.",
|
"Row/column indices map to module names shown in the legend below the matrix.",
|
||||||
"Use Coupling Ranking or JSON edges for source -> target direction.",
|
"Built from 'use' and 'mod' statements found by parsing each file's AST.",
|
||||||
"Built from top-level source 'use' and external 'mod' statements parsed from each file.",
|
|
||||||
]);
|
]);
|
||||||
render::guide_ref("modularity");
|
render::guide_ref("modularity");
|
||||||
}
|
}
|
||||||
|
|
@ -206,9 +204,9 @@ fn render_coupling_ranking(analysis: &DepAnalysis, top_n: usize, verbose: bool)
|
||||||
if verbose {
|
if verbose {
|
||||||
render::verbose_block(&[
|
render::verbose_block(&[
|
||||||
"Module pairs ranked by coupling strength.",
|
"Module pairs ranked by coupling strength.",
|
||||||
"One-way rows show actual source module -> target module direction.",
|
|
||||||
"◆ bidirectional (red) = both modules import each other. This is a coupling risk —",
|
"◆ bidirectional (red) = both modules import each other. This is a coupling risk —",
|
||||||
" changes in either module may ripple to the other.",
|
" changes in either module may ripple to the other.",
|
||||||
|
"→ unidirectional (yellow) = one-way dependency, lower risk.",
|
||||||
"Pairs are sorted by coupling strength descending, then alphabetically.",
|
"Pairs are sorted by coupling strength descending, then alphabetically.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -219,52 +217,37 @@ fn render_coupling_ranking(analysis: &DepAnalysis, top_n: usize, verbose: bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
let display_count = analysis.coupling_pairs.len().min(top_n);
|
let display_count = analysis.coupling_pairs.len().min(top_n);
|
||||||
let edge_set: HashSet<(&str, &str)> = analysis
|
|
||||||
.edges
|
|
||||||
.iter()
|
|
||||||
.map(|edge| (edge.from.as_str(), edge.to.as_str()))
|
|
||||||
.collect();
|
|
||||||
println!(
|
println!(
|
||||||
"\n {:<30} {:<30} {}",
|
"\n {:<30} {:<30} {}",
|
||||||
"From".bold().underline(),
|
"Module A".bold().underline(),
|
||||||
"To".bold().underline(),
|
"Module B".bold().underline(),
|
||||||
"Coupling".bold().underline(),
|
"Coupling".bold().underline(),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (a, b, strength) in analysis.coupling_pairs.iter().take(display_count) {
|
for (a, b, strength) in analysis.coupling_pairs.iter().take(display_count) {
|
||||||
let a_to_b = edge_set.contains(&(a.as_str(), b.as_str()));
|
let coupling_label = if *strength >= 2 {
|
||||||
let b_to_a = edge_set.contains(&(b.as_str(), a.as_str()));
|
"◆ bidirectional".red().bold().to_string()
|
||||||
let (from, to, coupling_label) = if a_to_b && b_to_a {
|
|
||||||
(
|
|
||||||
a.as_str(),
|
|
||||||
b.as_str(),
|
|
||||||
"◆ bidirectional".red().bold().to_string(),
|
|
||||||
)
|
|
||||||
} else if a_to_b {
|
|
||||||
(a.as_str(), b.as_str(), "→ one-way".yellow().to_string())
|
|
||||||
} else {
|
} else {
|
||||||
(b.as_str(), a.as_str(), "→ one-way".yellow().to_string())
|
"→ unidirectional".yellow().to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let from_display = if from.len() > 28 {
|
let a_display = if a.len() > 28 {
|
||||||
format!("..{}", &from[from.len() - 26..])
|
format!("..{}", &a[a.len() - 26..])
|
||||||
} else {
|
} else {
|
||||||
from.to_string()
|
a.clone()
|
||||||
};
|
};
|
||||||
let to_display = if to.len() > 28 {
|
let b_display = if b.len() > 28 {
|
||||||
format!("..{}", &to[to.len() - 26..])
|
format!("..{}", &b[b.len() - 26..])
|
||||||
} else {
|
} else {
|
||||||
to.to_string()
|
b.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {:<30} {:<30} {}",
|
" {:<30} {:<30} {}",
|
||||||
from_display.dimmed(),
|
a_display.dimmed(),
|
||||||
to_display.dimmed(),
|
b_display.dimmed(),
|
||||||
coupling_label
|
coupling_label
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = strength;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if analysis.coupling_pairs.len() > display_count {
|
if analysis.coupling_pairs.len() > display_count {
|
||||||
|
|
@ -420,11 +403,11 @@ fn render_cohesion_section(cohesion: &[ModuleCohesion], top_n: usize, verbose: b
|
||||||
|
|
||||||
if verbose {
|
if verbose {
|
||||||
render::verbose_block(&[
|
render::verbose_block(&[
|
||||||
"Per-source-module cohesion scores measuring internal focus/coherence.",
|
"Per-module cohesion scores measuring internal focus/coherence.",
|
||||||
"Call cohesion: fraction of parsed source functions calling another function in the same source module.",
|
"Call cohesion: fraction of functions calling another function in the same module.",
|
||||||
"Type cohesion: fraction of parsed source functions sharing signature type tokens within the module.",
|
"Type cohesion: fraction of functions sharing parameter/return types within the module.",
|
||||||
"Combined: weighted average (60% call + 40% type), 0.0–1.0 where higher = more cohesive.",
|
"Combined: weighted average (60% call + 40% type), 0.0–1.0 where higher = more cohesive.",
|
||||||
"Low cohesion suggests the source module bundles unrelated functionality.",
|
"Low cohesion suggests the module bundles unrelated functionality.",
|
||||||
"Modules sorted by combined cohesion ascending (least cohesive first).",
|
"Modules sorted by combined cohesion ascending (least cohesive first).",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -522,29 +505,10 @@ pub fn render_deps_json(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let edge_set: HashSet<(&str, &str)> = analysis
|
|
||||||
.edges
|
|
||||||
.iter()
|
|
||||||
.map(|edge| (edge.from.as_str(), edge.to.as_str()))
|
|
||||||
.collect();
|
|
||||||
let coupling_pairs: Vec<CouplingPairJson> = analysis
|
let coupling_pairs: Vec<CouplingPairJson> = analysis
|
||||||
.coupling_pairs
|
.coupling_pairs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(a, b, s)| {
|
.map(|(a, b, s)| CouplingPairJson {
|
||||||
let mut pair_edges = Vec::new();
|
|
||||||
if edge_set.contains(&(a.as_str(), b.as_str())) {
|
|
||||||
pair_edges.push(DepEdgeJson {
|
|
||||||
from: a.clone(),
|
|
||||||
to: b.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if edge_set.contains(&(b.as_str(), a.as_str())) {
|
|
||||||
pair_edges.push(DepEdgeJson {
|
|
||||||
from: b.clone(),
|
|
||||||
to: a.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
CouplingPairJson {
|
|
||||||
module_a: a.clone(),
|
module_a: a.clone(),
|
||||||
module_b: b.clone(),
|
module_b: b.clone(),
|
||||||
strength: *s,
|
strength: *s,
|
||||||
|
|
@ -553,8 +517,6 @@ pub fn render_deps_json(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
} else {
|
} else {
|
||||||
"unidirectional".to_string()
|
"unidirectional".to_string()
|
||||||
},
|
},
|
||||||
edges: pair_edges,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
|
||||||
699
src/diagnostics/mod.rs
Normal file
699
src/diagnostics/mod.rs
Normal file
|
|
@ -0,0 +1,699 @@
|
||||||
|
mod scoring;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::ast_parser;
|
||||||
|
use crate::complexity;
|
||||||
|
use crate::deps;
|
||||||
|
use crate::flow;
|
||||||
|
use crate::loc;
|
||||||
|
use crate::redundancy;
|
||||||
|
|
||||||
|
// ── Diagnostic types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Diagnostic {
|
||||||
|
pub severity: Severity,
|
||||||
|
pub category: Category,
|
||||||
|
pub location: Location,
|
||||||
|
pub metric: f64,
|
||||||
|
pub threshold: f64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context: Option<serde_json::Value>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Severity {
|
||||||
|
Warn,
|
||||||
|
Alert,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Category {
|
||||||
|
BloatedFile,
|
||||||
|
BloatedFunction,
|
||||||
|
HighComplexity,
|
||||||
|
HighCognitive,
|
||||||
|
DeepNesting,
|
||||||
|
HighCoupling,
|
||||||
|
GodModule,
|
||||||
|
DeadCode,
|
||||||
|
LowCohesion,
|
||||||
|
RedundantCode,
|
||||||
|
TooManyParams,
|
||||||
|
CyclicDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Location {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub file: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub function: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub module: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub line_start: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub line_end: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HealthScores {
|
||||||
|
pub modularity: f64,
|
||||||
|
pub complexity: f64,
|
||||||
|
pub maintainability: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DiagnosticReport {
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
pub scores: HealthScores,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thresholds ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) struct Threshold {
|
||||||
|
pub(crate) warn: f64,
|
||||||
|
pub(crate) alert: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const FILE_LOC: Threshold = Threshold {
|
||||||
|
warn: 500.0,
|
||||||
|
alert: 800.0,
|
||||||
|
};
|
||||||
|
const FUNC_LOC: Threshold = Threshold {
|
||||||
|
warn: 50.0,
|
||||||
|
alert: 100.0,
|
||||||
|
};
|
||||||
|
pub(crate) const CYCLOMATIC: Threshold = Threshold {
|
||||||
|
warn: 10.0,
|
||||||
|
alert: 20.0,
|
||||||
|
};
|
||||||
|
pub(crate) const COGNITIVE: Threshold = Threshold {
|
||||||
|
warn: 15.0,
|
||||||
|
alert: 30.0,
|
||||||
|
};
|
||||||
|
const NESTING: Threshold = Threshold {
|
||||||
|
warn: 4.0,
|
||||||
|
alert: 6.0,
|
||||||
|
};
|
||||||
|
const PARAMS: Threshold = Threshold {
|
||||||
|
warn: 5.0,
|
||||||
|
alert: 8.0,
|
||||||
|
};
|
||||||
|
pub(crate) const REDUNDANCY: Threshold = Threshold {
|
||||||
|
warn: 0.7,
|
||||||
|
alert: 0.9,
|
||||||
|
};
|
||||||
|
pub(crate) const COHESION: Threshold = Threshold {
|
||||||
|
warn: 0.3,
|
||||||
|
alert: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main analysis entry point (orchestrator) ─────────────────────
|
||||||
|
|
||||||
|
pub fn run_diagnostics(rs_files: &[PathBuf], project_path: &Path) -> DiagnosticReport {
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
|
||||||
|
// --- LoC analysis ---
|
||||||
|
let (file_stats, _agg_loc) = loc::analyze_files(rs_files);
|
||||||
|
|
||||||
|
// --- Complexity analysis ---
|
||||||
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
|
let func_complexities = complexity::compute_all(&symbols, project_path);
|
||||||
|
|
||||||
|
// Build per-file function index for cross-referencing
|
||||||
|
let mut funcs_by_file: HashMap<String, Vec<&complexity::FunctionComplexity>> = HashMap::new();
|
||||||
|
for fc in &func_complexities {
|
||||||
|
funcs_by_file.entry(fc.file.clone()).or_default().push(fc);
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnose_bloated_files(&file_stats, &funcs_by_file, project_path, &mut diagnostics);
|
||||||
|
diagnose_function_metrics(&func_complexities, &mut diagnostics);
|
||||||
|
|
||||||
|
// --- Dependency analysis ---
|
||||||
|
let dep_analysis = deps::analyze_deps(rs_files, project_path);
|
||||||
|
|
||||||
|
diagnose_coupling(&dep_analysis, &mut diagnostics);
|
||||||
|
diagnose_god_modules(&dep_analysis, &mut diagnostics);
|
||||||
|
|
||||||
|
// --- Dead code (cold functions) ---
|
||||||
|
let call_graph = flow::build_call_graph(rs_files, project_path);
|
||||||
|
let reachable = compute_reachable(&call_graph);
|
||||||
|
diagnose_dead_code(&call_graph, &reachable, &mut diagnostics);
|
||||||
|
|
||||||
|
diagnose_cyclic_deps(&dep_analysis, &mut diagnostics);
|
||||||
|
|
||||||
|
// --- Redundancy ---
|
||||||
|
let redundant_pairs = redundancy::detect_redundancy(rs_files, project_path);
|
||||||
|
diagnose_redundancy(&redundant_pairs, &mut diagnostics);
|
||||||
|
|
||||||
|
// --- Low cohesion ---
|
||||||
|
let cohesion_data = deps::compute_cohesion(rs_files, project_path);
|
||||||
|
diagnose_low_cohesion(&cohesion_data, &mut diagnostics);
|
||||||
|
|
||||||
|
// --- Sort diagnostics: alerts first, then by metric magnitude descending ---
|
||||||
|
diagnostics.sort_by(|a, b| {
|
||||||
|
a.severity.cmp(&b.severity).then_with(|| {
|
||||||
|
b.metric
|
||||||
|
.partial_cmp(&a.metric)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Compute health scores ---
|
||||||
|
let scores = scoring::compute_health_scores(
|
||||||
|
&file_stats,
|
||||||
|
&func_complexities,
|
||||||
|
&dep_analysis,
|
||||||
|
&redundant_pairs,
|
||||||
|
&call_graph,
|
||||||
|
&reachable,
|
||||||
|
rs_files,
|
||||||
|
project_path,
|
||||||
|
);
|
||||||
|
|
||||||
|
DiagnosticReport {
|
||||||
|
diagnostics,
|
||||||
|
scores,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extracted diagnostic helpers ────────────────────────────────
|
||||||
|
|
||||||
|
fn diagnose_bloated_files(
|
||||||
|
file_stats: &[loc::FileLocStats],
|
||||||
|
funcs_by_file: &HashMap<String, Vec<&complexity::FunctionComplexity>>,
|
||||||
|
project_path: &Path,
|
||||||
|
diagnostics: &mut Vec<Diagnostic>,
|
||||||
|
) {
|
||||||
|
for stat in file_stats {
|
||||||
|
let loc = stat.code_lines as f64;
|
||||||
|
let rel_path = stat
|
||||||
|
.path
|
||||||
|
.strip_prefix(project_path)
|
||||||
|
.unwrap_or(&stat.path)
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (sev, thresh) = if loc > FILE_LOC.alert {
|
||||||
|
(Some(Severity::Alert), FILE_LOC.alert)
|
||||||
|
} else if loc > FILE_LOC.warn {
|
||||||
|
(Some(Severity::Warn), FILE_LOC.warn)
|
||||||
|
} else {
|
||||||
|
(None, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(severity) = sev {
|
||||||
|
let file_funcs = funcs_by_file.get(rel_path.as_str());
|
||||||
|
let func_count = file_funcs.map(|v| v.len()).unwrap_or(0);
|
||||||
|
let top_complex: Vec<serde_json::Value> = file_funcs
|
||||||
|
.map(|fns| {
|
||||||
|
let mut sorted: Vec<_> = fns.iter().collect();
|
||||||
|
sorted.sort_by(|a, b| {
|
||||||
|
b.composite_score
|
||||||
|
.partial_cmp(&a.composite_score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
sorted
|
||||||
|
.into_iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|f| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": f.name,
|
||||||
|
"composite_score": f.composite_score,
|
||||||
|
"lines": f.line_count
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"function_count": func_count,
|
||||||
|
"top_complex_functions": top_complex,
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"{} is {} lines with {} functions",
|
||||||
|
rel_path, stat.code_lines, func_count
|
||||||
|
);
|
||||||
|
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity,
|
||||||
|
category: Category::BloatedFile,
|
||||||
|
location: Location {
|
||||||
|
file: Some(rel_path.clone()),
|
||||||
|
function: None,
|
||||||
|
module: None,
|
||||||
|
line_start: None,
|
||||||
|
line_end: None,
|
||||||
|
},
|
||||||
|
metric: loc,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_function_metrics(
|
||||||
|
func_complexities: &[complexity::FunctionComplexity],
|
||||||
|
diagnostics: &mut Vec<Diagnostic>,
|
||||||
|
) {
|
||||||
|
for fc in func_complexities {
|
||||||
|
diagnose_fn_complexity(fc, diagnostics);
|
||||||
|
diagnose_fn_structure(fc, diagnostics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_fn_complexity(fc: &complexity::FunctionComplexity, diagnostics: &mut Vec<Diagnostic>) {
|
||||||
|
let line_count = fc.line_count as f64;
|
||||||
|
let cyc = fc.cyclomatic as f64;
|
||||||
|
let cog = fc.cognitive as f64;
|
||||||
|
|
||||||
|
// Bloated function
|
||||||
|
if let Some((sev, thresh)) = check_threshold(line_count, &FUNC_LOC) {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"cyclomatic": fc.cyclomatic,
|
||||||
|
"cognitive": fc.cognitive,
|
||||||
|
"nesting_depth": fc.nesting_depth,
|
||||||
|
"composite_score": fc.composite_score,
|
||||||
|
});
|
||||||
|
let msg = format!(
|
||||||
|
"{} is {} lines (cyclomatic {}, cognitive {})",
|
||||||
|
fc.name, fc.line_count, fc.cyclomatic, fc.cognitive
|
||||||
|
);
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: sev,
|
||||||
|
category: Category::BloatedFunction,
|
||||||
|
location: fn_location(&fc.name, &fc.file),
|
||||||
|
metric: line_count,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cyclomatic complexity
|
||||||
|
if let Some((sev, thresh)) = check_threshold(cyc, &CYCLOMATIC) {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"cognitive": fc.cognitive,
|
||||||
|
"nesting_depth": fc.nesting_depth,
|
||||||
|
"line_count": fc.line_count,
|
||||||
|
});
|
||||||
|
let msg = format!(
|
||||||
|
"{} has cyclomatic complexity {} ({} lines, nesting depth {})",
|
||||||
|
fc.name, fc.cyclomatic, fc.line_count, fc.nesting_depth
|
||||||
|
);
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: sev,
|
||||||
|
category: Category::HighComplexity,
|
||||||
|
location: fn_location(&fc.name, &fc.file),
|
||||||
|
metric: cyc,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cognitive complexity
|
||||||
|
if let Some((sev, thresh)) = check_threshold(cog, &COGNITIVE) {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"cyclomatic": fc.cyclomatic,
|
||||||
|
"nesting_depth": fc.nesting_depth,
|
||||||
|
"line_count": fc.line_count,
|
||||||
|
});
|
||||||
|
let msg = format!(
|
||||||
|
"{} has cognitive complexity {} (cyclomatic {}, nesting depth {})",
|
||||||
|
fc.name, fc.cognitive, fc.cyclomatic, fc.nesting_depth
|
||||||
|
);
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: sev,
|
||||||
|
category: Category::HighCognitive,
|
||||||
|
location: fn_location(&fc.name, &fc.file),
|
||||||
|
metric: cog,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_fn_structure(fc: &complexity::FunctionComplexity, diagnostics: &mut Vec<Diagnostic>) {
|
||||||
|
let nest = fc.nesting_depth as f64;
|
||||||
|
let params = fc.param_count as f64;
|
||||||
|
|
||||||
|
// Deep nesting
|
||||||
|
if let Some((sev, thresh)) = check_threshold(nest, &NESTING) {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"cyclomatic": fc.cyclomatic,
|
||||||
|
"cognitive": fc.cognitive,
|
||||||
|
"line_count": fc.line_count,
|
||||||
|
});
|
||||||
|
let msg = format!(
|
||||||
|
"{} has nesting depth {} (cyclomatic {}, {} lines)",
|
||||||
|
fc.name, fc.nesting_depth, fc.cyclomatic, fc.line_count
|
||||||
|
);
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: sev,
|
||||||
|
category: Category::DeepNesting,
|
||||||
|
location: fn_location(&fc.name, &fc.file),
|
||||||
|
metric: nest,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too many params
|
||||||
|
if let Some((sev, thresh)) = check_threshold(params, &PARAMS) {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"line_count": fc.line_count,
|
||||||
|
"cyclomatic": fc.cyclomatic,
|
||||||
|
});
|
||||||
|
let msg = format!(
|
||||||
|
"{} takes {} parameters ({} lines, cyclomatic {})",
|
||||||
|
fc.name, fc.param_count, fc.line_count, fc.cyclomatic
|
||||||
|
);
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: sev,
|
||||||
|
category: Category::TooManyParams,
|
||||||
|
location: fn_location(&fc.name, &fc.file),
|
||||||
|
metric: params,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_coupling(dep_analysis: &deps::DepAnalysis, diagnostics: &mut Vec<Diagnostic>) {
|
||||||
|
for (a, b, strength) in &dep_analysis.coupling_pairs {
|
||||||
|
if *strength >= 2 {
|
||||||
|
let fan_in_a = dep_analysis.in_degree.get(a).copied().unwrap_or(0);
|
||||||
|
let fan_out_a = dep_analysis.out_degree.get(a).copied().unwrap_or(0);
|
||||||
|
let fan_in_b = dep_analysis.in_degree.get(b).copied().unwrap_or(0);
|
||||||
|
let fan_out_b = dep_analysis.out_degree.get(b).copied().unwrap_or(0);
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"module_a": { "fan_in": fan_in_a, "fan_out": fan_out_a },
|
||||||
|
"module_b": { "fan_in": fan_in_b, "fan_out": fan_out_b },
|
||||||
|
});
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: Severity::Warn,
|
||||||
|
category: Category::HighCoupling,
|
||||||
|
location: Location {
|
||||||
|
file: None,
|
||||||
|
function: None,
|
||||||
|
module: Some(format!("{} <-> {}", a, b)),
|
||||||
|
line_start: None,
|
||||||
|
line_end: None,
|
||||||
|
},
|
||||||
|
metric: *strength as f64,
|
||||||
|
threshold: 2.0,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: format!(
|
||||||
|
"{} and {} have bidirectional dependency (strength {})",
|
||||||
|
a, b, strength
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_god_modules(dep_analysis: &deps::DepAnalysis, diagnostics: &mut Vec<Diagnostic>) {
|
||||||
|
let fan_totals: Vec<f64> = dep_analysis
|
||||||
|
.modules
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let in_d = dep_analysis.in_degree.get(m).copied().unwrap_or(0);
|
||||||
|
let out_d = dep_analysis.out_degree.get(m).copied().unwrap_or(0);
|
||||||
|
(in_d + out_d) as f64
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if fan_totals.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mean_fan = fan_totals.iter().sum::<f64>() / fan_totals.len() as f64;
|
||||||
|
let var_fan = fan_totals
|
||||||
|
.iter()
|
||||||
|
.map(|v| (v - mean_fan).powi(2))
|
||||||
|
.sum::<f64>()
|
||||||
|
/ fan_totals.len() as f64;
|
||||||
|
let std_fan = var_fan.sqrt();
|
||||||
|
let warn_thresh = mean_fan + std_fan;
|
||||||
|
let alert_thresh = mean_fan + 2.0 * std_fan;
|
||||||
|
|
||||||
|
for (i, m) in dep_analysis.modules.iter().enumerate() {
|
||||||
|
let total = fan_totals[i];
|
||||||
|
let in_d = dep_analysis.in_degree.get(m).copied().unwrap_or(0);
|
||||||
|
let out_d = dep_analysis.out_degree.get(m).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
if total > alert_thresh && alert_thresh > 0.0 {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"fan_in": in_d,
|
||||||
|
"fan_out": out_d,
|
||||||
|
"mean": mean_fan,
|
||||||
|
"stddev": std_fan,
|
||||||
|
});
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: Severity::Alert,
|
||||||
|
category: Category::GodModule,
|
||||||
|
location: mod_location(m),
|
||||||
|
metric: total,
|
||||||
|
threshold: alert_thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: format!(
|
||||||
|
"{} has fan-in+fan-out of {} (mean+2σ={:.1}, fan-in {}, fan-out {})",
|
||||||
|
m, total as usize, alert_thresh, in_d, out_d
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if total > warn_thresh && warn_thresh > 0.0 {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"fan_in": in_d,
|
||||||
|
"fan_out": out_d,
|
||||||
|
"mean": mean_fan,
|
||||||
|
"stddev": std_fan,
|
||||||
|
});
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: Severity::Warn,
|
||||||
|
category: Category::GodModule,
|
||||||
|
location: mod_location(m),
|
||||||
|
metric: total,
|
||||||
|
threshold: warn_thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: format!(
|
||||||
|
"{} has fan-in+fan-out of {} (mean+1σ={:.1}, fan-in {}, fan-out {})",
|
||||||
|
m, total as usize, warn_thresh, in_d, out_d
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_reachable(call_graph: &flow::CallGraph) -> HashSet<String> {
|
||||||
|
let mut reachable = HashSet::new();
|
||||||
|
let mut queue: Vec<String> = call_graph.entry_points.clone();
|
||||||
|
while let Some(node) = queue.pop() {
|
||||||
|
if !reachable.insert(node.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(callees) = call_graph.adjacency.get(&node) {
|
||||||
|
for callee in callees {
|
||||||
|
if !reachable.contains(callee) {
|
||||||
|
queue.push(callee.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_dead_code(
|
||||||
|
call_graph: &flow::CallGraph,
|
||||||
|
reachable: &HashSet<String>,
|
||||||
|
diagnostics: &mut Vec<Diagnostic>,
|
||||||
|
) {
|
||||||
|
for func in &call_graph.functions {
|
||||||
|
if !reachable.contains(func) {
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: Severity::Warn,
|
||||||
|
category: Category::DeadCode,
|
||||||
|
location: Location {
|
||||||
|
file: None,
|
||||||
|
function: Some(func.clone()),
|
||||||
|
module: None,
|
||||||
|
line_start: None,
|
||||||
|
line_end: None,
|
||||||
|
},
|
||||||
|
metric: 0.0,
|
||||||
|
threshold: 0.0,
|
||||||
|
context: None,
|
||||||
|
message: format!("{} is not reachable from any entry point", func),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_cyclic_deps(dep_analysis: &deps::DepAnalysis, diagnostics: &mut Vec<Diagnostic>) {
|
||||||
|
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for m in &dep_analysis.modules {
|
||||||
|
adj.entry(m.clone()).or_default();
|
||||||
|
}
|
||||||
|
for edge in &dep_analysis.edges {
|
||||||
|
adj.entry(edge.from.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(edge.to.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let sccs = scoring::tarjan_scc_simple(&dep_analysis.modules, &adj);
|
||||||
|
for scc in &sccs {
|
||||||
|
if scc.len() > 1 {
|
||||||
|
let members = scc.join(", ");
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"members": scc,
|
||||||
|
"member_count": scc.len(),
|
||||||
|
});
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity: Severity::Warn,
|
||||||
|
category: Category::CyclicDeps,
|
||||||
|
location: Location {
|
||||||
|
file: None,
|
||||||
|
function: None,
|
||||||
|
module: Some(members.clone()),
|
||||||
|
line_start: None,
|
||||||
|
line_end: None,
|
||||||
|
},
|
||||||
|
metric: scc.len() as f64,
|
||||||
|
threshold: 1.0,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: format!("Cyclic dependency among {} modules: {}", scc.len(), members),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_redundancy(
|
||||||
|
redundant_pairs: &[redundancy::RedundantPair],
|
||||||
|
diagnostics: &mut Vec<Diagnostic>,
|
||||||
|
) {
|
||||||
|
for pair in redundant_pairs {
|
||||||
|
let (sev, thresh) = if pair.similarity > REDUNDANCY.alert {
|
||||||
|
(Some(Severity::Alert), REDUNDANCY.alert)
|
||||||
|
} else if pair.similarity > REDUNDANCY.warn {
|
||||||
|
(Some(Severity::Warn), REDUNDANCY.warn)
|
||||||
|
} else {
|
||||||
|
(None, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(severity) = sev {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"func_a": pair.func_a,
|
||||||
|
"func_b": pair.func_b,
|
||||||
|
"file_a": pair.file_a,
|
||||||
|
"file_b": pair.file_b,
|
||||||
|
"kind": pair.kind.to_string(),
|
||||||
|
});
|
||||||
|
let msg = format!(
|
||||||
|
"{} and {} have {:.0}% similarity ({})",
|
||||||
|
pair.func_a,
|
||||||
|
pair.func_b,
|
||||||
|
pair.similarity * 100.0,
|
||||||
|
pair.kind
|
||||||
|
);
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity,
|
||||||
|
category: Category::RedundantCode,
|
||||||
|
location: Location {
|
||||||
|
file: Some(pair.file_a.clone()),
|
||||||
|
function: Some(format!("{} / {}", pair.func_a, pair.func_b)),
|
||||||
|
module: None,
|
||||||
|
line_start: Some(pair.line_a),
|
||||||
|
line_end: None,
|
||||||
|
},
|
||||||
|
metric: pair.similarity,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnose_low_cohesion(
|
||||||
|
cohesion_data: &[deps::ModuleCohesion],
|
||||||
|
diagnostics: &mut Vec<Diagnostic>,
|
||||||
|
) {
|
||||||
|
for mc in cohesion_data {
|
||||||
|
if mc.function_count <= 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (sev, thresh) = if mc.combined < COHESION.alert {
|
||||||
|
(Some(Severity::Alert), COHESION.alert)
|
||||||
|
} else if mc.combined < COHESION.warn {
|
||||||
|
(Some(Severity::Warn), COHESION.warn)
|
||||||
|
} else {
|
||||||
|
(None, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(severity) = sev {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"call_cohesion": mc.call_cohesion,
|
||||||
|
"type_cohesion": mc.type_cohesion,
|
||||||
|
"function_count": mc.function_count,
|
||||||
|
});
|
||||||
|
let msg = format!(
|
||||||
|
"{} has cohesion {:.2} (call {:.2}, type {:.2}, {} functions)",
|
||||||
|
mc.module, mc.combined, mc.call_cohesion, mc.type_cohesion, mc.function_count
|
||||||
|
);
|
||||||
|
diagnostics.push(Diagnostic {
|
||||||
|
severity,
|
||||||
|
category: Category::LowCohesion,
|
||||||
|
location: mod_location(&mc.module),
|
||||||
|
metric: mc.combined,
|
||||||
|
threshold: thresh,
|
||||||
|
context: Some(ctx),
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn check_threshold(value: f64, thresh: &Threshold) -> Option<(Severity, f64)> {
|
||||||
|
if value > thresh.alert {
|
||||||
|
Some((Severity::Alert, thresh.alert))
|
||||||
|
} else if value > thresh.warn {
|
||||||
|
Some((Severity::Warn, thresh.warn))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_location(file: Option<&str>, function: Option<&str>, module: Option<&str>) -> Location {
|
||||||
|
Location {
|
||||||
|
file: file.map(str::to_string),
|
||||||
|
function: function.map(str::to_string),
|
||||||
|
module: module.map(str::to_string),
|
||||||
|
line_start: None,
|
||||||
|
line_end: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fn_location(name: &str, file: &str) -> Location {
|
||||||
|
make_location(Some(file), Some(name), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mod_location(module: &str) -> Location {
|
||||||
|
make_location(None, None, Some(module))
|
||||||
|
}
|
||||||
220
src/diagnostics/scoring.rs
Normal file
220
src/diagnostics/scoring.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::circuits;
|
||||||
|
use crate::complexity;
|
||||||
|
use crate::deps;
|
||||||
|
use crate::flow;
|
||||||
|
use crate::loc;
|
||||||
|
use crate::redundancy;
|
||||||
|
|
||||||
|
use super::{HealthScores, COGNITIVE, CYCLOMATIC, FILE_LOC, REDUNDANCY};
|
||||||
|
|
||||||
|
pub(super) fn compute_health_scores(
|
||||||
|
file_stats: &[loc::FileLocStats],
|
||||||
|
func_complexities: &[complexity::FunctionComplexity],
|
||||||
|
dep_analysis: &deps::DepAnalysis,
|
||||||
|
redundant_pairs: &[redundancy::RedundantPair],
|
||||||
|
call_graph: &flow::CallGraph,
|
||||||
|
reachable: &std::collections::HashSet<String>,
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
) -> HealthScores {
|
||||||
|
let modularity = compute_modularity_score(dep_analysis, rs_files, project_path);
|
||||||
|
let complexity = compute_complexity_score(func_complexities);
|
||||||
|
let maintainability = compute_maintainability_score(
|
||||||
|
file_stats,
|
||||||
|
func_complexities,
|
||||||
|
redundant_pairs,
|
||||||
|
call_graph,
|
||||||
|
reachable,
|
||||||
|
modularity,
|
||||||
|
complexity,
|
||||||
|
);
|
||||||
|
|
||||||
|
HealthScores {
|
||||||
|
modularity,
|
||||||
|
complexity,
|
||||||
|
maintainability,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_modularity_score(
|
||||||
|
dep_analysis: &deps::DepAnalysis,
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
) -> f64 {
|
||||||
|
if dep_analysis.modules.is_empty() {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bidi_count = dep_analysis
|
||||||
|
.coupling_pairs
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, _, s)| *s >= 2)
|
||||||
|
.count();
|
||||||
|
let total_pairs = dep_analysis.coupling_pairs.len().max(1);
|
||||||
|
let coupling_ratio = bidi_count as f64 / total_pairs as f64;
|
||||||
|
let coupling_score = 1.0 - coupling_ratio;
|
||||||
|
|
||||||
|
let modularity_q = circuits::circuit_summary(rs_files, project_path)
|
||||||
|
.map(|cs| cs.modularity)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let q_score = ((modularity_q + 0.5) / 1.5).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
(0.5 * coupling_score + 0.5 * q_score).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_complexity_score(func_complexities: &[complexity::FunctionComplexity]) -> f64 {
|
||||||
|
if func_complexities.is_empty() {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let clean_cycl = func_complexities
|
||||||
|
.iter()
|
||||||
|
.filter(|fc| (fc.cyclomatic as f64) <= CYCLOMATIC.warn)
|
||||||
|
.count();
|
||||||
|
let cycl_ratio = clean_cycl as f64 / func_complexities.len() as f64;
|
||||||
|
|
||||||
|
let clean_cogn = func_complexities
|
||||||
|
.iter()
|
||||||
|
.filter(|fc| (fc.cognitive as f64) <= COGNITIVE.warn)
|
||||||
|
.count();
|
||||||
|
let cogn_ratio = clean_cogn as f64 / func_complexities.len() as f64;
|
||||||
|
|
||||||
|
let mut scores: Vec<f64> = func_complexities
|
||||||
|
.iter()
|
||||||
|
.map(|fc| fc.composite_score)
|
||||||
|
.collect();
|
||||||
|
scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let median = if scores.len() % 2 == 0 {
|
||||||
|
(scores[scores.len() / 2 - 1] + scores[scores.len() / 2]) / 2.0
|
||||||
|
} else {
|
||||||
|
scores[scores.len() / 2]
|
||||||
|
};
|
||||||
|
let median_score = (1.0 - (median / 30.0)).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
(0.4 * cycl_ratio + 0.3 * cogn_ratio + 0.3 * median_score).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_maintainability_score(
|
||||||
|
file_stats: &[loc::FileLocStats],
|
||||||
|
func_complexities: &[complexity::FunctionComplexity],
|
||||||
|
redundant_pairs: &[redundancy::RedundantPair],
|
||||||
|
call_graph: &flow::CallGraph,
|
||||||
|
reachable: &std::collections::HashSet<String>,
|
||||||
|
modularity: f64,
|
||||||
|
complexity: f64,
|
||||||
|
) -> f64 {
|
||||||
|
let clean_files = file_stats
|
||||||
|
.iter()
|
||||||
|
.filter(|f| (f.code_lines as f64) <= FILE_LOC.warn)
|
||||||
|
.count();
|
||||||
|
let file_ratio = if file_stats.is_empty() {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
clean_files as f64 / file_stats.len() as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
let high_redundancy = redundant_pairs
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.similarity > REDUNDANCY.alert)
|
||||||
|
.count();
|
||||||
|
let total_funcs = func_complexities.len().max(1);
|
||||||
|
let redundancy_ratio =
|
||||||
|
(1.0 - (high_redundancy as f64 * 2.0 / total_funcs as f64)).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
let dead_ratio = if call_graph.functions.is_empty() {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
let dead = call_graph
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !reachable.contains(*f))
|
||||||
|
.count();
|
||||||
|
(1.0 - dead as f64 / call_graph.functions.len() as f64).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
(0.25 * modularity
|
||||||
|
+ 0.25 * complexity
|
||||||
|
+ 0.2 * file_ratio
|
||||||
|
+ 0.15 * redundancy_ratio
|
||||||
|
+ 0.15 * dead_ratio)
|
||||||
|
.clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simple Tarjan SCC (to avoid depending on graph module internals) ─
|
||||||
|
|
||||||
|
pub(super) fn tarjan_scc_simple(
|
||||||
|
nodes: &[String],
|
||||||
|
adj: &std::collections::HashMap<String, Vec<String>>,
|
||||||
|
) -> Vec<Vec<String>> {
|
||||||
|
struct State {
|
||||||
|
index_counter: usize,
|
||||||
|
stack: Vec<String>,
|
||||||
|
on_stack: std::collections::HashSet<String>,
|
||||||
|
index: std::collections::HashMap<String, usize>,
|
||||||
|
lowlink: std::collections::HashMap<String, usize>,
|
||||||
|
sccs: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strongconnect(
|
||||||
|
v: &str,
|
||||||
|
adj: &std::collections::HashMap<String, Vec<String>>,
|
||||||
|
state: &mut State,
|
||||||
|
) {
|
||||||
|
state.index.insert(v.to_string(), state.index_counter);
|
||||||
|
state.lowlink.insert(v.to_string(), state.index_counter);
|
||||||
|
state.index_counter += 1;
|
||||||
|
state.stack.push(v.to_string());
|
||||||
|
state.on_stack.insert(v.to_string());
|
||||||
|
|
||||||
|
if let Some(neighbors) = adj.get(v) {
|
||||||
|
for w in neighbors {
|
||||||
|
if !state.index.contains_key(w.as_str()) {
|
||||||
|
strongconnect(w, adj, state);
|
||||||
|
let wll = state.lowlink[w.as_str()];
|
||||||
|
let vll = state.lowlink.get_mut(v).unwrap();
|
||||||
|
if wll < *vll {
|
||||||
|
*vll = wll;
|
||||||
|
}
|
||||||
|
} else if state.on_stack.contains(w.as_str()) {
|
||||||
|
let wi = state.index[w.as_str()];
|
||||||
|
let vll = state.lowlink.get_mut(v).unwrap();
|
||||||
|
if wi < *vll {
|
||||||
|
*vll = wi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.lowlink[v] == state.index[v] {
|
||||||
|
let mut component = Vec::new();
|
||||||
|
loop {
|
||||||
|
let w = state.stack.pop().unwrap();
|
||||||
|
state.on_stack.remove(&w);
|
||||||
|
component.push(w.clone());
|
||||||
|
if w == v {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.sccs.push(component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = State {
|
||||||
|
index_counter: 0,
|
||||||
|
stack: Vec::new(),
|
||||||
|
on_stack: std::collections::HashSet::new(),
|
||||||
|
index: std::collections::HashMap::new(),
|
||||||
|
lowlink: std::collections::HashMap::new(),
|
||||||
|
sccs: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for node in nodes {
|
||||||
|
if !state.index.contains_key(node.as_str()) {
|
||||||
|
strongconnect(node, adj, &mut state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.sccs
|
||||||
|
}
|
||||||
706
src/dist.rs
Normal file
706
src/dist.rs
Normal file
|
|
@ -0,0 +1,706 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::complexity::{self, FunctionComplexity};
|
||||||
|
use crate::render;
|
||||||
|
|
||||||
|
// ── Metric extraction ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const METRIC_NAMES: &[&str] = &["loc", "cyclomatic", "cognitive", "nesting", "params"];
|
||||||
|
|
||||||
|
struct MetricSet {
|
||||||
|
/// One vector of values per metric, in METRIC_NAMES order.
|
||||||
|
columns: Vec<Vec<f64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_metrics(fcs: &[FunctionComplexity]) -> MetricSet {
|
||||||
|
let mut columns: Vec<Vec<f64>> = vec![Vec::new(); METRIC_NAMES.len()];
|
||||||
|
for fc in fcs {
|
||||||
|
columns[0].push(fc.line_count as f64);
|
||||||
|
columns[1].push(fc.cyclomatic as f64);
|
||||||
|
columns[2].push(fc.cognitive as f64);
|
||||||
|
columns[3].push(fc.nesting_depth as f64);
|
||||||
|
columns[4].push(fc.param_count as f64);
|
||||||
|
}
|
||||||
|
MetricSet { columns }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Statistics helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn mean(v: &[f64]) -> f64 {
|
||||||
|
if v.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
v.iter().sum::<f64>() / v.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn std_dev(v: &[f64]) -> f64 {
|
||||||
|
if v.len() < 2 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let m = mean(v);
|
||||||
|
let var = v.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / v.len() as f64;
|
||||||
|
var.sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pearson(x: &[f64], y: &[f64]) -> f64 {
|
||||||
|
let n = x.len();
|
||||||
|
if n < 2 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mx = mean(x);
|
||||||
|
let my = mean(y);
|
||||||
|
let mut num = 0.0;
|
||||||
|
let mut dx2 = 0.0;
|
||||||
|
let mut dy2 = 0.0;
|
||||||
|
for i in 0..n {
|
||||||
|
let dx = x[i] - mx;
|
||||||
|
let dy = y[i] - my;
|
||||||
|
num += dx * dy;
|
||||||
|
dx2 += dx * dx;
|
||||||
|
dy2 += dy * dy;
|
||||||
|
}
|
||||||
|
let denom = (dx2 * dy2).sqrt();
|
||||||
|
if denom < 1e-12 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
num / denom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skewness(v: &[f64]) -> f64 {
|
||||||
|
let n = v.len();
|
||||||
|
if n < 3 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let m = mean(v);
|
||||||
|
let s = std_dev(v);
|
||||||
|
if s < 1e-12 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let m3 = v.iter().map(|x| ((x - m) / s).powi(3)).sum::<f64>();
|
||||||
|
m3 / n as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kurtosis(v: &[f64]) -> f64 {
|
||||||
|
let n = v.len();
|
||||||
|
if n < 4 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let m = mean(v);
|
||||||
|
let s = std_dev(v);
|
||||||
|
if s < 1e-12 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let m4 = v.iter().map(|x| ((x - m) / s).powi(4)).sum::<f64>();
|
||||||
|
m4 / n as f64 - 3.0 // excess kurtosis
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Histogram ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_histogram(values: &[f64], label: &str, num_bins: usize, verbose: bool) {
|
||||||
|
if values.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_v = values.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
let max_v = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
|
||||||
|
// Cap bins to the actual range for integer-valued data
|
||||||
|
let range = max_v - min_v;
|
||||||
|
let num_bins = if range.abs() < 1e-12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
let int_range = range.ceil() as usize;
|
||||||
|
num_bins.min(int_range.max(1))
|
||||||
|
};
|
||||||
|
let bin_width = if num_bins == 1 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
range / num_bins as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut bins = vec![0usize; num_bins];
|
||||||
|
for &v in values {
|
||||||
|
let idx = if num_bins == 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
((v - min_v) / bin_width).floor() as usize
|
||||||
|
};
|
||||||
|
let idx = idx.min(num_bins - 1);
|
||||||
|
bins[idx] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_count = *bins.iter().max().unwrap_or(&1).max(&1);
|
||||||
|
|
||||||
|
let term_w = render::terminal_width();
|
||||||
|
let label_w = 12; // " [xxx, yyy)"
|
||||||
|
let count_w = format!("{}", max_count).len() + 1;
|
||||||
|
let bar_budget = term_w.saturating_sub(label_w + count_w + 4).max(10);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n {} (n={})",
|
||||||
|
label.bright_cyan().bold(),
|
||||||
|
format!("{}", values.len()).bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Histogram: frequency distribution of values. Each row is a bin range.",
|
||||||
|
"Bar length proportional to count. Color: red = most frequent, green = least.",
|
||||||
|
"Stats below: skew > 0 = right-tailed (few very high values),",
|
||||||
|
" excess kurtosis > 0 = heavy tails (more outliers than a normal distribution).",
|
||||||
|
]);
|
||||||
|
render::guide_ref("complexity");
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..num_bins {
|
||||||
|
let lo = min_v + i as f64 * bin_width;
|
||||||
|
let hi = lo + bin_width;
|
||||||
|
let range_label = if num_bins == 1 {
|
||||||
|
format!("[{:.0}]", lo)
|
||||||
|
} else if i == num_bins - 1 {
|
||||||
|
format!("[{:.0},{:.0}]", lo, hi)
|
||||||
|
} else {
|
||||||
|
format!("[{:.0},{:.0})", lo, hi)
|
||||||
|
};
|
||||||
|
|
||||||
|
let bar_len = if max_count > 0 {
|
||||||
|
(bins[i] as f64 / max_count as f64 * bar_budget as f64).ceil() as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
.max(if bins[i] > 0 { 1 } else { 0 });
|
||||||
|
|
||||||
|
let ratio = if max_count > 0 {
|
||||||
|
1.0 - (bins[i] as f64 / max_count as f64)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let bar = render::bar_color(&"█".repeat(bar_len), ratio);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:>10} │ {}{} {}",
|
||||||
|
range_label.dimmed(),
|
||||||
|
bar,
|
||||||
|
" ".repeat(bar_budget.saturating_sub(bar_len)),
|
||||||
|
format!("{}", bins[i]).bold(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribution shape stats
|
||||||
|
let m = mean(values);
|
||||||
|
let sd = std_dev(values);
|
||||||
|
let sk = skewness(values);
|
||||||
|
let ku = kurtosis(values);
|
||||||
|
println!(
|
||||||
|
" {} mean={:.1} σ={:.1} skew={:.2} kurt={:.2}",
|
||||||
|
"↳".dimmed(),
|
||||||
|
m,
|
||||||
|
sd,
|
||||||
|
sk,
|
||||||
|
ku
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scatter plot ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_scatter(x: &[f64], y: &[f64], x_label: &str, y_label: &str, verbose: bool) {
|
||||||
|
if x.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = pearson(x, y);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
format!("── {} vs {} (r={:.3}) ", x_label, y_label, r)
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Scatter plot: each ● represents one or more functions at that (x, y) position.",
|
||||||
|
" Green ● = 1 function, Yellow ● = 2-3, Red ● = 4+ (overlapping).",
|
||||||
|
" · = empty cell. X-axis = LoC, Y-axis = composite complexity.",
|
||||||
|
"Pearson r measures linear correlation: r > 0.7 = strong positive,",
|
||||||
|
" r ≈ 0 = no linear relationship, r < -0.7 = strong negative.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let plot_w: usize = render::terminal_width().min(72).saturating_sub(8);
|
||||||
|
let plot_h: usize = 20;
|
||||||
|
|
||||||
|
let x_min = x.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
let x_max = x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
let y_min = y.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
let y_max = y.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
|
||||||
|
let x_range = if (x_max - x_min).abs() < 1e-12 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
x_max - x_min
|
||||||
|
};
|
||||||
|
let y_range = if (y_max - y_min).abs() < 1e-12 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
y_max - y_min
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build grid with counts
|
||||||
|
let mut grid = vec![vec![0u32; plot_w]; plot_h];
|
||||||
|
for i in 0..x.len() {
|
||||||
|
let col = ((x[i] - x_min) / x_range * (plot_w - 1) as f64).round() as usize;
|
||||||
|
let row = ((y[i] - y_min) / y_range * (plot_h - 1) as f64).round() as usize;
|
||||||
|
let col = col.min(plot_w - 1);
|
||||||
|
let row = row.min(plot_h - 1);
|
||||||
|
grid[row][col] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render top to bottom (high y first)
|
||||||
|
let y_label_w = 6;
|
||||||
|
for row in (0..plot_h).rev() {
|
||||||
|
let y_val = y_min + (row as f64 / (plot_h - 1).max(1) as f64) * y_range;
|
||||||
|
let label = if row == plot_h - 1 || row == 0 || row == plot_h / 2 {
|
||||||
|
format!("{:>5.0}", y_val)
|
||||||
|
} else {
|
||||||
|
" ".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
for col in 0..plot_w {
|
||||||
|
let count = grid[row][col];
|
||||||
|
if count == 0 {
|
||||||
|
line.push('·');
|
||||||
|
} else if count == 1 {
|
||||||
|
line.push_str(&"●".green().to_string());
|
||||||
|
} else if count < 4 {
|
||||||
|
line.push_str(&"●".yellow().to_string());
|
||||||
|
} else {
|
||||||
|
line.push_str(&"●".red().bold().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let border = if row == 0 { "└" } else { "│" };
|
||||||
|
println!("{} {}{}", label.dimmed(), border, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis
|
||||||
|
let x_min_s = format!("{:.0}", x_min);
|
||||||
|
let x_max_s = format!("{:.0}", x_max);
|
||||||
|
let mid_x = (x_min + x_max) / 2.0;
|
||||||
|
let x_mid_s = format!("{:.0}", mid_x);
|
||||||
|
let axis_padding = plot_w.saturating_sub(x_min_s.len() + x_max_s.len() + x_mid_s.len()) / 2;
|
||||||
|
println!(
|
||||||
|
"{} {}{}{}{}{}",
|
||||||
|
" ".repeat(y_label_w),
|
||||||
|
x_min_s.dimmed(),
|
||||||
|
" ".repeat(axis_padding),
|
||||||
|
x_mid_s.dimmed(),
|
||||||
|
" ".repeat(axis_padding),
|
||||||
|
x_max_s.dimmed(),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} {} → {} {} ↑",
|
||||||
|
" ".repeat(y_label_w),
|
||||||
|
x_label.dimmed(),
|
||||||
|
y_label.dimmed(),
|
||||||
|
format!("r={:.3}", r).bold(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outlier detection ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_outliers(fcs: &[FunctionComplexity], metrics: &MetricSet, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Outliers (z-score > 2.0) ───────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Outlier detection: functions with a z-score > 2.0 in any metric.",
|
||||||
|
"z-score = (value - mean) / standard deviation. z > 2.0 means the value is",
|
||||||
|
"more than 2 standard deviations above the mean — statistically unusual.",
|
||||||
|
"These are candidates for refactoring or closer inspection.",
|
||||||
|
"Grouped by metric, showing up to 5 outliers per metric.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut any_outlier = false;
|
||||||
|
|
||||||
|
for (mi, metric_name) in METRIC_NAMES.iter().enumerate() {
|
||||||
|
let vals = &metrics.columns[mi];
|
||||||
|
let m = mean(vals);
|
||||||
|
let s = std_dev(vals);
|
||||||
|
if s < 1e-12 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut outliers: Vec<(usize, f64)> = Vec::new();
|
||||||
|
for (i, &v) in vals.iter().enumerate() {
|
||||||
|
let z = (v - m) / s;
|
||||||
|
if z > 2.0 {
|
||||||
|
outliers.push((i, z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outliers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
if !outliers.is_empty() {
|
||||||
|
any_outlier = true;
|
||||||
|
println!(
|
||||||
|
"\n {} ({} outlier{})",
|
||||||
|
metric_name.yellow().bold(),
|
||||||
|
outliers.len(),
|
||||||
|
if outliers.len() == 1 { "" } else { "s" }
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, z) in outliers.iter().take(5) {
|
||||||
|
let name = if fcs[*i].name.len() > 45 {
|
||||||
|
format!("{}...", &fcs[*i].name[..42])
|
||||||
|
} else {
|
||||||
|
fcs[*i].name.clone()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {} {} (z={:.2}, val={:.0})",
|
||||||
|
"▸".red(),
|
||||||
|
name.dimmed(),
|
||||||
|
z,
|
||||||
|
vals[*i],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !any_outlier {
|
||||||
|
println!(" {}", "No outliers detected.".dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Correlation matrix ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_correlation_matrix(metrics: &MetricSet, verbose: bool) {
|
||||||
|
let n = METRIC_NAMES.len();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Correlation Matrix ─────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Pairwise Pearson correlation coefficients (r) between all metrics.",
|
||||||
|
"r ranges from -1.0 (perfect negative) to +1.0 (perfect positive).",
|
||||||
|
"Color: red/bold = strong positive (r > 0.7), blue/bold = strong negative (r < -0.7),",
|
||||||
|
" yellow = moderate positive (r > 0.4), cyan = moderate negative (r < -0.4),",
|
||||||
|
" dim = weak correlation (|r| ≤ 0.4).",
|
||||||
|
"Diagonal is always 1.000 (a metric perfectly correlates with itself).",
|
||||||
|
"High correlation between two metrics suggests redundancy or a shared underlying factor.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
print!(" {:>12}", "");
|
||||||
|
for name in METRIC_NAMES {
|
||||||
|
print!(" {:>10}", name.bold());
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
print!(" {:>12}", METRIC_NAMES[i].bold());
|
||||||
|
for j in 0..n {
|
||||||
|
let r = pearson(&metrics.columns[i], &metrics.columns[j]);
|
||||||
|
let cell = format!("{:>7.3}", r);
|
||||||
|
let colored = color_correlation(&cell, r);
|
||||||
|
print!(" {}", colored);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_correlation(text: &str, r: f64) -> String {
|
||||||
|
let abs_r = r.abs();
|
||||||
|
if abs_r > 0.7 {
|
||||||
|
if r > 0.0 {
|
||||||
|
text.red().bold().to_string()
|
||||||
|
} else {
|
||||||
|
text.blue().bold().to_string()
|
||||||
|
}
|
||||||
|
} else if abs_r > 0.4 {
|
||||||
|
if r > 0.0 {
|
||||||
|
text.yellow().to_string()
|
||||||
|
} else {
|
||||||
|
text.cyan().to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text.dimmed().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public entry point ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn render_dist(
|
||||||
|
rs_files: &[std::path::PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
metric_filter: Option<&str>,
|
||||||
|
num_bins: usize,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
let symbols = crate::ast_parser::parse_project(rs_files);
|
||||||
|
let fcs = complexity::compute_all(&symbols, project_path);
|
||||||
|
|
||||||
|
if fcs.is_empty() {
|
||||||
|
println!("{}", "No functions found to analyze.".yellow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metrics = extract_metrics(&fcs);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Distribution Analysis ──────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Statistical distribution analysis of complexity metrics across all functions.",
|
||||||
|
"Histograms show frequency distributions. Scatter plot shows LoC vs complexity.",
|
||||||
|
"Outliers are functions with z-score > 2.0 (more than 2σ above the mean).",
|
||||||
|
"Correlation matrix shows pairwise Pearson r between all metrics.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} functions across {} files\n",
|
||||||
|
format!("{}", fcs.len()).bold(),
|
||||||
|
format!("{}", rs_files.len()).bold(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Histograms
|
||||||
|
let labels = &[
|
||||||
|
"Function LoC",
|
||||||
|
"Cyclomatic",
|
||||||
|
"Cognitive",
|
||||||
|
"Nesting Depth",
|
||||||
|
"Param Count",
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(filter) = metric_filter {
|
||||||
|
// Find the matching metric
|
||||||
|
let filter_lower = filter.to_lowercase();
|
||||||
|
if let Some(idx) = METRIC_NAMES.iter().position(|&n| n == filter_lower) {
|
||||||
|
render_histogram(&metrics.columns[idx], labels[idx], num_bins, verbose);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{} Unknown metric '{}'. Available: {}",
|
||||||
|
"Error:".red().bold(),
|
||||||
|
filter,
|
||||||
|
METRIC_NAMES.join(", "),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (i, label) in labels.iter().enumerate() {
|
||||||
|
render_histogram(&metrics.columns[i], label, num_bins, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scatter plot: complexity (composite score) vs LoC
|
||||||
|
let composite_scores: Vec<f64> = fcs.iter().map(|f| f.composite_score).collect();
|
||||||
|
render_scatter(
|
||||||
|
&metrics.columns[0], // LoC
|
||||||
|
&composite_scores,
|
||||||
|
"Function LoC",
|
||||||
|
"Complexity",
|
||||||
|
verbose,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Outlier detection
|
||||||
|
render_outliers(&fcs, &metrics, verbose);
|
||||||
|
|
||||||
|
// Correlation matrix
|
||||||
|
render_correlation_matrix(&metrics, verbose);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct HistogramBinJson {
|
||||||
|
low: f64,
|
||||||
|
high: f64,
|
||||||
|
count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct HistogramJson {
|
||||||
|
metric: String,
|
||||||
|
bins: Vec<HistogramBinJson>,
|
||||||
|
mean: f64,
|
||||||
|
std_dev: f64,
|
||||||
|
skewness: f64,
|
||||||
|
kurtosis: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct OutlierJson {
|
||||||
|
metric: String,
|
||||||
|
function: String,
|
||||||
|
z_score: f64,
|
||||||
|
value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CorrelationEntryJson {
|
||||||
|
metric_a: String,
|
||||||
|
metric_b: String,
|
||||||
|
pearson_r: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DistJson {
|
||||||
|
cstat_version: String,
|
||||||
|
histograms: Vec<HistogramJson>,
|
||||||
|
outliers: Vec<OutlierJson>,
|
||||||
|
correlations: Vec<CorrelationEntryJson>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_histogram_bins(values: &[f64], num_bins: usize) -> Vec<HistogramBinJson> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_v = values.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
let max_v = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
let range = max_v - min_v;
|
||||||
|
let num_bins = if range.abs() < 1e-12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
let int_range = range.ceil() as usize;
|
||||||
|
num_bins.min(int_range.max(1))
|
||||||
|
};
|
||||||
|
let bin_width = if num_bins == 1 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
range / num_bins as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut counts = vec![0usize; num_bins];
|
||||||
|
for &v in values {
|
||||||
|
let idx = if num_bins == 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
((v - min_v) / bin_width).floor() as usize
|
||||||
|
};
|
||||||
|
let idx = idx.min(num_bins - 1);
|
||||||
|
counts[idx] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
(0..num_bins)
|
||||||
|
.map(|i| {
|
||||||
|
let lo = min_v + i as f64 * bin_width;
|
||||||
|
let hi = lo + bin_width;
|
||||||
|
HistogramBinJson {
|
||||||
|
low: lo,
|
||||||
|
high: hi,
|
||||||
|
count: counts[i],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render distribution analysis as JSON.
|
||||||
|
pub fn render_dist_json(
|
||||||
|
rs_files: &[std::path::PathBuf],
|
||||||
|
project_path: &std::path::Path,
|
||||||
|
metric_filter: Option<&str>,
|
||||||
|
num_bins: usize,
|
||||||
|
) {
|
||||||
|
let symbols = crate::ast_parser::parse_project(rs_files);
|
||||||
|
let fcs = complexity::compute_all(&symbols, project_path);
|
||||||
|
let metrics = extract_metrics(&fcs);
|
||||||
|
|
||||||
|
let labels = &["loc", "cyclomatic", "cognitive", "nesting", "params"];
|
||||||
|
|
||||||
|
let indices: Vec<usize> = if let Some(filter) = metric_filter {
|
||||||
|
let filter_lower = filter.to_lowercase();
|
||||||
|
if let Some(idx) = METRIC_NAMES.iter().position(|&n| n == filter_lower) {
|
||||||
|
vec![idx]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(0..METRIC_NAMES.len()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let histograms: Vec<HistogramJson> = indices
|
||||||
|
.iter()
|
||||||
|
.map(|&i| {
|
||||||
|
let vals = &metrics.columns[i];
|
||||||
|
let bins_json = compute_histogram_bins(vals, num_bins);
|
||||||
|
HistogramJson {
|
||||||
|
metric: labels[i].to_string(),
|
||||||
|
bins: bins_json,
|
||||||
|
mean: mean(vals),
|
||||||
|
std_dev: std_dev(vals),
|
||||||
|
skewness: skewness(vals),
|
||||||
|
kurtosis: kurtosis(vals),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut outliers_json: Vec<OutlierJson> = Vec::new();
|
||||||
|
for (mi, metric_name) in METRIC_NAMES.iter().enumerate() {
|
||||||
|
let vals = &metrics.columns[mi];
|
||||||
|
let m = mean(vals);
|
||||||
|
let s = std_dev(vals);
|
||||||
|
if s < 1e-12 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (i, &v) in vals.iter().enumerate() {
|
||||||
|
let z = (v - m) / s;
|
||||||
|
if z > 2.0 {
|
||||||
|
outliers_json.push(OutlierJson {
|
||||||
|
metric: metric_name.to_string(),
|
||||||
|
function: fcs[i].name.clone(),
|
||||||
|
z_score: z,
|
||||||
|
value: v,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outliers_json.sort_by(|a, b| {
|
||||||
|
b.z_score
|
||||||
|
.partial_cmp(&a.z_score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
let n = METRIC_NAMES.len();
|
||||||
|
let mut correlations: Vec<CorrelationEntryJson> = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
for j in (i + 1)..n {
|
||||||
|
let r = pearson(&metrics.columns[i], &metrics.columns[j]);
|
||||||
|
correlations.push(CorrelationEntryJson {
|
||||||
|
metric_a: METRIC_NAMES[i].to_string(),
|
||||||
|
metric_b: METRIC_NAMES[j].to_string(),
|
||||||
|
pearson_r: r,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = DistJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
histograms,
|
||||||
|
outliers: outliers_json,
|
||||||
|
correlations,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
248
src/dump.rs
Normal file
248
src/dump.rs
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::ast_parser;
|
||||||
|
use crate::complexity;
|
||||||
|
use crate::deps;
|
||||||
|
use crate::diagnostics;
|
||||||
|
use crate::loc;
|
||||||
|
|
||||||
|
// ── Top-level dump output ────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DumpOutput {
|
||||||
|
cstat_version: String,
|
||||||
|
project: ProjectInfo,
|
||||||
|
scores: diagnostics::HealthScores,
|
||||||
|
diagnostic_counts: DiagnosticCounts,
|
||||||
|
worst_items: Vec<diagnostics::Diagnostic>,
|
||||||
|
suggested_drilldowns: Vec<Drilldown>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProjectInfo {
|
||||||
|
files: usize,
|
||||||
|
total_loc: usize,
|
||||||
|
functions: usize,
|
||||||
|
modules: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diagnostic counts ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DiagnosticCounts {
|
||||||
|
total: usize,
|
||||||
|
alerts: usize,
|
||||||
|
warns: usize,
|
||||||
|
by_category: BTreeMap<String, CategoryCounts>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CategoryCounts {
|
||||||
|
alerts: usize,
|
||||||
|
warns: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drilldowns ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Drilldown {
|
||||||
|
command: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public entry point ───────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn render_dump(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
|
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||||
|
|
||||||
|
let (file_stats, agg_loc) = loc::analyze_files(rs_files);
|
||||||
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
|
let func_complexities = complexity::compute_all(&symbols, project_path);
|
||||||
|
let dep_analysis = deps::analyze_deps(rs_files, project_path);
|
||||||
|
|
||||||
|
let report = diagnostics::run_diagnostics(rs_files, project_path);
|
||||||
|
|
||||||
|
let project = ProjectInfo {
|
||||||
|
files: file_stats.len(),
|
||||||
|
total_loc: agg_loc.as_ref().map(|a| a.total_loc).unwrap_or(0),
|
||||||
|
functions: func_complexities.len(),
|
||||||
|
modules: dep_analysis.modules.len(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let diagnostic_counts = build_diagnostic_counts(&report.diagnostics);
|
||||||
|
let worst_items = build_worst_items(&report.diagnostics);
|
||||||
|
let suggested_drilldowns = build_drilldowns(&report.diagnostics);
|
||||||
|
|
||||||
|
let output = DumpOutput {
|
||||||
|
cstat_version: version,
|
||||||
|
project,
|
||||||
|
scores: report.scores,
|
||||||
|
diagnostic_counts,
|
||||||
|
worst_items,
|
||||||
|
suggested_drilldowns,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diagnostic count builder ────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_diagnostic_counts(diagnostics: &[diagnostics::Diagnostic]) -> DiagnosticCounts {
|
||||||
|
let mut total_alerts = 0usize;
|
||||||
|
let mut total_warns = 0usize;
|
||||||
|
let mut by_category: BTreeMap<String, CategoryCounts> = BTreeMap::new();
|
||||||
|
|
||||||
|
// Initialize all categories so they always appear
|
||||||
|
let all_categories = [
|
||||||
|
"bloated_function",
|
||||||
|
"redundant_code",
|
||||||
|
"high_cognitive",
|
||||||
|
"high_complexity",
|
||||||
|
"deep_nesting",
|
||||||
|
"dead_code",
|
||||||
|
"bloated_file",
|
||||||
|
"low_cohesion",
|
||||||
|
"too_many_params",
|
||||||
|
"god_module",
|
||||||
|
"high_coupling",
|
||||||
|
"cyclic_deps",
|
||||||
|
];
|
||||||
|
for cat in &all_categories {
|
||||||
|
by_category.insert(
|
||||||
|
cat.to_string(),
|
||||||
|
CategoryCounts {
|
||||||
|
alerts: 0,
|
||||||
|
warns: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for d in diagnostics {
|
||||||
|
let cat_name = category_name(&d.category);
|
||||||
|
let entry = by_category.entry(cat_name).or_insert(CategoryCounts {
|
||||||
|
alerts: 0,
|
||||||
|
warns: 0,
|
||||||
|
});
|
||||||
|
match d.severity {
|
||||||
|
diagnostics::Severity::Alert => {
|
||||||
|
total_alerts += 1;
|
||||||
|
entry.alerts += 1;
|
||||||
|
}
|
||||||
|
diagnostics::Severity::Warn => {
|
||||||
|
total_warns += 1;
|
||||||
|
entry.warns += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiagnosticCounts {
|
||||||
|
total: diagnostics.len(),
|
||||||
|
alerts: total_alerts,
|
||||||
|
warns: total_warns,
|
||||||
|
by_category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Worst items (top 5) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_worst_items(diagnostics: &[diagnostics::Diagnostic]) -> Vec<diagnostics::Diagnostic> {
|
||||||
|
// Sort: alerts first, then by metric magnitude descending.
|
||||||
|
let mut sorted: Vec<_> = diagnostics.to_vec();
|
||||||
|
sorted.sort_by(|a, b| {
|
||||||
|
let sev_order = |s: &diagnostics::Severity| match s {
|
||||||
|
diagnostics::Severity::Alert => 0,
|
||||||
|
diagnostics::Severity::Warn => 1,
|
||||||
|
};
|
||||||
|
sev_order(&a.severity)
|
||||||
|
.cmp(&sev_order(&b.severity))
|
||||||
|
.then_with(|| {
|
||||||
|
b.metric
|
||||||
|
.partial_cmp(&a.metric)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
sorted.into_iter().take(5).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drilldown suggestions ───────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_drilldowns(diagnostics: &[diagnostics::Diagnostic]) -> Vec<Drilldown> {
|
||||||
|
// Count alerts per category
|
||||||
|
let mut alert_counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
for d in diagnostics {
|
||||||
|
if d.severity == diagnostics::Severity::Alert {
|
||||||
|
*alert_counts.entry(category_name(&d.category)).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map categories to subcommands
|
||||||
|
let category_to_command: &[(&[&str], &str)] = &[
|
||||||
|
(
|
||||||
|
&["high_complexity", "high_cognitive", "deep_nesting"],
|
||||||
|
"cstat branching --json",
|
||||||
|
),
|
||||||
|
(&["too_many_params"], "cstat signature --json"),
|
||||||
|
(&["bloated_function"], "cstat span --json"),
|
||||||
|
(&["redundant_code"], "cstat redundancy --json"),
|
||||||
|
(&["bloated_file"], "cstat loc --json"),
|
||||||
|
(
|
||||||
|
&["low_cohesion", "high_coupling", "god_module"],
|
||||||
|
"cstat deps --json",
|
||||||
|
),
|
||||||
|
(&["dead_code"], "cstat test-reachability --json"),
|
||||||
|
(&["cyclic_deps"], "cstat graph --json"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut drilldowns: Vec<(usize, String, Vec<String>)> = Vec::new();
|
||||||
|
|
||||||
|
for (categories, command) in category_to_command {
|
||||||
|
let mut total_alerts = 0usize;
|
||||||
|
let mut reasons: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for cat in *categories {
|
||||||
|
if let Some(&count) = alert_counts.get(*cat) {
|
||||||
|
total_alerts += count;
|
||||||
|
let display = cat.replace('_', " ");
|
||||||
|
reasons.push(format!("{} alert-level {}", count, display));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_alerts > 0 {
|
||||||
|
drilldowns.push((total_alerts, command.to_string(), reasons));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by total alert count descending
|
||||||
|
drilldowns.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
|
||||||
|
drilldowns
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, command, reasons)| Drilldown {
|
||||||
|
command,
|
||||||
|
reason: reasons.join(", "),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn category_name(cat: &diagnostics::Category) -> String {
|
||||||
|
// Match the serde snake_case serialization
|
||||||
|
match cat {
|
||||||
|
diagnostics::Category::BloatedFile => "bloated_file",
|
||||||
|
diagnostics::Category::BloatedFunction => "bloated_function",
|
||||||
|
diagnostics::Category::HighComplexity => "high_complexity",
|
||||||
|
diagnostics::Category::HighCognitive => "high_cognitive",
|
||||||
|
diagnostics::Category::DeepNesting => "deep_nesting",
|
||||||
|
diagnostics::Category::HighCoupling => "high_coupling",
|
||||||
|
diagnostics::Category::GodModule => "god_module",
|
||||||
|
diagnostics::Category::DeadCode => "dead_code",
|
||||||
|
diagnostics::Category::LowCohesion => "low_cohesion",
|
||||||
|
diagnostics::Category::RedundantCode => "redundant_code",
|
||||||
|
diagnostics::Category::TooManyParams => "too_many_params",
|
||||||
|
diagnostics::Category::CyclicDeps => "cyclic_deps",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
507
src/file_summary.rs
Normal file
507
src/file_summary.rs
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ast_parser, call_trace, datapaths, dead_code, deps, file_metrics, flow, loc, render, symbols,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FileSummaryReport {
|
||||||
|
pub cstat_version: String,
|
||||||
|
pub file: String,
|
||||||
|
pub project: String,
|
||||||
|
pub line_counts: loc::FileProjectedLineReachabilityReport,
|
||||||
|
pub static_reachability: datapaths::FileStaticReachabilityReport,
|
||||||
|
pub symbols: symbols::FileSymbolReport,
|
||||||
|
pub dependencies: deps::FileDependencyReport,
|
||||||
|
pub dead_code: dead_code::FileDeadCodeReport,
|
||||||
|
pub call_trace: call_trace::FileCallTraceReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_file_summary(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) {
|
||||||
|
let report = build_file_summary(rs_files, project_path, file);
|
||||||
|
render_file_summary_report(&report, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_file_summary_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
|
||||||
|
let report = build_file_summary(rs_files, project_path, file);
|
||||||
|
println!("{}", serde_json::to_string(&report).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_file_summary(
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
file: &Path,
|
||||||
|
) -> FileSummaryReport {
|
||||||
|
let file_display_path = call_trace::relative_path_string(project_path, file);
|
||||||
|
let project = project_name(project_path);
|
||||||
|
|
||||||
|
let symbols_raw = ast_parser::parse_project(rs_files);
|
||||||
|
let graph = flow::build_call_graph(rs_files, project_path);
|
||||||
|
let file_functions =
|
||||||
|
file_metrics::selected_file_functions(&symbols_raw, &graph, project_path, file);
|
||||||
|
let static_reachability =
|
||||||
|
datapaths::analyze_file_static_reachability(&graph, &file_functions, &file_display_path);
|
||||||
|
let reachable_functions: HashSet<String> = static_reachability
|
||||||
|
.reachable_functions
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let line_counts = loc::analyze_file_projected_line_reachability(
|
||||||
|
file,
|
||||||
|
&file_functions,
|
||||||
|
&reachable_functions,
|
||||||
|
&file_display_path,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|| empty_line_report(file, &file_display_path));
|
||||||
|
let symbols = symbols::analyze_symbols_file(&symbols_raw, project_path, file);
|
||||||
|
let dependencies = deps::analyze_file_deps(rs_files, project_path, file);
|
||||||
|
let dead_code = dead_code::analyze_file_dead_code(&graph, &file_functions, &file_display_path);
|
||||||
|
let call_trace =
|
||||||
|
call_trace::analyze_file_call_traces(&graph, &file_functions, &file_display_path);
|
||||||
|
|
||||||
|
FileSummaryReport {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
file: file_display_path,
|
||||||
|
project,
|
||||||
|
line_counts,
|
||||||
|
static_reachability,
|
||||||
|
symbols,
|
||||||
|
dependencies,
|
||||||
|
dead_code,
|
||||||
|
call_trace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_name(project_path: &Path) -> String {
|
||||||
|
let fallback = || {
|
||||||
|
project_path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(manifest) = std::fs::read_to_string(project_path.join("Cargo.toml")) else {
|
||||||
|
return fallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut in_package = false;
|
||||||
|
for line in manifest.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
|
in_package = trimmed == "[package]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_package {
|
||||||
|
if let Some(value) = trimmed.strip_prefix("name") {
|
||||||
|
if let Some((_, raw_name)) = value.split_once('=') {
|
||||||
|
let name = raw_name.trim().trim_matches('"');
|
||||||
|
if !name.is_empty() {
|
||||||
|
return name.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_line_report(
|
||||||
|
file: &Path,
|
||||||
|
file_display_path: &str,
|
||||||
|
) -> loc::FileProjectedLineReachabilityReport {
|
||||||
|
let stats = loc::count_file_lines(file);
|
||||||
|
loc::FileProjectedLineReachabilityReport {
|
||||||
|
file: file_display_path.to_string(),
|
||||||
|
total_lines: stats.as_ref().map_or(0, |stats| stats.total_lines),
|
||||||
|
code_lines: 0,
|
||||||
|
projected_reachable_lines: 0,
|
||||||
|
projected_unreachable_lines: 0,
|
||||||
|
reachable_spans: Vec::new(),
|
||||||
|
unreachable_spans: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_file_summary_report(report: &FileSummaryReport, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
"cstat file summary".bright_cyan().bold(),
|
||||||
|
format!("({})", report.file).dimmed(),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
"Single-file static metrics projected through the containing crate.".dimmed()
|
||||||
|
);
|
||||||
|
|
||||||
|
render_line_counts(&report.line_counts, &report.file, verbose);
|
||||||
|
render_static_reachability(&report.static_reachability, &report.file, verbose);
|
||||||
|
render_symbols(&report.symbols, &report.file, verbose);
|
||||||
|
render_dependencies(&report.dependencies, &report.file, verbose);
|
||||||
|
render_dead_code(&report.dead_code, &report.file, verbose);
|
||||||
|
render_call_trace(&report.call_trace, &report.file, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_line_counts(
|
||||||
|
report: &loc::FileProjectedLineReachabilityReport,
|
||||||
|
file: &str,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
render::section_header("Projected line reachability");
|
||||||
|
if verbose {
|
||||||
|
let full_section = format!("Full section: cstat loc --path {file} -v");
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Production code lines exclude blank/comment-only lines and test/support/wrapper spans.",
|
||||||
|
"Projected reachable lines are static function spans reached from project tests/benches.",
|
||||||
|
full_section.as_str(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pct = percentage(report.projected_reachable_lines, report.code_lines);
|
||||||
|
println!(
|
||||||
|
" {} {} {} {} {} {} / {} ({:.1}%) {} {}",
|
||||||
|
"production code lines".cyan(),
|
||||||
|
report.code_lines.to_string().bold(),
|
||||||
|
"physical".cyan(),
|
||||||
|
report.total_lines.to_string().bold(),
|
||||||
|
"statically reachable".cyan(),
|
||||||
|
report.projected_reachable_lines.to_string().bold(),
|
||||||
|
report.code_lines,
|
||||||
|
pct,
|
||||||
|
"not statically reachable".cyan(),
|
||||||
|
report.projected_unreachable_lines.to_string().bold(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !report.reachable_spans.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(" {}", "reachable spans:".bold());
|
||||||
|
for span in report.reachable_spans.iter().take(6) {
|
||||||
|
println!(
|
||||||
|
" {} lines {}-{}",
|
||||||
|
span.function, span.line_start, span.line_end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
print_more(report.reachable_spans.len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !report.unreachable_spans.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(" {}", "unreachable spans:".bold().yellow());
|
||||||
|
for span in report.unreachable_spans.iter().take(6) {
|
||||||
|
println!(
|
||||||
|
" {} lines {}-{}",
|
||||||
|
span.function, span.line_start, span.line_end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
print_more(report.unreachable_spans.len(), 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_static_reachability(
|
||||||
|
report: &datapaths::FileStaticReachabilityReport,
|
||||||
|
file: &str,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
render::section_header("Static test/benchmark reachability");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
let full_section = format!("Full section: cstat test-reachability --path {file} -v");
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Roots are tests and benchmarks discovered anywhere in the project.",
|
||||||
|
"Reachability follows the full crate call graph, then projects production results onto this file.",
|
||||||
|
full_section.as_str(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pct = percentage(
|
||||||
|
report.reachable_function_count,
|
||||||
|
report.production_function_count,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} {} {} {} / {} ({:.1}%) {} {} / {}",
|
||||||
|
"test/bench roots".cyan(),
|
||||||
|
report.test_benchmark_entry_count.to_string().bold(),
|
||||||
|
"functions reachable".cyan(),
|
||||||
|
report.reachable_function_count.to_string().bold(),
|
||||||
|
report.production_function_count,
|
||||||
|
pct,
|
||||||
|
"local edges reached".cyan(),
|
||||||
|
report.reachable_local_edge_count.to_string().bold(),
|
||||||
|
report.local_edge_count,
|
||||||
|
);
|
||||||
|
|
||||||
|
if !report.wrapper_entrypoints.is_empty() {
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
"wrapper entrypoints:".cyan(),
|
||||||
|
report.wrapper_entrypoints.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !report.entries.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {:<48} {:<7} {:>9} {:>7} {:>8}",
|
||||||
|
"entry root".bold(),
|
||||||
|
"kind".bold(),
|
||||||
|
"functions".bold(),
|
||||||
|
"steps".bold(),
|
||||||
|
"depth".bold(),
|
||||||
|
);
|
||||||
|
for entry in report.entries.iter().take(8) {
|
||||||
|
println!(
|
||||||
|
" {:<48} {:<7} {:>9} {:>7} {:>8}",
|
||||||
|
truncate(&entry.entry, 48),
|
||||||
|
entry.kind,
|
||||||
|
entry.reachable_functions,
|
||||||
|
entry.unique_call_steps,
|
||||||
|
entry.max_call_depth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
print_more(report.entries.len(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" {}", "not statically reachable:".bold().yellow());
|
||||||
|
if report.unreachable_functions.is_empty() {
|
||||||
|
println!(" {}", "<none>".dimmed());
|
||||||
|
} else {
|
||||||
|
for function in report.unreachable_functions.iter().take(8) {
|
||||||
|
println!(" {} {}", "•".yellow(), function);
|
||||||
|
}
|
||||||
|
print_more(report.unreachable_functions.len(), 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_symbols(report: &symbols::FileSymbolReport, file: &str, verbose: bool) {
|
||||||
|
render::section_header("Symbols");
|
||||||
|
if verbose {
|
||||||
|
let full_section = format!("Full section: cstat symbols --path {file} -v");
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Selected-file symbols use syn Rust AST item discovery.",
|
||||||
|
"Functions include free functions and impl methods once.",
|
||||||
|
full_section.as_str(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||||
|
"total".cyan(),
|
||||||
|
report.total.to_string().bold(),
|
||||||
|
"fn".cyan(),
|
||||||
|
report.functions.to_string().bold(),
|
||||||
|
"struct".cyan(),
|
||||||
|
report.structs.to_string().bold(),
|
||||||
|
"enum".cyan(),
|
||||||
|
report.enums.to_string().bold(),
|
||||||
|
"trait".cyan(),
|
||||||
|
report.traits.to_string().bold(),
|
||||||
|
"trait_impl".cyan(),
|
||||||
|
report.trait_impls.to_string().bold(),
|
||||||
|
"const".cyan(),
|
||||||
|
report.consts.to_string().bold(),
|
||||||
|
"static".cyan(),
|
||||||
|
report.statics.to_string().bold(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if report.parse_error {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
"parse error: selected file could not be parsed"
|
||||||
|
.yellow()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !report.symbols.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(" {:<10} {}", "kind".bold(), "symbol".bold());
|
||||||
|
for row in report.symbols.iter().take(12) {
|
||||||
|
println!(
|
||||||
|
" {:<10} {}",
|
||||||
|
file_symbol_kind_label(&row.kind),
|
||||||
|
truncate(&row.symbol, 60),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
print_more(report.symbols.len(), 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_dependencies(report: &deps::FileDependencyReport, file: &str, verbose: bool) {
|
||||||
|
render::section_header("Dependencies");
|
||||||
|
if verbose {
|
||||||
|
let full_section = format!("Full section: cstat deps --path {file} -v");
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Imports are raw use/mod targets parsed from this file.",
|
||||||
|
"Internal imports resolve to known modules in the containing crate.",
|
||||||
|
full_section.as_str(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} {} {} {} {} {} {}",
|
||||||
|
"module".cyan(),
|
||||||
|
report.module.bold(),
|
||||||
|
"imports".cyan(),
|
||||||
|
report.imports.to_string().bold(),
|
||||||
|
"internal".cyan(),
|
||||||
|
report.internal.len().to_string().bold(),
|
||||||
|
"external".cyan(),
|
||||||
|
report.external.len().to_string().bold(),
|
||||||
|
);
|
||||||
|
|
||||||
|
print_dependency_list("internal", &report.internal, 6, true);
|
||||||
|
print_dependency_list("external", &report.external, 6, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_dead_code(report: &dead_code::FileDeadCodeReport, file: &str, verbose: bool) {
|
||||||
|
render::section_header("Dead code candidates");
|
||||||
|
if verbose {
|
||||||
|
let full_section = format!("Full section: cstat dead-code --path {file} -v");
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Candidates are file functions not reachable from crate roots.",
|
||||||
|
"Fan-in distinguishes crate callers from callers inside this file.",
|
||||||
|
full_section.as_str(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} / {} {} {} candidates",
|
||||||
|
"reached from crate roots".cyan(),
|
||||||
|
report.reached_from_crate_count.to_string().bold(),
|
||||||
|
report.file_function_count,
|
||||||
|
"cold".cyan(),
|
||||||
|
report.candidate_count.to_string().bold(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let candidates: Vec<_> = report
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.filter(|function| function.candidate)
|
||||||
|
.collect();
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {:<44} {:>11} {:>13} {}",
|
||||||
|
"function".bold(),
|
||||||
|
"crate fan-in".bold(),
|
||||||
|
"in-file fan-in".bold(),
|
||||||
|
"reason".bold(),
|
||||||
|
);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
println!(" {}", "<none>".dimmed());
|
||||||
|
} else {
|
||||||
|
for function in candidates.iter().take(8) {
|
||||||
|
println!(
|
||||||
|
" {:<44} {:>11} {:>13} {}",
|
||||||
|
truncate(&function.function, 44),
|
||||||
|
function.crate_fan_in,
|
||||||
|
function.in_file_fan_in,
|
||||||
|
function.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
print_more(candidates.len(), 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_call_trace(report: &call_trace::FileCallTraceReport, file: &str, verbose: bool) {
|
||||||
|
render::section_header("Call trace size");
|
||||||
|
if verbose {
|
||||||
|
let full_section = format!("Full section: cstat call-trace --path {file} -v");
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Rows rank non-test functions by the size of their static call tree.",
|
||||||
|
"Direct callees are truncated for the summary; full trees are in the section command.",
|
||||||
|
full_section.as_str(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:<44} {:>7} {:>5} {}",
|
||||||
|
"function".bold(),
|
||||||
|
"reached".bold(),
|
||||||
|
"depth".bold(),
|
||||||
|
"direct callees".bold(),
|
||||||
|
);
|
||||||
|
let width = render::terminal_width();
|
||||||
|
let callee_width = width.saturating_sub(64).max(24);
|
||||||
|
for function in report.functions.iter().take(12) {
|
||||||
|
let callees = if function.direct_callees.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
function.direct_callees.join(", ")
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {:<44} {:>7} {:>5} {}",
|
||||||
|
truncate(&function.function, 44),
|
||||||
|
function.functions_reached,
|
||||||
|
function.max_depth,
|
||||||
|
truncate(&callees, callee_width),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
print_more(report.functions.len(), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_dependency_list(
|
||||||
|
label: &str,
|
||||||
|
dependencies: &[deps::FileDependency],
|
||||||
|
limit: usize,
|
||||||
|
show_resolved: bool,
|
||||||
|
) {
|
||||||
|
println!();
|
||||||
|
println!(" {}", format!("{label} imports:").bold());
|
||||||
|
if dependencies.is_empty() {
|
||||||
|
println!(" {}", "<none>".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for dependency in dependencies.iter().take(limit) {
|
||||||
|
if show_resolved {
|
||||||
|
println!(
|
||||||
|
" {} {} -> {}",
|
||||||
|
"•".cyan(),
|
||||||
|
dependency.raw,
|
||||||
|
dependency.resolved_module.as_deref().unwrap_or("-"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(" {} {}", "•".cyan(), dependency.raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print_more(dependencies.len(), limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_symbol_kind_label(kind: &symbols::FileSymbolKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
symbols::FileSymbolKind::Fn => "fn",
|
||||||
|
symbols::FileSymbolKind::Struct => "struct",
|
||||||
|
symbols::FileSymbolKind::Enum => "enum",
|
||||||
|
symbols::FileSymbolKind::Trait => "trait",
|
||||||
|
symbols::FileSymbolKind::TraitImpl => "trait_impl",
|
||||||
|
symbols::FileSymbolKind::Const => "const",
|
||||||
|
symbols::FileSymbolKind::Static => "static",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percentage(numerator: usize, denominator: usize) -> f64 {
|
||||||
|
if denominator == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
numerator as f64 / denominator as f64 * 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_more(total: usize, shown: usize) {
|
||||||
|
if total > shown {
|
||||||
|
println!(" … +{} more", total - shown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(value: &str, max: usize) -> String {
|
||||||
|
if value.chars().count() <= max {
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
let mut out: String = value.chars().take(max.saturating_sub(1)).collect();
|
||||||
|
out.push('…');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
395
src/flow.rs
395
src/flow.rs
|
|
@ -1,3 +1,4 @@
|
||||||
|
use colored::Colorize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -58,6 +59,8 @@ pub struct CallGraph {
|
||||||
pub entry_points: Vec<String>,
|
pub entry_points: Vec<String>,
|
||||||
/// Entry point classification keyed by function name.
|
/// Entry point classification keyed by function name.
|
||||||
pub entry_point_kinds: HashMap<String, EntryPointKind>,
|
pub entry_point_kinds: HashMap<String, EntryPointKind>,
|
||||||
|
/// Edges that form cycles.
|
||||||
|
pub cycle_edges: HashSet<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -1446,6 +1449,49 @@ fn concrete_generic_method_edges(
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect cycles in the call graph using DFS. Returns the set of back-edges.
|
||||||
|
fn detect_cycles(adjacency: &HashMap<String, Vec<String>>) -> HashSet<(String, String)> {
|
||||||
|
let mut cycle_edges = HashSet::new();
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
let mut on_stack = HashSet::new();
|
||||||
|
|
||||||
|
for node in adjacency.keys() {
|
||||||
|
if !visited.contains(node) {
|
||||||
|
dfs_cycle(
|
||||||
|
node,
|
||||||
|
adjacency,
|
||||||
|
&mut visited,
|
||||||
|
&mut on_stack,
|
||||||
|
&mut cycle_edges,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cycle_edges
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dfs_cycle(
|
||||||
|
node: &str,
|
||||||
|
adjacency: &HashMap<String, Vec<String>>,
|
||||||
|
visited: &mut HashSet<String>,
|
||||||
|
on_stack: &mut HashSet<String>,
|
||||||
|
cycle_edges: &mut HashSet<(String, String)>,
|
||||||
|
) {
|
||||||
|
visited.insert(node.to_string());
|
||||||
|
on_stack.insert(node.to_string());
|
||||||
|
|
||||||
|
if let Some(callees) = adjacency.get(node) {
|
||||||
|
for callee in callees {
|
||||||
|
if on_stack.contains(callee.as_str()) {
|
||||||
|
cycle_edges.insert((node.to_string(), callee.clone()));
|
||||||
|
} else if !visited.contains(callee.as_str()) {
|
||||||
|
dfs_cycle(callee, adjacency, visited, on_stack, cycle_edges);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on_stack.remove(node);
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the call graph from all project files.
|
/// Build the call graph from all project files.
|
||||||
pub fn build_call_graph(rs_files: &[PathBuf], project_path: &Path) -> CallGraph {
|
pub fn build_call_graph(rs_files: &[PathBuf], project_path: &Path) -> CallGraph {
|
||||||
let mut records = Vec::new();
|
let mut records = Vec::new();
|
||||||
|
|
@ -1589,12 +1635,15 @@ pub fn build_call_graph(rs_files: &[PathBuf], project_path: &Path) -> CallGraph
|
||||||
callees.retain(|c| seen.insert(c.clone()));
|
callees.retain(|c| seen.insert(c.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cycle_edges = detect_cycles(&adjacency);
|
||||||
|
|
||||||
CallGraph {
|
CallGraph {
|
||||||
functions,
|
functions,
|
||||||
function_nodes,
|
function_nodes,
|
||||||
adjacency,
|
adjacency,
|
||||||
entry_points,
|
entry_points,
|
||||||
entry_point_kinds,
|
entry_point_kinds,
|
||||||
|
cycle_edges,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1627,6 +1676,352 @@ pub fn entry_points_by_kind(call_graph: &CallGraph, kind: EntryPointKind) -> Vec
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the call graph as a tree rooted at entry points.
|
||||||
|
fn render_call_tree(graph: &CallGraph, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Call Flow ─────────────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Static call graph rendered as a tree rooted at entry points.",
|
||||||
|
"Entry points are main() and #[test] functions.",
|
||||||
|
"Each indented line is a callee of the function above it.",
|
||||||
|
"├── / └── = tree branch connectors. ─┬ = node has children below.",
|
||||||
|
"⟲ (red) = cycle detected — this call creates a recursive loop.",
|
||||||
|
"… (dimmed) = already visited in this tree — prevents infinite expansion.",
|
||||||
|
"⋯ (depth limit) = tree truncated at maximum display depth (12 levels).",
|
||||||
|
"Yellow-highlighted names = functions that themselves have callees.",
|
||||||
|
]);
|
||||||
|
render::guide_ref("flow");
|
||||||
|
}
|
||||||
|
|
||||||
|
if graph.entry_points.is_empty() {
|
||||||
|
println!(" {}", "No entry points found.".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render from each entry point
|
||||||
|
for entry in &graph.entry_points {
|
||||||
|
println!();
|
||||||
|
println!(" {}", entry.bold().bright_white());
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
visited.insert(entry.clone());
|
||||||
|
render_subtree(graph, entry, " ", &mut visited, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively render a subtree of the call graph.
|
||||||
|
fn render_subtree(
|
||||||
|
graph: &CallGraph,
|
||||||
|
node: &str,
|
||||||
|
prefix: &str,
|
||||||
|
visited: &mut HashSet<String>,
|
||||||
|
depth: usize,
|
||||||
|
) {
|
||||||
|
const MAX_DEPTH: usize = 12;
|
||||||
|
|
||||||
|
let callees = match graph.adjacency.get(node) {
|
||||||
|
Some(c) if !c.is_empty() => c,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = callees.len();
|
||||||
|
for (i, callee) in callees.iter().enumerate() {
|
||||||
|
let is_last = i == count - 1;
|
||||||
|
let connector = if is_last { "└── " } else { "├── " };
|
||||||
|
let child_prefix = if is_last { " " } else { "│ " };
|
||||||
|
|
||||||
|
let is_cycle = graph
|
||||||
|
.cycle_edges
|
||||||
|
.contains(&(node.to_string(), callee.clone()));
|
||||||
|
let is_revisit = visited.contains(callee);
|
||||||
|
|
||||||
|
if is_cycle {
|
||||||
|
println!(
|
||||||
|
"{}{}{}",
|
||||||
|
prefix,
|
||||||
|
connector,
|
||||||
|
format!("{} ⟲ cycle", callee).red().bold(),
|
||||||
|
);
|
||||||
|
} else if is_revisit {
|
||||||
|
println!(
|
||||||
|
"{}{}{}",
|
||||||
|
prefix,
|
||||||
|
connector,
|
||||||
|
format!("{} …", callee).dimmed(),
|
||||||
|
);
|
||||||
|
} else if depth >= MAX_DEPTH {
|
||||||
|
println!(
|
||||||
|
"{}{}{}",
|
||||||
|
prefix,
|
||||||
|
connector,
|
||||||
|
format!("{} ⋯ (depth limit)", callee).dimmed(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let has_children = graph
|
||||||
|
.adjacency
|
||||||
|
.get(callee.as_str())
|
||||||
|
.map_or(false, |c| !c.is_empty());
|
||||||
|
|
||||||
|
if has_children {
|
||||||
|
println!(
|
||||||
|
"{}{}{}",
|
||||||
|
prefix,
|
||||||
|
connector.replace("── ", "─┬ "),
|
||||||
|
callee.bright_yellow(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("{}{}{}", prefix, connector, callee);
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.insert(callee.clone());
|
||||||
|
let new_prefix = format!("{}{}", prefix, child_prefix);
|
||||||
|
render_subtree(graph, callee, &new_prefix, visited, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render call graph statistics.
|
||||||
|
fn render_stats(graph: &CallGraph, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Call Graph Stats ──────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Summary statistics for the call graph.",
|
||||||
|
"Functions = total discovered. Call edges = total caller→callee links.",
|
||||||
|
"Entry points = main() and #[test] functions. Cycles (red) = back-edges detected.",
|
||||||
|
" Cycles = none (green) means the call graph is a DAG.",
|
||||||
|
"Fan-out (top callers): functions that call the most other functions.",
|
||||||
|
" High fan-out = orchestrator or god function that coordinates many pieces.",
|
||||||
|
" Bar (blue) length proportional to call count.",
|
||||||
|
"Hot paths (longest call chains): deepest call chain from each entry point.",
|
||||||
|
" Depth = number of function hops from entry to deepest leaf.",
|
||||||
|
" Color: red = depth > 6, yellow = depth > 3, white = shallow.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_fns = graph.functions.len();
|
||||||
|
let total_edges: usize = graph.adjacency.values().map(|v| v.len()).sum();
|
||||||
|
let cycle_count = graph.cycle_edges.len();
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" Functions: {}", format!("{}", total_fns).bold());
|
||||||
|
println!(" Call edges: {}", format!("{}", total_edges).bold());
|
||||||
|
println!(
|
||||||
|
" Entry points: {}",
|
||||||
|
format!("{}", graph.entry_points.len()).bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if cycle_count > 0 {
|
||||||
|
println!(
|
||||||
|
" Cycles: {}",
|
||||||
|
format!("{}", cycle_count).red().bold()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(" Cycles: {}", "none".green());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-out: functions that call the most others
|
||||||
|
let mut fan_out: Vec<(&String, usize)> =
|
||||||
|
graph.adjacency.iter().map(|(k, v)| (k, v.len())).collect();
|
||||||
|
fan_out.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
if !fan_out.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(" {} (top callers):", "Fan-out".bold().underline());
|
||||||
|
for (name, count) in fan_out.iter().take(8) {
|
||||||
|
let bar_len = (*count).min(30);
|
||||||
|
let bar = "█".repeat(bar_len);
|
||||||
|
println!(" {:<40} {} {}", name.dimmed(), bar.bright_blue(), count,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deepest call chains
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} (longest call chains):",
|
||||||
|
"Hot paths".bold().underline()
|
||||||
|
);
|
||||||
|
for entry in &graph.entry_points {
|
||||||
|
let depth = measure_max_depth(graph, entry, &mut HashSet::new());
|
||||||
|
let indicator = if depth > 6 {
|
||||||
|
format!("depth {}", depth).red().bold().to_string()
|
||||||
|
} else if depth > 3 {
|
||||||
|
format!("depth {}", depth).yellow().to_string()
|
||||||
|
} else {
|
||||||
|
format!("depth {}", depth).to_string()
|
||||||
|
};
|
||||||
|
println!(" {} → {}", entry.bright_white(), indicator);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure the maximum call depth from a given node.
|
||||||
|
fn measure_max_depth(graph: &CallGraph, node: &str, visited: &mut HashSet<String>) -> usize {
|
||||||
|
if visited.contains(node) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
visited.insert(node.to_string());
|
||||||
|
|
||||||
|
let max_child = graph
|
||||||
|
.adjacency
|
||||||
|
.get(node)
|
||||||
|
.map(|callees| {
|
||||||
|
callees
|
||||||
|
.iter()
|
||||||
|
.map(|c| measure_max_depth(graph, c, visited))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
visited.remove(node);
|
||||||
|
1 + max_child
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public entry point: render the flow subcommand.
|
||||||
|
pub fn render_flow(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
|
||||||
|
let graph = build_call_graph(rs_files, project_path);
|
||||||
|
|
||||||
|
if graph.functions.is_empty() {
|
||||||
|
println!("{}", "No functions found to analyze.".yellow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_call_tree(&graph, verbose);
|
||||||
|
render_stats(&graph, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CallEdgeJson {
|
||||||
|
caller: String,
|
||||||
|
callee: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FanOutJson {
|
||||||
|
function: String,
|
||||||
|
call_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct EntryDepthJson {
|
||||||
|
entry_point: String,
|
||||||
|
max_depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FlowJson {
|
||||||
|
cstat_version: String,
|
||||||
|
functions: Vec<String>,
|
||||||
|
call_edges: Vec<CallEdgeJson>,
|
||||||
|
entry_points: Vec<String>,
|
||||||
|
cycles: Vec<CallEdgeJson>,
|
||||||
|
max_depth: usize,
|
||||||
|
top_callers: Vec<FanOutJson>,
|
||||||
|
entry_depths: Vec<EntryDepthJson>,
|
||||||
|
cold_functions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render flow analysis as JSON.
|
||||||
|
pub fn render_flow_json(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
|
let graph = build_call_graph(rs_files, project_path);
|
||||||
|
|
||||||
|
let mut call_edges = Vec::new();
|
||||||
|
for (caller, callees) in &graph.adjacency {
|
||||||
|
for callee in callees {
|
||||||
|
call_edges.push(CallEdgeJson {
|
||||||
|
caller: caller.clone(),
|
||||||
|
callee: callee.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cycles: Vec<CallEdgeJson> = graph
|
||||||
|
.cycle_edges
|
||||||
|
.iter()
|
||||||
|
.map(|(a, b)| CallEdgeJson {
|
||||||
|
caller: a.clone(),
|
||||||
|
callee: b.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut fan_out: Vec<(&String, usize)> =
|
||||||
|
graph.adjacency.iter().map(|(k, v)| (k, v.len())).collect();
|
||||||
|
fan_out.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
let top_callers: Vec<FanOutJson> = fan_out
|
||||||
|
.iter()
|
||||||
|
.take(20)
|
||||||
|
.map(|(f, c)| FanOutJson {
|
||||||
|
function: (*f).clone(),
|
||||||
|
call_count: *c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let entry_depths: Vec<EntryDepthJson> = graph
|
||||||
|
.entry_points
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let depth = measure_max_depth(&graph, entry, &mut HashSet::new());
|
||||||
|
EntryDepthJson {
|
||||||
|
entry_point: entry.clone(),
|
||||||
|
max_depth: depth,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let max_depth = entry_depths.iter().map(|e| e.max_depth).max().unwrap_or(0);
|
||||||
|
|
||||||
|
// Cold functions
|
||||||
|
let reachable = {
|
||||||
|
let mut reachable = HashSet::new();
|
||||||
|
let mut queue: Vec<String> = graph.entry_points.clone();
|
||||||
|
while let Some(node) = queue.pop() {
|
||||||
|
if !reachable.insert(node.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(callees) = graph.adjacency.get(&node) {
|
||||||
|
for callee in callees {
|
||||||
|
if !reachable.contains(callee) {
|
||||||
|
queue.push(callee.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reachable
|
||||||
|
};
|
||||||
|
let cold_functions: Vec<String> = graph
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !reachable.contains(*f))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let output = FlowJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
functions: graph.functions,
|
||||||
|
call_edges,
|
||||||
|
entry_points: graph.entry_points,
|
||||||
|
cycles,
|
||||||
|
max_depth,
|
||||||
|
top_callers,
|
||||||
|
entry_depths,
|
||||||
|
cold_functions,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
411
src/graph/algorithms.rs
Normal file
411
src/graph/algorithms.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
|
// ── Generic directed graph ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A simple directed graph used for metric computation.
|
||||||
|
pub(super) struct DiGraph {
|
||||||
|
pub(super) nodes: Vec<String>,
|
||||||
|
pub(super) adj: HashMap<String, Vec<String>>, // forward edges
|
||||||
|
pub(super) rev_adj: HashMap<String, Vec<String>>, // reverse edges
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiGraph {
|
||||||
|
pub(super) fn from_edges(nodes: Vec<String>, edges: &[(String, String)]) -> Self {
|
||||||
|
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut rev_adj: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for n in &nodes {
|
||||||
|
adj.entry(n.clone()).or_default();
|
||||||
|
rev_adj.entry(n.clone()).or_default();
|
||||||
|
}
|
||||||
|
for (from, to) in edges {
|
||||||
|
adj.entry(from.clone()).or_default().push(to.clone());
|
||||||
|
rev_adj.entry(to.clone()).or_default().push(from.clone());
|
||||||
|
}
|
||||||
|
DiGraph {
|
||||||
|
nodes,
|
||||||
|
adj,
|
||||||
|
rev_adj,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn node_count(&self) -> usize {
|
||||||
|
self.nodes.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn edge_count(&self) -> usize {
|
||||||
|
self.adj.values().map(|v| v.len()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn density(&self) -> f64 {
|
||||||
|
let n = self.node_count();
|
||||||
|
if n <= 1 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.edge_count() as f64 / (n * (n - 1)) as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tarjan's SCC ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct TarjanState {
|
||||||
|
index_counter: usize,
|
||||||
|
stack: Vec<String>,
|
||||||
|
on_stack: HashSet<String>,
|
||||||
|
index: HashMap<String, usize>,
|
||||||
|
lowlink: HashMap<String, usize>,
|
||||||
|
sccs: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn tarjan_scc(graph: &DiGraph) -> Vec<Vec<String>> {
|
||||||
|
let mut state = TarjanState {
|
||||||
|
index_counter: 0,
|
||||||
|
stack: Vec::new(),
|
||||||
|
on_stack: HashSet::new(),
|
||||||
|
index: HashMap::new(),
|
||||||
|
lowlink: HashMap::new(),
|
||||||
|
sccs: Vec::new(),
|
||||||
|
};
|
||||||
|
for node in &graph.nodes {
|
||||||
|
if !state.index.contains_key(node) {
|
||||||
|
tarjan_strongconnect(node, graph, &mut state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.sccs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tarjan_strongconnect(v: &str, graph: &DiGraph, state: &mut TarjanState) {
|
||||||
|
state.index.insert(v.to_string(), state.index_counter);
|
||||||
|
state.lowlink.insert(v.to_string(), state.index_counter);
|
||||||
|
state.index_counter += 1;
|
||||||
|
state.stack.push(v.to_string());
|
||||||
|
state.on_stack.insert(v.to_string());
|
||||||
|
|
||||||
|
if let Some(neighbors) = graph.adj.get(v) {
|
||||||
|
for w in neighbors {
|
||||||
|
if !state.index.contains_key(w.as_str()) {
|
||||||
|
tarjan_strongconnect(w, graph, state);
|
||||||
|
let wll = state.lowlink[w.as_str()];
|
||||||
|
let vll = state.lowlink.get_mut(v).unwrap();
|
||||||
|
if wll < *vll {
|
||||||
|
*vll = wll;
|
||||||
|
}
|
||||||
|
} else if state.on_stack.contains(w.as_str()) {
|
||||||
|
let wi = state.index[w.as_str()];
|
||||||
|
let vll = state.lowlink.get_mut(v).unwrap();
|
||||||
|
if wi < *vll {
|
||||||
|
*vll = wi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.lowlink[v] == state.index[v] {
|
||||||
|
let mut component = Vec::new();
|
||||||
|
loop {
|
||||||
|
let w = state.stack.pop().unwrap();
|
||||||
|
state.on_stack.remove(&w);
|
||||||
|
component.push(w.clone());
|
||||||
|
if w == v {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.sccs.push(component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Brandes' betweenness centrality ────────────────────────────────
|
||||||
|
|
||||||
|
/// Result of BFS forward pass from a single source.
|
||||||
|
struct BfsResult {
|
||||||
|
stack: Vec<String>,
|
||||||
|
predecessors: HashMap<String, Vec<String>>,
|
||||||
|
sigma: HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BFS forward pass: compute shortest-path counts and predecessor lists from source `s`.
|
||||||
|
fn bfs_shortest_paths(s: &str, graph: &DiGraph) -> BfsResult {
|
||||||
|
let mut stack: Vec<String> = Vec::new();
|
||||||
|
let mut predecessors: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut sigma: HashMap<String, f64> = HashMap::new();
|
||||||
|
let mut dist: HashMap<String, i64> = HashMap::new();
|
||||||
|
|
||||||
|
for n in &graph.nodes {
|
||||||
|
predecessors.insert(n.clone(), Vec::new());
|
||||||
|
sigma.insert(n.clone(), 0.0);
|
||||||
|
dist.insert(n.clone(), -1);
|
||||||
|
}
|
||||||
|
*sigma.get_mut(s).unwrap() = 1.0;
|
||||||
|
*dist.get_mut(s).unwrap() = 0;
|
||||||
|
|
||||||
|
let mut queue: VecDeque<String> = VecDeque::new();
|
||||||
|
queue.push_back(s.to_string());
|
||||||
|
|
||||||
|
while let Some(v) = queue.pop_front() {
|
||||||
|
stack.push(v.clone());
|
||||||
|
let d_v = dist[&v];
|
||||||
|
if let Some(neighbors) = graph.adj.get(&v) {
|
||||||
|
for w in neighbors {
|
||||||
|
if dist[w] < 0 {
|
||||||
|
*dist.get_mut(w).unwrap() = d_v + 1;
|
||||||
|
queue.push_back(w.clone());
|
||||||
|
}
|
||||||
|
if dist[w] == d_v + 1 {
|
||||||
|
*sigma.get_mut(w).unwrap() += sigma[&v];
|
||||||
|
predecessors.get_mut(w).unwrap().push(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BfsResult {
|
||||||
|
stack,
|
||||||
|
predecessors,
|
||||||
|
sigma,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-propagation: accumulate betweenness from dependency scores.
|
||||||
|
fn accumulate_betweenness(
|
||||||
|
mut stack: Vec<String>,
|
||||||
|
predecessors: &HashMap<String, Vec<String>>,
|
||||||
|
sigma: &HashMap<String, f64>,
|
||||||
|
s: &str,
|
||||||
|
cb: &mut HashMap<String, f64>,
|
||||||
|
) {
|
||||||
|
let mut delta: HashMap<String, f64> = HashMap::new();
|
||||||
|
for n in sigma.keys() {
|
||||||
|
delta.insert(n.clone(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(w) = stack.pop() {
|
||||||
|
for v in &predecessors[&w] {
|
||||||
|
let d = sigma[v] / sigma[&w] * (1.0 + delta[&w]);
|
||||||
|
*delta.get_mut(v).unwrap() += d;
|
||||||
|
}
|
||||||
|
if w != s {
|
||||||
|
*cb.get_mut(&w).unwrap() += delta[&w];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn betweenness_centrality(graph: &DiGraph) -> HashMap<String, f64> {
|
||||||
|
let mut cb: HashMap<String, f64> = HashMap::new();
|
||||||
|
for n in &graph.nodes {
|
||||||
|
cb.insert(n.clone(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in &graph.nodes {
|
||||||
|
let bfs = bfs_shortest_paths(s, graph);
|
||||||
|
accumulate_betweenness(bfs.stack, &bfs.predecessors, &bfs.sigma, s, &mut cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
let n = graph.node_count() as f64;
|
||||||
|
if n > 2.0 {
|
||||||
|
let norm = (n - 1.0) * (n - 2.0);
|
||||||
|
for v in cb.values_mut() {
|
||||||
|
*v /= norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cb
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PageRank ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(super) fn pagerank(graph: &DiGraph, damping: f64, iterations: usize) -> HashMap<String, f64> {
|
||||||
|
let n = graph.node_count();
|
||||||
|
if n == 0 {
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
let initial = 1.0 / n as f64;
|
||||||
|
let mut rank: HashMap<String, f64> = graph.nodes.iter().map(|n| (n.clone(), initial)).collect();
|
||||||
|
|
||||||
|
for _ in 0..iterations {
|
||||||
|
let mut new_rank: HashMap<String, f64> = HashMap::new();
|
||||||
|
let mut dangling_sum = 0.0;
|
||||||
|
|
||||||
|
for node in &graph.nodes {
|
||||||
|
let out_deg = graph.adj.get(node).map_or(0, |v| v.len());
|
||||||
|
if out_deg == 0 {
|
||||||
|
dangling_sum += rank[node];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in &graph.nodes {
|
||||||
|
let mut incoming_sum = 0.0;
|
||||||
|
if let Some(preds) = graph.rev_adj.get(node) {
|
||||||
|
for pred in preds {
|
||||||
|
let out_deg = graph.adj.get(pred).map_or(1, |v| v.len().max(1));
|
||||||
|
incoming_sum += rank[pred] / out_deg as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pr =
|
||||||
|
(1.0 - damping) / n as f64 + damping * (incoming_sum + dangling_sum / n as f64);
|
||||||
|
new_rank.insert(node.clone(), pr);
|
||||||
|
}
|
||||||
|
rank = new_rank;
|
||||||
|
}
|
||||||
|
rank
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clustering coefficient ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Count directed links that exist between members of `neighbors` in the adjacency map.
|
||||||
|
fn count_neighbor_links(neighbors: &[&str], adj: &HashMap<String, Vec<String>>) -> usize {
|
||||||
|
let mut links = 0usize;
|
||||||
|
for i in 0..neighbors.len() {
|
||||||
|
for j in 0..neighbors.len() {
|
||||||
|
if i == j {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(succs) = adj.get(neighbors[i]) {
|
||||||
|
if succs.iter().any(|x| x == neighbors[j]) {
|
||||||
|
links += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
links
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn clustering_coefficient(graph: &DiGraph) -> HashMap<String, f64> {
|
||||||
|
let mut cc: HashMap<String, f64> = HashMap::new();
|
||||||
|
|
||||||
|
for node in &graph.nodes {
|
||||||
|
// Neighbors = union of successors and predecessors
|
||||||
|
let mut neighbors: HashSet<&str> = HashSet::new();
|
||||||
|
if let Some(succs) = graph.adj.get(node) {
|
||||||
|
for s in succs {
|
||||||
|
neighbors.insert(s.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(preds) = graph.rev_adj.get(node) {
|
||||||
|
for p in preds {
|
||||||
|
neighbors.insert(p.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
neighbors.remove(node.as_str());
|
||||||
|
|
||||||
|
let k = neighbors.len();
|
||||||
|
if k < 2 {
|
||||||
|
cc.insert(node.clone(), 0.0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighbor_vec: Vec<&str> = neighbors.iter().copied().collect();
|
||||||
|
let links = count_neighbor_links(&neighbor_vec, &graph.adj);
|
||||||
|
|
||||||
|
let possible = k * (k - 1); // directed pairs
|
||||||
|
cc.insert(node.clone(), links as f64 / possible as f64);
|
||||||
|
}
|
||||||
|
cc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Graph-level stats ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(super) struct GraphStats {
|
||||||
|
pub(super) density: f64,
|
||||||
|
pub(super) diameter: Option<usize>,
|
||||||
|
pub(super) avg_path_length: f64,
|
||||||
|
pub(super) component_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn graph_level_stats(graph: &DiGraph) -> GraphStats {
|
||||||
|
let density = graph.density();
|
||||||
|
|
||||||
|
let (diameter, avg_path_length) = if graph.node_count() <= 2000 {
|
||||||
|
compute_distances(graph)
|
||||||
|
} else {
|
||||||
|
(None, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let component_count = count_weak_components(graph);
|
||||||
|
|
||||||
|
GraphStats {
|
||||||
|
density,
|
||||||
|
diameter,
|
||||||
|
avg_path_length,
|
||||||
|
component_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_distances(graph: &DiGraph) -> (Option<usize>, f64) {
|
||||||
|
let mut max_dist = 0usize;
|
||||||
|
let mut total_dist = 0u64;
|
||||||
|
let mut pair_count = 0u64;
|
||||||
|
|
||||||
|
for s in &graph.nodes {
|
||||||
|
let mut dist: HashMap<&str, usize> = HashMap::new();
|
||||||
|
dist.insert(s.as_str(), 0);
|
||||||
|
let mut queue: VecDeque<&str> = VecDeque::new();
|
||||||
|
queue.push_back(s.as_str());
|
||||||
|
|
||||||
|
while let Some(v) = queue.pop_front() {
|
||||||
|
let d = dist[v];
|
||||||
|
if let Some(neighbors) = graph.adj.get(v) {
|
||||||
|
for w in neighbors {
|
||||||
|
if !dist.contains_key(w.as_str()) {
|
||||||
|
let nd = d + 1;
|
||||||
|
dist.insert(w.as_str(), nd);
|
||||||
|
queue.push_back(w.as_str());
|
||||||
|
if nd > max_dist {
|
||||||
|
max_dist = nd;
|
||||||
|
}
|
||||||
|
total_dist += nd as u64;
|
||||||
|
pair_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let diameter = if pair_count > 0 { Some(max_dist) } else { None };
|
||||||
|
let avg = if pair_count > 0 {
|
||||||
|
total_dist as f64 / pair_count as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
(diameter, avg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BFS traversal for one weak component starting at `start`.
|
||||||
|
/// Visits all nodes reachable via forward or reverse edges, marking them in `visited`.
|
||||||
|
fn bfs_weak_component<'a>(start: &'a str, graph: &'a DiGraph, visited: &mut HashSet<&'a str>) {
|
||||||
|
let mut queue: VecDeque<&str> = VecDeque::new();
|
||||||
|
queue.push_back(start);
|
||||||
|
visited.insert(start);
|
||||||
|
|
||||||
|
while let Some(v) = queue.pop_front() {
|
||||||
|
if let Some(neighbors) = graph.adj.get(v) {
|
||||||
|
for w in neighbors {
|
||||||
|
if !visited.contains(w.as_str()) {
|
||||||
|
visited.insert(w.as_str());
|
||||||
|
queue.push_back(w.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(neighbors) = graph.rev_adj.get(v) {
|
||||||
|
for w in neighbors {
|
||||||
|
if !visited.contains(w.as_str()) {
|
||||||
|
visited.insert(w.as_str());
|
||||||
|
queue.push_back(w.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_weak_components(graph: &DiGraph) -> usize {
|
||||||
|
let mut visited: HashSet<&str> = HashSet::new();
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
for node in &graph.nodes {
|
||||||
|
if visited.contains(node.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
bfs_weak_component(node.as_str(), graph, &mut visited);
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
511
src/graph/mod.rs
Normal file
511
src/graph/mod.rs
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
mod algorithms;
|
||||||
|
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::deps;
|
||||||
|
use crate::flow;
|
||||||
|
use crate::render;
|
||||||
|
use algorithms::{
|
||||||
|
betweenness_centrality, clustering_coefficient, graph_level_stats, pagerank, tarjan_scc,
|
||||||
|
DiGraph, GraphStats,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Full analysis result ───────────────────────────────────────────
|
||||||
|
|
||||||
|
struct NodeMetrics {
|
||||||
|
betweenness: f64,
|
||||||
|
pagerank: f64,
|
||||||
|
clustering: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GraphAnalysis {
|
||||||
|
label: String,
|
||||||
|
node_count: usize,
|
||||||
|
edge_count: usize,
|
||||||
|
sccs: Vec<Vec<String>>,
|
||||||
|
node_metrics: HashMap<String, NodeMetrics>,
|
||||||
|
stats: GraphStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze_graph(graph: &DiGraph, label: &str) -> GraphAnalysis {
|
||||||
|
let sccs = tarjan_scc(graph);
|
||||||
|
let bc = betweenness_centrality(graph);
|
||||||
|
let pr = pagerank(graph, 0.85, 100);
|
||||||
|
let cc = clustering_coefficient(graph);
|
||||||
|
let stats = graph_level_stats(graph);
|
||||||
|
|
||||||
|
let mut node_metrics: HashMap<String, NodeMetrics> = HashMap::new();
|
||||||
|
for node in &graph.nodes {
|
||||||
|
node_metrics.insert(
|
||||||
|
node.clone(),
|
||||||
|
NodeMetrics {
|
||||||
|
betweenness: bc.get(node).copied().unwrap_or(0.0),
|
||||||
|
pagerank: pr.get(node).copied().unwrap_or(0.0),
|
||||||
|
clustering: cc.get(node).copied().unwrap_or(0.0),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphAnalysis {
|
||||||
|
label: label.to_string(),
|
||||||
|
node_count: graph.node_count(),
|
||||||
|
edge_count: graph.edge_count(),
|
||||||
|
sccs,
|
||||||
|
node_metrics,
|
||||||
|
stats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build DiGraph from call graph / dep graph ──────────────────────
|
||||||
|
|
||||||
|
fn digraph_from_call_graph(cg: &flow::CallGraph) -> DiGraph {
|
||||||
|
let nodes = cg.functions.clone();
|
||||||
|
let mut edges = Vec::new();
|
||||||
|
for (caller, callees) in &cg.adjacency {
|
||||||
|
for callee in callees {
|
||||||
|
edges.push((caller.clone(), callee.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DiGraph::from_edges(nodes, &edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digraph_from_dep_analysis(da: &deps::DepAnalysis) -> DiGraph {
|
||||||
|
let nodes = da.modules.clone();
|
||||||
|
let edges: Vec<(String, String)> = da
|
||||||
|
.edges
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.from.clone(), e.to.clone()))
|
||||||
|
.collect();
|
||||||
|
DiGraph::from_edges(nodes, &edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_analysis(analysis: &GraphAnalysis, top_n: usize, verbose: bool) {
|
||||||
|
let term_w = render::terminal_width();
|
||||||
|
|
||||||
|
let header = format!("── {} ─", analysis.label);
|
||||||
|
let padded = format!("{:─<width$}", header, width = 56);
|
||||||
|
println!("\n{}", padded.bright_cyan().bold());
|
||||||
|
|
||||||
|
render_stats_box(analysis, verbose);
|
||||||
|
render_sccs(analysis, verbose);
|
||||||
|
render_node_table(analysis, top_n, term_w, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stats_box(analysis: &GraphAnalysis, verbose: bool) {
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Graph-level statistics for the overall structure:",
|
||||||
|
" Nodes = number of functions (or modules for dependency graph).",
|
||||||
|
" Edges = total directed connections between nodes.",
|
||||||
|
" Density = edges / (nodes × (nodes - 1)). Ranges 0 to 1.",
|
||||||
|
" Low density = sparse graph; high density = many connections.",
|
||||||
|
" Components (weak) = weakly connected subgraphs (ignoring edge direction).",
|
||||||
|
" >1 component = some nodes are completely isolated from others.",
|
||||||
|
" Diameter = longest shortest path between any two reachable nodes.",
|
||||||
|
" Avg path length = mean shortest path distance across all reachable pairs.",
|
||||||
|
]);
|
||||||
|
render::guide_ref("architecture");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" ┌──────────────────────────────────────┐");
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Nodes".cyan(),
|
||||||
|
format!("{}", analysis.node_count).bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Edges".cyan(),
|
||||||
|
format!("{}", analysis.edge_count).bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Density".cyan(),
|
||||||
|
format!("{:.4}", analysis.stats.density).bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Components (weak)".cyan(),
|
||||||
|
format!("{}", analysis.stats.component_count).bold()
|
||||||
|
);
|
||||||
|
if let Some(d) = analysis.stats.diameter {
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Diameter".cyan(),
|
||||||
|
format!("{}", d).bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if analysis.stats.avg_path_length > 0.0 {
|
||||||
|
println!(
|
||||||
|
" │ {:<18} {:>17} │",
|
||||||
|
"Avg path length".cyan(),
|
||||||
|
format!("{:.2}", analysis.stats.avg_path_length).bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(" └──────────────────────────────────────┘");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sccs(analysis: &GraphAnalysis, verbose: bool) {
|
||||||
|
let cyclic_sccs: Vec<&Vec<String>> = analysis.sccs.iter().filter(|c| c.len() > 1).collect();
|
||||||
|
|
||||||
|
println!("\n {}", "Strongly Connected Components".bold().underline());
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"SCCs found by Tarjan's algorithm: groups of mutually reachable nodes (cycles).",
|
||||||
|
"Every node in an SCC can reach every other node in the same SCC.",
|
||||||
|
"A DAG (directed acyclic graph) has no cyclic SCCs — all components are singletons.",
|
||||||
|
"Cyclic clusters indicate circular dependencies that may complicate refactoring.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cyclic_sccs.is_empty() {
|
||||||
|
println!(" {}", "No cyclic clusters found (DAG).".green());
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} cyclic cluster{}:",
|
||||||
|
format!("{}", cyclic_sccs.len()).red().bold(),
|
||||||
|
if cyclic_sccs.len() == 1 { "" } else { "s" }
|
||||||
|
);
|
||||||
|
for (i, scc) in cyclic_sccs.iter().enumerate() {
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} (size {})",
|
||||||
|
format!("Cluster #{}", i + 1).yellow().bold(),
|
||||||
|
scc.len()
|
||||||
|
);
|
||||||
|
for member in *scc {
|
||||||
|
println!(" {}", member.dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one metric section: heading, optional verbose lines, sorted bar chart.
|
||||||
|
fn render_metric_section(
|
||||||
|
label: &str,
|
||||||
|
sorted_items: &[(&String, f64)],
|
||||||
|
bar_budget: usize,
|
||||||
|
top_n: usize,
|
||||||
|
verbose: bool,
|
||||||
|
verbose_lines: &[&str],
|
||||||
|
) {
|
||||||
|
println!("\n {}", label.bold().underline());
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(verbose_lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
if sorted_items.is_empty() {
|
||||||
|
println!(" {}", "No clustering detected.".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_val = sorted_items
|
||||||
|
.first()
|
||||||
|
.map(|(_, v)| *v)
|
||||||
|
.unwrap_or(1.0)
|
||||||
|
.max(f64::MIN_POSITIVE);
|
||||||
|
|
||||||
|
for (name, val) in sorted_items.iter().take(top_n) {
|
||||||
|
if *val < f64::EPSILON {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bar_len = ((*val / max_val) * bar_budget as f64).ceil() as usize;
|
||||||
|
let bar = "█".repeat(bar_len.max(1));
|
||||||
|
let ratio = (*val / max_val).min(1.0);
|
||||||
|
let colored_bar = render::bar_color(&bar, 1.0 - ratio);
|
||||||
|
println!(" {:<40} {} {:.4}", name.dimmed(), colored_bar, val,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_node_table(analysis: &GraphAnalysis, top_n: usize, term_w: usize, verbose: bool) {
|
||||||
|
if analysis.node_metrics.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bar_budget = 20.min(term_w.saturating_sub(70));
|
||||||
|
|
||||||
|
// Betweenness centrality
|
||||||
|
let mut bc_sorted: Vec<(&String, f64)> = analysis
|
||||||
|
.node_metrics
|
||||||
|
.iter()
|
||||||
|
.map(|(n, m)| (n, m.betweenness))
|
||||||
|
.collect();
|
||||||
|
bc_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
render_metric_section(
|
||||||
|
"Betweenness Centrality (bridges/bottlenecks)",
|
||||||
|
&bc_sorted,
|
||||||
|
bar_budget,
|
||||||
|
top_n,
|
||||||
|
verbose,
|
||||||
|
&[
|
||||||
|
"Betweenness centrality (Brandes algorithm): for each node, the fraction of",
|
||||||
|
"all shortest paths in the graph that pass through it.",
|
||||||
|
"Scale: 0.0 = no shortest paths traverse this node; 1.0 = all paths do.",
|
||||||
|
"High bc = bridge/bottleneck — removing this node would disconnect or lengthen",
|
||||||
|
"paths between other parts of the graph. Normalized by (n-1)(n-2).",
|
||||||
|
"Bar length proportional to highest bc value. Color: red = top, green = low.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// PageRank
|
||||||
|
let mut pr_sorted: Vec<(&String, f64)> = analysis
|
||||||
|
.node_metrics
|
||||||
|
.iter()
|
||||||
|
.map(|(n, m)| (n, m.pagerank))
|
||||||
|
.collect();
|
||||||
|
pr_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
render_metric_section(
|
||||||
|
"PageRank (recursive importance)",
|
||||||
|
&pr_sorted,
|
||||||
|
bar_budget,
|
||||||
|
top_n,
|
||||||
|
verbose,
|
||||||
|
&[
|
||||||
|
"PageRank: iterative importance score. A node is important if important nodes",
|
||||||
|
"point to it. Damping factor = 0.85, 100 iterations.",
|
||||||
|
"Dangling nodes (no outgoing edges) distribute their rank uniformly.",
|
||||||
|
"Higher value = more central/important in the call graph.",
|
||||||
|
"All PageRank values sum to 1.0 across the entire graph.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clustering coefficient
|
||||||
|
let mut cc_sorted: Vec<(&String, f64)> = analysis
|
||||||
|
.node_metrics
|
||||||
|
.iter()
|
||||||
|
.map(|(n, m)| (n, m.clustering))
|
||||||
|
.filter(|(_, v)| *v > f64::EPSILON)
|
||||||
|
.collect();
|
||||||
|
cc_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
render_metric_section(
|
||||||
|
"Clustering Coefficient (neighborhood density)",
|
||||||
|
&cc_sorted,
|
||||||
|
bar_budget,
|
||||||
|
top_n,
|
||||||
|
verbose,
|
||||||
|
&[
|
||||||
|
"Clustering coefficient: for each node, the fraction of its neighbors that",
|
||||||
|
"are also connected to each other. Neighbors = union of successors and predecessors.",
|
||||||
|
"Scale: 0.0 = none of the node's neighbors know each other; 1.0 = complete clique.",
|
||||||
|
"High cc = tight neighborhood cluster. Low cc = broker between different groups.",
|
||||||
|
"Only nodes with cc > 0 are shown.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public entry point ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Compact summary data for embedding in the dashboard.
|
||||||
|
pub struct GraphSummary {
|
||||||
|
/// Node with highest betweenness centrality (bridge).
|
||||||
|
pub top_bridge: Option<(String, f64)>,
|
||||||
|
/// Node with highest PageRank (hub).
|
||||||
|
pub top_hub: Option<(String, f64)>,
|
||||||
|
/// Node with highest clustering coefficient.
|
||||||
|
pub top_clustered: Option<(String, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute compact graph summary from the call graph for dashboard use.
|
||||||
|
pub fn graph_summary(rs_files: &[PathBuf], project_path: &Path) -> Option<GraphSummary> {
|
||||||
|
let cg = flow::build_call_graph(rs_files, project_path);
|
||||||
|
if cg.functions.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dg = digraph_from_call_graph(&cg);
|
||||||
|
let analysis = analyze_graph(&dg, "");
|
||||||
|
|
||||||
|
let top_bridge = analysis
|
||||||
|
.node_metrics
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, m)| m.betweenness > f64::EPSILON)
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.1.betweenness
|
||||||
|
.partial_cmp(&b.1.betweenness)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.map(|(n, m)| (n.clone(), m.betweenness));
|
||||||
|
|
||||||
|
let top_hub = analysis
|
||||||
|
.node_metrics
|
||||||
|
.iter()
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.1.pagerank
|
||||||
|
.partial_cmp(&b.1.pagerank)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.map(|(n, m)| (n.clone(), m.pagerank));
|
||||||
|
|
||||||
|
let top_clustered = analysis
|
||||||
|
.node_metrics
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, m)| m.clustering > f64::EPSILON)
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.1.clustering
|
||||||
|
.partial_cmp(&b.1.clustering)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.map(|(n, m)| (n.clone(), m.clustering));
|
||||||
|
|
||||||
|
Some(GraphSummary {
|
||||||
|
top_bridge,
|
||||||
|
top_hub,
|
||||||
|
top_clustered,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_graph(
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
call_only: bool,
|
||||||
|
dep_only: bool,
|
||||||
|
top_n: usize,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
if !dep_only {
|
||||||
|
let cg = flow::build_call_graph(rs_files, project_path);
|
||||||
|
if cg.functions.is_empty() {
|
||||||
|
println!("{}", "No functions found for call graph analysis.".yellow());
|
||||||
|
} else {
|
||||||
|
let dg = digraph_from_call_graph(&cg);
|
||||||
|
let analysis = analyze_graph(&dg, "Call Graph Analysis");
|
||||||
|
render_analysis(&analysis, top_n, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !call_only {
|
||||||
|
let da = deps::analyze_deps(rs_files, project_path);
|
||||||
|
if da.modules.is_empty() {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
"No modules found for dependency graph analysis.".yellow()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let dg = digraph_from_dep_analysis(&da);
|
||||||
|
let analysis = analyze_graph(&dg, "Module Dependency Graph Analysis");
|
||||||
|
render_analysis(&analysis, top_n, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON Output ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct NodeMetricsJson {
|
||||||
|
node: String,
|
||||||
|
betweenness: f64,
|
||||||
|
pagerank: f64,
|
||||||
|
clustering: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphStatsJson {
|
||||||
|
density: f64,
|
||||||
|
diameter: Option<usize>,
|
||||||
|
avg_path_length: f64,
|
||||||
|
component_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphAnalysisJson {
|
||||||
|
label: String,
|
||||||
|
node_count: usize,
|
||||||
|
edge_count: usize,
|
||||||
|
sccs: Vec<Vec<String>>,
|
||||||
|
node_metrics: Vec<NodeMetricsJson>,
|
||||||
|
stats: GraphStatsJson,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GraphOutputJson {
|
||||||
|
cstat_version: String,
|
||||||
|
call_graph: Option<GraphAnalysisJson>,
|
||||||
|
dep_graph: Option<GraphAnalysisJson>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analysis_to_json(analysis: &GraphAnalysis) -> GraphAnalysisJson {
|
||||||
|
let mut node_metrics: Vec<NodeMetricsJson> = analysis
|
||||||
|
.node_metrics
|
||||||
|
.iter()
|
||||||
|
.map(|(n, m)| NodeMetricsJson {
|
||||||
|
node: n.clone(),
|
||||||
|
betweenness: m.betweenness,
|
||||||
|
pagerank: m.pagerank,
|
||||||
|
clustering: m.clustering,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
node_metrics.sort_by(|a, b| {
|
||||||
|
b.betweenness
|
||||||
|
.partial_cmp(&a.betweenness)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
GraphAnalysisJson {
|
||||||
|
label: analysis.label.clone(),
|
||||||
|
node_count: analysis.node_count,
|
||||||
|
edge_count: analysis.edge_count,
|
||||||
|
sccs: analysis.sccs.clone(),
|
||||||
|
node_metrics,
|
||||||
|
stats: GraphStatsJson {
|
||||||
|
density: analysis.stats.density,
|
||||||
|
diameter: analysis.stats.diameter,
|
||||||
|
avg_path_length: analysis.stats.avg_path_length,
|
||||||
|
component_count: analysis.stats.component_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render graph analysis as JSON.
|
||||||
|
pub fn render_graph_json(
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
call_only: bool,
|
||||||
|
dep_only: bool,
|
||||||
|
) {
|
||||||
|
let call_graph_json = if !dep_only {
|
||||||
|
let cg = flow::build_call_graph(rs_files, project_path);
|
||||||
|
if cg.functions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let dg = digraph_from_call_graph(&cg);
|
||||||
|
let analysis = analyze_graph(&dg, "Call Graph Analysis");
|
||||||
|
Some(analysis_to_json(&analysis))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let dep_graph_json = if !call_only {
|
||||||
|
let da = deps::analyze_deps(rs_files, project_path);
|
||||||
|
if da.modules.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let dg = digraph_from_dep_analysis(&da);
|
||||||
|
let analysis = analyze_graph(&dg, "Module Dependency Graph Analysis");
|
||||||
|
Some(analysis_to_json(&analysis))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = GraphOutputJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
call_graph: call_graph_json,
|
||||||
|
dep_graph: dep_graph_json,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
467
src/guide.rs
Normal file
467
src/guide.rs
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
// ── Topic definitions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const TOPICS: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"getting-started",
|
||||||
|
"First steps: reading your codebase with cstat",
|
||||||
|
),
|
||||||
|
("size", "File and function size, LoC distribution"),
|
||||||
|
(
|
||||||
|
"complexity",
|
||||||
|
"Cyclomatic, cognitive, nesting, parameter metrics",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modularity",
|
||||||
|
"Coupling, cohesion, dependency structure, circuits",
|
||||||
|
),
|
||||||
|
("flow", "Call graph, reachability, dead code, hot paths"),
|
||||||
|
("redundancy", "Similar and duplicate function detection"),
|
||||||
|
(
|
||||||
|
"architecture",
|
||||||
|
"Centrality, SCCs, hubs, tiers, graph topology",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
struct TopicContent {
|
||||||
|
description: &'static str,
|
||||||
|
metrics: &'static [(&'static str, &'static str)],
|
||||||
|
commands: &'static [&'static str],
|
||||||
|
patterns: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_getting_started() -> TopicContent {
|
||||||
|
TopicContent {
|
||||||
|
description: "\
|
||||||
|
cstat analyzes Rust codebases along several dimensions — size, complexity, \
|
||||||
|
dependency structure, call flow, and duplication — then surfaces observations \
|
||||||
|
about what it finds. Start with `cstat summary` for a human-readable dashboard, \
|
||||||
|
or `cstat dump` for a machine-readable JSON diagnostic report.",
|
||||||
|
metrics: &[
|
||||||
|
("summary dashboard", "Compact overview of all dimensions with key stats"),
|
||||||
|
("dump report", "Full JSON diagnostic with health scores, observations, hotspots, and topology"),
|
||||||
|
("health scores", "Normalized 0.0–1.0 scores for modularity, complexity, and maintainability (higher = healthier)"),
|
||||||
|
],
|
||||||
|
commands: &[
|
||||||
|
"cstat summary — human-readable dashboard (default command)",
|
||||||
|
"cstat summary -v — dashboard with educational explanations",
|
||||||
|
"cstat dump — machine-readable JSON diagnostic report",
|
||||||
|
"cstat <command> --json — structured JSON output for any command",
|
||||||
|
],
|
||||||
|
patterns: &[
|
||||||
|
"Human workflow: run summary first, read the dashboard, drill into specific \
|
||||||
|
dimensions with loc/branching/signature/span/deps/flow/graph as needed.",
|
||||||
|
"Agent workflow: run dump for the full diagnostic picture, read scores to \
|
||||||
|
identify weak dimensions, use individual commands with --json to explore specifics.",
|
||||||
|
"The -v (verbose) flag adds contextual explanations to any command — useful \
|
||||||
|
when learning what the metrics mean.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_size() -> TopicContent {
|
||||||
|
TopicContent {
|
||||||
|
description: "\
|
||||||
|
Size metrics measure the volume and distribution of Rust source. `cstat loc` owns the line-count contract; run `cstat loc --explain` for modes, code_lines rules, and JSON fields.",
|
||||||
|
metrics: &[
|
||||||
|
(
|
||||||
|
"file size shape",
|
||||||
|
"Project-level file-size distribution from `cstat loc --path .`.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"selected-file line reachability",
|
||||||
|
"Projected static reachable/unreachable production spans from `cstat loc --path <file.rs>`.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"distribution statistics",
|
||||||
|
"Mean, median, std_dev, min, and max summarize project file sizes.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
commands: &[
|
||||||
|
"cstat loc --explain — loc modes, code_lines rules, and JSON field contract",
|
||||||
|
"cstat loc --path . — project size-shape report",
|
||||||
|
"cstat loc --path src/lib.rs — selected-file projected line reachability",
|
||||||
|
"cstat dist --metric loc — histogram and outlier analysis of LoC distribution",
|
||||||
|
],
|
||||||
|
patterns: &[
|
||||||
|
"Use `cstat loc --explain` as the canonical reference before consuming loc output.",
|
||||||
|
"A right-skewed project-mode code_lines distribution means a few files dominate size.",
|
||||||
|
"Selected-file loc is a static projection from function spans and test/benchmark reachability; do not read it as runtime coverage.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_complexity() -> TopicContent {
|
||||||
|
TopicContent {
|
||||||
|
description: "\
|
||||||
|
Complexity metrics are split by question: branching for decision/path shape, \
|
||||||
|
signature for API-boundary difficulty, and span for function-local implementation \
|
||||||
|
size. No single metric captures the full picture — cyclomatic counts paths, \
|
||||||
|
cognitive models reading difficulty in diagnostics/distributions, nesting measures \
|
||||||
|
structural depth, and parameter count reflects interface width.",
|
||||||
|
metrics: &[
|
||||||
|
("cyclomatic complexity", "Number of linearly independent paths through a function (branch_points + 1). Higher values mean more test cases needed for full coverage."),
|
||||||
|
("cognitive complexity", "SonarSource model: +1 per control flow break, +nesting_level penalty. Used by diagnostics/distributions, not exposed as a top-level probe."),
|
||||||
|
("nesting depth", "Maximum depth of nested control structures. Deep nesting forces readers to hold more context in working memory."),
|
||||||
|
("parameter count", "Number of function parameters. Wide interfaces are harder to call correctly and may indicate a function doing too much."),
|
||||||
|
("function span", "Inclusive function-local line range and top-level body statement count. This complements file/project LOC."),
|
||||||
|
],
|
||||||
|
commands: &[
|
||||||
|
"cstat branching — decision/path complexity by function",
|
||||||
|
"cstat signature — API-boundary complexity by function and file",
|
||||||
|
"cstat span — function-local implementation size and line ranges",
|
||||||
|
"cstat advanced dist --metric cyclomatic — distribution and outlier analysis",
|
||||||
|
],
|
||||||
|
patterns: &[
|
||||||
|
"High cyclomatic but low cognitive complexity usually means straightforward \
|
||||||
|
branching (e.g. match statements with simple arms). High cognitive with \
|
||||||
|
moderate cyclomatic often means nested conditionals.",
|
||||||
|
"Functions with nesting depth > 4 are nearly always worth refactoring — \
|
||||||
|
extract inner blocks into helper functions to flatten the structure.",
|
||||||
|
"Use branching, signature, and span together: a function can be easy to call \
|
||||||
|
but branch-heavy, short but hard to satisfy generically, or long without many decisions.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_modularity() -> TopicContent {
|
||||||
|
TopicContent {
|
||||||
|
description: "\
|
||||||
|
Modularity metrics describe the dependency structure between modules — how \
|
||||||
|
tightly they are coupled to each other and how cohesive each module is \
|
||||||
|
internally. Good modularity means changes tend to stay local rather than \
|
||||||
|
rippling across the codebase.",
|
||||||
|
metrics: &[
|
||||||
|
("fan-in", "Number of modules that depend on this module. High fan-in modules are foundational — changes to them have wide impact."),
|
||||||
|
("fan-out", "Number of modules this module depends on. High fan-out may indicate a module that coordinates too many concerns."),
|
||||||
|
("bidirectional coupling", "Module pairs that depend on each other. Often a sign that the modules should be merged or the interface redesigned."),
|
||||||
|
("call cohesion", "Fraction of a module's functions that call at least one other function in the same module. Low values suggest the module groups unrelated functionality."),
|
||||||
|
("type cohesion", "Fraction of functions sharing parameter or return types with siblings. Measures data-level relatedness."),
|
||||||
|
("modularity Q", "Graph-theoretic partition quality from community detection. Higher values indicate well-separated clusters."),
|
||||||
|
],
|
||||||
|
commands: &[
|
||||||
|
"cstat deps — module dependency graph with coupling and cohesion",
|
||||||
|
"cstat deps --json — structured dependency and cohesion data",
|
||||||
|
"cstat circuits — community detection showing functional clusters",
|
||||||
|
"cstat circuits --json — structured circuit membership data",
|
||||||
|
],
|
||||||
|
patterns: &[
|
||||||
|
"Bidirectional coupling pairs are the highest-priority modularity issue. \
|
||||||
|
They create change amplification — modifying either module risks breaking \
|
||||||
|
the other.",
|
||||||
|
"A module with high fan-in AND high fan-out is a potential god module — it \
|
||||||
|
both serves many consumers and depends on many providers.",
|
||||||
|
"Low cohesion (< 0.3) combined with large file size strongly suggests the \
|
||||||
|
module bundles unrelated concerns. Check if its functions form distinct \
|
||||||
|
clusters using `cstat circuits`.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_flow() -> TopicContent {
|
||||||
|
TopicContent {
|
||||||
|
description: "\
|
||||||
|
Flow analysis maps the call graph — which functions call which others — and \
|
||||||
|
uses it to identify entry points, dead code, hot paths, and structural \
|
||||||
|
properties of execution flow. This is static analysis; it shows what *can* \
|
||||||
|
be called, not runtime frequency.",
|
||||||
|
metrics: &[
|
||||||
|
("entry points", "Functions reachable as starting points: main() and #[test] functions."),
|
||||||
|
("reachable functions", "Functions reachable from any entry point via the call graph."),
|
||||||
|
("cold functions", "Functions with zero incoming calls. May be dead code, or may be entry points for external consumers."),
|
||||||
|
("max call depth", "Longest chain of function calls. Very deep chains can indicate over-decomposition or recursion."),
|
||||||
|
("coverage", "Percentage of functions reachable from entry points. Low coverage means much of the code may be unused."),
|
||||||
|
("hot paths", "Paths through the call graph that visit the most functions. Shows the main execution spine."),
|
||||||
|
],
|
||||||
|
commands: &[
|
||||||
|
"cstat flow — call graph edges, entry points, cycles, cold functions",
|
||||||
|
"cstat flow --json — structured call graph data",
|
||||||
|
"cstat test-reachability — static test/benchmark reachability",
|
||||||
|
"cstat test-reachability --json — structured test/benchmark reachability data",
|
||||||
|
"cstat advanced flow-heatmap — legacy random-walk heatmap",
|
||||||
|
"cstat advanced flow-heatmap --json — structured visit counts and hot paths",
|
||||||
|
],
|
||||||
|
patterns: &[
|
||||||
|
"Cold functions that are not pub items or test helpers are likely dead code. \
|
||||||
|
Verify by checking if they appear in the module's public interface.",
|
||||||
|
"Cycles in the call graph (recursive or mutually recursive functions) are \
|
||||||
|
worth noting — they make reasoning about termination harder and can cause \
|
||||||
|
stack overflows.",
|
||||||
|
"Low coverage (< 70%) may indicate modules that are libraries consumed \
|
||||||
|
externally, or it may indicate accumulated dead code.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_redundancy() -> TopicContent {
|
||||||
|
TopicContent {
|
||||||
|
description: "\
|
||||||
|
Redundancy detection identifies functions that are structurally or \
|
||||||
|
signature-similar to each other. Duplicated logic is a maintenance burden — \
|
||||||
|
bug fixes need to be applied in multiple places, and divergent copies create \
|
||||||
|
subtle inconsistencies.",
|
||||||
|
metrics: &[
|
||||||
|
("signature similarity", "Compares function name, parameter types, and return type. High similarity suggests functions that evolved from copy-paste."),
|
||||||
|
("structural similarity", "Normalizes function ASTs (strips identifiers, collapses literals) and compares hashes. Catches duplicates even when variable names differ."),
|
||||||
|
("similarity score", "Combined score from 0.0 to 1.0. Above 0.7 is suspicious; above 0.9 is near-certain duplication."),
|
||||||
|
("similarity kind", "Whether the match is by signature, structure, or both. 'Both' matches are the strongest signal."),
|
||||||
|
],
|
||||||
|
commands: &[
|
||||||
|
"cstat redundancy — table of suspected duplicate function pairs",
|
||||||
|
"cstat redundancy --json — structured similarity data for all pairs",
|
||||||
|
],
|
||||||
|
patterns: &[
|
||||||
|
"Structural duplicates with different names often indicate utility functions \
|
||||||
|
that were independently implemented in different modules. Consider extracting \
|
||||||
|
to a shared location.",
|
||||||
|
"Signature-only matches (same parameter/return types, similar names) may be \
|
||||||
|
intentional polymorphism or may indicate an interface that should be a trait.",
|
||||||
|
"A high count of redundant pairs in a single module suggests the module grew \
|
||||||
|
by accretion rather than design.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_architecture() -> TopicContent {
|
||||||
|
TopicContent {
|
||||||
|
description: "\
|
||||||
|
Architecture metrics apply graph theory to the call graph and dependency \
|
||||||
|
graph to reveal structural properties invisible at the function level — \
|
||||||
|
bridges, hubs, clusters, and overall connectivity patterns. These metrics \
|
||||||
|
describe the shape of the codebase.",
|
||||||
|
metrics: &[
|
||||||
|
("betweenness centrality", "How often a node lies on shortest paths between other nodes. High-betweenness nodes are bridges — their removal disconnects the graph."),
|
||||||
|
("PageRank", "Recursive importance: a node is important if important nodes point to it. Identifies the most depended-upon functions."),
|
||||||
|
("SCCs (strongly connected components)", "Groups of nodes where every node can reach every other. In a call graph, these are mutual recursion groups. In a dependency graph, these are cyclic dependency clusters."),
|
||||||
|
("clustering coefficient", "How densely connected a node's neighbors are to each other. High values mean tight local clusters; low values mean the node bridges separate groups."),
|
||||||
|
("graph density", "Ratio of actual edges to possible edges. Very low density means a sparse, tree-like structure; higher density means more interconnection."),
|
||||||
|
],
|
||||||
|
commands: &[
|
||||||
|
"cstat graph — SCC analysis, centrality, PageRank, clustering",
|
||||||
|
"cstat graph --json — structured graph metrics",
|
||||||
|
"cstat graph --call-only — analyze only the call graph",
|
||||||
|
"cstat graph --dep-only — analyze only the module dependency graph",
|
||||||
|
"cstat map — architectural tier visualization",
|
||||||
|
],
|
||||||
|
patterns: &[
|
||||||
|
"High-betweenness nodes are refactoring leverage points: splitting them can \
|
||||||
|
decouple large portions of the codebase.",
|
||||||
|
"Large SCCs in the dependency graph indicate tightly coupled module groups. \
|
||||||
|
These tend to grow over time as dependencies accumulate.",
|
||||||
|
"A hub (high PageRank + high fan-out) that is also a bridge (high betweenness) \
|
||||||
|
is a critical risk point — it is both heavily depended upon and structurally \
|
||||||
|
load-bearing.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_content(name: &str) -> Option<TopicContent> {
|
||||||
|
match name {
|
||||||
|
"getting-started" => Some(topic_getting_started()),
|
||||||
|
"size" => Some(topic_size()),
|
||||||
|
"complexity" => Some(topic_complexity()),
|
||||||
|
"modularity" => Some(topic_modularity()),
|
||||||
|
"flow" => Some(topic_flow()),
|
||||||
|
"redundancy" => Some(topic_redundancy()),
|
||||||
|
"architecture" => Some(topic_architecture()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON serialization ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuideTocJson {
|
||||||
|
cstat_version: String,
|
||||||
|
topics: Vec<GuideTopicEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuideTopicEntry {
|
||||||
|
name: String,
|
||||||
|
summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuideTopicJson {
|
||||||
|
cstat_version: String,
|
||||||
|
topic: String,
|
||||||
|
description: String,
|
||||||
|
metrics: Vec<GuideMetricJson>,
|
||||||
|
commands: Vec<String>,
|
||||||
|
patterns: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuideMetricJson {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public interface ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Render the guide table of contents (no topic specified).
|
||||||
|
pub fn render_guide_toc(json: bool) {
|
||||||
|
if json {
|
||||||
|
render_guide_toc_json();
|
||||||
|
} else {
|
||||||
|
render_guide_toc_human();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a specific guide topic.
|
||||||
|
pub fn render_guide_topic(topic: &str, json: bool) {
|
||||||
|
if json {
|
||||||
|
render_guide_topic_json(topic);
|
||||||
|
} else {
|
||||||
|
render_guide_topic_human(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Human-readable rendering ───────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_guide_toc_human() {
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
"cstat guide — code health reference".bright_cyan().bold()
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
for (name, summary) in TOPICS {
|
||||||
|
println!(" {:<18}{}", name.bold(), summary);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!("Run: {}", "cstat guide <topic>".white().bold());
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_guide_topic_human(name: &str) {
|
||||||
|
let content = match topic_content(name) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
eprintln!("Unknown guide topic: {}", name);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Available topics:");
|
||||||
|
for (t, _) in TOPICS {
|
||||||
|
eprintln!(" {}", t);
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let topic_summary = TOPICS
|
||||||
|
.iter()
|
||||||
|
.find(|(n, _)| *n == name)
|
||||||
|
.map(|(_, s)| *s)
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{} — {}",
|
||||||
|
format!("cstat guide {}", name).bright_cyan().bold(),
|
||||||
|
topic_summary
|
||||||
|
);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
println!();
|
||||||
|
for line in content.description.lines() {
|
||||||
|
println!(" {}", line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
if !content.metrics.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!("{}", " Key metrics:".bold());
|
||||||
|
for (metric, desc) in content.metrics {
|
||||||
|
println!();
|
||||||
|
println!(" {}", metric.yellow().bold());
|
||||||
|
// Wrap description text
|
||||||
|
for line in desc.lines() {
|
||||||
|
println!(" {}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
if !content.commands.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!("{}", " Relevant commands:".bold());
|
||||||
|
for cmd in content.commands {
|
||||||
|
println!(" {}", cmd.dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns
|
||||||
|
if !content.patterns.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!("{}", " Patterns worth noticing:".bold());
|
||||||
|
for pattern in content.patterns {
|
||||||
|
println!();
|
||||||
|
print!(" • ");
|
||||||
|
// Simple word-wrap at ~72 chars for readability
|
||||||
|
let words: Vec<&str> = pattern.split_whitespace().collect();
|
||||||
|
let mut col = 6; // indent of " • "
|
||||||
|
for (i, word) in words.iter().enumerate() {
|
||||||
|
if i > 0 && col + 1 + word.len() > 76 {
|
||||||
|
println!();
|
||||||
|
print!(" ");
|
||||||
|
col = 6;
|
||||||
|
} else if i > 0 {
|
||||||
|
print!(" ");
|
||||||
|
col += 1;
|
||||||
|
}
|
||||||
|
print!("{}", word);
|
||||||
|
col += word.len();
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_guide_toc_json() {
|
||||||
|
let output = GuideTocJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
topics: TOPICS
|
||||||
|
.iter()
|
||||||
|
.map(|(name, summary)| GuideTopicEntry {
|
||||||
|
name: name.to_string(),
|
||||||
|
summary: summary.to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_guide_topic_json(name: &str) {
|
||||||
|
let content = match topic_content(name) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
let err = serde_json::json!({"error": format!("Unknown guide topic: {}", name)});
|
||||||
|
println!("{}", serde_json::to_string(&err).unwrap());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = GuideTopicJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
topic: name.to_string(),
|
||||||
|
description: content.description.to_string(),
|
||||||
|
metrics: content
|
||||||
|
.metrics
|
||||||
|
.iter()
|
||||||
|
.map(|(n, d)| GuideMetricJson {
|
||||||
|
name: n.to_string(),
|
||||||
|
description: d.to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
commands: content.commands.iter().map(|s| s.to_string()).collect(),
|
||||||
|
patterns: content.patterns.iter().map(|s| s.to_string()).collect(),
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
228
src/main.rs
228
src/main.rs
|
|
@ -1,17 +1,27 @@
|
||||||
mod ast_parser;
|
mod ast_parser;
|
||||||
mod call_trace;
|
mod call_trace;
|
||||||
|
mod circuits;
|
||||||
mod cluster;
|
mod cluster;
|
||||||
mod complexity;
|
mod complexity;
|
||||||
mod coverage;
|
mod coverage;
|
||||||
mod datapaths;
|
mod datapaths;
|
||||||
mod dead_code;
|
mod dead_code;
|
||||||
mod deps;
|
mod deps;
|
||||||
|
mod diagnostics;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
mod dist;
|
||||||
|
mod dump;
|
||||||
mod file_metrics;
|
mod file_metrics;
|
||||||
|
mod file_summary;
|
||||||
mod flow;
|
mod flow;
|
||||||
|
mod graph;
|
||||||
|
mod guide;
|
||||||
mod loc;
|
mod loc;
|
||||||
|
mod map;
|
||||||
|
mod redundancy;
|
||||||
mod render;
|
mod render;
|
||||||
mod scorecard;
|
mod report;
|
||||||
|
mod summary;
|
||||||
mod symbols;
|
mod symbols;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
@ -21,7 +31,7 @@ use std::path::PathBuf;
|
||||||
#[command(name = "cstat", about = "Rust codebase complexity analyzer")]
|
#[command(name = "cstat", about = "Rust codebase complexity analyzer")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Option<Commands>,
|
||||||
|
|
||||||
/// Rust project directory or Rust source file to analyze (defaults to current directory)
|
/// Rust project directory or Rust source file to analyze (defaults to current directory)
|
||||||
#[arg(long, default_value = ".", global = true)]
|
#[arg(long, default_value = ".", global = true)]
|
||||||
|
|
@ -42,6 +52,10 @@ struct Cli {
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
|
/// Focused module metrics report (default)
|
||||||
|
Summary,
|
||||||
|
/// Alias for the focused module metrics report
|
||||||
|
Report,
|
||||||
#[command(
|
#[command(
|
||||||
about = "Lines-of-code size-shape analysis",
|
about = "Lines-of-code size-shape analysis",
|
||||||
long_about = r#"Lines-of-code size-shape analysis for Rust source.
|
long_about = r#"Lines-of-code size-shape analysis for Rust source.
|
||||||
|
|
@ -85,12 +99,6 @@ Use --explain to print this usage and JSON field contract without running analys
|
||||||
Signature,
|
Signature,
|
||||||
/// Function implementation span rankings
|
/// Function implementation span rankings
|
||||||
Span,
|
Span,
|
||||||
/// Deterministic structural code complexity scorecard
|
|
||||||
Scorecard {
|
|
||||||
/// Show only the top N contributors
|
|
||||||
#[arg(long)]
|
|
||||||
top: Option<usize>,
|
|
||||||
},
|
|
||||||
/// Module dependency connectome
|
/// Module dependency connectome
|
||||||
Deps {
|
Deps {
|
||||||
/// Show only the top N most connected modules
|
/// Show only the top N most connected modules
|
||||||
|
|
@ -115,6 +123,66 @@ Use --explain to print this usage and JSON field contract without running analys
|
||||||
},
|
},
|
||||||
/// Value-cluster transcript from AST def-use structure.
|
/// Value-cluster transcript from AST def-use structure.
|
||||||
Cluster,
|
Cluster,
|
||||||
|
/// Opt-in legacy and exploratory analyses
|
||||||
|
Advanced {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: AdvancedCommands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum AdvancedCommands {
|
||||||
|
/// Previous broad dashboard overview
|
||||||
|
Summary,
|
||||||
|
/// Bird's eye module map — architectural overview
|
||||||
|
Map,
|
||||||
|
/// Static call graph / execution flow analysis
|
||||||
|
Flow,
|
||||||
|
/// Graph-theoretic analysis (SCCs, centrality, PageRank, clustering)
|
||||||
|
Graph {
|
||||||
|
/// Analyze only the call graph
|
||||||
|
#[arg(long)]
|
||||||
|
call_only: bool,
|
||||||
|
/// Analyze only the module dependency graph
|
||||||
|
#[arg(long)]
|
||||||
|
dep_only: bool,
|
||||||
|
/// Show only the top N nodes per metric
|
||||||
|
#[arg(long, default_value = "10")]
|
||||||
|
top: usize,
|
||||||
|
},
|
||||||
|
/// Previous random-walk execution flow heatmap
|
||||||
|
FlowHeatmap {
|
||||||
|
/// Number of random walks to simulate
|
||||||
|
#[arg(long, default_value = "10000")]
|
||||||
|
walks: usize,
|
||||||
|
/// Start from a specific function instead of all entry points
|
||||||
|
#[arg(long, name = "FUNCTION")]
|
||||||
|
from: Option<String>,
|
||||||
|
},
|
||||||
|
/// Distribution visualizations (histograms, scatter, outliers, correlations)
|
||||||
|
Dist {
|
||||||
|
/// Show only a single metric (loc, cyclomatic, cognitive, nesting, params)
|
||||||
|
#[arg(long)]
|
||||||
|
metric: Option<String>,
|
||||||
|
/// Number of histogram bins
|
||||||
|
#[arg(long, default_value = "10")]
|
||||||
|
bins: usize,
|
||||||
|
},
|
||||||
|
/// Functional cluster decomposition via community detection
|
||||||
|
Circuits {
|
||||||
|
/// Minimum circuit size to display
|
||||||
|
#[arg(long, default_value = "1")]
|
||||||
|
min_size: usize,
|
||||||
|
},
|
||||||
|
/// Detect suspected duplicate or near-duplicate functions
|
||||||
|
Redundancy,
|
||||||
|
/// Machine-readable diagnostic summary (always JSON)
|
||||||
|
Dump,
|
||||||
|
/// Code health reference — topic-based workflow and metric guide
|
||||||
|
Guide {
|
||||||
|
/// Topic to display (e.g. getting-started, size, complexity, modularity, flow, redundancy, architecture)
|
||||||
|
topic: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
@ -127,13 +195,12 @@ fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let verbose = cli.verbose;
|
let verbose = cli.verbose;
|
||||||
let command = cli.command;
|
let command = cli.command.unwrap_or(Commands::Summary);
|
||||||
|
|
||||||
if let Commands::Loc { explain: true, .. } = &command {
|
if let Commands::Loc { explain: true, .. } = &command {
|
||||||
loc::render_loc_explain(json);
|
loc::render_loc_explain(json);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = match discovery::resolve_target(&cli.path) {
|
let target = match discovery::resolve_target(&cli.path) {
|
||||||
Ok(target) => target,
|
Ok(target) => target,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -153,6 +220,18 @@ fn main() {
|
||||||
let project_rs_files = discovery::files_for_project(&target);
|
let project_rs_files = discovery::files_for_project(&target);
|
||||||
let target_rs_files = discovery::files_for_target(&target);
|
let target_rs_files = discovery::files_for_target(&target);
|
||||||
match command {
|
match command {
|
||||||
|
Commands::Summary | Commands::Report => {
|
||||||
|
if json {
|
||||||
|
file_summary::render_file_summary_json(project_rs_files, project_path, file);
|
||||||
|
} else {
|
||||||
|
file_summary::render_file_summary(
|
||||||
|
project_rs_files,
|
||||||
|
project_path,
|
||||||
|
file,
|
||||||
|
verbose,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Commands::Loc { .. } => {
|
Commands::Loc { .. } => {
|
||||||
if json {
|
if json {
|
||||||
loc::render_loc_file_json(project_rs_files, project_path, file);
|
loc::render_loc_file_json(project_rs_files, project_path, file);
|
||||||
|
|
@ -183,16 +262,6 @@ fn main() {
|
||||||
cluster::render_cluster_file(project_rs_files, project_path, file, verbose);
|
cluster::render_cluster_file(project_rs_files, project_path, file, verbose);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Scorecard { top } => {
|
|
||||||
scorecard::render_scorecard(
|
|
||||||
target_rs_files,
|
|
||||||
project_path,
|
|
||||||
&cli.path.display().to_string(),
|
|
||||||
top,
|
|
||||||
json,
|
|
||||||
verbose,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Commands::Deps { .. } => {
|
Commands::Deps { .. } => {
|
||||||
if json {
|
if json {
|
||||||
deps::render_deps_file_json(project_rs_files, project_path, file);
|
deps::render_deps_file_json(project_rs_files, project_path, file);
|
||||||
|
|
@ -248,7 +317,6 @@ fn main() {
|
||||||
Some(file),
|
Some(file),
|
||||||
no_run,
|
no_run,
|
||||||
json,
|
json,
|
||||||
verbose,
|
|
||||||
) {
|
) {
|
||||||
if json {
|
if json {
|
||||||
let err = serde_json::json!({"error": message});
|
let err = serde_json::json!({"error": message});
|
||||||
|
|
@ -259,11 +327,29 @@ fn main() {
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Commands::Advanced { .. } => {
|
||||||
|
let message =
|
||||||
|
"advanced commands require a project directory; pass --path <crate-dir>";
|
||||||
|
if json {
|
||||||
|
let err = serde_json::json!({"error": message});
|
||||||
|
println!("{}", serde_json::to_string(&err).unwrap());
|
||||||
|
} else {
|
||||||
|
eprintln!("Error: {}", message);
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
|
Commands::Summary | Commands::Report => {
|
||||||
|
if json {
|
||||||
|
report::render_report_json(&rs_files, &project_path);
|
||||||
|
} else {
|
||||||
|
report::render_report(&rs_files, &project_path, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
Commands::Loc { top, .. } => {
|
Commands::Loc { top, .. } => {
|
||||||
if json {
|
if json {
|
||||||
loc::render_loc_json(&rs_files, &project_path);
|
loc::render_loc_json(&rs_files, &project_path);
|
||||||
|
|
@ -287,16 +373,6 @@ fn main() {
|
||||||
Commands::Span => {
|
Commands::Span => {
|
||||||
complexity::run_span(&rs_files, &project_path, json, verbose);
|
complexity::run_span(&rs_files, &project_path, json, verbose);
|
||||||
}
|
}
|
||||||
Commands::Scorecard { top } => {
|
|
||||||
scorecard::render_scorecard(
|
|
||||||
&rs_files,
|
|
||||||
&project_path,
|
|
||||||
&cli.path.display().to_string(),
|
|
||||||
top,
|
|
||||||
json,
|
|
||||||
verbose,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Commands::Cluster => {
|
Commands::Cluster => {
|
||||||
cluster::render_cluster(&rs_files, &project_path, json, verbose);
|
cluster::render_cluster(&rs_files, &project_path, json, verbose);
|
||||||
}
|
}
|
||||||
|
|
@ -336,7 +412,7 @@ fn main() {
|
||||||
}
|
}
|
||||||
Commands::Coverage { no_run } => {
|
Commands::Coverage { no_run } => {
|
||||||
if let Err(message) =
|
if let Err(message) =
|
||||||
coverage::render_coverage(&rs_files, &project_path, None, no_run, json, verbose)
|
coverage::render_coverage(&rs_files, &project_path, None, no_run, json)
|
||||||
{
|
{
|
||||||
if json {
|
if json {
|
||||||
let err = serde_json::json!({"error": message});
|
let err = serde_json::json!({"error": message});
|
||||||
|
|
@ -347,5 +423,93 @@ fn main() {
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Commands::Advanced { command } => match command {
|
||||||
|
AdvancedCommands::Summary => {
|
||||||
|
if json {
|
||||||
|
summary::render_summary_json(&rs_files, &project_path);
|
||||||
|
} else {
|
||||||
|
summary::render_summary(&rs_files, &project_path, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::Map => {
|
||||||
|
if json {
|
||||||
|
map::render_map_json(&rs_files, &project_path);
|
||||||
|
} else {
|
||||||
|
map::render_map(&rs_files, &project_path, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::Flow => {
|
||||||
|
if json {
|
||||||
|
flow::render_flow_json(&rs_files, &project_path);
|
||||||
|
} else {
|
||||||
|
flow::render_flow(&rs_files, &project_path, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::Graph {
|
||||||
|
call_only,
|
||||||
|
dep_only,
|
||||||
|
top,
|
||||||
|
} => {
|
||||||
|
if json {
|
||||||
|
graph::render_graph_json(&rs_files, &project_path, call_only, dep_only);
|
||||||
|
} else {
|
||||||
|
graph::render_graph(
|
||||||
|
&rs_files,
|
||||||
|
&project_path,
|
||||||
|
call_only,
|
||||||
|
dep_only,
|
||||||
|
top,
|
||||||
|
verbose,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::FlowHeatmap { walks, from } => {
|
||||||
|
if json {
|
||||||
|
datapaths::render_datapaths_json(
|
||||||
|
&rs_files,
|
||||||
|
&project_path,
|
||||||
|
walks,
|
||||||
|
from.as_deref(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
datapaths::render_datapaths(
|
||||||
|
&rs_files,
|
||||||
|
&project_path,
|
||||||
|
walks,
|
||||||
|
from.as_deref(),
|
||||||
|
verbose,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::Dist { metric, bins } => {
|
||||||
|
if json {
|
||||||
|
dist::render_dist_json(&rs_files, &project_path, metric.as_deref(), bins);
|
||||||
|
} else {
|
||||||
|
dist::render_dist(&rs_files, &project_path, metric.as_deref(), bins, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::Circuits { min_size } => {
|
||||||
|
if json {
|
||||||
|
circuits::render_circuits_json(&rs_files, &project_path, min_size);
|
||||||
|
} else {
|
||||||
|
circuits::render_circuits(&rs_files, &project_path, min_size, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::Redundancy => {
|
||||||
|
if json {
|
||||||
|
redundancy::render_redundancy_json(&rs_files, &project_path);
|
||||||
|
} else {
|
||||||
|
redundancy::render_redundancy(&rs_files, &project_path, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdvancedCommands::Dump => {
|
||||||
|
colored::control::set_override(false);
|
||||||
|
dump::render_dump(&rs_files, &project_path);
|
||||||
|
}
|
||||||
|
AdvancedCommands::Guide { topic } => match topic {
|
||||||
|
Some(t) => guide::render_guide_topic(&t, json),
|
||||||
|
None => guide::render_guide_toc(json),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
867
src/map.rs
Normal file
867
src/map.rs
Normal file
|
|
@ -0,0 +1,867 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::ast_parser::{self, ProjectSymbols};
|
||||||
|
use crate::complexity;
|
||||||
|
use crate::deps;
|
||||||
|
use crate::loc;
|
||||||
|
use crate::render;
|
||||||
|
|
||||||
|
/// Information about a module for the map view.
|
||||||
|
struct ModuleBox {
|
||||||
|
name: String,
|
||||||
|
structs: Vec<String>,
|
||||||
|
enums: Vec<String>,
|
||||||
|
traits: Vec<String>,
|
||||||
|
functions: Vec<String>,
|
||||||
|
pub_count: usize,
|
||||||
|
total_count: usize,
|
||||||
|
code_lines: usize,
|
||||||
|
/// Per-function complexity scores (for heatbar).
|
||||||
|
func_scores: Vec<f64>,
|
||||||
|
/// Total connectivity (in-degree + out-degree).
|
||||||
|
connectivity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An inter-tier connection.
|
||||||
|
struct TierEdge {
|
||||||
|
from_tier: usize,
|
||||||
|
to_tier: usize,
|
||||||
|
from_mod: String,
|
||||||
|
to_mod: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print the tier header separator line.
|
||||||
|
fn render_tier_header(tier_idx: usize, max_tier: usize, term_w: usize) {
|
||||||
|
let tier_label = match tier_idx {
|
||||||
|
0 => "Entry Points".to_string(),
|
||||||
|
t if t == max_tier && max_tier > 0 => "Leaf Modules".to_string(),
|
||||||
|
t => format!("Layer {}", t),
|
||||||
|
};
|
||||||
|
let separator = format!(
|
||||||
|
"╶╶╶ {} {}",
|
||||||
|
tier_label,
|
||||||
|
"╶".repeat(term_w.saturating_sub(tier_label.len()).saturating_sub(6))
|
||||||
|
);
|
||||||
|
println!("\n{}", separator.dimmed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the module boxes for a single tier in a side-by-side layout.
|
||||||
|
fn render_tier_modules(
|
||||||
|
group: &[String],
|
||||||
|
module_boxes: &HashMap<String, ModuleBox>,
|
||||||
|
hub_threshold: usize,
|
||||||
|
max_loc: usize,
|
||||||
|
term_w: usize,
|
||||||
|
) {
|
||||||
|
let box_min_width = 30;
|
||||||
|
let max_side_by_side = (term_w / box_min_width).max(1);
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < group.len() {
|
||||||
|
let row_end = (i + max_side_by_side).min(group.len());
|
||||||
|
let row = &group[i..row_end];
|
||||||
|
let col_count = row.len();
|
||||||
|
let col_width = if col_count > 1 {
|
||||||
|
(term_w / col_count).min(term_w).max(box_min_width)
|
||||||
|
} else {
|
||||||
|
term_w.min(80).max(box_min_width)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut box_lines: Vec<Vec<String>> = Vec::new();
|
||||||
|
for mod_name in row {
|
||||||
|
if let Some(mbox) = module_boxes.get(mod_name.as_str()) {
|
||||||
|
let is_hub = mbox.connectivity >= hub_threshold && hub_threshold > 0;
|
||||||
|
let height = proportional_height(mbox.code_lines, max_loc);
|
||||||
|
let lines = render_module_box(mbox, col_width.saturating_sub(1), is_hub, height);
|
||||||
|
box_lines.push(lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_lines = box_lines.iter().map(|b| b.len()).max().unwrap_or(0);
|
||||||
|
|
||||||
|
for line_idx in 0..max_lines {
|
||||||
|
let mut row_str = String::new();
|
||||||
|
for (col_idx, lines) in box_lines.iter().enumerate() {
|
||||||
|
let line = if line_idx < lines.len() {
|
||||||
|
&lines[line_idx]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let vis_len = render::visible_len(line);
|
||||||
|
let pad = col_width.saturating_sub(1).saturating_sub(vis_len);
|
||||||
|
row_str.push_str(line);
|
||||||
|
row_str.push_str(&" ".repeat(pad));
|
||||||
|
if col_idx < box_lines.len() - 1 {
|
||||||
|
row_str.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", row_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
i = row_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render weighted dependency arrows from the given tier to deeper tiers.
|
||||||
|
fn render_tier_arrows(tier_edges: &[TierEdge], tier_idx: usize) {
|
||||||
|
let arrows_from_tier: Vec<&TierEdge> = tier_edges
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.from_tier == tier_idx && e.to_tier > tier_idx)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if arrows_from_tier.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut arrow_groups: HashMap<(&str, &str), usize> = HashMap::new();
|
||||||
|
for e in &arrows_from_tier {
|
||||||
|
*arrow_groups
|
||||||
|
.entry((e.from_mod.as_str(), e.to_mod.as_str()))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
let mut arrow_list: Vec<((&str, &str), usize)> = arrow_groups.into_iter().collect();
|
||||||
|
arrow_list.sort_by(|a, b| b.1.cmp(&a.1).then(a.0 .0.cmp(b.0 .0)));
|
||||||
|
|
||||||
|
let show_count = arrow_list.len().min(6);
|
||||||
|
let mut arrow_strs: Vec<String> = Vec::new();
|
||||||
|
for ((from, to), count) in arrow_list.iter().take(show_count) {
|
||||||
|
let weight_str = if *count > 1 {
|
||||||
|
format!("({})", count)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
arrow_strs.push(format!(
|
||||||
|
"{}→{}{}",
|
||||||
|
from.bright_blue(),
|
||||||
|
to.bright_blue(),
|
||||||
|
weight_str.dimmed()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if arrow_list.len() > show_count {
|
||||||
|
arrow_strs.push(
|
||||||
|
format!("+{} more", arrow_list.len() - show_count)
|
||||||
|
.dimmed()
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(" {} {}", "───▶".dimmed(), arrow_strs.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepared data for rendering the module map.
|
||||||
|
struct MapRenderData {
|
||||||
|
module_boxes: HashMap<String, ModuleBox>,
|
||||||
|
tier_groups: Vec<Vec<String>>,
|
||||||
|
tier_edges: Vec<TierEdge>,
|
||||||
|
max_loc: usize,
|
||||||
|
hub_threshold: usize,
|
||||||
|
max_tier: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather and prepare all data needed to render the module map.
|
||||||
|
fn prepare_map_data(
|
||||||
|
rs_files: &[PathBuf],
|
||||||
|
project_path: &Path,
|
||||||
|
symbols: &ProjectSymbols,
|
||||||
|
dep_analysis: &deps::DepAnalysis,
|
||||||
|
) -> Option<MapRenderData> {
|
||||||
|
// Get LoC data per file
|
||||||
|
let (file_stats, _) = loc::analyze_files(rs_files);
|
||||||
|
let mut loc_by_mod: HashMap<String, usize> = HashMap::new();
|
||||||
|
for stat in &file_stats {
|
||||||
|
let mod_name = render::module_name_from_path(&stat.path, project_path);
|
||||||
|
loc_by_mod.insert(mod_name, stat.code_lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get complexity data
|
||||||
|
let func_complexities = complexity::compute_all(symbols, project_path);
|
||||||
|
let mut complexity_by_mod: HashMap<String, Vec<f64>> = HashMap::new();
|
||||||
|
for fc in &func_complexities {
|
||||||
|
let mod_name = file_to_module_name(&fc.file);
|
||||||
|
complexity_by_mod
|
||||||
|
.entry(mod_name)
|
||||||
|
.or_default()
|
||||||
|
.push(fc.composite_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build module boxes
|
||||||
|
let module_boxes = build_module_boxes(
|
||||||
|
symbols,
|
||||||
|
project_path,
|
||||||
|
&loc_by_mod,
|
||||||
|
&complexity_by_mod,
|
||||||
|
dep_analysis,
|
||||||
|
);
|
||||||
|
|
||||||
|
if module_boxes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute tier assignments via dependency depth (BFS from roots)
|
||||||
|
let tiers = compute_tiers(&module_boxes, dep_analysis);
|
||||||
|
|
||||||
|
// Group modules by tier
|
||||||
|
let max_tier = tiers.values().copied().max().unwrap_or(0);
|
||||||
|
let mut tier_groups: Vec<Vec<String>> = vec![Vec::new(); max_tier + 1];
|
||||||
|
for (name, &tier) in &tiers {
|
||||||
|
tier_groups[tier].push(name.clone());
|
||||||
|
}
|
||||||
|
// Sort modules within each tier by LoC descending
|
||||||
|
for group in &mut tier_groups {
|
||||||
|
group.sort_by(|a, b| {
|
||||||
|
let a_loc = module_boxes
|
||||||
|
.get(a.as_str())
|
||||||
|
.map(|m| m.code_lines)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let b_loc = module_boxes
|
||||||
|
.get(b.as_str())
|
||||||
|
.map(|m| m.code_lines)
|
||||||
|
.unwrap_or(0);
|
||||||
|
b_loc.cmp(&a_loc).then(a.cmp(b))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect inter-tier edges
|
||||||
|
let mut tier_edges: Vec<TierEdge> = Vec::new();
|
||||||
|
for edge in &dep_analysis.edges {
|
||||||
|
let from_tier = tiers.get(edge.from.as_str()).copied().unwrap_or(0);
|
||||||
|
let to_tier = tiers.get(edge.to.as_str()).copied().unwrap_or(0);
|
||||||
|
tier_edges.push(TierEdge {
|
||||||
|
from_tier,
|
||||||
|
to_tier,
|
||||||
|
from_mod: edge.from.clone(),
|
||||||
|
to_mod: edge.to.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find max LoC for proportional sizing
|
||||||
|
let max_loc = module_boxes
|
||||||
|
.values()
|
||||||
|
.map(|m| m.code_lines)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
|
||||||
|
// Compute connectivity threshold for hub detection
|
||||||
|
let connectivities: Vec<usize> = module_boxes.values().map(|m| m.connectivity).collect();
|
||||||
|
let mean_conn = if connectivities.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
connectivities.iter().sum::<usize>() as f64 / connectivities.len() as f64
|
||||||
|
};
|
||||||
|
let hub_threshold = (mean_conn + 1.0) as usize;
|
||||||
|
|
||||||
|
Some(MapRenderData {
|
||||||
|
module_boxes,
|
||||||
|
tier_groups,
|
||||||
|
tier_edges,
|
||||||
|
max_loc,
|
||||||
|
hub_threshold,
|
||||||
|
max_tier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the bird's eye module map.
|
||||||
|
pub fn render_map(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
|
||||||
|
let term_w = render::terminal_width();
|
||||||
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
|
let dep_analysis = deps::analyze_deps(rs_files, project_path);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Architectural Module Map ───────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Bird's-eye module map arranged by dependency depth (BFS from roots).",
|
||||||
|
"Tier 0 = Entry Points (zero in-degree — nothing depends on them).",
|
||||||
|
"Deepest tier = Leaf Modules (only depended upon, never import others).",
|
||||||
|
"Inside each box: S = structs, E = enums, T = traits, f = functions.",
|
||||||
|
" Numbers after the module name: LoC count and pub/total item ratio.",
|
||||||
|
" ● (white) in the legend = public items (pub fn, pub struct, etc.).",
|
||||||
|
"Heatbar (▁▃▅▇) = per-function complexity distribution within the module:",
|
||||||
|
" green (▁) = low complexity, yellow (▅) = medium, red (▇) = high.",
|
||||||
|
"Border style: ╔ (double-line, bold) = hub module (connectivity > mean+1σ).",
|
||||||
|
" ┌ (single-line, dimmed) = regular or leaf module.",
|
||||||
|
"Arrows (───▶) show cross-tier dependency flow between modules.",
|
||||||
|
" Arrow weight in parentheses = number of dependency edges along that path.",
|
||||||
|
]);
|
||||||
|
render::guide_ref("architecture");
|
||||||
|
}
|
||||||
|
|
||||||
|
if symbols.files.is_empty() {
|
||||||
|
println!(" {}", "No modules found.".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = match prepare_map_data(rs_files, project_path, &symbols, &dep_analysis) {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
println!(" {}", "No modules found.".dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render tier by tier (top = tier 0 = entry points, bottom = leaves)
|
||||||
|
for tier_idx in 0..=data.max_tier {
|
||||||
|
let group = &data.tier_groups[tier_idx];
|
||||||
|
if group.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_tier_header(tier_idx, data.max_tier, term_w);
|
||||||
|
render_tier_modules(
|
||||||
|
group,
|
||||||
|
&data.module_boxes,
|
||||||
|
data.hub_threshold,
|
||||||
|
data.max_loc,
|
||||||
|
term_w,
|
||||||
|
);
|
||||||
|
render_tier_arrows(&data.tier_edges, tier_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} struct {} enum {} trait {} fn {} pub {} hub {} leaf",
|
||||||
|
"S".bright_green().bold(),
|
||||||
|
"E".bright_yellow().bold(),
|
||||||
|
"T".bright_magenta().bold(),
|
||||||
|
"f".bright_blue(),
|
||||||
|
"●".white().bold(),
|
||||||
|
"╔".bold(),
|
||||||
|
"┌".dimmed(),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} complexity heatbar: {} low {} med {} high",
|
||||||
|
"▁▃▅▇".dimmed(),
|
||||||
|
"▁".green(),
|
||||||
|
"▅".yellow(),
|
||||||
|
"▇".red(),
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a file path like "src/foo.rs" to module name "foo".
|
||||||
|
fn file_to_module_name(file: &str) -> String {
|
||||||
|
let s = file.strip_suffix(".rs").unwrap_or(file);
|
||||||
|
let s = s.replace('/', "::");
|
||||||
|
let s = s.strip_prefix("src::").unwrap_or(&s);
|
||||||
|
if s.ends_with("::mod") {
|
||||||
|
s.strip_suffix("::mod").unwrap_or(s).to_string()
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build module boxes with LoC, complexity, and connectivity data.
|
||||||
|
fn build_module_boxes(
|
||||||
|
symbols: &ProjectSymbols,
|
||||||
|
project_path: &Path,
|
||||||
|
loc_by_mod: &HashMap<String, usize>,
|
||||||
|
complexity_by_mod: &HashMap<String, Vec<f64>>,
|
||||||
|
dep_analysis: &deps::DepAnalysis,
|
||||||
|
) -> HashMap<String, ModuleBox> {
|
||||||
|
let mut boxes: HashMap<String, ModuleBox> = HashMap::new();
|
||||||
|
|
||||||
|
for file_syms in &symbols.files {
|
||||||
|
let mod_name = render::module_name_from_path(&file_syms.path, project_path);
|
||||||
|
|
||||||
|
let structs: Vec<String> = file_syms.structs.iter().map(|s| s.name.clone()).collect();
|
||||||
|
let enums: Vec<String> = file_syms.enums.iter().map(|e| e.name.clone()).collect();
|
||||||
|
let traits: Vec<String> = file_syms.traits.iter().map(|t| t.name.clone()).collect();
|
||||||
|
let functions: Vec<String> = file_syms
|
||||||
|
.functions
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !f.name.contains("::"))
|
||||||
|
.map(|f| f.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total = structs.len() + enums.len() + traits.len() + functions.len();
|
||||||
|
let pub_count = count_pub_items(&file_syms.path);
|
||||||
|
let code_lines = loc_by_mod.get(&mod_name).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
let func_scores = complexity_by_mod
|
||||||
|
.get(&mod_name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let in_d = dep_analysis.in_degree.get(&mod_name).copied().unwrap_or(0);
|
||||||
|
let out_d = dep_analysis.out_degree.get(&mod_name).copied().unwrap_or(0);
|
||||||
|
let connectivity = in_d + out_d;
|
||||||
|
|
||||||
|
boxes.insert(
|
||||||
|
mod_name.clone(),
|
||||||
|
ModuleBox {
|
||||||
|
name: mod_name,
|
||||||
|
structs,
|
||||||
|
enums,
|
||||||
|
traits,
|
||||||
|
functions,
|
||||||
|
pub_count,
|
||||||
|
total_count: total,
|
||||||
|
code_lines,
|
||||||
|
func_scores,
|
||||||
|
connectivity,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count `pub fn`, `pub struct`, `pub enum`, `pub trait` in source file.
|
||||||
|
fn count_pub_items(path: &Path) -> usize {
|
||||||
|
let content = match std::fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let ast = match syn::parse_file(&content) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let mut count = 0;
|
||||||
|
for item in &ast.items {
|
||||||
|
match item {
|
||||||
|
syn::Item::Fn(f) => {
|
||||||
|
if matches!(f.vis, syn::Visibility::Public(_)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syn::Item::Struct(s) => {
|
||||||
|
if matches!(s.vis, syn::Visibility::Public(_)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syn::Item::Enum(e) => {
|
||||||
|
if matches!(e.vis, syn::Visibility::Public(_)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syn::Item::Trait(t) => {
|
||||||
|
if matches!(t.vis, syn::Visibility::Public(_)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute dependency depth tiers via BFS from root modules (zero in-degree).
|
||||||
|
fn compute_tiers(
|
||||||
|
module_boxes: &HashMap<String, ModuleBox>,
|
||||||
|
dep_analysis: &deps::DepAnalysis,
|
||||||
|
) -> HashMap<String, usize> {
|
||||||
|
let mut tiers: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut queue: VecDeque<String> = VecDeque::new();
|
||||||
|
|
||||||
|
// Build adjacency list: from -> [to] (follows dependency direction)
|
||||||
|
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
for edge in &dep_analysis.edges {
|
||||||
|
adj.entry(edge.from.as_str())
|
||||||
|
.or_default()
|
||||||
|
.push(edge.to.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start from modules with zero in-degree (entry points / roots)
|
||||||
|
for name in module_boxes.keys() {
|
||||||
|
let in_d = dep_analysis.in_degree.get(name).copied().unwrap_or(0);
|
||||||
|
if in_d == 0 {
|
||||||
|
tiers.insert(name.clone(), 0);
|
||||||
|
queue.push_back(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no roots found (all cycles), start everyone at tier 0
|
||||||
|
if queue.is_empty() {
|
||||||
|
for name in module_boxes.keys() {
|
||||||
|
tiers.insert(name.clone(), 0);
|
||||||
|
}
|
||||||
|
return tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS: propagate max depth. Limit iterations to avoid infinite loops on cycles.
|
||||||
|
let max_iterations = module_boxes.len() * module_boxes.len() + 1;
|
||||||
|
let mut iterations = 0;
|
||||||
|
while let Some(current) = queue.pop_front() {
|
||||||
|
iterations += 1;
|
||||||
|
if iterations > max_iterations {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let current_tier = tiers[¤t];
|
||||||
|
if let Some(neighbors) = adj.get(current.as_str()) {
|
||||||
|
for &neighbor in neighbors {
|
||||||
|
let new_tier = current_tier + 1;
|
||||||
|
let existing = tiers.get(neighbor).copied();
|
||||||
|
if existing.is_none() || new_tier > existing.unwrap() {
|
||||||
|
// Cap tier depth to prevent runaway on cycles
|
||||||
|
if new_tier <= module_boxes.len() {
|
||||||
|
tiers.insert(neighbor.to_string(), new_tier);
|
||||||
|
queue.push_back(neighbor.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all modules have a tier
|
||||||
|
for name in module_boxes.keys() {
|
||||||
|
tiers.entry(name.clone()).or_insert(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
tiers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute proportional height (content lines) based on LoC.
|
||||||
|
/// Minimum 1, maximum 6.
|
||||||
|
fn proportional_height(code_lines: usize, max_loc: usize) -> usize {
|
||||||
|
if max_loc == 0 || code_lines == 0 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let ratio = code_lines as f64 / max_loc as f64;
|
||||||
|
let height = (ratio * 5.0).ceil() as usize + 1;
|
||||||
|
height.clamp(1, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the header line for a module box.
|
||||||
|
fn render_box_header(
|
||||||
|
mbox: &ModuleBox,
|
||||||
|
box_width: usize,
|
||||||
|
is_hub: bool,
|
||||||
|
h: &str,
|
||||||
|
tl: &str,
|
||||||
|
tr: &str,
|
||||||
|
) -> String {
|
||||||
|
let header = format!(" {} ", mbox.name);
|
||||||
|
let pub_hint = format!("{}/{} pub", mbox.pub_count, mbox.total_count);
|
||||||
|
let loc_hint = format!("{} loc", mbox.code_lines);
|
||||||
|
let meta = format!("{} {}", loc_hint, pub_hint);
|
||||||
|
let fill_len = box_width
|
||||||
|
.saturating_sub(2)
|
||||||
|
.saturating_sub(header.len())
|
||||||
|
.saturating_sub(meta.len())
|
||||||
|
.saturating_sub(2);
|
||||||
|
let fill = h.repeat(fill_len);
|
||||||
|
|
||||||
|
if is_hub {
|
||||||
|
format!(
|
||||||
|
"{}{}{}{}{}{}",
|
||||||
|
tl.bold(),
|
||||||
|
h.repeat(1).bold(),
|
||||||
|
header.white().bold(),
|
||||||
|
fill.bold(),
|
||||||
|
format!(" {} ", meta).dimmed(),
|
||||||
|
tr.bold(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}{}{}{}{}{}",
|
||||||
|
tl.dimmed(),
|
||||||
|
h.repeat(1).dimmed(),
|
||||||
|
header.white().bold(),
|
||||||
|
fill.dimmed(),
|
||||||
|
format!(" {} ", meta).dimmed(),
|
||||||
|
tr.dimmed(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the content lines (heatbar, symbols) for a module box.
|
||||||
|
fn build_box_content(mbox: &ModuleBox, inner_w: usize, prop_height: usize) -> Vec<String> {
|
||||||
|
let mut content: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Complexity heatbar
|
||||||
|
let heatbar = build_heatbar(&mbox.func_scores, inner_w);
|
||||||
|
if !heatbar.is_empty() {
|
||||||
|
content.push(heatbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structs
|
||||||
|
if !mbox.structs.is_empty() {
|
||||||
|
let items = abbreviate_list(&mbox.structs, inner_w.saturating_sub(2));
|
||||||
|
content.push(format!("{} {}", "S".bright_green().bold(), items));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enums
|
||||||
|
if !mbox.enums.is_empty() {
|
||||||
|
let items = abbreviate_list(&mbox.enums, inner_w.saturating_sub(2));
|
||||||
|
content.push(format!("{} {}", "E".bright_yellow().bold(), items));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traits
|
||||||
|
if !mbox.traits.is_empty() {
|
||||||
|
let items = abbreviate_list(&mbox.traits, inner_w.saturating_sub(2));
|
||||||
|
content.push(format!("{} {}", "T".bright_magenta().bold(), items));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
if !mbox.functions.is_empty() {
|
||||||
|
let items = abbreviate_list(&mbox.functions, inner_w.saturating_sub(2));
|
||||||
|
content.push(format!("{} {}", "f".bright_blue(), items));
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.is_empty() {
|
||||||
|
content.push(format!("{}", "(empty)".dimmed()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad or trim to proportional height
|
||||||
|
while content.len() < prop_height {
|
||||||
|
content.push(String::new());
|
||||||
|
}
|
||||||
|
if content.len() > prop_height && prop_height > 0 {
|
||||||
|
content.truncate(prop_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render bordered content lines and the footer of a module box.
|
||||||
|
fn render_box_body(
|
||||||
|
content: &[String],
|
||||||
|
inner_w: usize,
|
||||||
|
box_width: usize,
|
||||||
|
is_hub: bool,
|
||||||
|
v: &str,
|
||||||
|
bl: &str,
|
||||||
|
br: &str,
|
||||||
|
h: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
for line in content {
|
||||||
|
let vis_len = render::visible_len(line);
|
||||||
|
let pad = inner_w.saturating_sub(vis_len);
|
||||||
|
if is_hub {
|
||||||
|
lines.push(format!(
|
||||||
|
"{} {}{} {}",
|
||||||
|
v.bold(),
|
||||||
|
line,
|
||||||
|
" ".repeat(pad),
|
||||||
|
v.bold()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
lines.push(format!(
|
||||||
|
"{} {}{} {}",
|
||||||
|
v.dimmed(),
|
||||||
|
line,
|
||||||
|
" ".repeat(pad),
|
||||||
|
v.dimmed()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
let footer_fill = box_width.saturating_sub(2);
|
||||||
|
if is_hub {
|
||||||
|
lines.push(format!(
|
||||||
|
"{}{}{}",
|
||||||
|
bl.bold(),
|
||||||
|
h.repeat(footer_fill).bold(),
|
||||||
|
br.bold()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
lines.push(format!(
|
||||||
|
"{}{}{}",
|
||||||
|
bl.dimmed(),
|
||||||
|
h.repeat(footer_fill).dimmed(),
|
||||||
|
br.dimmed()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a single module box, returning lines.
|
||||||
|
fn render_module_box(
|
||||||
|
mbox: &ModuleBox,
|
||||||
|
box_width: usize,
|
||||||
|
is_hub: bool,
|
||||||
|
prop_height: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let box_width = box_width.max(20);
|
||||||
|
let inner_w = box_width.saturating_sub(4); // "│ " + " │" = 4 chars
|
||||||
|
|
||||||
|
// Border characters: hubs get bold double-line borders
|
||||||
|
let (tl, tr, bl, br, h, v) = if is_hub {
|
||||||
|
("╔", "╗", "╚", "╝", "═", "║")
|
||||||
|
} else {
|
||||||
|
("┌", "┐", "└", "┘", "─", "│")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(render_box_header(mbox, box_width, is_hub, h, tl, tr));
|
||||||
|
let content = build_box_content(mbox, inner_w, prop_height);
|
||||||
|
lines.extend(render_box_body(
|
||||||
|
&content, inner_w, box_width, is_hub, v, bl, br, h,
|
||||||
|
));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a complexity heatbar from function scores.
|
||||||
|
fn build_heatbar(scores: &[f64], max_width: usize) -> String {
|
||||||
|
if scores.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
let max_score = scores.iter().cloned().fold(0.0f64, f64::max).max(1.0);
|
||||||
|
|
||||||
|
let bar_width = max_width.min(scores.len());
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
if scores.len() <= bar_width {
|
||||||
|
let mut sorted_scores = scores.to_vec();
|
||||||
|
sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
for &score in &sorted_scores {
|
||||||
|
let ratio = score / max_score;
|
||||||
|
let idx = (ratio * (blocks.len() - 1) as f64).round() as usize;
|
||||||
|
let idx = idx.min(blocks.len() - 1);
|
||||||
|
let ch = blocks[idx].to_string();
|
||||||
|
let colored = if ratio < 0.33 {
|
||||||
|
ch.green().to_string()
|
||||||
|
} else if ratio < 0.66 {
|
||||||
|
ch.yellow().to_string()
|
||||||
|
} else {
|
||||||
|
ch.red().to_string()
|
||||||
|
};
|
||||||
|
result.push_str(&colored);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let bucket_size = (scores.len() as f64 / bar_width as f64).ceil() as usize;
|
||||||
|
let mut sorted_scores = scores.to_vec();
|
||||||
|
sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
for chunk in sorted_scores.chunks(bucket_size) {
|
||||||
|
let bucket_max = chunk.iter().cloned().fold(0.0f64, f64::max);
|
||||||
|
let ratio = bucket_max / max_score;
|
||||||
|
let idx = (ratio * (blocks.len() - 1) as f64).round() as usize;
|
||||||
|
let idx = idx.min(blocks.len() - 1);
|
||||||
|
let ch = blocks[idx].to_string();
|
||||||
|
let colored = if ratio < 0.33 {
|
||||||
|
ch.green().to_string()
|
||||||
|
} else if ratio < 0.66 {
|
||||||
|
ch.yellow().to_string()
|
||||||
|
} else {
|
||||||
|
ch.red().to_string()
|
||||||
|
};
|
||||||
|
result.push_str(&colored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abbreviate a list of names to fit within the given width.
|
||||||
|
fn abbreviate_list(names: &[String], max_width: usize) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut remaining = names.len();
|
||||||
|
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
let separator = if i > 0 { ", " } else { "" };
|
||||||
|
let addition = format!("{}{}", separator, name);
|
||||||
|
|
||||||
|
if result.len() + addition.len() > max_width && i > 0 {
|
||||||
|
let more = remaining;
|
||||||
|
result.push_str(&format!(" +{}", more));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str(&addition);
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ModuleMetadataJson {
|
||||||
|
name: String,
|
||||||
|
structs: Vec<String>,
|
||||||
|
enums: Vec<String>,
|
||||||
|
traits: Vec<String>,
|
||||||
|
functions: Vec<String>,
|
||||||
|
pub_count: usize,
|
||||||
|
total_count: usize,
|
||||||
|
code_lines: usize,
|
||||||
|
tier: usize,
|
||||||
|
is_hub: bool,
|
||||||
|
complexity_scores: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MapJson {
|
||||||
|
cstat_version: String,
|
||||||
|
modules: Vec<ModuleMetadataJson>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render module map as JSON.
|
||||||
|
pub fn render_map_json(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
|
let dep_analysis = deps::analyze_deps(rs_files, project_path);
|
||||||
|
|
||||||
|
let (file_stats, _) = loc::analyze_files(rs_files);
|
||||||
|
let mut loc_by_mod: HashMap<String, usize> = HashMap::new();
|
||||||
|
for stat in &file_stats {
|
||||||
|
let mod_name = render::module_name_from_path(&stat.path, project_path);
|
||||||
|
loc_by_mod.insert(mod_name, stat.code_lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
let func_complexities = complexity::compute_all(&symbols, project_path);
|
||||||
|
let mut complexity_by_mod: HashMap<String, Vec<f64>> = HashMap::new();
|
||||||
|
for fc in &func_complexities {
|
||||||
|
let mod_name = file_to_module_name(&fc.file);
|
||||||
|
complexity_by_mod
|
||||||
|
.entry(mod_name)
|
||||||
|
.or_default()
|
||||||
|
.push(fc.composite_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
let module_boxes = build_module_boxes(
|
||||||
|
&symbols,
|
||||||
|
project_path,
|
||||||
|
&loc_by_mod,
|
||||||
|
&complexity_by_mod,
|
||||||
|
&dep_analysis,
|
||||||
|
);
|
||||||
|
|
||||||
|
let tiers = compute_tiers(&module_boxes, &dep_analysis);
|
||||||
|
|
||||||
|
let connectivities: Vec<usize> = module_boxes.values().map(|m| m.connectivity).collect();
|
||||||
|
let mean_conn = if connectivities.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
connectivities.iter().sum::<usize>() as f64 / connectivities.len() as f64
|
||||||
|
};
|
||||||
|
let hub_threshold = (mean_conn + 1.0) as usize;
|
||||||
|
|
||||||
|
let mut modules: Vec<ModuleMetadataJson> = module_boxes
|
||||||
|
.values()
|
||||||
|
.map(|mbox| ModuleMetadataJson {
|
||||||
|
name: mbox.name.clone(),
|
||||||
|
structs: mbox.structs.clone(),
|
||||||
|
enums: mbox.enums.clone(),
|
||||||
|
traits: mbox.traits.clone(),
|
||||||
|
functions: mbox.functions.clone(),
|
||||||
|
pub_count: mbox.pub_count,
|
||||||
|
total_count: mbox.total_count,
|
||||||
|
code_lines: mbox.code_lines,
|
||||||
|
tier: tiers.get(&mbox.name).copied().unwrap_or(0),
|
||||||
|
is_hub: mbox.connectivity >= hub_threshold && hub_threshold > 0,
|
||||||
|
complexity_scores: mbox.func_scores.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
modules.sort_by(|a, b| a.tier.cmp(&b.tier).then(b.code_lines.cmp(&a.code_lines)));
|
||||||
|
|
||||||
|
let output = MapJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
modules,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
673
src/redundancy.rs
Normal file
673
src/redundancy.rs
Normal file
|
|
@ -0,0 +1,673 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use syn::visit::Visit;
|
||||||
|
use syn::{Expr, File as SynFile, ImplItem, Item, ReturnType, Type};
|
||||||
|
|
||||||
|
use crate::render;
|
||||||
|
|
||||||
|
/// Information about a function sufficient for redundancy comparison.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct FunctionSig {
|
||||||
|
name: String,
|
||||||
|
file: String,
|
||||||
|
line_start: usize,
|
||||||
|
param_types: Vec<String>,
|
||||||
|
return_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pair of functions flagged as potentially redundant.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RedundantPair {
|
||||||
|
pub func_a: String,
|
||||||
|
pub file_a: String,
|
||||||
|
pub line_a: usize,
|
||||||
|
pub func_b: String,
|
||||||
|
pub file_b: String,
|
||||||
|
pub line_b: usize,
|
||||||
|
pub similarity: f64,
|
||||||
|
pub kind: SimilarityKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum SimilarityKind {
|
||||||
|
Signature,
|
||||||
|
Structural,
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SimilarityKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SimilarityKind::Signature => write!(f, "signature"),
|
||||||
|
SimilarityKind::Structural => write!(f, "structural"),
|
||||||
|
SimilarityKind::Both => write!(f, "both"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signature extraction ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extract function signatures from all project files.
|
||||||
|
fn extract_signatures(rs_files: &[PathBuf], project_path: &Path) -> Vec<FunctionSig> {
|
||||||
|
let mut sigs = Vec::new();
|
||||||
|
|
||||||
|
for file in rs_files {
|
||||||
|
let content = match std::fs::read_to_string(file) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ast: SynFile = match syn::parse_file(&content) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rel_path = file
|
||||||
|
.strip_prefix(project_path)
|
||||||
|
.unwrap_or(file)
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
for item in &ast.items {
|
||||||
|
match item {
|
||||||
|
Item::Fn(func) => {
|
||||||
|
let sig = extract_fn_sig(&func.sig, &func.sig.ident.to_string(), &rel_path);
|
||||||
|
sigs.push(sig);
|
||||||
|
}
|
||||||
|
Item::Impl(imp) => {
|
||||||
|
let type_name = type_to_short(&imp.self_ty);
|
||||||
|
for impl_item in &imp.items {
|
||||||
|
if let ImplItem::Fn(method) = impl_item {
|
||||||
|
let qualified = format!("{}::{}", type_name, method.sig.ident);
|
||||||
|
let sig = extract_fn_sig(&method.sig, &qualified, &rel_path);
|
||||||
|
sigs.push(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_fn_sig(sig: &syn::Signature, name: &str, file: &str) -> FunctionSig {
|
||||||
|
let param_types: Vec<String> = sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|arg| match arg {
|
||||||
|
syn::FnArg::Typed(pat_type) => Some(type_to_short(&pat_type.ty)),
|
||||||
|
syn::FnArg::Receiver(_) => Some("self".to_string()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let return_type = match &sig.output {
|
||||||
|
ReturnType::Default => "()".to_string(),
|
||||||
|
ReturnType::Type(_, ty) => type_to_short(ty),
|
||||||
|
};
|
||||||
|
|
||||||
|
let line_start = sig.fn_token.span.start().line;
|
||||||
|
|
||||||
|
FunctionSig {
|
||||||
|
name: name.to_string(),
|
||||||
|
file: file.to_string(),
|
||||||
|
line_start,
|
||||||
|
param_types,
|
||||||
|
return_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_to_short(ty: &Type) -> String {
|
||||||
|
match ty {
|
||||||
|
Type::Path(tp) => tp
|
||||||
|
.path
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let ident = s.ident.to_string();
|
||||||
|
if let syn::PathArguments::AngleBracketed(args) = &s.arguments {
|
||||||
|
let inner: Vec<String> = args
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| {
|
||||||
|
if let syn::GenericArgument::Type(t) = a {
|
||||||
|
Some(type_to_short(t))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if inner.is_empty() {
|
||||||
|
ident
|
||||||
|
} else {
|
||||||
|
format!("{}<{}>", ident, inner.join(", "))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ident
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("::"),
|
||||||
|
Type::Reference(r) => {
|
||||||
|
let mutability = if r.mutability.is_some() { "&mut " } else { "&" };
|
||||||
|
format!("{}{}", mutability, type_to_short(&r.elem))
|
||||||
|
}
|
||||||
|
Type::Tuple(t) => {
|
||||||
|
let inner: Vec<String> = t.elems.iter().map(type_to_short).collect();
|
||||||
|
format!("({})", inner.join(", "))
|
||||||
|
}
|
||||||
|
Type::Slice(s) => format!("[{}]", type_to_short(&s.elem)),
|
||||||
|
Type::Array(a) => format!("[{}; _]", type_to_short(&a.elem)),
|
||||||
|
Type::ImplTrait(_) => "impl Trait".to_string(),
|
||||||
|
Type::TraitObject(_) => "dyn Trait".to_string(),
|
||||||
|
_ => "?".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pass 1: Signature similarity ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Compute normalized Levenshtein distance between two strings (0.0 = identical, 1.0 = completely different).
|
||||||
|
fn levenshtein_distance(a: &str, b: &str) -> f64 {
|
||||||
|
let a_chars: Vec<char> = a.chars().collect();
|
||||||
|
let b_chars: Vec<char> = b.chars().collect();
|
||||||
|
let m = a_chars.len();
|
||||||
|
let n = b_chars.len();
|
||||||
|
|
||||||
|
if m == 0 && n == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if m == 0 || n == 0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prev = vec![0usize; n + 1];
|
||||||
|
let mut curr = vec![0usize; n + 1];
|
||||||
|
|
||||||
|
for j in 0..=n {
|
||||||
|
prev[j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 1..=m {
|
||||||
|
curr[0] = i;
|
||||||
|
for j in 1..=n {
|
||||||
|
let cost = if a_chars[i - 1] == b_chars[j - 1] {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev, &mut curr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_len = m.max(n);
|
||||||
|
prev[n] as f64 / max_len as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name similarity: 1.0 - normalized_levenshtein. Also checks substring containment.
|
||||||
|
fn name_similarity(a: &str, b: &str) -> f64 {
|
||||||
|
// Use the "bare" name (after last ::) for comparison
|
||||||
|
let a_bare = a.rsplit("::").next().unwrap_or(a);
|
||||||
|
let b_bare = b.rsplit("::").next().unwrap_or(b);
|
||||||
|
|
||||||
|
let lev_sim = 1.0 - levenshtein_distance(a_bare, b_bare);
|
||||||
|
|
||||||
|
// Substring containment bonus
|
||||||
|
let substr_sim = if a_bare.len() >= 3 && b_bare.len() >= 3 {
|
||||||
|
if a_bare.contains(b_bare) || b_bare.contains(a_bare) {
|
||||||
|
let shorter = a_bare.len().min(b_bare.len()) as f64;
|
||||||
|
let longer = a_bare.len().max(b_bare.len()) as f64;
|
||||||
|
shorter / longer
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
lev_sim.max(substr_sim)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jaccard index on parameter type sets.
|
||||||
|
fn param_type_jaccard(a: &[String], b: &[String]) -> f64 {
|
||||||
|
if a.is_empty() && b.is_empty() {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
if a.is_empty() || b.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let set_a: HashSet<&str> = a.iter().map(|s| s.as_str()).collect();
|
||||||
|
let set_b: HashSet<&str> = b.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
let intersection = set_a.intersection(&set_b).count() as f64;
|
||||||
|
let union = set_a.union(&set_b).count() as f64;
|
||||||
|
|
||||||
|
if union == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
intersection / union
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute signature similarity between two functions. Returns 0.0..1.0.
|
||||||
|
fn signature_similarity(a: &FunctionSig, b: &FunctionSig) -> f64 {
|
||||||
|
let name_sim = name_similarity(&a.name, &b.name);
|
||||||
|
let param_sim = param_type_jaccard(&a.param_types, &b.param_types);
|
||||||
|
let ret_sim = if a.return_type == b.return_type {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weighted combination
|
||||||
|
0.4 * name_sim + 0.4 * param_sim + 0.2 * ret_sim
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find signature-similar pairs above a threshold.
|
||||||
|
fn find_signature_pairs(sigs: &[FunctionSig], threshold: f64) -> Vec<(usize, usize, f64)> {
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
|
||||||
|
for i in 0..sigs.len() {
|
||||||
|
for j in (i + 1)..sigs.len() {
|
||||||
|
// Skip trivially tiny functions (getters, etc.)
|
||||||
|
if sigs[i].param_types.is_empty()
|
||||||
|
&& sigs[j].param_types.is_empty()
|
||||||
|
&& sigs[i].return_type == "()"
|
||||||
|
&& sigs[j].return_type == "()"
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sim = signature_similarity(&sigs[i], &sigs[j]);
|
||||||
|
if sim >= threshold {
|
||||||
|
pairs.push((i, j, sim));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pass 2: Structural similarity ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Normalized AST node kind (stripping identifiers and literals).
|
||||||
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||||
|
enum NormNode {
|
||||||
|
Block(usize), // number of statements
|
||||||
|
If,
|
||||||
|
Match(usize), // number of arms
|
||||||
|
For,
|
||||||
|
While,
|
||||||
|
Loop,
|
||||||
|
Let,
|
||||||
|
Return,
|
||||||
|
Call(usize), // number of arguments
|
||||||
|
MethodCall(usize),
|
||||||
|
Binary,
|
||||||
|
Unary,
|
||||||
|
Closure(usize), // number of params
|
||||||
|
Index,
|
||||||
|
Field,
|
||||||
|
Assign,
|
||||||
|
Break,
|
||||||
|
Continue,
|
||||||
|
Literal,
|
||||||
|
Path,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a structural hash from a function body by normalizing the AST.
|
||||||
|
fn structural_hash_from_file(
|
||||||
|
file_path: &Path,
|
||||||
|
_project_path: &Path,
|
||||||
|
func_name: &str,
|
||||||
|
) -> Option<u64> {
|
||||||
|
let content = std::fs::read_to_string(file_path).ok()?;
|
||||||
|
let ast = syn::parse_file(&content).ok()?;
|
||||||
|
|
||||||
|
for item in &ast.items {
|
||||||
|
match item {
|
||||||
|
Item::Fn(func) => {
|
||||||
|
if func.sig.ident == func_name || func_name == func.sig.ident.to_string() {
|
||||||
|
return Some(hash_block(&func.block));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Item::Impl(imp) => {
|
||||||
|
let type_name = type_to_short(&imp.self_ty);
|
||||||
|
for impl_item in &imp.items {
|
||||||
|
if let ImplItem::Fn(method) = impl_item {
|
||||||
|
let qualified = format!("{}::{}", type_name, method.sig.ident);
|
||||||
|
if qualified == func_name {
|
||||||
|
return Some(hash_block(&method.block));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a block by normalizing its structure.
|
||||||
|
fn hash_block(block: &syn::Block) -> u64 {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
let mut visitor = StructuralVisitor { nodes: &mut nodes };
|
||||||
|
visitor.visit_block(block);
|
||||||
|
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
nodes.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StructuralVisitor<'a> {
|
||||||
|
nodes: &'a mut Vec<NormNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'ast> Visit<'ast> for StructuralVisitor<'a> {
|
||||||
|
fn visit_block(&mut self, block: &'ast syn::Block) {
|
||||||
|
self.nodes.push(NormNode::Block(block.stmts.len()));
|
||||||
|
syn::visit::visit_block(self, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_expr(&mut self, expr: &'ast Expr) {
|
||||||
|
match expr {
|
||||||
|
Expr::If(_) => self.nodes.push(NormNode::If),
|
||||||
|
Expr::Match(m) => self.nodes.push(NormNode::Match(m.arms.len())),
|
||||||
|
Expr::ForLoop(_) => self.nodes.push(NormNode::For),
|
||||||
|
Expr::While(_) => self.nodes.push(NormNode::While),
|
||||||
|
Expr::Loop(_) => self.nodes.push(NormNode::Loop),
|
||||||
|
Expr::Return(_) => self.nodes.push(NormNode::Return),
|
||||||
|
Expr::Call(c) => self.nodes.push(NormNode::Call(c.args.len())),
|
||||||
|
Expr::MethodCall(m) => self.nodes.push(NormNode::MethodCall(m.args.len())),
|
||||||
|
Expr::Binary(_) => self.nodes.push(NormNode::Binary),
|
||||||
|
Expr::Unary(_) => self.nodes.push(NormNode::Unary),
|
||||||
|
Expr::Closure(c) => self.nodes.push(NormNode::Closure(c.inputs.len())),
|
||||||
|
Expr::Index(_) => self.nodes.push(NormNode::Index),
|
||||||
|
Expr::Field(_) => self.nodes.push(NormNode::Field),
|
||||||
|
Expr::Assign(_) => self.nodes.push(NormNode::Assign),
|
||||||
|
Expr::Break(_) => self.nodes.push(NormNode::Break),
|
||||||
|
Expr::Continue(_) => self.nodes.push(NormNode::Continue),
|
||||||
|
Expr::Lit(_) => self.nodes.push(NormNode::Literal),
|
||||||
|
Expr::Path(_) => self.nodes.push(NormNode::Path),
|
||||||
|
_ => self.nodes.push(NormNode::Other),
|
||||||
|
}
|
||||||
|
syn::visit::visit_expr(self, expr);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_local(&mut self, _local: &'ast syn::Local) {
|
||||||
|
self.nodes.push(NormNode::Let);
|
||||||
|
syn::visit::visit_local(self, _local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analysis entry point ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SIG_THRESHOLD: f64 = 0.6;
|
||||||
|
const REPORT_THRESHOLD: f64 = 0.7;
|
||||||
|
|
||||||
|
/// Find structural duplicate pairs from functions sharing identical AST hashes.
|
||||||
|
fn find_structural_duplicates(
|
||||||
|
sigs: &[FunctionSig],
|
||||||
|
structural_hashes: &HashMap<usize, u64>,
|
||||||
|
) -> (Vec<RedundantPair>, HashSet<(usize, usize)>) {
|
||||||
|
let mut hash_to_indices: HashMap<u64, Vec<usize>> = HashMap::new();
|
||||||
|
for (&idx, &hash) in structural_hashes {
|
||||||
|
hash_to_indices.entry(hash).or_default().push(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results: Vec<RedundantPair> = Vec::new();
|
||||||
|
let mut seen_pairs: HashSet<(usize, usize)> = HashSet::new();
|
||||||
|
|
||||||
|
for indices in hash_to_indices.values() {
|
||||||
|
if indices.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for i in 0..indices.len() {
|
||||||
|
for j in (i + 1)..indices.len() {
|
||||||
|
let a = indices[i].min(indices[j]);
|
||||||
|
let b = indices[i].max(indices[j]);
|
||||||
|
if seen_pairs.insert((a, b)) {
|
||||||
|
let sig_sim = signature_similarity(&sigs[a], &sigs[b]);
|
||||||
|
let kind = if sig_sim >= SIG_THRESHOLD {
|
||||||
|
SimilarityKind::Both
|
||||||
|
} else {
|
||||||
|
SimilarityKind::Structural
|
||||||
|
};
|
||||||
|
results.push(RedundantPair {
|
||||||
|
func_a: sigs[a].name.clone(),
|
||||||
|
file_a: sigs[a].file.clone(),
|
||||||
|
line_a: sigs[a].line_start,
|
||||||
|
func_b: sigs[b].name.clone(),
|
||||||
|
file_b: sigs[b].file.clone(),
|
||||||
|
line_b: sigs[b].line_start,
|
||||||
|
similarity: 1.0,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(results, seen_pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge signature-similar pairs that were not already found as structural duplicates.
|
||||||
|
fn merge_signature_pairs(
|
||||||
|
sig_pairs: &[(usize, usize, f64)],
|
||||||
|
sigs: &[FunctionSig],
|
||||||
|
structural_hashes: &HashMap<usize, u64>,
|
||||||
|
seen_pairs: &mut HashSet<(usize, usize)>,
|
||||||
|
) -> Vec<RedundantPair> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for (i, j, sim) in sig_pairs {
|
||||||
|
let a = (*i).min(*j);
|
||||||
|
let b = (*i).max(*j);
|
||||||
|
if seen_pairs.contains(&(a, b)) || *sim < REPORT_THRESHOLD {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let struct_match = match (structural_hashes.get(&a), structural_hashes.get(&b)) {
|
||||||
|
(Some(ha), Some(hb)) => ha == hb,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (final_sim, kind) = if struct_match {
|
||||||
|
(1.0, SimilarityKind::Both)
|
||||||
|
} else {
|
||||||
|
(*sim, SimilarityKind::Signature)
|
||||||
|
};
|
||||||
|
|
||||||
|
seen_pairs.insert((a, b));
|
||||||
|
results.push(RedundantPair {
|
||||||
|
func_a: sigs[a].name.clone(),
|
||||||
|
file_a: sigs[a].file.clone(),
|
||||||
|
line_a: sigs[a].line_start,
|
||||||
|
func_b: sigs[b].name.clone(),
|
||||||
|
file_b: sigs[b].file.clone(),
|
||||||
|
line_b: sigs[b].line_start,
|
||||||
|
similarity: final_sim,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run redundancy detection on the project. Returns pairs sorted by similarity descending.
|
||||||
|
pub fn detect_redundancy(rs_files: &[PathBuf], project_path: &Path) -> Vec<RedundantPair> {
|
||||||
|
let sigs = extract_signatures(rs_files, project_path);
|
||||||
|
|
||||||
|
if sigs.len() < 2 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: signature similarity
|
||||||
|
let sig_pairs = find_signature_pairs(&sigs, SIG_THRESHOLD);
|
||||||
|
|
||||||
|
// Pass 2: structural hashes for all functions
|
||||||
|
let mut structural_hashes: HashMap<usize, u64> = HashMap::new();
|
||||||
|
for (i, sig) in sigs.iter().enumerate() {
|
||||||
|
let full_path = project_path.join(&sig.file);
|
||||||
|
if let Some(hash) = structural_hash_from_file(&full_path, project_path, &sig.name) {
|
||||||
|
structural_hashes.insert(i, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find structural duplicates
|
||||||
|
let (mut results, mut seen_pairs) = find_structural_duplicates(&sigs, &structural_hashes);
|
||||||
|
|
||||||
|
// Merge in signature-similar pairs
|
||||||
|
let sig_results = merge_signature_pairs(&sig_pairs, &sigs, &structural_hashes, &mut seen_pairs);
|
||||||
|
results.extend(sig_results);
|
||||||
|
|
||||||
|
// Sort by similarity descending
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.similarity
|
||||||
|
.partial_cmp(&a.similarity)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Human-readable rendering ─────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn render_redundancy(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
|
||||||
|
let pairs = detect_redundancy(rs_files, project_path);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{}",
|
||||||
|
"── Redundancy Detection ──────────────────────────────────"
|
||||||
|
.bright_cyan()
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Detects suspected duplicate or near-duplicate functions.",
|
||||||
|
"Pass 1: Signature similarity — compares function names, parameter types, and return types.",
|
||||||
|
"Pass 2: Structural similarity — normalizes ASTs (strips identifiers/literals) and compares hashes.",
|
||||||
|
"Similarity 1.00 = structurally identical. >= 0.70 = signature-similar candidates.",
|
||||||
|
"Kind: 'signature' = similar signatures, 'structural' = identical AST structure, 'both' = both match.",
|
||||||
|
]);
|
||||||
|
render::guide_ref("redundancy");
|
||||||
|
}
|
||||||
|
|
||||||
|
if pairs.is_empty() {
|
||||||
|
println!("\n {}", "No suspected duplicates found.".green());
|
||||||
|
println!();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n {} suspected duplicate pair(s) found:\n",
|
||||||
|
format!("{}", pairs.len()).bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
println!(
|
||||||
|
" {:<35} {:<35} {:>5} {}",
|
||||||
|
"Function A".bold().underline(),
|
||||||
|
"Function B".bold().underline(),
|
||||||
|
"Sim".bold().underline(),
|
||||||
|
"Kind".bold().underline(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for pair in &pairs {
|
||||||
|
let name_a = if pair.func_a.len() > 33 {
|
||||||
|
format!("{}...", &pair.func_a[..30])
|
||||||
|
} else {
|
||||||
|
pair.func_a.clone()
|
||||||
|
};
|
||||||
|
let name_b = if pair.func_b.len() > 33 {
|
||||||
|
format!("{}...", &pair.func_b[..30])
|
||||||
|
} else {
|
||||||
|
pair.func_b.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sim_str = format!("{:.2}", pair.similarity);
|
||||||
|
let colored_sim = if pair.similarity >= 0.9 {
|
||||||
|
sim_str.red().bold().to_string()
|
||||||
|
} else {
|
||||||
|
sim_str.yellow().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let kind_str = match pair.kind {
|
||||||
|
SimilarityKind::Both => "both".red().to_string(),
|
||||||
|
SimilarityKind::Structural => "structural".yellow().to_string(),
|
||||||
|
SimilarityKind::Signature => "signature".dimmed().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:<35} {:<35} {:>5} {}",
|
||||||
|
name_a.dimmed(),
|
||||||
|
name_b.dimmed(),
|
||||||
|
colored_sim,
|
||||||
|
kind_str,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {:<35} {}",
|
||||||
|
format!(" {}:{}", pair.file_a, pair.line_a).dimmed(),
|
||||||
|
format!(" {}:{}", pair.file_b, pair.line_b).dimmed(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON rendering ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RedundantPairJson {
|
||||||
|
func_a: String,
|
||||||
|
file_a: String,
|
||||||
|
line_a: usize,
|
||||||
|
func_b: String,
|
||||||
|
file_b: String,
|
||||||
|
line_b: usize,
|
||||||
|
similarity: f64,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RedundancyJson {
|
||||||
|
cstat_version: String,
|
||||||
|
redundant_pairs: Vec<RedundantPairJson>,
|
||||||
|
total_pairs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_redundancy_json(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
|
let pairs = detect_redundancy(rs_files, project_path);
|
||||||
|
|
||||||
|
let total = pairs.len();
|
||||||
|
let json_pairs: Vec<RedundantPairJson> = pairs
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| RedundantPairJson {
|
||||||
|
func_a: p.func_a,
|
||||||
|
file_a: p.file_a,
|
||||||
|
line_a: p.line_a,
|
||||||
|
func_b: p.func_b,
|
||||||
|
file_b: p.file_b,
|
||||||
|
line_b: p.line_b,
|
||||||
|
similarity: p.similarity,
|
||||||
|
kind: p.kind.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let output = RedundancyJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
redundant_pairs: json_pairs,
|
||||||
|
total_pairs: total,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
298
src/report.rs
Normal file
298
src/report.rs
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::{ast_parser, datapaths, dead_code, deps, flow, loc, render, symbols};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LineCountFile {
|
||||||
|
pub path: String,
|
||||||
|
pub total_lines: usize,
|
||||||
|
pub code_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LineCountReport {
|
||||||
|
pub total_files: usize,
|
||||||
|
pub total_lines: usize,
|
||||||
|
pub total_code_lines: usize,
|
||||||
|
pub mean_code_lines: f64,
|
||||||
|
pub median_code_lines: usize,
|
||||||
|
pub files: Vec<LineCountFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ModuleDegree {
|
||||||
|
pub module: String,
|
||||||
|
pub fan_in: usize,
|
||||||
|
pub fan_out: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ModuleDependencyMatrix {
|
||||||
|
pub modules: Vec<String>,
|
||||||
|
pub matrix: Vec<Vec<usize>>,
|
||||||
|
pub edges: usize,
|
||||||
|
pub degrees: Vec<ModuleDegree>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FocusReport {
|
||||||
|
pub cstat_version: String,
|
||||||
|
pub project: String,
|
||||||
|
pub line_counts: LineCountReport,
|
||||||
|
pub symbols: symbols::SymbolReport,
|
||||||
|
pub dependencies: ModuleDependencyMatrix,
|
||||||
|
pub dead_code: dead_code::DeadCodeReport,
|
||||||
|
pub test_reachability: datapaths::TestReachabilityAnalysis,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_report(rs_files: &[PathBuf], project_path: &Path) -> FocusReport {
|
||||||
|
let project_path = project_path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| project_path.to_path_buf());
|
||||||
|
let project = project_path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let (file_stats, aggregate) = loc::analyze_files(rs_files);
|
||||||
|
let symbols_raw = ast_parser::parse_project(rs_files);
|
||||||
|
let dep_analysis = deps::analyze_deps(rs_files, &project_path);
|
||||||
|
let call_graph = flow::build_call_graph(rs_files, &project_path);
|
||||||
|
|
||||||
|
FocusReport {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
project,
|
||||||
|
line_counts: build_line_report(&file_stats, aggregate.as_ref(), &project_path),
|
||||||
|
symbols: symbols::analyze_symbols(&symbols_raw, &project_path),
|
||||||
|
dependencies: build_dependency_matrix(&dep_analysis),
|
||||||
|
dead_code: dead_code::analyze_graph(&call_graph),
|
||||||
|
test_reachability: datapaths::analyze_graph_test_reachability(&call_graph),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_report(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
|
||||||
|
let report = build_report(rs_files, project_path);
|
||||||
|
render_report_data(&report, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_report_json(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
|
let report = build_report(rs_files, project_path);
|
||||||
|
println!("{}", serde_json::to_string(&report).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_report_data(report: &FocusReport, verbose: bool) {
|
||||||
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
"cstat focused report".bright_cyan().bold(),
|
||||||
|
format!("({})", report.project).dimmed()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
"Objective module metrics: size, symbols, dependencies, dead-code candidates, and test/bench reachability."
|
||||||
|
.dimmed()
|
||||||
|
);
|
||||||
|
|
||||||
|
render_line_counts(&report.line_counts, verbose);
|
||||||
|
symbols::render_symbol_report(&report.symbols, verbose);
|
||||||
|
render_dependency_matrix(&report.dependencies, verbose);
|
||||||
|
dead_code::render_dead_code_report(&report.dead_code, verbose);
|
||||||
|
datapaths::render_test_reachability_report(&report.test_reachability, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_line_report(
|
||||||
|
file_stats: &[loc::FileLocStats],
|
||||||
|
aggregate: Option<&loc::AggregateStats>,
|
||||||
|
project_path: &Path,
|
||||||
|
) -> LineCountReport {
|
||||||
|
let total_lines = file_stats.iter().map(|file| file.total_lines).sum();
|
||||||
|
let files: Vec<LineCountFile> = file_stats
|
||||||
|
.iter()
|
||||||
|
.map(|file| LineCountFile {
|
||||||
|
path: file
|
||||||
|
.path
|
||||||
|
.strip_prefix(project_path)
|
||||||
|
.unwrap_or(&file.path)
|
||||||
|
.display()
|
||||||
|
.to_string(),
|
||||||
|
total_lines: file.total_lines,
|
||||||
|
code_lines: file.code_lines,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
LineCountReport {
|
||||||
|
total_files: aggregate.map_or(file_stats.len(), |agg| agg.total_files),
|
||||||
|
total_lines,
|
||||||
|
total_code_lines: aggregate.map_or(0, |agg| agg.total_loc),
|
||||||
|
mean_code_lines: aggregate.map_or(0.0, |agg| agg.mean),
|
||||||
|
median_code_lines: aggregate.map_or(0, |agg| agg.median),
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_dependency_matrix(dep_analysis: &deps::DepAnalysis) -> ModuleDependencyMatrix {
|
||||||
|
let modules = dep_analysis.modules.clone();
|
||||||
|
let module_index: HashMap<&str, usize> = modules
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, module)| (module.as_str(), idx))
|
||||||
|
.collect();
|
||||||
|
let mut matrix = vec![vec![0usize; modules.len()]; modules.len()];
|
||||||
|
|
||||||
|
for edge in &dep_analysis.edges {
|
||||||
|
if let (Some(from), Some(to)) = (
|
||||||
|
module_index.get(edge.from.as_str()),
|
||||||
|
module_index.get(edge.to.as_str()),
|
||||||
|
) {
|
||||||
|
matrix[*from][*to] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut degrees: Vec<ModuleDegree> = modules
|
||||||
|
.iter()
|
||||||
|
.map(|module| ModuleDegree {
|
||||||
|
module: module.clone(),
|
||||||
|
fan_in: dep_analysis.in_degree.get(module).copied().unwrap_or(0),
|
||||||
|
fan_out: dep_analysis.out_degree.get(module).copied().unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
degrees.sort_by(|a, b| {
|
||||||
|
(b.fan_in + b.fan_out)
|
||||||
|
.cmp(&(a.fan_in + a.fan_out))
|
||||||
|
.then(a.module.cmp(&b.module))
|
||||||
|
});
|
||||||
|
|
||||||
|
ModuleDependencyMatrix {
|
||||||
|
modules,
|
||||||
|
matrix,
|
||||||
|
edges: dep_analysis.edges.len(),
|
||||||
|
degrees,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_line_counts(report: &LineCountReport, verbose: bool) {
|
||||||
|
render::section_header("Line counts");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Code lines exclude blank lines and comment-only lines.",
|
||||||
|
"Physical lines are kept beside code lines so generated or dense files are visible.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} {} {} {} {:.1} {} {}",
|
||||||
|
"files".cyan(),
|
||||||
|
report.total_files.to_string().bold(),
|
||||||
|
"code lines".cyan(),
|
||||||
|
report.total_code_lines.to_string().bold(),
|
||||||
|
"mean".cyan(),
|
||||||
|
report.mean_code_lines,
|
||||||
|
"median".cyan(),
|
||||||
|
report.median_code_lines.to_string().bold(),
|
||||||
|
);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {:<56} {:>8} {:>8}",
|
||||||
|
"file".bold(),
|
||||||
|
"code".bold(),
|
||||||
|
"physical".bold()
|
||||||
|
);
|
||||||
|
for file in &report.files {
|
||||||
|
println!(
|
||||||
|
" {:<56} {:>8} {:>8}",
|
||||||
|
truncate(&file.path, 56),
|
||||||
|
file.code_lines,
|
||||||
|
file.total_lines,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_dependency_matrix(report: &ModuleDependencyMatrix, verbose: bool) {
|
||||||
|
render::section_header("Module dependency matrix");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Rows are modules that depend on columns.",
|
||||||
|
"A 1 means at least one source-level use/mod edge from row to column.",
|
||||||
|
"The index list keeps the matrix readable for medium-sized crates.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} {} {}",
|
||||||
|
"modules".cyan(),
|
||||||
|
report.modules.len().to_string().bold(),
|
||||||
|
"edges".cyan(),
|
||||||
|
report.edges.to_string().bold(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if report.modules.is_empty() {
|
||||||
|
println!(" {}", "No modules found.".yellow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
for (idx, module) in report.modules.iter().enumerate() {
|
||||||
|
println!(" [{:>2}] {}", idx, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cell_width = report
|
||||||
|
.modules
|
||||||
|
.len()
|
||||||
|
.saturating_sub(1)
|
||||||
|
.to_string()
|
||||||
|
.len()
|
||||||
|
.max(1);
|
||||||
|
println!();
|
||||||
|
print!(" {:>3} │", "");
|
||||||
|
for idx in 0..report.modules.len() {
|
||||||
|
print!(" {:>width$}", idx, width = cell_width);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
print!(" {}─┼", "─".repeat(3));
|
||||||
|
for _ in 0..report.modules.len() {
|
||||||
|
print!("{}", "─".repeat(cell_width + 1));
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for (idx, row) in report.matrix.iter().enumerate() {
|
||||||
|
print!(" {:>3} │", idx);
|
||||||
|
for value in row {
|
||||||
|
print!(" {:>width$}", value, width = cell_width);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
let connected: Vec<&ModuleDegree> = report
|
||||||
|
.degrees
|
||||||
|
.iter()
|
||||||
|
.filter(|degree| degree.fan_in + degree.fan_out > 0)
|
||||||
|
.take(10)
|
||||||
|
.collect();
|
||||||
|
if !connected.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(" {}", "Highest fan-in/fan-out:".bold());
|
||||||
|
for degree in connected {
|
||||||
|
println!(
|
||||||
|
" {:<40} in {:>3} out {:>3}",
|
||||||
|
truncate(°ree.module, 40),
|
||||||
|
degree.fan_in,
|
||||||
|
degree.fan_out,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(value: &str, max: usize) -> String {
|
||||||
|
if value.chars().count() <= max {
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
let mut out: String = value.chars().take(max.saturating_sub(1)).collect();
|
||||||
|
out.push('…');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
980
src/scorecard.rs
980
src/scorecard.rs
|
|
@ -1,980 +0,0 @@
|
||||||
use colored::Colorize;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::{ast_parser, dead_code, deps, flow, loc, render};
|
|
||||||
|
|
||||||
const SCORE_VERSION: &str = "code_complexity_cost_v0";
|
|
||||||
const DEFAULT_TOP: usize = 20;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct ScorecardReport {
|
|
||||||
pub cstat_version: String,
|
|
||||||
pub score_version: String,
|
|
||||||
pub target: String,
|
|
||||||
pub code_complexity_cost: f64,
|
|
||||||
pub code_complexity_cost_per_kloc: f64,
|
|
||||||
pub component_costs: ComponentCosts,
|
|
||||||
pub scope_breakdown: BTreeMap<String, ScopeSummary>,
|
|
||||||
pub top_contributors: Vec<TopContributor>,
|
|
||||||
pub metadata: ScoreMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct ComponentCosts {
|
|
||||||
pub function_complexity: FunctionComponent,
|
|
||||||
pub file_concentration: FileComponent,
|
|
||||||
pub module_coupling: ModuleComponent,
|
|
||||||
pub abstraction_surface: AbstractionComponent,
|
|
||||||
pub stale_surface: StaleComponent,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct FunctionComponent {
|
|
||||||
pub cost: f64,
|
|
||||||
pub functions_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct FileComponent {
|
|
||||||
pub cost: f64,
|
|
||||||
pub files_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct ModuleComponent {
|
|
||||||
pub cost: f64,
|
|
||||||
pub modules_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct AbstractionComponent {
|
|
||||||
pub cost: f64,
|
|
||||||
pub symbols_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct StaleComponent {
|
|
||||||
pub cost: f64,
|
|
||||||
pub candidate_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize)]
|
|
||||||
pub struct ScopeSummary {
|
|
||||||
pub cost: f64,
|
|
||||||
pub functions_scored: usize,
|
|
||||||
pub files_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct TopContributor {
|
|
||||||
pub kind: String,
|
|
||||||
pub scope: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub file: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub module: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub function: Option<String>,
|
|
||||||
pub cost: f64,
|
|
||||||
pub reasons: BTreeMap<String, serde_json::Value>,
|
|
||||||
pub component_costs: BTreeMap<String, f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct ScoreMetadata {
|
|
||||||
pub rust_files: usize,
|
|
||||||
pub code_lines: usize,
|
|
||||||
pub parse_error_files: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
struct ScopeAccumulator {
|
|
||||||
cost: f64,
|
|
||||||
functions_scored: usize,
|
|
||||||
files_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct Contributor {
|
|
||||||
kind: String,
|
|
||||||
scope: String,
|
|
||||||
file: Option<String>,
|
|
||||||
module: Option<String>,
|
|
||||||
function: Option<String>,
|
|
||||||
cost: f64,
|
|
||||||
reasons: BTreeMap<String, serde_json::Value>,
|
|
||||||
component_costs: BTreeMap<String, f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct FunctionScoreInput {
|
|
||||||
file: String,
|
|
||||||
function: String,
|
|
||||||
graph_name: String,
|
|
||||||
scope: String,
|
|
||||||
cyclomatic: usize,
|
|
||||||
nesting_depth: usize,
|
|
||||||
line_count: usize,
|
|
||||||
body_stmt_count: usize,
|
|
||||||
signature_score: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_scorecard(
|
|
||||||
files: &[PathBuf],
|
|
||||||
project_path: &Path,
|
|
||||||
target: &str,
|
|
||||||
top: Option<usize>,
|
|
||||||
json_output: bool,
|
|
||||||
verbose: bool,
|
|
||||||
) {
|
|
||||||
let report = analyze_scorecard(files, project_path, target, top.unwrap_or(DEFAULT_TOP));
|
|
||||||
if json_output {
|
|
||||||
println!("{}", serde_json::to_string(&report).unwrap());
|
|
||||||
} else {
|
|
||||||
render_scorecard_human(&report, verbose);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn analyze_scorecard(
|
|
||||||
files: &[PathBuf],
|
|
||||||
project_path: &Path,
|
|
||||||
target: &str,
|
|
||||||
top: usize,
|
|
||||||
) -> ScorecardReport {
|
|
||||||
let symbols = ast_parser::parse_project(files);
|
|
||||||
let graph = flow::build_call_graph(files, project_path);
|
|
||||||
let loc_by_file = collect_loc_by_file(files);
|
|
||||||
let file_scopes = collect_file_scopes(files, project_path);
|
|
||||||
let function_inputs = collect_function_inputs(&symbols, &graph, project_path, &file_scopes);
|
|
||||||
|
|
||||||
let mut scope_totals = initial_scope_totals(files, &symbols, project_path, &file_scopes);
|
|
||||||
let mut contributors = Vec::new();
|
|
||||||
|
|
||||||
let function_complexity =
|
|
||||||
score_functions(&function_inputs, &mut scope_totals, &mut contributors);
|
|
||||||
let file_concentration = score_files(
|
|
||||||
&symbols,
|
|
||||||
&loc_by_file,
|
|
||||||
project_path,
|
|
||||||
&file_scopes,
|
|
||||||
&mut scope_totals,
|
|
||||||
&mut contributors,
|
|
||||||
);
|
|
||||||
let abstraction_surface = score_abstraction_surface(
|
|
||||||
&symbols,
|
|
||||||
project_path,
|
|
||||||
&file_scopes,
|
|
||||||
&mut scope_totals,
|
|
||||||
&mut contributors,
|
|
||||||
);
|
|
||||||
let module_coupling = score_module_coupling(
|
|
||||||
files,
|
|
||||||
project_path,
|
|
||||||
&file_scopes,
|
|
||||||
&mut scope_totals,
|
|
||||||
&mut contributors,
|
|
||||||
);
|
|
||||||
let stale_surface = score_stale_surface(
|
|
||||||
&graph,
|
|
||||||
&function_inputs,
|
|
||||||
&mut scope_totals,
|
|
||||||
&mut contributors,
|
|
||||||
);
|
|
||||||
|
|
||||||
contributors.sort_by(|a, b| {
|
|
||||||
b.cost
|
|
||||||
.partial_cmp(&a.cost)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
.then_with(|| a.kind.cmp(&b.kind))
|
|
||||||
.then_with(|| a.file.cmp(&b.file))
|
|
||||||
.then_with(|| a.module.cmp(&b.module))
|
|
||||||
.then_with(|| a.function.cmp(&b.function))
|
|
||||||
});
|
|
||||||
contributors.truncate(top);
|
|
||||||
|
|
||||||
let code_lines: usize = loc_by_file.values().map(|stats| stats.code_lines).sum();
|
|
||||||
let total_cost = function_complexity.cost
|
|
||||||
+ file_concentration.cost
|
|
||||||
+ module_coupling.cost
|
|
||||||
+ abstraction_surface.cost
|
|
||||||
+ stale_surface.cost;
|
|
||||||
let kloc = (code_lines as f64 / 1000.0).max(1.0);
|
|
||||||
|
|
||||||
ScorecardReport {
|
|
||||||
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
score_version: SCORE_VERSION.to_string(),
|
|
||||||
target: target.to_string(),
|
|
||||||
code_complexity_cost: round1(total_cost),
|
|
||||||
code_complexity_cost_per_kloc: round1(total_cost / kloc),
|
|
||||||
component_costs: ComponentCosts {
|
|
||||||
function_complexity: FunctionComponent {
|
|
||||||
cost: round1(function_complexity.cost),
|
|
||||||
functions_scored: function_complexity.functions_scored,
|
|
||||||
},
|
|
||||||
file_concentration: FileComponent {
|
|
||||||
cost: round1(file_concentration.cost),
|
|
||||||
files_scored: file_concentration.files_scored,
|
|
||||||
},
|
|
||||||
module_coupling: ModuleComponent {
|
|
||||||
cost: round1(module_coupling.cost),
|
|
||||||
modules_scored: module_coupling.modules_scored,
|
|
||||||
},
|
|
||||||
abstraction_surface: AbstractionComponent {
|
|
||||||
cost: round1(abstraction_surface.cost),
|
|
||||||
symbols_scored: abstraction_surface.symbols_scored,
|
|
||||||
},
|
|
||||||
stale_surface: StaleComponent {
|
|
||||||
cost: round1(stale_surface.cost),
|
|
||||||
candidate_count: stale_surface.candidate_count,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scope_breakdown: finalize_scope_totals(scope_totals),
|
|
||||||
top_contributors: contributors
|
|
||||||
.into_iter()
|
|
||||||
.map(|contributor| TopContributor {
|
|
||||||
kind: contributor.kind,
|
|
||||||
scope: contributor.scope,
|
|
||||||
file: contributor.file,
|
|
||||||
module: contributor.module,
|
|
||||||
function: contributor.function,
|
|
||||||
cost: round1(contributor.cost),
|
|
||||||
reasons: contributor.reasons,
|
|
||||||
component_costs: contributor
|
|
||||||
.component_costs
|
|
||||||
.into_iter()
|
|
||||||
.map(|(key, value)| (key, round1(value)))
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
metadata: ScoreMetadata {
|
|
||||||
rust_files: files.len(),
|
|
||||||
code_lines,
|
|
||||||
parse_error_files: symbols.files.iter().filter(|file| file.parse_error).count(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_scorecard_human(report: &ScorecardReport, verbose: bool) {
|
|
||||||
render::section_header("Code complexity score");
|
|
||||||
println!("score version: {}", report.score_version.bold());
|
|
||||||
println!("lower is cleaner; harness guards behavior separately");
|
|
||||||
if verbose {
|
|
||||||
render::verbose_block(&[
|
|
||||||
"Pure structural cost; no tests, benchmark, coverage, reward, or behavior signals are included.",
|
|
||||||
"All discovered Rust scopes contribute equally; scope breakdown is explanatory only.",
|
|
||||||
"This command is a compact scorecard, not a bundle of every underlying probe.",
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("total cost: {:.1}", report.code_complexity_cost);
|
|
||||||
println!("cost / KLOC: {:.1}", report.code_complexity_cost_per_kloc);
|
|
||||||
println!();
|
|
||||||
println!("components:");
|
|
||||||
println!(
|
|
||||||
" {:<24} {:>8.1}",
|
|
||||||
"function complexity:", report.component_costs.function_complexity.cost
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" {:<24} {:>8.1}",
|
|
||||||
"file concentration:", report.component_costs.file_concentration.cost
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" {:<24} {:>8.1}",
|
|
||||||
"module coupling:", report.component_costs.module_coupling.cost
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" {:<24} {:>8.1}",
|
|
||||||
"abstraction surface:", report.component_costs.abstraction_surface.cost
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" {:<24} {:>8.1}",
|
|
||||||
"stale surface:", report.component_costs.stale_surface.cost
|
|
||||||
);
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("top contributors:");
|
|
||||||
if report.top_contributors.is_empty() {
|
|
||||||
println!(" <none>");
|
|
||||||
} else {
|
|
||||||
for (index, contributor) in report.top_contributors.iter().enumerate() {
|
|
||||||
println!(
|
|
||||||
" {:>2}. {:<19} {:<48} {:>8.1}",
|
|
||||||
index + 1,
|
|
||||||
contributor.kind,
|
|
||||||
contributor_label(contributor),
|
|
||||||
contributor.cost,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn contributor_label(contributor: &TopContributor) -> String {
|
|
||||||
match contributor.kind.as_str() {
|
|
||||||
"function" => match (&contributor.file, &contributor.function) {
|
|
||||||
(Some(file), Some(function)) => format!("{file}::{function}"),
|
|
||||||
_ => contributor
|
|
||||||
.function
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "<unknown>".to_string()),
|
|
||||||
},
|
|
||||||
"file" | "abstraction_surface" => contributor
|
|
||||||
.file
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "<unknown>".to_string()),
|
|
||||||
"module" => contributor
|
|
||||||
.module
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "<unknown>".to_string()),
|
|
||||||
"stale_surface" => contributor
|
|
||||||
.function
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "<unknown>".to_string()),
|
|
||||||
_ => "<unknown>".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct FunctionScoreSummary {
|
|
||||||
cost: f64,
|
|
||||||
functions_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct FileScoreSummary {
|
|
||||||
cost: f64,
|
|
||||||
files_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct ModuleScoreSummary {
|
|
||||||
cost: f64,
|
|
||||||
modules_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct AbstractionScoreSummary {
|
|
||||||
cost: f64,
|
|
||||||
symbols_scored: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct StaleScoreSummary {
|
|
||||||
cost: f64,
|
|
||||||
candidate_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn score_functions(
|
|
||||||
functions: &[FunctionScoreInput],
|
|
||||||
scopes: &mut BTreeMap<String, ScopeAccumulator>,
|
|
||||||
contributors: &mut Vec<Contributor>,
|
|
||||||
) -> FunctionScoreSummary {
|
|
||||||
let mut summary = FunctionScoreSummary {
|
|
||||||
functions_scored: functions.len(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
for function in functions {
|
|
||||||
let branch_cost = 2.0 * excess(function.cyclomatic, 5);
|
|
||||||
let nesting_cost = 3.0 * excess(function.nesting_depth, 3);
|
|
||||||
let span_cost = 0.2 * excess(function.line_count, 50);
|
|
||||||
let body_cost = 0.5 * excess(function.body_stmt_count, 20);
|
|
||||||
let signature_cost = excess(function.signature_score, 6);
|
|
||||||
let cost = branch_cost + nesting_cost + span_cost + body_cost + signature_cost;
|
|
||||||
|
|
||||||
summary.cost += cost;
|
|
||||||
add_scope_cost(scopes, &function.scope, cost);
|
|
||||||
|
|
||||||
if cost > 0.0 {
|
|
||||||
let mut reasons = BTreeMap::new();
|
|
||||||
reasons.insert("cyclomatic".to_string(), json!(function.cyclomatic));
|
|
||||||
reasons.insert("nesting_depth".to_string(), json!(function.nesting_depth));
|
|
||||||
reasons.insert("line_count".to_string(), json!(function.line_count));
|
|
||||||
reasons.insert(
|
|
||||||
"body_stmt_count".to_string(),
|
|
||||||
json!(function.body_stmt_count),
|
|
||||||
);
|
|
||||||
reasons.insert(
|
|
||||||
"signature_score".to_string(),
|
|
||||||
json!(function.signature_score),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut component_costs = BTreeMap::new();
|
|
||||||
component_costs.insert("branching".to_string(), branch_cost);
|
|
||||||
component_costs.insert("nesting".to_string(), nesting_cost);
|
|
||||||
component_costs.insert("span".to_string(), span_cost);
|
|
||||||
component_costs.insert("body_statements".to_string(), body_cost);
|
|
||||||
component_costs.insert("signature".to_string(), signature_cost);
|
|
||||||
|
|
||||||
contributors.push(Contributor {
|
|
||||||
kind: "function".to_string(),
|
|
||||||
scope: function.scope.clone(),
|
|
||||||
file: Some(function.file.clone()),
|
|
||||||
module: None,
|
|
||||||
function: Some(function.function.clone()),
|
|
||||||
cost,
|
|
||||||
reasons,
|
|
||||||
component_costs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn score_files(
|
|
||||||
symbols: &ast_parser::ProjectSymbols,
|
|
||||||
loc_by_file: &HashMap<PathBuf, loc::FileLocStats>,
|
|
||||||
project_path: &Path,
|
|
||||||
file_scopes: &HashMap<PathBuf, String>,
|
|
||||||
scopes: &mut BTreeMap<String, ScopeAccumulator>,
|
|
||||||
contributors: &mut Vec<Contributor>,
|
|
||||||
) -> FileScoreSummary {
|
|
||||||
let mut summary = FileScoreSummary {
|
|
||||||
files_scored: symbols.files.len(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
for file in &symbols.files {
|
|
||||||
let code_lines = loc_by_file
|
|
||||||
.get(&file.path)
|
|
||||||
.map(|stats| stats.code_lines)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let total_symbols = total_symbols(file);
|
|
||||||
let function_count = file.functions.len();
|
|
||||||
let line_cost = 0.04 * excess(code_lines, 400);
|
|
||||||
let symbol_cost = 0.25 * excess(total_symbols, 35);
|
|
||||||
let function_count_cost = excess(function_count, 25);
|
|
||||||
let cost = line_cost + symbol_cost + function_count_cost;
|
|
||||||
let scope = file_scopes
|
|
||||||
.get(&file.path)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| file_scope(&file.path, project_path));
|
|
||||||
|
|
||||||
summary.cost += cost;
|
|
||||||
add_scope_cost(scopes, &scope, cost);
|
|
||||||
|
|
||||||
if cost > 0.0 {
|
|
||||||
let mut reasons = BTreeMap::new();
|
|
||||||
reasons.insert("code_lines".to_string(), json!(code_lines));
|
|
||||||
reasons.insert("total_symbols".to_string(), json!(total_symbols));
|
|
||||||
reasons.insert("function_count".to_string(), json!(function_count));
|
|
||||||
|
|
||||||
let mut component_costs = BTreeMap::new();
|
|
||||||
component_costs.insert("code_lines".to_string(), line_cost);
|
|
||||||
component_costs.insert("symbols".to_string(), symbol_cost);
|
|
||||||
component_costs.insert("functions".to_string(), function_count_cost);
|
|
||||||
|
|
||||||
contributors.push(Contributor {
|
|
||||||
kind: "file".to_string(),
|
|
||||||
scope,
|
|
||||||
file: Some(relative_file(&file.path, project_path)),
|
|
||||||
module: None,
|
|
||||||
function: None,
|
|
||||||
cost,
|
|
||||||
reasons,
|
|
||||||
component_costs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn score_abstraction_surface(
|
|
||||||
symbols: &ast_parser::ProjectSymbols,
|
|
||||||
project_path: &Path,
|
|
||||||
file_scopes: &HashMap<PathBuf, String>,
|
|
||||||
scopes: &mut BTreeMap<String, ScopeAccumulator>,
|
|
||||||
contributors: &mut Vec<Contributor>,
|
|
||||||
) -> AbstractionScoreSummary {
|
|
||||||
let mut summary = AbstractionScoreSummary::default();
|
|
||||||
|
|
||||||
for file in &symbols.files {
|
|
||||||
let structs = file.structs.len();
|
|
||||||
let enums = file.enums.len();
|
|
||||||
let traits = file.traits.len();
|
|
||||||
let trait_impls = file
|
|
||||||
.impls
|
|
||||||
.iter()
|
|
||||||
.filter(|imp| imp.trait_name.is_some())
|
|
||||||
.count();
|
|
||||||
let consts_statics = file.consts.len() + file.statics.len();
|
|
||||||
summary.symbols_scored += total_symbols(file);
|
|
||||||
|
|
||||||
let struct_cost = 0.5 * excess(structs, 12);
|
|
||||||
let enum_cost = 0.5 * excess(enums, 8);
|
|
||||||
let trait_cost = excess(traits, 4);
|
|
||||||
let trait_impl_cost = 0.5 * excess(trait_impls, 8);
|
|
||||||
let const_static_cost = 0.2 * excess(consts_statics, 10);
|
|
||||||
let cost = struct_cost + enum_cost + trait_cost + trait_impl_cost + const_static_cost;
|
|
||||||
let scope = file_scopes
|
|
||||||
.get(&file.path)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| file_scope(&file.path, project_path));
|
|
||||||
|
|
||||||
summary.cost += cost;
|
|
||||||
add_scope_cost(scopes, &scope, cost);
|
|
||||||
|
|
||||||
if cost > 0.0 {
|
|
||||||
let mut reasons = BTreeMap::new();
|
|
||||||
reasons.insert("structs".to_string(), json!(structs));
|
|
||||||
reasons.insert("enums".to_string(), json!(enums));
|
|
||||||
reasons.insert("traits".to_string(), json!(traits));
|
|
||||||
reasons.insert("trait_impls".to_string(), json!(trait_impls));
|
|
||||||
reasons.insert("consts_and_statics".to_string(), json!(consts_statics));
|
|
||||||
|
|
||||||
let mut component_costs = BTreeMap::new();
|
|
||||||
component_costs.insert("structs".to_string(), struct_cost);
|
|
||||||
component_costs.insert("enums".to_string(), enum_cost);
|
|
||||||
component_costs.insert("traits".to_string(), trait_cost);
|
|
||||||
component_costs.insert("trait_impls".to_string(), trait_impl_cost);
|
|
||||||
component_costs.insert("consts_and_statics".to_string(), const_static_cost);
|
|
||||||
|
|
||||||
contributors.push(Contributor {
|
|
||||||
kind: "abstraction_surface".to_string(),
|
|
||||||
scope,
|
|
||||||
file: Some(relative_file(&file.path, project_path)),
|
|
||||||
module: None,
|
|
||||||
function: None,
|
|
||||||
cost,
|
|
||||||
reasons,
|
|
||||||
component_costs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn score_module_coupling(
|
|
||||||
files: &[PathBuf],
|
|
||||||
project_path: &Path,
|
|
||||||
file_scopes: &HashMap<PathBuf, String>,
|
|
||||||
scopes: &mut BTreeMap<String, ScopeAccumulator>,
|
|
||||||
contributors: &mut Vec<Contributor>,
|
|
||||||
) -> ModuleScoreSummary {
|
|
||||||
let analysis = deps::analyze_deps(files, project_path);
|
|
||||||
let cohesion = deps::compute_cohesion(files, project_path);
|
|
||||||
let module_scopes = module_scopes(files, project_path, file_scopes);
|
|
||||||
let mut summary = ModuleScoreSummary {
|
|
||||||
modules_scored: analysis.modules.len(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
for module in &analysis.modules {
|
|
||||||
let in_degree = analysis.in_degree.get(module).copied().unwrap_or(0);
|
|
||||||
let out_degree = analysis.out_degree.get(module).copied().unwrap_or(0);
|
|
||||||
let out_cost = 2.0 * excess(out_degree, 5);
|
|
||||||
let in_cost = excess(in_degree, 10);
|
|
||||||
let cost = out_cost + in_cost;
|
|
||||||
let scope = module_scopes
|
|
||||||
.get(module)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "production".to_string());
|
|
||||||
|
|
||||||
summary.cost += cost;
|
|
||||||
add_scope_cost(scopes, &scope, cost);
|
|
||||||
|
|
||||||
if cost > 0.0 {
|
|
||||||
let mut reasons = BTreeMap::new();
|
|
||||||
reasons.insert("in_degree".to_string(), json!(in_degree));
|
|
||||||
reasons.insert("out_degree".to_string(), json!(out_degree));
|
|
||||||
|
|
||||||
let mut component_costs = BTreeMap::new();
|
|
||||||
component_costs.insert("fan_out".to_string(), out_cost);
|
|
||||||
component_costs.insert("fan_in".to_string(), in_cost);
|
|
||||||
|
|
||||||
contributors.push(Contributor {
|
|
||||||
kind: "module".to_string(),
|
|
||||||
scope,
|
|
||||||
file: None,
|
|
||||||
module: Some(module.clone()),
|
|
||||||
function: None,
|
|
||||||
cost,
|
|
||||||
reasons,
|
|
||||||
component_costs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (module_a, module_b, strength) in &analysis.coupling_pairs {
|
|
||||||
if *strength < 2 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let cost = 20.0 + 5.0 * *strength as f64;
|
|
||||||
let scope = pair_scope(module_a, module_b, &module_scopes);
|
|
||||||
summary.cost += cost;
|
|
||||||
add_scope_cost(scopes, &scope, cost);
|
|
||||||
|
|
||||||
let mut reasons = BTreeMap::new();
|
|
||||||
reasons.insert("strength".to_string(), json!(strength));
|
|
||||||
reasons.insert("direction".to_string(), json!("bidirectional"));
|
|
||||||
|
|
||||||
let mut component_costs = BTreeMap::new();
|
|
||||||
component_costs.insert("bidirectional_pair".to_string(), cost);
|
|
||||||
|
|
||||||
contributors.push(Contributor {
|
|
||||||
kind: "module".to_string(),
|
|
||||||
scope,
|
|
||||||
file: None,
|
|
||||||
module: Some(format!("{module_a} <-> {module_b}")),
|
|
||||||
function: None,
|
|
||||||
cost,
|
|
||||||
reasons,
|
|
||||||
component_costs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in &cohesion {
|
|
||||||
if row.function_count < 4 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let cohesion_gap = (0.55 - row.combined).max(0.0);
|
|
||||||
let cost = 2.0 * row.function_count as f64 * cohesion_gap;
|
|
||||||
let scope = module_scopes
|
|
||||||
.get(&row.module)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "production".to_string());
|
|
||||||
summary.cost += cost;
|
|
||||||
add_scope_cost(scopes, &scope, cost);
|
|
||||||
|
|
||||||
if cost > 0.0 {
|
|
||||||
let mut reasons = BTreeMap::new();
|
|
||||||
reasons.insert("combined_cohesion".to_string(), json!(row.combined));
|
|
||||||
reasons.insert("function_count".to_string(), json!(row.function_count));
|
|
||||||
|
|
||||||
let mut component_costs = BTreeMap::new();
|
|
||||||
component_costs.insert("low_cohesion".to_string(), cost);
|
|
||||||
|
|
||||||
contributors.push(Contributor {
|
|
||||||
kind: "module".to_string(),
|
|
||||||
scope,
|
|
||||||
file: None,
|
|
||||||
module: Some(row.module.clone()),
|
|
||||||
function: None,
|
|
||||||
cost,
|
|
||||||
reasons,
|
|
||||||
component_costs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn score_stale_surface(
|
|
||||||
graph: &flow::CallGraph,
|
|
||||||
functions: &[FunctionScoreInput],
|
|
||||||
scopes: &mut BTreeMap<String, ScopeAccumulator>,
|
|
||||||
contributors: &mut Vec<Contributor>,
|
|
||||||
) -> StaleScoreSummary {
|
|
||||||
let report = dead_code::analyze_graph(graph);
|
|
||||||
let function_scopes: HashMap<&str, &str> = functions
|
|
||||||
.iter()
|
|
||||||
.map(|function| (function.graph_name.as_str(), function.scope.as_str()))
|
|
||||||
.collect();
|
|
||||||
let function_files: HashMap<&str, &str> = functions
|
|
||||||
.iter()
|
|
||||||
.map(|function| (function.graph_name.as_str(), function.file.as_str()))
|
|
||||||
.collect();
|
|
||||||
let summary = StaleScoreSummary {
|
|
||||||
candidate_count: report.candidate_count,
|
|
||||||
cost: 0.5 * report.candidate_count as f64,
|
|
||||||
};
|
|
||||||
|
|
||||||
for candidate in &report.candidates {
|
|
||||||
let cost = 0.5;
|
|
||||||
let scope = function_scopes
|
|
||||||
.get(candidate.function.as_str())
|
|
||||||
.map(|scope| (*scope).to_string())
|
|
||||||
.unwrap_or_else(|| graph_function_scope(graph, &candidate.function));
|
|
||||||
add_scope_cost(scopes, &scope, cost);
|
|
||||||
|
|
||||||
let mut reasons = BTreeMap::new();
|
|
||||||
reasons.insert("reason".to_string(), json!(candidate.reason));
|
|
||||||
|
|
||||||
let mut component_costs = BTreeMap::new();
|
|
||||||
component_costs.insert("stale_candidate".to_string(), cost);
|
|
||||||
|
|
||||||
contributors.push(Contributor {
|
|
||||||
kind: "stale_surface".to_string(),
|
|
||||||
scope,
|
|
||||||
file: function_files
|
|
||||||
.get(candidate.function.as_str())
|
|
||||||
.map(|file| (*file).to_string()),
|
|
||||||
module: None,
|
|
||||||
function: Some(candidate.function.clone()),
|
|
||||||
cost,
|
|
||||||
reasons,
|
|
||||||
component_costs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_function_inputs(
|
|
||||||
symbols: &ast_parser::ProjectSymbols,
|
|
||||||
graph: &flow::CallGraph,
|
|
||||||
project_path: &Path,
|
|
||||||
file_scopes: &HashMap<PathBuf, String>,
|
|
||||||
) -> Vec<FunctionScoreInput> {
|
|
||||||
let mut inputs = Vec::new();
|
|
||||||
|
|
||||||
for file in &symbols.files {
|
|
||||||
let file_module = render::module_name_from_path(&file.path, project_path);
|
|
||||||
let rel_file = relative_file(&file.path, project_path);
|
|
||||||
let base_scope = file_scopes
|
|
||||||
.get(&file.path)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| file_scope(&file.path, project_path));
|
|
||||||
|
|
||||||
for function in &file.functions {
|
|
||||||
let graph_name = graph_name_for_function(&file_module, &function.name);
|
|
||||||
let scope = function_scope(graph, &graph_name, &base_scope, function.is_test_support);
|
|
||||||
let line_count = function_line_count(function);
|
|
||||||
let signature_score = signature_score(function);
|
|
||||||
inputs.push(FunctionScoreInput {
|
|
||||||
file: rel_file.clone(),
|
|
||||||
function: function.name.clone(),
|
|
||||||
graph_name,
|
|
||||||
scope,
|
|
||||||
cyclomatic: function.branch_points + 1,
|
|
||||||
nesting_depth: function.nesting_depth,
|
|
||||||
line_count,
|
|
||||||
body_stmt_count: function.body_stmt_count,
|
|
||||||
signature_score,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inputs
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_loc_by_file(files: &[PathBuf]) -> HashMap<PathBuf, loc::FileLocStats> {
|
|
||||||
files
|
|
||||||
.iter()
|
|
||||||
.filter_map(|file| loc::count_file_lines(file).map(|stats| (file.clone(), stats)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_file_scopes(files: &[PathBuf], project_path: &Path) -> HashMap<PathBuf, String> {
|
|
||||||
files
|
|
||||||
.iter()
|
|
||||||
.map(|file| (file.clone(), file_scope(file, project_path)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn initial_scope_totals(
|
|
||||||
files: &[PathBuf],
|
|
||||||
symbols: &ast_parser::ProjectSymbols,
|
|
||||||
project_path: &Path,
|
|
||||||
file_scopes: &HashMap<PathBuf, String>,
|
|
||||||
) -> BTreeMap<String, ScopeAccumulator> {
|
|
||||||
let mut scopes = BTreeMap::new();
|
|
||||||
for standard in ["production", "tests", "benches"] {
|
|
||||||
scopes.insert(standard.to_string(), ScopeAccumulator::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
for file in files {
|
|
||||||
let scope = file_scopes
|
|
||||||
.get(file)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| file_scope(file, project_path));
|
|
||||||
scopes.entry(scope).or_default().files_scored += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for file in &symbols.files {
|
|
||||||
let scope = file_scopes
|
|
||||||
.get(&file.path)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| file_scope(&file.path, project_path));
|
|
||||||
scopes.entry(scope).or_default().functions_scored += file.functions.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
scopes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finalize_scope_totals(
|
|
||||||
scopes: BTreeMap<String, ScopeAccumulator>,
|
|
||||||
) -> BTreeMap<String, ScopeSummary> {
|
|
||||||
scopes
|
|
||||||
.into_iter()
|
|
||||||
.map(|(scope, summary)| {
|
|
||||||
(
|
|
||||||
scope,
|
|
||||||
ScopeSummary {
|
|
||||||
cost: round1(summary.cost),
|
|
||||||
functions_scored: summary.functions_scored,
|
|
||||||
files_scored: summary.files_scored,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_scope_cost(scopes: &mut BTreeMap<String, ScopeAccumulator>, scope: &str, cost: f64) {
|
|
||||||
scopes.entry(scope.to_string()).or_default().cost += cost;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn module_scopes(
|
|
||||||
files: &[PathBuf],
|
|
||||||
project_path: &Path,
|
|
||||||
file_scopes: &HashMap<PathBuf, String>,
|
|
||||||
) -> HashMap<String, String> {
|
|
||||||
files
|
|
||||||
.iter()
|
|
||||||
.map(|file| {
|
|
||||||
let module = render::module_name_from_path(file, project_path);
|
|
||||||
let scope = file_scopes
|
|
||||||
.get(file)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| file_scope(file, project_path));
|
|
||||||
(module, scope)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pair_scope(module_a: &str, module_b: &str, module_scopes: &HashMap<String, String>) -> String {
|
|
||||||
let scope_a = module_scopes
|
|
||||||
.get(module_a)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "production".to_string());
|
|
||||||
let scope_b = module_scopes
|
|
||||||
.get(module_b)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "production".to_string());
|
|
||||||
if scope_a == scope_b {
|
|
||||||
scope_a
|
|
||||||
} else {
|
|
||||||
"mixed".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn function_scope(
|
|
||||||
graph: &flow::CallGraph,
|
|
||||||
graph_name: &str,
|
|
||||||
file_scope: &str,
|
|
||||||
is_test_support: bool,
|
|
||||||
) -> String {
|
|
||||||
if file_scope == "benches" || file_scope == "tests" {
|
|
||||||
return file_scope.to_string();
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
graph.entry_point_kinds.get(graph_name),
|
|
||||||
Some(flow::EntryPointKind::Bench)
|
|
||||||
) {
|
|
||||||
return "benches".to_string();
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
graph.entry_point_kinds.get(graph_name),
|
|
||||||
Some(flow::EntryPointKind::Test)
|
|
||||||
) {
|
|
||||||
return "tests".to_string();
|
|
||||||
}
|
|
||||||
if let Some(node) = graph.function_nodes.get(graph_name) {
|
|
||||||
if node.is_bench_entry {
|
|
||||||
return "benches".to_string();
|
|
||||||
}
|
|
||||||
if node.is_test_entry || node.is_test_support {
|
|
||||||
return "tests".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if is_test_support {
|
|
||||||
return "tests".to_string();
|
|
||||||
}
|
|
||||||
file_scope.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn graph_function_scope(graph: &flow::CallGraph, graph_name: &str) -> String {
|
|
||||||
if matches!(
|
|
||||||
graph.entry_point_kinds.get(graph_name),
|
|
||||||
Some(flow::EntryPointKind::Bench)
|
|
||||||
) {
|
|
||||||
return "benches".to_string();
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
graph.entry_point_kinds.get(graph_name),
|
|
||||||
Some(flow::EntryPointKind::Test)
|
|
||||||
) {
|
|
||||||
return "tests".to_string();
|
|
||||||
}
|
|
||||||
if let Some(node) = graph.function_nodes.get(graph_name) {
|
|
||||||
if node.is_bench_entry {
|
|
||||||
return "benches".to_string();
|
|
||||||
}
|
|
||||||
if node.is_test_entry || node.is_test_support {
|
|
||||||
return "tests".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"production".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file_scope(file: &Path, project_path: &Path) -> String {
|
|
||||||
let rel = file.strip_prefix(project_path).unwrap_or(file);
|
|
||||||
let first = rel
|
|
||||||
.components()
|
|
||||||
.next()
|
|
||||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| "src".to_string());
|
|
||||||
match first.as_str() {
|
|
||||||
"tests" => "tests".to_string(),
|
|
||||||
"benches" => "benches".to_string(),
|
|
||||||
"examples" => "examples".to_string(),
|
|
||||||
_ => "production".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn graph_name_for_function(file_module: &str, function_name: &str) -> String {
|
|
||||||
if file_module == "main" || file_module == "lib" {
|
|
||||||
function_name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{file_module}::{function_name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn total_symbols(file: &ast_parser::FileSymbols) -> usize {
|
|
||||||
file.functions.len()
|
|
||||||
+ file.structs.len()
|
|
||||||
+ file.enums.len()
|
|
||||||
+ file.traits.len()
|
|
||||||
+ file.impls.len()
|
|
||||||
+ file.consts.len()
|
|
||||||
+ file.statics.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn function_line_count(function: &ast_parser::FunctionInfo) -> usize {
|
|
||||||
if function.line_end >= function.line_start {
|
|
||||||
function.line_end - function.line_start + 1
|
|
||||||
} else {
|
|
||||||
function.body_stmt_count.max(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn signature_score(function: &ast_parser::FunctionInfo) -> usize {
|
|
||||||
function.param_count
|
|
||||||
+ function.return_type_complexity
|
|
||||||
+ function.generic_param_count
|
|
||||||
+ function.trait_bound_count
|
|
||||||
+ function.where_predicate_count
|
|
||||||
}
|
|
||||||
|
|
||||||
fn excess(value: usize, threshold: usize) -> f64 {
|
|
||||||
value.saturating_sub(threshold) as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relative_file(path: &Path, project_path: &Path) -> String {
|
|
||||||
path.strip_prefix(project_path)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace('\\', "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn round1(value: f64) -> f64 {
|
|
||||||
(value * 10.0).round() / 10.0
|
|
||||||
}
|
|
||||||
644
src/summary/mod.rs
Normal file
644
src/summary/mod.rs
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
mod sections;
|
||||||
|
|
||||||
|
use colored::Colorize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::ast_parser;
|
||||||
|
use crate::circuits;
|
||||||
|
use crate::complexity;
|
||||||
|
use crate::datapaths;
|
||||||
|
use crate::deps;
|
||||||
|
use crate::graph;
|
||||||
|
use crate::loc;
|
||||||
|
use crate::render;
|
||||||
|
|
||||||
|
use sections::*;
|
||||||
|
|
||||||
|
/// Render the summary dashboard — the default view combining all analyses.
|
||||||
|
pub fn render_summary(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
|
||||||
|
let term_w = render::terminal_width();
|
||||||
|
let project_path_canon = project_path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| project_path.to_path_buf());
|
||||||
|
|
||||||
|
// ── Gather data ──────────────────────────────────────────
|
||||||
|
let (file_stats, agg_loc) = loc::analyze_files(rs_files);
|
||||||
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
|
let func_complexities = complexity::compute_all(&symbols, &project_path_canon);
|
||||||
|
let complexity_stats = complexity::compute_stats(&func_complexities);
|
||||||
|
let dep_analysis = deps::analyze_deps(rs_files, &project_path_canon);
|
||||||
|
|
||||||
|
// ── Project name ─────────────────────────────────────────
|
||||||
|
let project_name = project_path_canon
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let total_files = file_stats.len();
|
||||||
|
let total_loc = agg_loc.as_ref().map_or(0, |a| a.total_loc);
|
||||||
|
let total_functions = func_complexities.len();
|
||||||
|
let total_modules = dep_analysis.modules.len();
|
||||||
|
|
||||||
|
// ── Render each section ──────────────────────────────────
|
||||||
|
render_summary_header(
|
||||||
|
&project_name,
|
||||||
|
total_files,
|
||||||
|
total_loc,
|
||||||
|
total_functions,
|
||||||
|
total_modules,
|
||||||
|
term_w,
|
||||||
|
verbose,
|
||||||
|
);
|
||||||
|
render_loc_section(&file_stats, &project_path_canon, term_w, total_loc, verbose);
|
||||||
|
render_complexity_section(&func_complexities, verbose);
|
||||||
|
render_connectome_section(&dep_analysis, term_w, verbose);
|
||||||
|
render_distributions_section(&func_complexities, verbose);
|
||||||
|
render_centrality_section(rs_files, &project_path_canon, verbose);
|
||||||
|
render_reachability_section(rs_files, &project_path_canon, verbose);
|
||||||
|
render_circuits_section(rs_files, &project_path_canon, verbose);
|
||||||
|
render_stats_footer(
|
||||||
|
&agg_loc,
|
||||||
|
&complexity_stats,
|
||||||
|
total_files,
|
||||||
|
total_functions,
|
||||||
|
total_modules,
|
||||||
|
verbose,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the dashboard header banner.
|
||||||
|
fn render_summary_header(
|
||||||
|
project_name: &str,
|
||||||
|
total_files: usize,
|
||||||
|
total_loc: usize,
|
||||||
|
total_functions: usize,
|
||||||
|
total_modules: usize,
|
||||||
|
term_w: usize,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
let header_line = "═".repeat(term_w.min(60));
|
||||||
|
println!("\n{}", header_line.bright_cyan().bold());
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"cstat".bright_cyan().bold(),
|
||||||
|
project_name.white().bold()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} files · {} code lines · {} functions · {} modules",
|
||||||
|
format!("{}", total_files).bold(),
|
||||||
|
format!("{}", total_loc).bold(),
|
||||||
|
format!("{}", total_functions).bold(),
|
||||||
|
format!("{}", total_modules).bold(),
|
||||||
|
);
|
||||||
|
println!("{}", header_line.bright_cyan().bold());
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"This dashboard gives a bird's-eye view of your codebase.",
|
||||||
|
"Each section below summarizes a different dimension of the project:",
|
||||||
|
"size, complexity, dependencies, graph structure, execution flow, and modularity.",
|
||||||
|
"",
|
||||||
|
"Tip: For machine-readable output, use 'cstat dump' to get a structured JSON",
|
||||||
|
"diagnostic report, or pass --json to any subcommand for raw structured data.",
|
||||||
|
"Run 'cstat guide' for a topic-based reference explaining what each metric means.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the Lines of Code section with top-10 chart and directory breakdown.
|
||||||
|
fn render_loc_section(
|
||||||
|
file_stats: &[loc::FileLocStats],
|
||||||
|
project_path_canon: &Path,
|
||||||
|
term_w: usize,
|
||||||
|
total_loc: usize,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
render::section_header("Lines of Code — Top Files");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Files ranked by non-blank, non-comment source lines of code (LoC).",
|
||||||
|
"Bar length is proportional to LoC. Color gradient: red = largest, green = smallest.",
|
||||||
|
"Only the top 10 files are shown; the full list is available via 'cstat loc'.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_stats.is_empty() {
|
||||||
|
println!(" {}", "No source files found.".dimmed());
|
||||||
|
} else {
|
||||||
|
render_loc_bars(file_stats, project_path_canon, term_w);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
println!();
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Directory breakdown: proportional LoC by top-level directory.",
|
||||||
|
"Bar size = share of total code. Color: red > 50%, yellow > 25%, green otherwise.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
render_dir_breakdown(file_stats, project_path_canon, term_w, total_loc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the LoC bar chart for up to 10 files.
|
||||||
|
fn render_loc_bars(file_stats: &[loc::FileLocStats], project_path_canon: &Path, term_w: usize) {
|
||||||
|
let display_count = file_stats.len().min(10);
|
||||||
|
let display_stats = &file_stats[..display_count];
|
||||||
|
let max_label_len = display_stats
|
||||||
|
.iter()
|
||||||
|
.map(|f| short_path(&f.path, project_path_canon).len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(35);
|
||||||
|
let max_loc = display_stats
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.code_lines)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1);
|
||||||
|
let loc_digits = format!("{}", max_loc).len();
|
||||||
|
let bar_budget = term_w
|
||||||
|
.saturating_sub(max_label_len + 2)
|
||||||
|
.saturating_sub(3)
|
||||||
|
.saturating_sub(loc_digits)
|
||||||
|
.saturating_sub(2)
|
||||||
|
.max(10);
|
||||||
|
|
||||||
|
for (i, stat) in display_stats.iter().enumerate() {
|
||||||
|
let ratio = i as f64 / display_count.max(1) as f64;
|
||||||
|
let label = short_path_truncated(&stat.path, project_path_canon, max_label_len);
|
||||||
|
let bar_len = if max_loc > 0 {
|
||||||
|
(stat.code_lines as f64 / max_loc as f64 * bar_budget as f64).ceil() as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
.max(if stat.code_lines > 0 { 1 } else { 0 });
|
||||||
|
|
||||||
|
let bar = render::bar_color(&"█".repeat(bar_len), ratio);
|
||||||
|
let padding = " ".repeat(bar_budget.saturating_sub(bar_len));
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:>width$} │ {}{} {}",
|
||||||
|
label.dimmed(),
|
||||||
|
bar,
|
||||||
|
padding,
|
||||||
|
format!("{}", stat.code_lines).bold(),
|
||||||
|
width = max_label_len,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file_stats.len() > display_count {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
format!("... and {} more files", file_stats.len() - display_count).dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the branching hotspots table.
|
||||||
|
fn render_complexity_section(func_complexities: &[complexity::FunctionComplexity], verbose: bool) {
|
||||||
|
render::section_header("Branching Hotspots");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Functions ranked by cyclomatic branching complexity (highest first).",
|
||||||
|
"Cycl = cyclomatic complexity = branch points + 1 (linearly independent paths).",
|
||||||
|
"Branches = cyclomatic - 1. Nest = deepest nesting depth within the function body.",
|
||||||
|
"See 'cstat branching', 'cstat signature', and 'cstat span' for focused detail.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if func_complexities.is_empty() {
|
||||||
|
println!(" {}", "No functions found.".dimmed());
|
||||||
|
} else {
|
||||||
|
let mut rows: Vec<_> = func_complexities.iter().collect();
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
b.cyclomatic
|
||||||
|
.cmp(&a.cyclomatic)
|
||||||
|
.then_with(|| b.nesting_depth.cmp(&a.nesting_depth))
|
||||||
|
.then_with(|| a.file.cmp(&b.file))
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
|
||||||
|
let display_count = rows.len().min(10);
|
||||||
|
println!(
|
||||||
|
" {:<36} {:>5} {:>8} {:>5}",
|
||||||
|
"Function".bold().underline(),
|
||||||
|
"Cycl".bold().underline(),
|
||||||
|
"Branches".bold().underline(),
|
||||||
|
"Nest".bold().underline(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for fc in rows.into_iter().take(display_count) {
|
||||||
|
let name = if fc.name.len() > 34 {
|
||||||
|
format!("{}...", &fc.name[..31])
|
||||||
|
} else {
|
||||||
|
fc.name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:<36} {:>5} {:>8} {:>5}",
|
||||||
|
name.dimmed(),
|
||||||
|
fc.cyclomatic,
|
||||||
|
fc.cyclomatic.saturating_sub(1),
|
||||||
|
fc.nesting_depth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if func_complexities.len() > display_count {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
format!(
|
||||||
|
"... and {} more functions",
|
||||||
|
func_complexities.len() - display_count
|
||||||
|
)
|
||||||
|
.dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the module connectome heatmap and coupling statistics.
|
||||||
|
fn render_connectome_section(dep_analysis: &deps::DepAnalysis, term_w: usize, verbose: bool) {
|
||||||
|
render::section_header("Module Connectome");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Heatmap of inter-module dependencies built from 'use' and 'mod' statements.",
|
||||||
|
"Cell values: 0 (dim) = no dependency, 1 (yellow) = unidirectional, 2 (red) = bidirectional.",
|
||||||
|
"Diagonal = · (a module cannot depend on itself).",
|
||||||
|
"Bidirectional (2, red) means both modules import each other — a coupling risk.",
|
||||||
|
"Top coupled pairs: ◆ bidi (red) = bidirectional, → uni (yellow) = one-way.",
|
||||||
|
"Fan-in (blue) = how many modules depend on me (high = core utility).",
|
||||||
|
"Fan-out (yellow) = how many modules I depend on (high = potential god module).",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if dep_analysis.modules.is_empty() || dep_analysis.edges.is_empty() {
|
||||||
|
println!(" {}", "No inter-module dependencies detected.".dimmed());
|
||||||
|
} else {
|
||||||
|
render_mini_heatmap(dep_analysis, term_w);
|
||||||
|
render_connectome_details(dep_analysis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render coupling pairs, fan-in/fan-out, and edge counts for the connectome.
|
||||||
|
fn render_connectome_details(dep_analysis: &deps::DepAnalysis) {
|
||||||
|
let coupling_display = dep_analysis.coupling_pairs.len().min(5);
|
||||||
|
if coupling_display > 0 {
|
||||||
|
println!(" {}", "Top coupled pairs:".bold());
|
||||||
|
for (a, b, strength) in dep_analysis.coupling_pairs.iter().take(coupling_display) {
|
||||||
|
let kind = if *strength >= 2 {
|
||||||
|
"◆ bidi".red().bold().to_string()
|
||||||
|
} else {
|
||||||
|
"→ uni".yellow().to_string()
|
||||||
|
};
|
||||||
|
println!(" {} ↔ {} {}", a.dimmed(), b.dimmed(), kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
let mut by_fan_in: Vec<(&String, usize)> = dep_analysis
|
||||||
|
.in_degree
|
||||||
|
.iter()
|
||||||
|
.map(|(m, d)| (m, *d))
|
||||||
|
.filter(|(_, d)| *d > 0)
|
||||||
|
.collect();
|
||||||
|
by_fan_in.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
let mut by_fan_out: Vec<(&String, usize)> = dep_analysis
|
||||||
|
.out_degree
|
||||||
|
.iter()
|
||||||
|
.map(|(m, d)| (m, *d))
|
||||||
|
.filter(|(_, d)| *d > 0)
|
||||||
|
.collect();
|
||||||
|
by_fan_out.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
if let Some((name, deg)) = by_fan_in.first() {
|
||||||
|
println!(
|
||||||
|
" {} {} (fan-in: {})",
|
||||||
|
"Highest fan-in:".bold(),
|
||||||
|
name.bright_blue(),
|
||||||
|
format!("{}", deg).bold(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((name, deg)) = by_fan_out.first() {
|
||||||
|
println!(
|
||||||
|
" {} {} (fan-out: {})",
|
||||||
|
"Highest fan-out:".bold(),
|
||||||
|
name.yellow(),
|
||||||
|
format!("{}", deg).bold(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_edges = dep_analysis.edges.len();
|
||||||
|
let bidi_count = dep_analysis
|
||||||
|
.coupling_pairs
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, _, s)| *s >= 2)
|
||||||
|
.count();
|
||||||
|
println!(
|
||||||
|
" {} edges, {} bidirectional pairs",
|
||||||
|
format!("{}", total_edges).bold(),
|
||||||
|
format!("{}", bidi_count).bold(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the sparkline metric distributions section.
|
||||||
|
fn render_distributions_section(
|
||||||
|
func_complexities: &[complexity::FunctionComplexity],
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
render::section_header("Metric Distributions");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Sparklines show the sorted distribution shape for each metric.",
|
||||||
|
"Values are sorted ascending, then binned into ~40 buckets to form the sparkline.",
|
||||||
|
"▁ = low, █ = high. A flat line means uniform values; a steep ramp means outliers.",
|
||||||
|
"Range shows min–max. μ = arithmetic mean across all functions.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !func_complexities.is_empty() {
|
||||||
|
render_sparkline_distributions(func_complexities);
|
||||||
|
} else {
|
||||||
|
println!(" {}", "No functions to analyze.".dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the graph centrality highlights section.
|
||||||
|
fn render_centrality_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) {
|
||||||
|
render::section_header("Graph Centrality");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Key nodes in the call graph, identified by three complementary metrics.",
|
||||||
|
"bc = betweenness centrality (Brandes algorithm): fraction of all shortest paths",
|
||||||
|
" passing through this node. High bc = bridge/bottleneck — removing it would",
|
||||||
|
" disconnect parts of the graph. Scale: 0.0 (no shortest paths) to 1.0 (all paths).",
|
||||||
|
"pr = PageRank (damping 0.85, 100 iterations): recursive importance — a node is",
|
||||||
|
" important if important nodes point to it. Higher = more central.",
|
||||||
|
"cc = clustering coefficient: fraction of a node's neighbors that are also connected",
|
||||||
|
" to each other. High cc = tight clique; low cc = broker between groups.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
match graph::graph_summary(rs_files, project_path_canon) {
|
||||||
|
Some(gs) => render_graph_centrality(&gs),
|
||||||
|
None => println!(" {}", "No call graph data available.".dimmed()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the static entrypoint reachability section.
|
||||||
|
fn render_reachability_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) {
|
||||||
|
render::section_header("Static entrypoint reachability");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Percentage of functions reachable from entry points via static call graph traversal.",
|
||||||
|
"Entry points are main(), #[test] functions, and benchmarks. Reachability is computed via BFS.",
|
||||||
|
"Reachability = reachable / total. Cold functions = unreachable = dead code candidates.",
|
||||||
|
"Color: green > 80%, yellow > 50%, red ≤ 50%.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
match datapaths::entrypoint_reachability_summary(rs_files, project_path_canon) {
|
||||||
|
Some(ds) => render_entrypoint_reachability(&ds),
|
||||||
|
None => println!(" {}", "No reachability data available.".dimmed()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the circuits overview section.
|
||||||
|
fn render_circuits_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) {
|
||||||
|
render::section_header("Circuits");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Communities detected by label propagation on the call graph.",
|
||||||
|
"Each node starts with its own label, then iteratively adopts the most frequent",
|
||||||
|
"neighbor label until stable. The result is functional clusters (circuits).",
|
||||||
|
"Modularity Q measures partition quality: Q > 0.3 (green) = good modular structure,",
|
||||||
|
"Q > 0 (yellow) = weak, Q ≤ 0 (red) = worse than random.",
|
||||||
|
"Sparkline shows relative circuit sizes (█ = largest, ▁ = smallest).",
|
||||||
|
"Sizes: min–max (avg) of functions per circuit.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
match circuits::circuit_summary(rs_files, project_path_canon) {
|
||||||
|
Some(cs) => render_circuit_overview(&cs),
|
||||||
|
None => println!(" {}", "No circuit data available.".dimmed()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the statistics footer with aggregate metrics.
|
||||||
|
fn render_stats_footer(
|
||||||
|
agg_loc: &Option<loc::AggregateStats>,
|
||||||
|
complexity_stats: &Option<complexity::ComplexityStats>,
|
||||||
|
total_files: usize,
|
||||||
|
total_functions: usize,
|
||||||
|
total_modules: usize,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
render::section_header("Statistics");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Aggregate statistics across all analyzed source files and functions.",
|
||||||
|
"LoC mean / std = average and standard deviation of code lines per file.",
|
||||||
|
"LoC median = middle value when files are sorted by size (robust to outliers).",
|
||||||
|
"Complexity mean / std = average and spread of composite complexity scores.",
|
||||||
|
"Complexity median = middle composite score across all functions.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let box_w = 42;
|
||||||
|
println!(" ┌{}┐", "─".repeat(box_w));
|
||||||
|
|
||||||
|
if let Some(agg) = agg_loc {
|
||||||
|
print_stat(
|
||||||
|
"LoC mean / std",
|
||||||
|
&format!("{:.1} / {:.1}", agg.mean, agg.std_dev),
|
||||||
|
box_w,
|
||||||
|
);
|
||||||
|
print_stat("LoC median", &format!("{}", agg.median), box_w);
|
||||||
|
}
|
||||||
|
if let Some(cs) = complexity_stats {
|
||||||
|
print_stat(
|
||||||
|
"Complexity mean / std",
|
||||||
|
&format!("{:.1} / {:.1}", cs.mean, cs.std_dev),
|
||||||
|
box_w,
|
||||||
|
);
|
||||||
|
print_stat("Complexity median", &format!("{:.1}", cs.median), box_w);
|
||||||
|
}
|
||||||
|
print_stat("Total files", &format!("{}", total_files), box_w);
|
||||||
|
print_stat("Total functions", &format!("{}", total_functions), box_w);
|
||||||
|
print_stat("Total modules", &format!("{}", total_modules), box_w);
|
||||||
|
|
||||||
|
println!(" └{}┘", "─".repeat(box_w));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON Output ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryLocJson {
|
||||||
|
total_files: usize,
|
||||||
|
total_loc: usize,
|
||||||
|
mean: f64,
|
||||||
|
std_dev: f64,
|
||||||
|
median: usize,
|
||||||
|
min: usize,
|
||||||
|
max: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryComplexityJson {
|
||||||
|
total_functions: usize,
|
||||||
|
mean: f64,
|
||||||
|
std_dev: f64,
|
||||||
|
median: f64,
|
||||||
|
max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryDepsJson {
|
||||||
|
total_modules: usize,
|
||||||
|
total_edges: usize,
|
||||||
|
bidirectional_pairs: usize,
|
||||||
|
highest_fan_in: Option<(String, usize)>,
|
||||||
|
highest_fan_out: Option<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryGraphJson {
|
||||||
|
top_bridge: Option<(String, f64)>,
|
||||||
|
top_hub: Option<(String, f64)>,
|
||||||
|
top_clustered: Option<(String, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryReachabilityJson {
|
||||||
|
reachable_pct: f64,
|
||||||
|
total_functions: usize,
|
||||||
|
reachable_count: usize,
|
||||||
|
cold_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryCircuitsJson {
|
||||||
|
circuit_count: usize,
|
||||||
|
sizes: Vec<usize>,
|
||||||
|
modularity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SummaryJson {
|
||||||
|
cstat_version: String,
|
||||||
|
project: String,
|
||||||
|
loc: Option<SummaryLocJson>,
|
||||||
|
complexity: Option<SummaryComplexityJson>,
|
||||||
|
deps: SummaryDepsJson,
|
||||||
|
graph: Option<SummaryGraphJson>,
|
||||||
|
reachability: Option<SummaryReachabilityJson>,
|
||||||
|
circuits: Option<SummaryCircuitsJson>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the summary as JSON.
|
||||||
|
pub fn render_summary_json(rs_files: &[PathBuf], project_path: &Path) {
|
||||||
|
let project_path_canon = project_path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| project_path.to_path_buf());
|
||||||
|
|
||||||
|
let project_name = project_path_canon
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let (_file_stats, agg_loc) = loc::analyze_files(rs_files);
|
||||||
|
let symbols = ast_parser::parse_project(rs_files);
|
||||||
|
let func_complexities = complexity::compute_all(&symbols, &project_path_canon);
|
||||||
|
let complexity_stats = complexity::compute_stats(&func_complexities);
|
||||||
|
let dep_analysis = deps::analyze_deps(rs_files, &project_path_canon);
|
||||||
|
|
||||||
|
let loc_json = agg_loc.map(|a| SummaryLocJson {
|
||||||
|
total_files: a.total_files,
|
||||||
|
total_loc: a.total_loc,
|
||||||
|
mean: a.mean,
|
||||||
|
std_dev: a.std_dev,
|
||||||
|
median: a.median,
|
||||||
|
min: a.min,
|
||||||
|
max: a.max,
|
||||||
|
});
|
||||||
|
|
||||||
|
let complexity_json = complexity_stats.map(|s| SummaryComplexityJson {
|
||||||
|
total_functions: s.total_functions,
|
||||||
|
mean: s.mean,
|
||||||
|
std_dev: s.std_dev,
|
||||||
|
median: s.median,
|
||||||
|
max: s.max,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut by_fan_in: Vec<(&String, usize)> = dep_analysis
|
||||||
|
.in_degree
|
||||||
|
.iter()
|
||||||
|
.map(|(m, d)| (m, *d))
|
||||||
|
.filter(|(_, d)| *d > 0)
|
||||||
|
.collect();
|
||||||
|
by_fan_in.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
let mut by_fan_out: Vec<(&String, usize)> = dep_analysis
|
||||||
|
.out_degree
|
||||||
|
.iter()
|
||||||
|
.map(|(m, d)| (m, *d))
|
||||||
|
.filter(|(_, d)| *d > 0)
|
||||||
|
.collect();
|
||||||
|
by_fan_out.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
let bidi_count = dep_analysis
|
||||||
|
.coupling_pairs
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, _, s)| *s >= 2)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let deps_json = SummaryDepsJson {
|
||||||
|
total_modules: dep_analysis.modules.len(),
|
||||||
|
total_edges: dep_analysis.edges.len(),
|
||||||
|
bidirectional_pairs: bidi_count,
|
||||||
|
highest_fan_in: by_fan_in.first().map(|(n, d)| ((*n).clone(), *d)),
|
||||||
|
highest_fan_out: by_fan_out.first().map(|(n, d)| ((*n).clone(), *d)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let graph_json =
|
||||||
|
graph::graph_summary(rs_files, &project_path_canon).map(|gs| SummaryGraphJson {
|
||||||
|
top_bridge: gs.top_bridge,
|
||||||
|
top_hub: gs.top_hub,
|
||||||
|
top_clustered: gs.top_clustered,
|
||||||
|
});
|
||||||
|
|
||||||
|
let reachability_json =
|
||||||
|
datapaths::entrypoint_reachability_summary(rs_files, &project_path_canon).map(|ds| {
|
||||||
|
SummaryReachabilityJson {
|
||||||
|
reachable_pct: ds.reachable_pct,
|
||||||
|
total_functions: ds.total_functions,
|
||||||
|
reachable_count: ds.reachable_count,
|
||||||
|
cold_count: ds.cold_count,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let circuits_json =
|
||||||
|
circuits::circuit_summary(rs_files, &project_path_canon).map(|cs| SummaryCircuitsJson {
|
||||||
|
circuit_count: cs.circuit_count,
|
||||||
|
sizes: cs.sizes,
|
||||||
|
modularity: cs.modularity,
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = SummaryJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
project: project_name,
|
||||||
|
loc: loc_json,
|
||||||
|
complexity: complexity_json,
|
||||||
|
deps: deps_json,
|
||||||
|
graph: graph_json,
|
||||||
|
reachability: reachability_json,
|
||||||
|
circuits: circuits_json,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
374
src/summary/sections.rs
Normal file
374
src/summary/sections.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
use colored::Colorize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::circuits;
|
||||||
|
use crate::complexity;
|
||||||
|
use crate::datapaths;
|
||||||
|
use crate::deps;
|
||||||
|
use crate::graph;
|
||||||
|
use crate::loc;
|
||||||
|
|
||||||
|
/// Build a sparkline string from a slice of f64 values.
|
||||||
|
pub(super) fn sparkline(values: &[f64]) -> String {
|
||||||
|
if values.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
let range = (max - min).max(f64::EPSILON);
|
||||||
|
|
||||||
|
let num_bins = 40.min(values.len());
|
||||||
|
let bin_size = (values.len() as f64 / num_bins as f64).ceil() as usize;
|
||||||
|
|
||||||
|
let mut bins: Vec<f64> = Vec::new();
|
||||||
|
for chunk in values.chunks(bin_size) {
|
||||||
|
let avg = chunk.iter().sum::<f64>() / chunk.len() as f64;
|
||||||
|
bins.push(avg);
|
||||||
|
}
|
||||||
|
|
||||||
|
bins.iter()
|
||||||
|
.map(|&v| {
|
||||||
|
let idx = ((v - min) / range * 7.0).round() as usize;
|
||||||
|
blocks[idx.min(7)]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render single-line sparkline distributions for key metrics.
|
||||||
|
pub(super) fn render_sparkline_distributions(fcs: &[complexity::FunctionComplexity]) {
|
||||||
|
let metrics: Vec<(&str, Vec<f64>)> = vec![
|
||||||
|
(
|
||||||
|
"Function LoC",
|
||||||
|
fcs.iter().map(|f| f.line_count as f64).collect(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Cyclomatic",
|
||||||
|
fcs.iter().map(|f| f.cyclomatic as f64).collect(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Cognitive",
|
||||||
|
fcs.iter().map(|f| f.cognitive as f64).collect(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Nesting",
|
||||||
|
fcs.iter().map(|f| f.nesting_depth as f64).collect(),
|
||||||
|
),
|
||||||
|
("Params", fcs.iter().map(|f| f.param_count as f64).collect()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, mut values) in metrics {
|
||||||
|
if values.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let spark = sparkline(&values);
|
||||||
|
let min = values.first().copied().unwrap_or(0.0);
|
||||||
|
let max = values.last().copied().unwrap_or(0.0);
|
||||||
|
let mean = values.iter().sum::<f64>() / values.len() as f64;
|
||||||
|
println!(
|
||||||
|
" {:<14} {} {:.0}–{:.0} (μ={:.1})",
|
||||||
|
name.dimmed(),
|
||||||
|
spark.bright_yellow(),
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
mean,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render graph centrality highlights.
|
||||||
|
pub(super) fn render_graph_centrality(gs: &graph::GraphSummary) {
|
||||||
|
if let Some((name, val)) = &gs.top_bridge {
|
||||||
|
let short = if name.len() > 30 {
|
||||||
|
&name[name.len() - 30..]
|
||||||
|
} else {
|
||||||
|
name.as_str()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {} {} (bc={:.4})",
|
||||||
|
"Top bridge:".bold(),
|
||||||
|
short.bright_blue(),
|
||||||
|
val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((name, val)) = &gs.top_hub {
|
||||||
|
let short = if name.len() > 30 {
|
||||||
|
&name[name.len() - 30..]
|
||||||
|
} else {
|
||||||
|
name.as_str()
|
||||||
|
};
|
||||||
|
println!(" {} {} (pr={:.4})", "Top hub:".bold(), short.yellow(), val,);
|
||||||
|
}
|
||||||
|
if let Some((name, val)) = &gs.top_clustered {
|
||||||
|
let short = if name.len() > 30 {
|
||||||
|
&name[name.len() - 30..]
|
||||||
|
} else {
|
||||||
|
name.as_str()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {} {} (cc={:.4})",
|
||||||
|
"Most clustered:".bold(),
|
||||||
|
short.green(),
|
||||||
|
val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if gs.top_bridge.is_none() && gs.top_hub.is_none() && gs.top_clustered.is_none() {
|
||||||
|
println!(" {}", "No centrality data available.".dimmed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render static entrypoint reachability section.
|
||||||
|
pub(super) fn render_entrypoint_reachability(ds: &datapaths::EntrypointReachabilitySummary) {
|
||||||
|
let reachability_color = if ds.reachable_pct > 80.0 {
|
||||||
|
format!("{:.1}%", ds.reachable_pct)
|
||||||
|
.green()
|
||||||
|
.bold()
|
||||||
|
.to_string()
|
||||||
|
} else if ds.reachable_pct > 50.0 {
|
||||||
|
format!("{:.1}%", ds.reachable_pct)
|
||||||
|
.yellow()
|
||||||
|
.bold()
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{:.1}%", ds.reachable_pct).red().bold().to_string()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {} {} reachable from entry points",
|
||||||
|
"Reachability:".bold(),
|
||||||
|
reachability_color,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} reachable, {} cold (of {} total)",
|
||||||
|
format!("{}", ds.reachable_count).bold(),
|
||||||
|
if ds.cold_count > 0 {
|
||||||
|
format!("{}", ds.cold_count).red().to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}", ds.cold_count).green().to_string()
|
||||||
|
},
|
||||||
|
ds.total_functions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render circuit overview section.
|
||||||
|
pub(super) fn render_circuit_overview(cs: &circuits::CircuitSummary) {
|
||||||
|
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
let size_spark: String = if cs.sizes.len() > 1 {
|
||||||
|
let max_s = *cs.sizes.iter().max().unwrap_or(&1) as f64;
|
||||||
|
cs.sizes
|
||||||
|
.iter()
|
||||||
|
.map(|&s| {
|
||||||
|
let idx = ((s as f64 / max_s) * 7.0).round() as usize;
|
||||||
|
blocks[idx.min(7)]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else if cs.sizes.len() == 1 {
|
||||||
|
"█".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} circuit{} {}",
|
||||||
|
"Circuits:".bold(),
|
||||||
|
format!("{}", cs.circuit_count).bold(),
|
||||||
|
if cs.circuit_count == 1 { "" } else { "s" },
|
||||||
|
size_spark.bright_yellow(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mod_color = if cs.modularity > 0.3 {
|
||||||
|
format!("{:.4}", cs.modularity).green().bold().to_string()
|
||||||
|
} else if cs.modularity > 0.0 {
|
||||||
|
format!("{:.4}", cs.modularity).yellow().to_string()
|
||||||
|
} else {
|
||||||
|
format!("{:.4}", cs.modularity).red().to_string()
|
||||||
|
};
|
||||||
|
println!(" {} {}", "Modularity:".bold(), mod_color,);
|
||||||
|
|
||||||
|
if !cs.sizes.is_empty() {
|
||||||
|
let max_s = cs.sizes.iter().max().unwrap_or(&0);
|
||||||
|
let min_s = cs.sizes.iter().min().unwrap_or(&0);
|
||||||
|
let avg_s = cs.sizes.iter().sum::<usize>() as f64 / cs.sizes.len() as f64;
|
||||||
|
println!(" Sizes: {}–{} (avg {:.1})", min_s, max_s, avg_s,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_stat(label: &str, value: &str, box_w: usize) {
|
||||||
|
let inner = box_w - 2;
|
||||||
|
let val_w = inner.saturating_sub(label.len() + 3);
|
||||||
|
println!(" │ {} {:>w$} │", label.cyan(), value.bold(), w = val_w,);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn short_path(path: &Path, base: &Path) -> String {
|
||||||
|
let rel = path.strip_prefix(base).unwrap_or(path);
|
||||||
|
rel.display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn short_path_truncated(path: &Path, base: &Path, max_len: usize) -> String {
|
||||||
|
let s = short_path(path, base);
|
||||||
|
if s.len() > max_len {
|
||||||
|
format!("...{}", &s[s.len() - (max_len - 3)..])
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a compact mini heatmap matrix in the summary's connectome section.
|
||||||
|
pub(super) fn render_mini_heatmap(analysis: &deps::DepAnalysis, term_w: usize) {
|
||||||
|
let mut module_connectivity: Vec<(&String, usize)> = analysis
|
||||||
|
.modules
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let in_d = analysis.in_degree.get(m).copied().unwrap_or(0);
|
||||||
|
let out_d = analysis.out_degree.get(m).copied().unwrap_or(0);
|
||||||
|
(m, in_d + out_d)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
module_connectivity.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
let display_modules: Vec<&String> = module_connectivity
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, c)| *c > 0)
|
||||||
|
.take(8)
|
||||||
|
.map(|(m, _)| *m)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if display_modules.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let edge_set: HashSet<(&str, &str)> = analysis
|
||||||
|
.edges
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.from.as_str(), e.to.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let n = display_modules.len();
|
||||||
|
|
||||||
|
let short_names: Vec<&str> = display_modules
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.rsplit("::").next().unwrap_or(m))
|
||||||
|
.collect();
|
||||||
|
let max_short = short_names
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(3)
|
||||||
|
.min(12);
|
||||||
|
|
||||||
|
let matrix_cols = max_short + 3 + n * 2 + 4;
|
||||||
|
if matrix_cols > term_w {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print!(" {:>width$} ", "", width = max_short);
|
||||||
|
for i in 0..n {
|
||||||
|
print!("{} ", format!("{}", i).dimmed());
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for (ri, row_mod) in display_modules.iter().enumerate() {
|
||||||
|
let label = if short_names[ri].len() > max_short {
|
||||||
|
&short_names[ri][..max_short]
|
||||||
|
} else {
|
||||||
|
short_names[ri]
|
||||||
|
};
|
||||||
|
print!(" {:>width$} │ ", label.dimmed(), width = max_short);
|
||||||
|
|
||||||
|
for (ci, col_mod) in display_modules.iter().enumerate() {
|
||||||
|
if ri == ci {
|
||||||
|
print!("{} ", "·".dimmed());
|
||||||
|
} else {
|
||||||
|
let fwd = edge_set.contains(&(row_mod.as_str(), col_mod.as_str()));
|
||||||
|
let rev = edge_set.contains(&(col_mod.as_str(), row_mod.as_str()));
|
||||||
|
let strength = (if fwd { 1 } else { 0 }) + (if rev { 1 } else { 0 });
|
||||||
|
match strength {
|
||||||
|
0 => print!("{} ", "0".dimmed()),
|
||||||
|
1 => print!("{} ", "1".yellow()),
|
||||||
|
_ => print!("{} ", "2".red().bold()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", format!("{}", ri).dimmed());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" {} {} {}", "0".dimmed(), "1".yellow(), "2".red().bold(),);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a proportional directory breakdown showing LoC distribution by directory.
|
||||||
|
pub(super) fn render_dir_breakdown(
|
||||||
|
file_stats: &[loc::FileLocStats],
|
||||||
|
project_path: &Path,
|
||||||
|
term_w: usize,
|
||||||
|
total_loc: usize,
|
||||||
|
) {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
if total_loc == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dir_loc: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
for stat in file_stats {
|
||||||
|
let rel = stat.path.strip_prefix(project_path).unwrap_or(&stat.path);
|
||||||
|
let components: Vec<_> = rel.components().collect();
|
||||||
|
let dir_name = if components.len() <= 1 {
|
||||||
|
".".to_string()
|
||||||
|
} else {
|
||||||
|
components[0].as_os_str().to_string_lossy().to_string()
|
||||||
|
};
|
||||||
|
*dir_loc.entry(dir_name).or_insert(0) += stat.code_lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir_loc.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dirs: Vec<(String, usize)> = dir_loc.into_iter().collect();
|
||||||
|
dirs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" {}", "Directory breakdown:".bold());
|
||||||
|
|
||||||
|
let max_dir_label = dirs.iter().map(|(d, _)| d.len()).max().unwrap_or(3).min(20);
|
||||||
|
let bar_budget = term_w
|
||||||
|
.saturating_sub(max_dir_label + 4)
|
||||||
|
.saturating_sub(12)
|
||||||
|
.max(10);
|
||||||
|
|
||||||
|
for (dir, loc) in &dirs {
|
||||||
|
let pct = *loc as f64 / total_loc as f64 * 100.0;
|
||||||
|
let bar_len = (*loc as f64 / total_loc as f64 * bar_budget as f64).ceil() as usize;
|
||||||
|
let bar_len = bar_len.max(if *loc > 0 { 1 } else { 0 });
|
||||||
|
|
||||||
|
let ratio = *loc as f64 / total_loc as f64;
|
||||||
|
let color_fn = |t: &str| -> String {
|
||||||
|
if ratio > 0.5 {
|
||||||
|
t.red().bold().to_string()
|
||||||
|
} else if ratio > 0.25 {
|
||||||
|
t.yellow().to_string()
|
||||||
|
} else {
|
||||||
|
t.green().to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bar = color_fn(&"█".repeat(bar_len));
|
||||||
|
let padding = " ".repeat(bar_budget.saturating_sub(bar_len));
|
||||||
|
let label = if dir.len() > max_dir_label {
|
||||||
|
format!("..{}", &dir[dir.len() - (max_dir_label - 2)..])
|
||||||
|
} else {
|
||||||
|
format!("{}/", dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:>width$} {}{} {:>5.1}%",
|
||||||
|
label.dimmed(),
|
||||||
|
bar,
|
||||||
|
padding,
|
||||||
|
pct,
|
||||||
|
width = max_dir_label + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,14 +50,6 @@ fn call_trace_infers_cargo_run_entry() {
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
assert!(stdout.contains("cstat call trace"), "stdout={stdout}");
|
assert!(stdout.contains("cstat call trace"), "stdout={stdout}");
|
||||||
assert!(
|
|
||||||
stdout.contains("static possible-call expansion; not a runtime execution trace"),
|
|
||||||
"stdout={stdout}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
stdout.contains("static expansion summary:"),
|
|
||||||
"stdout={stdout}"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
stdout.contains("cargo command: cargo run --"),
|
stdout.contains("cargo command: cargo run --"),
|
||||||
"stdout={stdout}"
|
"stdout={stdout}"
|
||||||
|
|
|
||||||
|
|
@ -63,16 +63,6 @@ fn run_cstat(path: &Path, export: &Path, args: &[&str]) -> Output {
|
||||||
command.output().expect("invoke cstat binary")
|
command.output().expect("invoke cstat binary")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_cstat_without_export(path: &Path, args: &[&str]) -> Output {
|
|
||||||
let bin = env!("CARGO_BIN_EXE_cstat");
|
|
||||||
let mut command = Command::new(bin);
|
|
||||||
command.args(["--no-color", "--path"]);
|
|
||||||
command.arg(path);
|
|
||||||
command.args(args);
|
|
||||||
command.env_remove("CSTAT_LLVM_COV_EXPORT_JSON");
|
|
||||||
command.output().expect("invoke cstat binary")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_success(output: &Output) {
|
fn assert_success(output: &Output) {
|
||||||
assert!(
|
assert!(
|
||||||
output.status.success(),
|
output.status.success(),
|
||||||
|
|
@ -83,32 +73,6 @@ fn assert_success(output: &Output) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coverage_verbose_prints_guidance_before_no_run_env_error() {
|
|
||||||
let root = temp_project("missing-env");
|
|
||||||
let output = run_cstat_without_export(&root, &["coverage", "--no-run", "-v"]);
|
|
||||||
assert!(
|
|
||||||
!output.status.success(),
|
|
||||||
"coverage --no-run without export env should fail"
|
|
||||||
);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
assert!(
|
|
||||||
stdout.contains("Normal mode runs cargo test with Rust source-based coverage flags"),
|
|
||||||
"stdout={stdout}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
stdout.contains("Use coverage --no-run with CSTAT_LLVM_COV_EXPORT_JSON"),
|
|
||||||
"stdout={stdout}"
|
|
||||||
);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
assert!(
|
|
||||||
stderr.contains("CSTAT_LLVM_COV_EXPORT_JSON is required"),
|
|
||||||
"stderr={stderr}"
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
#[test]
|
#[test]
|
||||||
fn coverage_no_run_reports_dynamic_lines_and_branches_from_export_json() {
|
fn coverage_no_run_reports_dynamic_lines_and_branches_from_export_json() {
|
||||||
let root = temp_project("json");
|
let root = temp_project("json");
|
||||||
|
|
@ -152,7 +116,7 @@ fn coverage_human_separates_static_projected_dynamic_and_branch_sections() {
|
||||||
let root = temp_project("human");
|
let root = temp_project("human");
|
||||||
let file = root.join("src/lib.rs");
|
let file = root.join("src/lib.rs");
|
||||||
let export = export_json(&root);
|
let export = export_json(&root);
|
||||||
let output = run_cstat(&file, &export, &["coverage", "--no-run", "-v"]);
|
let output = run_cstat(&file, &export, &["coverage", "--no-run"]);
|
||||||
assert_success(&output);
|
assert_success(&output);
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
@ -162,27 +126,13 @@ fn coverage_human_separates_static_projected_dynamic_and_branch_sections() {
|
||||||
"Dynamic line coverage",
|
"Dynamic line coverage",
|
||||||
"Branch coverage",
|
"Branch coverage",
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert!(stdout.contains(section), "missing {section}: {stdout}");
|
||||||
stdout
|
}
|
||||||
.lines()
|
assert!(stdout.contains("missing lines: 5"), "stdout={stdout}");
|
||||||
.filter(|line| line.trim_start().starts_with(&format!("┌─ {section}")))
|
assert!(
|
||||||
.count(),
|
stdout.contains("line 2: flag missing false"),
|
||||||
1,
|
"stdout={stdout}"
|
||||||
"section header should appear once: {section}: {stdout}"
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
for expected in [
|
|
||||||
"Normal mode runs cargo test with Rust source-based coverage flags",
|
|
||||||
"Line coverage is dynamic llvm-cov segment data; branch coverage uses branch records when the Rust/LLVM toolchain emits them.",
|
|
||||||
"Reachability is computed from the static call graph; it is not runtime coverage.",
|
|
||||||
"Projected reachable lines are production code lines inside functions statically reached from project tests/benches.",
|
|
||||||
"Coverable/executed lines come from llvm-cov segment counts after cstat's production-span filter.",
|
|
||||||
"Branch coverage counts true/false outcomes from llvm-cov branch records.",
|
|
||||||
"missing lines: 5",
|
|
||||||
"line 2: flag missing false",
|
|
||||||
] {
|
|
||||||
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
|
|
||||||
}
|
|
||||||
assert!(!stdout.contains("Path coverage"), "stdout={stdout}");
|
assert!(!stdout.contains("Path coverage"), "stdout={stdout}");
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::{Command, Output};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_project(name: &str) -> PathBuf {
|
|
||||||
let unique = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let root = std::env::temp_dir().join(format!("cstat-deps-cli-{name}-{unique}"));
|
|
||||||
fs::create_dir_all(root.join("src")).unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("Cargo.toml"),
|
|
||||||
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(root.join("src/lib.rs"), "pub mod alpha;\npub mod beta;\n").unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("src/alpha.rs"),
|
|
||||||
r#"pub struct Shared;
|
|
||||||
|
|
||||||
pub fn shared(input: Shared) -> Shared {
|
|
||||||
input
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn closure_owner(input: Shared) -> Shared {
|
|
||||||
let first = || shared(input);
|
|
||||||
let second = || shared(Shared);
|
|
||||||
first();
|
|
||||||
second()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sibling(input: Shared) -> Shared {
|
|
||||||
shared(input)
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("src/beta.rs"),
|
|
||||||
r#"use crate::alpha::shared;
|
|
||||||
|
|
||||||
pub fn beta() {
|
|
||||||
let _ = shared(crate::alpha::Shared);
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
root
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_cstat(project: &Path, args: &[&str]) -> Output {
|
|
||||||
let bin = env!("CARGO_BIN_EXE_cstat");
|
|
||||||
let mut command = Command::new(bin);
|
|
||||||
command.args(["--no-color", "--path"]);
|
|
||||||
command.arg(project);
|
|
||||||
command.args(args);
|
|
||||||
command.output().expect("invoke cstat binary")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_success(output: &Output) {
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"cstat failed: status={:?}\nstderr={}\nstdout={}",
|
|
||||||
output.status,
|
|
||||||
String::from_utf8_lossy(&output.stderr),
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deps_human_verbose_shows_actual_one_way_direction() {
|
|
||||||
let root = temp_project("human-direction");
|
|
||||||
let output = run_cstat(&root, &["deps", "--top", "10", "-v"]);
|
|
||||||
assert_success(&output);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
assert!(
|
|
||||||
stdout.contains("Cells are pair strength, not edge direction"),
|
|
||||||
"stdout={stdout}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
stdout.contains("One-way rows show actual source module -> target module direction."),
|
|
||||||
"stdout={stdout}"
|
|
||||||
);
|
|
||||||
assert!(stdout.contains("signature type tokens"), "stdout={stdout}");
|
|
||||||
let directed_line = stdout
|
|
||||||
.lines()
|
|
||||||
.find(|line| line.contains("beta") && line.contains("alpha") && line.contains("one-way"))
|
|
||||||
.unwrap_or_else(|| panic!("missing beta -> alpha coupling line: {stdout}"));
|
|
||||||
assert!(
|
|
||||||
directed_line.find("beta") < directed_line.find("alpha"),
|
|
||||||
"line should show source before target: {directed_line}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!stdout.contains("alpha::closure_owner"),
|
|
||||||
"function-scope pseudo-module leaked into cohesion output: {stdout}"
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deps_json_coupling_edges_and_cohesion_use_source_modules() {
|
|
||||||
let root = temp_project("json-direction");
|
|
||||||
let output = run_cstat(&root, &["--json", "deps"]);
|
|
||||||
assert_success(&output);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
||||||
let value: Value = serde_json::from_str(&stdout).expect("parse deps JSON");
|
|
||||||
let pairs = value["coupling_pairs"]
|
|
||||||
.as_array()
|
|
||||||
.expect("coupling_pairs array");
|
|
||||||
let alpha_beta = pairs
|
|
||||||
.iter()
|
|
||||||
.find(|pair| pair["module_a"] == "alpha" && pair["module_b"] == "beta")
|
|
||||||
.unwrap_or_else(|| panic!("missing alpha/beta pair: {value}"));
|
|
||||||
assert_eq!(alpha_beta["direction"], "unidirectional");
|
|
||||||
assert_eq!(
|
|
||||||
alpha_beta["edges"],
|
|
||||||
serde_json::json!([{ "from": "beta", "to": "alpha" }]),
|
|
||||||
"pair={alpha_beta}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let cohesion = value["cohesion"].as_array().expect("cohesion array");
|
|
||||||
let modules = cohesion
|
|
||||||
.iter()
|
|
||||||
.filter_map(|row| row["module"].as_str())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert!(modules.contains(&"alpha"), "modules={modules:?}");
|
|
||||||
assert!(modules.contains(&"beta"), "modules={modules:?}");
|
|
||||||
assert!(
|
|
||||||
!modules
|
|
||||||
.iter()
|
|
||||||
.any(|module| module.contains("closure_owner")),
|
|
||||||
"cohesion should use source modules, not function scopes: {modules:?}"
|
|
||||||
);
|
|
||||||
let alpha = cohesion
|
|
||||||
.iter()
|
|
||||||
.find(|row| row["module"] == "alpha")
|
|
||||||
.unwrap_or_else(|| panic!("missing alpha cohesion row: {value}"));
|
|
||||||
assert_eq!(alpha["function_count"], 3);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nested_project(name: &str) -> PathBuf {
|
|
||||||
let root = temp_project(name);
|
|
||||||
fs::remove_file(root.join("src/alpha.rs")).unwrap();
|
|
||||||
fs::remove_file(root.join("src/beta.rs")).unwrap();
|
|
||||||
fs::write(root.join("src/lib.rs"), "pub mod parent;\n").unwrap();
|
|
||||||
fs::create_dir_all(root.join("src/parent")).unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("src/parent/mod.rs"),
|
|
||||||
r#"mod child;
|
|
||||||
|
|
||||||
pub struct Shared;
|
|
||||||
|
|
||||||
pub use child::child;
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("src/parent/child.rs"),
|
|
||||||
r#"use super::Shared;
|
|
||||||
|
|
||||||
pub fn child(_: Shared) {}
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
root
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deps_json_resolves_nested_mod_and_super_as_bidirectional_pair() {
|
|
||||||
let root = nested_project("nested-bidir");
|
|
||||||
let output = run_cstat(&root, &["--json", "deps"]);
|
|
||||||
assert_success(&output);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
||||||
let value: Value = serde_json::from_str(&stdout).expect("parse deps JSON");
|
|
||||||
let pairs = value["coupling_pairs"]
|
|
||||||
.as_array()
|
|
||||||
.expect("coupling_pairs array");
|
|
||||||
let parent_child = pairs
|
|
||||||
.iter()
|
|
||||||
.find(|pair| pair["module_a"] == "parent" && pair["module_b"] == "parent::child")
|
|
||||||
.unwrap_or_else(|| panic!("missing parent/child pair: {value}"));
|
|
||||||
assert_eq!(parent_child["direction"], "bidirectional");
|
|
||||||
assert_eq!(
|
|
||||||
parent_child["edges"],
|
|
||||||
serde_json::json!([
|
|
||||||
{ "from": "parent", "to": "parent::child" },
|
|
||||||
{ "from": "parent::child", "to": "parent" }
|
|
||||||
]),
|
|
||||||
"pair={parent_child}"
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
@ -94,6 +94,165 @@ fn assert_success(output: &Output) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_json_array_contains(array: &Value, expected: &str) {
|
||||||
|
let values = array.as_array().expect("json array");
|
||||||
|
assert!(
|
||||||
|
values.iter().any(|value| value == expected),
|
||||||
|
"expected {expected:?} in {values:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_summary_human_accepts_file_path() {
|
||||||
|
let root = temp_project("human");
|
||||||
|
let file = root.join("src/lib.rs");
|
||||||
|
let output = run_cstat(&file, &["-v", "summary"]);
|
||||||
|
assert_success(&output);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("cstat file summary (src/lib.rs)"),
|
||||||
|
"stdout={stdout}"
|
||||||
|
);
|
||||||
|
for section in [
|
||||||
|
"Projected line reachability",
|
||||||
|
"Static test/benchmark reachability",
|
||||||
|
"Symbols",
|
||||||
|
"Dependencies",
|
||||||
|
"Dead code candidates",
|
||||||
|
"Call trace size",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
stdout.contains(section),
|
||||||
|
"missing section {section}: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for full_section in [
|
||||||
|
"ℹ Full section: cstat loc --path src/lib.rs -v",
|
||||||
|
"ℹ Full section: cstat test-reachability --path src/lib.rs -v",
|
||||||
|
"ℹ Full section: cstat symbols --path src/lib.rs -v",
|
||||||
|
"ℹ Full section: cstat deps --path src/lib.rs -v",
|
||||||
|
"ℹ Full section: cstat dead-code --path src/lib.rs -v",
|
||||||
|
"ℹ Full section: cstat call-trace --path src/lib.rs -v",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
stdout.contains(full_section),
|
||||||
|
"missing verbose line {full_section}: {stdout}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for expected in [
|
||||||
|
"fn 6", "struct 1", "enum 1", "trait 1", "const 1", "static 1",
|
||||||
|
] {
|
||||||
|
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
|
||||||
|
}
|
||||||
|
assert!(stdout.contains("test/bench roots 1"), "stdout={stdout}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("not statically reachable:"),
|
||||||
|
"stdout={stdout}"
|
||||||
|
);
|
||||||
|
assert!(stdout.contains("orphan"), "stdout={stdout}");
|
||||||
|
assert!(stdout.contains("dead_child"), "stdout={stdout}");
|
||||||
|
assert!(stdout.contains("in-file fan-in"), "stdout={stdout}");
|
||||||
|
for expected in ["Call trace size", "live", "dead_parent", "orphan"] {
|
||||||
|
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_summary_json_is_untruncated_and_file_scoped() {
|
||||||
|
let root = temp_project("json");
|
||||||
|
let file = root.join("src/lib.rs");
|
||||||
|
let output = run_cstat(&file, &["--json", "summary"]);
|
||||||
|
assert_success(&output);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&stdout).expect("parse file summary JSON");
|
||||||
|
assert_eq!(value["file"], "src/lib.rs");
|
||||||
|
assert_eq!(value["project"], "fixture");
|
||||||
|
for field in [
|
||||||
|
"line_counts",
|
||||||
|
"static_reachability",
|
||||||
|
"symbols",
|
||||||
|
"dependencies",
|
||||||
|
"dead_code",
|
||||||
|
"call_trace",
|
||||||
|
] {
|
||||||
|
assert!(value.get(field).is_some(), "missing {field}: {stdout}");
|
||||||
|
}
|
||||||
|
assert_eq!(value["symbols"]["functions"], 6);
|
||||||
|
assert_eq!(value["symbols"]["structs"], 1);
|
||||||
|
assert_eq!(value["symbols"]["enums"], 1);
|
||||||
|
assert_eq!(value["symbols"]["traits"], 1);
|
||||||
|
assert_eq!(value["symbols"]["consts"], 1);
|
||||||
|
assert_eq!(value["symbols"]["statics"], 1);
|
||||||
|
assert_eq!(
|
||||||
|
value["static_reachability"]["test_benchmark_entry_count"],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_json_array_contains(&value["static_reachability"]["reachable_functions"], "live");
|
||||||
|
assert_json_array_contains(
|
||||||
|
&value["static_reachability"]["reachable_functions"],
|
||||||
|
"helper",
|
||||||
|
);
|
||||||
|
for function in ["orphan", "dead_parent", "dead_child"] {
|
||||||
|
assert_json_array_contains(
|
||||||
|
&value["static_reachability"]["unreachable_functions"],
|
||||||
|
function,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dead_code = value["dead_code"]["functions"]
|
||||||
|
.as_array()
|
||||||
|
.expect("dead code functions");
|
||||||
|
let dead_child = dead_code
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["function"] == "dead_child")
|
||||||
|
.expect("dead_child dead-code row");
|
||||||
|
assert_eq!(dead_child["candidate"], true);
|
||||||
|
assert_eq!(dead_child["in_file_fan_in"], 1);
|
||||||
|
assert!(
|
||||||
|
dead_child["reason"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("no test/benchmark root reaches that caller"),
|
||||||
|
"dead_child reason={:?}",
|
||||||
|
dead_child["reason"]
|
||||||
|
);
|
||||||
|
let orphan = dead_code
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["function"] == "orphan")
|
||||||
|
.expect("orphan dead-code row");
|
||||||
|
assert!(
|
||||||
|
orphan["reason"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("no direct, trait, drop, function-item, or closure edge"),
|
||||||
|
"orphan reason={:?}",
|
||||||
|
orphan["reason"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let traces = value["call_trace"]["functions"]
|
||||||
|
.as_array()
|
||||||
|
.expect("call trace functions");
|
||||||
|
let live = traces
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["function"] == "live")
|
||||||
|
.expect("live trace row");
|
||||||
|
assert!(
|
||||||
|
live["functions_reached"].as_u64().unwrap() > 1,
|
||||||
|
"live trace={live:?}",
|
||||||
|
);
|
||||||
|
let orphan = traces
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["function"] == "orphan")
|
||||||
|
.expect("orphan trace row");
|
||||||
|
assert_eq!(orphan["functions_reached"], 1);
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_section_commands_accept_file_path() {
|
fn file_section_commands_accept_file_path() {
|
||||||
let root = temp_project("sections");
|
let root = temp_project("sections");
|
||||||
|
|
@ -133,7 +292,7 @@ fn file_path_errors_are_clear() {
|
||||||
let notes = root.join("notes.rs");
|
let notes = root.join("notes.rs");
|
||||||
fs::write(¬es, "fn scratch() {}\n").unwrap();
|
fs::write(¬es, "fn scratch() {}\n").unwrap();
|
||||||
|
|
||||||
let output = run_cstat(¬es, &["loc"]);
|
let output = run_cstat(¬es, &["summary"]);
|
||||||
assert!(
|
assert!(
|
||||||
!output.status.success(),
|
!output.status.success(),
|
||||||
"unexpected success: stdout={} stderr={}",
|
"unexpected success: stdout={} stderr={}",
|
||||||
|
|
@ -146,7 +305,7 @@ fn file_path_errors_are_clear() {
|
||||||
"stderr={stderr}",
|
"stderr={stderr}",
|
||||||
);
|
);
|
||||||
|
|
||||||
let output = run_cstat(¬es, &["--json", "loc"]);
|
let output = run_cstat(¬es, &["--json", "summary"]);
|
||||||
assert!(
|
assert!(
|
||||||
!output.status.success(),
|
!output.status.success(),
|
||||||
"unexpected json success: stdout={} stderr={}",
|
"unexpected json success: stdout={} stderr={}",
|
||||||
|
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::{Command, Output};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_project(name: &str) -> PathBuf {
|
|
||||||
let unique = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let root = std::env::temp_dir().join(format!("cstat-scorecard-{name}-{unique}"));
|
|
||||||
fs::create_dir_all(root.join("src")).unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("Cargo.toml"),
|
|
||||||
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
root
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_cstat(project: &Path, args: &[&str]) -> Output {
|
|
||||||
let bin = env!("CARGO_BIN_EXE_cstat");
|
|
||||||
let mut command = Command::new(bin);
|
|
||||||
command.args(["--no-color", "--path"]);
|
|
||||||
command.arg(project);
|
|
||||||
command.args(args);
|
|
||||||
command.output().expect("invoke cstat binary")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_success(output: &Output) {
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"cstat failed: status={:?}\nstderr={}\nstdout={}",
|
|
||||||
output.status,
|
|
||||||
String::from_utf8_lossy(&output.stderr),
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn score(project: &Path) -> Value {
|
|
||||||
let output = run_cstat(project, &["--json", "scorecard"]);
|
|
||||||
assert_success(&output);
|
|
||||||
serde_json::from_slice(&output.stdout).expect("parse scorecard JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn component(value: &Value, name: &str) -> f64 {
|
|
||||||
value["component_costs"][name]["cost"]
|
|
||||||
.as_f64()
|
|
||||||
.unwrap_or_else(|| panic!("missing component {name}: {value}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn total(value: &Value) -> f64 {
|
|
||||||
value["code_complexity_cost"]
|
|
||||||
.as_f64()
|
|
||||||
.unwrap_or_else(|| panic!("missing total: {value}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_main(root: &Path, source: &str) {
|
|
||||||
fs::write(root.join("src/main.rs"), source).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scorecard_json_contract_is_stable_and_purely_structural() {
|
|
||||||
let root = temp_project("json-contract");
|
|
||||||
fs::create_dir_all(root.join("tests")).unwrap();
|
|
||||||
fs::create_dir_all(root.join("benches")).unwrap();
|
|
||||||
write_main(
|
|
||||||
&root,
|
|
||||||
r#"fn main() {
|
|
||||||
live();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn live() -> usize {
|
|
||||||
1
|
|
||||||
}
|
|
||||||
|
|
||||||
fn branchy(input: usize) -> usize {
|
|
||||||
let mut total = 0;
|
|
||||||
if input > 0 { total += 1; }
|
|
||||||
if input > 1 { total += 1; }
|
|
||||||
if input > 2 { total += 1; }
|
|
||||||
if input > 3 { total += 1; }
|
|
||||||
if input > 4 { total += 1; }
|
|
||||||
total
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
fs::write(
|
|
||||||
root.join("tests/smoke.rs"),
|
|
||||||
"#[test]\nfn smoke() { assert_eq!(1 + 1, 2); }\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(root.join("benches/bench.rs"), "fn bench_helper() {}\n").unwrap();
|
|
||||||
|
|
||||||
let value = score(&root);
|
|
||||||
assert_eq!(value["cstat_version"], env!("CARGO_PKG_VERSION"));
|
|
||||||
assert_eq!(value["score_version"], "code_complexity_cost_v0");
|
|
||||||
for field in [
|
|
||||||
"target",
|
|
||||||
"code_complexity_cost",
|
|
||||||
"code_complexity_cost_per_kloc",
|
|
||||||
"component_costs",
|
|
||||||
"scope_breakdown",
|
|
||||||
"top_contributors",
|
|
||||||
"metadata",
|
|
||||||
] {
|
|
||||||
assert!(value.get(field).is_some(), "missing {field}: {value}");
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
value["component_costs"].get("verification_gap").is_none(),
|
|
||||||
"scorecard must not mix harness verification into code complexity: {value}",
|
|
||||||
);
|
|
||||||
for scope in ["production", "tests", "benches"] {
|
|
||||||
assert!(
|
|
||||||
value["scope_breakdown"].get(scope).is_some(),
|
|
||||||
"missing scope {scope}: {value}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
value["top_contributors"]
|
|
||||||
.as_array()
|
|
||||||
.expect("top array")
|
|
||||||
.len()
|
|
||||||
<= 20,
|
|
||||||
"default top contributor count should be bounded: {value}",
|
|
||||||
);
|
|
||||||
|
|
||||||
let repeated = score(&root);
|
|
||||||
assert_eq!(value, repeated, "scorecard should be deterministic");
|
|
||||||
|
|
||||||
let human = run_cstat(&root, &["scorecard", "--top", "3"]);
|
|
||||||
assert_success(&human);
|
|
||||||
let stdout = String::from_utf8_lossy(&human.stdout);
|
|
||||||
for expected in [
|
|
||||||
"Code complexity score",
|
|
||||||
"score version: code_complexity_cost_v0",
|
|
||||||
"lower is cleaner; harness guards behavior separately",
|
|
||||||
"components:",
|
|
||||||
"top contributors:",
|
|
||||||
] {
|
|
||||||
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deliberately_messy_code_scores_worse_than_simple_code() {
|
|
||||||
let clean = temp_project("clean");
|
|
||||||
write_main(
|
|
||||||
&clean,
|
|
||||||
r#"fn main() {
|
|
||||||
println!("{}", live());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn live() -> usize {
|
|
||||||
1
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let messy = temp_project("messy");
|
|
||||||
let mut source = String::from(
|
|
||||||
r#"fn main() {
|
|
||||||
println!("{}", tangled(12));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tangled(input: usize) -> usize {
|
|
||||||
let mut total = 0;
|
|
||||||
if input > 0 { total += 1; }
|
|
||||||
if input > 1 { total += 1; }
|
|
||||||
if input > 2 { total += 1; }
|
|
||||||
if input > 3 { total += 1; }
|
|
||||||
if input > 4 { total += 1; }
|
|
||||||
if input > 5 { total += 1; }
|
|
||||||
if input > 6 { total += 1; }
|
|
||||||
if input > 7 {
|
|
||||||
if input > 8 {
|
|
||||||
if input > 9 {
|
|
||||||
total += input;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
for index in 0..70 {
|
|
||||||
source.push_str(&format!(" let value_{index} = {index};\n"));
|
|
||||||
}
|
|
||||||
source.push_str(" total\n}\n");
|
|
||||||
for index in 0..30 {
|
|
||||||
source.push_str(&format!("fn helper_{index}() -> usize {{ {index} }}\n"));
|
|
||||||
}
|
|
||||||
write_main(&messy, &source);
|
|
||||||
|
|
||||||
let clean_score = score(&clean);
|
|
||||||
let messy_score = score(&messy);
|
|
||||||
assert!(
|
|
||||||
total(&messy_score) > total(&clean_score),
|
|
||||||
"messy code should cost more: clean={clean_score} messy={messy_score}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
component(&messy_score, "function_complexity")
|
|
||||||
> component(&clean_score, "function_complexity"),
|
|
||||||
"messy function shape should cost more: clean={clean_score} messy={messy_score}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
component(&messy_score, "file_concentration")
|
|
||||||
> component(&clean_score, "file_concentration"),
|
|
||||||
"messy file concentration should cost more: clean={clean_score} messy={messy_score}",
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(clean).unwrap();
|
|
||||||
fs::remove_dir_all(messy).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn adding_branching_nesting_and_large_body_does_not_reduce_function_cost() {
|
|
||||||
let root = temp_project("function-monotonic");
|
|
||||||
write_main(
|
|
||||||
&root,
|
|
||||||
r#"fn main() {
|
|
||||||
println!("{}", value(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value(input: usize) -> usize {
|
|
||||||
input + 1
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
let before = score(&root);
|
|
||||||
|
|
||||||
let mut source = String::from(
|
|
||||||
r#"fn main() {
|
|
||||||
println!("{}", value(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value(input: usize) -> usize {
|
|
||||||
let mut total = input;
|
|
||||||
if input > 0 { total += 1; }
|
|
||||||
if input > 1 { total += 1; }
|
|
||||||
if input > 2 { total += 1; }
|
|
||||||
if input > 3 { total += 1; }
|
|
||||||
if input > 4 { total += 1; }
|
|
||||||
if input > 5 {
|
|
||||||
if input > 6 {
|
|
||||||
if input > 7 {
|
|
||||||
total += input;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
for index in 0..80 {
|
|
||||||
source.push_str(&format!(" total += {index};\n"));
|
|
||||||
}
|
|
||||||
source.push_str(" total\n}\n");
|
|
||||||
write_main(&root, &source);
|
|
||||||
let after = score(&root);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
total(&after) > total(&before),
|
|
||||||
"larger, branchier body should not reduce total cost: before={before} after={after}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
component(&after, "function_complexity") > component(&before, "function_complexity"),
|
|
||||||
"larger, branchier body should increase function cost: before={before} after={after}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
component(&after, "file_concentration") >= component(&before, "file_concentration"),
|
|
||||||
"larger body should not reduce file concentration: before={before} after={after}",
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn symbol_piles_increase_abstraction_surface_and_file_concentration() {
|
|
||||||
let root = temp_project("symbol-pile");
|
|
||||||
write_main(
|
|
||||||
&root,
|
|
||||||
r#"fn main() {
|
|
||||||
println!("ok");
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
let before = score(&root);
|
|
||||||
|
|
||||||
let mut source = String::from(
|
|
||||||
r#"fn main() {
|
|
||||||
println!("ok");
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
for index in 0..18 {
|
|
||||||
source.push_str(&format!("struct Data{index};\n"));
|
|
||||||
}
|
|
||||||
for index in 0..12 {
|
|
||||||
source.push_str(&format!("enum Choice{index} {{ A, B }}\n"));
|
|
||||||
}
|
|
||||||
for index in 0..30 {
|
|
||||||
source.push_str(&format!("fn helper_{index}() -> usize {{ {index} }}\n"));
|
|
||||||
}
|
|
||||||
write_main(&root, &source);
|
|
||||||
let after = score(&root);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
component(&after, "abstraction_surface") > component(&before, "abstraction_surface"),
|
|
||||||
"symbol pile should increase abstraction surface: before={before} after={after}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
component(&after, "file_concentration") > component(&before, "file_concentration"),
|
|
||||||
"symbol pile should increase file concentration: before={before} after={after}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
total(&after) > total(&before),
|
|
||||||
"symbol pile should increase total cost: before={before} after={after}",
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fan_out_and_bidirectional_dependencies_increase_module_coupling() {
|
|
||||||
let root = temp_project("coupling");
|
|
||||||
fs::write(root.join("src/lib.rs"), "pub mod root;\npub mod a;\n").unwrap();
|
|
||||||
fs::write(root.join("src/root.rs"), "pub fn touch() {}\n").unwrap();
|
|
||||||
fs::write(root.join("src/a.rs"), "pub fn a() {}\n").unwrap();
|
|
||||||
let before = score(&root);
|
|
||||||
|
|
||||||
fs::write(
|
|
||||||
root.join("src/lib.rs"),
|
|
||||||
"pub mod root;\npub mod a;\npub mod b;\npub mod c;\npub mod d;\npub mod e;\npub mod f;\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("src/root.rs"),
|
|
||||||
r#"use crate::a::a;
|
|
||||||
use crate::b::b;
|
|
||||||
use crate::c::c;
|
|
||||||
use crate::d::d;
|
|
||||||
use crate::e::e;
|
|
||||||
use crate::f::f;
|
|
||||||
|
|
||||||
pub fn touch() {
|
|
||||||
a(); b(); c(); d(); e(); f();
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("src/a.rs"),
|
|
||||||
"use crate::root::touch;\npub fn a() { touch(); }\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
for module in ["b", "c", "d", "e", "f"] {
|
|
||||||
fs::write(
|
|
||||||
root.join(format!("src/{module}.rs")),
|
|
||||||
format!("pub fn {module}() {{}}\n"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let after = score(&root);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
component(&after, "module_coupling") > component(&before, "module_coupling"),
|
|
||||||
"fan-out and bidirectional pair should increase coupling: before={before} after={after}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
total(&after) > total(&before),
|
|
||||||
"coupling fixture should increase total cost: before={before} after={after}",
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn adding_static_dead_code_candidate_increases_stale_surface() {
|
|
||||||
let root = temp_project("stale");
|
|
||||||
write_main(
|
|
||||||
&root,
|
|
||||||
r#"fn main() {
|
|
||||||
live();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn live() {}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
let before = score(&root);
|
|
||||||
|
|
||||||
write_main(
|
|
||||||
&root,
|
|
||||||
r#"fn main() {
|
|
||||||
live();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn live() {}
|
|
||||||
|
|
||||||
fn unused_candidate() {}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
let after = score(&root);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
component(&after, "stale_surface") > component(&before, "stale_surface"),
|
|
||||||
"unused private function should increase stale surface: before={before} after={after}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
total(&after) > total(&before),
|
|
||||||
"unused private function should increase total cost: before={before} after={after}",
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
@ -188,21 +188,21 @@ fn test_reachability_json_uses_reachability_fields() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_summary_report_and_advanced_commands_are_unavailable() {
|
fn focused_report_json_renames_top_level_field() {
|
||||||
let root = temp_project("stale-commands");
|
let root = temp_project("report-json");
|
||||||
|
let output = run_cstat(&root, &["--json", "report"]);
|
||||||
|
assert_success(&output);
|
||||||
|
|
||||||
for args in [
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
&["summary"][..],
|
let value: Value = serde_json::from_str(&stdout).expect("parse focused report JSON");
|
||||||
&["report"][..],
|
assert!(value.get("test_reachability").is_some());
|
||||||
&["advanced"][..],
|
assert!(value.get("datapaths").is_none());
|
||||||
&["advanced", "flow-heatmap", "--walks", "10"][..],
|
assert_eq!(value["test_reachability"]["reachable_function_count"], 3);
|
||||||
] {
|
assert_eq!(value["test_reachability"]["unreachable_function_count"], 1);
|
||||||
let output = run_cstat(&root, args);
|
for field in ["line_counts", "symbols", "dependencies", "dead_code"] {
|
||||||
assert!(
|
assert!(
|
||||||
!output.status.success(),
|
value.get(field).is_some(),
|
||||||
"stale command unexpectedly succeeded for {args:?}: stdout={} stderr={}",
|
"missing focused report field: {field}"
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,7 +210,52 @@ fn stale_summary_report_and_advanced_commands_are_unavailable() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_lists_focused_commands_and_omits_pruned_commands() {
|
fn advanced_flow_heatmap_runs_legacy_random_walk_analysis() {
|
||||||
|
let root = temp_project("advanced");
|
||||||
|
let output = run_cstat(&root, &["advanced", "flow-heatmap", "--walks", "10"]);
|
||||||
|
assert_success(&output);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("Execution Flow Heatmap"), "stdout={stdout}");
|
||||||
|
assert!(stdout.contains("random walks"), "stdout={stdout}");
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("Static test/benchmark reachability"),
|
||||||
|
"stdout={stdout}"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_focused_datapaths_command_is_not_available() {
|
||||||
|
let root = temp_project("old-command");
|
||||||
|
let output = run_cstat(&root, &["datapaths"]);
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"legacy root command unexpectedly succeeded: stdout={} stderr={}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_advanced_datapaths_command_is_not_available() {
|
||||||
|
let root = temp_project("old-advanced-command");
|
||||||
|
let output = run_cstat(&root, &["advanced", "datapaths"]);
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"legacy advanced command unexpectedly succeeded: stdout={} stderr={}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_lists_test_reachability_and_advanced_flow_heatmap() {
|
||||||
let root = temp_project("help");
|
let root = temp_project("help");
|
||||||
|
|
||||||
let root_help = run_cstat(&root, &["--help"]);
|
let root_help = run_cstat(&root, &["--help"]);
|
||||||
|
|
@ -224,14 +269,30 @@ fn help_lists_focused_commands_and_omits_pruned_commands() {
|
||||||
root_stdout.contains("Static test/benchmark reachability"),
|
root_stdout.contains("Static test/benchmark reachability"),
|
||||||
"stdout={root_stdout}"
|
"stdout={root_stdout}"
|
||||||
);
|
);
|
||||||
for pruned in ["summary", "report", "advanced", "datapaths"] {
|
|
||||||
assert!(
|
assert!(
|
||||||
!root_stdout
|
!root_stdout
|
||||||
.lines()
|
.lines()
|
||||||
.any(|line| line.trim_start().starts_with(pruned)),
|
.any(|line| line.starts_with(" datapaths")),
|
||||||
"help still listed pruned command {pruned}: {root_stdout}"
|
"root help still listed datapaths: {root_stdout}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let advanced_help = run_cstat(&root, &["advanced", "--help"]);
|
||||||
|
assert_success(&advanced_help);
|
||||||
|
let advanced_stdout = String::from_utf8_lossy(&advanced_help.stdout);
|
||||||
|
assert!(
|
||||||
|
advanced_stdout.contains("flow-heatmap"),
|
||||||
|
"stdout={advanced_stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!advanced_stdout
|
||||||
|
.lines()
|
||||||
|
.any(|line| line.starts_with(" datapaths")),
|
||||||
|
"advanced help still listed datapaths: {advanced_stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
advanced_stdout.contains("random-walk") || advanced_stdout.contains("heatmap"),
|
||||||
|
"stdout={advanced_stdout}"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
fs::remove_dir_all(root).unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue