feat: add public api to complexity score
This commit is contained in:
parent
2b14234d6c
commit
32d04ae1f7
4 changed files with 871 additions and 3 deletions
528
COMPLEXITY_SCORE_SPEC.md
Normal file
528
COMPLEXITY_SCORE_SPEC.md
Normal 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.
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
246
src/scorecard.rs
246
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<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue