feat: add public api to complexity score

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-29 12:52:06 +04:00
parent 2b14234d6c
commit 32d04ae1f7
4 changed files with 871 additions and 3 deletions

528
COMPLEXITY_SCORE_SPEC.md Normal file
View file

@ -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 <project-or-file> [--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.

View file

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

View file

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
pub cost: f64,
pub reasons: BTreeMap<String, serde_json::Value>,
pub component_costs: BTreeMap<String, f64>,
@ -104,6 +114,7 @@ struct Contributor {
file: Option<String>,
module: Option<String>,
function: Option<String>,
symbol: Option<String>,
cost: f64,
reasons: BTreeMap<String, serde_json::Value>,
component_costs: BTreeMap<String, f64>,
@ -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(|| "<unknown>".to_string()),
"api_surface" => match (&contributor.file, &contributor.symbol) {
(Some(file), Some(symbol)) => format!("{file}::{symbol}"),
_ => contributor
.symbol
.clone()
.unwrap_or_else(|| "<unknown>".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<PathBuf, String>,
scopes: &mut BTreeMap<String, ScopeAccumulator>,
contributors: &mut Vec<Contributor>,
) -> 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<usize>,
}
fn score_api_symbol(
input: ApiSymbolInput<'_>,
summary: &mut ApiSurfaceScoreSummary,
scopes: &mut BTreeMap<String, ScopeAccumulator>,
contributors: &mut Vec<Contributor>,
) {
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,
});
}
}

View file

@ -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");