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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StudFinder
4
+ VERSION = '0.1.0'
5
+ end
@@ -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: []