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,
|
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).
|
/// Extracted information about a function (free function or method).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|
@ -22,6 +30,7 @@ pub struct FunctionInfo {
|
||||||
pub line_start: usize,
|
pub line_start: usize,
|
||||||
pub line_end: usize,
|
pub line_end: usize,
|
||||||
pub is_test_support: bool,
|
pub is_test_support: bool,
|
||||||
|
pub visibility: ApiVisibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracted information about a struct.
|
/// Extracted information about a struct.
|
||||||
|
|
@ -33,6 +42,7 @@ pub struct StructInfo {
|
||||||
pub field_count: usize,
|
pub field_count: usize,
|
||||||
pub derive_count: usize,
|
pub derive_count: usize,
|
||||||
pub generic_param_count: usize,
|
pub generic_param_count: usize,
|
||||||
|
pub visibility: ApiVisibility,
|
||||||
pub line_start: usize,
|
pub line_start: usize,
|
||||||
pub line_end: usize,
|
pub line_end: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +56,7 @@ pub struct EnumInfo {
|
||||||
pub variant_count: usize,
|
pub variant_count: usize,
|
||||||
pub derive_count: usize,
|
pub derive_count: usize,
|
||||||
pub generic_param_count: usize,
|
pub generic_param_count: usize,
|
||||||
|
pub visibility: ApiVisibility,
|
||||||
pub line_start: usize,
|
pub line_start: usize,
|
||||||
pub line_end: usize,
|
pub line_end: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +69,7 @@ pub struct TraitInfo {
|
||||||
pub file: PathBuf,
|
pub file: PathBuf,
|
||||||
pub method_count: usize,
|
pub method_count: usize,
|
||||||
pub generic_param_count: usize,
|
pub generic_param_count: usize,
|
||||||
|
pub visibility: ApiVisibility,
|
||||||
pub line_start: usize,
|
pub line_start: usize,
|
||||||
pub line_end: usize,
|
pub line_end: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +80,7 @@ pub struct TraitInfo {
|
||||||
pub struct ConstInfo {
|
pub struct ConstInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub file: PathBuf,
|
pub file: PathBuf,
|
||||||
|
pub visibility: ApiVisibility,
|
||||||
pub line_start: usize,
|
pub line_start: usize,
|
||||||
pub line_end: usize,
|
pub line_end: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +91,7 @@ pub struct ConstInfo {
|
||||||
pub struct StaticInfo {
|
pub struct StaticInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub file: PathBuf,
|
pub file: PathBuf,
|
||||||
|
pub visibility: ApiVisibility,
|
||||||
pub line_start: usize,
|
pub line_start: usize,
|
||||||
pub line_end: usize,
|
pub line_end: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -220,6 +234,7 @@ impl SymbolExtractor {
|
||||||
&self.file_path,
|
&self.file_path,
|
||||||
&func.sig,
|
&func.sig,
|
||||||
Some(&func.block),
|
Some(&func.block),
|
||||||
|
visibility_from_syn(&func.vis),
|
||||||
is_test_support,
|
is_test_support,
|
||||||
);
|
);
|
||||||
self.functions.push(info);
|
self.functions.push(info);
|
||||||
|
|
@ -247,6 +262,7 @@ impl SymbolExtractor {
|
||||||
field_count,
|
field_count,
|
||||||
derive_count,
|
derive_count,
|
||||||
generic_param_count,
|
generic_param_count,
|
||||||
|
visibility: visibility_from_syn(&s.vis),
|
||||||
line_start,
|
line_start,
|
||||||
line_end,
|
line_end,
|
||||||
});
|
});
|
||||||
|
|
@ -260,6 +276,7 @@ impl SymbolExtractor {
|
||||||
variant_count: e.variants.len(),
|
variant_count: e.variants.len(),
|
||||||
derive_count,
|
derive_count,
|
||||||
generic_param_count,
|
generic_param_count,
|
||||||
|
visibility: visibility_from_syn(&e.vis),
|
||||||
line_start: e.enum_token.span.start().line,
|
line_start: e.enum_token.span.start().line,
|
||||||
line_end: e.brace_token.span.close().end().line,
|
line_end: e.brace_token.span.close().end().line,
|
||||||
});
|
});
|
||||||
|
|
@ -276,6 +293,7 @@ impl SymbolExtractor {
|
||||||
file: self.file_path.clone(),
|
file: self.file_path.clone(),
|
||||||
method_count,
|
method_count,
|
||||||
generic_param_count,
|
generic_param_count,
|
||||||
|
visibility: visibility_from_syn(&t.vis),
|
||||||
line_start: t.trait_token.span.start().line,
|
line_start: t.trait_token.span.start().line,
|
||||||
line_end: t.brace_token.span.close().end().line,
|
line_end: t.brace_token.span.close().end().line,
|
||||||
});
|
});
|
||||||
|
|
@ -284,6 +302,7 @@ impl SymbolExtractor {
|
||||||
self.consts.push(ConstInfo {
|
self.consts.push(ConstInfo {
|
||||||
name: self.qualify_name(&c.ident.to_string()),
|
name: self.qualify_name(&c.ident.to_string()),
|
||||||
file: self.file_path.clone(),
|
file: self.file_path.clone(),
|
||||||
|
visibility: visibility_from_syn(&c.vis),
|
||||||
line_start: c.const_token.span.start().line,
|
line_start: c.const_token.span.start().line,
|
||||||
line_end: c.semi_token.span.end().line,
|
line_end: c.semi_token.span.end().line,
|
||||||
});
|
});
|
||||||
|
|
@ -292,6 +311,7 @@ impl SymbolExtractor {
|
||||||
self.statics.push(StaticInfo {
|
self.statics.push(StaticInfo {
|
||||||
name: self.qualify_name(&s.ident.to_string()),
|
name: self.qualify_name(&s.ident.to_string()),
|
||||||
file: self.file_path.clone(),
|
file: self.file_path.clone(),
|
||||||
|
visibility: visibility_from_syn(&s.vis),
|
||||||
line_start: s.static_token.span.start().line,
|
line_start: s.static_token.span.start().line,
|
||||||
line_end: s.semi_token.span.end().line,
|
line_end: s.semi_token.span.end().line,
|
||||||
});
|
});
|
||||||
|
|
@ -317,6 +337,7 @@ impl SymbolExtractor {
|
||||||
&self.file_path,
|
&self.file_path,
|
||||||
&method.sig,
|
&method.sig,
|
||||||
Some(&method.block),
|
Some(&method.block),
|
||||||
|
visibility_from_syn(&method.vis),
|
||||||
self.is_test_support_context()
|
self.is_test_support_context()
|
||||||
|| has_cfg_test_attr(&method.attrs)
|
|| has_cfg_test_attr(&method.attrs)
|
||||||
|| has_cfg_test_attr(&imp.attrs),
|
|| has_cfg_test_attr(&imp.attrs),
|
||||||
|
|
@ -370,6 +391,7 @@ fn extract_function_info(
|
||||||
file: &Path,
|
file: &Path,
|
||||||
sig: &syn::Signature,
|
sig: &syn::Signature,
|
||||||
body: Option<&syn::Block>,
|
body: Option<&syn::Block>,
|
||||||
|
visibility: ApiVisibility,
|
||||||
is_test_support: bool,
|
is_test_support: bool,
|
||||||
) -> FunctionInfo {
|
) -> FunctionInfo {
|
||||||
let param_count = sig.inputs.len();
|
let param_count = sig.inputs.len();
|
||||||
|
|
@ -413,6 +435,7 @@ fn extract_function_info(
|
||||||
trait_bound_count,
|
trait_bound_count,
|
||||||
where_predicate_count,
|
where_predicate_count,
|
||||||
return_type_complexity,
|
return_type_complexity,
|
||||||
|
visibility,
|
||||||
line_start,
|
line_start,
|
||||||
line_end,
|
line_end,
|
||||||
is_test_support,
|
is_test_support,
|
||||||
|
|
@ -424,6 +447,16 @@ fn count_derives(attrs: &[syn::Attribute]) -> usize {
|
||||||
attrs.iter().filter(|a| a.path().is_ident("derive")).count()
|
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 {
|
fn has_cfg_test_attr(attrs: &[syn::Attribute]) -> bool {
|
||||||
attrs.iter().any(|attr| {
|
attrs.iter().any(|attr| {
|
||||||
attr.path()
|
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};
|
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;
|
const DEFAULT_TOP: usize = 20;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|
@ -28,6 +28,7 @@ pub struct ComponentCosts {
|
||||||
pub file_concentration: FileComponent,
|
pub file_concentration: FileComponent,
|
||||||
pub module_coupling: ModuleComponent,
|
pub module_coupling: ModuleComponent,
|
||||||
pub abstraction_surface: AbstractionComponent,
|
pub abstraction_surface: AbstractionComponent,
|
||||||
|
pub api_surface: ApiSurfaceComponent,
|
||||||
pub stale_surface: StaleComponent,
|
pub stale_surface: StaleComponent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +56,13 @@ pub struct AbstractionComponent {
|
||||||
pub symbols_scored: usize,
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct StaleComponent {
|
pub struct StaleComponent {
|
||||||
pub cost: f64,
|
pub cost: f64,
|
||||||
|
|
@ -78,6 +86,8 @@ pub struct TopContributor {
|
||||||
pub module: Option<String>,
|
pub module: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub function: Option<String>,
|
pub function: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub symbol: Option<String>,
|
||||||
pub cost: f64,
|
pub cost: f64,
|
||||||
pub reasons: BTreeMap<String, serde_json::Value>,
|
pub reasons: BTreeMap<String, serde_json::Value>,
|
||||||
pub component_costs: BTreeMap<String, f64>,
|
pub component_costs: BTreeMap<String, f64>,
|
||||||
|
|
@ -104,6 +114,7 @@ struct Contributor {
|
||||||
file: Option<String>,
|
file: Option<String>,
|
||||||
module: Option<String>,
|
module: Option<String>,
|
||||||
function: Option<String>,
|
function: Option<String>,
|
||||||
|
symbol: Option<String>,
|
||||||
cost: f64,
|
cost: f64,
|
||||||
reasons: BTreeMap<String, serde_json::Value>,
|
reasons: BTreeMap<String, serde_json::Value>,
|
||||||
component_costs: BTreeMap<String, f64>,
|
component_costs: BTreeMap<String, f64>,
|
||||||
|
|
@ -120,6 +131,7 @@ struct FunctionScoreInput {
|
||||||
line_count: usize,
|
line_count: usize,
|
||||||
body_stmt_count: usize,
|
body_stmt_count: usize,
|
||||||
signature_score: usize,
|
signature_score: usize,
|
||||||
|
visibility: ast_parser::ApiVisibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_scorecard(
|
pub fn render_scorecard(
|
||||||
|
|
@ -170,6 +182,14 @@ pub fn analyze_scorecard(
|
||||||
&mut scope_totals,
|
&mut scope_totals,
|
||||||
&mut contributors,
|
&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(
|
let module_coupling = score_module_coupling(
|
||||||
files,
|
files,
|
||||||
project_path,
|
project_path,
|
||||||
|
|
@ -192,6 +212,7 @@ pub fn analyze_scorecard(
|
||||||
.then_with(|| a.file.cmp(&b.file))
|
.then_with(|| a.file.cmp(&b.file))
|
||||||
.then_with(|| a.module.cmp(&b.module))
|
.then_with(|| a.module.cmp(&b.module))
|
||||||
.then_with(|| a.function.cmp(&b.function))
|
.then_with(|| a.function.cmp(&b.function))
|
||||||
|
.then_with(|| a.symbol.cmp(&b.symbol))
|
||||||
});
|
});
|
||||||
contributors.truncate(top);
|
contributors.truncate(top);
|
||||||
|
|
||||||
|
|
@ -200,6 +221,7 @@ pub fn analyze_scorecard(
|
||||||
+ file_concentration.cost
|
+ file_concentration.cost
|
||||||
+ module_coupling.cost
|
+ module_coupling.cost
|
||||||
+ abstraction_surface.cost
|
+ abstraction_surface.cost
|
||||||
|
+ api_surface.cost
|
||||||
+ stale_surface.cost;
|
+ stale_surface.cost;
|
||||||
let kloc = (code_lines as f64 / 1000.0).max(1.0);
|
let kloc = (code_lines as f64 / 1000.0).max(1.0);
|
||||||
|
|
||||||
|
|
@ -226,6 +248,11 @@ pub fn analyze_scorecard(
|
||||||
cost: round1(abstraction_surface.cost),
|
cost: round1(abstraction_surface.cost),
|
||||||
symbols_scored: abstraction_surface.symbols_scored,
|
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 {
|
stale_surface: StaleComponent {
|
||||||
cost: round1(stale_surface.cost),
|
cost: round1(stale_surface.cost),
|
||||||
candidate_count: stale_surface.candidate_count,
|
candidate_count: stale_surface.candidate_count,
|
||||||
|
|
@ -240,6 +267,7 @@ pub fn analyze_scorecard(
|
||||||
file: contributor.file,
|
file: contributor.file,
|
||||||
module: contributor.module,
|
module: contributor.module,
|
||||||
function: contributor.function,
|
function: contributor.function,
|
||||||
|
symbol: contributor.symbol,
|
||||||
cost: round1(contributor.cost),
|
cost: round1(contributor.cost),
|
||||||
reasons: contributor.reasons,
|
reasons: contributor.reasons,
|
||||||
component_costs: contributor
|
component_costs: contributor
|
||||||
|
|
@ -291,6 +319,10 @@ fn render_scorecard_human(report: &ScorecardReport, verbose: bool) {
|
||||||
" {:<24} {:>8.1}",
|
" {:<24} {:>8.1}",
|
||||||
"abstraction surface:", report.component_costs.abstraction_surface.cost
|
"abstraction surface:", report.component_costs.abstraction_surface.cost
|
||||||
);
|
);
|
||||||
|
println!(
|
||||||
|
" {:<24} {:>8.1}",
|
||||||
|
"api surface:", report.component_costs.api_surface.cost
|
||||||
|
);
|
||||||
println!(
|
println!(
|
||||||
" {:<24} {:>8.1}",
|
" {:<24} {:>8.1}",
|
||||||
"stale surface:", report.component_costs.stale_surface.cost
|
"stale surface:", report.component_costs.stale_surface.cost
|
||||||
|
|
@ -326,6 +358,13 @@ fn contributor_label(contributor: &TopContributor) -> String {
|
||||||
.file
|
.file
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "<unknown>".to_string()),
|
.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" => contributor
|
||||||
.module
|
.module
|
||||||
.clone()
|
.clone()
|
||||||
|
|
@ -362,6 +401,13 @@ struct AbstractionScoreSummary {
|
||||||
symbols_scored: usize,
|
symbols_scored: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ApiSurfaceScoreSummary {
|
||||||
|
cost: f64,
|
||||||
|
public_symbols: usize,
|
||||||
|
crate_symbols: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct StaleScoreSummary {
|
struct StaleScoreSummary {
|
||||||
cost: f64,
|
cost: f64,
|
||||||
|
|
@ -416,6 +462,7 @@ fn score_functions(
|
||||||
file: Some(function.file.clone()),
|
file: Some(function.file.clone()),
|
||||||
module: None,
|
module: None,
|
||||||
function: Some(function.function.clone()),
|
function: Some(function.function.clone()),
|
||||||
|
symbol: None,
|
||||||
cost,
|
cost,
|
||||||
reasons,
|
reasons,
|
||||||
component_costs,
|
component_costs,
|
||||||
|
|
@ -475,6 +522,7 @@ fn score_files(
|
||||||
file: Some(relative_file(&file.path, project_path)),
|
file: Some(relative_file(&file.path, project_path)),
|
||||||
module: None,
|
module: None,
|
||||||
function: None,
|
function: None,
|
||||||
|
symbol: None,
|
||||||
cost,
|
cost,
|
||||||
reasons,
|
reasons,
|
||||||
component_costs,
|
component_costs,
|
||||||
|
|
@ -541,6 +589,7 @@ fn score_abstraction_surface(
|
||||||
file: Some(relative_file(&file.path, project_path)),
|
file: Some(relative_file(&file.path, project_path)),
|
||||||
module: None,
|
module: None,
|
||||||
function: None,
|
function: None,
|
||||||
|
symbol: None,
|
||||||
cost,
|
cost,
|
||||||
reasons,
|
reasons,
|
||||||
component_costs,
|
component_costs,
|
||||||
|
|
@ -551,6 +600,196 @@ fn score_abstraction_surface(
|
||||||
summary
|
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(
|
fn score_module_coupling(
|
||||||
files: &[PathBuf],
|
files: &[PathBuf],
|
||||||
project_path: &Path,
|
project_path: &Path,
|
||||||
|
|
@ -595,6 +834,7 @@ fn score_module_coupling(
|
||||||
file: None,
|
file: None,
|
||||||
module: Some(module.clone()),
|
module: Some(module.clone()),
|
||||||
function: None,
|
function: None,
|
||||||
|
symbol: None,
|
||||||
cost,
|
cost,
|
||||||
reasons,
|
reasons,
|
||||||
component_costs,
|
component_costs,
|
||||||
|
|
@ -624,6 +864,7 @@ fn score_module_coupling(
|
||||||
file: None,
|
file: None,
|
||||||
module: Some(format!("{module_a} <-> {module_b}")),
|
module: Some(format!("{module_a} <-> {module_b}")),
|
||||||
function: None,
|
function: None,
|
||||||
|
symbol: None,
|
||||||
cost,
|
cost,
|
||||||
reasons,
|
reasons,
|
||||||
component_costs,
|
component_costs,
|
||||||
|
|
@ -657,6 +898,7 @@ fn score_module_coupling(
|
||||||
file: None,
|
file: None,
|
||||||
module: Some(row.module.clone()),
|
module: Some(row.module.clone()),
|
||||||
function: None,
|
function: None,
|
||||||
|
symbol: None,
|
||||||
cost,
|
cost,
|
||||||
reasons,
|
reasons,
|
||||||
component_costs,
|
component_costs,
|
||||||
|
|
@ -709,6 +951,7 @@ fn score_stale_surface(
|
||||||
.map(|file| (*file).to_string()),
|
.map(|file| (*file).to_string()),
|
||||||
module: None,
|
module: None,
|
||||||
function: Some(candidate.function.clone()),
|
function: Some(candidate.function.clone()),
|
||||||
|
symbol: None,
|
||||||
cost,
|
cost,
|
||||||
reasons,
|
reasons,
|
||||||
component_costs,
|
component_costs,
|
||||||
|
|
@ -749,6 +992,7 @@ fn collect_function_inputs(
|
||||||
line_count,
|
line_count,
|
||||||
body_stmt_count: function.body_stmt_count,
|
body_stmt_count: function.body_stmt_count,
|
||||||
signature_score,
|
signature_score,
|
||||||
|
visibility: function.visibility,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ fn branchy(input: usize) -> usize {
|
||||||
|
|
||||||
let value = score(&root);
|
let value = score(&root);
|
||||||
assert_eq!(value["cstat_version"], env!("CARGO_PKG_VERSION"));
|
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 [
|
for field in [
|
||||||
"target",
|
"target",
|
||||||
"code_complexity_cost",
|
"code_complexity_cost",
|
||||||
|
|
@ -111,6 +111,10 @@ fn branchy(input: usize) -> usize {
|
||||||
value["component_costs"].get("verification_gap").is_none(),
|
value["component_costs"].get("verification_gap").is_none(),
|
||||||
"scorecard must not mix harness verification into code complexity: {value}",
|
"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"] {
|
for scope in ["production", "tests", "benches"] {
|
||||||
assert!(
|
assert!(
|
||||||
value["scope_breakdown"].get(scope).is_some(),
|
value["scope_breakdown"].get(scope).is_some(),
|
||||||
|
|
@ -134,9 +138,10 @@ fn branchy(input: usize) -> usize {
|
||||||
let stdout = String::from_utf8_lossy(&human.stdout);
|
let stdout = String::from_utf8_lossy(&human.stdout);
|
||||||
for expected in [
|
for expected in [
|
||||||
"Code complexity score",
|
"Code complexity score",
|
||||||
"score version: code_complexity_cost_v0",
|
"score version: code_complexity_cost_v1",
|
||||||
"lower is cleaner; harness guards behavior separately",
|
"lower is cleaner; harness guards behavior separately",
|
||||||
"components:",
|
"components:",
|
||||||
|
"api surface:",
|
||||||
"top contributors:",
|
"top contributors:",
|
||||||
] {
|
] {
|
||||||
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
|
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
|
||||||
|
|
@ -374,6 +379,64 @@ pub fn touch() {
|
||||||
fs::remove_dir_all(root).unwrap();
|
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]
|
#[test]
|
||||||
fn adding_static_dead_code_candidate_increases_stale_surface() {
|
fn adding_static_dead_code_candidate_increases_stale_surface() {
|
||||||
let root = temp_project("stale");
|
let root = temp_project("stale");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue