necropsy 0.2.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. metadata +85 -1
@@ -6,42 +6,49 @@ module Necropsy
6
6
  module EntryPoints
7
7
  class Rails
8
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
9
12
  RESTFUL_ACTIONS = %w[index show new create edit update destroy].freeze
10
13
  SINGULAR_ACTIONS = %w[show new create edit update destroy].freeze
11
14
  IRREGULAR_PLURALS = { 'person' => 'people', 'man' => 'men', 'woman' => 'women', 'child' => 'children' }.freeze
15
+ INFLECTION_RULES_AFFECTING_PLURALIZATION = %i[clear plural].freeze
12
16
  RouteContext = Struct.new(:modules, :resource, :controller, keyword_init: true)
13
17
 
14
18
  def apply(graph, project)
15
- 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)
16
23
 
17
24
  referenced_view_methods = view_method_names(project)
18
25
  graph.method_nodes.each do |node|
19
26
  case node.file
20
27
  when %r{\Aapp/jobs/}
21
- 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'
22
29
  when %r{\Aapp/mailers/}
23
30
  if node.kind == :instance_method && node.visibility == :public
24
- graph.add_entry_point(node.id,
31
+ graph.add_entry_point(node.graph_id,
25
32
  :mailer_action)
26
33
  end
27
34
  when %r{\Aapp/helpers/}
28
- graph.add_entry_point(node.id, :rails_view_helper) if referenced_view_methods.include?(node.name)
35
+ graph.add_entry_point(node.graph_id, :rails_view_helper) if referenced_view_methods.include?(node.name)
29
36
  when %r{\Aapp/components/}
30
- 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)
31
38
  when %r{\Adb/migrate/}
32
- graph.add_entry_point(node.id, :rails_migration) if %w[change up down].include?(node.name)
39
+ graph.add_entry_point(node.graph_id, :rails_migration) if %w[change up down].include?(node.name)
33
40
  end
34
41
 
35
42
  if node.file.start_with?('app/') && !node.file.start_with?('app/helpers/', 'app/components/') &&
36
43
  referenced_view_methods.include?(node.name)
37
- graph.add_entry_point(node.id, :rails_view_reference)
44
+ graph.add_entry_point(node.graph_id, :rails_view_reference)
38
45
  end
39
46
  end
40
47
 
41
48
  graph.nodes.values.select do |node|
42
49
  node.kind == :block_entry && node.file.start_with?('config/initializers/')
43
50
  end.each do |node|
44
- graph.add_entry_point(node.id, :callback_registered)
51
+ graph.add_entry_point(node.graph_id, :callback_registered)
45
52
  end
46
53
 
47
54
  route_entry_points(project).each do |node_id|
@@ -49,52 +56,176 @@ module Necropsy
49
56
  graph.add_entry_point(matching_id, :rails_route)
50
57
  end
51
58
  end
59
+ @route_blockers.each { |blocker| graph.add_blocker(blocker) }
52
60
  end
53
61
 
54
62
  private
55
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
+
56
170
  def route_entry_points(project)
57
171
  routes = File.join(project.root, 'config/routes.rb')
58
- parse_route_file(project.root, routes, seen: {}, concerns: {})
172
+ parse_route_file(project, routes, seen: {}, concerns: {})
59
173
  end
60
174
 
61
- def parse_route_file(root, path, seen:, concerns:)
62
- expanded = File.expand_path(path, root)
63
- 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)
64
179
  return [] if seen[expanded]
65
180
 
66
181
  seen[expanded] = true
182
+ @current_route_path = project.relative_path(expanded)
67
183
  source = File.read(expanded)
68
184
  result = Prism.parse(source)
69
- return [] if result.failure?
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
70
191
 
71
192
  parse_route_statements(
72
193
  result.value.statements,
73
194
  source: source,
74
- root: root,
195
+ project: project,
75
196
  seen: seen,
76
197
  concerns: concerns,
77
198
  context: RouteContext.new(modules: [], resource: nil)
78
199
  )
79
- rescue SystemCallError, EncodingError
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
+ )
80
205
  []
206
+ ensure
207
+ @current_route_path = previous_route_path
81
208
  end
82
209
 
83
- def parse_route_statements(statements, source:, root:, seen:, concerns:, context:)
210
+ def parse_route_statements(statements, source:, project:, seen:, concerns:, context:)
84
211
  Array(statements&.body).flat_map do |statement|
85
- parse_route_statement(statement, source: source, root: root, seen: seen, concerns: concerns,
212
+ parse_route_statement(statement, source: source, project: project, seen: seen, concerns: concerns,
86
213
  context: context)
87
214
  end.compact.uniq
88
215
  end
89
216
 
90
- def parse_route_statement(statement, source:, root:, seen:, concerns:, context:)
217
+ def parse_route_statement(statement, source:, project:, seen:, concerns:, context:)
91
218
  unless statement.is_a?(Prism::CallNode)
92
219
  return statement.child_nodes.compact.flat_map do |child|
93
- parse_route_statement(child, source: source, root: root, seen: seen, concerns: concerns, context: context)
220
+ parse_route_statement(child, source: source, project: project, seen: seen, concerns: concerns,
221
+ context: context)
94
222
  end
95
223
  end
96
224
 
225
+ return [] unless ROUTE_DSL_CALLS.include?(statement.name)
226
+
97
227
  call_source = route_call_source(statement, source)
228
+ record_dynamic_route(statement, context) if dynamic_route_statement?(statement)
98
229
  if statement.name == :concern && statement.block
99
230
  name = literal_route_argument(statement.arguments&.arguments&.first)
100
231
  concerns[name] = [statement.block.body, source] if name
@@ -102,8 +233,8 @@ module Necropsy
102
233
  end
103
234
 
104
235
  targets = route_targets(call_source, context)
105
- targets.concat(route_file_targets(call_source, root, seen, concerns))
106
- targets.concat(concern_targets(call_source, context, root, seen, concerns))
236
+ targets.concat(route_file_targets(call_source, project, seen, concerns))
237
+ targets.concat(concern_targets(call_source, context, project, seen, concerns))
107
238
  return targets unless statement.block
108
239
 
109
240
  child_context = nested_route_context(call_source, context)
@@ -111,7 +242,7 @@ module Necropsy
111
242
  parse_route_statements(
112
243
  statement.block.body,
113
244
  source: source,
114
- root: root,
245
+ project: project,
115
246
  seen: seen,
116
247
  concerns: concerns,
117
248
  context: child_context
@@ -130,6 +261,119 @@ module Necropsy
130
261
  node.unescaped.to_s
131
262
  end
132
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)
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
302
+
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]
324
+ end
325
+
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
+
133
377
  def nested_route_context(line, context)
134
378
  contexts = [context]
135
379
  push_context(contexts, line, context)
@@ -207,13 +451,18 @@ module Necropsy
207
451
  end
208
452
  end
209
453
 
210
- def route_file_targets(line, root, seen, concerns)
454
+ def route_file_targets(line, project, seen, concerns)
211
455
  return [] unless (match = line.match(/\bdraw\s+:?["']?([a-zA-Z_]\w*)/))
212
456
 
213
- 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
+ )
214
463
  end
215
464
 
216
- def concern_targets(line, context, root, seen, concerns)
465
+ def concern_targets(line, context, project, seen, concerns)
217
466
  target_context = concern_context(line, context)
218
467
  concern_names(line).flat_map do |name|
219
468
  statements, source = concerns[name]
@@ -222,7 +471,7 @@ module Necropsy
222
471
  parse_route_statements(
223
472
  statements,
224
473
  source: source,
225
- root: root,
474
+ project: project,
226
475
  seen: seen,
227
476
  concerns: concerns,
228
477
  context: target_context
@@ -288,22 +537,42 @@ module Necropsy
288
537
  end
289
538
 
290
539
  def controller_action_target(line, context)
291
- controller = line[%r{\bcontroller:\s+:?["']?([a-zA-Z_][\w/]*)}, 1]
292
- 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')
293
542
  controller ||= context.controller
294
543
  return nil unless controller && action
295
544
 
296
545
  controller_action_id(route_controller(context, controller), action)
297
546
  end
298
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
+
299
554
  def mounted_engine_targets(engine_name)
300
555
  ["#{engine_name}.call", "#{engine_name}#call"]
301
556
  end
302
557
 
303
558
  def pluralize(name)
304
- return IRREGULAR_PLURALS.fetch(name) if IRREGULAR_PLURALS.key?(name)
305
- return "#{name}es" if name.end_with?('s', 'x', 'z', 'ch', 'sh')
306
- return "#{name.delete_suffix('y')}ies" if name.match?(/[^aeiou]y\z/)
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')
307
576
 
308
577
  "#{name}s"
309
578
  end
@@ -313,16 +582,18 @@ module Necropsy
313
582
  end
314
583
 
315
584
  def matching_route_nodes(graph, node_id)
316
- return [node_id] if graph.nodes.key?(node_id)
585
+ exact = graph.definitions_for(node_id)
586
+ return exact.select { |node| node.visibility == :public }.map(&:graph_id) unless exact.empty?
317
587
  return [] unless node_id.include?('Controller#')
318
588
 
319
589
  controller, action = node_id.split('#', 2)
320
590
  expected_file = "app/controllers/#{underscore(controller.delete_suffix('Controller'))}_controller.rb"
321
591
  graph.method_nodes.filter_map do |node|
322
592
  next unless node.name == action && node.owner&.end_with?(controller)
593
+ next unless node.visibility == :public
323
594
  next unless node.file == expected_file
324
595
 
325
- node.id
596
+ node.graph_id
326
597
  end
327
598
  end
328
599
 
@@ -331,21 +602,28 @@ module Necropsy
331
602
  end
332
603
 
333
604
  def view_files(project)
334
- 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
335
608
  end
336
609
 
337
610
  def view_source(path)
338
- File.read(path)
339
- .gsub(/<%#.*?%>/m, '')
340
- .gsub(/<!--.*?-->/m, '')
341
- .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
342
615
  end
343
616
 
344
617
  def view_method_names(project)
345
618
  @view_method_names ||= {}
346
619
  @view_method_names[project.root] ||= view_files(project).each_with_object(Set.new) do |path, names|
347
- view_source(path).scan(/(?<![A-Za-z0-9_])([a-zA-Z_]\w*[!?=]?)(?![A-Za-z0-9_])/).each do |match|
348
- names << match.first
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
349
627
  end
350
628
  rescue SystemCallError, EncodingError
351
629
  next
@@ -357,7 +635,10 @@ module Necropsy
357
635
  end
358
636
 
359
637
  def camelize(path)
360
- 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('::')
361
642
  end
362
643
 
363
644
  def underscore(constant)
@@ -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