From 32d04ae1f7d95ade7d2ba8845654fb1866b235d8 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Wed, 29 Jul 2026 12:52:06 +0400 Subject: [PATCH] feat: add public api to complexity score --- COMPLEXITY_SCORE_SPEC.md | 528 +++++++++++++++++++++++++++++++++++++++ src/ast_parser.rs | 33 +++ src/scorecard.rs | 246 +++++++++++++++++- tests/scorecard_cli.rs | 67 ++++- 4 files changed, 871 insertions(+), 3 deletions(-) create mode 100644 COMPLEXITY_SCORE_SPEC.md diff --git a/COMPLEXITY_SCORE_SPEC.md b/COMPLEXITY_SCORE_SPEC.md new file mode 100644 index 0000000..1117f3b --- /dev/null +++ b/COMPLEXITY_SCORE_SPEC.md @@ -0,0 +1,528 @@ +# cstat code complexity score v0 + +## Status + +Implemented by the `scorecard` CLI command and `src/scorecard.rs`. + +This score is intentionally heuristic. It is the first deterministic target for +agents to optimize before the project has behavioral data from agent cleanup +loops. Incompatible formula or JSON changes must bump `score_version`. + +## Purpose + +`code_complexity_cost_v0` measures structural reasoning burden in a Rust +codebase. + +Lower cost means the code is structurally cleaner according to this metric. +The score is designed for agent optimization: an agent can compare before/after +runs and try to reduce the scalar by simplifying functions, reducing oversized +files, lowering coupling, shrinking excessive abstraction surface, and removing +stale code. + +The score does not prove correctness, safety, maintainability, public API +fitness, or behavior preservation. Those are harness responsibilities. + +## Non-goals + +The score must not include: + +- test pass/fail status; +- benchmark pass/fail status; +- coverage pass/fail status; +- reward shaping; +- task-specific acceptance criteria; +- model training logic; +- patch generation; +- semantic proof of dead-code safety; +- rustc-level name resolution or macro expansion requirements for v0. + +The harness may reject an attempted optimization if tests, benchmarks, coverage, +protected files, or behavior checks fail. That rejection is separate from the +complexity score. + +## Optimization contract + +The agent-facing objective is: + +```text +minimize code_complexity_cost_v0 +``` + +The environment-facing objective is: + +```text +minimize code_complexity_cost_v0 subject to harness guards passing +``` + +The score itself is pure structural cost. It does not contain verification +signals or guardrail penalties. + +## Scope + +The score covers all discovered Rust code: + +- production code; +- test code; +- benchmark code; +- any future discovered Rust scope such as examples. + +All scopes contribute to the default total with equal weight. Tests and benches +are code; if they are structurally complex, they increase the score. The harness +is responsible for preventing agents from deleting, weakening, or bypassing +those scopes. + +The output must still include a scope breakdown so users can understand where +cost comes from: + +```json +"scope_breakdown": { + "production": { "cost": 900.0 }, + "tests": { "cost": 120.0 }, + "benches": { "cost": 30.0 } +} +``` + +Scope labels are explanatory. They do not change scoring weight in v0. + +## Primary scalar + +```text +code_complexity_cost = + function_complexity_cost + + file_concentration_cost + + module_coupling_cost + + abstraction_surface_cost + + api_surface_cost + + stale_surface_cost +``` + +Also emit a normalized companion scalar: + +```text +code_complexity_cost_per_kloc = code_complexity_cost / max(1.0, code_lines / 1000.0) +``` + +Use `code_complexity_cost` for before/after comparisons inside the same repo. +Use `code_complexity_cost_per_kloc` only for rough cross-repo comparisons. + +## Component 1: function complexity + +Function complexity is the primary local reasoning-cost signal. + +For each discovered Rust function or method: + +```text +function_cost = + 2.0 * max(0, cyclomatic - 5) + + 3.0 * max(0, nesting_depth - 3) + + 0.2 * max(0, line_count - 50) + + 0.5 * max(0, body_stmt_count - 20) + + 1.0 * max(0, signature_score - 6) +``` + +Where: + +```text +cyclomatic = branch_points + 1 + +signature_score = + param_count + + return_type_complexity + + generic_param_count + + trait_bound_count + + where_predicate_count +``` + +Then: + +```text +function_complexity_cost = sum(function_cost) +``` + +Rationale: + +- Branching increases path reasoning. +- Nesting increases local context stack depth. +- Long functions increase scan burden, but line count has low weight to avoid + rewarding code golf. +- Body statement count adds a syntax-level size signal that is less sensitive to + formatting than physical lines. +- Signature complexity captures call-boundary and API reasoning cost. +- Thresholds avoid penalizing ordinary small functions. + +## Component 2: file concentration + +File concentration measures how much code and symbol surface is packed into one +file. + +For each discovered Rust file: + +```text +file_cost = + 0.04 * max(0, code_lines - 400) + + 0.25 * max(0, total_symbols - 35) + + 1.0 * max(0, function_count - 25) +``` + +Then: + +```text +file_concentration_cost = sum(file_cost) +``` + +Rationale: + +- Large files require more navigation and context loading. +- Symbol piles make a file harder to summarize. +- Function piles usually indicate several concepts sharing one module. +- The thresholds keep normal files free and penalize concentration rather than + raw existence of code. + +## Component 3: module coupling + +Module coupling measures source-module navigation burden and architectural +tangling. + +```text +module_coupling_cost = + sum_per_module( + 2.0 * max(0, out_degree - 5) + + 1.0 * max(0, in_degree - 10) + ) + + sum_bidirectional_pairs(20.0 + 5.0 * pair_strength) + + sum_low_cohesion_modules( + if function_count >= 4: + 2.0 * function_count * max(0.0, 0.55 - combined_cohesion) + ) +``` + +Rationale: + +- Fan-out means a module must know about many neighbors, so it is weighted more + heavily than fan-in. +- Fan-in can indicate useful central code, so it receives a higher threshold and + lower weight. +- Bidirectional pairs are strong architecture smells and receive a large fixed + penalty. +- Low cohesion matters only when a module has enough functions for cohesion to + be meaningful. + +For v0, use existing dependency, module-degree, coupling-pair, and cohesion +signals. If full graph-cycle detection is added later, it should be a new field +or a `score_version` bump. + +## Component 4: abstraction surface + +Abstraction surface measures named conceptual inventory independent of line +count. + +Per file or module, using symbol counts: + +```text +abstraction_surface_cost = + 0.5 * max(0, structs - 12) + + 0.5 * max(0, enums - 8) + + 1.0 * max(0, traits - 4) + + 0.5 * max(0, trait_impls - 8) + + 0.2 * max(0, consts + statics - 10) +``` + +Then sum across files/modules. + +Rationale: + +- Named concepts are real reasoning surface even when individual functions are + small. +- Traits are weighted higher because they introduce abstraction and dispatch + reasoning. +- This component should not punish ordinary data modeling; it only penalizes + excessive local surface. + +## Component 5: API surface + +API surface measures interface area that callers outside the local module may +depend on. + +```text +api_surface_cost = + sum_visible_symbols(api_visibility_weight * api_kind_weight) + + sum_visible_functions(api_visibility_weight * 0.5 * max(0, signature_score - 3)) +``` + +Where: + +```text +api_visibility_weight: + pub = 1.0 + pub(crate) = 0.4 + +api_kind_weight: + function/method = 1.0 + struct = 1.0 + enum = 1.0 + trait = 2.0 + const/static = 0.5 +``` + +Then: + +```text +api_surface_cost = sum(api symbol costs) +``` + +Rationale: + +- `pub` creates repository-external compatibility surface. +- `pub(crate)` creates crate-wide non-local reasoning surface, but is cheaper + because it remains internally changeable. +- Traits are weighted higher because they expose behavioral contracts and + dispatch/implementation reasoning. +- Visible functions receive a small signature surcharge so complex public call + boundaries cost more than simple public call boundaries. +- This component may encourage visibility reduction; public API compatibility + checks and human review decide whether a reduction is allowed. + +For v1, only direct `pub` and `pub(crate)` item visibility are counted. Private +items and narrower restricted visibilities such as `pub(super)` are not counted. + +## Component 6: stale surface + +Stale surface measures code that appears unused by current static evidence. + +```text +stale_surface_cost = 0.5 * static_dead_code_candidate_count +``` + +Rationale: + +- Unused code still imposes reading, search, and maintenance cost. +- The weight is low because static dead-code candidates can be false positives + around macros, public API usage, trait-object dispatch, build scripts, and + string-based dispatch. +- The score may encourage deletion; the harness and human review decide whether + deletion is allowed. + +## JSON output contract + +The score command should emit compact JSON by default when `--json` is passed: + +```json +{ + "cstat_version": "0.1.0", + "score_version": "code_complexity_cost_v1", + "target": ".", + "code_complexity_cost": 1234.5, + "code_complexity_cost_per_kloc": 104.7, + "component_costs": { + "function_complexity": { + "cost": 800.0, + "functions_scored": 120 + }, + "file_concentration": { + "cost": 120.0, + "files_scored": 14 + }, + "module_coupling": { + "cost": 180.0, + "modules_scored": 14 + }, + "abstraction_surface": { + "cost": 90.0, + "symbols_scored": 300 + }, + "api_surface": { + "cost": 20.0, + "public_symbols": 12, + "crate_symbols": 8 + }, + "stale_surface": { + "cost": 44.5, + "candidate_count": 89 + } + }, + "scope_breakdown": { + "production": { + "cost": 900.0, + "functions_scored": 100, + "files_scored": 10 + }, + "tests": { + "cost": 300.0, + "functions_scored": 20, + "files_scored": 4 + }, + "benches": { + "cost": 34.5, + "functions_scored": 2, + "files_scored": 1 + } + }, + "top_contributors": [ + { + "kind": "function", + "scope": "production", + "file": "src/main.rs", + "function": "main", + "cost": 115.4, + "reasons": { + "cyclomatic": 45, + "nesting_depth": 4, + "line_count": 212, + "body_stmt_count": 11, + "signature_score": 1 + }, + "component_costs": { + "branching": 80.0, + "nesting": 3.0, + "span": 32.4, + "signature": 0.0 + } + } + ], + "metadata": { + "rust_files": 25, + "code_lines": 11793, + "parse_error_files": 0 + } +} +``` + +Required stable fields: + +- `cstat_version` +- `score_version` +- `target` +- `code_complexity_cost` +- `code_complexity_cost_per_kloc` +- `component_costs` +- `scope_breakdown` +- `top_contributors` +- `metadata` + +Field additions are allowed within the same score version if they do not change +existing field meaning. Formula changes, field removals, or semantic changes +must bump `score_version`. + +## Human output contract + +Human output should be short and explanatory: + +```text +cstat code complexity score +score version: code_complexity_cost_v1 +lower is cleaner; harness guards behavior separately + +total cost: 1234.5 +cost / KLOC: 104.7 + +components: + function complexity: 800.0 + file concentration: 120.0 + module coupling: 180.0 + abstraction surface: 90.0 + api surface: 20.0 + stale surface: 44.5 + +top contributors: + 1. function src/main.rs::main 115.4 + 2. module src/flow 73.0 + 3. file src/coverage.rs 47.4 +``` + +Do not print every underlying probe. This command is a scorecard, not an `all` +command. + +## Implementation guide + +Recommended module: + +```text +src/scorecard.rs +``` + +Recommended CLI: + +```text +cstat scorecard --path [--json] [--top N] [-v] +``` + +Initial implementation should support project mode first. Selected-file mode can +be added later if it naturally falls out of the data model. + +Implementation steps: + +1. Parse/discover Rust files using existing project discovery. +2. Parse symbols with `ast_parser::parse_project`. +3. Build function rows using the same raw fields as `branching`, `signature`, + and `span`. +4. Count line and symbol concentration per file using existing `loc` and + `symbols` data where possible. +5. Compute dependency/coupling/cohesion data using existing `deps` logic. +6. Compute API surface from visible parsed symbols and function signatures. +7. Compute stale surface using existing `dead_code` analysis. +8. Classify each function/file into a scope label for reporting: + - `production` + - `tests` + - `benches` + - future labels as needed +9. Compute component costs. +10. Sum total cost. +11. Build top contributors from function, file, module, abstraction, API surface, + and stale contributors. +12. Render compact human output or stable JSON. + +Avoid shelling out to existing CLI commands from the implementation. Reuse the +same Rust collectors directly so the score is fast, deterministic, and testable. + +## Scope classification guide + +Scope classification is only for explanation and breakdown in v0. It does not +change scoring weight. + +Suggested rules: + +- `benches`: file under `benches/` or benchmark-recognized function where known; +- `tests`: file under `tests/`, function with `#[test]`, or code inside obvious + `#[cfg(test)]` modules where existing analysis can identify it; +- `production`: default for discovered source that is not classified above. + +If classification is ambiguous, classify as `production` and avoid hiding cost. +The score should remain deterministic. + +## Top contributors + +Emit enough contributor detail for an agent to choose a bounded cleanup target. + +Contributor kinds: + +- `function` +- `file` +- `module` +- `abstraction_surface` +- `stale_surface` + +Each contributor should include: + +- `kind` +- `scope` +- `file` or `module` +- `function` when applicable +- `cost` +- raw reason fields used to compute cost +- per-reason component costs when applicable + +Default top count: 20. + +## Known limitations + +- The weights are guessed, not learned. +- Physical line count can be affected by formatting, so it has low weight and is + paired with body statement count. +- Static dead-code detection can be wrong. +- Dependency extraction is source-level and can miss macro-generated edges. +- Symbol counts come from syntax parsing, not full semantic analysis. +- A lower score does not prove better architecture in every local case. +- The harness must prevent destructive or behavior-changing optimizations. + +These limitations are acceptable for v0 because the score is deterministic, +explainable, and calibrated enough to start collecting before/after cleanup data. diff --git a/src/ast_parser.rs b/src/ast_parser.rs index 1739d5e..32b0df3 100644 --- a/src/ast_parser.rs +++ b/src/ast_parser.rs @@ -5,6 +5,14 @@ use syn::{ WherePredicate, }; +/// Rust item visibility bucket used by scorecard API-surface accounting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiVisibility { + Private, + Crate, + Public, +} + /// Extracted information about a function (free function or method). #[derive(Debug, Clone)] #[allow(dead_code)] @@ -22,6 +30,7 @@ pub struct FunctionInfo { pub line_start: usize, pub line_end: usize, pub is_test_support: bool, + pub visibility: ApiVisibility, } /// Extracted information about a struct. @@ -33,6 +42,7 @@ pub struct StructInfo { pub field_count: usize, pub derive_count: usize, pub generic_param_count: usize, + pub visibility: ApiVisibility, pub line_start: usize, pub line_end: usize, } @@ -46,6 +56,7 @@ pub struct EnumInfo { pub variant_count: usize, pub derive_count: usize, pub generic_param_count: usize, + pub visibility: ApiVisibility, pub line_start: usize, pub line_end: usize, } @@ -58,6 +69,7 @@ pub struct TraitInfo { pub file: PathBuf, pub method_count: usize, pub generic_param_count: usize, + pub visibility: ApiVisibility, pub line_start: usize, pub line_end: usize, } @@ -68,6 +80,7 @@ pub struct TraitInfo { pub struct ConstInfo { pub name: String, pub file: PathBuf, + pub visibility: ApiVisibility, pub line_start: usize, pub line_end: usize, } @@ -78,6 +91,7 @@ pub struct ConstInfo { pub struct StaticInfo { pub name: String, pub file: PathBuf, + pub visibility: ApiVisibility, pub line_start: usize, pub line_end: usize, } @@ -220,6 +234,7 @@ impl SymbolExtractor { &self.file_path, &func.sig, Some(&func.block), + visibility_from_syn(&func.vis), is_test_support, ); self.functions.push(info); @@ -247,6 +262,7 @@ impl SymbolExtractor { field_count, derive_count, generic_param_count, + visibility: visibility_from_syn(&s.vis), line_start, line_end, }); @@ -260,6 +276,7 @@ impl SymbolExtractor { variant_count: e.variants.len(), derive_count, generic_param_count, + visibility: visibility_from_syn(&e.vis), line_start: e.enum_token.span.start().line, line_end: e.brace_token.span.close().end().line, }); @@ -276,6 +293,7 @@ impl SymbolExtractor { file: self.file_path.clone(), method_count, generic_param_count, + visibility: visibility_from_syn(&t.vis), line_start: t.trait_token.span.start().line, line_end: t.brace_token.span.close().end().line, }); @@ -284,6 +302,7 @@ impl SymbolExtractor { self.consts.push(ConstInfo { name: self.qualify_name(&c.ident.to_string()), file: self.file_path.clone(), + visibility: visibility_from_syn(&c.vis), line_start: c.const_token.span.start().line, line_end: c.semi_token.span.end().line, }); @@ -292,6 +311,7 @@ impl SymbolExtractor { self.statics.push(StaticInfo { name: self.qualify_name(&s.ident.to_string()), file: self.file_path.clone(), + visibility: visibility_from_syn(&s.vis), line_start: s.static_token.span.start().line, line_end: s.semi_token.span.end().line, }); @@ -317,6 +337,7 @@ impl SymbolExtractor { &self.file_path, &method.sig, Some(&method.block), + visibility_from_syn(&method.vis), self.is_test_support_context() || has_cfg_test_attr(&method.attrs) || has_cfg_test_attr(&imp.attrs), @@ -370,6 +391,7 @@ fn extract_function_info( file: &Path, sig: &syn::Signature, body: Option<&syn::Block>, + visibility: ApiVisibility, is_test_support: bool, ) -> FunctionInfo { let param_count = sig.inputs.len(); @@ -413,6 +435,7 @@ fn extract_function_info( trait_bound_count, where_predicate_count, return_type_complexity, + visibility, line_start, line_end, is_test_support, @@ -424,6 +447,16 @@ fn count_derives(attrs: &[syn::Attribute]) -> usize { attrs.iter().filter(|a| a.path().is_ident("derive")).count() } +fn visibility_from_syn(vis: &syn::Visibility) -> ApiVisibility { + match vis { + syn::Visibility::Public(_) => ApiVisibility::Public, + syn::Visibility::Restricted(restricted) if restricted.path.is_ident("crate") => { + ApiVisibility::Crate + } + _ => ApiVisibility::Private, + } +} + fn has_cfg_test_attr(attrs: &[syn::Attribute]) -> bool { attrs.iter().any(|attr| { attr.path() diff --git a/src/scorecard.rs b/src/scorecard.rs index b1d71d3..6c3dc1a 100644 --- a/src/scorecard.rs +++ b/src/scorecard.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use crate::{ast_parser, dead_code, deps, flow, loc, render}; -const SCORE_VERSION: &str = "code_complexity_cost_v0"; +const SCORE_VERSION: &str = "code_complexity_cost_v1"; const DEFAULT_TOP: usize = 20; #[derive(Debug, Clone, Serialize)] @@ -28,6 +28,7 @@ pub struct ComponentCosts { pub file_concentration: FileComponent, pub module_coupling: ModuleComponent, pub abstraction_surface: AbstractionComponent, + pub api_surface: ApiSurfaceComponent, pub stale_surface: StaleComponent, } @@ -55,6 +56,13 @@ pub struct AbstractionComponent { pub symbols_scored: usize, } +#[derive(Debug, Clone, Serialize)] +pub struct ApiSurfaceComponent { + pub cost: f64, + pub public_symbols: usize, + pub crate_symbols: usize, +} + #[derive(Debug, Clone, Serialize)] pub struct StaleComponent { pub cost: f64, @@ -78,6 +86,8 @@ pub struct TopContributor { pub module: Option, #[serde(skip_serializing_if = "Option::is_none")] pub function: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, pub cost: f64, pub reasons: BTreeMap, pub component_costs: BTreeMap, @@ -104,6 +114,7 @@ struct Contributor { file: Option, module: Option, function: Option, + symbol: Option, cost: f64, reasons: BTreeMap, component_costs: BTreeMap, @@ -120,6 +131,7 @@ struct FunctionScoreInput { line_count: usize, body_stmt_count: usize, signature_score: usize, + visibility: ast_parser::ApiVisibility, } pub fn render_scorecard( @@ -170,6 +182,14 @@ pub fn analyze_scorecard( &mut scope_totals, &mut contributors, ); + let api_surface = score_api_surface( + &symbols, + &function_inputs, + project_path, + &file_scopes, + &mut scope_totals, + &mut contributors, + ); let module_coupling = score_module_coupling( files, project_path, @@ -192,6 +212,7 @@ pub fn analyze_scorecard( .then_with(|| a.file.cmp(&b.file)) .then_with(|| a.module.cmp(&b.module)) .then_with(|| a.function.cmp(&b.function)) + .then_with(|| a.symbol.cmp(&b.symbol)) }); contributors.truncate(top); @@ -200,6 +221,7 @@ pub fn analyze_scorecard( + file_concentration.cost + module_coupling.cost + abstraction_surface.cost + + api_surface.cost + stale_surface.cost; let kloc = (code_lines as f64 / 1000.0).max(1.0); @@ -226,6 +248,11 @@ pub fn analyze_scorecard( cost: round1(abstraction_surface.cost), symbols_scored: abstraction_surface.symbols_scored, }, + api_surface: ApiSurfaceComponent { + cost: round1(api_surface.cost), + public_symbols: api_surface.public_symbols, + crate_symbols: api_surface.crate_symbols, + }, stale_surface: StaleComponent { cost: round1(stale_surface.cost), candidate_count: stale_surface.candidate_count, @@ -240,6 +267,7 @@ pub fn analyze_scorecard( file: contributor.file, module: contributor.module, function: contributor.function, + symbol: contributor.symbol, cost: round1(contributor.cost), reasons: contributor.reasons, component_costs: contributor @@ -291,6 +319,10 @@ fn render_scorecard_human(report: &ScorecardReport, verbose: bool) { " {:<24} {:>8.1}", "abstraction surface:", report.component_costs.abstraction_surface.cost ); + println!( + " {:<24} {:>8.1}", + "api surface:", report.component_costs.api_surface.cost + ); println!( " {:<24} {:>8.1}", "stale surface:", report.component_costs.stale_surface.cost @@ -326,6 +358,13 @@ fn contributor_label(contributor: &TopContributor) -> String { .file .clone() .unwrap_or_else(|| "".to_string()), + "api_surface" => match (&contributor.file, &contributor.symbol) { + (Some(file), Some(symbol)) => format!("{file}::{symbol}"), + _ => contributor + .symbol + .clone() + .unwrap_or_else(|| "".to_string()), + }, "module" => contributor .module .clone() @@ -362,6 +401,13 @@ struct AbstractionScoreSummary { symbols_scored: usize, } +#[derive(Default)] +struct ApiSurfaceScoreSummary { + cost: f64, + public_symbols: usize, + crate_symbols: usize, +} + #[derive(Default)] struct StaleScoreSummary { cost: f64, @@ -416,6 +462,7 @@ fn score_functions( file: Some(function.file.clone()), module: None, function: Some(function.function.clone()), + symbol: None, cost, reasons, component_costs, @@ -475,6 +522,7 @@ fn score_files( file: Some(relative_file(&file.path, project_path)), module: None, function: None, + symbol: None, cost, reasons, component_costs, @@ -541,6 +589,7 @@ fn score_abstraction_surface( file: Some(relative_file(&file.path, project_path)), module: None, function: None, + symbol: None, cost, reasons, component_costs, @@ -551,6 +600,196 @@ fn score_abstraction_surface( summary } +fn score_api_surface( + symbols: &ast_parser::ProjectSymbols, + functions: &[FunctionScoreInput], + project_path: &Path, + file_scopes: &HashMap, + scopes: &mut BTreeMap, + contributors: &mut Vec, +) -> ApiSurfaceScoreSummary { + let mut summary = ApiSurfaceScoreSummary::default(); + + for function in functions { + score_api_symbol( + ApiSymbolInput { + symbol_kind: "function", + file: &function.file, + symbol: &function.function, + scope: &function.scope, + visibility: function.visibility, + kind_weight: 1.0, + signature_score: Some(function.signature_score), + }, + &mut summary, + scopes, + contributors, + ); + } + + for file in &symbols.files { + let rel_file = relative_file(&file.path, project_path); + let scope = file_scopes + .get(&file.path) + .cloned() + .unwrap_or_else(|| file_scope(&file.path, project_path)); + + for item in &file.structs { + score_api_symbol( + ApiSymbolInput { + symbol_kind: "struct", + file: &rel_file, + symbol: &item.name, + scope: &scope, + visibility: item.visibility, + kind_weight: 1.0, + signature_score: None, + }, + &mut summary, + scopes, + contributors, + ); + } + for item in &file.enums { + score_api_symbol( + ApiSymbolInput { + symbol_kind: "enum", + file: &rel_file, + symbol: &item.name, + scope: &scope, + visibility: item.visibility, + kind_weight: 1.0, + signature_score: None, + }, + &mut summary, + scopes, + contributors, + ); + } + for item in &file.traits { + score_api_symbol( + ApiSymbolInput { + symbol_kind: "trait", + file: &rel_file, + symbol: &item.name, + scope: &scope, + visibility: item.visibility, + kind_weight: 2.0, + signature_score: None, + }, + &mut summary, + scopes, + contributors, + ); + } + for item in &file.consts { + score_api_symbol( + ApiSymbolInput { + symbol_kind: "const", + file: &rel_file, + symbol: &item.name, + scope: &scope, + visibility: item.visibility, + kind_weight: 0.5, + signature_score: None, + }, + &mut summary, + scopes, + contributors, + ); + } + for item in &file.statics { + score_api_symbol( + ApiSymbolInput { + symbol_kind: "static", + file: &rel_file, + symbol: &item.name, + scope: &scope, + visibility: item.visibility, + kind_weight: 0.5, + signature_score: None, + }, + &mut summary, + scopes, + contributors, + ); + } + } + + summary +} + +struct ApiSymbolInput<'a> { + symbol_kind: &'static str, + file: &'a str, + symbol: &'a str, + scope: &'a str, + visibility: ast_parser::ApiVisibility, + kind_weight: f64, + signature_score: Option, +} + +fn score_api_symbol( + input: ApiSymbolInput<'_>, + summary: &mut ApiSurfaceScoreSummary, + scopes: &mut BTreeMap, + contributors: &mut Vec, +) { + let Some((visibility, visibility_weight)) = api_visibility_weight(input.visibility) else { + return; + }; + + match input.visibility { + ast_parser::ApiVisibility::Public => summary.public_symbols += 1, + ast_parser::ApiVisibility::Crate => summary.crate_symbols += 1, + ast_parser::ApiVisibility::Private => {} + } + + let visibility_surface_cost = visibility_weight * input.kind_weight; + let signature_surface_cost = input + .signature_score + .map(|score| visibility_weight * 0.5 * excess(score, 3)) + .unwrap_or(0.0); + let cost = visibility_surface_cost + signature_surface_cost; + + summary.cost += cost; + add_scope_cost(scopes, input.scope, cost); + + let mut reasons = BTreeMap::new(); + reasons.insert("visibility".to_string(), json!(visibility)); + reasons.insert("symbol_kind".to_string(), json!(input.symbol_kind)); + reasons.insert("symbol".to_string(), json!(input.symbol)); + if let Some(signature_score) = input.signature_score { + reasons.insert("signature_score".to_string(), json!(signature_score)); + } + + let mut component_costs = BTreeMap::new(); + component_costs.insert("visibility_surface".to_string(), visibility_surface_cost); + if signature_surface_cost > 0.0 { + component_costs.insert("signature_surface".to_string(), signature_surface_cost); + } + + contributors.push(Contributor { + kind: "api_surface".to_string(), + scope: input.scope.to_string(), + file: Some(input.file.to_string()), + module: None, + function: None, + symbol: Some(input.symbol.to_string()), + cost, + reasons, + component_costs, + }); +} + +fn api_visibility_weight(visibility: ast_parser::ApiVisibility) -> Option<(&'static str, f64)> { + match visibility { + ast_parser::ApiVisibility::Public => Some(("pub", 1.0)), + ast_parser::ApiVisibility::Crate => Some(("pub(crate)", 0.4)), + ast_parser::ApiVisibility::Private => None, + } +} + fn score_module_coupling( files: &[PathBuf], project_path: &Path, @@ -595,6 +834,7 @@ fn score_module_coupling( file: None, module: Some(module.clone()), function: None, + symbol: None, cost, reasons, component_costs, @@ -624,6 +864,7 @@ fn score_module_coupling( file: None, module: Some(format!("{module_a} <-> {module_b}")), function: None, + symbol: None, cost, reasons, component_costs, @@ -657,6 +898,7 @@ fn score_module_coupling( file: None, module: Some(row.module.clone()), function: None, + symbol: None, cost, reasons, component_costs, @@ -709,6 +951,7 @@ fn score_stale_surface( .map(|file| (*file).to_string()), module: None, function: Some(candidate.function.clone()), + symbol: None, cost, reasons, component_costs, @@ -749,6 +992,7 @@ fn collect_function_inputs( line_count, body_stmt_count: function.body_stmt_count, signature_score, + visibility: function.visibility, }); } } diff --git a/tests/scorecard_cli.rs b/tests/scorecard_cli.rs index f316a52..817dd18 100644 --- a/tests/scorecard_cli.rs +++ b/tests/scorecard_cli.rs @@ -95,7 +95,7 @@ fn branchy(input: usize) -> usize { let value = score(&root); assert_eq!(value["cstat_version"], env!("CARGO_PKG_VERSION")); - assert_eq!(value["score_version"], "code_complexity_cost_v0"); + assert_eq!(value["score_version"], "code_complexity_cost_v1"); for field in [ "target", "code_complexity_cost", @@ -111,6 +111,10 @@ fn branchy(input: usize) -> usize { value["component_costs"].get("verification_gap").is_none(), "scorecard must not mix harness verification into code complexity: {value}", ); + assert!( + value["component_costs"].get("api_surface").is_some(), + "scorecard should expose API surface component: {value}", + ); for scope in ["production", "tests", "benches"] { assert!( value["scope_breakdown"].get(scope).is_some(), @@ -134,9 +138,10 @@ fn branchy(input: usize) -> usize { let stdout = String::from_utf8_lossy(&human.stdout); for expected in [ "Code complexity score", - "score version: code_complexity_cost_v0", + "score version: code_complexity_cost_v1", "lower is cleaner; harness guards behavior separately", "components:", + "api surface:", "top contributors:", ] { assert!(stdout.contains(expected), "missing {expected}: {stdout}"); @@ -374,6 +379,64 @@ pub fn touch() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn widening_visibility_increases_api_surface_cost() { + let root = temp_project("api-surface"); + write_main( + &root, + r#"fn main() { + println!("{}", exposed(1)); +} + +fn exposed(input: usize) -> usize { + input + 1 +} +"#, + ); + let private_score = score(&root); + + write_main( + &root, + r#"fn main() { + println!("{}", exposed(1)); +} + +pub(crate) fn exposed(input: usize) -> usize { + input + 1 +} +"#, + ); + let crate_score = score(&root); + + write_main( + &root, + r#"fn main() { + println!("{}", exposed(1)); +} + +pub fn exposed(input: usize) -> usize { + input + 1 +} +"#, + ); + let public_score = score(&root); + + assert!( + component(&crate_score, "api_surface") > component(&private_score, "api_surface"), + "pub(crate) visibility should add API surface cost: private={private_score} crate={crate_score}", + ); + assert!( + component(&public_score, "api_surface") > component(&crate_score, "api_surface"), + "pub visibility should cost more than pub(crate): crate={crate_score} public={public_score}", + ); + assert!( + total(&public_score) > total(&private_score), + "widening visibility should increase total score: private={private_score} public={public_score}", + ); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn adding_static_dead_code_candidate_increases_stale_surface() { let root = temp_project("stale");