From 2b14234d6c051a90ae2d62e64b3293f8db626e25 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Thu, 23 Jul 2026 12:55:53 +0400 Subject: [PATCH] feat: complexity score --- PROJECT_GROUNDING.md | 392 +++++++++++++++++ README.md | 1 + cstat-agent-use.md | 3 + src/main.rs | 27 ++ src/scorecard.rs | 980 +++++++++++++++++++++++++++++++++++++++++ tests/scorecard_cli.rs | 414 +++++++++++++++++ 6 files changed, 1817 insertions(+) create mode 100644 PROJECT_GROUNDING.md create mode 100644 src/scorecard.rs create mode 100644 tests/scorecard_cli.rs diff --git a/PROJECT_GROUNDING.md b/PROJECT_GROUNDING.md new file mode 100644 index 0000000..5838317 --- /dev/null +++ b/PROJECT_GROUNDING.md @@ -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. diff --git a/README.md b/README.md index 58d6231..9b84cfc 100644 --- a/README.md +++ b/README.md @@ -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 ``` \ No newline at end of file diff --git a/cstat-agent-use.md b/cstat-agent-use.md index 8ec254e..46e7368 100644 --- a/cstat-agent-use.md +++ b/cstat-agent-use.md @@ -12,6 +12,7 @@ and tests preserve behavior. Start with the focused commands that answer the current cleanup question: ```sh +cstat scorecard --json --path cstat loc --json --path cstat symbols --json --path cstat deps --json --path @@ -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 diff --git a/src/main.rs b/src/main.rs index ecc6070..9343368 100644 --- a/src/main.rs +++ b/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, + }, /// 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); } diff --git a/src/scorecard.rs b/src/scorecard.rs new file mode 100644 index 0000000..b1d71d3 --- /dev/null +++ b/src/scorecard.rs @@ -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, + pub top_contributors: Vec, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub module: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, + pub cost: f64, + pub reasons: BTreeMap, + pub component_costs: BTreeMap, +} + +#[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, + module: Option, + function: Option, + cost: f64, + reasons: BTreeMap, + component_costs: BTreeMap, +} + +#[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, + 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!(" "); + } 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(|| "".to_string()), + }, + "file" | "abstraction_surface" => contributor + .file + .clone() + .unwrap_or_else(|| "".to_string()), + "module" => contributor + .module + .clone() + .unwrap_or_else(|| "".to_string()), + "stale_surface" => contributor + .function + .clone() + .unwrap_or_else(|| "".to_string()), + _ => "".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, + contributors: &mut Vec, +) -> 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, + project_path: &Path, + file_scopes: &HashMap, + scopes: &mut BTreeMap, + contributors: &mut Vec, +) -> 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, + scopes: &mut BTreeMap, + contributors: &mut Vec, +) -> 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, + scopes: &mut BTreeMap, + contributors: &mut Vec, +) -> 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, + contributors: &mut Vec, +) -> 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, +) -> Vec { + 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 { + 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 { + 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, +) -> BTreeMap { + 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, +) -> BTreeMap { + 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, scope: &str, cost: f64) { + scopes.entry(scope.to_string()).or_default().cost += cost; +} + +fn module_scopes( + files: &[PathBuf], + project_path: &Path, + file_scopes: &HashMap, +) -> HashMap { + 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 { + 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 +} diff --git a/tests/scorecard_cli.rs b/tests/scorecard_cli.rs new file mode 100644 index 0000000..f316a52 --- /dev/null +++ b/tests/scorecard_cli.rs @@ -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(); +}