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,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'set'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
module StudFinder
|
|
9
|
+
class JsFanIn
|
|
10
|
+
Result = Struct.new(:counts, :fan_out_counts, :edges, :warnings, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
TOOL_MISSING = 'js_tools_missing'
|
|
13
|
+
TIMEOUT = 'js_depcruise_timeout'
|
|
14
|
+
|
|
15
|
+
def initialize(repo_path:, files:, js_timeout: 60, stderr: $stderr)
|
|
16
|
+
@repo_path = File.expand_path(repo_path)
|
|
17
|
+
@files = files
|
|
18
|
+
@js_timeout = js_timeout
|
|
19
|
+
@stderr = stderr
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
return missing_tools unless node_available?
|
|
24
|
+
|
|
25
|
+
depcruise = depcruise_binary
|
|
26
|
+
return missing_tools unless depcruise
|
|
27
|
+
|
|
28
|
+
stdout, _stderr, status = run_depcruise(depcruise)
|
|
29
|
+
return missing_tools unless status.success?
|
|
30
|
+
|
|
31
|
+
counts, fan_out_counts, edges = parse(stdout)
|
|
32
|
+
Result.new(counts: counts, fan_out_counts: fan_out_counts, edges: edges, warnings: [])
|
|
33
|
+
rescue Timeout::Error
|
|
34
|
+
warn(TIMEOUT)
|
|
35
|
+
Result.new(counts: zero_counts, fan_out_counts: zero_counts, edges: empty_edges, warnings: [TIMEOUT])
|
|
36
|
+
rescue JSON::ParserError, KeyError, TypeError
|
|
37
|
+
missing_tools
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def node_available?
|
|
43
|
+
_stdout, _stderr, status = Open3.capture3('node', '--version')
|
|
44
|
+
status.success?
|
|
45
|
+
rescue Errno::ENOENT
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def depcruise_binary
|
|
50
|
+
local = File.join(@repo_path, 'node_modules/.bin/depcruise')
|
|
51
|
+
return local if File.executable?(local)
|
|
52
|
+
|
|
53
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
|
|
54
|
+
candidate = File.join(dir, 'depcruise')
|
|
55
|
+
return 'depcruise' if File.file?(candidate) && File.executable?(candidate)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_depcruise(depcruise)
|
|
62
|
+
Timeout.timeout(@js_timeout) do
|
|
63
|
+
Open3.capture3(depcruise, '--output-type', 'json', '.', chdir: @repo_path)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse(stdout)
|
|
68
|
+
payload = JSON.parse(stdout)
|
|
69
|
+
file_set = @files.to_h { |file| [file, true] }
|
|
70
|
+
counts = zero_counts
|
|
71
|
+
fan_out_counts = zero_counts
|
|
72
|
+
dependents = @files.to_h { |file| [file, []] }
|
|
73
|
+
dependencies = @files.to_h { |file| [file, []] }
|
|
74
|
+
seen_edges = Set.new
|
|
75
|
+
|
|
76
|
+
Array(payload.fetch('modules')).each do |mod|
|
|
77
|
+
source = normalize_path(mod.fetch('source'))
|
|
78
|
+
next unless file_set[source]
|
|
79
|
+
|
|
80
|
+
Array(mod['dependencies']).each do |dependency|
|
|
81
|
+
target = normalize_path(dependency['resolved'].to_s)
|
|
82
|
+
next unless file_set[target]
|
|
83
|
+
next if target == source
|
|
84
|
+
next unless seen_edges.add?([source, target])
|
|
85
|
+
|
|
86
|
+
counts[target] += 1
|
|
87
|
+
fan_out_counts[source] += 1
|
|
88
|
+
dependents[target] << source
|
|
89
|
+
dependencies[source] << target
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
edges = @files.to_h do |file|
|
|
94
|
+
[file, { dependents: dependents[file], dependencies: dependencies[file] }]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[counts, fan_out_counts, edges]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_path(path)
|
|
101
|
+
path.delete_prefix('./')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def missing_tools
|
|
105
|
+
warn(TOOL_MISSING)
|
|
106
|
+
Result.new(counts: zero_counts, fan_out_counts: zero_counts, edges: empty_edges, warnings: [TOOL_MISSING])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def zero_counts
|
|
110
|
+
@files.to_h { |file| [file, 0] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def empty_edges
|
|
114
|
+
@files.to_h { |file| [file, { dependents: [], dependencies: [] }] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def warn(code)
|
|
118
|
+
@stderr.puts "Warning: #{code}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StudFinder
|
|
4
|
+
module Normalizer
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def percentile_rank(raw_counts, files)
|
|
8
|
+
return {} if files.empty?
|
|
9
|
+
|
|
10
|
+
values = files.map { |file| raw_counts.fetch(file, 0).to_f }
|
|
11
|
+
return files.to_h { |file| [file, 0.0] } if files.length == 1 || values.uniq.length == 1
|
|
12
|
+
|
|
13
|
+
denominator = files.length - 1
|
|
14
|
+
sorted_values = values.sort
|
|
15
|
+
|
|
16
|
+
files.to_h do |file|
|
|
17
|
+
raw = raw_counts.fetch(file, 0).to_f
|
|
18
|
+
[file, lower_bound(sorted_values, raw).to_f / denominator]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lower_bound(sorted_values, raw)
|
|
23
|
+
low = 0
|
|
24
|
+
high = sorted_values.length
|
|
25
|
+
|
|
26
|
+
while low < high
|
|
27
|
+
mid = (low + high) / 2
|
|
28
|
+
if sorted_values[mid] < raw
|
|
29
|
+
low = mid + 1
|
|
30
|
+
else
|
|
31
|
+
high = mid
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
low
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'normalizer'
|
|
4
|
+
|
|
5
|
+
module StudFinder
|
|
6
|
+
class Scorer
|
|
7
|
+
DEFAULT_WEIGHTS = { fan_in: 0.25, fan_out: 0.10, complexity: 0.25, churn: 0.25, coverage: 0.15 }.freeze
|
|
8
|
+
RENORMALIZED_KEYS = %i[fan_in fan_out complexity churn].freeze
|
|
9
|
+
|
|
10
|
+
class ValidationError < StandardError; end
|
|
11
|
+
|
|
12
|
+
attr_reader :normalized_weights
|
|
13
|
+
|
|
14
|
+
def initialize(files:, fan_in:, fan_out:, complexity:, churn:, churn_lines: nil, coverage: nil,
|
|
15
|
+
weights: DEFAULT_WEIGHTS, branch_threshold: 50, trunk_threshold: 85, coupling: nil)
|
|
16
|
+
@files = files
|
|
17
|
+
@fan_in = fan_in
|
|
18
|
+
@fan_out = fan_out
|
|
19
|
+
@complexity = complexity
|
|
20
|
+
@churn = churn
|
|
21
|
+
@churn_lines = churn_lines || churn
|
|
22
|
+
@coverage = coverage
|
|
23
|
+
@weights = weights
|
|
24
|
+
@branch_threshold = branch_threshold
|
|
25
|
+
@trunk_threshold = trunk_threshold
|
|
26
|
+
@coupling = coupling
|
|
27
|
+
validate!
|
|
28
|
+
@normalized_weights = normalize_weights
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
pcts = {
|
|
33
|
+
fan_in: Normalizer.percentile_rank(@fan_in, @files),
|
|
34
|
+
fan_out: Normalizer.percentile_rank(@fan_out, @files),
|
|
35
|
+
complexity: Normalizer.percentile_rank(@complexity, @files),
|
|
36
|
+
churn: composite_churn_pct,
|
|
37
|
+
instability: instability_pct,
|
|
38
|
+
coupling: coupling_pct
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
rows = @files.each_with_index.map do |file, index|
|
|
42
|
+
score = weighted_score(file, pcts)
|
|
43
|
+
[index, result_row(file, score, pcts)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rows.sort_by { |index, row| [-row[:score], index] }
|
|
47
|
+
.map.with_index(1) do |(_index, row), rank|
|
|
48
|
+
row.merge(rank: rank)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate!
|
|
55
|
+
return if @branch_threshold < @trunk_threshold
|
|
56
|
+
|
|
57
|
+
raise ValidationError, 'Error: branch-threshold must be strictly less than trunk-threshold.'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_weights
|
|
61
|
+
active_total = RENORMALIZED_KEYS.sum { |key| @weights.fetch(key, 0.0) }
|
|
62
|
+
if !coverage_available? && active_total <= 0.0
|
|
63
|
+
raise ValidationError,
|
|
64
|
+
'Error: active weights must be greater than 0.0.'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@active_weights = if active_total > 0.0
|
|
68
|
+
RENORMALIZED_KEYS.to_h { |key| [key, @weights.fetch(key, 0.0) / active_total] }
|
|
69
|
+
.merge(coverage: nil)
|
|
70
|
+
else
|
|
71
|
+
RENORMALIZED_KEYS.to_h { |key| [key, 0.0] }.merge(coverage: nil)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return @weights if coverage_available?
|
|
75
|
+
|
|
76
|
+
@active_weights
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def weighted_score(file, pcts)
|
|
80
|
+
return active_weights_score(file, pcts) unless coverage_available?
|
|
81
|
+
|
|
82
|
+
file_coverage = @coverage.fetch(file, 0.0)
|
|
83
|
+
structural_score(@normalized_weights, file, pcts) +
|
|
84
|
+
(@normalized_weights[:coverage] * (1.0 - file_coverage))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def active_weights_score(file, pcts)
|
|
88
|
+
structural_score(@active_weights, file, pcts)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def structural_score(weights, file, pcts)
|
|
92
|
+
(weights[:fan_in] * pcts[:fan_in].fetch(file)) +
|
|
93
|
+
(weights[:fan_out] * pcts[:fan_out].fetch(file)) +
|
|
94
|
+
(weights[:complexity] * pcts[:complexity].fetch(file)) +
|
|
95
|
+
(weights[:churn] * pcts[:churn].fetch(file))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def composite_churn_pct
|
|
99
|
+
count_pct = Normalizer.percentile_rank(@churn, @files)
|
|
100
|
+
line_pct = Normalizer.percentile_rank(@churn_lines, @files)
|
|
101
|
+
|
|
102
|
+
@files.to_h do |file|
|
|
103
|
+
[file, (0.5 * count_pct.fetch(file)) + (0.5 * line_pct.fetch(file))]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def result_row(file, score, pcts)
|
|
108
|
+
fi = @fan_in.fetch(file, 0).to_i
|
|
109
|
+
fo = @fan_out.fetch(file, 0).to_i
|
|
110
|
+
{
|
|
111
|
+
path: file,
|
|
112
|
+
score: score.round(4),
|
|
113
|
+
classification: classification(pcts[:fan_in].fetch(file)),
|
|
114
|
+
fan_in: fi,
|
|
115
|
+
fan_in_pct: pcts[:fan_in].fetch(file).round(4),
|
|
116
|
+
fan_out: fo,
|
|
117
|
+
fan_out_pct: pcts[:fan_out].fetch(file).round(4),
|
|
118
|
+
instability: instability(fi, fo),
|
|
119
|
+
instability_pct: pcts[:instability].fetch(file).round(4),
|
|
120
|
+
complexity: @complexity.fetch(file, 0).to_i,
|
|
121
|
+
complexity_pct: pcts[:complexity].fetch(file).round(4),
|
|
122
|
+
churn_commits: @churn.fetch(file, 0).to_i,
|
|
123
|
+
churn_lines: @churn_lines.fetch(file, 0).to_i,
|
|
124
|
+
churn_pct: pcts[:churn].fetch(file).round(4),
|
|
125
|
+
**coupling_fields(file, pcts),
|
|
126
|
+
coverage: coverage_available? ? @coverage.fetch(file, 0.0).round(4) : nil
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def coupling_fields(file, pcts)
|
|
131
|
+
partner = @coupling&.fetch(file, nil)
|
|
132
|
+
{
|
|
133
|
+
max_coupling: partner ? partner.fetch(:max_coupling, 0.0).to_f.round(4) : 0.0,
|
|
134
|
+
max_coupling_partner: partner ? partner.fetch(:max_coupling_partner, nil).to_s : '',
|
|
135
|
+
coupling_partners: partner ? partner.fetch(:partners, 0).to_i : 0,
|
|
136
|
+
coupling_pct: pcts[:coupling].fetch(file, 0.0).round(4)
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def instability(fan_in, fan_out)
|
|
141
|
+
total = fan_in + fan_out
|
|
142
|
+
return 0.0 if total.zero?
|
|
143
|
+
|
|
144
|
+
(fan_out.to_f / total).round(4)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def instability_pct
|
|
148
|
+
values = @files.to_h { |file| [file, instability(@fan_in.fetch(file, 0).to_i, @fan_out.fetch(file, 0).to_i)] }
|
|
149
|
+
Normalizer.percentile_rank(values, @files)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def coupling_pct
|
|
153
|
+
values = @files.to_h do |file|
|
|
154
|
+
partner = @coupling&.fetch(file, nil)
|
|
155
|
+
[file, partner ? partner.fetch(:max_coupling, 0.0).to_f : 0.0]
|
|
156
|
+
end
|
|
157
|
+
Normalizer.percentile_rank(values, @files)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def coverage_available?
|
|
161
|
+
!@coverage.nil?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def classification(fan_in_pct)
|
|
165
|
+
return 'trunk' if fan_in_pct >= @trunk_threshold / 100.0
|
|
166
|
+
return 'branch' if fan_in_pct >= @branch_threshold / 100.0
|
|
167
|
+
|
|
168
|
+
'leaf'
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module StudFinder
|
|
6
|
+
class TemporalCoupling
|
|
7
|
+
Result = Struct.new(:pairs, :warnings, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
SHA_PATTERN = /\A[0-9a-f]{40}\z/
|
|
10
|
+
|
|
11
|
+
def initialize(repo_path:, files:, days:, min_co_changes: 5, coupling_threshold: 0.30)
|
|
12
|
+
@repo_path = File.expand_path(repo_path)
|
|
13
|
+
@file_set = files.to_h { |f| [f, true] }
|
|
14
|
+
@days = days
|
|
15
|
+
@min_co_changes = min_co_changes
|
|
16
|
+
@coupling_threshold = coupling_threshold
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
stdout, _err, status = git_log
|
|
21
|
+
return Result.new(pairs: {}, warnings: ['git_error']) unless status.success?
|
|
22
|
+
|
|
23
|
+
commits = parse_commits(stdout)
|
|
24
|
+
co_matrix = build_co_change_matrix(commits)
|
|
25
|
+
own_changes = build_own_changes(commits)
|
|
26
|
+
Result.new(pairs: build_pairs(co_matrix, own_changes), warnings: [])
|
|
27
|
+
rescue Errno::ENOENT
|
|
28
|
+
Result.new(pairs: {}, warnings: ['git_not_found'])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def git_log
|
|
34
|
+
Open3.capture3(
|
|
35
|
+
'git', '-C', @repo_path, 'log',
|
|
36
|
+
"--since=#{@days} days ago",
|
|
37
|
+
'--diff-filter=ACDMR',
|
|
38
|
+
'--name-only',
|
|
39
|
+
'--relative',
|
|
40
|
+
'--format=%H'
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse_commits(stdout)
|
|
45
|
+
commits = []
|
|
46
|
+
current = nil
|
|
47
|
+
stdout.each_line do |raw|
|
|
48
|
+
line = raw.chomp
|
|
49
|
+
if SHA_PATTERN.match?(line)
|
|
50
|
+
commits << current if current&.any?
|
|
51
|
+
current = []
|
|
52
|
+
elsif !line.empty? && current
|
|
53
|
+
relative = normalize_path(line)
|
|
54
|
+
# Guard against the same path appearing twice in one commit's --name-only
|
|
55
|
+
# output: a dup would inflate own_changes and create a spurious self-pair.
|
|
56
|
+
current << relative if @file_set[relative] && !current.include?(relative)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
commits << current if current&.any?
|
|
60
|
+
commits
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_co_change_matrix(commits)
|
|
64
|
+
matrix = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
65
|
+
commits.each do |files|
|
|
66
|
+
files.combination(2).each do |a, b|
|
|
67
|
+
a, b = b, a if a > b
|
|
68
|
+
matrix[a][b] += 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
matrix
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_own_changes(commits)
|
|
75
|
+
counts = Hash.new(0)
|
|
76
|
+
commits.each { |files| files.each { |f| counts[f] += 1 } }
|
|
77
|
+
counts
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_pairs(co_matrix, own_changes)
|
|
81
|
+
pairs = Hash.new { |h, k| h[k] = [] }
|
|
82
|
+
co_matrix.each do |a, partners|
|
|
83
|
+
partners.each do |b, count|
|
|
84
|
+
next if count < @min_co_changes
|
|
85
|
+
|
|
86
|
+
min_own = [own_changes[a], own_changes[b]].min
|
|
87
|
+
next if min_own.zero?
|
|
88
|
+
|
|
89
|
+
coupling = (count.to_f / min_own).round(4)
|
|
90
|
+
next if coupling < @coupling_threshold
|
|
91
|
+
|
|
92
|
+
pairs[a] << { path: b, coupling: coupling, co_changes: count, own_changes: own_changes[b] }
|
|
93
|
+
pairs[b] << { path: a, coupling: coupling, co_changes: count, own_changes: own_changes[a] }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
pairs.transform_values { |p| p.sort_by { |e| -e[:coupling] } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_path(path)
|
|
100
|
+
absolute = File.expand_path(path, @repo_path)
|
|
101
|
+
absolute.start_with?("#{@repo_path}/") ? absolute.delete_prefix("#{@repo_path}/") : path
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/stud_finder.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'stud_finder/version'
|
|
4
|
+
require_relative 'stud_finder/file_collector'
|
|
5
|
+
require_relative 'stud_finder/fan_in'
|
|
6
|
+
require_relative 'stud_finder/js_fan_in'
|
|
7
|
+
require_relative 'stud_finder/js_complexity'
|
|
8
|
+
require_relative 'stud_finder/normalizer'
|
|
9
|
+
require_relative 'stud_finder/coverage/cobertura'
|
|
10
|
+
require_relative 'stud_finder/coverage/detector'
|
|
11
|
+
require_relative 'stud_finder/coverage/lcov'
|
|
12
|
+
require_relative 'stud_finder/coverage/resultset'
|
|
13
|
+
require_relative 'stud_finder/scorer'
|
|
14
|
+
require_relative 'stud_finder/cli'
|
metadata
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: stud-finder
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- bazfer
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-03 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: csv
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '4.0'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '3.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '4.0'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: rexml
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '4.0'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.0'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '4.0'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: rubocop
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '1.0'
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '2.0'
|
|
63
|
+
type: :runtime
|
|
64
|
+
prerelease: false
|
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '1.0'
|
|
70
|
+
- - "<"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '2.0'
|
|
73
|
+
- !ruby/object:Gem::Dependency
|
|
74
|
+
name: rubocop-ast
|
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '1.0'
|
|
80
|
+
- - "<"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.0'
|
|
83
|
+
type: :runtime
|
|
84
|
+
prerelease: false
|
|
85
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '1.0'
|
|
90
|
+
- - "<"
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '2.0'
|
|
93
|
+
- !ruby/object:Gem::Dependency
|
|
94
|
+
name: rake
|
|
95
|
+
requirement: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - "~>"
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '13.0'
|
|
100
|
+
type: :development
|
|
101
|
+
prerelease: false
|
|
102
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - "~>"
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '13.0'
|
|
107
|
+
- !ruby/object:Gem::Dependency
|
|
108
|
+
name: rspec
|
|
109
|
+
requirement: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - "~>"
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '3.12'
|
|
114
|
+
type: :development
|
|
115
|
+
prerelease: false
|
|
116
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - "~>"
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: '3.12'
|
|
121
|
+
- !ruby/object:Gem::Dependency
|
|
122
|
+
name: simplecov
|
|
123
|
+
requirement: !ruby/object:Gem::Requirement
|
|
124
|
+
requirements:
|
|
125
|
+
- - "~>"
|
|
126
|
+
- !ruby/object:Gem::Version
|
|
127
|
+
version: '0.22'
|
|
128
|
+
type: :development
|
|
129
|
+
prerelease: false
|
|
130
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
131
|
+
requirements:
|
|
132
|
+
- - "~>"
|
|
133
|
+
- !ruby/object:Gem::Version
|
|
134
|
+
version: '0.22'
|
|
135
|
+
description: A code risk scoring CLI for Ruby and JS/TS projects. Ranks every file
|
|
136
|
+
by five signals — fan-in (blast radius), fan-out, cyclomatic complexity, git churn,
|
|
137
|
+
and test coverage — with temporal coupling analysis and a diff mode for scoring
|
|
138
|
+
only the files changed in a PR. Table, JSON, CSV, and Markdown output.
|
|
139
|
+
email:
|
|
140
|
+
- bazfer@gmail.com
|
|
141
|
+
executables:
|
|
142
|
+
- stud-finder
|
|
143
|
+
extensions: []
|
|
144
|
+
extra_rdoc_files: []
|
|
145
|
+
files:
|
|
146
|
+
- CHANGELOG.md
|
|
147
|
+
- LICENSE
|
|
148
|
+
- PRODUCT.md
|
|
149
|
+
- README.md
|
|
150
|
+
- VISION.md
|
|
151
|
+
- bin/stud-finder
|
|
152
|
+
- lib/stud-finder.rb
|
|
153
|
+
- lib/stud_finder.rb
|
|
154
|
+
- lib/stud_finder/churn.rb
|
|
155
|
+
- lib/stud_finder/cli.rb
|
|
156
|
+
- lib/stud_finder/complexity.rb
|
|
157
|
+
- lib/stud_finder/coverage/cobertura.rb
|
|
158
|
+
- lib/stud_finder/coverage/detector.rb
|
|
159
|
+
- lib/stud_finder/coverage/lcov.rb
|
|
160
|
+
- lib/stud_finder/coverage/resultset.rb
|
|
161
|
+
- lib/stud_finder/diff.rb
|
|
162
|
+
- lib/stud_finder/edges.rb
|
|
163
|
+
- lib/stud_finder/fan_in.rb
|
|
164
|
+
- lib/stud_finder/file_collector.rb
|
|
165
|
+
- lib/stud_finder/js_complexity.rb
|
|
166
|
+
- lib/stud_finder/js_fan_in.rb
|
|
167
|
+
- lib/stud_finder/normalizer.rb
|
|
168
|
+
- lib/stud_finder/scorer.rb
|
|
169
|
+
- lib/stud_finder/temporal_coupling.rb
|
|
170
|
+
- lib/stud_finder/version.rb
|
|
171
|
+
homepage: https://github.com/bazfer/stud-finder
|
|
172
|
+
licenses:
|
|
173
|
+
- MIT
|
|
174
|
+
metadata:
|
|
175
|
+
homepage_uri: https://github.com/bazfer/stud-finder
|
|
176
|
+
source_code_uri: https://github.com/bazfer/stud-finder
|
|
177
|
+
changelog_uri: https://github.com/bazfer/stud-finder/blob/main/CHANGELOG.md
|
|
178
|
+
bug_tracker_uri: https://github.com/bazfer/stud-finder/issues
|
|
179
|
+
rubygems_mfa_required: 'true'
|
|
180
|
+
post_install_message:
|
|
181
|
+
rdoc_options: []
|
|
182
|
+
require_paths:
|
|
183
|
+
- lib
|
|
184
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
185
|
+
requirements:
|
|
186
|
+
- - ">="
|
|
187
|
+
- !ruby/object:Gem::Version
|
|
188
|
+
version: '3.2'
|
|
189
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
190
|
+
requirements:
|
|
191
|
+
- - ">="
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: '0'
|
|
194
|
+
requirements: []
|
|
195
|
+
rubygems_version: 3.4.10
|
|
196
|
+
signing_key:
|
|
197
|
+
specification_version: 4
|
|
198
|
+
summary: Rank files by structural risk in Ruby and JavaScript/TypeScript codebases.
|
|
199
|
+
test_files: []
|