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.
@@ -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