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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -1
- data/README.md +130 -33
- data/SIGNALS.md +143 -0
- data/lib/stud_finder/churn.rb +10 -1
- data/lib/stud_finder/cli.rb +232 -46
- data/lib/stud_finder/complexity.rb +47 -8
- data/lib/stud_finder/coverage/cobertura.rb +1 -1
- data/lib/stud_finder/coverage/lcov.rb +1 -1
- data/lib/stud_finder/coverage/resultset.rb +1 -1
- data/lib/stud_finder/dispersion_warnings.rb +20 -0
- data/lib/stud_finder/edges.rb +1 -0
- data/lib/stud_finder/fan_in.rb +35 -6
- data/lib/stud_finder/file_collector.rb +2 -1
- data/lib/stud_finder/js_fan_in.rb +49 -8
- data/lib/stud_finder/loc_counter.rb +22 -0
- data/lib/stud_finder/newness.rb +306 -0
- data/lib/stud_finder/rails_inference.rb +172 -0
- data/lib/stud_finder/scorer.rb +133 -37
- data/lib/stud_finder/temporal_coupling.rb +33 -6
- data/lib/stud_finder/version.rb +1 -1
- metadata +7 -4
- data/PRODUCT.md +0 -172
- data/VISION.md +0 -151
|
@@ -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
|