stud-finder 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -1
- data/README.md +130 -33
- data/SIGNALS.md +143 -0
- data/lib/stud_finder/churn.rb +10 -1
- data/lib/stud_finder/cli.rb +232 -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 +133 -37
- data/lib/stud_finder/temporal_coupling.rb +33 -6
- data/lib/stud_finder/version.rb +1 -1
- metadata +7 -4
- 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,17 @@ 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 percentile cutoff for trunk classification, integer 1-99 ' \
|
|
213
|
+
'(default: 85 = top 15% of files by score)') do |value|
|
|
187
214
|
@options[:trunk_threshold] = value
|
|
188
215
|
end
|
|
189
216
|
opts.on('--branch-threshold N', Integer,
|
|
190
|
-
'
|
|
217
|
+
'score percentile cutoff for branch classification, integer 1-99 ' \
|
|
218
|
+
'(default: 50 = top 50% of files by score)') do |value|
|
|
191
219
|
@options[:branch_threshold] = value
|
|
192
220
|
end
|
|
193
221
|
opts.on('--exclude PATTERN', 'Exclude glob pattern (repeatable)') do |value|
|
|
@@ -215,6 +243,22 @@ module StudFinder
|
|
|
215
243
|
'Minimum co-change count for edges output (default: 5)') do |value|
|
|
216
244
|
@options[:coupling_min_commits] = value
|
|
217
245
|
end
|
|
246
|
+
opts.on('--coupling-max-commit-files N', Integer,
|
|
247
|
+
'Skip temporal-coupling commits touching more than N files (default: 50, 0 = unlimited)') do |value|
|
|
248
|
+
@options[:coupling_max_commit_files] = value
|
|
249
|
+
end
|
|
250
|
+
opts.on('--new-file-days N', Integer,
|
|
251
|
+
'Treat files first committed within N days as new (default: 30, 0 disables age floor)') do |value|
|
|
252
|
+
@options[:new_file_days] = value
|
|
253
|
+
end
|
|
254
|
+
opts.on('--new-file-min-commits N', Integer,
|
|
255
|
+
'Treat files with fewer than N full-history commits as new ' \
|
|
256
|
+
'(default: 3, 0 disables count floor)') do |value|
|
|
257
|
+
@options[:new_file_min_commits] = value
|
|
258
|
+
end
|
|
259
|
+
opts.on('--no-newness', 'Disable new-file classification rules') do
|
|
260
|
+
@options[:newness] = false
|
|
261
|
+
end
|
|
218
262
|
opts.on('--verbose', 'Print suppressed per-file warnings to stderr') do
|
|
219
263
|
@options[:verbose] = true
|
|
220
264
|
end
|
|
@@ -230,6 +274,7 @@ module StudFinder
|
|
|
230
274
|
end
|
|
231
275
|
# rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/MethodLength
|
|
232
276
|
|
|
277
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
233
278
|
def parse_weights(value)
|
|
234
279
|
pairs = value.split(',').map do |entry|
|
|
235
280
|
key, raw = entry.split(':', 2)
|
|
@@ -242,18 +287,23 @@ module StudFinder
|
|
|
242
287
|
|
|
243
288
|
weights = pairs.to_h
|
|
244
289
|
missing = WEIGHT_KEYS - weights.keys
|
|
245
|
-
extra = weights.keys - WEIGHT_KEYS
|
|
290
|
+
extra = weights.keys - WEIGHT_KEYS - OPTIONAL_WEIGHT_KEYS
|
|
246
291
|
unless missing.empty? && extra.empty?
|
|
247
292
|
raise ValidationError,
|
|
248
293
|
'Error: weights must include fan_in, fan_out, complexity, churn, and coverage.'
|
|
249
294
|
end
|
|
250
295
|
|
|
296
|
+
weights[:interaction] = 0.0 unless weights.key?(:interaction)
|
|
297
|
+
weights[:coupling] = 0.05 unless weights.key?(:coupling)
|
|
298
|
+
|
|
251
299
|
out_of_range = weights.any? { |_key, weight| weight.negative? || weight > 1.0 }
|
|
252
300
|
raise ValidationError, 'Error: weight values must be between 0.0 and 1.0.' if out_of_range
|
|
253
301
|
|
|
254
302
|
weights
|
|
255
303
|
end
|
|
256
304
|
|
|
305
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
306
|
+
|
|
257
307
|
def validate_options!
|
|
258
308
|
validate_threshold!(:trunk_threshold)
|
|
259
309
|
validate_threshold!(:branch_threshold)
|
|
@@ -266,7 +316,12 @@ module StudFinder
|
|
|
266
316
|
raise ValidationError, 'Error: --churn-days must be positive.' if @options[:churn_days] <= 0
|
|
267
317
|
raise ValidationError, 'Error: --js-timeout must be positive.' if @options[:js_timeout] <= 0
|
|
268
318
|
|
|
319
|
+
validate_newness_options!
|
|
320
|
+
|
|
269
321
|
raise ValidationError, 'Error: --coupling-min-commits must be positive.' if @options[:coupling_min_commits] <= 0
|
|
322
|
+
if @options[:coupling_max_commit_files].negative?
|
|
323
|
+
raise ValidationError, 'Error: --coupling-max-commit-files must be zero or positive.'
|
|
324
|
+
end
|
|
270
325
|
unless (0.0..1.0).cover?(@options[:coupling_threshold])
|
|
271
326
|
raise ValidationError, 'Error: --coupling-threshold must be between 0.0 and 1.0.'
|
|
272
327
|
end
|
|
@@ -276,6 +331,13 @@ module StudFinder
|
|
|
276
331
|
validate_weights! if @options[:custom_weights]
|
|
277
332
|
end
|
|
278
333
|
|
|
334
|
+
def validate_newness_options!
|
|
335
|
+
raise ValidationError, 'Error: --new-file-days must be zero or positive.' if @options[:new_file_days].negative?
|
|
336
|
+
return unless @options[:new_file_min_commits].negative?
|
|
337
|
+
|
|
338
|
+
raise ValidationError, 'Error: --new-file-min-commits must be zero or positive.'
|
|
339
|
+
end
|
|
340
|
+
|
|
279
341
|
def validate_coverage_paths!
|
|
280
342
|
if @options[:ruby_coverage_path] && !File.file?(@options[:ruby_coverage_path])
|
|
281
343
|
raise ValidationError, "Error: coverage file not found: #{@options[:ruby_coverage_path]}"
|
|
@@ -309,9 +371,15 @@ module StudFinder
|
|
|
309
371
|
|
|
310
372
|
def validate_weights!
|
|
311
373
|
weights = @options[:weights]
|
|
374
|
+
out_of_range = weights.any? { |_key, weight| !weight.nil? && (weight.negative? || weight > 1.0) }
|
|
375
|
+
raise ValidationError, 'Error: weight values must be between 0.0 and 1.0.' if out_of_range
|
|
376
|
+
|
|
312
377
|
if weights[:coverage].positive? && !coverage_available?
|
|
313
378
|
raise ValidationError, 'Error: coverage weight must be 0.0 when no coverage data is provided.'
|
|
314
379
|
end
|
|
380
|
+
if weights.fetch(:interaction, 0.0).positive? && !coverage_available?
|
|
381
|
+
raise ValidationError, 'Error: interaction weight must be 0.0 when no coverage data is provided.'
|
|
382
|
+
end
|
|
315
383
|
|
|
316
384
|
active_sum = weights.values.sum
|
|
317
385
|
return if (active_sum - 1.0).abs <= 0.001
|
|
@@ -331,11 +399,24 @@ module StudFinder
|
|
|
331
399
|
files: files,
|
|
332
400
|
days: @options[:churn_days],
|
|
333
401
|
min_co_changes: @options[:coupling_min_commits],
|
|
334
|
-
coupling_threshold: @options[:coupling_threshold]
|
|
402
|
+
coupling_threshold: @options[:coupling_threshold],
|
|
403
|
+
max_commit_files: @options[:coupling_max_commit_files]
|
|
335
404
|
).call
|
|
405
|
+
record_coupling_warnings(result.warnings)
|
|
336
406
|
result.pairs.transform_values { |partners| aggregate_partners(partners) }
|
|
337
407
|
end
|
|
338
408
|
|
|
409
|
+
def record_coupling_warnings(warnings)
|
|
410
|
+
@options[:cli_warnings].concat(warnings)
|
|
411
|
+
bulk_warning = warnings.find do |warning|
|
|
412
|
+
warning.is_a?(Hash) && warning[:code] == 'temporal_coupling_bulk_commits_skipped'
|
|
413
|
+
end
|
|
414
|
+
return unless @options[:verbose] && bulk_warning
|
|
415
|
+
|
|
416
|
+
@stderr.puts "Temporal coupling: skipped #{bulk_warning[:count]} bulk commits " \
|
|
417
|
+
"(> #{bulk_warning[:max_commit_files]} files)."
|
|
418
|
+
end
|
|
419
|
+
|
|
339
420
|
# Reduces a file's partner entries to its strongest-coupling partner.
|
|
340
421
|
# Tie-break is deterministic: highest :coupling, then highest :co_changes,
|
|
341
422
|
# then ascending alphabetical :path — so identical inputs always pick the same partner.
|
|
@@ -362,7 +443,8 @@ module StudFinder
|
|
|
362
443
|
|
|
363
444
|
def analyze_ruby(path, files, coupling = nil)
|
|
364
445
|
progress('computing Ruby fan_in + fan_out (rubocop-ast)...')
|
|
365
|
-
fan_in_result = FanIn.new(repo_path: path, files: files
|
|
446
|
+
fan_in_result = FanIn.new(repo_path: path, files: files, stderr: @stderr,
|
|
447
|
+
rails_inference: @options[:rails_inference]).call
|
|
366
448
|
|
|
367
449
|
progress('computing Ruby complexity (rubocop)...')
|
|
368
450
|
complexity_result = Complexity.new(repo_path: path, files: files, stderr: @stderr).call
|
|
@@ -372,8 +454,9 @@ module StudFinder
|
|
|
372
454
|
churn_result = Churn.new(repo_path: path, files: analysis_files, days: @options[:churn_days],
|
|
373
455
|
stderr: @stderr).call
|
|
374
456
|
|
|
457
|
+
loc = LocCounter.new(repo_path: path, files: analysis_files).call
|
|
375
458
|
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,
|
|
459
|
+
complexity_result.counts, churn_result, loc, complexity_result.skipped_files,
|
|
377
460
|
ruby_coverage(path, analysis_files),
|
|
378
461
|
language_by_file: analysis_files.to_h { |file| [file, :ruby] }, coupling: coupling)
|
|
379
462
|
end
|
|
@@ -386,19 +469,21 @@ module StudFinder
|
|
|
386
469
|
complexity_result = JsComplexity.new(repo_path: path, files: files, js_timeout: @options[:js_timeout],
|
|
387
470
|
stderr: @stderr).call
|
|
388
471
|
churn_result = Churn.new(repo_path: path, files: files, days: @options[:churn_days], stderr: @stderr).call
|
|
472
|
+
loc = LocCounter.new(repo_path: path, files: files).call
|
|
389
473
|
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),
|
|
474
|
+
complexity_result.counts, churn_result, loc, [], js_coverage(path, files),
|
|
391
475
|
language_by_file: languages, extra_warnings: fan_in_result.warnings + complexity_result.warnings,
|
|
392
476
|
coupling: coupling)
|
|
393
477
|
end
|
|
394
478
|
|
|
395
479
|
# rubocop:disable Metrics/ParameterLists
|
|
396
|
-
def score_group(files, fan_in, fan_out, edges, complexity, churn_result, skipped_files, coverage_payload,
|
|
480
|
+
def score_group(files, fan_in, fan_out, edges, complexity, churn_result, loc, skipped_files, coverage_payload,
|
|
397
481
|
language_by_file: {}, extra_warnings: [], coupling: nil)
|
|
398
482
|
progress("normalizing + scoring #{files.length} files...")
|
|
399
483
|
coverage_result, coverage_parser = coverage_payload
|
|
484
|
+
loc_pct = loc_percentiles_by_language(files, loc, language_by_file)
|
|
400
485
|
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,
|
|
486
|
+
churn: churn_result.counts, churn_lines: churn_result.line_counts, loc: loc, loc_pct: loc_pct,
|
|
402
487
|
coverage: coverage_result, weights: @options[:weights],
|
|
403
488
|
branch_threshold: @options[:branch_threshold], trunk_threshold: @options[:trunk_threshold],
|
|
404
489
|
coupling: coupling)
|
|
@@ -409,16 +494,40 @@ module StudFinder
|
|
|
409
494
|
warnings << 'files_skipped' if skipped_files.any?
|
|
410
495
|
warnings << 'small_repo' if files.length < @options[:min_files]
|
|
411
496
|
emit_scoring_note(scorer, coverage_result)
|
|
497
|
+
scored_rows = scorer.call
|
|
498
|
+
warnings.concat(scorer.warnings)
|
|
499
|
+
rows = apply_newness(files, edges, scored_rows, coverage_result)
|
|
500
|
+
.map { |row| with_language(row, language_by_file) }
|
|
501
|
+
rows = sort_and_rank_rows(rows, files)
|
|
412
502
|
Analysis.new(
|
|
413
503
|
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,
|
|
504
|
+
churn_commits: churn_result.churn_commits, churn_lines: churn_result.churn_lines, loc: loc,
|
|
415
505
|
coverage: coverage_result, coverage_available: !coverage_result.nil?, skipped_files: skipped_files,
|
|
416
|
-
warnings: warnings.uniq, rows:
|
|
506
|
+
warnings: warnings.uniq, rows: rows,
|
|
417
507
|
weights: scorer.normalized_weights
|
|
418
508
|
)
|
|
419
509
|
end
|
|
420
510
|
# rubocop:enable Metrics/ParameterLists
|
|
421
511
|
|
|
512
|
+
def apply_newness(files, edges, rows, coverage_result = nil)
|
|
513
|
+
metadata = if @options[:newness] && Newness.shallow_repository?(@repo_path)
|
|
514
|
+
@options[:cli_warnings] << Newness::SHALLOW_CLONE_WARNING
|
|
515
|
+
Newness.disabled_metadata(files)
|
|
516
|
+
elsif @options[:newness]
|
|
517
|
+
Newness.new(repo_path: @repo_path, files: files, days: @options[:new_file_days],
|
|
518
|
+
min_commits: @options[:new_file_min_commits]).call
|
|
519
|
+
else
|
|
520
|
+
Newness.disabled_metadata(files)
|
|
521
|
+
end
|
|
522
|
+
Newness.apply(rows: rows, edges: edges, metadata: metadata, coverage: coverage_result)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def loc_percentiles_by_language(files, loc, language_by_file)
|
|
526
|
+
files.group_by { |file| language_by_file.fetch(file) }.values.reduce({}) do |pct, language_files|
|
|
527
|
+
pct.merge(Normalizer.percentile_rank(loc, language_files))
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
422
531
|
def with_language(row, language_by_file)
|
|
423
532
|
row.merge(language: language_by_file.fetch(row[:path]).to_s)
|
|
424
533
|
end
|
|
@@ -439,7 +548,7 @@ module StudFinder
|
|
|
439
548
|
|
|
440
549
|
def emit_scoring_note(scorer, coverage_result)
|
|
441
550
|
if coverage_result
|
|
442
|
-
@stderr.puts
|
|
551
|
+
@stderr.puts coverage_scoring_note(weights: scorer.normalized_weights)
|
|
443
552
|
else
|
|
444
553
|
@stderr.puts scoring_note(weights: scorer.normalized_weights, stderr: true)
|
|
445
554
|
end
|
|
@@ -525,6 +634,14 @@ module StudFinder
|
|
|
525
634
|
warnings: (analysis.warnings + ['diff_no_scored_files']).uniq)
|
|
526
635
|
end
|
|
527
636
|
|
|
637
|
+
def sort_and_rank_rows(rows, files)
|
|
638
|
+
original_index = files.each_with_index.to_h
|
|
639
|
+
sorted = rows.sort_by do |row|
|
|
640
|
+
[-CLASS_RANK.fetch(row[:classification], 0), -row[:score], original_index.fetch(row[:path])]
|
|
641
|
+
end
|
|
642
|
+
sorted.map.with_index(1) { |row, rank| row.merge(rank: rank) }
|
|
643
|
+
end
|
|
644
|
+
|
|
528
645
|
def limited_rows(rows)
|
|
529
646
|
filtered = @options[:filter_set] ? rows.select { |row| @options[:filter_set].include?(row[:path]) } : rows
|
|
530
647
|
@options[:top] ? filtered.first(@options[:top]) : filtered
|
|
@@ -541,8 +658,10 @@ module StudFinder
|
|
|
541
658
|
end
|
|
542
659
|
|
|
543
660
|
def empty_analysis
|
|
544
|
-
Analysis.new(
|
|
545
|
-
|
|
661
|
+
Analysis.new(
|
|
662
|
+
files: [], fan_in: {}, fan_out: {}, edges: {}, complexity: {}, churn_commits: {}, churn_lines: {}, loc: {},
|
|
663
|
+
coverage: nil, coverage_available: false, skipped_files: [], warnings: [], rows: [], weights: nil
|
|
664
|
+
)
|
|
546
665
|
end
|
|
547
666
|
|
|
548
667
|
def emit_markdown_section(title, rows)
|
|
@@ -556,9 +675,12 @@ module StudFinder
|
|
|
556
675
|
|
|
557
676
|
def emit_table_section(title, rows)
|
|
558
677
|
@stdout.puts title
|
|
559
|
-
@stdout.puts ' rank language file score class
|
|
560
|
-
'
|
|
561
|
-
'
|
|
678
|
+
@stdout.puts ' rank language file score evidence class new ' \
|
|
679
|
+
'age_days ' \
|
|
680
|
+
'escalation fan_in fan_out instability complexity churn_commits churn_lines ' \
|
|
681
|
+
'churn_pct loc loc_pct ' \
|
|
682
|
+
'max_coupling max_coupling_partner coupling_partners coupling_pct ' \
|
|
683
|
+
'coverage'
|
|
562
684
|
rows.each { |row| @stdout.puts table_row(row) }
|
|
563
685
|
@stdout.puts
|
|
564
686
|
end
|
|
@@ -586,7 +708,7 @@ module StudFinder
|
|
|
586
708
|
churn_days: @options[:churn_days],
|
|
587
709
|
file_count: analysis.ruby.files.length + analysis.javascript.files.length,
|
|
588
710
|
files_skipped: analysis.ruby.skipped_files.length + analysis.javascript.skipped_files.length,
|
|
589
|
-
formula:
|
|
711
|
+
formula: json_formula(analysis),
|
|
590
712
|
weights: json_weights(analysis.ruby.weights || analysis.javascript.weights),
|
|
591
713
|
warnings: analysis.warnings
|
|
592
714
|
}
|
|
@@ -600,8 +722,10 @@ module StudFinder
|
|
|
600
722
|
@stdout.puts "## stud-finder — #{Time.now.utc.strftime('%Y-%m-%d')}"
|
|
601
723
|
@stdout.puts
|
|
602
724
|
file_count = analysis.ruby.files.length + analysis.javascript.files.length
|
|
603
|
-
|
|
604
|
-
|
|
725
|
+
weights = analysis.ruby.weights || analysis.javascript.weights
|
|
726
|
+
md_formula = formula_label(coverage_available: report_coverage_available?(analysis),
|
|
727
|
+
coupling_available: weights[:coupling])
|
|
728
|
+
@stdout.puts "> #{md_formula}. Churn window: #{@options[:churn_days]} days. #{file_count} files analyzed."
|
|
605
729
|
note = filter_note
|
|
606
730
|
if note
|
|
607
731
|
@stdout.puts
|
|
@@ -615,18 +739,20 @@ module StudFinder
|
|
|
615
739
|
|
|
616
740
|
def markdown_row(row)
|
|
617
741
|
values = [
|
|
618
|
-
row[:rank], row[:language], row[:path], format_score(row[:score]), row[:
|
|
619
|
-
row[:
|
|
620
|
-
|
|
621
|
-
row[:
|
|
622
|
-
|
|
742
|
+
row[:rank], row[:language], row[:path], format_score(row[:score]), format_evidence(row[:evidence]),
|
|
743
|
+
row[:classification], row[:new_file], row[:age_days], row[:escalation], row[:fan_in], row[:fan_out],
|
|
744
|
+
format_score(row[:fan_out_pct]),
|
|
745
|
+
format_score(row[:instability]), row[:complexity], row[:churn_commits], row[:churn_lines],
|
|
746
|
+
format_score(row[:churn_pct]), format_score(row[:max_coupling]), row[:max_coupling_partner],
|
|
747
|
+
row[:coupling_partners], format_score(row[:coupling_pct]), format_coverage(row[:coverage])
|
|
623
748
|
]
|
|
624
749
|
"| #{values.join(' | ')} |"
|
|
625
750
|
end
|
|
626
751
|
|
|
627
752
|
def emit_table(path, result, analysis, ruby_rows, javascript_rows)
|
|
628
753
|
coverage_available = report_coverage_available?(analysis)
|
|
629
|
-
|
|
754
|
+
weights = analysis.ruby.weights || analysis.javascript.weights
|
|
755
|
+
formula = formula_label(coverage_available: coverage_available, coupling_available: weights[:coupling])
|
|
630
756
|
@stdout.puts "stud-finder — #{path} (#{@options[:churn_days]}-day churn, #{formula})"
|
|
631
757
|
unless coverage_available
|
|
632
758
|
@stdout.puts scoring_note(weights: analysis.ruby.weights || analysis.javascript.weights,
|
|
@@ -649,7 +775,11 @@ module StudFinder
|
|
|
649
775
|
language: row[:language],
|
|
650
776
|
path: row[:path],
|
|
651
777
|
score: row[:score],
|
|
778
|
+
evidence: row[:evidence],
|
|
652
779
|
class: row[:classification],
|
|
780
|
+
new_file: row[:new_file],
|
|
781
|
+
age_days: row[:age_days],
|
|
782
|
+
escalation: row[:escalation],
|
|
653
783
|
fan_in: row[:fan_in],
|
|
654
784
|
fan_in_pct: row[:fan_in_pct],
|
|
655
785
|
fan_out: row[:fan_out],
|
|
@@ -661,6 +791,8 @@ module StudFinder
|
|
|
661
791
|
churn_commits: row[:churn_commits],
|
|
662
792
|
churn_lines: row[:churn_lines],
|
|
663
793
|
churn_pct: row[:churn_pct],
|
|
794
|
+
loc: row[:loc],
|
|
795
|
+
loc_pct: row[:loc_pct],
|
|
664
796
|
max_coupling: row[:max_coupling],
|
|
665
797
|
max_coupling_partner: row[:max_coupling_partner],
|
|
666
798
|
coupling_partners: row[:coupling_partners],
|
|
@@ -675,7 +807,11 @@ module StudFinder
|
|
|
675
807
|
row[:language],
|
|
676
808
|
row[:path],
|
|
677
809
|
format_score(row[:score]),
|
|
810
|
+
format_evidence(row[:evidence]),
|
|
678
811
|
row[:classification],
|
|
812
|
+
row[:new_file],
|
|
813
|
+
row[:age_days],
|
|
814
|
+
row[:escalation],
|
|
679
815
|
row[:fan_in],
|
|
680
816
|
format_score(row[:fan_in_pct]),
|
|
681
817
|
row[:fan_out],
|
|
@@ -687,6 +823,8 @@ module StudFinder
|
|
|
687
823
|
row[:churn_commits],
|
|
688
824
|
row[:churn_lines],
|
|
689
825
|
format_score(row[:churn_pct]),
|
|
826
|
+
row[:loc],
|
|
827
|
+
format_score(row[:loc_pct]),
|
|
690
828
|
format_score(row[:max_coupling]),
|
|
691
829
|
row[:max_coupling_partner],
|
|
692
830
|
row[:coupling_partners],
|
|
@@ -699,19 +837,53 @@ module StudFinder
|
|
|
699
837
|
analysis.ruby.coverage_available || analysis.javascript.coverage_available
|
|
700
838
|
end
|
|
701
839
|
|
|
840
|
+
def json_formula(analysis)
|
|
841
|
+
weights = analysis.ruby.weights || analysis.javascript.weights
|
|
842
|
+
formula_label(
|
|
843
|
+
coverage_available: report_coverage_available?(analysis),
|
|
844
|
+
coupling_available: weights[:coupling]
|
|
845
|
+
)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def formula_label(coverage_available:, coupling_available:)
|
|
849
|
+
coupling = coupling_available.to_f.positive?
|
|
850
|
+
if coverage_available
|
|
851
|
+
coupling ? '5-factor + coupling' : '5-factor'
|
|
852
|
+
else
|
|
853
|
+
coupling ? '4-factor + coupling' : '4-factor'
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
702
857
|
def json_weights(weights)
|
|
703
858
|
{
|
|
704
859
|
fan_in: weights[:fan_in].round(4),
|
|
705
860
|
fan_out: weights[:fan_out].round(4),
|
|
706
861
|
complexity: weights[:complexity].round(4),
|
|
707
862
|
churn: weights[:churn].round(4),
|
|
708
|
-
coverage: weights[:coverage]&.round(4)
|
|
863
|
+
coverage: weights[:coverage]&.round(4),
|
|
864
|
+
interaction: weights[:interaction]&.round(4),
|
|
865
|
+
coupling: weights[:coupling]&.round(4)
|
|
709
866
|
}
|
|
710
867
|
end
|
|
711
868
|
|
|
869
|
+
def coverage_scoring_note(weights:)
|
|
870
|
+
label = formula_label(coverage_available: true, coupling_available: weights[:coupling])
|
|
871
|
+
"Note: coverage data available. Score uses #{label} formula."
|
|
872
|
+
end
|
|
873
|
+
|
|
712
874
|
def scoring_note(weights:, stderr:)
|
|
713
|
-
|
|
714
|
-
|
|
875
|
+
label = formula_label(coverage_available: false, coupling_available: weights[:coupling])
|
|
876
|
+
if weights[:coupling]
|
|
877
|
+
if stderr
|
|
878
|
+
format("Note: coverage data not available. Score uses #{label} formula (fan_in %<fan_in>.2f, " \
|
|
879
|
+
'fan_out %<fan_out>.2f, complexity %<complexity>.2f, churn %<churn>.2f, ' \
|
|
880
|
+
'coupling %<coupling>.2f).', **weights)
|
|
881
|
+
else
|
|
882
|
+
format('Note: coverage data not available. Score uses fan_in %<fan_in>.2f, fan_out %<fan_out>.2f, ' \
|
|
883
|
+
'complexity %<complexity>.2f, churn %<churn>.2f, coupling %<coupling>.2f.', **weights)
|
|
884
|
+
end
|
|
885
|
+
elsif stderr
|
|
886
|
+
format("Note: coverage data not available. Score uses #{label} formula (fan_in %<fan_in>.2f, " \
|
|
715
887
|
'fan_out %<fan_out>.2f, complexity %<complexity>.2f, churn %<churn>.2f).', **weights)
|
|
716
888
|
else
|
|
717
889
|
format('Note: coverage data not available. Score uses fan_in %<fan_in>.2f, fan_out %<fan_out>.2f, ' \
|
|
@@ -739,19 +911,33 @@ module StudFinder
|
|
|
739
911
|
def format_coverage(coverage)
|
|
740
912
|
return 'n/a' if coverage.nil?
|
|
741
913
|
|
|
914
|
+
return coverage if coverage.is_a?(String)
|
|
915
|
+
|
|
742
916
|
"#{(coverage * 100).round}%"
|
|
743
917
|
end
|
|
744
918
|
|
|
919
|
+
def format_evidence(evidence)
|
|
920
|
+
return nil if evidence.nil?
|
|
921
|
+
|
|
922
|
+
format_score(evidence)
|
|
923
|
+
end
|
|
924
|
+
|
|
745
925
|
def table_row(row)
|
|
746
|
-
format('%<rank>5d %<language>-10s %<path>-45s %<score>6s %<
|
|
747
|
-
'%<
|
|
748
|
-
'%<
|
|
926
|
+
format('%<rank>5d %<language>-10s %<path>-45s %<score>6s %<evidence>8s ' \
|
|
927
|
+
'%<classification>-6s %<new_file>5s ' \
|
|
928
|
+
'%<age_days>8d %<escalation>-15s %<fan_in>6d %<fan_out>7d %<instability>11s %<complexity>10d ' \
|
|
929
|
+
'%<churn_commits>13d %<churn_lines>11d %<churn_pct>9s %<loc>5d %<loc_pct>7s ' \
|
|
930
|
+
'%<max_coupling>12s %<max_coupling_partner>-40s %<coupling_partners>17d ' \
|
|
749
931
|
'%<coupling_pct>12s %<coverage>8s',
|
|
750
932
|
rank: row[:rank], language: row[:language], path: row[:path], score: format_score(row[:score]),
|
|
751
|
-
|
|
752
|
-
|
|
933
|
+
evidence: format_evidence(row[:evidence]) || 'n/a', classification: row[:classification],
|
|
934
|
+
new_file: row[:new_file].to_s, age_days: row[:age_days].to_i,
|
|
935
|
+
escalation: row[:escalation],
|
|
936
|
+
fan_in: row[:fan_in], fan_out: row[:fan_out], instability: format_score(row[:instability]),
|
|
937
|
+
complexity: row[:complexity],
|
|
753
938
|
churn_commits: row[:churn_commits], churn_lines: row[:churn_lines],
|
|
754
|
-
churn_pct: format_score(row[:churn_pct]),
|
|
939
|
+
churn_pct: format_score(row[:churn_pct]), loc: row[:loc], loc_pct: format_score(row[:loc_pct]),
|
|
940
|
+
max_coupling: format_score(row[:max_coupling]),
|
|
755
941
|
max_coupling_partner: truncate_partner(row[:max_coupling_partner]),
|
|
756
942
|
coupling_partners: row[:coupling_partners], coupling_pct: format_score(row[:coupling_pct]),
|
|
757
943
|
coverage: format_coverage(row[:coverage]))
|