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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/README.md +127 -34
- data/SIGNALS.md +143 -0
- data/lib/stud_finder/churn.rb +10 -1
- data/lib/stud_finder/cli.rb +230 -46
- data/lib/stud_finder/complexity.rb +47 -8
- data/lib/stud_finder/coverage/cobertura.rb +1 -1
- data/lib/stud_finder/coverage/lcov.rb +1 -1
- data/lib/stud_finder/coverage/resultset.rb +1 -1
- data/lib/stud_finder/dispersion_warnings.rb +20 -0
- data/lib/stud_finder/edges.rb +1 -0
- data/lib/stud_finder/fan_in.rb +35 -6
- data/lib/stud_finder/file_collector.rb +2 -1
- data/lib/stud_finder/js_fan_in.rb +49 -8
- data/lib/stud_finder/loc_counter.rb +22 -0
- data/lib/stud_finder/newness.rb +306 -0
- data/lib/stud_finder/rails_inference.rb +172 -0
- data/lib/stud_finder/scorer.rb +128 -34
- data/lib/stud_finder/temporal_coupling.rb +33 -6
- data/lib/stud_finder/version.rb +1 -1
- metadata +11 -8
- data/PRODUCT.md +0 -172
- data/VISION.md +0 -151
data/lib/stud_finder/scorer.rb
CHANGED
|
@@ -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 = {
|
|
8
|
-
|
|
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,
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
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,
|
|
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:
|
|
113
|
-
classification:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
return
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/stud_finder/version.rb
CHANGED
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.
|
|
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-
|
|
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
|
-
-
|
|
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.
|
|
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.*
|