stud-finder 0.3.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: 77b17fc211c036ac76d6508d2d37177a0aff6331cb7be4101bb56c8c17e0c61f
4
- data.tar.gz: d601cf1c93a7fa5d9bad190614f57375f2c02a4c4f07d00d6487f4d0ab7e4cf6
3
+ metadata.gz: 8c203b1ef550bd6c05b1f07211cbad55d55520dcbf081ec59f24ab955c3a34d8
4
+ data.tar.gz: 10c285f7512708afda1af1a3c8f0b629eccdddc45aee9ac1cd2869c44af86f64
5
5
  SHA512:
6
- metadata.gz: '03438058c1234cd7740190d78807102e39a7b75def36ce914f276d0be9d7cfadc277ee6f5b6a04b7eabc031c7473316b65b0e2d1309a8f98d9e3d96b0559f405'
7
- data.tar.gz: 7f1f8bba0ed383a56dedd2f59efa10ff1f0c662b5b2649416efdd40bc4f66bc99424ca257cc321e3c32e6a77a49880bc93ee74bf2b078dbe29aeaa1f922c8081
6
+ metadata.gz: 2d68892673ccaed71bde8f223ae5d1098033aeee65ca89bb7b21c301edf7a3eb7a7745cd5e70df24bbdb779a70d6f44ddbe4ccab324ff4731fba47f8c3a01cef
7
+ data.tar.gz: 704e0964915bbe5dce1628d082e5eccb6e1376bdd9919ace07251000892432373f94e61d1f2cebb91442feda1cef50358aeda31c2e30ed95e5414529ce5479ce
data/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ 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
+
8
14
  ## [0.3.0] - 2026-07-11
9
15
 
10
16
  ### Fixed
@@ -38,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
44
 
39
45
  ### Added
40
46
 
41
- - Initial `stud-finder` cut: 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.
42
48
  - Temporal coupling analysis for identifying files that change together.
43
49
  - Diff mode for scoring only files changed in a pull request while preserving repo-relative scores.
44
50
  - Ruby and JavaScript/TypeScript support.
data/README.md CHANGED
@@ -9,12 +9,12 @@ $ bundle exec bin/stud-finder ./my-rails-app
9
9
 
10
10
  Ruby
11
11
  rank language file score evidence class new age_days escalation ...
12
- 1 ruby app/models/proficiency.rb 0.7304 1.0000 branch false 842 ...
13
- 2 ruby app/services/payment_service.rb 0.6890 1.0000 branch false 611 ...
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
14
  3 ruby app/controllers/orders_ctlr.rb 0.5721 0.6667 branch false 520 ...
15
15
  ```
16
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`). Real runs on well-distributed repos typically top out around 0.70–0.75 composite score — see Classification.*
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
18
 
19
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
20
 
@@ -117,15 +117,19 @@ Result is clamped to `[0.0, 1.0]` and rounded to four decimal places.
117
117
 
118
118
  ## Classification
119
119
 
120
- Every row is labelled with a `class`:
120
+ Files are classified into three tiers based on the **percentile rank of their composite score** within the repo:
121
121
 
122
- - **trunk** — composite score `trunk_threshold / 100` (default `trunk_threshold: 85`, so score ≥ 0.85). Load-bearing. High review bar.
123
- - **branch** — composite score `branch_threshold / 100` and below the trunk cutoff (default `branch_threshold: 50`, so 0.50 ≤ score < 0.85). Meaningful coupling.
124
- - **leaf** — score below the branch cutoff. Isolated. Move fast here.
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.
124
+ - **leaf** — everything below the 50th percentile. Isolated. Move fast here.
125
125
 
126
- `--trunk-threshold` and `--branch-threshold` take integer values 1–99 that set the composite-score cutoff (a threshold of 85 means score 0.85). Classification is driven by the full composite, not by fan-in percentile a file with modest fan-in but very high complexity + churn can still classify as `trunk`.
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
127
 
128
- Because `score` is a weighted sum of percentile-ranked signals, not itself a percentile, a threshold of 85 does not mean "top 15% of files." Well-distributed projects rarely have any file scoring above 0.85; in tighter distributions the cutoffs may need tuning per-project.
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.
129
133
 
130
134
  ### Absolute floors
131
135
 
@@ -218,8 +222,8 @@ Each language gets its own ranking section in the output — Ruby and JS are not
218
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`. |
219
223
  | `--interaction-weight N` | Sugar flag for setting only the interaction weight. |
220
224
  | `--coupling-weight N` | Sugar flag for setting only the coupling weight. Bounds-checked `[0.0, 1.0]`. |
221
- | `--trunk-threshold N` | Composite-score threshold for trunk classification: score N/100 (integer 1–99, default: 85) |
222
- | `--branch-threshold N` | Composite-score threshold for branch classification: score N/100 (integer 1–99, default: 50) |
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) |
223
227
  | `--exclude PATTERN` | Exclude glob pattern (repeatable). `spec/` and `test/` excluded by default. |
224
228
  | `--top N` | Emit only the top N results |
225
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. |
data/SIGNALS.md CHANGED
@@ -88,7 +88,7 @@ Ties receive the same rank. Edge cases: a codebase with only one file gets `0.0`
88
88
  Three pieces of output serve three purposes. Confusing them causes gate consumers to threshold the wrong number.
89
89
 
90
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 against configurable thresholds (defaults: `branch` at score ≥ 0.50, `trunk` at score 0.85). This is what a gate should threshold on for verdicts.
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
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
93
 
94
94
  ### Absolute floors
@@ -209,11 +209,13 @@ module StudFinder
209
209
  @options[:rails_inference] = false
210
210
  end
211
211
  opts.on('--trunk-threshold N', Integer,
212
- 'score cutoff for trunk classification, integer 1-99 (default: 85)') do |value|
212
+ 'score percentile cutoff for trunk classification, integer 1-99 ' \
213
+ '(default: 85 = top 15% of files by score)') do |value|
213
214
  @options[:trunk_threshold] = value
214
215
  end
215
216
  opts.on('--branch-threshold N', Integer,
216
- 'score cutoff for branch classification, integer 1-99 (default: 50)') do |value|
217
+ 'score percentile cutoff for branch classification, integer 1-99 ' \
218
+ '(default: 50 = top 50% of files by score)') do |value|
217
219
  @options[:branch_threshold] = value
218
220
  end
219
221
  opts.on('--exclude PATTERN', 'Exclude glob pattern (repeatable)') do |value|
@@ -52,9 +52,11 @@ module StudFinder
52
52
  }
53
53
  @warnings = insufficient_dispersion_warnings(pcts)
54
54
 
55
+ scores = @files.to_h { |file| [file, weighted_score(file, pcts)] }
56
+ score_pcts = Normalizer.percentile_rank(scores, @files)
57
+
55
58
  rows = @files.each_with_index.map do |file, index|
56
- score = weighted_score(file, pcts)
57
- [index, result_row(file, score, pcts)]
59
+ [index, result_row(file, scores.fetch(file), pcts, score_pcts.fetch(file))]
58
60
  end
59
61
 
60
62
  rows.sort_by { |index, row| [-row[:score], index] }
@@ -123,12 +125,12 @@ module StudFinder
123
125
  end
124
126
  end
125
127
 
126
- def result_row(file, score, pcts)
128
+ def result_row(file, score, pcts, score_pct)
127
129
  fi = @fan_in.fetch(file, 0).to_i
128
130
  fo = @fan_out.fetch(file, 0).to_i
129
131
  rounded_score = score.round(4)
130
132
  complexity = @complexity.fetch(file, 0).to_i
131
- floored_class, floor_escalation = floored_classification(classification(rounded_score), complexity, fi)
133
+ floored_class, floor_escalation = floored_classification(classification(score_pct), complexity, fi)
132
134
  {
133
135
  path: file,
134
136
  score: rounded_score,
@@ -242,9 +244,9 @@ module StudFinder
242
244
  end
243
245
  end
244
246
 
245
- def classification(score)
246
- return 'trunk' if score >= @trunk_threshold / 100.0
247
- return 'branch' if score >= @branch_threshold / 100.0
247
+ def classification(score_pct)
248
+ return 'trunk' if score_pct >= @trunk_threshold / 100.0
249
+ return 'branch' if score_pct >= @branch_threshold / 100.0
248
250
 
249
251
  'leaf'
250
252
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StudFinder
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stud-finder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - bazfer
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-07-11 00:00:00.000000000 Z
@@ -180,7 +180,7 @@ metadata:
180
180
  changelog_uri: https://github.com/bazfer/stud-finder/blob/main/CHANGELOG.md
181
181
  bug_tracker_uri: https://github.com/bazfer/stud-finder/issues
182
182
  rubygems_mfa_required: 'true'
183
- post_install_message:
183
+ post_install_message:
184
184
  rdoc_options: []
185
185
  require_paths:
186
186
  - lib
@@ -195,8 +195,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
195
195
  - !ruby/object:Gem::Version
196
196
  version: '0'
197
197
  requirements: []
198
- rubygems_version: 3.5.22
199
- signing_key:
198
+ rubygems_version: 3.4.10
199
+ signing_key:
200
200
  specification_version: 4
201
201
  summary: Rank files by structural risk in Ruby and JavaScript/TypeScript codebases.
202
202
  test_files: []