feat: complexity score

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-23 12:55:53 +04:00
parent 3ce211e764
commit 2b14234d6c
6 changed files with 1817 additions and 0 deletions

392
PROJECT_GROUNDING.md Normal file
View 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.

View file

@ -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
```

View file

@ -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

View file

@ -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
View 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
View 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();
}