stud-finder 0.1.0 → 0.4.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,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'pathname'
5
+ require 'set'
6
+
7
+ module StudFinder
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Newness
10
+ DEFAULT_DAYS = 30
11
+ DEFAULT_MIN_COMMITS = 3
12
+ SECONDS_PER_DAY = 86_400
13
+ SHALLOW_CLONE_WARNING = {
14
+ code: 'shallow_clone_newness_disabled',
15
+ message: 'shallow git clone detected; newness rules disabled (use fetch-depth: 0 in CI for full newness behavior)'
16
+ }.freeze
17
+
18
+ History = Struct.new(:first_commit_epoch, :total_commits, keyword_init: true)
19
+
20
+ class Error < StandardError; end
21
+
22
+ def initialize(repo_path:, files:, days: DEFAULT_DAYS, min_commits: DEFAULT_MIN_COMMITS, enabled: true,
23
+ now: Time.now)
24
+ @repo_path = File.expand_path(repo_path)
25
+ @files = files
26
+ @days = days
27
+ @min_commits = min_commits
28
+ @enabled = enabled
29
+ @now = now.to_i
30
+ end
31
+
32
+ def call
33
+ return self.class.disabled_metadata(@files) unless @enabled
34
+ return self.class.disabled_metadata(@files) if self.class.shallow_repository?(@repo_path)
35
+
36
+ history = git_history
37
+ @files.to_h do |file|
38
+ first_epoch = history.first_commit_epoch[file]
39
+ total_commits = history.total_commits.fetch(file, 0)
40
+ age_days = age_days(first_epoch)
41
+ is_new = new_file?(age_days, total_commits)
42
+
43
+ [file, { new_file: is_new, age_days: age_days || 0, total_commits: total_commits, escalation: '',
44
+ metadata_available: true }]
45
+ end
46
+ end
47
+
48
+ def self.apply(rows:, edges:, metadata:, branch_threshold: 'branch', coverage: nil)
49
+ rows = rows.map do |row|
50
+ file_metadata = metadata.fetch(row[:path], nil)
51
+ nf = newness_fields(file_metadata)
52
+ # Preserve floor escalation from the scorer (complexity_floor / fan_in_floor); newness rules
53
+ # may still override it below for new files (trunk_adjacent or recency_floor always win).
54
+ existing_escalation = row.fetch(:escalation, '')
55
+ nf = nf.merge(escalation: existing_escalation) unless existing_escalation.to_s.empty?
56
+ row.merge(nf).merge(evidence: evidence(file_metadata, coverage&.key?(row[:path])))
57
+ end
58
+ # Rule 2: scorer "trunk" now means high composite risk, not fan-in-only structural coupling.
59
+ trunk_paths = rows.select { |row| row[:classification] == 'trunk' }.to_set { |row| row[:path] }
60
+
61
+ rows.map do |row|
62
+ next row unless row[:new_file]
63
+
64
+ dependencies = edges.fetch(row[:path], {}).fetch(:dependencies, [])
65
+ if dependencies.any? { |path| trunk_paths.include?(path) }
66
+ row.merge(classification: 'trunk', escalation: 'trunk_adjacent')
67
+ elsif row[:classification] == 'leaf' || %w[complexity_floor fan_in_floor].include?(row[:escalation].to_s)
68
+ row.merge(classification: branch_threshold, escalation: 'recency_floor')
69
+ else
70
+ row
71
+ end
72
+ end
73
+ end
74
+
75
+ def self.disabled_metadata(files)
76
+ files.to_h do |file|
77
+ [file, { new_file: false, age_days: 0, total_commits: 0, escalation: '', metadata_available: false }]
78
+ end
79
+ end
80
+
81
+ def self.shallow_repository?(repo_path)
82
+ repo_path = File.expand_path(repo_path)
83
+ stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--is-shallow-repository', chdir: repo_path)
84
+ return stdout.strip == 'true' if status.success?
85
+
86
+ git_shallow_file?(repo_path)
87
+ rescue Errno::ENOENT
88
+ git_shallow_file?(repo_path)
89
+ end
90
+
91
+ def self.newness_fields(metadata)
92
+ metadata ||= { new_file: false, age_days: 0, total_commits: 0, metadata_available: false }
93
+ {
94
+ new_file: metadata.fetch(:new_file, false),
95
+ age_days: metadata.fetch(:age_days, 0),
96
+ total_commits: metadata.fetch(:total_commits, 0),
97
+ newness_metadata_available: metadata.fetch(:metadata_available, false),
98
+ escalation: ''
99
+ }
100
+ end
101
+
102
+ def self.evidence(metadata, explicit_coverage)
103
+ return nil unless metadata&.fetch(:metadata_available, false)
104
+
105
+ age_component = [metadata.fetch(:age_days, 0) / 30.0, 1.0].min
106
+ commits_component = [metadata.fetch(:total_commits, 0) / 3.0, 1.0].min
107
+ coverage_component = explicit_coverage ? 1.0 : 0.0
108
+
109
+ ((age_component + commits_component + coverage_component) / 3.0).round(4)
110
+ end
111
+
112
+ def self.git_shallow_file?(repo_path)
113
+ return true if File.exist?(File.join(repo_path, '.git', 'shallow'))
114
+
115
+ stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--git-dir', chdir: repo_path)
116
+ status.success? && File.exist?(File.join(File.expand_path(stdout.strip, repo_path), 'shallow'))
117
+ rescue Errno::ENOENT
118
+ false
119
+ end
120
+ private_class_method :git_shallow_file?
121
+
122
+ private
123
+
124
+ def empty_history
125
+ History.new(first_commit_epoch: {}, total_commits: {})
126
+ end
127
+
128
+ def git_history
129
+ # Use committer time (%ct), not author time, because newness is about when code landed in this repo.
130
+ # Cherry-picked or imported files can carry old author dates while still being newly introduced here.
131
+ stdout, _stderr, status = Open3.capture3(
132
+ 'git', '-C', @repo_path, 'log',
133
+ '--format=%x1e%ct',
134
+ '--name-status',
135
+ '--find-renames',
136
+ '--diff-filter=ACMRD'
137
+ )
138
+ raise Error, "Error: #{@repo_path} is not a git repository." unless status.success?
139
+
140
+ parse_history(stdout)
141
+ rescue Errno::ENOENT
142
+ raise Error, 'Error: git not found in PATH.'
143
+ end
144
+
145
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
146
+ def parse_history(stdout)
147
+ wanted = @files.to_set
148
+ first_commit_epoch = {}
149
+ total_commits = Hash.new(0)
150
+ aliases = {}
151
+ current_epoch = nil
152
+ touched = Set.new
153
+ lineage_boundaries = Set.new
154
+
155
+ flush_commit = lambda do
156
+ touched.each do |file|
157
+ total_commits[file] += 1
158
+ first_commit_epoch[file] = [first_commit_epoch[file], current_epoch].compact.min if current_epoch
159
+ end
160
+ touched.clear
161
+ end
162
+
163
+ stdout.each_line(chomp: true) do |line|
164
+ if line.start_with?("\x1e")
165
+ flush_commit.call if current_epoch
166
+ current_epoch = line.delete_prefix("\x1e").to_i
167
+ next
168
+ end
169
+
170
+ deleted_path = deleted_path_for_status(line)
171
+ if deleted_path
172
+ canonical = trackable_canonical_path(deleted_path, aliases)
173
+ lineage_boundaries << deleted_path if canonical && wanted.include?(canonical)
174
+ next
175
+ end
176
+
177
+ paths_for_status(line).each do |path|
178
+ canonical = trackable_canonical_path(path, aliases)
179
+ next unless canonical && wanted.include?(canonical)
180
+ next if lineage_boundaries.include?(path)
181
+
182
+ touched << canonical
183
+ end
184
+
185
+ old_path, new_path = rename_paths_for_status(line)
186
+ next unless old_path && new_path
187
+
188
+ new_rebased = rebase_to_analysis_root(new_path)
189
+ next unless new_rebased
190
+
191
+ canonical_new = canonical_path(new_rebased, aliases)
192
+ next unless wanted.include?(canonical_new) && !lineage_boundaries.include?(old_path) &&
193
+ !lineage_boundaries.include?(new_path)
194
+
195
+ touched << canonical_new
196
+ aliases[alias_key_for(old_path)] = canonical_new
197
+ end
198
+ flush_commit.call if current_epoch
199
+
200
+ History.new(first_commit_epoch: first_commit_epoch, total_commits: total_commits)
201
+ end
202
+
203
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
204
+
205
+ def paths_for_status(line)
206
+ return [] if line.nil? || line.empty?
207
+
208
+ parts = line.split("\t")
209
+ status = parts.first.to_s
210
+ if status.start_with?('R') || status.start_with?('C')
211
+ [parts[2]].compact
212
+ else
213
+ [parts[1]].compact
214
+ end
215
+ end
216
+
217
+ def rename_paths_for_status(line)
218
+ return [nil, nil] if line.nil? || line.empty?
219
+
220
+ parts = line.split("\t")
221
+ return [nil, nil] unless parts.first.to_s.start_with?('R')
222
+
223
+ [parts[1], parts[2]]
224
+ end
225
+
226
+ def deleted_path_for_status(line)
227
+ return nil if line.nil? || line.empty?
228
+
229
+ parts = line.split("\t")
230
+ return nil unless parts.first == 'D'
231
+
232
+ parts[1]
233
+ end
234
+
235
+ def trackable_canonical_path(raw_path, aliases)
236
+ rebased = rebase_to_analysis_root(raw_path)
237
+ return nil unless !rebased.nil? || aliases.key?(raw_path)
238
+
239
+ canonical_path(rebased || raw_path, aliases)
240
+ end
241
+
242
+ def alias_key_for(old_path)
243
+ rebase_to_analysis_root(old_path) || old_path
244
+ end
245
+
246
+ def canonical_path(path, aliases)
247
+ canonical = path
248
+ seen = Set.new
249
+ while aliases.key?(canonical) && !seen.include?(canonical)
250
+ seen << canonical
251
+ canonical = aliases[canonical]
252
+ end
253
+ canonical
254
+ end
255
+
256
+ # git log emits paths relative to the repository root even when -C points at
257
+ # an analysis subdirectory. Rebase those records to the analysis root so they
258
+ # can be compared with FileCollector paths (for example, app/models/user.rb
259
+ # becomes models/user.rb when scanning <repo>/app).
260
+ def rebase_to_analysis_root(path)
261
+ return nil if path.nil? || path.empty?
262
+ return path unless analysis_root_prefix
263
+
264
+ path.start_with?(analysis_root_prefix) ? path.delete_prefix(analysis_root_prefix) : nil
265
+ end
266
+
267
+ def analysis_root_prefix
268
+ return @analysis_root_prefix if defined?(@analysis_root_prefix)
269
+
270
+ @analysis_root_prefix = begin
271
+ toplevel = git_toplevel
272
+ analysis_abs = File.realpath(@repo_path)
273
+ if toplevel.nil? || analysis_abs == toplevel
274
+ nil
275
+ else
276
+ prefix = Pathname.new(analysis_abs).relative_path_from(Pathname.new(toplevel)).to_s
277
+ prefix.empty? || prefix == '.' || prefix.start_with?('..') ? nil : "#{prefix}/"
278
+ end
279
+ end
280
+ rescue Errno::ENOENT
281
+ nil
282
+ end
283
+
284
+ def git_toplevel
285
+ return @git_toplevel if defined?(@git_toplevel)
286
+
287
+ stdout, _stderr, status = Open3.capture3('git', '-C', @repo_path, 'rev-parse', '--show-toplevel')
288
+ @git_toplevel = status.success? ? File.realpath(stdout.strip) : nil
289
+ end
290
+
291
+ def age_days(first_epoch)
292
+ return nil unless first_epoch
293
+
294
+ [((@now - first_epoch) / SECONDS_PER_DAY).floor, 0].max
295
+ end
296
+
297
+ def new_file?(age_days, total_commits)
298
+ within_age_window = @days.positive? && !age_days.nil? && age_days < @days
299
+ low_commit_count = @min_commits.positive? && total_commits < @min_commits
300
+ unknown_lineage = age_days.nil?
301
+
302
+ unknown_lineage || within_age_window || low_commit_count
303
+ end
304
+ end
305
+ # rubocop:enable Metrics/ClassLength
306
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StudFinder
4
+ # Extracts conservative Rails-style implicit constant references from Ruby ASTs.
5
+ class RailsInference
6
+ Inference = Struct.new(:node, :name, :absolute, keyword_init: true)
7
+
8
+ ASSOCIATIONS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
9
+ COLLECTION_ASSOCIATIONS = %i[has_many has_and_belongs_to_many].freeze
10
+ STRING_CONSTANTIZERS = %i[constantize safe_constantize].freeze
11
+ SAFE_CLASS_BODY_WRAPPERS = %i[with_options included class_eval class_exec].freeze
12
+ IRREGULAR_SINGULARS = {
13
+ 'people' => 'person',
14
+ 'children' => 'child',
15
+ 'men' => 'man',
16
+ 'women' => 'woman',
17
+ 'mice' => 'mouse',
18
+ 'geese' => 'goose'
19
+ }.freeze
20
+
21
+ def initialize(ast)
22
+ @ast = ast
23
+ end
24
+
25
+ def call
26
+ return [] unless @ast
27
+
28
+ @ast.each_node(:send).filter_map do |node|
29
+ association_inference(node) || string_constant_inference(node)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def association_inference(node)
36
+ _receiver, method_name, *args = *node
37
+ return unless class_body_call?(node)
38
+ return unless implicit_receiver?(node.receiver)
39
+
40
+ if ASSOCIATIONS.include?(method_name)
41
+ return association_reference(node, method_name, args)
42
+ elsif method_name == :composed_of
43
+ explicit_class = class_name_option(args)
44
+ return reference(node, explicit_class) if explicit_class.is_a?(String)
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ def string_constant_inference(node)
51
+ receiver, method_name, = *node
52
+ literal = string_literal(receiver)
53
+ return unless literal
54
+
55
+ if STRING_CONSTANTIZERS.include?(method_name)
56
+ reference(node, literal, absolute: true)
57
+ elsif method_name == :const_get
58
+ reference(node, literal)
59
+ end
60
+ end
61
+
62
+ def association_reference(node, method_name, args)
63
+ association_name = symbol_name(args.first)
64
+ return unless association_name
65
+ return if method_name == :belongs_to && polymorphic_belongs_to?(args)
66
+
67
+ explicit_class = class_name_option(args)
68
+ return if explicit_class == :dynamic
69
+
70
+ collection = COLLECTION_ASSOCIATIONS.include?(method_name)
71
+ name = explicit_class || inferred_association_class(association_name, collection: collection)
72
+ reference(node, name) if name
73
+ end
74
+
75
+ def reference(node, name, absolute: nil)
76
+ absolute = name.start_with?('::') if absolute.nil?
77
+ Inference.new(node: node, name: name.delete_prefix('::'), absolute: absolute)
78
+ end
79
+
80
+ def class_body_call?(node)
81
+ seen_class_or_module = false
82
+
83
+ node.each_ancestor do |ancestor|
84
+ return false if ancestor.def_type? || ancestor.defs_type?
85
+ return false if ancestor.block_type? && !safe_class_body_wrapper?(ancestor)
86
+
87
+ if ancestor.class_type? || ancestor.module_type?
88
+ seen_class_or_module = true
89
+ break
90
+ end
91
+ end
92
+
93
+ seen_class_or_module
94
+ end
95
+
96
+ def safe_class_body_wrapper?(node)
97
+ send_node = node.send_node
98
+ send_node && SAFE_CLASS_BODY_WRAPPERS.include?(send_node.method_name)
99
+ end
100
+
101
+ def implicit_receiver?(receiver)
102
+ receiver.nil? || receiver.self_type?
103
+ end
104
+
105
+ def symbol_name(node)
106
+ return unless node&.sym_type?
107
+
108
+ node.value.to_s
109
+ end
110
+
111
+ def class_name_option(args)
112
+ pair = option_pair(args, 'class_name')
113
+ return unless pair
114
+
115
+ string_literal(pair.value) || :dynamic
116
+ end
117
+
118
+ def polymorphic_belongs_to?(args)
119
+ pair = option_pair(args, 'polymorphic')
120
+ return false unless pair
121
+
122
+ !pair.value&.false_type?
123
+ end
124
+
125
+ def option_pair(args, name)
126
+ hash = args.find(&:hash_type?)
127
+ return unless hash
128
+
129
+ hash.pairs.find { |candidate| hash_key_name(candidate.key) == name }
130
+ end
131
+
132
+ def hash_key_name(node)
133
+ return node.value.to_s if node&.sym_type?
134
+ return node.value if node&.str_type?
135
+
136
+ nil
137
+ end
138
+
139
+ def string_literal(node)
140
+ return node.value if node&.str_type?
141
+
142
+ nil
143
+ end
144
+
145
+ def inferred_association_class(name, collection:)
146
+ source = collection ? singularize(name) : name
147
+ camelize(source)
148
+ end
149
+
150
+ # Minimal heuristic only, not Rails inflection. Wrong guesses simply do not
151
+ # resolve to owned constants in FanIn.
152
+ def singularize(word)
153
+ lower = word.downcase
154
+ return IRREGULAR_SINGULARS.fetch(lower) if IRREGULAR_SINGULARS.key?(lower)
155
+
156
+ case word
157
+ when /(ses|xes|zes|ches|shes)\z/
158
+ word.delete_suffix('es')
159
+ when /ies\z/
160
+ "#{word[0...-3]}y"
161
+ when /s\z/
162
+ word.delete_suffix('s')
163
+ else
164
+ word
165
+ end
166
+ end
167
+
168
+ def camelize(segment)
169
+ segment.split('_').map(&:capitalize).join
170
+ end
171
+ end
172
+ end