rubocop-metz 0.5.2

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.

Potentially problematic release.


This version of rubocop-metz might be problematic. Click here for more details.

Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
  4. data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
  5. data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
  6. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
  7. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
  8. data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
  9. data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
  10. data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
  11. data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
  12. data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
  13. data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
  14. data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
  15. data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
  16. data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
  17. data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
  18. data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
  19. data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
  20. data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
  21. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
  22. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
  23. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
  24. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
  25. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
  26. data/lib/metz_scan/analyzers/occurrence.rb +28 -0
  27. data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
  28. data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
  29. data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
  30. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
  31. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
  32. data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
  33. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
  34. data/lib/metz_scan/analyzers/package_map.rb +81 -0
  35. data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
  36. data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
  37. data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
  38. data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
  39. data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
  40. data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
  41. data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
  42. data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
  43. data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
  44. data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
  45. data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
  46. data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
  47. data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
  48. data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
  49. data/lib/metz_scan/analyzers/service_soup.rb +128 -0
  50. data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
  51. data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
  52. data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
  53. data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
  54. data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
  55. data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
  56. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
  57. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
  58. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
  59. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
  60. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
  61. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
  62. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
  63. data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
  64. data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
  65. data/lib/metz_scan/cli.rb +120 -0
  66. data/lib/metz_scan/commands/explain.rb +128 -0
  67. data/lib/metz_scan/commands/project_analyzers.rb +105 -0
  68. data/lib/metz_scan/commands/report.rb +115 -0
  69. data/lib/metz_scan/commands/rules.rb +92 -0
  70. data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
  71. data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
  72. data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
  73. data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
  74. data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
  75. data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
  76. data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
  77. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
  78. data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
  79. data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
  80. data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
  81. data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
  82. data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
  83. data/lib/metz_scan/commands/scan/runner.rb +199 -0
  84. data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
  85. data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
  86. data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
  87. data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
  88. data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
  89. data/lib/metz_scan/commands/scan.rb +201 -0
  90. data/lib/metz_scan/project_index/null_backend.rb +35 -0
  91. data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
  92. data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
  93. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
  94. data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
  95. data/lib/metz_scan/project_index.rb +109 -0
  96. data/lib/metz_scan/version.rb +5 -0
  97. data/lib/metz_scan.rb +5 -0
  98. metadata +185 -0
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class RepeatedQueryCriteria
6
+ module Triage
7
+ CATEGORY_TRIAGE = {
8
+ "polymorphic_where_criteria" => {
9
+ triage_summary: "Candidate polymorphic query signal; review whether polymorphic lookup rules need a " \
10
+ "name.",
11
+ why: "Repeated polymorphic query criteria spread type/id coupling across callers and make policy " \
12
+ "changes harder to find.",
13
+ suggested_next_moves: [
14
+ "Extract a named scope or query object when the polymorphic lookup is a domain concept.",
15
+ "Keep inline polymorphic criteria only when the repetition is mechanical and local."
16
+ ]
17
+ },
18
+ "compound_association_where_criteria" => {
19
+ triage_summary: "Candidate compound association query signal; review whether association pairs need a " \
20
+ "named query.",
21
+ why: "Repeated compound association criteria can hide a relationship rule in several callers instead " \
22
+ "of behind one named query.",
23
+ suggested_next_moves: [
24
+ "Extract a named scope when the association pair represents a reusable relationship.",
25
+ "Keep simple join-table lookups inline when the criteria are purely mechanical."
26
+ ]
27
+ },
28
+ "scoped_association_where_criteria" => {
29
+ triage_summary: "Candidate scoped association query signal; review whether scoped criteria belong " \
30
+ "behind a named query.",
31
+ why: "Repeated association-scoped criteria spread filtering rules across callers and make policy " \
32
+ "changes harder to find.",
33
+ suggested_next_moves: [
34
+ "Extract a named scope or query object when the predicate represents a reusable business concept.",
35
+ "Keep one-off lookup criteria inline when repetition is incidental or purely local."
36
+ ]
37
+ },
38
+ "where_hash_criteria" => {
39
+ triage_summary: "Candidate repeated query signal; review whether criteria belong behind a named query.",
40
+ why: "Repeated query predicates spread data-access rules across callers and make policy changes " \
41
+ "harder to find.",
42
+ suggested_next_moves: [
43
+ "Extract a named scope or query object when the predicate represents a reusable business concept.",
44
+ "Keep one-off lookup criteria inline when repetition is incidental or purely local."
45
+ ]
46
+ }
47
+ }.freeze
48
+ MESSAGE_PHRASES = {
49
+ "polymorphic_where_criteria" => "repeats polymorphic query criteria",
50
+ "compound_association_where_criteria" => "repeats compound association query criteria",
51
+ "scoped_association_where_criteria" => "repeats association-scoped query criteria",
52
+ "where_hash_criteria" => "appears"
53
+ }.freeze
54
+ private_constant :CATEGORY_TRIAGE, :MESSAGE_PHRASES
55
+
56
+ private
57
+
58
+ def repeated_query_category_for(site)
59
+ return "polymorphic_where_criteria" if polymorphic_criteria?(site.criteria_keys)
60
+ return "compound_association_where_criteria" if foreign_key_count(site.criteria_keys) >= 2
61
+ return "scoped_association_where_criteria" if foreign_key_count(site.criteria_keys).positive?
62
+
63
+ "where_hash_criteria"
64
+ end
65
+
66
+ def polymorphic_criteria?(criteria_keys)
67
+ criteria_keys.any? do |key|
68
+ key.end_with?("_type") && criteria_keys.include?("#{key.delete_suffix('_type')}_id")
69
+ end
70
+ end
71
+
72
+ def foreign_key_count(criteria_keys)
73
+ criteria_keys.count { |key| key.end_with?("_id") }
74
+ end
75
+
76
+ def criteria_key_shape_for(site)
77
+ repeated_query_category_for(site).delete_suffix("_where_criteria")
78
+ end
79
+
80
+ def category_triage_attributes(category)
81
+ profile = CATEGORY_TRIAGE.fetch(category)
82
+
83
+ { triage_summary: profile.fetch(:triage_summary), why_it_matters: profile.fetch(:why),
84
+ suggested_next_moves: profile.fetch(:suggested_next_moves) }
85
+ end
86
+
87
+ def query_message_phrase(site, category)
88
+ return "repeats finder query criteria" if site.query_operation == "finder"
89
+ return negative_query_message_phrase(category) if site.query_operation == "negative_filter"
90
+
91
+ MESSAGE_PHRASES.fetch(category)
92
+ end
93
+
94
+ def negative_query_message_phrase(category)
95
+ { "polymorphic_where_criteria" => "repeats negative polymorphic query criteria",
96
+ "compound_association_where_criteria" => "repeats negative compound association query criteria",
97
+ "scoped_association_where_criteria" => "repeats negative association-scoped query criteria",
98
+ "where_hash_criteria" => "repeats negative query criteria" }.fetch(category)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "contextual_node_walker"
6
+ require_relative "occurrence"
7
+ require_relative "package_map"
8
+ require_relative "project_analyzer_triage"
9
+ require_relative "repeated_query_criteria/triage"
10
+ require_relative "ruby_file_enumerator"
11
+
12
+ module MetzScan
13
+ module Analyzers
14
+ # Reports repeated hash-style ActiveRecord query predicates across packages.
15
+ class RepeatedQueryCriteria
16
+ include ProjectAnalyzerTriage
17
+ include Triage
18
+
19
+ RULE_ID = "MetzProject/RepeatedQueryCriteria"
20
+ PROJECT_ANALYZER_STATUS = "candidate"
21
+ CONFIDENCE = "medium"
22
+ TRIAGE_SEVERITY = "manual review"
23
+ TRIAGE_SUMMARY = "Candidate repeated query signal; review whether criteria belong behind a named query."
24
+ WHY = "Repeated query predicates spread data-access rules across callers and make policy changes harder to find."
25
+ SUGGESTED_NEXT_MOVES = [
26
+ "Extract a named scope or query object when the predicate represents a reusable business concept.",
27
+ "Keep one-off lookup criteria inline when repetition is incidental or purely local."
28
+ ].freeze
29
+ MINIMUM_FILES = 3
30
+ MINIMUM_PACKAGES = 2
31
+ MINIMUM_CRITERIA_KEYS = 2
32
+ Finding = Struct.new(:source, :rule_id, :message, :query, :receiver, :criteria_keys,
33
+ :referring_files, :referring_packages, :occurrences,
34
+ :project_analyzer_status, :confidence, :triage_severity, :triage_summary,
35
+ :project_analyzer_metadata, :why_it_matters, :suggested_next_moves,
36
+ keyword_init: true) do
37
+ def report_occurrences
38
+ occurrence = occurrences.first
39
+ [Occurrence.from(occurrence, context: occurrence&.context)].compact
40
+ end
41
+ end
42
+
43
+ def initialize(paths: nil, index: nil, minimum_files: MINIMUM_FILES, minimum_packages: MINIMUM_PACKAGES)
44
+ @paths = Array(paths)
45
+ @index = index
46
+ @minimum_files = minimum_files
47
+ @minimum_packages = minimum_packages
48
+ end
49
+
50
+ def call
51
+ query_sites.group_by(&:fingerprint).filter_map do |_fingerprint, sites|
52
+ finding_for(sites)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :paths, :index, :minimum_files, :minimum_packages
59
+
60
+ def query_sites
61
+ ruby_files.flat_map { |path| QuerySiteCollector.new(path).call }
62
+ .reject { |site| PackageMap.ignored_path?(site.path) }
63
+ .select { |site| PackageMap.package_for(site.path) }
64
+ end
65
+
66
+ def ruby_files
67
+ RubyFileEnumerator.new(paths: paths, index: index).call
68
+ end
69
+
70
+ def finding_for(sites)
71
+ grouped = QuerySiteSet.new(sites)
72
+ return unless grouped.referring_files.size >= minimum_files
73
+ return unless grouped.referring_packages.size >= minimum_packages
74
+
75
+ first = grouped.sites.first
76
+ Finding.new(finding_attributes(first, grouped))
77
+ end
78
+
79
+ def finding_attributes(first, grouped)
80
+ category = repeated_query_category_for(first)
81
+
82
+ core_finding_attributes(first, grouped, category)
83
+ .merge(project_analyzer_triage_attributes, category_triage_attributes(category))
84
+ .merge(project_analyzer_context_attributes(first, grouped, category))
85
+ end
86
+
87
+ def core_finding_attributes(first, grouped, category)
88
+ { source: source_name, rule_id: RULE_ID, message: message_for(first, grouped, category),
89
+ query: first.query, receiver: first.receiver, criteria_keys: first.criteria_keys,
90
+ referring_files: grouped.referring_files, referring_packages: grouped.referring_packages,
91
+ occurrences: grouped.sites }
92
+ end
93
+
94
+ def project_analyzer_context_attributes(first, grouped, category)
95
+ { project_analyzer_metadata: project_analyzer_metadata_for(first, grouped, category) }
96
+ end
97
+
98
+ def message_for(site, grouped, category)
99
+ "#{site.query} #{query_message_phrase(site, category)} in #{grouped.referring_files.size} files across " \
100
+ "#{grouped.referring_packages.size} packages; consider naming the query criteria."
101
+ end
102
+
103
+ def project_analyzer_metadata_for(site, grouped, category)
104
+ query_metadata(site, category).merge(query_reference_metadata(grouped))
105
+ end
106
+
107
+ def query_metadata(site, category)
108
+ query_identity_metadata(site, category).merge(query_shape_metadata(site))
109
+ end
110
+
111
+ def query_identity_metadata(site, category)
112
+ { "project_analyzer_category" => category, "repeated_query_category" => category, "query" => site.query,
113
+ "query_method" => site.query_method, "query_operation" => site.query_operation }
114
+ end
115
+
116
+ def query_shape_metadata(site)
117
+ { "receiver" => site.receiver, "criteria_keys" => site.criteria_keys,
118
+ "receiver_shape" => site.receiver_shape, "criteria_key_shape" => criteria_key_shape_for(site) }
119
+ end
120
+
121
+ def query_reference_metadata(grouped)
122
+ { "referring_files" => grouped.referring_files, "referring_packages" => grouped.referring_packages,
123
+ "occurrences" => occurrences_metadata(grouped) }
124
+ end
125
+
126
+ def occurrences_metadata(grouped)
127
+ grouped.sites.map { |site| occurrence_metadata(site) }
128
+ end
129
+
130
+ def occurrence_metadata(site)
131
+ { "path" => site.path, "line" => site.line, "context" => site.context,
132
+ "expression" => site.expression }.compact
133
+ end
134
+
135
+ class QuerySiteSet
136
+ def initialize(sites)
137
+ @sites = sites.sort_by { |site| [site.path, site.line.to_i] }
138
+ end
139
+
140
+ attr_reader :sites
141
+
142
+ def referring_files
143
+ sites.map(&:path).uniq
144
+ end
145
+
146
+ def referring_packages
147
+ sites.map { |site| PackageMap.package_for(site.path) }.compact.uniq.sort
148
+ end
149
+ end
150
+
151
+ class QuerySiteCollector
152
+ FINDER_QUERY_METHODS = %i[find_by find_or_create_by find_or_initialize_by].freeze
153
+ QuerySite = Struct.new(:receiver, :criteria_keys, :query_method, :query_operation, :enclosing_name,
154
+ :method_name, :path, :line, :expression, keyword_init: true) do
155
+ def query
156
+ "#{receiver}.#{query_method}(#{criteria_keys.join(', ')})"
157
+ end
158
+
159
+ def receiver_shape
160
+ receiver.include?(".") ? "scope_chain" : "constant"
161
+ end
162
+
163
+ def fingerprint
164
+ [receiver, query_method, criteria_keys].join(":")
165
+ end
166
+
167
+ def context
168
+ return "#{enclosing_name}#{method_name}" if enclosing_name && method_name
169
+
170
+ method_name || enclosing_name
171
+ end
172
+ end
173
+
174
+ def initialize(path)
175
+ @path = path
176
+ end
177
+
178
+ def call
179
+ contextual_nodes.filter_map { |contextual_node| query_site_for(contextual_node) }
180
+ rescue Parser::SyntaxError
181
+ []
182
+ end
183
+
184
+ private
185
+
186
+ attr_reader :path
187
+
188
+ def processed_source
189
+ RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
190
+ end
191
+
192
+ def contextual_nodes
193
+ ContextualNodeWalker.new(processed_source.ast).nodes
194
+ end
195
+
196
+ def receiver_fingerprint_for(node)
197
+ return unless node
198
+ return node.source if node.type == :const
199
+ return unless simple_scope_call?(node)
200
+
201
+ parent = receiver_fingerprint_for(node.receiver)
202
+ "#{parent}.#{node.method_name}" if parent
203
+ end
204
+
205
+ def simple_scope_call?(node)
206
+ node.type == :send && node.receiver && node.arguments.empty? &&
207
+ node.method_name.to_s.match?(/\A[a-z_]\w*[!?]?\z/)
208
+ end
209
+
210
+ def query_site_for(contextual_node)
211
+ node = contextual_node.node
212
+ query_attributes = query_attributes_for(node)
213
+ return unless query_attributes
214
+
215
+ QuerySite.new(query_attributes.merge(context_attributes_for(contextual_node, node)))
216
+ end
217
+
218
+ def context_attributes_for(contextual_node, node)
219
+ { enclosing_name: contextual_node.enclosing_name, method_name: contextual_node.method_name,
220
+ path: path, line: node.loc.expression.line, expression: first_line(node) }
221
+ end
222
+
223
+ def query_attributes_for(node)
224
+ return unless node.type == :send
225
+
226
+ positive_where_query_attributes(node) ||
227
+ finder_query_attributes(node) ||
228
+ negative_where_query_attributes(node)
229
+ end
230
+
231
+ def positive_where_query_attributes(node)
232
+ return unless node.method_name == :where
233
+
234
+ query_attributes_for_parts(node.receiver, criteria_keys_for(node), "where", "filter")
235
+ end
236
+
237
+ def finder_query_attributes(node)
238
+ return unless FINDER_QUERY_METHODS.include?(node.method_name)
239
+
240
+ query_attributes_for_parts(node.receiver, criteria_keys_for(node), node.method_name.to_s, "finder")
241
+ end
242
+
243
+ def negative_where_query_attributes(node)
244
+ return unless node.method_name == :not
245
+
246
+ receiver_node = empty_where_receiver_for(node)
247
+ return unless receiver_node
248
+
249
+ query_attributes_for_parts(receiver_node.receiver, criteria_keys_for(node), "where.not", "negative_filter")
250
+ end
251
+
252
+ def empty_where_receiver_for(node)
253
+ receiver_node = node.receiver
254
+ receiver_node if receiver_node&.type == :send && receiver_node.method_name == :where &&
255
+ receiver_node.arguments.empty?
256
+ end
257
+
258
+ def query_attributes_for_parts(receiver_node, criteria_keys, query_method, query_operation)
259
+ receiver = receiver_fingerprint_for(receiver_node)
260
+ return unless receiver
261
+ return unless criteria_keys.size >= MINIMUM_CRITERIA_KEYS
262
+
263
+ { receiver: receiver, criteria_keys: criteria_keys, query_method: query_method,
264
+ query_operation: query_operation }
265
+ end
266
+
267
+ def criteria_keys_for(node)
268
+ hash = node.arguments.first
269
+ return [] unless hash&.type == :hash
270
+ return [] unless hash.children.all? { |child| child.type == :pair }
271
+
272
+ hash.children.filter_map { |pair| literal_key(pair.children.first) }.sort
273
+ end
274
+
275
+ def literal_key(node)
276
+ node.value.to_s if %i[sym str].include?(node.type)
277
+ end
278
+
279
+ def first_line(node)
280
+ node.loc.expression.source.lines.first.strip
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class RubyFileEnumerator
6
+ RUBY_GLOB = "**/*.rb"
7
+
8
+ def initialize(paths:, index: nil)
9
+ @paths = Array(paths)
10
+ @index = index
11
+ end
12
+
13
+ def call
14
+ return ruby_files_for(paths) unless paths.empty?
15
+ return index.indexed_files if index&.available?
16
+
17
+ []
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :paths, :index
23
+
24
+ def ruby_files_for(paths)
25
+ paths.flat_map { |path| ruby_files_under(path) }.uniq.sort
26
+ end
27
+
28
+ def ruby_files_under(path)
29
+ expanded = File.expand_path(path)
30
+ return Dir.glob(File.join(expanded, RUBY_GLOB)) if File.directory?(expanded)
31
+ return [expanded] if File.file?(expanded) && File.extname(expanded) == ".rb"
32
+
33
+ []
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class ServiceSoup
6
+ class ServiceCallPattern
7
+ Match = Struct.new(:service_name, :style, keyword_init: true)
8
+ CLASS_SERVICE_METHODS = %i[call].freeze
9
+ NEW_SERVICE_METHOD_STYLES = { call: :new_call, perform: :new_perform }.freeze
10
+
11
+ def initialize(node)
12
+ @node = node
13
+ end
14
+
15
+ def match
16
+ return class_match if class_service_call?
17
+ return new_match if new_service_call?
18
+
19
+ nil
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :node
25
+
26
+ def class_match
27
+ Match.new(service_name: node.receiver.source, style: :class_call)
28
+ end
29
+
30
+ def new_match
31
+ Match.new(service_name: node.receiver.receiver.source,
32
+ style: NEW_SERVICE_METHOD_STYLES.fetch(node.method_name))
33
+ end
34
+
35
+ def class_service_call?
36
+ CLASS_SERVICE_METHODS.include?(node.method_name) && constant_receiver?(node.receiver)
37
+ end
38
+
39
+ def new_service_call?
40
+ NEW_SERVICE_METHOD_STYLES.key?(node.method_name) && new_service_receiver?(node.receiver)
41
+ end
42
+
43
+ def new_service_receiver?(receiver)
44
+ receiver&.type == :send && receiver.method_name == :new && constant_receiver?(receiver.receiver)
45
+ end
46
+
47
+ def constant_receiver?(receiver)
48
+ receiver&.type == :const
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class ServiceSoup
6
+ module SetupWorkflowTriage
7
+ CONFIDENCE = "low"
8
+ TRIAGE_SEVERITY = "setup orchestration"
9
+ TRIAGE_SUMMARY = "Setup workflow signal; review only when setup orchestration changes often " \
10
+ "or hides domain workflow pressure."
11
+ WHY = "Setup orchestration often intentionally runs a list of tasks; review only when it " \
12
+ "changes often or hides domain workflow pressure."
13
+ SUGGESTED_NEXT_MOVES = [
14
+ "Leave setup orchestration as a list when it is stable and declarative.",
15
+ "Extract a named workflow only when setup steps change together or need clearer sequencing."
16
+ ].freeze
17
+ TOKEN_PATTERN = /\A(?:seeds?|seeders?|setup|install(?:er|ation)?|bootstrap)\z/i
18
+
19
+ module_function
20
+
21
+ def setup?(workflow)
22
+ setup_path?(workflow.path) || setup_name?(workflow.name) || setup_method?(workflow.method_name)
23
+ end
24
+
25
+ def attributes(status)
26
+ { project_analyzer_status: status, confidence: CONFIDENCE,
27
+ triage_severity: TRIAGE_SEVERITY, triage_summary: TRIAGE_SUMMARY }
28
+ end
29
+
30
+ def setup_path?(path)
31
+ path.to_s.split(%r{[/\\]}).any? { |segment| setup_token?(File.basename(segment, ".rb")) }
32
+ end
33
+
34
+ def setup_name?(name)
35
+ name.to_s.split("::").any? { |segment| setup_token?(segment.delete_prefix("#").delete_prefix(".")) }
36
+ end
37
+
38
+ def setup_method?(method_name)
39
+ setup_token?(method_name.to_s.delete_prefix("#").delete_prefix("."))
40
+ end
41
+
42
+ def setup_token?(token)
43
+ token.to_s.split(/[_\-.]/).any? { |part| part.match?(TOKEN_PATTERN) }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "service_call_pattern"
6
+
7
+ module MetzScan
8
+ module Analyzers
9
+ class ServiceSoup
10
+ class WorkflowCollector
11
+ Workflow = Struct.new(:name, :enclosing_name, :method_name, :path, :line, :expression, :service_calls,
12
+ keyword_init: true)
13
+ ServiceCall = Struct.new(:service_name, :path, :line, :expression, :style, keyword_init: true)
14
+
15
+ def initialize(path)
16
+ @path = path
17
+ end
18
+
19
+ def call
20
+ collect_workflows(processed_source.ast, [], [])
21
+ rescue Parser::SyntaxError
22
+ []
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :path
28
+
29
+ def processed_source
30
+ RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
31
+ end
32
+
33
+ def collect_workflows(node, namespace, workflows)
34
+ return workflows unless node
35
+
36
+ collect_node(node, namespace, workflows)
37
+ end
38
+
39
+ def collect_node(node, namespace, workflows)
40
+ return collect_namespace(node, namespace, workflows) if %i[class module].include?(node.type)
41
+ return collect_instance_method(node, namespace, workflows) if node.type == :def
42
+ return collect_singleton_method(node, namespace, workflows) if node.type == :defs
43
+
44
+ collect_child_workflows(node, namespace, workflows)
45
+ end
46
+
47
+ def collect_namespace(node, namespace, workflows)
48
+ name = constant_name(node.children.first)
49
+ body = node.children.last
50
+ collect_workflows(body, namespace + [name].compact, workflows)
51
+ end
52
+
53
+ def collect_instance_method(node, namespace, workflows)
54
+ service_calls = service_calls_under(node.children[2])
55
+ workflows << workflow_for(namespace, "##{node.method_name}", node, service_calls) unless service_calls.empty?
56
+ workflows
57
+ end
58
+
59
+ def collect_singleton_method(node, namespace, workflows)
60
+ service_calls = service_calls_under(node.children[3])
61
+ workflows << workflow_for(namespace, ".#{node.method_name}", node, service_calls) unless service_calls.empty?
62
+ workflows
63
+ end
64
+
65
+ def collect_child_workflows(node, namespace, workflows)
66
+ node.children.grep(RuboCop::AST::Node).each { |child| collect_workflows(child, namespace, workflows) }
67
+ workflows
68
+ end
69
+
70
+ def workflow_for(namespace, method_suffix, node, service_calls)
71
+ enclosing_name = namespace.join("::")
72
+ Workflow.new(name: "#{enclosing_name}#{method_suffix}", enclosing_name: optional_name(enclosing_name),
73
+ method_name: method_suffix, path: path, line: node.loc.expression.line,
74
+ expression: first_line(node), service_calls: service_calls)
75
+ end
76
+
77
+ def service_calls_under(node, service_calls = [])
78
+ return service_calls unless service_search_node?(node)
79
+
80
+ collect_service_call(node, service_calls)
81
+ collect_child_service_calls(node, service_calls)
82
+ end
83
+
84
+ def nested_scope?(node)
85
+ %i[class module def defs].include?(node.type)
86
+ end
87
+
88
+ def service_search_node?(node)
89
+ node && !nested_scope?(node)
90
+ end
91
+
92
+ def collect_service_call(node, service_calls)
93
+ service_call = service_call_for(node)
94
+ service_calls << service_call if service_call
95
+ end
96
+
97
+ def collect_child_service_calls(node, service_calls)
98
+ node.children.grep(RuboCop::AST::Node).each { |child| service_calls_under(child, service_calls) }
99
+ service_calls
100
+ end
101
+
102
+ def service_call_for(node)
103
+ return unless node.type == :send
104
+
105
+ pattern = ServiceCallPattern.new(node).match
106
+ return unless pattern
107
+
108
+ ServiceCall.new(service_name: pattern.service_name, path: path, line: node.loc.expression.line,
109
+ expression: first_line(node), style: pattern.style)
110
+ end
111
+
112
+ def constant_receiver?(node)
113
+ node&.type == :const
114
+ end
115
+
116
+ def constant_name(node)
117
+ node.source if constant_receiver?(node)
118
+ end
119
+
120
+ def optional_name(name)
121
+ name unless name.empty?
122
+ end
123
+
124
+ def first_line(node)
125
+ node.loc.expression.source.lines.first.strip
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end