stud-finder 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 187e021ca929ec91327d22576d8e834b085dfcddcf68915c14ada124b1b99d5d
4
- data.tar.gz: 840844ff17652ab19c769a52f8bc5a3162de63bb4d47827664f4e9ca58433520
3
+ metadata.gz: 8c203b1ef550bd6c05b1f07211cbad55d55520dcbf081ec59f24ab955c3a34d8
4
+ data.tar.gz: 10c285f7512708afda1af1a3c8f0b629eccdddc45aee9ac1cd2869c44af86f64
5
5
  SHA512:
6
- metadata.gz: 43b47e35c51c450555ef66ae9f879979242fe8e5cb24119de98ea1b13f12b3befefe8d5f28a4443b35ead8691138eff6214fd6a36a5a5a851f0c4f64a3affae0
7
- data.tar.gz: '049bd829d40c7f63417c251875670df9f6f1544b53d71d484adb5d0c129b64fdeb6074a6b4fe2fcc79c3cff3972bdd5dfa44827a36ac37d574dd2684f2c66fc9'
6
+ metadata.gz: 2d68892673ccaed71bde8f223ae5d1098033aeee65ca89bb7b21c301edf7a3eb7a7745cd5e70df24bbdb779a70d6f44ddbe4ccab324ff4731fba47f8c3a01cef
7
+ data.tar.gz: 704e0964915bbe5dce1628d082e5eccb6e1376bdd9919ace07251000892432373f94e61d1f2cebb91442feda1cef50358aeda31c2e30ed95e5414529ce5479ce
data/CHANGELOG.md CHANGED
@@ -5,11 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - Unreleased
9
+
10
+ ### Changed
11
+
12
+ - BREAKING: Classification thresholds are now percentile-based, not score-value-based. `--trunk-threshold 85` and `--branch-threshold 50` now mean "top 15% and top 50% of files by composite score" — not "score value ≥ 0.85 / 0.50". This is a semantic change to the same flag values; the numbers you pass stay the same but their meaning inverts. Previously, trunk was unreachable at defaults (max observed composite score ~0.717 in real repos, zero trunks); now every repo of meaningful size produces some trunks and branches. Absolute floors (raw complexity ≥ 15, raw fan_in ≥ 25) still escalate to `branch` regardless of percentile.
13
+
14
+ ## [0.3.0] - 2026-07-11
15
+
16
+ ### Fixed
17
+
18
+ - `--interaction-weight N` now works correctly: setting only the interaction weight no longer triggers `weights must sum to 1.0` or `coverage weight must be 0.0` errors; it behaves identically to `--coupling-weight N` (bounds-checked inline, then renormalized by the scorer).
19
+ - Absolute-floor escalations now carry an `escalation` marker in the output row: `complexity_floor` when raw complexity ≥ 15 triggered the leaf→branch escalation, `fan_in_floor` when raw fan_in ≥ 25 triggered it. Consumers can now distinguish a floor-escalated branch from a threshold-classified branch without parsing the score. Newness escalations (`recency_floor`, `trunk_adjacent`) continue to take precedence.
20
+ - Formula label is now consistent across all output surfaces. The canonical labels are `5-factor + coupling`, `5-factor`, `4-factor + coupling`, and `4-factor`; the previous mismatch (stderr reported `6-factor formula` while JSON meta reported `5-factor + coupling`) is resolved.
21
+ - `coverage/**` is now excluded by default. Previously, projects that had run SimpleCov (or any tool that writes into `coverage/`) had those generated files scanned as project source, silently inflating the JavaScript file set with vendored assets (e.g. `coverage/assets/*/application.js`).
22
+
23
+ ### Added
24
+
25
+ - Absolute floors in classification: raw complexity >= 15 or raw fan_in >= 25 can no longer classify as leaf; these files escalate to branch only, never downgrade.
26
+ - Temporal coupling now contributes to composite score at a nominal `0.05` weight when coupling data is available.
27
+ - Added `--coupling-weight N` and optional `coupling:P` in `--weights`, defaulting to `0.05`.
28
+ - Insufficient-dispersion warnings in `analysis.warnings` when a signal percentile-rank collapses to all 0.0 despite non-zero raw values; score is unchanged.
29
+ - Added insufficient-dispersion warnings for temporal coupling.
30
+
31
+ ### Changed
32
+
33
+ - BREAKING: `class` (`trunk`/`branch`/`leaf`) is now driven by composite `score`, not fan-in percentile only. Class reflects overall risk, not just coupling role. `--trunk-threshold` and `--branch-threshold` still take integer values 1-99, but now threshold composite `score` instead of `fan_in_pct`; defaults remain 85/50.
34
+ - BREAKING: Composite scoring now uses a uniform rebalance: `fan_in` changes from `0.25` to `0.20`, `coverage` changes from `0.15` to `0.10`, and a new `0.10 × fan_in_pct × coverage_risk_pct` interaction term is added with no global divisor; four-factor scores also shift intentionally because the reduced fan-in weight flows through no-coverage renormalization.
35
+ - BREAKING: Churn composite (`churn_pct`) is now percentile-ranked after averaging commit-count and lines-changed percentiles. The previous triangular distribution gave churn about half the variance/effective weight; re-ranking restores the intended contribution. Scores shift, especially for high-churn files.
36
+ - BREAKING: Numeric default weights are now `fan_in: 0.19`, `fan_out: 0.095`, `complexity: 0.2375`, `churn: 0.2375`, `coverage: 0.095`, `interaction: 0.095`, and `coupling: 0.05`; base-four ratios are preserved, so four-factor scoring without coupling data keeps the same effective weights as before.
37
+ - BREAKING: Row output now includes `evidence` immediately after `score`, a 0.0-1.0 metadata confidence value based on age, commit count, and explicit coverage-data presence. Output sorting now keys on `(class_rank, score)`, so trunks rank above branches above leaves and `--top N` no longer drops newness-escalated `trunk_adjacent` files behind high-score branches. Gate consumers should threshold `class` for verdicts and `evidence` for confidence, not raw `score`.
38
+
39
+ ### Docs
40
+
41
+ - Docs surface reduced to `README.md`, `SIGNALS.md`, and `CHANGELOG.md`. `PRODUCT.md` is renamed to `SIGNALS.md` and stripped of weights/roadmap so it only holds signal theory. `VISION.md` and `TRD.md` are removed — their content was drifting out of sync with the code faster than it was being read. `stud-finder --help` and the JSON output are the authoritative CLI/schema references.
42
+
8
43
  ## [0.1.0] - 2026-07-03
9
44
 
10
45
  ### Added
11
46
 
12
- - Initial RubyGems release of `stud-finder`, a CLI that ranks files by five risk signals: fan-in (blast radius), fan-out, cyclomatic complexity, git churn, and test coverage.
47
+ - Initial RubyGems release: a CLI that ranks files by five risk signals: fan-in (blast radius), fan-out, cyclomatic complexity, git churn, and test coverage.
13
48
  - Temporal coupling analysis for identifying files that change together.
14
49
  - Diff mode for scoring only files changed in a pull request while preserving repo-relative scores.
15
50
  - Ruby and JavaScript/TypeScript support.
data/README.md CHANGED
@@ -7,14 +7,17 @@ A code risk scoring CLI for Ruby and JavaScript/TypeScript codebases. Ranks ever
7
7
  ```
8
8
  $ bundle exec bin/stud-finder ./my-rails-app
9
9
 
10
- RANK LANGUAGE FILE SCORE CLASS FAN_IN FAN_OUT COMPLEXITY CHURN_COMMITS MAX_COUPLING COUPLING_PARTNERS COVERAGE
11
- 1 ruby app/models/proficiency.rb 0.91 trunk 223 4 85 11 0.62 3 0.99
12
- 2 ruby app/services/payment_service.rb 0.84 trunk 78 12 91 42 0.71 5 0.22
13
- 3 ruby app/controllers/orders_controller 0.73 branch 61 9 65 74 0.48 2 0.31
14
- 4 js src/components/Dashboard.tsx 0.68 branch 44 18 56 18 0.00 0 —
15
- ...
10
+ Ruby
11
+ rank language file score evidence class new age_days escalation ...
12
+ 1 ruby app/models/proficiency.rb 0.7304 1.0000 trunk false 842 ...
13
+ 2 ruby app/services/payment_service.rb 0.6890 1.0000 trunk false 611 ...
14
+ 3 ruby app/controllers/orders_ctlr.rb 0.5721 0.6667 branch false 520 ...
16
15
  ```
17
16
 
17
+ *Scores and evidence are illustrative. Row 3 has no coverage data (evidence capped at `0.6667 = (1.0 + 1.0 + 0.0) / 3`); rows 1–2 have full coverage (evidence `1.0000`). The `class` column reflects percentile rank of composite score within the repo — see Classification.*
18
+
19
+ The full table adds `fan_in`, `fan_out`, `instability`, `complexity`, `churn_commits`, `churn_lines`, `churn_pct`, `loc`, `loc_pct`, `max_coupling`, `max_coupling_partner`, `coupling_partners`, `coupling_pct`, and `coverage`. Use `--output json` for machine-readable output including a `warnings` section and full `meta`.
20
+
18
21
  ---
19
22
 
20
23
  ## Install
@@ -65,7 +68,7 @@ bundle exec bin/stud-finder ./my-rails-app --output csv > risk.csv
65
68
  # Top 50 highest-risk files, markdown for a PR comment
66
69
  bundle exec bin/stud-finder ./my-rails-app --top 50 --output markdown
67
70
 
68
- # With coverage signals (5-factor scoring)
71
+ # With coverage signals (activates the five-factor formula and interaction term)
69
72
  bundle exec bin/stud-finder ./my-rails-app \
70
73
  --ruby-coverage ./coverage/resultset.json \
71
74
  --js-coverage ./coverage/lcov.info
@@ -73,34 +76,111 @@ bundle exec bin/stud-finder ./my-rails-app \
73
76
 
74
77
  ---
75
78
 
76
- ## The Five Signals
79
+ ## Signals and weights
80
+
81
+ Each file is scored on up to seven inputs — six direct signals plus one cross-term (`interaction`) that fires when coverage data is present. See [SIGNALS.md](SIGNALS.md) for the theory behind each signal; this section lists the current defaults.
82
+
83
+ | Signal | Default weight | Notes |
84
+ |--------|---------------:|-------|
85
+ | **fan_in** | 0.19 | Blast radius — incoming dependencies |
86
+ | **fan_out** | 0.095 | Coupling burden — outgoing dependencies |
87
+ | **complexity** | 0.2375 | Max cyclomatic complexity of any method in the file |
88
+ | **churn** | 0.2375 | Commit frequency + line volume, both percentile-ranked and averaged, then re-ranked |
89
+ | **coverage** | 0.095 | Inverse of line coverage (`1 − coverage`), percentile-ranked. Optional. |
90
+ | **interaction** | 0.095 | Cross-term: `fan_in_pct × coverage_risk_pct`. Only active when coverage is present. |
91
+ | **coupling** | 0.05 | Percentile-rank of `max_coupling` from temporal-coupling analysis. Requires git history. |
92
+
93
+ Weights sum to 1.00 when all signals are available. The **base-four ratios** (fan_in : fan_out : complexity : churn = 4:2:5:5) are preserved across all availability modes, so removing optional signals and re-normalizing does not distort the relative weighting of the base structural signals.
94
+
95
+ **Availability modes** — the composite drops unavailable signals and re-normalizes the rest to sum to 1.0:
77
96
 
78
- Each file is scored on up to five independently measured signals. See [PRODUCT.md](PRODUCT.md) for the full theory and weighting math.
97
+ | coverage | coupling | Active signals | Formula label |
98
+ |----------|----------|----------------|---------------|
99
+ | ✓ | ✓ | all 7 | `5-factor + coupling` |
100
+ | ✓ | ✗ | 6 (drop coupling) | `5-factor` |
101
+ | ✗ | ✓ | 5 (drop coverage + interaction) | `4-factor + coupling` |
102
+ | ✗ | ✗ | 4 (base only) | `4-factor` |
79
103
 
80
- | Signal | What it measures | Weight |
81
- |--------|------------------|--------|
82
- | **fan_in** | How many other files depend on this one (blast radius) | 25% |
83
- | **fan_out** | How many other files this one depends on (its own coupling burden) | 10% |
84
- | **complexity** | Cyclomatic complexity of the hardest method in the file | 25% |
85
- | **churn** | Commit frequency + line volume over a 180-day window | 25% |
86
- | **coverage** | Inverse of line coverage (lower coverage = higher risk) | 15% |
104
+ The formula label appears in JSON output at `meta.formula` and in the stderr scoring note.
87
105
 
88
- When coverage isn't available, the remaining four signals (fan_in, fan_out, complexity, churn) re-normalize to 100% automatically (4-factor mode).
106
+ ### The score
89
107
 
90
- ### Informational columns (not scored)
108
+ Every signal is percentile-ranked across the full codebase, so scores are relative to the project itself. The composite score is the weighted sum of the active signals plus the interaction cross-term when coverage is present:
91
109
 
92
- These ride alongside the score to give reviewers extra context, but do not contribute to it:
110
+ ```
111
+ score = Σ (weight_i × signal_i_pct) + (weight_interaction × fan_in_pct × coverage_risk_pct)
112
+ ```
113
+
114
+ Result is clamped to `[0.0, 1.0]` and rounded to four decimal places.
93
115
 
94
- - **instability** / **instability_pct** — `fan_out / (fan_in + fan_out)`, and its percentile rank across the repo. High instability = depends on a lot while little depends on it.
95
- - **max_coupling** / **max_coupling_partner** / **coupling_partners** / **coupling_pct** — temporal coupling from git history: the strongest co-change ratio with any partner file, the path of that strongest partner, how many partners cross the threshold, and the percentile rank of `max_coupling`. The analysis produces co-change pairs; each file's row keeps the strongest pair's ratio (`max_coupling`), that partner's path (`max_coupling_partner`), and the count of pairs (`coupling_partners`). On ties the strongest partner is chosen deterministically: highest coupling, then highest co-change count, then alphabetical path; `max_coupling_partner` is an empty string when a file has no qualifying partners. Computed once over the full file set in the main scan (one extra `git log` pass), so cross-language co-change is captured. Same thresholds as the `edges` subcommand (`--coupling-threshold`, `--coupling-min-commits`).
116
+ ---
117
+
118
+ ## Classification
96
119
 
97
- Files are classified into three labels based on their **fan_in percentile** (not the total score):
120
+ Files are classified into three tiers based on the **percentile rank of their composite score** within the repo:
98
121
 
99
- - **trunk** — fan_in in the top 15% (default `trunk_threshold: 85`). Load-bearing. High review bar, change with care.
100
- - **branch** — fan_in between the 50th and 85th percentile (default `branch_threshold: 50`). Meaningful coupling.
122
+ - **trunk** — top 15% by composite score (default `--trunk-threshold 85`). Load-bearing. High review bar, change with care.
123
+ - **branch** — top 50% but below top 15% (default `--branch-threshold 50`). Meaningful coupling.
101
124
  - **leaf** — everything below the 50th percentile. Isolated. Move fast here.
102
125
 
103
- The total score still drives the ranking. The class label is a separate coupling-based signal.
126
+ This means every repo of meaningful size has trunks: a file at score 0.55 is trunk if the rest of the repo scores below it. The absolute floors below provide a safety net for tiny repos.
127
+
128
+ **Absolute floors:** raw complexity ≥ 15 or raw fan-in ≥ 25 escalates a `leaf` to `branch` regardless of percentile. This ensures that structurally dangerous files in tiny repos (where percentile spread is minimal) still receive elevated attention.
129
+
130
+ **Tiny repos:** In repos with very few files and uniform scores, the percentile spread may place all files at the same score_pct (0.0), resulting in 0 trunks. The absolute floor is the escape hatch for dangerous files in this case.
131
+
132
+ Note: `--trunk-threshold 85` and `--branch-threshold 50` now mean "top 15% / top 50% of files by composite score" — not "score value ≥ 0.85 / 0.50". This is a BREAKING semantic change from 0.3.0. Trunk was previously unreachable at defaults (max observed composite score ~0.717 in real repos); this change restores the guarantee that some files are always classified trunk-tier relative to their codebase.
133
+
134
+ ### Absolute floors
135
+
136
+ After the score threshold runs, safety floors escalate anything visibly dangerous that percentile ranking flattened. A file with raw complexity ≥ 15 or raw fan-in ≥ 25 cannot classify as `leaf` — those files escalate to `branch` regardless of score. Floors escalate only; they never downgrade a `branch` or `trunk`.
137
+
138
+ The floors exist because tiny or uniform-signal codebases can produce a composite score below the branch threshold even when the raw signals are visibly high — the percentile pass collapses everyone to the middle. Absolute floors catch this failure mode without altering the numeric score.
139
+
140
+ A floor escalation sets `escalation=complexity_floor` (raw complexity ≥ 15) or `escalation=fan_in_floor` (raw fan-in ≥ 25) on the output row, so consumers can distinguish a floor-escalated branch from a threshold-classified one. When a file meets a floor condition and is also considered new, `escalation=recency_floor` takes precedence — the newness marker wins.
141
+
142
+ ### Newness rules
143
+
144
+ History-based signals can under-protect brand-new files: a fresh AI-generated file may have little churn, low fan-in, and no established blast radius yet, even though it is often the least proven code in the change. Post-scoring newness rules therefore change only `class`, `new_file`, `age_days`, and `escalation`; the numeric `score` stays honest and unchanged.
145
+
146
+ A file is considered new when its first commit is within `--new-file-days` days (default 30), or when it has fewer than `--new-file-min-commits` commits in full git history (default 3). New files cannot classify below `branch`; those rows show `escalation=recency_floor`.
147
+
148
+ A stronger rule runs first: if a new file depends on a structurally `trunk` file through its fan-out edges, it escalates to `trunk` with `escalation=trunk_adjacent`. This highlights new code consuming critical interfaces, where contract-violation risk is highest. Use `--no-newness` to disable both newness rules.
149
+
150
+ **CI usage:** newness rules require full git history. In GitHub Actions, set `fetch-depth: 0` before running Stud Finder. If Stud Finder detects a shallow clone, it auto-disables both newness rules and emits `shallow_clone_newness_disabled` in `warnings` so classifications match `--no-newness` instead of misclassifying mature files as new.
151
+
152
+ ---
153
+
154
+ ## Evidence
155
+
156
+ Every row carries an `evidence` value (0.0–1.0) alongside `score`. Score is the weighted signal composite. Evidence is a metadata confidence: how much history + coverage-data backing does that score have?
157
+
158
+ Evidence combines file age, commit count, and whether coverage data was explicitly provided. A high score with low evidence means "structural signals concentrated risk here, but we're not certain because the file is young or the history is thin." A high score with high evidence is a strong claim.
159
+
160
+ In shallow clones (git fetch-depth < full history), evidence is `null` on every row because file-metadata history is unavailable. The `shallow_clone_newness_disabled` warning also fires. See the CI note above.
161
+
162
+ **Gate consumers should threshold `class` for verdicts and `evidence` for confidence, not raw `score` alone.** Output is sorted by `(class_rank, score)` so trunks group above branches above leaves, and `--top N` no longer drops newness-escalated `trunk_adjacent` files behind high-score branches.
163
+
164
+ ---
165
+
166
+ ## Warnings
167
+
168
+ `analysis.warnings` (available in JSON output) surfaces conditions the run detected that a consumer should know about:
169
+
170
+ - **`shallow_clone_newness_disabled`** — shallow git clone detected; newness rules auto-disabled.
171
+ - **`insufficient_dispersion_<signal>`** — every file in the codebase has the same non-zero raw value for `<signal>`, so its percentile-ranked contribution collapsed to `0.0`. The score is unchanged; the warning flags that the signal is silently uninformative rather than genuinely absent. One per affected signal: `fan_in`, `fan_out`, `complexity`, `churn`, `coverage`, `interaction`, `coupling`.
172
+ - Language-specific warnings such as `js_depcruise_no_config` when the JS pipeline had to fall back.
173
+
174
+ ---
175
+
176
+ ## Informational columns
177
+
178
+ These ride alongside the score to give reviewers extra context, but do not contribute to it directly:
179
+
180
+ - **`instability`** / **`instability_pct`** — `fan_out / (fan_in + fan_out)`, and its percentile rank across the repo. High instability = depends on a lot while little depends on it.
181
+ - **`max_coupling`** / **`max_coupling_partner`** / **`coupling_partners`** — temporal coupling from git history. The strongest co-change ratio with any partner file, the path of that strongest partner, and how many partners cross the threshold. `coupling_pct` (the percentile rank of `max_coupling`) does contribute to the score at weight `0.05` — the raw fields are informational.
182
+
183
+ On ties the strongest partner is chosen deterministically: highest coupling, then highest co-change count, then alphabetical path; `max_coupling_partner` is an empty string when a file has no qualifying partners. Coupling is computed once over the full file set in the main scan (one extra `git log` pass), so cross-language co-change is captured. By default, commits touching more than 50 scored files are skipped as bulk commits; use `--coupling-max-commit-files 0` for unlimited/legacy behavior.
104
184
 
105
185
  ---
106
186
 
@@ -111,17 +191,25 @@ The total score still drives the ranking. The class label is a separate coupling
111
191
  - complexity via RuboCop
112
192
  - coverage: SimpleCov resultset JSON, Cobertura XML
113
193
 
194
+ ### Rails inference
195
+
196
+ Ruby fan-in includes conservative Rails-style implicit references by default. Association calls such as `belongs_to :user`, `has_one :profile`, `has_many :comments`, and `has_and_belongs_to_many :tags` are treated as references to their likely model constants. Literal `class_name: 'Foo::Bar'` overrides the symbol; dynamic `class_name:` values are ignored rather than guessed. Disable this with `--no-rails-inference`.
197
+
114
198
  **JavaScript / TypeScript (.js, .jsx, .ts, .tsx):**
115
199
  - fan_in via `dependency-cruiser` (must be installed in the target project)
116
200
  - complexity via `eslint` (`--rule '{"complexity":["error",0]}'`)
117
201
  - coverage: LCOV (`.info` format)
118
202
 
203
+ Stud Finder first runs dependency-cruiser with the target project's normal config so path aliases, TypeScript config, and bundler resolution can be honored. If that fails because no usable configuration is available, it retries once with `--no-config` and reports `js_depcruise_no_config`. The fallback keeps analysis running, but aliases such as `tsconfig` paths and webpack aliases will not resolve, so JS/TS `fan_in` may be undercounted. For alias-heavy TypeScript projects, run `npx depcruise --init` in the target repo for accurate results.
204
+
119
205
  Each language gets its own ranking section in the output — Ruby and JS are not pooled.
120
206
 
121
207
  ---
122
208
 
123
209
  ## Flag Reference
124
210
 
211
+ `stud-finder --help` is the authoritative reference; this table is a summary.
212
+
125
213
  | Flag | Description |
126
214
  |------|-------------|
127
215
  | `--output FORMAT` | `table` (default), `json`, `markdown`, `csv` |
@@ -129,15 +217,24 @@ Each language gets its own ranking section in the output — Ruby and JS are not
129
217
  | `--js-coverage PATH` | JavaScript coverage report (LCOV `.info`) |
130
218
  | `--coverage PATH` | Deprecated alias for `--ruby-coverage` |
131
219
  | `--js-timeout N` | dependency-cruiser timeout in seconds (default: 60) |
132
- | `--churn-days N` | Commit lookback window in days (default: 180) |
133
- | `--weights WEIGHTS` | Custom weights as fractions, e.g. `fan_in:0.25,fan_out:0.10,complexity:0.25,churn:0.25,coverage:0.15`. Defaults shown. All five keys are required. |
134
- | `--trunk-threshold N` | fan_in percentile cutoff for trunk classification (default: 85) |
135
- | `--branch-threshold N` | fan_in percentile cutoff for branch classification (default: 50) |
220
+ | `--no-rails-inference` | Disable Rails association/string fan-in inference |
221
+ | `--churn-days N` | Commit lookback window in days (default: 180). Churn uses git rename detection; within the window, rename commits are attributed to the new path when git pairs the rename. |
222
+ | `--weights WEIGHTS` | Custom weights as fractions, e.g. `fan_in:F,fan_out:O,complexity:C,churn:H,coverage:V[,interaction:I][,coupling:P]`. The five base keys (`fan_in`, `fan_out`, `complexity`, `churn`, `coverage`) are required. `interaction` and `coupling` are optional: when omitted, `interaction` defaults to `0.0` (custom weights opt-in) and `coupling` defaults to `0.05`. Each value must be in `[0.0, 1.0]`. When no coverage data is provided, `coverage` must be `0.0`. |
223
+ | `--interaction-weight N` | Sugar flag for setting only the interaction weight. |
224
+ | `--coupling-weight N` | Sugar flag for setting only the coupling weight. Bounds-checked `[0.0, 1.0]`. |
225
+ | `--trunk-threshold N` | composite-score percentile cutoff for trunk classification; top (100-N)% of files by score (default: 85) |
226
+ | `--branch-threshold N` | composite-score percentile cutoff for branch classification; top (100-N)% of files by score (default: 50) |
136
227
  | `--exclude PATTERN` | Exclude glob pattern (repeatable). `spec/` and `test/` excluded by default. |
137
228
  | `--top N` | Emit only the top N results |
138
229
  | `--diff-base REF` | Score the whole repo but emit only the files changed on `HEAD` vs the merge-base with `REF` (e.g. `origin/staging`). Ranks and scores stay relative to the full repo. Ideal for per-PR runs. |
139
230
  | `--only PATHS` | Emit only these comma-separated repo-relative paths. Like `--diff-base` but with an explicit list instead of a git diff. Mutually exclusive with `--diff-base`. |
140
231
  | `--min-files N` | Advisory minimum file count to trust percentiles (default: 20) |
232
+ | `--coupling-threshold FLOAT` | Minimum temporal-coupling ratio for edges output and main-scan coupling columns (default: 0.30) |
233
+ | `--coupling-min-commits N` | Minimum co-change count for temporal-coupling edges/columns (default: 5) |
234
+ | `--coupling-max-commit-files N` | Skip temporal-coupling commits touching more than N scored files (default: 50; `0` = unlimited) |
235
+ | `--new-file-days N` | Treat files first committed within N days as new (default: 30; `0` disables the age floor) |
236
+ | `--new-file-min-commits N` | Treat files with fewer than N full-history commits as new (default: 3; `0` disables the commit-count floor) |
237
+ | `--no-newness` | Disable new-file classification rules |
141
238
  | `--verbose` | Print suppressed per-file warnings to stderr |
142
239
  | `--version`, `--help` | Self-explanatory |
143
240
 
@@ -147,7 +244,7 @@ Each language gets its own ranking section in the output — Ruby and JS are not
147
244
 
148
245
  - `table` — human-readable, aligned columns
149
246
  - `csv` — spreadsheet-friendly, pipe to a file
150
- - `json` — machine-readable with `meta`, `warnings`, `ruby`, `javascript` sections
247
+ - `json` — machine-readable with `meta`, `warnings`, `ruby`, `javascript` sections. `meta.formula` labels the active mode (`5-factor + coupling`, `5-factor`, `4-factor + coupling`, `4-factor`). `meta.weights` reports the normalized weights actually used (with `null` for signals that were unavailable).
151
248
  - `markdown` — drop directly into a PR comment or issue
152
249
 
153
250
  ---
@@ -160,14 +257,14 @@ Run it:
160
257
  - Before a code review, to know which PRs deserve extra scrutiny
161
258
  - On every PR in CI, as a risk-tagged diff context
162
259
 
163
- Don't run it as a gaterisk isn't a binary blocker. Run it as input to human judgment.
260
+ Don't run it as a hard blocker on raw `score` `score` is evidence, not a decision. Threshold `class` for verdicts and `evidence` for confidence.
164
261
 
165
262
  ---
166
263
 
167
264
  ## Documentation
168
265
 
169
- - **[PRODUCT.md](PRODUCT.md)** — theory, formulas, and the research behind each signal
170
- - **[VISION.md](VISION.md)** — project vision and positioning
266
+ - **[SIGNALS.md](SIGNALS.md)** — theory behind each signal, and the score / class / evidence separation
267
+ - **[CHANGELOG.md](CHANGELOG.md)** — per-version changes, weight-shift history, breaking notes
171
268
 
172
269
  ---
173
270
 
data/SIGNALS.md ADDED
@@ -0,0 +1,143 @@
1
+ # stud-finder — Signal Theory
2
+
3
+ This document explains **what each signal measures** and **why it correlates with risk**. It does not list weights, defaults, or flag semantics — those live in [README.md](README.md) and `stud-finder --help`, which stay current with the code. Read this once to understand the model; refer back to it only when adding a new signal or challenging an existing one.
4
+
5
+ ---
6
+
7
+ ## The scoring signals
8
+
9
+ Each file is scored on up to seven independently measured inputs. Six are direct signals; one (`interaction`) is a cross-term that only activates when coverage data is present.
10
+
11
+ ### 1. Fan-in — Blast Radius
12
+
13
+ *"How many files depend on this one?"*
14
+
15
+ Rooted in Robert Martin's **afferent coupling (Ca)** metric (1994) and graph theory in-degree analysis. A file with fan-in 60 means 60 other files break if it breaks. The Stable Dependencies Principle says: high-coupling files must be treated as infrastructure.
16
+
17
+ Built via static analysis — Zeitwerk constant mapping for Rails, falling back to AST scanning for Ruby; `dependency-cruiser` for JavaScript/TypeScript. No runtime instrumentation.
18
+
19
+ ### 2. Fan-out — Coupling Burden
20
+
21
+ *"How many files does this one depend on?"*
22
+
23
+ Rooted in Robert Martin's **efferent coupling (Ce)** metric. A high fan-out file has more direct dependencies to understand, coordinate, and mock in tests. Several production bugs surface not because the failing file had a large blast radius, but because it depended on a fragile implicit contract in one of its many upstream neighbors.
24
+
25
+ ### 3. Complexity — Cognitive Load
26
+
27
+ *"How hard is this file to reason about?"*
28
+
29
+ Cyclomatic complexity, measured as the **maximum across any single method** in the file. A file with one function of complexity 12 is riskier than a file with ten functions of complexity 3 each — the hardest function determines how deep you have to go.
30
+
31
+ Computed via RuboCop for Ruby, ESLint for JS/TS. No manual annotation.
32
+
33
+ ### 4. Churn — Change Velocity
34
+
35
+ *"How often is this file being touched, and how much?"*
36
+
37
+ A composite signal built in two stages:
38
+
39
+ 1. Commit count and lines-changed are each percentile-ranked across the codebase over the churn window (default 180 days).
40
+ 2. The two percentiles are averaged 50/50 and **the average is percentile-ranked again** to produce the final `churn_pct`.
41
+
42
+ The second re-ranking matters: without it, averaging two uniform distributions produces a triangular distribution centered at 0.5, which halves the effective variance of the churn signal. Re-ranking restores the intended dispersion, so churn contributes the weight the composite claims it does.
43
+
44
+ A file touched in 40 commits but only for small fixes is different from a file touched in 40 commits with major rewrites each time. Combining commit count with line volume captures both patterns.
45
+
46
+ ### 5. Coverage — Safety Net
47
+
48
+ *"If this file breaks, will tests catch it?"*
49
+
50
+ Low coverage on a high-risk file is compounded danger — no complexity safety net, no test catch. Coverage is measured as inverse risk (`1 − coverage`) and percentile-ranked across the codebase. Files absent from the coverage report score as maximum coverage risk but render as `—` so missing data is distinct from explicit 0% coverage.
51
+
52
+ Supports Cobertura XML (RSpec + SimpleCov), LCOV (Jest, lcov), and SimpleCov JSON resultsets. Auto-detected by file extension.
53
+
54
+ Coverage is optional. When no report is provided, the composite drops to a four-signal formula and re-normalizes the remaining structural weights to sum to 1.0.
55
+
56
+ ### 6. Temporal Coupling — Hidden Contracts
57
+
58
+ *"Which files always change together?"*
59
+
60
+ Co-change frequency from git history: file pairs that change together in the same commit more often than expected by chance. Captures hidden coupling that static analysis cannot see — implicit contracts, shared state, callback side effects that always require coordinated edits.
61
+
62
+ This is the most empirically defensible structural metric available: observed behavior in real production git history, not a theoretical approximation. When two files always change together, they have a hidden dependency; if that dependency is not explicit, it is a risk.
63
+
64
+ Each file's row keeps the strongest pair's ratio (`max_coupling`), that partner's path (`max_coupling_partner`), and the count of qualifying pairs. `coupling_pct` is the percentile rank of `max_coupling` across the codebase and contributes to the composite score. Requires git history; unavailable on shallow clones.
65
+
66
+ Coupling is weighted deliberately low. Files that historically change together is correlational, not causal — genuine implicit contracts share a bin with routine "we touched both because they were in the same feature." Underweighting keeps false positives from dominating the ranking until an outcome-labelled dataset justifies raising it.
67
+
68
+ ### 7. Interaction — Fan-in × Coverage Risk
69
+
70
+ *"High blast radius AND weak test net."*
71
+
72
+ A cross-term, not an independent signal: `fan_in_pct × coverage_risk_pct`, contributed with its own weight when coverage data is present. Captures the compounded danger of a high-fan-in file that also has poor test coverage. The two signals are independently weighted (fan-in and coverage), so a linear combination cannot express "both risky at once"; the interaction term does.
73
+
74
+ Only active in the five-factor (coverage-present) formula. Silently dropped in the four-factor mode along with coverage.
75
+
76
+ ---
77
+
78
+ ## Percentile ranking
79
+
80
+ Every signal is percentile-ranked across the full codebase — so scores are always relative to the project itself, not an external benchmark. A file at the 90th percentile of fan-in has more incoming dependencies than 90% of its peers, regardless of whether that peer set has 50 files or 5,000.
81
+
82
+ Ties receive the same rank. Edge cases: a codebase with only one file gets `0.0` for every signal (nothing to rank against); a codebase where every file has the identical raw value for a signal also collapses to `0.0` — see **Insufficient-dispersion warnings** below.
83
+
84
+ ---
85
+
86
+ ## Score vs. classification vs. evidence
87
+
88
+ Three pieces of output serve three purposes. Confusing them causes gate consumers to threshold the wrong number.
89
+
90
+ - **`score`** (0.0–1.0, four decimals) — the weighted composite. This is **evidence about the file's structural risk**, nothing more. Higher score means the signals concentrated more risk on this file.
91
+ - **`class`** (`leaf` / `branch` / `trunk`) — the decision label. Driven by the composite-score PERCENTILE across the codebase against configurable thresholds (defaults: `branch` at top 50%, `trunk` at top 15%). This guarantees that some files are always trunk-tier relative to their repo — a file scoring 0.55 can be trunk if the rest of the codebase scores below it. This is what a gate should threshold on for verdicts.
92
+ - **`evidence`** (0.0–1.0) — a metadata confidence value based on file age, commit count, and whether coverage data was explicitly provided. A high score with low evidence means "structural signals concentrated risk here, but we're not certain because the file is young or the history is thin." Gates should threshold `evidence` for confidence, not raw `score`.
93
+
94
+ ### Absolute floors
95
+
96
+ Classification runs the composite-score threshold first, then applies safety floors. A file with raw complexity ≥ 15 or raw fan-in ≥ 25 cannot classify as `leaf`; those files escalate to `branch` regardless of score. Floors escalate only — a file already classed as `branch` or `trunk` by score is never downgraded by the floor logic.
97
+
98
+ The floors catch the tiny-repo / uniform-signal failure mode: in a 10-file codebase where every file has a high raw complexity, percentile ranking flattens everyone to the middle, the composite score sits around 0.4, and every file classifies as `leaf` despite being structurally dangerous.
99
+
100
+ A floor escalation sets `escalation=complexity_floor` (raw complexity ≥ 15) or `escalation=fan_in_floor` (raw fan-in ≥ 25) on the output row. When a file qualifies for a floor escalation and is also considered new, `escalation=recency_floor` takes precedence — the newness marker wins over the floor marker.
101
+
102
+ ### Newness rules
103
+
104
+ History-based signals under-protect brand-new files: a fresh AI-generated file may have little churn, low fan-in, and no established blast radius yet, even though it is often the least proven code in the change.
105
+
106
+ Post-scoring newness rules therefore adjust `class`, `new_file`, `age_days`, and `escalation` only — the numeric `score` stays honest and unchanged. A file is considered new when its first commit is within the newness window (default 30 days) or it has fewer than the minimum commit count (default 3). New files cannot classify below `branch`; those rows carry `escalation=recency_floor`.
107
+
108
+ A stronger rule runs first: if a new file depends on a structurally `trunk` file through its fan-out edges, it escalates to `trunk` with `escalation=trunk_adjacent`. This highlights new code consuming critical interfaces, where contract-violation risk is highest.
109
+
110
+ Newness rules require full git history — see the `fetch-depth: 0` note in the README.
111
+
112
+ ---
113
+
114
+ ## Insufficient-dispersion warnings
115
+
116
+ When every file in a codebase has an identical non-zero raw value for a signal — five files with 100 lines each, ten files with the same cyclomatic complexity, a tiny repo where nothing has been touched in the churn window — the percentile-rank pass collapses that signal to all-`0.0`. The signal contributes nothing to differentiation, but the underlying data is not "missing" — it exists, it just happens to be flat.
117
+
118
+ `analysis.warnings` emits `insufficient_dispersion_<signal>` in that case. The score is unchanged; the warning surfaces so consumers know a signal is silently uninformative rather than genuinely absent. Warnings fire per signal (`fan_in`, `fan_out`, `complexity`, `churn`, `coverage`, `interaction`, `coupling`) and only when raw data is present but dispersion is degenerate.
119
+
120
+ ---
121
+
122
+ ## What's out of scope for this document
123
+
124
+ - **Current default weights and thresholds.** Live in [README.md](README.md) and `stud-finder --help`. Weights get re-balanced between versions; keeping them in this document guarantees they drift out of sync.
125
+ - **CLI flag semantics.** `stud-finder --help` is the authoritative reference.
126
+ - **JSON output shape.** Emitted by `stud-finder --output json` — that output is the schema.
127
+ - **Roadmap / calibration plans.** Tracked in commit history and per-arc architect reviews, not in a maintained document.
128
+
129
+ ---
130
+
131
+ ## Instability (informational)
132
+
133
+ `instability` = `fan_out / (fan_in + fan_out)` — Robert Martin's I metric, bounded [0, 1]. Reported in every row for context but **not scored**. A file with fan-in 100 and fan-out 10 has instability 0.09 (stable, load-bearing); a file with fan-in 2 and fan-out 50 has instability 0.96 (fragile consumer). The metric captures a real property but has proved noisy enough at the file level that including it in the composite would dilute signals with better outcome correlations. It stays as reviewer context.
134
+
135
+ ---
136
+
137
+ ## Honest limits
138
+
139
+ - **Coupling ≠ correctness.** High-coupling files often receive the most attention and best maintenance precisely because they are load-bearing. A high score means "this file concentrates risk signals"; it does not mean "this file has more bugs." Score is a triage signal, not an audit.
140
+ - **Weights are heuristics, not calibrated estimates.** The default weights encode a plausible structural ordering but have not been fit against a labelled bug dataset. Until outcome calibration runs, treat weight differences as directional, not quantitative.
141
+ - **Bugs live at interfaces, not files.** The most common production bugs surface at the boundary between two files — a producer/consumer contract that changed on one side. A single file's score misses cross-file interaction; use the `coupling` signal and `edges` output to reason about pairs.
142
+ - **File-risk ≠ change-risk.** A file that is structurally load-bearing but untouched in a sprint carries lower per-change risk than a simpler file being actively rewritten. For per-PR gating, combine score with diff size rather than using score alone.
143
+ - **Coverage measures execution, not assertion quality.** A line-covered file may have shallow assertions that miss real logic errors. Coverage as a risk signal catches the "no test catches a change here" case; it does not vouch for test depth.
@@ -46,7 +46,7 @@ module StudFinder
46
46
  added, deleted, path = line.split("\t", 3)
47
47
  next if path.nil?
48
48
 
49
- relative = normalize_path(path)
49
+ relative = normalize_path(renamed_path(path))
50
50
  next unless file_set[relative]
51
51
 
52
52
  counts[relative] += 1
@@ -72,6 +72,7 @@ module StudFinder
72
72
  '--format=tformat:',
73
73
  '--numstat',
74
74
  '--no-merges',
75
+ '--find-renames',
75
76
  '--diff-filter=ACDMR'
76
77
  )
77
78
  end
@@ -85,6 +86,14 @@ module StudFinder
85
86
  absolute.start_with?("#{@repo_path}/") ? absolute.delete_prefix("#{@repo_path}/") : path
86
87
  end
87
88
 
89
+ def renamed_path(path)
90
+ return path unless path.include?(' => ')
91
+
92
+ path.sub(/\{[^{}]* => ([^{}]*)\}/, '\\1').then do |renamed|
93
+ renamed == path ? path.split(' => ', 2).last : renamed
94
+ end
95
+ end
96
+
88
97
  def numeric?(value)
89
98
  value&.match?(/\A\d+\z/)
90
99
  end