feat: complexity score
This commit is contained in:
parent
3ce211e764
commit
2b14234d6c
6 changed files with 1817 additions and 0 deletions
392
PROJECT_GROUNDING.md
Normal file
392
PROJECT_GROUNDING.md
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
# 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,6 +4,7 @@ Terminal CLI tool to examine a codebase through a statistical lens
|
|||
## helpful commands
|
||||
```bash
|
||||
cstat --help # cli options
|
||||
cstat scorecard --path . --json # machine-readable code complexity cost
|
||||
cstat loc --path . -v # size-shape analysis plus explanations
|
||||
cstat test-reachability --path . --json # structured static test/bench reachability
|
||||
```
|
||||
|
|
@ -12,6 +12,7 @@ and tests preserve behavior.
|
|||
Start with the focused commands that answer the current cleanup question:
|
||||
|
||||
```sh
|
||||
cstat scorecard --json --path <project_root>
|
||||
cstat loc --json --path <project_root>
|
||||
cstat symbols --json --path <project_root>
|
||||
cstat deps --json --path <project_root>
|
||||
|
|
@ -41,6 +42,8 @@ Each accepts `--json` for structured output.
|
|||
- `cstat branching --json --path .` — per-function decision/path complexity.
|
||||
- `cstat signature --json --path .` — per-function API boundary complexity.
|
||||
- `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.
|
||||
|
||||
|
||||
## Cleanup workflow
|
||||
|
|
|
|||
27
src/main.rs
27
src/main.rs
|
|
@ -11,6 +11,7 @@ mod file_metrics;
|
|||
mod flow;
|
||||
mod loc;
|
||||
mod render;
|
||||
mod scorecard;
|
||||
mod symbols;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
|
@ -84,6 +85,12 @@ Use --explain to print this usage and JSON field contract without running analys
|
|||
Signature,
|
||||
/// Function implementation span rankings
|
||||
Span,
|
||||
/// Deterministic structural code complexity scorecard
|
||||
Scorecard {
|
||||
/// Show only the top N contributors
|
||||
#[arg(long)]
|
||||
top: Option<usize>,
|
||||
},
|
||||
/// Module dependency connectome
|
||||
Deps {
|
||||
/// Show only the top N most connected modules
|
||||
|
|
@ -176,6 +183,16 @@ fn main() {
|
|||
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 { .. } => {
|
||||
if json {
|
||||
deps::render_deps_file_json(project_rs_files, project_path, file);
|
||||
|
|
@ -270,6 +287,16 @@ fn main() {
|
|||
Commands::Span => {
|
||||
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 => {
|
||||
cluster::render_cluster(&rs_files, &project_path, json, verbose);
|
||||
}
|
||||
|
|
|
|||
980
src/scorecard.rs
Normal file
980
src/scorecard.rs
Normal file
|
|
@ -0,0 +1,980 @@
|
|||
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
|
||||
}
|
||||
414
tests/scorecard_cli.rs
Normal file
414
tests/scorecard_cli.rs
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
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();
|
||||
}
|
||||
Loading…
Reference in a new issue