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
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module BlockerMatching
5
+ def blockers
6
+ @blockers.dup
7
+ end
8
+
9
+ def add_blocker(blocker)
10
+ blocker, key = canonical_blocker(blocker)
11
+ return blocker unless @blocker_keys.add?(key)
12
+
13
+ @dynamic_ancestry_cache = {} if blocker.kind == :dynamic_ancestry
14
+ @blockers << blocker
15
+ @blocker_keys_by_object[blocker] = key
16
+ index_blocker(blocker)
17
+ blocker
18
+ end
19
+
20
+ # Test-source uncertainty is diagnostic-only for production findings. A test
21
+ # call site cannot make a production definition callable in the deployed app.
22
+ def matching_blockers(node_or_id, caller_domain: :runtime)
23
+ node = node_or_id.is_a?(Node) ? node_or_id : nodes[node_or_id]
24
+ return [] unless node
25
+
26
+ blocker_candidates(node).select do |blocker|
27
+ blocker.caller_domain == caller_domain.to_sym && blocker_matches_node?(blocker, node)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def initialize_blocker_indexes
34
+ @blocker_keys = Set.new
35
+ @blocker_keys_by_object = {}.compare_by_identity
36
+ @blockers_by_message = Hash.new { |hash, key| hash[key] = [] }
37
+ @exact_blockers_by_scope = Hash.new do |scopes, kind|
38
+ scopes[kind] = Hash.new { |values, value| values[value] = [] }
39
+ end
40
+ @unmessaged_exact_owner_blockers = []
41
+ @glob_blockers_by_scope = Hash.new { |scopes, kind| scopes[kind] = [] }
42
+ end
43
+
44
+ def remove_blockers_matching(validate_retained: true, &)
45
+ removed, retained = @blockers.partition(&)
46
+ return if retained.length == @blockers.length
47
+
48
+ removed.each do |blocker|
49
+ key = @blocker_keys_by_object.delete(blocker)
50
+ @blocker_keys.delete(key) if key
51
+ end
52
+ @blockers = retained
53
+ rebuild_blocker_indexes(validate_retained: validate_retained)
54
+ end
55
+
56
+ def rebuild_blocker_indexes(validate_retained: true)
57
+ retained = @blockers
58
+ return rebuild_blocker_indexes_without_validation(retained) unless validate_retained
59
+
60
+ initialize_blocker_indexes
61
+ @blockers = []
62
+ retained.each { |blocker| add_blocker(blocker) }
63
+ end
64
+
65
+ def rebuild_blocker_indexes_without_validation(retained)
66
+ retained_keys = @blocker_keys_by_object
67
+ initialize_blocker_indexes
68
+ @blockers = retained
69
+ retained.each do |blocker|
70
+ key = retained_keys.fetch(blocker) { blocker_key(blocker) }
71
+ @blocker_keys.add?(key)
72
+ @blocker_keys_by_object[blocker] = key
73
+ index_blocker(blocker)
74
+ end
75
+ end
76
+
77
+ def index_blocker(blocker)
78
+ kind = blocker.scope_kind.to_sym
79
+ match = blocker_scope_match(blocker)
80
+ if match == :glob
81
+ @glob_blockers_by_scope[kind] << blocker
82
+ else
83
+ messages = blocker_index_messages(blocker).compact
84
+ if messages.any?
85
+ messages.each { |message| @blockers_by_message[message] << blocker }
86
+ elsif kind == :owner
87
+ @unmessaged_exact_owner_blockers << blocker
88
+ else
89
+ exact_scope_values(blocker).each { |value| @exact_blockers_by_scope[kind][value] << blocker }
90
+ end
91
+ end
92
+ end
93
+
94
+ def blocker_candidates(node)
95
+ candidates = []
96
+ candidates.concat(@blockers_by_message.fetch(node.name, []))
97
+ candidates.concat(exact_scope_blocker_candidates(node))
98
+ candidates.concat(unmessaged_owner_blocker_candidates(node))
99
+ candidates.concat(glob_scope_blocker_candidates(node))
100
+ candidates.uniq
101
+ end
102
+
103
+ def exact_scope_blocker_candidates(node)
104
+ candidates = []
105
+ candidates.concat(@exact_blockers_by_scope[:definition].fetch(node.graph_id, []))
106
+ candidates.concat(@exact_blockers_by_scope[:symbol].fetch(node.symbol_id, []))
107
+ candidates.concat(@exact_blockers_by_scope[:message].fetch(node.name, []))
108
+ namespace_scope_values(node.owner).each do |namespace|
109
+ candidates.concat(@exact_blockers_by_scope[:namespace].fetch(namespace, []))
110
+ end
111
+ candidates.concat(@exact_blockers_by_scope[:file].fetch(node.file.to_s, []))
112
+ candidates.concat(@exact_blockers_by_scope[:global].values.flatten)
113
+ candidates
114
+ end
115
+
116
+ def unmessaged_owner_blocker_candidates(node)
117
+ @unmessaged_exact_owner_blockers.select do |blocker|
118
+ exact_scope_values(blocker).any? { |owner| blocker_owner_matches?(blocker, node, owner) }
119
+ end
120
+ end
121
+
122
+ def namespace_scope_values(owner)
123
+ parts = owner.to_s.split('::')
124
+ parts.length.times.map { |index| parts.first(index + 1).join('::') }
125
+ end
126
+
127
+ def glob_scope_blocker_candidates(node)
128
+ @glob_blockers_by_scope.values.flatten.select do |blocker|
129
+ blocker_glob_scope_matches_node?(blocker, node)
130
+ end
131
+ end
132
+
133
+ def blocker_index_messages(blocker)
134
+ return Array(blocker.scope_value).compact.map(&:to_s) if blocker.scope_kind.to_sym == :message
135
+ return [blocker.message&.to_s] unless blocker.scope_kind.to_sym == :symbol
136
+
137
+ Array(blocker.scope_value).map { |symbol_id| logical_method_name(symbol_id) }.uniq
138
+ end
139
+
140
+ def exact_scope_values(blocker)
141
+ Array(blocker.scope_value).compact.map(&:to_s)
142
+ end
143
+
144
+ def logical_method_name(symbol_id)
145
+ value = symbol_id.to_s
146
+ separator = value.index(/[.#]/)
147
+ separator ? value[(separator + 1)..] : value
148
+ end
149
+
150
+ def blocker_key(blocker)
151
+ metadata = blocker.metadata
152
+ [
153
+ blocker.kind.to_s,
154
+ blocker.scope_kind.to_s,
155
+ canonical_key_value(blocker.scope_value),
156
+ canonical_key_value(blocker.source),
157
+ metadata['file'] || metadata[:file],
158
+ metadata['line'] || metadata[:line],
159
+ metadata['caller_id'] || metadata[:caller_id],
160
+ blocker.caller_domain.to_s,
161
+ metadata['caller_kind'] || metadata[:caller_kind],
162
+ metadata['receiver_kind'] || metadata[:receiver_kind],
163
+ metadata['original_message'] || metadata[:original_message],
164
+ metadata['call_site_id'] || metadata[:call_site_id],
165
+ metadata['producer'] || metadata[:producer],
166
+ metadata['producer_version'] || metadata[:producer_version],
167
+ metadata['resolution_record_id'] || metadata[:resolution_record_id],
168
+ metadata['evidence_id'] || metadata[:evidence_id],
169
+ canonical_key_value(metadata['assumptions'] || metadata[:assumptions]),
170
+ canonical_key_value(
171
+ metadata['known_target_definition_ids'] || metadata[:known_target_definition_ids]
172
+ ),
173
+ blocker_scope_match(blocker).to_s,
174
+ metadata.fetch('include_private') { metadata[:include_private] },
175
+ blocker.message&.to_s,
176
+ blocker.reason.to_s
177
+ ].freeze
178
+ end
179
+
180
+ def canonical_key_value(value)
181
+ BoundedCanonicalizer.dump(value)
182
+ end
183
+
184
+ def canonical_blocker(blocker)
185
+ [blocker, blocker_key(blocker)]
186
+ rescue BoundedCanonicalizer::Error, SystemStackError => e
187
+ sanitized = uncanonicalizable_blocker(blocker, e)
188
+ [sanitized, blocker_key(sanitized)]
189
+ end
190
+
191
+ def uncanonicalizable_blocker(blocker, error)
192
+ Blocker.new(
193
+ kind: :blocker_invalid,
194
+ scope_kind: :global,
195
+ scope_value: '*',
196
+ source: :blocker_store,
197
+ reason: 'Analyzer blocker could not be canonicalized safely',
198
+ suggested_action: :fix_analyzer,
199
+ metadata: {
200
+ 'caller_domain' => safe_blocker_domain(blocker),
201
+ 'receiver_kind' => 'implicit',
202
+ 'original_kind' => blocker.kind.to_s,
203
+ 'original_source_type' => blocker.source.class.name.to_s,
204
+ 'canonicalization_error' => error.class.name
205
+ }
206
+ )
207
+ end
208
+
209
+ def safe_blocker_domain(blocker)
210
+ blocker.caller_domain.to_s
211
+ rescue StandardError, SystemStackError
212
+ 'runtime'
213
+ end
214
+
215
+ def blocker_matches_node?(blocker, node)
216
+ return false if blocker_known_targets(blocker).include?(node.graph_id)
217
+ return false unless blocker_message_matches?(blocker, node)
218
+ return false unless blocker_visibility_matches?(blocker, node)
219
+ return blocker_glob_scope_matches_node?(blocker, node) if blocker_scope_match(blocker) == :glob
220
+
221
+ values = Array(blocker.scope_value).compact.map(&:to_s)
222
+ case blocker.scope_kind.to_sym
223
+ when :definition
224
+ values.include?(node.graph_id)
225
+ when :owner
226
+ values.any? { |owner| blocker_owner_matches?(blocker, node, owner) }
227
+ when :namespace
228
+ values.any? { |namespace| node.owner == namespace || node.owner&.start_with?("#{namespace}::") }
229
+ when :file
230
+ values.include?(node.file)
231
+ when :symbol
232
+ values.include?(node.symbol_id)
233
+ when :message, :global
234
+ true
235
+ else
236
+ false
237
+ end
238
+ end
239
+
240
+ def blocker_known_targets(blocker)
241
+ metadata = blocker.metadata
242
+ Array(metadata['known_target_definition_ids'] || metadata[:known_target_definition_ids]).map(&:to_s)
243
+ end
244
+
245
+ def blocker_message_matches?(blocker, node)
246
+ return true if blocker.scope_kind.to_sym == :symbol
247
+ if blocker.scope_kind.to_sym == :message && blocker_scope_match(blocker) == :glob
248
+ return scope_patterns(blocker).any? { |pattern| File.fnmatch?(pattern, node.name) }
249
+ end
250
+
251
+ blocker.message.nil? || blocker.message.to_s == node.name
252
+ end
253
+
254
+ def blocker_scope_match(blocker)
255
+ value = blocker.metadata['scope_match'] || blocker.metadata[:scope_match] || :exact
256
+ value.to_sym
257
+ end
258
+
259
+ def blocker_glob_scope_matches_node?(blocker, node)
260
+ values = glob_scope_node_values(blocker.scope_kind.to_sym, node)
261
+ return true if blocker.scope_kind.to_sym == :global
262
+
263
+ scope_patterns(blocker).any? do |pattern|
264
+ values.any? { |value| File.fnmatch?(pattern, value, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
265
+ end
266
+ end
267
+
268
+ def glob_scope_node_values(kind, node)
269
+ case kind
270
+ when :definition then [node.graph_id]
271
+ when :symbol then [node.symbol_id]
272
+ when :message then [node.name]
273
+ when :owner then [node.owner].compact
274
+ when :namespace then namespace_scope_values(node.owner)
275
+ when :file then [node.file]
276
+ else []
277
+ end.map(&:to_s)
278
+ end
279
+
280
+ def scope_patterns(blocker)
281
+ Array(blocker.scope_value).compact.map(&:to_s)
282
+ end
283
+
284
+ def blocker_visibility_matches?(blocker, node)
285
+ return true if %i[analyzer_failure duplicate_definition parse_incomplete].include?(blocker.kind.to_sym)
286
+
287
+ metadata = blocker.metadata
288
+ receiver_kind = (metadata['receiver_kind'] || metadata[:receiver_kind])&.to_sym
289
+ original_message = (metadata['original_message'] || metadata[:original_message])&.to_s
290
+ return node.visibility == :public if original_message == 'public_send'
291
+ return true if %w[send __send__ method].include?(original_message)
292
+ return respond_to_include_private(metadata) != false if original_message == 'respond_to?'
293
+ return true if %i[implicit self super].include?(receiver_kind)
294
+
295
+ node.visibility != :private
296
+ end
297
+
298
+ def respond_to_include_private(metadata)
299
+ return metadata['include_private'] if metadata.key?('include_private')
300
+ return metadata[:include_private] if metadata.key?(:include_private)
301
+
302
+ false
303
+ end
304
+
305
+ def blocker_owner_matches?(blocker, node, owner)
306
+ receiver_kind = (blocker.metadata['receiver_kind'] || blocker.metadata[:receiver_kind])&.to_sym
307
+ caller_kind = (blocker.metadata['caller_kind'] || blocker.metadata[:caller_kind])&.to_sym
308
+ if receiver_kind == :constant || (%i[implicit self].include?(receiver_kind) && caller_kind == :singleton_method)
309
+ return constant_owner_matches?(node, owner)
310
+ end
311
+
312
+ if receiver_kind == :super
313
+ expected_kind = caller_kind == :singleton_method ? :singleton_method : :instance_method
314
+ return node.kind == expected_kind && super_dispatch_owners(owner).include?(node.owner)
315
+ end
316
+
317
+ node.kind == :instance_method && instance_dispatch_owners(owner).include?(node.owner)
318
+ end
319
+
320
+ def constant_owner_matches?(node, owner)
321
+ return true if node.kind == :singleton_method && constant_dispatch_owners(owner).include?(node.owner)
322
+
323
+ node.kind == :instance_method && Array(class_info(owner)&.extends).include?(node.owner)
324
+ end
325
+
326
+ def constant_dispatch_owners(owner)
327
+ [owner, *ancestor_chain(owner), *Array(class_info(owner)&.extends)].compact.uniq
328
+ end
329
+
330
+ def super_dispatch_owners(owner)
331
+ ancestor_chain(owner).flat_map { |candidate| cached_lookup_chain(candidate) }.uniq
332
+ end
333
+
334
+ def instance_dispatch_owners(owner)
335
+ descendants_of(owner).flat_map { |candidate| cached_lookup_chain(candidate) }.uniq
336
+ end
337
+ end
338
+ end