stud-finder 0.1.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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/PRODUCT.md +172 -0
- data/README.md +176 -0
- data/VISION.md +151 -0
- data/bin/stud-finder +6 -0
- data/lib/stud-finder.rb +3 -0
- data/lib/stud_finder/churn.rb +111 -0
- data/lib/stud_finder/cli.rb +771 -0
- data/lib/stud_finder/complexity.rb +104 -0
- data/lib/stud_finder/coverage/cobertura.rb +59 -0
- data/lib/stud_finder/coverage/detector.rb +26 -0
- data/lib/stud_finder/coverage/lcov.rb +93 -0
- data/lib/stud_finder/coverage/resultset.rb +103 -0
- data/lib/stud_finder/diff.rb +53 -0
- data/lib/stud_finder/edges.rb +113 -0
- data/lib/stud_finder/fan_in.rb +243 -0
- data/lib/stud_finder/file_collector.rb +152 -0
- data/lib/stud_finder/js_complexity.rb +203 -0
- data/lib/stud_finder/js_fan_in.rb +121 -0
- data/lib/stud_finder/normalizer.rb +38 -0
- data/lib/stud_finder/scorer.rb +171 -0
- data/lib/stud_finder/temporal_coupling.rb +104 -0
- data/lib/stud_finder/version.rb +5 -0
- data/lib/stud_finder.rb +14 -0
- metadata +199 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
|
|
7
|
+
module StudFinder
|
|
8
|
+
class Complexity
|
|
9
|
+
Result = Struct.new(:counts, :skipped_files, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
COMPLEXITY_COP = 'Metrics/CyclomaticComplexity'
|
|
14
|
+
COMPLEXITY_PATTERN = %r{\[(\d+)/0\]}
|
|
15
|
+
PARSE_ERROR_COPS = %w[Lint/Syntax].freeze
|
|
16
|
+
RUBOCOP_CONFIG = <<~YAML
|
|
17
|
+
AllCops:
|
|
18
|
+
DisabledByDefault: true
|
|
19
|
+
Metrics/CyclomaticComplexity:
|
|
20
|
+
Enabled: true
|
|
21
|
+
Max: 0
|
|
22
|
+
YAML
|
|
23
|
+
|
|
24
|
+
def initialize(repo_path:, files:, stderr: $stderr)
|
|
25
|
+
@repo_path = File.expand_path(repo_path)
|
|
26
|
+
@files = files
|
|
27
|
+
@stderr = stderr
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
stdout, stderr, status = run_rubocop
|
|
32
|
+
raise Error, fatal_message(stderr) if status.exitstatus == 2
|
|
33
|
+
raise Error, fatal_message(stderr) unless [0, 1].include?(status.exitstatus)
|
|
34
|
+
|
|
35
|
+
parse(stdout)
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
raise Error, 'Error: rubocop not found. Install it: gem install rubocop'
|
|
38
|
+
rescue JSON::ParserError => e
|
|
39
|
+
raise Error, "Error: failed to parse RuboCop JSON output: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def run_rubocop
|
|
45
|
+
Tempfile.create(['stud-finder-rubocop', '.yml']) do |config|
|
|
46
|
+
config.write(RUBOCOP_CONFIG)
|
|
47
|
+
config.close
|
|
48
|
+
|
|
49
|
+
Open3.capture3(
|
|
50
|
+
'rubocop',
|
|
51
|
+
'--config', config.path,
|
|
52
|
+
'--format', 'json',
|
|
53
|
+
@repo_path
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse(stdout)
|
|
59
|
+
payload = JSON.parse(stdout)
|
|
60
|
+
counts = @files.to_h { |file| [file, 0] }
|
|
61
|
+
skipped = []
|
|
62
|
+
file_set = counts.keys.to_h { |file| [file, true] }
|
|
63
|
+
|
|
64
|
+
Array(payload['files']).each do |entry|
|
|
65
|
+
relative = normalize_path(entry['path'].to_s)
|
|
66
|
+
next unless file_set[relative]
|
|
67
|
+
|
|
68
|
+
offenses = Array(entry['offenses'])
|
|
69
|
+
if parse_error?(offenses)
|
|
70
|
+
skipped << relative
|
|
71
|
+
counts.delete(relative)
|
|
72
|
+
@stderr.puts "Warning: skipping #{relative}; RuboCop could not parse file."
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
counts[relative] = offenses.map { |offense| complexity_score(offense) }.max || 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Result.new(counts: counts, skipped_files: skipped)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def complexity_score(offense)
|
|
83
|
+
return 0 unless offense['cop_name'] == COMPLEXITY_COP
|
|
84
|
+
|
|
85
|
+
offense.fetch('message', '').match(COMPLEXITY_PATTERN)&.[](1).to_i
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_error?(offenses)
|
|
89
|
+
offenses.any? { |offense| PARSE_ERROR_COPS.include?(offense['cop_name']) || offense['fatal'] == true }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_path(path)
|
|
93
|
+
absolute = File.expand_path(path, @repo_path)
|
|
94
|
+
absolute.start_with?("#{@repo_path}/") ? absolute.delete_prefix("#{@repo_path}/") : path
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fatal_message(stderr)
|
|
98
|
+
message = stderr.to_s.strip
|
|
99
|
+
return 'Error: rubocop failed.' if message.empty?
|
|
100
|
+
|
|
101
|
+
"Error: rubocop failed: #{message}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rexml/document'
|
|
4
|
+
|
|
5
|
+
module StudFinder
|
|
6
|
+
module Coverage
|
|
7
|
+
class Cobertura
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
attr_reader :missing_files
|
|
11
|
+
|
|
12
|
+
def initialize(path:, files:, project_root: nil)
|
|
13
|
+
@path = path
|
|
14
|
+
@files = files
|
|
15
|
+
@project_root = project_root
|
|
16
|
+
@missing_files = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
reported = parse_report
|
|
21
|
+
@missing_files = @files.reject { |file| reported.key?(file) }
|
|
22
|
+
@files.to_h { |file| [file, reported.fetch(file, 0.0)] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_report
|
|
28
|
+
document = REXML::Document.new(File.read(@path))
|
|
29
|
+
{}.tap do |coverage|
|
|
30
|
+
REXML::XPath.each(document, '/coverage/packages/package/classes/class') do |element|
|
|
31
|
+
filename = element.attributes['filename']
|
|
32
|
+
next if filename.nil? || filename.empty?
|
|
33
|
+
|
|
34
|
+
coverage[normalize_filename(filename)] = parse_line_rate(filename, element.attributes['line-rate'])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
rescue REXML::ParseException => e
|
|
38
|
+
raise Error, "Error: malformed coverage XML: #{e.message.lines.first.strip}"
|
|
39
|
+
rescue Errno::ENOENT
|
|
40
|
+
raise Error, "Error: coverage file not found: #{@path}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize_filename(filename)
|
|
44
|
+
filename.delete_prefix('./')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_line_rate(filename, raw_rate)
|
|
48
|
+
raise Error, "Error: missing line-rate for coverage file: #{filename}" if raw_rate.nil? || raw_rate.empty?
|
|
49
|
+
|
|
50
|
+
rate = Float(raw_rate)
|
|
51
|
+
return rate if rate.between?(0.0, 1.0)
|
|
52
|
+
|
|
53
|
+
raise Error, "Error: line-rate out of range for coverage file: #{filename}"
|
|
54
|
+
rescue ArgumentError
|
|
55
|
+
raise Error, "Error: invalid line-rate for coverage file: #{filename}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cobertura'
|
|
4
|
+
require_relative 'lcov'
|
|
5
|
+
require_relative 'resultset'
|
|
6
|
+
|
|
7
|
+
module StudFinder
|
|
8
|
+
module Coverage
|
|
9
|
+
class Detector
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
PARSERS = {
|
|
13
|
+
'.xml' => Cobertura,
|
|
14
|
+
'.info' => Lcov,
|
|
15
|
+
'.json' => Resultset
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.for(path:, files:, project_root: nil)
|
|
19
|
+
parser = PARSERS[File.extname(path).downcase]
|
|
20
|
+
raise Error, "Error: unsupported coverage file type: #{path}" if parser.nil?
|
|
21
|
+
|
|
22
|
+
parser.new(path: path, files: files, project_root: project_root)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module StudFinder
|
|
6
|
+
module Coverage
|
|
7
|
+
class Lcov
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
attr_reader :missing_files
|
|
11
|
+
|
|
12
|
+
def initialize(path:, files:, project_root: nil)
|
|
13
|
+
@path = path
|
|
14
|
+
@files = files
|
|
15
|
+
@file_set = Set.new(files)
|
|
16
|
+
@project_root = File.expand_path(project_root) if project_root
|
|
17
|
+
@missing_files = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
reported = parse_report
|
|
22
|
+
@missing_files = @files.reject { |file| reported.key?(file) }
|
|
23
|
+
@files.to_h { |file| [file, reported.fetch(file, 0.0)] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def parse_report
|
|
29
|
+
records = File.read(@path).split(/^end_of_record\s*$/)
|
|
30
|
+
records.each_with_object({}) do |record, coverage|
|
|
31
|
+
filename = record[/^SF:(.+)$/, 1]
|
|
32
|
+
next if filename.nil? || filename.empty?
|
|
33
|
+
|
|
34
|
+
coverage[normalize_filename(filename)] = line_rate(record, filename)
|
|
35
|
+
end
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
raise Error, "Error: coverage file not found: #{@path}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize_filename(filename)
|
|
41
|
+
expanded = File.expand_path(filename)
|
|
42
|
+
stripped = project_root_stripped(filename, expanded)
|
|
43
|
+
return stripped if stripped && @file_set.include?(stripped)
|
|
44
|
+
|
|
45
|
+
if filename.start_with?('/')
|
|
46
|
+
suffix_match(filename) || stripped || filename.delete_prefix('./')
|
|
47
|
+
else
|
|
48
|
+
stripped || filename.delete_prefix('./')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def project_root_stripped(filename, expanded)
|
|
53
|
+
if @project_root && filename.start_with?("#{@project_root}/")
|
|
54
|
+
filename.delete_prefix("#{@project_root}/")
|
|
55
|
+
elsif @project_root && expanded.start_with?("#{@project_root}/")
|
|
56
|
+
expanded.delete_prefix("#{@project_root}/")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def suffix_match(filename)
|
|
61
|
+
components = filename.split('/').reject(&:empty?)
|
|
62
|
+
|
|
63
|
+
components.length.downto(1) do |count|
|
|
64
|
+
suffix = components.last(count).join('/')
|
|
65
|
+
return suffix if @file_set.include?(suffix)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def line_rate(record, filename)
|
|
72
|
+
found = integer_field(record, 'LF')
|
|
73
|
+
hit = integer_field(record, 'LH')
|
|
74
|
+
|
|
75
|
+
if found.nil? || hit.nil?
|
|
76
|
+
lines = record.scan(/^DA:\d+,(\d+)/).flatten.map(&:to_i)
|
|
77
|
+
found = lines.length
|
|
78
|
+
hit = lines.count(&:positive?)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
return 0.0 if found.zero?
|
|
82
|
+
raise Error, "Error: line hits exceed lines found for coverage file: #{filename}" if hit > found
|
|
83
|
+
|
|
84
|
+
hit.to_f / found
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def integer_field(record, field)
|
|
88
|
+
raw = record[/^#{field}:(\d+)$/, 1]
|
|
89
|
+
raw&.to_i
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module StudFinder
|
|
7
|
+
module Coverage
|
|
8
|
+
class Resultset
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
attr_reader :missing_files
|
|
12
|
+
|
|
13
|
+
def initialize(path:, files:, project_root: nil)
|
|
14
|
+
@path = path
|
|
15
|
+
@files = files
|
|
16
|
+
@file_set = Set.new(files)
|
|
17
|
+
@project_root = File.expand_path(project_root) if project_root
|
|
18
|
+
@missing_files = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
reported = parse_report
|
|
23
|
+
@missing_files = @files.reject { |file| reported.key?(file) }
|
|
24
|
+
@files.to_h { |file| [file, reported.fetch(file, 0.0)] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def parse_report
|
|
30
|
+
data = JSON.parse(File.read(@path))
|
|
31
|
+
merged = {}
|
|
32
|
+
|
|
33
|
+
coverage_payloads(data).each do |coverage|
|
|
34
|
+
coverage.each do |filename, details|
|
|
35
|
+
lines = details.is_a?(Hash) ? details['lines'] : details
|
|
36
|
+
next unless lines.is_a?(Array)
|
|
37
|
+
|
|
38
|
+
key = normalize_filename(filename)
|
|
39
|
+
merged[key] = merged.key?(key) ? merge_lines(merged[key], lines) : lines
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
merged.transform_values { |lines| line_rate(lines) }
|
|
44
|
+
rescue JSON::ParserError => e
|
|
45
|
+
raise Error, "Error: malformed coverage JSON: #{e.message.lines.first.strip}"
|
|
46
|
+
rescue Errno::ENOENT
|
|
47
|
+
raise Error, "Error: coverage file not found: #{@path}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def merge_lines(previous_lines, new_lines)
|
|
51
|
+
max_length = [previous_lines.length, new_lines.length].max
|
|
52
|
+
|
|
53
|
+
(0...max_length).map do |index|
|
|
54
|
+
previous = previous_lines[index]
|
|
55
|
+
current = new_lines[index]
|
|
56
|
+
|
|
57
|
+
previous.nil? && current.nil? ? nil : [previous || 0, current || 0].max
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def coverage_payloads(data)
|
|
62
|
+
if data['coverage'].is_a?(Hash)
|
|
63
|
+
[data['coverage']]
|
|
64
|
+
else
|
|
65
|
+
data.values.filter_map { |suite| suite['coverage'] if suite.is_a?(Hash) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalize_filename(filename)
|
|
70
|
+
stripped = project_root_stripped(filename)
|
|
71
|
+
return stripped if stripped && @file_set.include?(stripped)
|
|
72
|
+
|
|
73
|
+
if filename.start_with?('/')
|
|
74
|
+
suffix_match(filename) || stripped || filename.delete_prefix('./')
|
|
75
|
+
else
|
|
76
|
+
stripped || filename.delete_prefix('./')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def project_root_stripped(filename)
|
|
81
|
+
filename.delete_prefix("#{@project_root}/") if @project_root && filename.start_with?("#{@project_root}/")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def suffix_match(filename)
|
|
85
|
+
components = filename.split('/').reject(&:empty?)
|
|
86
|
+
|
|
87
|
+
components.length.downto(1) do |count|
|
|
88
|
+
suffix = components.last(count).join('/')
|
|
89
|
+
return suffix if @file_set.include?(suffix)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def line_rate(lines)
|
|
96
|
+
executable = lines.compact
|
|
97
|
+
return 0.0 if executable.empty?
|
|
98
|
+
|
|
99
|
+
executable.count(&:positive?).to_f / executable.length
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module StudFinder
|
|
6
|
+
# Resolves the set of files changed on HEAD relative to the merge-base with a
|
|
7
|
+
# base ref (e.g. origin/staging), as repo-root-relative paths that match the
|
|
8
|
+
# form FileCollector emits. Used to filter output down to a PR's touched files
|
|
9
|
+
# WITHOUT narrowing the analysis population — scoring still runs against the
|
|
10
|
+
# full repo, so fan_in and percentiles stay correct.
|
|
11
|
+
class Diff
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
def initialize(repo_path:, base_ref:)
|
|
15
|
+
@repo_path = File.expand_path(repo_path)
|
|
16
|
+
@base_ref = base_ref
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_ref!
|
|
20
|
+
verify_ref!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def changed_paths
|
|
24
|
+
verify_ref!
|
|
25
|
+
stdout, stderr, status = Open3.capture3(
|
|
26
|
+
'git', '-C', @repo_path, 'diff', '--name-only', '--diff-filter=d', "#{@base_ref}...HEAD"
|
|
27
|
+
)
|
|
28
|
+
raise Error, diff_error(stderr) unless status.success?
|
|
29
|
+
|
|
30
|
+
stdout.each_line.map(&:strip).reject(&:empty?)
|
|
31
|
+
rescue Errno::ENOENT
|
|
32
|
+
raise Error, 'Error: git not found in PATH.'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def verify_ref!
|
|
38
|
+
_stdout, _stderr, status = Open3.capture3(
|
|
39
|
+
'git', '-C', @repo_path, 'rev-parse', '--verify', '--quiet', "#{@base_ref}^{commit}"
|
|
40
|
+
)
|
|
41
|
+
return if status.success?
|
|
42
|
+
|
|
43
|
+
raise Error, "Error: diff base ref not found: #{@base_ref}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def diff_error(stderr)
|
|
47
|
+
message = stderr.to_s.strip
|
|
48
|
+
return 'Error: git diff failed.' if message.empty?
|
|
49
|
+
|
|
50
|
+
"Error: git diff failed: #{message}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StudFinder
|
|
4
|
+
class Edges
|
|
5
|
+
MAX_ROWS = 50
|
|
6
|
+
|
|
7
|
+
CouplingConfig = Struct.new(:data, :churn_days, :min_commits, :threshold, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize(target:, rows:, edges:, coupling: {}, churn_days: 180,
|
|
10
|
+
coupling_min_commits: 5, coupling_threshold: 0.30,
|
|
11
|
+
stdout: $stdout, stderr: $stderr)
|
|
12
|
+
@target = target
|
|
13
|
+
@rows = rows.to_h { |row| [row[:path], row] }
|
|
14
|
+
@edges = edges
|
|
15
|
+
@coupling = CouplingConfig.new(data: coupling, churn_days: churn_days,
|
|
16
|
+
min_commits: coupling_min_commits, threshold: coupling_threshold)
|
|
17
|
+
@stdout = stdout
|
|
18
|
+
@stderr = stderr
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
if @target.nil? || @target.empty?
|
|
23
|
+
@stderr.puts 'Usage: stud-finder edges FILE [PATH]'
|
|
24
|
+
return 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless @edges.key?(@target)
|
|
28
|
+
@stderr.puts "Error: '#{@target}' was not found in the scored file set."
|
|
29
|
+
return 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
target_row = @rows[@target]
|
|
33
|
+
edge_data = @edges[@target]
|
|
34
|
+
|
|
35
|
+
emit_header(target_row)
|
|
36
|
+
emit_section('Dependents', edge_data[:dependents], '(files that depend on this file — blast radius)')
|
|
37
|
+
emit_section('Dependencies', edge_data[:dependencies], '(files this file depends on — fan-out)')
|
|
38
|
+
emit_coupling_section
|
|
39
|
+
@stdout.puts
|
|
40
|
+
@stdout.puts 'Edges are statically computed — dynamic references not counted.'
|
|
41
|
+
@stdout.puts "Temporal coupling from git history (#{@coupling.churn_days}-day window)."
|
|
42
|
+
0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def emit_header(row)
|
|
48
|
+
@stdout.puts
|
|
49
|
+
@stdout.puts "stud-finder edges — #{@target}"
|
|
50
|
+
@stdout.puts
|
|
51
|
+
if row
|
|
52
|
+
@stdout.puts format(' score: %<score>s class: %<class>-6s fan_in: %<fan_in>d ' \
|
|
53
|
+
'fan_out: %<fan_out>d instability: %<instability>s',
|
|
54
|
+
score: format_score(row[:score]), class: row[:classification],
|
|
55
|
+
fan_in: row[:fan_in], fan_out: row[:fan_out],
|
|
56
|
+
instability: format_score(row[:instability]))
|
|
57
|
+
end
|
|
58
|
+
@stdout.puts
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def emit_section(title, paths, description)
|
|
62
|
+
scored, unscored = paths.partition { |p| @rows.key?(p) }
|
|
63
|
+
scored_rows = scored.map { |p| @rows[p] }.sort_by { |r| -r[:score] }.first(MAX_ROWS)
|
|
64
|
+
|
|
65
|
+
@stdout.puts " ── #{title} (#{paths.length} files) #{description} ──"
|
|
66
|
+
@stdout.puts
|
|
67
|
+
|
|
68
|
+
if scored_rows.empty?
|
|
69
|
+
@stdout.puts ' (none in scored file set)'
|
|
70
|
+
else
|
|
71
|
+
@stdout.puts format(' %<rank>4s %<path>-50s %<score>6s %<class>-6s %<fan_in>6s %<fan_out>6s ' \
|
|
72
|
+
'%<instability>11s',
|
|
73
|
+
rank: 'rank', path: 'file', score: 'score', class: 'class',
|
|
74
|
+
fan_in: 'fan_in', fan_out: 'fan_out', instability: 'instability')
|
|
75
|
+
scored_rows.each_with_index do |row, i|
|
|
76
|
+
@stdout.puts format(' %<rank>4d %<path>-50s %<score>6s %<class>-6s %<fan_in>6d %<fan_out>6d ' \
|
|
77
|
+
'%<instability>11s',
|
|
78
|
+
rank: i + 1, path: row[:path], score: format_score(row[:score]),
|
|
79
|
+
class: row[:classification], fan_in: row[:fan_in], fan_out: row[:fan_out],
|
|
80
|
+
instability: format_score(row[:instability]))
|
|
81
|
+
end
|
|
82
|
+
@stdout.puts " ... and #{unscored.length} unscored (gems, generated files)" if unscored.any?
|
|
83
|
+
end
|
|
84
|
+
@stdout.puts
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def emit_coupling_section
|
|
88
|
+
@stdout.puts format(
|
|
89
|
+
' ── Temporal Coupling (%<days>d-day window, min %<min>d co-changes, threshold %<threshold>.2f) ──',
|
|
90
|
+
days: @coupling.churn_days, min: @coupling.min_commits, threshold: @coupling.threshold
|
|
91
|
+
)
|
|
92
|
+
@stdout.puts
|
|
93
|
+
|
|
94
|
+
partners = @coupling.data[@target] || []
|
|
95
|
+
if partners.empty?
|
|
96
|
+
@stdout.puts ' (none above threshold)'
|
|
97
|
+
else
|
|
98
|
+
@stdout.puts ' coupling co_changes file'
|
|
99
|
+
partners.each do |entry|
|
|
100
|
+
@stdout.puts format(' %<coupling>8s %<co_changes>10d %<path>s',
|
|
101
|
+
coupling: format_score(entry[:coupling]),
|
|
102
|
+
co_changes: entry[:co_changes],
|
|
103
|
+
path: entry[:path])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
@stdout.puts
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def format_score(score)
|
|
110
|
+
format('%.4f', score)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|