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.
@@ -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,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
- 'fan_in percentile cutoff for trunk classification (default: 85)') do |value|
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
- 'fan_in percentile cutoff for branch classification (default: 50)') do |value|
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).call
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: scorer.call.map { |row| with_language(row, language_by_file) },
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 'Note: coverage data available. Score uses 5-factor formula.'
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(files: [], fan_in: {}, fan_out: {}, edges: {}, complexity: {}, churn_commits: {}, churn_lines: {},
545
- coverage: nil, coverage_available: false, skipped_files: [], warnings: [], rows: [], weights: nil)
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 fan_in ' \
560
- 'fan_out instability complexity churn_commits churn_lines churn_pct max_coupling ' \
561
- 'max_coupling_partner coupling_partners coupling_pct coverage'
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: report_coverage_available?(analysis) ? '5-factor' : '4-factor (no coverage)',
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
- @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."
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[: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])
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
- formula = coverage_available ? '5-factor score' : '4-factor score'
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
- if stderr
714
- format('Note: coverage data not available. Score uses 4-factor formula (fan_in %<fan_in>.2f, ' \
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 %<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 ' \
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
- classification: row[:classification], fan_in: row[:fan_in], fan_out: row[:fan_out],
752
- instability: format_score(row[:instability]), complexity: row[:complexity],
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]), max_coupling: format_score(row[:max_coupling]),
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]))