stud-finder 0.1.0 → 0.3.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.
@@ -1,29 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'dispersion_warnings'
3
4
  require_relative 'normalizer'
4
5
 
5
6
  module StudFinder
7
+ # rubocop:disable Metrics/ClassLength
6
8
  class Scorer
7
- DEFAULT_WEIGHTS = { fan_in: 0.25, fan_out: 0.10, complexity: 0.25, churn: 0.25, coverage: 0.15 }.freeze
8
- RENORMALIZED_KEYS = %i[fan_in fan_out complexity churn].freeze
9
+ DEFAULT_WEIGHTS = {
10
+ fan_in: 0.19, fan_out: 0.095, complexity: 0.2375, churn: 0.2375, coverage: 0.095,
11
+ interaction: 0.095, coupling: 0.05
12
+ }.freeze
13
+ BASE_WEIGHT_KEYS = %i[fan_in fan_out complexity churn].freeze
14
+ WEIGHT_KEYS = %i[fan_in fan_out complexity churn coverage interaction coupling].freeze
15
+ COMPLEXITY_FLOOR = 15
16
+ FAN_IN_FLOOR = 25
9
17
 
10
18
  class ValidationError < StandardError; end
11
19
 
12
- attr_reader :normalized_weights
20
+ attr_reader :normalized_weights, :warnings
13
21
 
14
- def initialize(files:, fan_in:, fan_out:, complexity:, churn:, churn_lines: nil, coverage: nil,
15
- weights: DEFAULT_WEIGHTS, branch_threshold: 50, trunk_threshold: 85, coupling: nil)
22
+ def initialize(files:, fan_in:, fan_out:, complexity:, churn:, churn_lines: nil, loc: nil, loc_pct: nil,
23
+ coverage: nil, weights: DEFAULT_WEIGHTS, branch_threshold: 50, trunk_threshold: 85, coupling: nil)
16
24
  @files = files
17
25
  @fan_in = fan_in
18
26
  @fan_out = fan_out
19
27
  @complexity = complexity
20
28
  @churn = churn
21
29
  @churn_lines = churn_lines || churn
30
+ @loc = loc || @files.to_h { |file| [file, 0] }
31
+ @loc_pct = loc_pct
22
32
  @coverage = coverage
23
33
  @weights = weights
24
34
  @branch_threshold = branch_threshold
25
35
  @trunk_threshold = trunk_threshold
26
36
  @coupling = coupling
37
+ @warnings = []
27
38
  validate!
28
39
  @normalized_weights = normalize_weights
29
40
  end
@@ -34,9 +45,12 @@ module StudFinder
34
45
  fan_out: Normalizer.percentile_rank(@fan_out, @files),
35
46
  complexity: Normalizer.percentile_rank(@complexity, @files),
36
47
  churn: composite_churn_pct,
48
+ loc: @loc_pct || Normalizer.percentile_rank(@loc, @files),
37
49
  instability: instability_pct,
38
- coupling: coupling_pct
50
+ coupling: coupling_pct,
51
+ coverage: coverage_risk_pct
39
52
  }
53
+ @warnings = insufficient_dispersion_warnings(pcts)
40
54
 
41
55
  rows = @files.each_with_index.map do |file, index|
42
56
  score = weighted_score(file, pcts)
@@ -58,34 +72,32 @@ module StudFinder
58
72
  end
59
73
 
60
74
  def normalize_weights
61
- active_total = RENORMALIZED_KEYS.sum { |key| @weights.fetch(key, 0.0) }
62
- if !coverage_available? && active_total <= 0.0
75
+ active_keys = BASE_WEIGHT_KEYS.dup
76
+ active_keys += %i[coverage interaction] if coverage_available?
77
+ active_keys << :coupling if coupling_available?
78
+
79
+ active_total = active_keys.sum { |key| @weights.fetch(key, 0.0) }
80
+ if active_total <= 0.0
63
81
  raise ValidationError,
64
82
  'Error: active weights must be greater than 0.0.'
65
83
  end
66
84
 
67
- @active_weights = if active_total > 0.0
68
- RENORMALIZED_KEYS.to_h { |key| [key, @weights.fetch(key, 0.0) / active_total] }
69
- .merge(coverage: nil)
70
- else
71
- RENORMALIZED_KEYS.to_h { |key| [key, 0.0] }.merge(coverage: nil)
72
- end
73
-
74
- return @weights if coverage_available?
75
-
76
- @active_weights
85
+ WEIGHT_KEYS.to_h do |key|
86
+ [key, active_keys.include?(key) ? @weights.fetch(key, 0.0) / active_total : nil]
87
+ end
77
88
  end
78
89
 
79
90
  def weighted_score(file, pcts)
80
- return active_weights_score(file, pcts) unless coverage_available?
91
+ score = structural_score(@normalized_weights, file, pcts)
92
+ score += @normalized_weights[:coverage] * pcts[:coverage].fetch(file) if coverage_available?
93
+ score += interaction_score(file, pcts) if coverage_available?
94
+ score += @normalized_weights[:coupling] * pcts[:coupling].fetch(file) if coupling_available?
81
95
 
82
- file_coverage = @coverage.fetch(file, 0.0)
83
- structural_score(@normalized_weights, file, pcts) +
84
- (@normalized_weights[:coverage] * (1.0 - file_coverage))
96
+ score.clamp(0.0, 1.0)
85
97
  end
86
98
 
87
- def active_weights_score(file, pcts)
88
- structural_score(@active_weights, file, pcts)
99
+ def interaction_score(file, pcts)
100
+ @normalized_weights.fetch(:interaction, 0.0) * pcts[:fan_in].fetch(file) * pcts[:coverage].fetch(file)
89
101
  end
90
102
 
91
103
  def structural_score(weights, file, pcts)
@@ -98,32 +110,45 @@ module StudFinder
98
110
  def composite_churn_pct
99
111
  count_pct = Normalizer.percentile_rank(@churn, @files)
100
112
  line_pct = Normalizer.percentile_rank(@churn_lines, @files)
113
+ @churn_signal_raw = @files.to_h do |file|
114
+ [file, ((0.5 * count_pct.fetch(file)) + (0.5 * line_pct.fetch(file))).round(10)]
115
+ end
101
116
 
117
+ Normalizer.percentile_rank(@churn_signal_raw, @files)
118
+ end
119
+
120
+ def churn_dispersion_raw_source
102
121
  @files.to_h do |file|
103
- [file, (0.5 * count_pct.fetch(file)) + (0.5 * line_pct.fetch(file))]
122
+ [file, @churn.fetch(file, 0).to_f.abs + @churn_lines.fetch(file, 0).to_f.abs]
104
123
  end
105
124
  end
106
125
 
107
126
  def result_row(file, score, pcts)
108
127
  fi = @fan_in.fetch(file, 0).to_i
109
128
  fo = @fan_out.fetch(file, 0).to_i
129
+ rounded_score = score.round(4)
130
+ complexity = @complexity.fetch(file, 0).to_i
131
+ floored_class, floor_escalation = floored_classification(classification(rounded_score), complexity, fi)
110
132
  {
111
133
  path: file,
112
- score: score.round(4),
113
- classification: classification(pcts[:fan_in].fetch(file)),
134
+ score: rounded_score,
135
+ classification: floored_class,
136
+ escalation: floor_escalation,
114
137
  fan_in: fi,
115
138
  fan_in_pct: pcts[:fan_in].fetch(file).round(4),
116
139
  fan_out: fo,
117
140
  fan_out_pct: pcts[:fan_out].fetch(file).round(4),
118
141
  instability: instability(fi, fo),
119
142
  instability_pct: pcts[:instability].fetch(file).round(4),
120
- complexity: @complexity.fetch(file, 0).to_i,
143
+ complexity: complexity,
121
144
  complexity_pct: pcts[:complexity].fetch(file).round(4),
122
145
  churn_commits: @churn.fetch(file, 0).to_i,
123
146
  churn_lines: @churn_lines.fetch(file, 0).to_i,
124
147
  churn_pct: pcts[:churn].fetch(file).round(4),
148
+ loc: @loc.fetch(file, 0).to_i,
149
+ loc_pct: pcts[:loc].fetch(file).round(4),
125
150
  **coupling_fields(file, pcts),
126
- coverage: coverage_available? ? @coverage.fetch(file, 0.0).round(4) : nil
151
+ coverage: coverage_value(file)
127
152
  }
128
153
  end
129
154
 
@@ -150,22 +175,91 @@ module StudFinder
150
175
  end
151
176
 
152
177
  def coupling_pct
153
- values = @files.to_h do |file|
178
+ Normalizer.percentile_rank(coupling_values, @files)
179
+ end
180
+
181
+ def coupling_values
182
+ @files.to_h do |file|
154
183
  partner = @coupling&.fetch(file, nil)
155
184
  [file, partner ? partner.fetch(:max_coupling, 0.0).to_f : 0.0]
156
185
  end
157
- Normalizer.percentile_rank(values, @files)
186
+ end
187
+
188
+ def coverage_risk_pct
189
+ return {} unless coverage_available?
190
+
191
+ Normalizer.percentile_rank(coverage_risk_values, @files)
192
+ end
193
+
194
+ def coverage_risk_values
195
+ return {} unless coverage_available?
196
+
197
+ @files.to_h { |file| [file, 1.0 - @coverage.fetch(file, 0.0)] }
198
+ end
199
+
200
+ def interaction_values(pcts)
201
+ return {} unless coverage_available?
202
+
203
+ @files.to_h { |file| [file, pcts[:fan_in].fetch(file) * pcts[:coverage].fetch(file)] }
204
+ end
205
+
206
+ def insufficient_dispersion_warnings(pcts)
207
+ raw_sources = {
208
+ fan_in: @fan_in,
209
+ fan_out: @fan_out,
210
+ complexity: @complexity,
211
+ churn: churn_dispersion_raw_source
212
+ }
213
+ if coverage_available?
214
+ raw_sources[:coverage] = coverage_risk_values
215
+ raw_sources[:interaction] = interaction_values(pcts)
216
+ pcts = pcts.merge(interaction: Normalizer.percentile_rank(raw_sources[:interaction], @files))
217
+ end
218
+ raw_sources[:coupling] = coupling_values if coupling_available?
219
+
220
+ DispersionWarnings.build(files: @files, pcts: pcts, raw_sources: raw_sources)
221
+ end
222
+
223
+ def coverage_value(file)
224
+ return nil unless coverage_available?
225
+ return '—' unless @coverage.key?(file)
226
+
227
+ @coverage.fetch(file).round(4)
158
228
  end
159
229
 
160
230
  def coverage_available?
161
231
  !@coverage.nil?
162
232
  end
163
233
 
164
- def classification(fan_in_pct)
165
- return 'trunk' if fan_in_pct >= @trunk_threshold / 100.0
166
- return 'branch' if fan_in_pct >= @branch_threshold / 100.0
234
+ def coupling_available?
235
+ return false if @coupling.nil? || @coupling.empty?
236
+
237
+ @files.any? do |file|
238
+ partner = @coupling.fetch(file, nil)
239
+ partner.respond_to?(:fetch) && partner.fetch(:max_coupling, 0.0).to_f.positive?
240
+ rescue ArgumentError, TypeError
241
+ false
242
+ end
243
+ end
244
+
245
+ def classification(score)
246
+ return 'trunk' if score >= @trunk_threshold / 100.0
247
+ return 'branch' if score >= @branch_threshold / 100.0
167
248
 
168
249
  'leaf'
169
250
  end
251
+
252
+ def floored_classification(classification, complexity, fan_in)
253
+ return [classification, ''] unless classification == 'leaf'
254
+
255
+ if complexity >= COMPLEXITY_FLOOR
256
+ %w[branch complexity_floor]
257
+ elsif fan_in >= FAN_IN_FLOOR
258
+ %w[branch fan_in_floor]
259
+ else
260
+ [classification, '']
261
+ end
262
+ end
170
263
  end
264
+ # rubocop:enable Metrics/ClassLength
171
265
  end
@@ -8,22 +8,23 @@ module StudFinder
8
8
 
9
9
  SHA_PATTERN = /\A[0-9a-f]{40}\z/
10
10
 
11
- def initialize(repo_path:, files:, days:, min_co_changes: 5, coupling_threshold: 0.30)
11
+ def initialize(repo_path:, files:, days:, min_co_changes: 5, coupling_threshold: 0.30, max_commit_files: 50)
12
12
  @repo_path = File.expand_path(repo_path)
13
13
  @file_set = files.to_h { |f| [f, true] }
14
14
  @days = days
15
15
  @min_co_changes = min_co_changes
16
16
  @coupling_threshold = coupling_threshold
17
+ @max_commit_files = max_commit_files
17
18
  end
18
19
 
19
20
  def call
20
21
  stdout, _err, status = git_log
21
22
  return Result.new(pairs: {}, warnings: ['git_error']) unless status.success?
22
23
 
23
- commits = parse_commits(stdout)
24
+ commits, skipped_bulk_commits = parse_commits(stdout)
24
25
  co_matrix = build_co_change_matrix(commits)
25
26
  own_changes = build_own_changes(commits)
26
- Result.new(pairs: build_pairs(co_matrix, own_changes), warnings: [])
27
+ Result.new(pairs: build_pairs(co_matrix, own_changes), warnings: warnings(skipped_bulk_commits))
27
28
  rescue Errno::ENOENT
28
29
  Result.new(pairs: {}, warnings: ['git_not_found'])
29
30
  end
@@ -44,10 +45,11 @@ module StudFinder
44
45
  def parse_commits(stdout)
45
46
  commits = []
46
47
  current = nil
48
+ skipped_bulk_commits = 0
47
49
  stdout.each_line do |raw|
48
50
  line = raw.chomp
49
51
  if SHA_PATTERN.match?(line)
50
- commits << current if current&.any?
52
+ skipped_bulk_commits += append_commit(commits, current)
51
53
  current = []
52
54
  elsif !line.empty? && current
53
55
  relative = normalize_path(line)
@@ -56,8 +58,17 @@ module StudFinder
56
58
  current << relative if @file_set[relative] && !current.include?(relative)
57
59
  end
58
60
  end
59
- commits << current if current&.any?
60
- commits
61
+ skipped_bulk_commits += append_commit(commits, current)
62
+ [commits, skipped_bulk_commits]
63
+ end
64
+
65
+ def append_commit(commits, files)
66
+ return 0 unless files&.any?
67
+
68
+ return 1 if @max_commit_files.positive? && files.length > @max_commit_files
69
+
70
+ commits << files
71
+ 0
61
72
  end
62
73
 
63
74
  def build_co_change_matrix(commits)
@@ -97,8 +108,24 @@ module StudFinder
97
108
  end
98
109
 
99
110
  def normalize_path(path)
111
+ path = renamed_path(path)
100
112
  absolute = File.expand_path(path, @repo_path)
101
113
  absolute.start_with?("#{@repo_path}/") ? absolute.delete_prefix("#{@repo_path}/") : path
102
114
  end
115
+
116
+ def renamed_path(path)
117
+ return path unless path.include?(' => ')
118
+
119
+ path.sub(/\{[^{}]* => ([^{}]*)\}/, '\\1').then do |renamed|
120
+ renamed == path ? path.split(' => ', 2).last : renamed
121
+ end
122
+ end
123
+
124
+ def warnings(skipped_bulk_commits)
125
+ return [] if skipped_bulk_commits.zero?
126
+
127
+ [{ code: 'temporal_coupling_bulk_commits_skipped', count: skipped_bulk_commits,
128
+ max_commit_files: @max_commit_files }]
129
+ end
103
130
  end
104
131
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StudFinder
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stud-finder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - bazfer
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-03 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: csv
@@ -145,9 +145,8 @@ extra_rdoc_files: []
145
145
  files:
146
146
  - CHANGELOG.md
147
147
  - LICENSE
148
- - PRODUCT.md
149
148
  - README.md
150
- - VISION.md
149
+ - SIGNALS.md
151
150
  - bin/stud-finder
152
151
  - lib/stud-finder.rb
153
152
  - lib/stud_finder.rb
@@ -159,12 +158,16 @@ files:
159
158
  - lib/stud_finder/coverage/lcov.rb
160
159
  - lib/stud_finder/coverage/resultset.rb
161
160
  - lib/stud_finder/diff.rb
161
+ - lib/stud_finder/dispersion_warnings.rb
162
162
  - lib/stud_finder/edges.rb
163
163
  - lib/stud_finder/fan_in.rb
164
164
  - lib/stud_finder/file_collector.rb
165
165
  - lib/stud_finder/js_complexity.rb
166
166
  - lib/stud_finder/js_fan_in.rb
167
+ - lib/stud_finder/loc_counter.rb
168
+ - lib/stud_finder/newness.rb
167
169
  - lib/stud_finder/normalizer.rb
170
+ - lib/stud_finder/rails_inference.rb
168
171
  - lib/stud_finder/scorer.rb
169
172
  - lib/stud_finder/temporal_coupling.rb
170
173
  - lib/stud_finder/version.rb
@@ -177,7 +180,7 @@ metadata:
177
180
  changelog_uri: https://github.com/bazfer/stud-finder/blob/main/CHANGELOG.md
178
181
  bug_tracker_uri: https://github.com/bazfer/stud-finder/issues
179
182
  rubygems_mfa_required: 'true'
180
- post_install_message:
183
+ post_install_message:
181
184
  rdoc_options: []
182
185
  require_paths:
183
186
  - lib
@@ -192,8 +195,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
192
195
  - !ruby/object:Gem::Version
193
196
  version: '0'
194
197
  requirements: []
195
- rubygems_version: 3.4.10
196
- signing_key:
198
+ rubygems_version: 3.5.22
199
+ signing_key:
197
200
  specification_version: 4
198
201
  summary: Rank files by structural risk in Ruby and JavaScript/TypeScript codebases.
199
202
  test_files: []
data/PRODUCT.md DELETED
@@ -1,172 +0,0 @@
1
- # stud-finder
2
-
3
- **Find the files that will hurt you before they do.**
4
-
5
- ---
6
-
7
- ## The Problem
8
-
9
- Every codebase has load-bearing walls. Files that dozens of other files depend on. Files that change every sprint. Files whose complexity means one wrong edit cascades into a day of debugging.
10
-
11
- Most teams discover these files the hard way — after the incident.
12
-
13
- stud-finder surfaces them before you touch them.
14
-
15
- ---
16
-
17
- ## What It Does
18
-
19
- stud-finder analyzes a codebase and produces a ranked list of every file, scored by structural risk. Run it before a sprint, before a refactor, before a code review. Know which files deserve extra attention before anyone writes a line.
20
-
21
- ```
22
- $ stud-finder ./my-rails-app
23
-
24
- FILE SCORE LABEL FAN_IN COMPLEXITY CHURN_COMMITS CHURN_LINES CHURN_PCT COVERAGE
25
- app/models/user.rb 0.91 trunk 0.97 0.42 0.88 0.91 0.89 0.14
26
- app/services/payment_service.rb 0.84 trunk 0.78 0.91 0.71 0.68 0.69 0.22
27
- app/controllers/orders_controller 0.73 branch 0.61 0.65 0.74 0.77 0.75 0.31
28
- ...
29
- ```
30
-
31
- Three labels, one decision framework:
32
-
33
- - **Trunk** — load-bearing. Change with care. High review bar.
34
- - **Branch** — meaningful coupling. Worth a second look.
35
- - **Leaf** — isolated. Lower risk. Move fast here.
36
-
37
- ---
38
-
39
- ## The Five Signals
40
-
41
- Each file is scored on up to five independently measured signals, each grounded in decades of software engineering research. M7 introduced scored `fan_out`, so the composite now considers both incoming blast radius and outgoing coupling burden.
42
-
43
- ### 1. Fan-in — Blast Radius
44
-
45
- *"How many files depend on this one?"*
46
-
47
- 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.
48
-
49
- stud-finder builds the dependency graph via static analysis — Zeitwerk constant mapping for Rails, falling back to AST scanning. No runtime instrumentation required.
50
-
51
- **Weight: 25% of total score**
52
-
53
- ### 2. Fan-out — Coupling Burden
54
-
55
- *"How many files does this one depend on?"*
56
-
57
- 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. In M7, fan-out moved from an informational column into the scored model.
58
-
59
- **Weight: 10% of total score**
60
-
61
- ### 3. Complexity — Cognitive Load
62
-
63
- *"How hard is this file to reason about?"*
64
-
65
- 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.
66
-
67
- Computed via RuboCop's static analysis engine. No manual annotation.
68
-
69
- **Weight: 25% of total score**
70
-
71
- ### 4. Churn — Change Velocity
72
-
73
- *"How often is this file being touched, and how much?"*
74
-
75
- A composite signal: 50% commit frequency + 50% lines changed, both percentile-ranked across the full codebase. 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.
76
-
77
- Computed from git history over a configurable window (default: 180 days). Language-agnostic.
78
-
79
- **Weight: 25% of total score**
80
-
81
- ### 5. Coverage — Safety Net
82
-
83
- *"If this file breaks, will tests catch it?"*
84
-
85
- Low coverage on a high-risk file is compounded danger — no blast-radius detection, no complexity safety net, no test catch. Coverage is measured as an inverse (0% coverage = maximum penalty), and files absent from the coverage report are handled via coverage fallback rather than penalized falsely.
86
-
87
- Supports Cobertura XML (RSpec + SimpleCov), LCOV (Jest, lcov), and SimpleCov JSON resultsets. Auto-detected by file extension.
88
-
89
- **Weight: 15% of total score** (optional — runs as 4-factor model when no coverage report provided)
90
-
91
- ---
92
-
93
- ## The Score
94
-
95
- Each 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.
96
-
97
- The composite score (0.0–1.0) weights the signals and produces the ranked output. Classification thresholds are configurable.
98
-
99
- **4-factor formula (no coverage):**
100
- ```
101
- score = 0.2941 × fan_in_pct + 0.1176 × fan_out_pct + 0.2941 × complexity_pct + 0.2941 × churn_pct
102
- ```
103
-
104
- **5-factor formula (with coverage):**
105
- ```
106
- score = 0.25 × fan_in_pct + 0.10 × fan_out_pct + 0.25 × complexity_pct + 0.25 × churn_pct + 0.15 × (1 − coverage)
107
- ```
108
-
109
- ---
110
-
111
- ## Use Cases
112
-
113
- **Pre-sprint risk assessment** — before planning, run stud-finder against the files your team is about to touch. Trunk files get more review time budgeted.
114
-
115
- **Refactor prioritization** — you have ten candidates for cleanup. stud-finder tells you which ones have the highest blast radius if the refactor goes wrong.
116
-
117
- **Onboarding** — new engineer joining the team. Here's the trunk map. These are the files you ask before changing.
118
-
119
- **PR review triage** — reviewer bandwidth is finite. Direct it at the files that matter.
120
-
121
- **Architecture health monitoring** — run stud-finder weekly. Watch if trunk is growing or shrinking. Trunk growth is a coupling smell.
122
-
123
- ---
124
-
125
- ## Technical Foundation
126
-
127
- - **Language:** Ruby gem, zero runtime instrumentation
128
- - **Static analysis:** RuboCop (complexity), Zeitwerk + custom AST (fan_in), git log (churn)
129
- - **Coverage formats:** Cobertura XML, LCOV, SimpleCov JSON — auto-detected
130
- - **Output formats:** table (default), JSON, CSV, Markdown
131
- - **Configuration:** CLI flags for weights, thresholds, excludes, churn window
132
- - **Requires:** Ruby, RuboCop, git. Nothing else for Ruby analysis.
133
-
134
- ---
135
-
136
- ## Roadmap
137
-
138
- **M1–M3 — Complete**
139
- Initial composite score (Ruby + JS/TS). `--diff-base` / `--only` filter for per-PR output. Per-PR CircleCI integration — stud-finder runs on every PR, posts ranked artifact and PR comment. Non-blocking.
140
-
141
- **M4 — Complete: fan-out, instability, `stud-finder edges`**
142
- Fan-out (efferent coupling) and instability (`fan_out / (fan_in + fan_out)`) added to every row in the core output. New `stud-finder edges FILE` subcommand emits the actual dependency edge list for a specific file — dependents and dependencies, both sorted by risk score. Shifts the output from "this file scores high" to "here are the specific files in the blast radius."
143
-
144
- **M5 — Sentry integration**
145
- Connect to the Sentry REST API. Parse production stack traces, aggregate error frequency by source file. A runtime signal: not structural approximation but observed failure in production. `--sentry-token`, `--sentry-org`, `--sentry-project` flags. Percentile-ranked and added to the composite score.
146
-
147
- **M6 — Temporal coupling**
148
- Co-change frequency from git history: file pairs that change together more often than expected by chance. Surfaces hidden coupling that static analysis cannot see — implicit contracts, shared state, callback side effects. Observed behavior, not structural approximation.
149
-
150
- **Pinned — Producer-consumer dependency mapping**
151
- Explicitly surfacing which components consume data produced by other components, flagging pairs with high temporal coupling but low static coupling as candidates for explicit contract documentation.
152
-
153
- **M7 — Complete: scored fan-out + rankings**
154
- Scored fan-out introduced as the fifth risk signal with a 10% default weight.
155
-
156
- **M7 follow-up — Merge-to-staging S3 timeline (lowest priority)**
157
- Full stud-finder run on each merge to the mainline branch → JSON → S3, keyed by timestamp + commit SHA. Durable risk-over-time feed for trend analysis.
158
-
159
- **Future — Toward a validated risk estimator**
160
- Calibrated weights back-tested against bug history. Historical bug density as a direct input metric. Change-scope awareness (per-PR risk = file-risk × change-magnitude × change-type). Test quality beyond line coverage. See `VISION.md` for the full analysis.
161
-
162
- ---
163
-
164
- ## Why stud-finder?
165
-
166
- In construction, a stud finder locates the load-bearing structure inside a wall before you drill. You don't guess — you know exactly where the structure is.
167
-
168
- Same principle. Before you refactor, before you sprint, before you review — know where the load-bearing code is.
169
-
170
- ---
171
-
172
- *Built by Artífice. Ruby gem. Open to collaboration.*