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/cli.rb
CHANGED
|
@@ -17,6 +17,8 @@ require_relative 'fan_in'
|
|
|
17
17
|
require_relative 'js_fan_in'
|
|
18
18
|
require_relative 'js_complexity'
|
|
19
19
|
require_relative 'file_collector'
|
|
20
|
+
require_relative 'loc_counter'
|
|
21
|
+
require_relative 'newness'
|
|
20
22
|
require_relative 'scorer'
|
|
21
23
|
require_relative 'version'
|
|
22
24
|
|
|
@@ -24,20 +26,23 @@ module StudFinder
|
|
|
24
26
|
# rubocop:disable Metrics/ClassLength
|
|
25
27
|
class CLI
|
|
26
28
|
OUTPUT_FORMATS = %w[table json markdown csv].freeze
|
|
29
|
+
CLASS_RANK = { 'trunk' => 3, 'branch' => 2, 'leaf' => 1 }.freeze
|
|
27
30
|
RESULT_COLUMNS = %w[
|
|
28
|
-
rank language file score class fan_in fan_in_pct fan_out fan_out_pct
|
|
29
|
-
complexity_pct churn_commits churn_lines churn_pct
|
|
30
|
-
coupling_pct coverage
|
|
31
|
+
rank language file score evidence class new_file age_days escalation fan_in fan_in_pct fan_out fan_out_pct
|
|
32
|
+
instability instability_pct complexity complexity_pct churn_commits churn_lines churn_pct loc loc_pct max_coupling
|
|
33
|
+
max_coupling_partner coupling_partners coupling_pct coverage
|
|
31
34
|
].freeze
|
|
32
35
|
MARKDOWN_COLUMNS = %w[
|
|
33
|
-
rank language file score class fan_in fan_out fan_out_pct instability
|
|
34
|
-
|
|
36
|
+
rank language file score evidence class new_file age_days escalation fan_in fan_out fan_out_pct instability
|
|
37
|
+
complexity
|
|
38
|
+
churn_commits churn_lines churn_pct max_coupling max_coupling_partner coupling_partners coupling_pct coverage
|
|
35
39
|
].freeze
|
|
36
40
|
WEIGHT_KEYS = %i[fan_in fan_out complexity churn coverage].freeze
|
|
41
|
+
OPTIONAL_WEIGHT_KEYS = %i[interaction coupling].freeze
|
|
37
42
|
DEFAULT_OPTIONS = {
|
|
38
43
|
output: 'table',
|
|
39
44
|
churn_days: 180,
|
|
40
|
-
weights:
|
|
45
|
+
weights: StudFinder::Scorer::DEFAULT_WEIGHTS,
|
|
41
46
|
custom_weights: false,
|
|
42
47
|
trunk_threshold: 85,
|
|
43
48
|
branch_threshold: 50,
|
|
@@ -48,15 +53,20 @@ module StudFinder
|
|
|
48
53
|
ruby_coverage_path: nil,
|
|
49
54
|
js_coverage_path: nil,
|
|
50
55
|
js_timeout: 60,
|
|
56
|
+
rails_inference: true,
|
|
51
57
|
diff_base: nil,
|
|
52
58
|
only_paths: nil,
|
|
53
59
|
filter_set: nil,
|
|
54
60
|
coupling_threshold: 0.30,
|
|
55
61
|
coupling_min_commits: 5,
|
|
62
|
+
coupling_max_commit_files: 50,
|
|
63
|
+
newness: true,
|
|
64
|
+
new_file_days: StudFinder::Newness::DEFAULT_DAYS,
|
|
65
|
+
new_file_min_commits: StudFinder::Newness::DEFAULT_MIN_COMMITS,
|
|
56
66
|
cli_warnings: []
|
|
57
67
|
}.freeze
|
|
58
68
|
|
|
59
|
-
Analysis = Struct.new(:files, :fan_in, :fan_out, :edges, :complexity, :churn_commits, :churn_lines, :coverage,
|
|
69
|
+
Analysis = Struct.new(:files, :fan_in, :fan_out, :edges, :complexity, :churn_commits, :churn_lines, :loc, :coverage,
|
|
60
70
|
:coverage_available, :skipped_files, :warnings, :rows, :weights, keyword_init: true)
|
|
61
71
|
Report = Struct.new(:ruby, :javascript, :warnings, keyword_init: true)
|
|
62
72
|
|
|
@@ -112,7 +122,7 @@ module StudFinder
|
|
|
112
122
|
0
|
|
113
123
|
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::InvalidArgument, ValidationError,
|
|
114
124
|
FileCollector::Error, Churn::Error, Complexity::Error, Coverage::Cobertura::Error, Coverage::Detector::Error,
|
|
115
|
-
Coverage::Lcov::Error, Coverage::Resultset::Error, Diff::Error, Scorer::ValidationError => e
|
|
125
|
+
Coverage::Lcov::Error, Coverage::Resultset::Error, Diff::Error, Newness::Error, Scorer::ValidationError => e
|
|
116
126
|
@stderr.puts e.message
|
|
117
127
|
1
|
|
118
128
|
end
|
|
@@ -132,7 +142,8 @@ module StudFinder
|
|
|
132
142
|
files: result.files,
|
|
133
143
|
days: @options[:churn_days],
|
|
134
144
|
min_co_changes: @options[:coupling_min_commits],
|
|
135
|
-
coupling_threshold: @options[:coupling_threshold]
|
|
145
|
+
coupling_threshold: @options[:coupling_threshold],
|
|
146
|
+
max_commit_files: @options[:coupling_max_commit_files]
|
|
136
147
|
).call
|
|
137
148
|
|
|
138
149
|
Edges.new(
|
|
@@ -143,7 +154,7 @@ module StudFinder
|
|
|
143
154
|
coupling_threshold: @options[:coupling_threshold],
|
|
144
155
|
stdout: @stdout, stderr: @stderr
|
|
145
156
|
).call
|
|
146
|
-
rescue FileCollector::Error, Churn::Error, Complexity::Error, Scorer::ValidationError => e
|
|
157
|
+
rescue FileCollector::Error, Churn::Error, Complexity::Error, Newness::Error, Scorer::ValidationError => e
|
|
147
158
|
@stderr.puts e.message
|
|
148
159
|
1
|
|
149
160
|
end
|
|
@@ -164,10 +175,22 @@ module StudFinder
|
|
|
164
175
|
opts.on('--churn-days N', Integer, 'Commit lookback window in days (default: 180)') do |value|
|
|
165
176
|
@options[:churn_days] = value
|
|
166
177
|
end
|
|
167
|
-
opts.on('--weights WEIGHTS',
|
|
178
|
+
opts.on('--weights WEIGHTS',
|
|
179
|
+
'fan_in:F,fan_out:O,complexity:C,churn:H,coverage:V[,interaction:I][,coupling:P]') do |value|
|
|
168
180
|
@options[:weights] = parse_weights(value)
|
|
169
181
|
@options[:custom_weights] = true
|
|
170
182
|
end
|
|
183
|
+
opts.on('--interaction-weight N', Float,
|
|
184
|
+
'fan_in × coverage_risk interaction weight (requires coverage)') do |value|
|
|
185
|
+
raise ValidationError, 'Error: weight values must be between 0.0 and 1.0.' if value.negative? || value > 1.0
|
|
186
|
+
|
|
187
|
+
@options[:weights][:interaction] = value
|
|
188
|
+
end
|
|
189
|
+
opts.on('--coupling-weight N', Float, 'temporal coupling weight') do |value|
|
|
190
|
+
raise ValidationError, 'Error: weight values must be between 0.0 and 1.0.' if value.negative? || value > 1.0
|
|
191
|
+
|
|
192
|
+
@options[:weights][:coupling] = value
|
|
193
|
+
end
|
|
171
194
|
opts.on('--ruby-coverage PATH', 'Path to a Ruby coverage report (.xml, .info, .json)') do |value|
|
|
172
195
|
@options[:ruby_coverage_path] = value
|
|
173
196
|
end
|
|
@@ -182,12 +205,15 @@ module StudFinder
|
|
|
182
205
|
opts.on('--js-timeout N', Integer, 'dependency-cruiser timeout in seconds (default: 60)') do |value|
|
|
183
206
|
@options[:js_timeout] = value
|
|
184
207
|
end
|
|
208
|
+
opts.on('--no-rails-inference', 'Disable Rails association/string fan-in inference') do
|
|
209
|
+
@options[:rails_inference] = false
|
|
210
|
+
end
|
|
185
211
|
opts.on('--trunk-threshold N', Integer,
|
|
186
|
-
'
|
|
212
|
+
'score cutoff for trunk classification, integer 1-99 (default: 85)') do |value|
|
|
187
213
|
@options[:trunk_threshold] = value
|
|
188
214
|
end
|
|
189
215
|
opts.on('--branch-threshold N', Integer,
|
|
190
|
-
'
|
|
216
|
+
'score cutoff for branch classification, integer 1-99 (default: 50)') do |value|
|
|
191
217
|
@options[:branch_threshold] = value
|
|
192
218
|
end
|
|
193
219
|
opts.on('--exclude PATTERN', 'Exclude glob pattern (repeatable)') do |value|
|
|
@@ -215,6 +241,22 @@ module StudFinder
|
|
|
215
241
|
'Minimum co-change count for edges output (default: 5)') do |value|
|
|
216
242
|
@options[:coupling_min_commits] = value
|
|
217
243
|
end
|
|
244
|
+
opts.on('--coupling-max-commit-files N', Integer,
|
|
245
|
+
'Skip temporal-coupling commits touching more than N files (default: 50, 0 = unlimited)') do |value|
|
|
246
|
+
@options[:coupling_max_commit_files] = value
|
|
247
|
+
end
|
|
248
|
+
opts.on('--new-file-days N', Integer,
|
|
249
|
+
'Treat files first committed within N days as new (default: 30, 0 disables age floor)') do |value|
|
|
250
|
+
@options[:new_file_days] = value
|
|
251
|
+
end
|
|
252
|
+
opts.on('--new-file-min-commits N', Integer,
|
|
253
|
+
'Treat files with fewer than N full-history commits as new ' \
|
|
254
|
+
'(default: 3, 0 disables count floor)') do |value|
|
|
255
|
+
@options[:new_file_min_commits] = value
|
|
256
|
+
end
|
|
257
|
+
opts.on('--no-newness', 'Disable new-file classification rules') do
|
|
258
|
+
@options[:newness] = false
|
|
259
|
+
end
|
|
218
260
|
opts.on('--verbose', 'Print suppressed per-file warnings to stderr') do
|
|
219
261
|
@options[:verbose] = true
|
|
220
262
|
end
|
|
@@ -230,6 +272,7 @@ module StudFinder
|
|
|
230
272
|
end
|
|
231
273
|
# rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/MethodLength
|
|
232
274
|
|
|
275
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
233
276
|
def parse_weights(value)
|
|
234
277
|
pairs = value.split(',').map do |entry|
|
|
235
278
|
key, raw = entry.split(':', 2)
|
|
@@ -242,18 +285,23 @@ module StudFinder
|
|
|
242
285
|
|
|
243
286
|
weights = pairs.to_h
|
|
244
287
|
missing = WEIGHT_KEYS - weights.keys
|
|
245
|
-
extra = weights.keys - WEIGHT_KEYS
|
|
288
|
+
extra = weights.keys - WEIGHT_KEYS - OPTIONAL_WEIGHT_KEYS
|
|
246
289
|
unless missing.empty? && extra.empty?
|
|
247
290
|
raise ValidationError,
|
|
248
291
|
'Error: weights must include fan_in, fan_out, complexity, churn, and coverage.'
|
|
249
292
|
end
|
|
250
293
|
|
|
294
|
+
weights[:interaction] = 0.0 unless weights.key?(:interaction)
|
|
295
|
+
weights[:coupling] = 0.05 unless weights.key?(:coupling)
|
|
296
|
+
|
|
251
297
|
out_of_range = weights.any? { |_key, weight| weight.negative? || weight > 1.0 }
|
|
252
298
|
raise ValidationError, 'Error: weight values must be between 0.0 and 1.0.' if out_of_range
|
|
253
299
|
|
|
254
300
|
weights
|
|
255
301
|
end
|
|
256
302
|
|
|
303
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
304
|
+
|
|
257
305
|
def validate_options!
|
|
258
306
|
validate_threshold!(:trunk_threshold)
|
|
259
307
|
validate_threshold!(:branch_threshold)
|
|
@@ -266,7 +314,12 @@ module StudFinder
|
|
|
266
314
|
raise ValidationError, 'Error: --churn-days must be positive.' if @options[:churn_days] <= 0
|
|
267
315
|
raise ValidationError, 'Error: --js-timeout must be positive.' if @options[:js_timeout] <= 0
|
|
268
316
|
|
|
317
|
+
validate_newness_options!
|
|
318
|
+
|
|
269
319
|
raise ValidationError, 'Error: --coupling-min-commits must be positive.' if @options[:coupling_min_commits] <= 0
|
|
320
|
+
if @options[:coupling_max_commit_files].negative?
|
|
321
|
+
raise ValidationError, 'Error: --coupling-max-commit-files must be zero or positive.'
|
|
322
|
+
end
|
|
270
323
|
unless (0.0..1.0).cover?(@options[:coupling_threshold])
|
|
271
324
|
raise ValidationError, 'Error: --coupling-threshold must be between 0.0 and 1.0.'
|
|
272
325
|
end
|
|
@@ -276,6 +329,13 @@ module StudFinder
|
|
|
276
329
|
validate_weights! if @options[:custom_weights]
|
|
277
330
|
end
|
|
278
331
|
|
|
332
|
+
def validate_newness_options!
|
|
333
|
+
raise ValidationError, 'Error: --new-file-days must be zero or positive.' if @options[:new_file_days].negative?
|
|
334
|
+
return unless @options[:new_file_min_commits].negative?
|
|
335
|
+
|
|
336
|
+
raise ValidationError, 'Error: --new-file-min-commits must be zero or positive.'
|
|
337
|
+
end
|
|
338
|
+
|
|
279
339
|
def validate_coverage_paths!
|
|
280
340
|
if @options[:ruby_coverage_path] && !File.file?(@options[:ruby_coverage_path])
|
|
281
341
|
raise ValidationError, "Error: coverage file not found: #{@options[:ruby_coverage_path]}"
|
|
@@ -309,9 +369,15 @@ module StudFinder
|
|
|
309
369
|
|
|
310
370
|
def validate_weights!
|
|
311
371
|
weights = @options[:weights]
|
|
372
|
+
out_of_range = weights.any? { |_key, weight| !weight.nil? && (weight.negative? || weight > 1.0) }
|
|
373
|
+
raise ValidationError, 'Error: weight values must be between 0.0 and 1.0.' if out_of_range
|
|
374
|
+
|
|
312
375
|
if weights[:coverage].positive? && !coverage_available?
|
|
313
376
|
raise ValidationError, 'Error: coverage weight must be 0.0 when no coverage data is provided.'
|
|
314
377
|
end
|
|
378
|
+
if weights.fetch(:interaction, 0.0).positive? && !coverage_available?
|
|
379
|
+
raise ValidationError, 'Error: interaction weight must be 0.0 when no coverage data is provided.'
|
|
380
|
+
end
|
|
315
381
|
|
|
316
382
|
active_sum = weights.values.sum
|
|
317
383
|
return if (active_sum - 1.0).abs <= 0.001
|
|
@@ -331,11 +397,24 @@ module StudFinder
|
|
|
331
397
|
files: files,
|
|
332
398
|
days: @options[:churn_days],
|
|
333
399
|
min_co_changes: @options[:coupling_min_commits],
|
|
334
|
-
coupling_threshold: @options[:coupling_threshold]
|
|
400
|
+
coupling_threshold: @options[:coupling_threshold],
|
|
401
|
+
max_commit_files: @options[:coupling_max_commit_files]
|
|
335
402
|
).call
|
|
403
|
+
record_coupling_warnings(result.warnings)
|
|
336
404
|
result.pairs.transform_values { |partners| aggregate_partners(partners) }
|
|
337
405
|
end
|
|
338
406
|
|
|
407
|
+
def record_coupling_warnings(warnings)
|
|
408
|
+
@options[:cli_warnings].concat(warnings)
|
|
409
|
+
bulk_warning = warnings.find do |warning|
|
|
410
|
+
warning.is_a?(Hash) && warning[:code] == 'temporal_coupling_bulk_commits_skipped'
|
|
411
|
+
end
|
|
412
|
+
return unless @options[:verbose] && bulk_warning
|
|
413
|
+
|
|
414
|
+
@stderr.puts "Temporal coupling: skipped #{bulk_warning[:count]} bulk commits " \
|
|
415
|
+
"(> #{bulk_warning[:max_commit_files]} files)."
|
|
416
|
+
end
|
|
417
|
+
|
|
339
418
|
# Reduces a file's partner entries to its strongest-coupling partner.
|
|
340
419
|
# Tie-break is deterministic: highest :coupling, then highest :co_changes,
|
|
341
420
|
# then ascending alphabetical :path — so identical inputs always pick the same partner.
|
|
@@ -362,7 +441,8 @@ module StudFinder
|
|
|
362
441
|
|
|
363
442
|
def analyze_ruby(path, files, coupling = nil)
|
|
364
443
|
progress('computing Ruby fan_in + fan_out (rubocop-ast)...')
|
|
365
|
-
fan_in_result = FanIn.new(repo_path: path, files: files
|
|
444
|
+
fan_in_result = FanIn.new(repo_path: path, files: files, stderr: @stderr,
|
|
445
|
+
rails_inference: @options[:rails_inference]).call
|
|
366
446
|
|
|
367
447
|
progress('computing Ruby complexity (rubocop)...')
|
|
368
448
|
complexity_result = Complexity.new(repo_path: path, files: files, stderr: @stderr).call
|
|
@@ -372,8 +452,9 @@ module StudFinder
|
|
|
372
452
|
churn_result = Churn.new(repo_path: path, files: analysis_files, days: @options[:churn_days],
|
|
373
453
|
stderr: @stderr).call
|
|
374
454
|
|
|
455
|
+
loc = LocCounter.new(repo_path: path, files: analysis_files).call
|
|
375
456
|
score_group(analysis_files, fan_in_result.counts, fan_in_result.fan_out_counts, fan_in_result.edges,
|
|
376
|
-
complexity_result.counts, churn_result, complexity_result.skipped_files,
|
|
457
|
+
complexity_result.counts, churn_result, loc, complexity_result.skipped_files,
|
|
377
458
|
ruby_coverage(path, analysis_files),
|
|
378
459
|
language_by_file: analysis_files.to_h { |file| [file, :ruby] }, coupling: coupling)
|
|
379
460
|
end
|
|
@@ -386,19 +467,21 @@ module StudFinder
|
|
|
386
467
|
complexity_result = JsComplexity.new(repo_path: path, files: files, js_timeout: @options[:js_timeout],
|
|
387
468
|
stderr: @stderr).call
|
|
388
469
|
churn_result = Churn.new(repo_path: path, files: files, days: @options[:churn_days], stderr: @stderr).call
|
|
470
|
+
loc = LocCounter.new(repo_path: path, files: files).call
|
|
389
471
|
score_group(files, fan_in_result.counts, fan_in_result.fan_out_counts, fan_in_result.edges,
|
|
390
|
-
complexity_result.counts, churn_result, [], js_coverage(path, files),
|
|
472
|
+
complexity_result.counts, churn_result, loc, [], js_coverage(path, files),
|
|
391
473
|
language_by_file: languages, extra_warnings: fan_in_result.warnings + complexity_result.warnings,
|
|
392
474
|
coupling: coupling)
|
|
393
475
|
end
|
|
394
476
|
|
|
395
477
|
# rubocop:disable Metrics/ParameterLists
|
|
396
|
-
def score_group(files, fan_in, fan_out, edges, complexity, churn_result, skipped_files, coverage_payload,
|
|
478
|
+
def score_group(files, fan_in, fan_out, edges, complexity, churn_result, loc, skipped_files, coverage_payload,
|
|
397
479
|
language_by_file: {}, extra_warnings: [], coupling: nil)
|
|
398
480
|
progress("normalizing + scoring #{files.length} files...")
|
|
399
481
|
coverage_result, coverage_parser = coverage_payload
|
|
482
|
+
loc_pct = loc_percentiles_by_language(files, loc, language_by_file)
|
|
400
483
|
scorer = Scorer.new(files: files, fan_in: fan_in, fan_out: fan_out, complexity: complexity,
|
|
401
|
-
churn: churn_result.counts, churn_lines: churn_result.line_counts,
|
|
484
|
+
churn: churn_result.counts, churn_lines: churn_result.line_counts, loc: loc, loc_pct: loc_pct,
|
|
402
485
|
coverage: coverage_result, weights: @options[:weights],
|
|
403
486
|
branch_threshold: @options[:branch_threshold], trunk_threshold: @options[:trunk_threshold],
|
|
404
487
|
coupling: coupling)
|
|
@@ -409,16 +492,40 @@ module StudFinder
|
|
|
409
492
|
warnings << 'files_skipped' if skipped_files.any?
|
|
410
493
|
warnings << 'small_repo' if files.length < @options[:min_files]
|
|
411
494
|
emit_scoring_note(scorer, coverage_result)
|
|
495
|
+
scored_rows = scorer.call
|
|
496
|
+
warnings.concat(scorer.warnings)
|
|
497
|
+
rows = apply_newness(files, edges, scored_rows, coverage_result)
|
|
498
|
+
.map { |row| with_language(row, language_by_file) }
|
|
499
|
+
rows = sort_and_rank_rows(rows, files)
|
|
412
500
|
Analysis.new(
|
|
413
501
|
files: files, fan_in: fan_in, fan_out: fan_out, edges: edges, complexity: complexity,
|
|
414
|
-
churn_commits: churn_result.churn_commits, churn_lines: churn_result.churn_lines,
|
|
502
|
+
churn_commits: churn_result.churn_commits, churn_lines: churn_result.churn_lines, loc: loc,
|
|
415
503
|
coverage: coverage_result, coverage_available: !coverage_result.nil?, skipped_files: skipped_files,
|
|
416
|
-
warnings: warnings.uniq, rows:
|
|
504
|
+
warnings: warnings.uniq, rows: rows,
|
|
417
505
|
weights: scorer.normalized_weights
|
|
418
506
|
)
|
|
419
507
|
end
|
|
420
508
|
# rubocop:enable Metrics/ParameterLists
|
|
421
509
|
|
|
510
|
+
def apply_newness(files, edges, rows, coverage_result = nil)
|
|
511
|
+
metadata = if @options[:newness] && Newness.shallow_repository?(@repo_path)
|
|
512
|
+
@options[:cli_warnings] << Newness::SHALLOW_CLONE_WARNING
|
|
513
|
+
Newness.disabled_metadata(files)
|
|
514
|
+
elsif @options[:newness]
|
|
515
|
+
Newness.new(repo_path: @repo_path, files: files, days: @options[:new_file_days],
|
|
516
|
+
min_commits: @options[:new_file_min_commits]).call
|
|
517
|
+
else
|
|
518
|
+
Newness.disabled_metadata(files)
|
|
519
|
+
end
|
|
520
|
+
Newness.apply(rows: rows, edges: edges, metadata: metadata, coverage: coverage_result)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def loc_percentiles_by_language(files, loc, language_by_file)
|
|
524
|
+
files.group_by { |file| language_by_file.fetch(file) }.values.reduce({}) do |pct, language_files|
|
|
525
|
+
pct.merge(Normalizer.percentile_rank(loc, language_files))
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
422
529
|
def with_language(row, language_by_file)
|
|
423
530
|
row.merge(language: language_by_file.fetch(row[:path]).to_s)
|
|
424
531
|
end
|
|
@@ -439,7 +546,7 @@ module StudFinder
|
|
|
439
546
|
|
|
440
547
|
def emit_scoring_note(scorer, coverage_result)
|
|
441
548
|
if coverage_result
|
|
442
|
-
@stderr.puts
|
|
549
|
+
@stderr.puts coverage_scoring_note(weights: scorer.normalized_weights)
|
|
443
550
|
else
|
|
444
551
|
@stderr.puts scoring_note(weights: scorer.normalized_weights, stderr: true)
|
|
445
552
|
end
|
|
@@ -525,6 +632,14 @@ module StudFinder
|
|
|
525
632
|
warnings: (analysis.warnings + ['diff_no_scored_files']).uniq)
|
|
526
633
|
end
|
|
527
634
|
|
|
635
|
+
def sort_and_rank_rows(rows, files)
|
|
636
|
+
original_index = files.each_with_index.to_h
|
|
637
|
+
sorted = rows.sort_by do |row|
|
|
638
|
+
[-CLASS_RANK.fetch(row[:classification], 0), -row[:score], original_index.fetch(row[:path])]
|
|
639
|
+
end
|
|
640
|
+
sorted.map.with_index(1) { |row, rank| row.merge(rank: rank) }
|
|
641
|
+
end
|
|
642
|
+
|
|
528
643
|
def limited_rows(rows)
|
|
529
644
|
filtered = @options[:filter_set] ? rows.select { |row| @options[:filter_set].include?(row[:path]) } : rows
|
|
530
645
|
@options[:top] ? filtered.first(@options[:top]) : filtered
|
|
@@ -541,8 +656,10 @@ module StudFinder
|
|
|
541
656
|
end
|
|
542
657
|
|
|
543
658
|
def empty_analysis
|
|
544
|
-
Analysis.new(
|
|
545
|
-
|
|
659
|
+
Analysis.new(
|
|
660
|
+
files: [], fan_in: {}, fan_out: {}, edges: {}, complexity: {}, churn_commits: {}, churn_lines: {}, loc: {},
|
|
661
|
+
coverage: nil, coverage_available: false, skipped_files: [], warnings: [], rows: [], weights: nil
|
|
662
|
+
)
|
|
546
663
|
end
|
|
547
664
|
|
|
548
665
|
def emit_markdown_section(title, rows)
|
|
@@ -556,9 +673,12 @@ module StudFinder
|
|
|
556
673
|
|
|
557
674
|
def emit_table_section(title, rows)
|
|
558
675
|
@stdout.puts title
|
|
559
|
-
@stdout.puts ' rank language file score class
|
|
560
|
-
'
|
|
561
|
-
'
|
|
676
|
+
@stdout.puts ' rank language file score evidence class new ' \
|
|
677
|
+
'age_days ' \
|
|
678
|
+
'escalation fan_in fan_out instability complexity churn_commits churn_lines ' \
|
|
679
|
+
'churn_pct loc loc_pct ' \
|
|
680
|
+
'max_coupling max_coupling_partner coupling_partners coupling_pct ' \
|
|
681
|
+
'coverage'
|
|
562
682
|
rows.each { |row| @stdout.puts table_row(row) }
|
|
563
683
|
@stdout.puts
|
|
564
684
|
end
|
|
@@ -586,7 +706,7 @@ module StudFinder
|
|
|
586
706
|
churn_days: @options[:churn_days],
|
|
587
707
|
file_count: analysis.ruby.files.length + analysis.javascript.files.length,
|
|
588
708
|
files_skipped: analysis.ruby.skipped_files.length + analysis.javascript.skipped_files.length,
|
|
589
|
-
formula:
|
|
709
|
+
formula: json_formula(analysis),
|
|
590
710
|
weights: json_weights(analysis.ruby.weights || analysis.javascript.weights),
|
|
591
711
|
warnings: analysis.warnings
|
|
592
712
|
}
|
|
@@ -600,8 +720,10 @@ module StudFinder
|
|
|
600
720
|
@stdout.puts "## stud-finder — #{Time.now.utc.strftime('%Y-%m-%d')}"
|
|
601
721
|
@stdout.puts
|
|
602
722
|
file_count = analysis.ruby.files.length + analysis.javascript.files.length
|
|
603
|
-
|
|
604
|
-
|
|
723
|
+
weights = analysis.ruby.weights || analysis.javascript.weights
|
|
724
|
+
md_formula = formula_label(coverage_available: report_coverage_available?(analysis),
|
|
725
|
+
coupling_available: weights[:coupling])
|
|
726
|
+
@stdout.puts "> #{md_formula}. Churn window: #{@options[:churn_days]} days. #{file_count} files analyzed."
|
|
605
727
|
note = filter_note
|
|
606
728
|
if note
|
|
607
729
|
@stdout.puts
|
|
@@ -615,18 +737,20 @@ module StudFinder
|
|
|
615
737
|
|
|
616
738
|
def markdown_row(row)
|
|
617
739
|
values = [
|
|
618
|
-
row[:rank], row[:language], row[:path], format_score(row[:score]), row[:
|
|
619
|
-
row[:
|
|
620
|
-
|
|
621
|
-
row[:
|
|
622
|
-
|
|
740
|
+
row[:rank], row[:language], row[:path], format_score(row[:score]), format_evidence(row[:evidence]),
|
|
741
|
+
row[:classification], row[:new_file], row[:age_days], row[:escalation], row[:fan_in], row[:fan_out],
|
|
742
|
+
format_score(row[:fan_out_pct]),
|
|
743
|
+
format_score(row[:instability]), row[:complexity], row[:churn_commits], row[:churn_lines],
|
|
744
|
+
format_score(row[:churn_pct]), format_score(row[:max_coupling]), row[:max_coupling_partner],
|
|
745
|
+
row[:coupling_partners], format_score(row[:coupling_pct]), format_coverage(row[:coverage])
|
|
623
746
|
]
|
|
624
747
|
"| #{values.join(' | ')} |"
|
|
625
748
|
end
|
|
626
749
|
|
|
627
750
|
def emit_table(path, result, analysis, ruby_rows, javascript_rows)
|
|
628
751
|
coverage_available = report_coverage_available?(analysis)
|
|
629
|
-
|
|
752
|
+
weights = analysis.ruby.weights || analysis.javascript.weights
|
|
753
|
+
formula = formula_label(coverage_available: coverage_available, coupling_available: weights[:coupling])
|
|
630
754
|
@stdout.puts "stud-finder — #{path} (#{@options[:churn_days]}-day churn, #{formula})"
|
|
631
755
|
unless coverage_available
|
|
632
756
|
@stdout.puts scoring_note(weights: analysis.ruby.weights || analysis.javascript.weights,
|
|
@@ -649,7 +773,11 @@ module StudFinder
|
|
|
649
773
|
language: row[:language],
|
|
650
774
|
path: row[:path],
|
|
651
775
|
score: row[:score],
|
|
776
|
+
evidence: row[:evidence],
|
|
652
777
|
class: row[:classification],
|
|
778
|
+
new_file: row[:new_file],
|
|
779
|
+
age_days: row[:age_days],
|
|
780
|
+
escalation: row[:escalation],
|
|
653
781
|
fan_in: row[:fan_in],
|
|
654
782
|
fan_in_pct: row[:fan_in_pct],
|
|
655
783
|
fan_out: row[:fan_out],
|
|
@@ -661,6 +789,8 @@ module StudFinder
|
|
|
661
789
|
churn_commits: row[:churn_commits],
|
|
662
790
|
churn_lines: row[:churn_lines],
|
|
663
791
|
churn_pct: row[:churn_pct],
|
|
792
|
+
loc: row[:loc],
|
|
793
|
+
loc_pct: row[:loc_pct],
|
|
664
794
|
max_coupling: row[:max_coupling],
|
|
665
795
|
max_coupling_partner: row[:max_coupling_partner],
|
|
666
796
|
coupling_partners: row[:coupling_partners],
|
|
@@ -675,7 +805,11 @@ module StudFinder
|
|
|
675
805
|
row[:language],
|
|
676
806
|
row[:path],
|
|
677
807
|
format_score(row[:score]),
|
|
808
|
+
format_evidence(row[:evidence]),
|
|
678
809
|
row[:classification],
|
|
810
|
+
row[:new_file],
|
|
811
|
+
row[:age_days],
|
|
812
|
+
row[:escalation],
|
|
679
813
|
row[:fan_in],
|
|
680
814
|
format_score(row[:fan_in_pct]),
|
|
681
815
|
row[:fan_out],
|
|
@@ -687,6 +821,8 @@ module StudFinder
|
|
|
687
821
|
row[:churn_commits],
|
|
688
822
|
row[:churn_lines],
|
|
689
823
|
format_score(row[:churn_pct]),
|
|
824
|
+
row[:loc],
|
|
825
|
+
format_score(row[:loc_pct]),
|
|
690
826
|
format_score(row[:max_coupling]),
|
|
691
827
|
row[:max_coupling_partner],
|
|
692
828
|
row[:coupling_partners],
|
|
@@ -699,19 +835,53 @@ module StudFinder
|
|
|
699
835
|
analysis.ruby.coverage_available || analysis.javascript.coverage_available
|
|
700
836
|
end
|
|
701
837
|
|
|
838
|
+
def json_formula(analysis)
|
|
839
|
+
weights = analysis.ruby.weights || analysis.javascript.weights
|
|
840
|
+
formula_label(
|
|
841
|
+
coverage_available: report_coverage_available?(analysis),
|
|
842
|
+
coupling_available: weights[:coupling]
|
|
843
|
+
)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def formula_label(coverage_available:, coupling_available:)
|
|
847
|
+
coupling = coupling_available.to_f.positive?
|
|
848
|
+
if coverage_available
|
|
849
|
+
coupling ? '5-factor + coupling' : '5-factor'
|
|
850
|
+
else
|
|
851
|
+
coupling ? '4-factor + coupling' : '4-factor'
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
|
|
702
855
|
def json_weights(weights)
|
|
703
856
|
{
|
|
704
857
|
fan_in: weights[:fan_in].round(4),
|
|
705
858
|
fan_out: weights[:fan_out].round(4),
|
|
706
859
|
complexity: weights[:complexity].round(4),
|
|
707
860
|
churn: weights[:churn].round(4),
|
|
708
|
-
coverage: weights[:coverage]&.round(4)
|
|
861
|
+
coverage: weights[:coverage]&.round(4),
|
|
862
|
+
interaction: weights[:interaction]&.round(4),
|
|
863
|
+
coupling: weights[:coupling]&.round(4)
|
|
709
864
|
}
|
|
710
865
|
end
|
|
711
866
|
|
|
867
|
+
def coverage_scoring_note(weights:)
|
|
868
|
+
label = formula_label(coverage_available: true, coupling_available: weights[:coupling])
|
|
869
|
+
"Note: coverage data available. Score uses #{label} formula."
|
|
870
|
+
end
|
|
871
|
+
|
|
712
872
|
def scoring_note(weights:, stderr:)
|
|
713
|
-
|
|
714
|
-
|
|
873
|
+
label = formula_label(coverage_available: false, coupling_available: weights[:coupling])
|
|
874
|
+
if weights[:coupling]
|
|
875
|
+
if stderr
|
|
876
|
+
format("Note: coverage data not available. Score uses #{label} formula (fan_in %<fan_in>.2f, " \
|
|
877
|
+
'fan_out %<fan_out>.2f, complexity %<complexity>.2f, churn %<churn>.2f, ' \
|
|
878
|
+
'coupling %<coupling>.2f).', **weights)
|
|
879
|
+
else
|
|
880
|
+
format('Note: coverage data not available. Score uses fan_in %<fan_in>.2f, fan_out %<fan_out>.2f, ' \
|
|
881
|
+
'complexity %<complexity>.2f, churn %<churn>.2f, coupling %<coupling>.2f.', **weights)
|
|
882
|
+
end
|
|
883
|
+
elsif stderr
|
|
884
|
+
format("Note: coverage data not available. Score uses #{label} formula (fan_in %<fan_in>.2f, " \
|
|
715
885
|
'fan_out %<fan_out>.2f, complexity %<complexity>.2f, churn %<churn>.2f).', **weights)
|
|
716
886
|
else
|
|
717
887
|
format('Note: coverage data not available. Score uses fan_in %<fan_in>.2f, fan_out %<fan_out>.2f, ' \
|
|
@@ -739,19 +909,33 @@ module StudFinder
|
|
|
739
909
|
def format_coverage(coverage)
|
|
740
910
|
return 'n/a' if coverage.nil?
|
|
741
911
|
|
|
912
|
+
return coverage if coverage.is_a?(String)
|
|
913
|
+
|
|
742
914
|
"#{(coverage * 100).round}%"
|
|
743
915
|
end
|
|
744
916
|
|
|
917
|
+
def format_evidence(evidence)
|
|
918
|
+
return nil if evidence.nil?
|
|
919
|
+
|
|
920
|
+
format_score(evidence)
|
|
921
|
+
end
|
|
922
|
+
|
|
745
923
|
def table_row(row)
|
|
746
|
-
format('%<rank>5d %<language>-10s %<path>-45s %<score>6s %<
|
|
747
|
-
'%<
|
|
748
|
-
'%<
|
|
924
|
+
format('%<rank>5d %<language>-10s %<path>-45s %<score>6s %<evidence>8s ' \
|
|
925
|
+
'%<classification>-6s %<new_file>5s ' \
|
|
926
|
+
'%<age_days>8d %<escalation>-15s %<fan_in>6d %<fan_out>7d %<instability>11s %<complexity>10d ' \
|
|
927
|
+
'%<churn_commits>13d %<churn_lines>11d %<churn_pct>9s %<loc>5d %<loc_pct>7s ' \
|
|
928
|
+
'%<max_coupling>12s %<max_coupling_partner>-40s %<coupling_partners>17d ' \
|
|
749
929
|
'%<coupling_pct>12s %<coverage>8s',
|
|
750
930
|
rank: row[:rank], language: row[:language], path: row[:path], score: format_score(row[:score]),
|
|
751
|
-
|
|
752
|
-
|
|
931
|
+
evidence: format_evidence(row[:evidence]) || 'n/a', classification: row[:classification],
|
|
932
|
+
new_file: row[:new_file].to_s, age_days: row[:age_days].to_i,
|
|
933
|
+
escalation: row[:escalation],
|
|
934
|
+
fan_in: row[:fan_in], fan_out: row[:fan_out], instability: format_score(row[:instability]),
|
|
935
|
+
complexity: row[:complexity],
|
|
753
936
|
churn_commits: row[:churn_commits], churn_lines: row[:churn_lines],
|
|
754
|
-
churn_pct: format_score(row[:churn_pct]),
|
|
937
|
+
churn_pct: format_score(row[:churn_pct]), loc: row[:loc], loc_pct: format_score(row[:loc_pct]),
|
|
938
|
+
max_coupling: format_score(row[:max_coupling]),
|
|
755
939
|
max_coupling_partner: truncate_partner(row[:max_coupling_partner]),
|
|
756
940
|
coupling_partners: row[:coupling_partners], coupling_pct: format_score(row[:coupling_pct]),
|
|
757
941
|
coverage: format_coverage(row[:coverage]))
|