necropsy 0.1.0 → 0.3.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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
@@ -1,33 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module Necropsy
4
6
  module EntryPoints
5
7
  class Rails
6
8
  ROUTE_VERBS = %w[get post put patch delete match].freeze
9
+ ROUTE_DSL_CALLS = %i[
10
+ collection concern concerns constraints controller defaults draw member mount namespace resource resources root scope
11
+ ].to_set.merge(ROUTE_VERBS.map(&:to_sym)).freeze
7
12
  RESTFUL_ACTIONS = %w[index show new create edit update destroy].freeze
8
13
  SINGULAR_ACTIONS = %w[show new create edit update destroy].freeze
14
+ IRREGULAR_PLURALS = { 'person' => 'people', 'man' => 'men', 'woman' => 'women', 'child' => 'children' }.freeze
15
+ INFLECTION_RULES_AFFECTING_PLURALIZATION = %i[clear plural].freeze
9
16
  RouteContext = Struct.new(:modules, :resource, :controller, keyword_init: true)
10
17
 
11
18
  def apply(graph, project)
12
- return unless project.config.rails_enabled?
19
+ return unless project.config.rails_enabled?(reference_files: project.reference_files)
20
+
21
+ @route_blockers = []
22
+ load_inflections(project)
13
23
 
24
+ referenced_view_methods = view_method_names(project)
14
25
  graph.method_nodes.each do |node|
15
26
  case node.file
16
27
  when %r{\Aapp/jobs/}
17
- graph.add_entry_point(node.id, :job_perform) if node.name == 'perform'
28
+ graph.add_entry_point(node.graph_id, :job_perform) if node.name == 'perform'
18
29
  when %r{\Aapp/mailers/}
19
- graph.add_entry_point(node.id, :mailer_action) if node.kind == :instance_method
30
+ if node.kind == :instance_method && node.visibility == :public
31
+ graph.add_entry_point(node.graph_id,
32
+ :mailer_action)
33
+ end
20
34
  when %r{\Aapp/helpers/}
21
- graph.add_entry_point(node.id, :rails_view_helper) if helper_referenced?(project, node.name)
35
+ graph.add_entry_point(node.graph_id, :rails_view_helper) if referenced_view_methods.include?(node.name)
22
36
  when %r{\Aapp/components/}
23
- graph.add_entry_point(node.id, :rails_component) if component_entrypoint?(node)
37
+ graph.add_entry_point(node.graph_id, :rails_component) if component_entrypoint?(node)
38
+ when %r{\Adb/migrate/}
39
+ graph.add_entry_point(node.graph_id, :rails_migration) if %w[change up down].include?(node.name)
40
+ end
41
+
42
+ if node.file.start_with?('app/') && !node.file.start_with?('app/helpers/', 'app/components/') &&
43
+ referenced_view_methods.include?(node.name)
44
+ graph.add_entry_point(node.graph_id, :rails_view_reference)
24
45
  end
25
46
  end
26
47
 
27
48
  graph.nodes.values.select do |node|
28
49
  node.kind == :block_entry && node.file.start_with?('config/initializers/')
29
50
  end.each do |node|
30
- graph.add_entry_point(node.id, :callback_registered)
51
+ graph.add_entry_point(node.graph_id, :callback_registered)
31
52
  end
32
53
 
33
54
  route_entry_points(project).each do |node_id|
@@ -35,64 +56,328 @@ module Necropsy
35
56
  graph.add_entry_point(matching_id, :rails_route)
36
57
  end
37
58
  end
59
+ @route_blockers.each { |blocker| graph.add_blocker(blocker) }
38
60
  end
39
61
 
40
62
  private
41
63
 
64
+ def load_inflections(project)
65
+ @irregular_plurals = IRREGULAR_PLURALS.dup
66
+ @acronyms = {}
67
+ @uncountables = Set.new
68
+ path = project.reference_files.find do |candidate|
69
+ project.relative_path(candidate) == 'config/initializers/inflections.rb'
70
+ end
71
+ return unless path
72
+
73
+ source = File.read(path)
74
+ result = Prism.parse(source)
75
+ unless result.success?
76
+ @route_blockers << route_health_blocker(
77
+ project.relative_path(path), result.errors.first&.location&.start_line,
78
+ 'Rails inflections could not be parsed'
79
+ )
80
+ return
81
+ end
82
+
83
+ inflection_blocks(result.value).each do |block, parameter_name|
84
+ each_call(block.body) do |call|
85
+ next unless call.receiver.is_a?(Prism::LocalVariableReadNode)
86
+ next unless call.receiver.name == parameter_name
87
+
88
+ case call.name
89
+ when :irregular then register_irregular(call, project.relative_path(path))
90
+ when :acronym then register_acronym(call, project.relative_path(path))
91
+ when :uncountable then register_uncountable(call, project.relative_path(path))
92
+ when *INFLECTION_RULES_AFFECTING_PLURALIZATION
93
+ @route_blockers << route_health_blocker(
94
+ project.relative_path(path), call.location.start_line,
95
+ "Rails #{call.name} inflection cannot be evaluated statically"
96
+ )
97
+ end
98
+ end
99
+ end
100
+ rescue SystemCallError, EncodingError => e
101
+ @route_blockers << route_health_blocker(
102
+ 'config/initializers/inflections.rb', 1, "Rails inflections could not be read: #{e.class}"
103
+ )
104
+ end
105
+
106
+ def each_call(root)
107
+ pending = [root]
108
+ until pending.empty?
109
+ node = pending.pop
110
+ yield node if node.is_a?(Prism::CallNode)
111
+ pending.concat(node.child_nodes.compact)
112
+ end
113
+ end
114
+
115
+ def inflection_blocks(root)
116
+ blocks = []
117
+ each_call(root) do |call|
118
+ next unless call.name == :inflections && constant_name(call.receiver) == 'ActiveSupport::Inflector'
119
+ next unless call.block
120
+
121
+ parameter = required_block_parameter(call.block)
122
+ unless parameter.respond_to?(:name)
123
+ @route_blockers << route_health_blocker(
124
+ 'config/initializers/inflections.rb', call.location.start_line,
125
+ 'Rails inflections block has no static block parameter'
126
+ )
127
+ next
128
+ end
129
+ blocks << [call.block, parameter.name]
130
+ end
131
+ blocks
132
+ end
133
+
134
+ def required_block_parameter(block)
135
+ block_parameters = block.parameters
136
+ parameters = block_parameters.parameters if block_parameters
137
+ Array(parameters&.requireds).first
138
+ end
139
+
140
+ def register_irregular(call, path)
141
+ singular, plural = Array(call.arguments&.arguments).first(2).map { |node| literal_route_argument(node) }
142
+ return @irregular_plurals[singular] = plural if singular && plural
143
+
144
+ @route_blockers << route_health_blocker(path, call.location.start_line, 'Rails irregular inflection is dynamic')
145
+ end
146
+
147
+ def register_acronym(call, path)
148
+ value = literal_route_argument(Array(call.arguments&.arguments).first)
149
+ return @acronyms[value.downcase] = value if value
150
+
151
+ @route_blockers << route_health_blocker(path, call.location.start_line, 'Rails acronym inflection is dynamic')
152
+ end
153
+
154
+ def register_uncountable(call, path)
155
+ words = Array(call.arguments&.arguments).flat_map do |argument|
156
+ if argument.is_a?(Prism::ArrayNode) && !argument.contains_splat?
157
+ argument.elements.map { |element| literal_route_argument(element) }
158
+ else
159
+ literal_route_argument(argument)
160
+ end
161
+ end
162
+ if words.any? && words.none?(&:nil?)
163
+ @uncountables.merge(words)
164
+ return
165
+ end
166
+
167
+ @route_blockers << route_health_blocker(path, call.location.start_line, 'Rails uncountable inflection is dynamic')
168
+ end
169
+
42
170
  def route_entry_points(project)
43
171
  routes = File.join(project.root, 'config/routes.rb')
44
- parse_route_file(project.root, routes, seen: {}, concerns: {})
172
+ parse_route_file(project, routes, seen: {}, concerns: {})
45
173
  end
46
174
 
47
- def parse_route_file(root, path, seen:, concerns:)
48
- expanded = File.expand_path(path, root)
49
- return [] unless File.exist?(expanded)
175
+ def parse_route_file(project, path, seen:, concerns:)
176
+ previous_route_path = @current_route_path
177
+ expanded = File.expand_path(path, project.root)
178
+ return [] unless project.reference_file?(expanded)
50
179
  return [] if seen[expanded]
51
180
 
52
181
  seen[expanded] = true
53
- parse_routes(File.readlines(expanded), root: root, seen: seen, concerns: concerns)
182
+ @current_route_path = project.relative_path(expanded)
183
+ source = File.read(expanded)
184
+ result = Prism.parse(source)
185
+ if result.failure?
186
+ @route_blockers << route_health_blocker(
187
+ @current_route_path, result.errors.first&.location&.start_line, 'routes.rb could not be parsed'
188
+ )
189
+ return []
190
+ end
191
+
192
+ parse_route_statements(
193
+ result.value.statements,
194
+ source: source,
195
+ project: project,
196
+ seen: seen,
197
+ concerns: concerns,
198
+ context: RouteContext.new(modules: [], resource: nil)
199
+ )
200
+ rescue SystemCallError, EncodingError => e
201
+ @route_blockers << route_health_blocker(
202
+ @current_route_path || project.relative_path(expanded), 1,
203
+ "route source could not be read: #{e.class}"
204
+ )
205
+ []
206
+ ensure
207
+ @current_route_path = previous_route_path
54
208
  end
55
209
 
56
- def parse_routes(lines, root:, seen:, concerns:, initial_context: RouteContext.new(modules: [], resource: nil))
57
- contexts = [initial_context]
58
- capture = nil
59
- targets = []
210
+ def parse_route_statements(statements, source:, project:, seen:, concerns:, context:)
211
+ Array(statements&.body).flat_map do |statement|
212
+ parse_route_statement(statement, source: source, project: project, seen: seen, concerns: concerns,
213
+ context: context)
214
+ end.compact.uniq
215
+ end
60
216
 
61
- lines.each do |line|
62
- stripped = strip_route_comment(line).strip
63
- next if stripped.empty?
217
+ def parse_route_statement(statement, source:, project:, seen:, concerns:, context:)
218
+ unless statement.is_a?(Prism::CallNode)
219
+ return statement.child_nodes.compact.flat_map do |child|
220
+ parse_route_statement(child, source: source, project: project, seen: seen, concerns: concerns,
221
+ context: context)
222
+ end
223
+ end
64
224
 
65
- if capture
66
- capture[:depth] += block_openings(stripped)
67
- capture[:depth] -= 1 if stripped == 'end'
225
+ return [] unless ROUTE_DSL_CALLS.include?(statement.name)
68
226
 
69
- if capture[:depth].zero?
70
- concerns[capture[:name]] = capture[:lines]
71
- capture = nil
72
- else
73
- capture[:lines] << stripped
74
- end
75
- next
76
- end
227
+ call_source = route_call_source(statement, source)
228
+ record_dynamic_route(statement, context) if dynamic_route_statement?(statement)
229
+ if statement.name == :concern && statement.block
230
+ name = literal_route_argument(statement.arguments&.arguments&.first)
231
+ concerns[name] = [statement.block.body, source] if name
232
+ return []
233
+ end
77
234
 
78
- if (match = stripped.match(/\bconcern\s+:([a-zA-Z_]\w*)\s+do\b/))
79
- capture = { name: match[1], depth: 1, lines: [] }
80
- next
81
- end
235
+ targets = route_targets(call_source, context)
236
+ targets.concat(route_file_targets(call_source, project, seen, concerns))
237
+ targets.concat(concern_targets(call_source, context, project, seen, concerns))
238
+ return targets unless statement.block
239
+
240
+ child_context = nested_route_context(call_source, context)
241
+ targets.concat(
242
+ parse_route_statements(
243
+ statement.block.body,
244
+ source: source,
245
+ project: project,
246
+ seen: seen,
247
+ concerns: concerns,
248
+ context: child_context
249
+ )
250
+ )
251
+ end
82
252
 
83
- if stripped == 'end'
84
- contexts.pop if contexts.length > 1
85
- next
253
+ def route_call_source(node, source)
254
+ finish = node.block ? node.block.opening_loc.start_offset : node.location.end_offset
255
+ source.byteslice(node.location.start_offset...finish).gsub(/\s+/, ' ').strip
256
+ end
257
+
258
+ def literal_route_argument(node)
259
+ return unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
260
+
261
+ node.unescaped.to_s
262
+ end
263
+
264
+ def dynamic_route_statement?(node)
265
+ return false unless ROUTE_VERBS.include?(node.name.to_s) ||
266
+ %i[root resources resource mount namespace scope controller].include?(node.name)
267
+
268
+ Array(node.arguments&.arguments).each_with_index.any? do |argument, index|
269
+ !literal_route_value?(argument) && !static_mount_target?(node, argument, index)
270
+ end
271
+ end
272
+
273
+ def static_mount_target?(statement, argument, index)
274
+ statement.name == :mount && index.zero? && !constant_name(argument).nil?
275
+ end
276
+
277
+ def constant_name(node)
278
+ case node
279
+ when Prism::ConstantReadNode
280
+ node.name.to_s
281
+ when Prism::ConstantPathNode
282
+ parent = node.parent ? constant_name(node.parent) : nil
283
+ [parent, node.name.to_s].compact.join('::')
284
+ end
285
+ end
286
+
287
+ def literal_route_value?(node)
288
+ case node
289
+ when Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode, Prism::TrueNode, Prism::FalseNode,
290
+ Prism::NilNode
291
+ true
292
+ when Prism::KeywordHashNode, Prism::HashNode
293
+ Array(node.elements).all? do |element|
294
+ element.is_a?(Prism::AssocNode) && literal_route_value?(element.value)
86
295
  end
296
+ when Prism::ArrayNode
297
+ !node.contains_splat? && node.elements.all? { |element| literal_route_value?(element) }
298
+ else
299
+ false
300
+ end
301
+ end
87
302
 
88
- context = contexts.last
89
- targets.concat(route_targets(stripped, context))
90
- targets.concat(route_file_targets(stripped, root, seen, concerns))
91
- targets.concat(concern_targets(stripped, context, root, seen, concerns))
92
- push_context(contexts, stripped, context)
303
+ def record_dynamic_route(statement, context)
304
+ arguments = Array(statement.arguments&.arguments)
305
+ dynamic = arguments.find { |argument| !literal_route_value?(argument) }
306
+ return unless dynamic
307
+
308
+ scope_kind, scope_value = dynamic_route_scope(statement, context)
309
+ @route_blockers << route_blocker(
310
+ @current_route_path,
311
+ statement.location.start_line,
312
+ "dynamic route argument for #{statement.name}",
313
+ scope_kind: scope_kind,
314
+ scope_value: scope_value
315
+ )
316
+ end
317
+
318
+ def dynamic_route_scope(statement, context)
319
+ controller = literal_route_option(statement, 'controller')
320
+ controller ||= context.controller || context.resource
321
+ if controller
322
+ owner = "#{camelize(route_controller(context, controller))}Controller"
323
+ return [:owner, owner]
93
324
  end
94
325
 
95
- targets.compact.uniq
326
+ action = literal_route_option(statement, 'action')
327
+ return [:message, action] if action
328
+ return [:namespace, camelize(context.modules.join('/'))] if context.modules.any?
329
+
330
+ [:global, '*']
331
+ end
332
+
333
+ def literal_route_option(statement, name)
334
+ hash = Array(statement.arguments&.arguments).find do |argument|
335
+ argument.is_a?(Prism::KeywordHashNode) || argument.is_a?(Prism::HashNode)
336
+ end
337
+ pair = Array(hash&.elements).find do |element|
338
+ element.is_a?(Prism::AssocNode) && literal_route_argument(element.key) == name
339
+ end
340
+ literal_route_argument(pair&.value)
341
+ end
342
+
343
+ def route_blocker(path, line, reason, scope_kind:, scope_value:)
344
+ Blocker.new(
345
+ kind: :rails_route_dynamic,
346
+ scope_kind: scope_kind,
347
+ scope_value: scope_value,
348
+ source: :rails_rule,
349
+ reason: reason,
350
+ suggested_action: :review_dynamic_route,
351
+ metadata: {
352
+ 'caller_domain' => 'runtime',
353
+ 'rule_id' => 'rails.route',
354
+ 'file' => path,
355
+ 'line' => line
356
+ }.compact
357
+ )
358
+ end
359
+
360
+ def route_health_blocker(path, line, reason)
361
+ Blocker.new(
362
+ kind: :rails_route_health,
363
+ scope_kind: :global,
364
+ scope_value: '*',
365
+ source: :rails_rule,
366
+ reason: reason,
367
+ suggested_action: :fix_route_source,
368
+ metadata: {
369
+ 'caller_domain' => 'runtime',
370
+ 'rule_id' => 'rails.route.health',
371
+ 'file' => path,
372
+ 'line' => line
373
+ }.compact
374
+ )
375
+ end
376
+
377
+ def nested_route_context(line, context)
378
+ contexts = [context]
379
+ push_context(contexts, line, context)
380
+ contexts.last
96
381
  end
97
382
 
98
383
  def route_targets(line, context)
@@ -152,37 +437,45 @@ module Necropsy
152
437
  elsif (match = line.match(/\bscope\b.*\bmodule:\s+:?["']?([a-zA-Z_]\w*)/))
153
438
  contexts << RouteContext.new(modules: context.modules + [match[1]], resource: context.resource,
154
439
  controller: scoped_controller_option(line, context.controller))
155
- elsif (match = line.match(%r{\bcontroller\s+:?["']?([a-zA-Z_][\w/]*)["']?\s+do\b}))
440
+ elsif (match = line.match(%r{\bcontroller\s+:?["']?([a-zA-Z_][\w/]*)["']?}))
156
441
  contexts << RouteContext.new(modules: context.modules, resource: context.resource, controller: match[1])
157
- elsif line.match?(/\bscope\b.*\bcontroller:\s*.*do\b/)
158
- contexts << RouteContext.new(modules: context.modules, resource: context.resource,
159
- controller: scoped_controller_option(line, context.controller))
160
- elsif (match = line.match(/\bresources\s+:([a-zA-Z_]\w*).*do\b/))
442
+ elsif (match = line.match(/\bresources\s+:([a-zA-Z_]\w*)/))
161
443
  contexts << RouteContext.new(modules: context.modules, resource: resource_controller(line, match[1]),
162
444
  controller: context.controller)
163
- elsif (match = line.match(/\bresource\s+:([a-zA-Z_]\w*).*do\b/))
445
+ elsif (match = line.match(/\bresource\s+:([a-zA-Z_]\w*)/))
164
446
  contexts << RouteContext.new(modules: context.modules,
165
447
  resource: resource_controller(line, pluralize(match[1])), controller: context.controller)
166
- elsif line.match?(/\b(?:member|collection)\s+do\b/)
167
- contexts << RouteContext.new(modules: context.modules, resource: context.resource,
168
- controller: context.controller)
169
- elsif line.match?(/\b(?:constraints|defaults|scope)\b.*do\b/)
448
+ elsif line.match?(/\b(?:member|collection|constraints|defaults|scope)\b/)
170
449
  contexts << RouteContext.new(modules: context.modules, resource: context.resource,
171
450
  controller: scoped_controller_option(line, context.controller))
172
451
  end
173
452
  end
174
453
 
175
- def route_file_targets(line, root, seen, concerns)
454
+ def route_file_targets(line, project, seen, concerns)
176
455
  return [] unless (match = line.match(/\bdraw\s+:?["']?([a-zA-Z_]\w*)/))
177
456
 
178
- parse_route_file(root, File.join(root, "config/routes/#{match[1]}.rb"), seen: seen, concerns: concerns)
457
+ parse_route_file(
458
+ project,
459
+ File.join(project.root, "config/routes/#{match[1]}.rb"),
460
+ seen: seen,
461
+ concerns: concerns
462
+ )
179
463
  end
180
464
 
181
- def concern_targets(line, context, root, seen, concerns)
465
+ def concern_targets(line, context, project, seen, concerns)
182
466
  target_context = concern_context(line, context)
183
467
  concern_names(line).flat_map do |name|
184
- parse_routes(concerns.fetch(name, []), root: root, seen: seen, concerns: concerns,
185
- initial_context: target_context)
468
+ statements, source = concerns[name]
469
+ next [] unless statements && source
470
+
471
+ parse_route_statements(
472
+ statements,
473
+ source: source,
474
+ project: project,
475
+ seen: seen,
476
+ concerns: concerns,
477
+ context: target_context
478
+ )
186
479
  end
187
480
  end
188
481
 
@@ -219,7 +512,7 @@ module Necropsy
219
512
  def action_option(line, name)
220
513
  array_value = line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 1] ||
221
514
  line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 2]
222
- return array_value.scan(/:?["']?([a-zA-Z_]\w*)["']?/) if array_value
515
+ return array_value.scan(/:?["']?([a-zA-Z_]\w*)["']?/).flatten if array_value
223
516
 
224
517
  Array(line[/\b#{name}:\s+:?["']?([a-zA-Z_]\w*)/, 1])
225
518
  end
@@ -244,21 +537,42 @@ module Necropsy
244
537
  end
245
538
 
246
539
  def controller_action_target(line, context)
247
- controller = line[%r{\bcontroller:\s+:?["']?([a-zA-Z_][\w/]*)}, 1]
248
- action = line[/\baction:\s+:?["']?([a-zA-Z_]\w*)/, 1]
540
+ controller = literal_source_option(line, 'controller', allow_path: true)
541
+ action = literal_source_option(line, 'action')
249
542
  controller ||= context.controller
250
543
  return nil unless controller && action
251
544
 
252
545
  controller_action_id(route_controller(context, controller), action)
253
546
  end
254
547
 
548
+ def literal_source_option(line, name, allow_path: false)
549
+ value_pattern = allow_path ? '[a-zA-Z_][\\w/]*' : '[a-zA-Z_]\\w*'
550
+ match = line.match(/\b#{Regexp.escape(name)}:\s*(?::(#{value_pattern})|["'](#{value_pattern})["'])/)
551
+ match && (match[1] || match[2])
552
+ end
553
+
255
554
  def mounted_engine_targets(engine_name)
256
555
  ["#{engine_name}.call", "#{engine_name}#call"]
257
556
  end
258
557
 
259
558
  def pluralize(name)
260
- return "#{name}es" if name.end_with?('s', 'x', 'z', 'ch', 'sh')
261
- return "#{name.delete_suffix('y')}ies" if name.end_with?('y')
559
+ irregular = @irregular_plurals || IRREGULAR_PLURALS
560
+ return irregular.fetch(name) if irregular.key?(name)
561
+ return name if @uncountables&.include?(name)
562
+ return name.sub(/(quiz)\z/i, '\\1zes') if name.match?(/quiz\z/i)
563
+ return name.sub(/^(ox)\z/i, '\\1en') if name.match?(/^ox\z/i)
564
+ return name.sub(/([ml])ouse\z/i, '\\1ice') if name.match?(/([ml])ouse\z/i)
565
+ return name.sub(/(matr|vert|ind)(?:ix|ex)\z/i, '\\1ices') if name.match?(/(matr|vert|ind)(?:ix|ex)\z/i)
566
+ return name.sub(/(x|ch|ss|sh)\z/i, '\\1es') if name.match?(/(x|ch|ss|sh)\z/i)
567
+ return name.sub(/([^aeiouy]|qu)y\z/i, '\\1ies') if name.match?(/([^aeiouy]|qu)y\z/i)
568
+ return name.sub(/(?:([^f])fe|([lr])f)\z/i, '\\1\\2ves') if name.match?(/(?:([^f])fe|([lr])f)\z/i)
569
+ return name.sub(/sis\z/i, 'ses') if name.match?(/sis\z/i)
570
+ return name.sub(/([ti])um\z/i, '\\1a') if name.match?(/([ti])um\z/i)
571
+ return name.sub(/(buffal|tomat)o\z/i, '\\1oes') if name.match?(/(buffal|tomat)o\z/i)
572
+ return name.sub(/(alias|status)\z/i, '\\1es') if name.match?(/(alias|status)\z/i)
573
+ return name.sub(/(octop|vir)us\z/i, '\\1i') if name.match?(/(octop|vir)us\z/i)
574
+ return name.sub(/^(ax|test)is\z/i, '\\1es') if name.match?(/^(ax|test)is\z/i)
575
+ return name if name.end_with?('s')
262
576
 
263
577
  "#{name}s"
264
578
  end
@@ -268,29 +582,52 @@ module Necropsy
268
582
  end
269
583
 
270
584
  def matching_route_nodes(graph, node_id)
271
- return [node_id] if graph.nodes.key?(node_id)
272
-
273
- suffix = "::#{node_id}"
274
- graph.nodes.keys.select { |candidate| candidate.end_with?(suffix) }
585
+ exact = graph.definitions_for(node_id)
586
+ return exact.select { |node| node.visibility == :public }.map(&:graph_id) unless exact.empty?
587
+ return [] unless node_id.include?('Controller#')
588
+
589
+ controller, action = node_id.split('#', 2)
590
+ expected_file = "app/controllers/#{underscore(controller.delete_suffix('Controller'))}_controller.rb"
591
+ graph.method_nodes.filter_map do |node|
592
+ next unless node.name == action && node.owner&.end_with?(controller)
593
+ next unless node.visibility == :public
594
+ next unless node.file == expected_file
595
+
596
+ node.graph_id
597
+ end
275
598
  end
276
599
 
277
600
  def helper_referenced?(project, method_name)
278
- view_files(project).any? do |path|
279
- view_source(path).match?(/\b#{Regexp.escape(method_name)}\b/)
280
- end
281
- rescue SystemCallError, EncodingError
282
- false
601
+ view_method_names(project).include?(method_name)
283
602
  end
284
603
 
285
604
  def view_files(project)
286
- Dir.glob(File.join(project.root, '{app/views,app/components}/**/*')).select { |path| File.file?(path) }
605
+ project.reference_files.select do |path|
606
+ project.relative_path(path).start_with?('app/views/', 'app/components/')
607
+ end
287
608
  end
288
609
 
289
610
  def view_source(path)
290
- File.read(path)
291
- .gsub(/<%#.*?%>/m, '')
292
- .gsub(/<!--.*?-->/m, '')
293
- .lines.reject { |line| line.strip.start_with?('-#') }.join
611
+ source = File.read(path)
612
+ return EmbeddedRuby.extract(source) if File.extname(path).downcase == '.erb'
613
+
614
+ source.gsub(/<!--.*?-->/m, '').lines.reject { |line| line.strip.start_with?('-#') }.join
615
+ end
616
+
617
+ def view_method_names(project)
618
+ @view_method_names ||= {}
619
+ @view_method_names[project.root] ||= view_files(project).each_with_object(Set.new) do |path, names|
620
+ source = File.read(path)
621
+ if File.extname(path).downcase == '.erb'
622
+ names.merge(EmbeddedRuby.call_names(source))
623
+ else
624
+ view_source(path).scan(/(?<![A-Za-z0-9_])([a-zA-Z_]\w*[!?=]?)(?![A-Za-z0-9_])/).each do |match|
625
+ names << match.first
626
+ end
627
+ end
628
+ rescue SystemCallError, EncodingError
629
+ next
630
+ end
294
631
  end
295
632
 
296
633
  def component_entrypoint?(node)
@@ -298,37 +635,17 @@ module Necropsy
298
635
  end
299
636
 
300
637
  def camelize(path)
301
- path.split('/').map { |part| part.split('_').map(&:capitalize).join }.join('::')
638
+ acronyms = @acronyms || {}
639
+ path.split('/').map do |part|
640
+ part.split('_').map { |word| acronyms.fetch(word.downcase, word.capitalize) }.join
641
+ end.join('::')
302
642
  end
303
643
 
304
- def block_openings(line)
305
- line.scan(/\bdo\b/).length
306
- end
307
-
308
- def strip_route_comment(line)
309
- quote = nil
310
- escaped = false
311
- line.each_char.with_index do |char, index|
312
- if escaped
313
- escaped = false
314
- next
315
- end
316
-
317
- if char == '\\'
318
- escaped = true
319
- next
320
- end
321
-
322
- if quote
323
- quote = nil if char == quote
324
- next
325
- end
326
-
327
- quote = char if ["'", '"'].include?(char)
328
- return line[0...index] if char == '#'
329
- end
330
-
331
- line
644
+ def underscore(constant)
645
+ constant.gsub('::', '/')
646
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
647
+ .gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
648
+ .downcase
332
649
  end
333
650
  end
334
651
  end
@@ -5,7 +5,12 @@ module Necropsy
5
5
  class Test
6
6
  def apply(graph, _project)
7
7
  graph.nodes.values.select { |node| node.kind == :block_entry && node.test }.each do |node|
8
- graph.add_entry_point(node.id, :test_suite)
8
+ graph.add_entry_point(
9
+ node.graph_id,
10
+ :test_suite,
11
+ domain: :test,
12
+ evidence: { 'type' => 'test_file', 'file' => node.file }
13
+ )
9
14
  end
10
15
  end
11
16
  end