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.
@@ -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 instability instability_pct complexity
29
- complexity_pct churn_commits churn_lines churn_pct max_coupling max_coupling_partner coupling_partners
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 complexity churn_commits churn_lines
34
- churn_pct max_coupling max_coupling_partner coupling_partners coupling_pct coverage
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: { fan_in: 0.25, fan_out: 0.10, complexity: 0.25, churn: 0.25, coverage: 0.15 },
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', 'fan_in:F,fan_out:O,complexity:C,churn:H,coverage:V') do |value|
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
- 'fan_in percentile cutoff for trunk classification (default: 85)') do |value|
212
+ 'score percentile cutoff for trunk classification, integer 1-99 ' \
213
+ '(default: 85 = top 15% of files by score)') do |value|
187
214
  @options[:trunk_threshold] = value
188
215
  end
189
216
  opts.on('--branch-threshold N', Integer,
190
- 'fan_in percentile cutoff for branch classification (default: 50)') do |value|
217
+ 'score percentile cutoff for branch classification, integer 1-99 ' \
218
+ '(default: 50 = top 50% of files by score)') do |value|
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).call
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: scorer.call.map { |row| with_language(row, language_by_file) },
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 'Note: coverage data available. Score uses 5-factor formula.'
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(files: [], fan_in: {}, fan_out: {}, edges: {}, complexity: {}, churn_commits: {}, churn_lines: {},
545
- coverage: nil, coverage_available: false, skipped_files: [], warnings: [], rows: [], weights: nil)
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 fan_in ' \
560
- 'fan_out instability complexity churn_commits churn_lines churn_pct max_coupling ' \
561
- 'max_coupling_partner coupling_partners coupling_pct coverage'
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: report_coverage_available?(analysis) ? '5-factor' : '4-factor (no coverage)',
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
- @stdout.puts "> #{report_coverage_available?(analysis) ? '5-factor score' : '4-factor score (no coverage)'}. " \
604
- "Churn window: #{@options[:churn_days]} days. #{file_count} files analyzed."
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[:classification], row[:fan_in],
619
- row[:fan_out], format_score(row[:fan_out_pct]), format_score(row[:instability]), row[:complexity],
620
- row[:churn_commits], row[:churn_lines], format_score(row[:churn_pct]), format_score(row[:max_coupling]),
621
- row[:max_coupling_partner], row[:coupling_partners], format_score(row[:coupling_pct]),
622
- format_coverage(row[:coverage])
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
- formula = coverage_available ? '5-factor score' : '4-factor score'
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
- if stderr
714
- format('Note: coverage data not available. Score uses 4-factor formula (fan_in %<fan_in>.2f, ' \
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 %<classification>-6s %<fan_in>6d ' \
747
- '%<fan_out>7d %<instability>11s %<complexity>10d %<churn_commits>13d %<churn_lines>11d ' \
748
- '%<churn_pct>9s %<max_coupling>12s %<max_coupling_partner>-40s %<coupling_partners>17d ' \
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
- classification: row[:classification], fan_in: row[:fan_in], fan_out: row[:fan_out],
752
- instability: format_score(row[:instability]), complexity: row[:complexity],
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]), max_coupling: format_score(row[:max_coupling]),
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]))