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
@@ -5,106 +5,133 @@ require 'date'
5
5
  module Necropsy
6
6
  module Confidence
7
7
  class Scorer
8
- def initialize(graph:, reachability:, project:)
8
+ RUBY_HOOKS = %w[
9
+ inherited included extended prepended method_added singleton_method_added
10
+ const_missing method_missing respond_to_missing?
11
+ ].freeze
12
+ RUBY_PROTOCOLS = %w[
13
+ == eql? hash <=> to_s to_str to_a to_h to_proc inspect coerce
14
+ succ initialize_copy marshal_dump marshal_load
15
+ ].freeze
16
+ BUILTIN_IMPLICIT_CALLERS = [
17
+ {
18
+ name_pattern: /^on_/,
19
+ owner_ancestors: ['RuboCop::Cop::Base'],
20
+ reason: 'RuboCop Commissioner dispatches on_* callbacks'
21
+ }
22
+ ].freeze
23
+
24
+ def initialize(graph:, reachability:, project:, clock: Clock.new)
9
25
  @graph = graph
10
26
  @reachability = reachability
11
27
  @project = project
28
+ @clock = clock
29
+ @source_lines = {}
12
30
  end
13
31
 
14
32
  def findings
15
33
  graph.method_nodes.filter_map do |node|
16
34
  next if node.test
35
+ next unless graph.analyze_source?(node.file)
36
+ next if rails_generated?(node)
17
37
 
18
- classification = classification_for(node)
38
+ blockers = graph.matching_blockers(node)
39
+ classification = classification_for(node, blockers)
19
40
  next unless classification
20
41
 
21
- score, level, reasons = score_for(node, classification)
42
+ score, level, reasons, score_components = score_for(node, classification, blockers)
22
43
  Finding.new(
23
44
  node: node,
24
45
  classification: classification,
25
46
  confidence: level,
26
47
  score: score,
48
+ score_components: score_components,
27
49
  reasons: reasons,
28
- evidences: graph.incoming_edges(node.id).flat_map(&:evidences) + graph.alive_evidences(node.id)
50
+ evidences: graph.incoming_edges(node.graph_id).flat_map(&:evidences) + graph.alive_evidences(node.graph_id),
51
+ blockers: blockers
29
52
  )
30
53
  end
31
54
  end
32
55
 
33
56
  private
34
57
 
35
- attr_reader :graph, :reachability, :project
58
+ attr_reader :graph, :reachability, :project, :clock
36
59
 
37
- def classification_for(node)
38
- if reachability.runtime_alive.include?(node.id)
39
- return :unused if graph.dynamic_enabled? && !graph.dynamic_alive?(node.id)
60
+ def classification_for(node, blockers)
61
+ return nil if graph.dynamic_alive?(node.graph_id)
40
62
 
63
+ if reachability.runtime_paths.key?(node.graph_id) || reachability.external_paths.key?(node.graph_id)
41
64
  nil
42
- elsif reachability.test_alive.include?(node.id)
65
+ elsif blockers.any?
66
+ :blocked
67
+ elsif reachability.test_paths.key?(node.graph_id)
43
68
  :test_only_reachable
44
69
  else
45
70
  :unreachable
46
71
  end
47
72
  end
48
73
 
49
- def score_for(node, classification)
74
+ def score_for(node, classification, blockers)
50
75
  reasons = []
51
76
  score = base_score(classification)
77
+ components = [score_component("base(#{classification})", score, 'Base classification score')]
78
+
79
+ if blockers.any?
80
+ components << score_component('matching_blocker', 0.0, 'Incomplete runtime dispatch matches this definition')
81
+ reasons << "Blocked by #{blockers.length} unresolved runtime dispatch#{'es' unless blockers.one?}."
82
+ end
52
83
 
53
- if graph.uncertainties(node.id).any?
84
+ if graph.uncertainties(node.graph_id).any?
54
85
  score -= 0.35
86
+ components << score_component('near_metaprogramming', -0.35, 'Unresolved metaprogramming nearby')
55
87
  reasons << 'Lowered because this node is near unresolved metaprogramming.'
56
88
  end
57
89
 
58
90
  if graph.class_info(node.owner)&.dynamic
59
91
  score -= 0.25
92
+ components << score_component('dynamic_owner', -0.25, 'Owner defines dynamic dispatch')
60
93
  reasons << 'Lowered because the owner class defines dynamic dispatch.'
61
94
  end
62
95
 
63
96
  if generated_accessor?(node)
64
97
  score -= 0.2
98
+ components << score_component('generated_accessor', -0.2, 'Generated by an accessor DSL')
65
99
  reasons << 'Lowered because this node is generated by a Ruby accessor DSL.'
66
100
  end
67
101
 
68
- static_count = graph.profiles.count { |profile| profile.kind == :static }
69
- if static_count > 1
70
- score += [static_count - 1, 3].min * 0.04
71
- reasons << "Raised because #{static_count} static analyzers participated."
102
+ if (reason = implicit_call_context(node))
103
+ score -= 0.4
104
+ components << score_component('implicit_caller', -0.4, reason)
105
+ reasons << "Lowered because #{reason}."
72
106
  end
73
107
 
74
- evidence_analyzers = graph.incoming_edges(node.id).flat_map(&:evidences).map(&:analyzer).uniq
75
- if evidence_analyzers.length > 1
76
- score += 0.08
77
- reasons << 'Raised because multiple analyzers agree on incoming reachability evidence.'
78
- end
108
+ add_runtime_unobserved_annotation(node, reasons, components)
79
109
 
80
- if classification == :unreachable && graph.dynamic_enabled? && !graph.dynamic_alive?(node.id)
81
- score += 0.25
82
- reasons << 'Static unreachable and absent from dynamic observations.'
83
- end
110
+ add_quarantine_annotation(node, classification, reasons, components)
84
111
 
85
- if classification == :unreachable && quarantine_expired?(node)
86
- score = [score, 0.95].max
87
- reasons << 'Raised because quarantine annotation has expired without alive evidence.'
88
- end
112
+ clamped_score = score.clamp(0.0, 1.0)
113
+ components << score_component('score_clamp', clamped_score - score, 'Clamped to the 0.0–1.0 range') if clamped_score != score
114
+ [clamped_score, level_for(clamped_score), reasons, components]
115
+ end
89
116
 
90
- if classification == :unused
91
- days = observation_days
92
- if days && days < project.config.min_observation_days
93
- score -= 0.3
94
- reasons << "Observation window is #{days} days, below the configured minimum."
95
- else
96
- reasons << 'Reachable statically but never observed dynamically.'
97
- end
98
- end
117
+ def score_component(name, value, details)
118
+ ScoreComponent.new(name: name, value: value.round(4), details: details)
119
+ end
120
+
121
+ def add_runtime_unobserved_annotation(node, reasons, components)
122
+ return unless graph.dynamic_observation?
123
+ return if graph.dynamic_alive?(node.graph_id)
99
124
 
100
- score = [[score, 0.0].max, 1.0].min
101
- [score, level_for(score), reasons]
125
+ components << score_component('runtime_unobserved', 0.0, 'Informational only; deadness is unchanged')
126
+ reasons << 'Runtime observations did not include this definition; classification and confidence are unchanged.'
102
127
  end
103
128
 
104
129
  def base_score(classification)
130
+ return 0.25 if classification == :blocked
131
+
105
132
  case classification
106
133
  when :unreachable
107
- graph.profiles.any? { |profile| profile.name == :rta } ? 0.78 : 0.62
134
+ 0.62
108
135
  when :test_only_reachable
109
136
  0.55
110
137
  when :unused
@@ -118,6 +145,62 @@ module Necropsy
118
145
  %i[attr_reader attr_writer attr_accessor struct_new data_define].include?(node.defined_via)
119
146
  end
120
147
 
148
+ def rails_generated?(node)
149
+ node.defined_via.to_s.start_with?('rails_')
150
+ end
151
+
152
+ def implicit_call_reason(node)
153
+ return "#{node.name} is a Ruby hook invoked by the VM" if RUBY_HOOKS.include?(node.name)
154
+ return "#{node.name} is a Ruby protocol method invoked implicitly" if RUBY_PROTOCOLS.include?(node.name)
155
+
156
+ rule = implicit_caller_rules.find do |candidate|
157
+ candidate[:name_pattern].match?(node.name) && owner_matches?(node.owner, candidate[:owner_ancestors])
158
+ end
159
+ return unless rule
160
+
161
+ rule[:reason] || 'the method matches a configured implicit caller'
162
+ end
163
+
164
+ def implicit_call_context(node)
165
+ direct_reason = implicit_call_reason(node)
166
+ return direct_reason if direct_reason
167
+
168
+ root_id = implicit_paths[node.graph_id]
169
+ "it is reachable from implicitly invoked #{root_id}" if root_id
170
+ end
171
+
172
+ def implicit_paths
173
+ @implicit_paths ||= begin
174
+ roots = graph.method_nodes.reject(&:test).select { |node| implicit_call_reason(node) }.map(&:graph_id)
175
+ paths = roots.to_h { |node_id| [node_id, node_id] }
176
+ queue = roots.dup
177
+ head = 0
178
+
179
+ while head < queue.length
180
+ node_id = queue.fetch(head)
181
+ head += 1
182
+ graph.edges_from(node_id).each_key do |callee_id|
183
+ next if paths.key?(callee_id)
184
+
185
+ paths[callee_id] = paths.fetch(node_id)
186
+ queue << callee_id
187
+ end
188
+ end
189
+ paths
190
+ end
191
+ end
192
+
193
+ def implicit_caller_rules
194
+ BUILTIN_IMPLICIT_CALLERS + project.config.implicit_callers
195
+ end
196
+
197
+ def owner_matches?(owner, ancestors)
198
+ return true if ancestors.empty?
199
+ return false unless owner
200
+
201
+ ancestors.any? { |ancestor| graph.owner_reachable_from_ancestor?(owner, ancestor) }
202
+ end
203
+
121
204
  def level_for(score)
122
205
  return :certain if score >= 0.9
123
206
  return :high if score >= 0.7
@@ -126,31 +209,71 @@ module Necropsy
126
209
  :low
127
210
  end
128
211
 
129
- def observation_days
130
- graph.observation.values.filter_map do |value|
131
- next unless value.is_a?(Hash)
132
-
133
- value['days'] || value[:days] || value['observation_days'] || value[:observation_days]
134
- end.map(&:to_i).max
135
- end
136
-
137
- def quarantine_expired?(node)
138
- since = quarantine_since(node)
139
- return false unless since
212
+ def add_quarantine_annotation(node, classification, reasons, components)
213
+ status = quarantine_status(node, classification)
214
+ return unless status
140
215
 
141
- since <= Date.today - project.config.quarantine_days
216
+ case status
217
+ when :review_required
218
+ components << score_component(
219
+ 'quarantine_review_required', 0.0, 'Quarantine expired; deadness is unchanged'
220
+ )
221
+ reasons << 'Quarantine annotation has expired and requires review; classification and confidence are unchanged.'
222
+ when :invalid
223
+ components << score_component(
224
+ 'quarantine_invalid_date', 0.0, 'Invalid quarantine date; deadness is unchanged'
225
+ )
226
+ reasons << 'Quarantine annotation has an invalid since date; classification and confidence are unchanged.'
227
+ when :fingerprint_required
228
+ components << score_component(
229
+ 'quarantine_fingerprint_required', 0.0, 'Legacy quarantine requires an exact physical fingerprint'
230
+ )
231
+ reasons << 'Quarantine annotation requires a physical fingerprint before it can apply to this definition.'
232
+ when :stale_fingerprint
233
+ components << score_component(
234
+ 'quarantine_stale_fingerprint', 0.0, 'Quarantine fingerprint does not match this physical definition'
235
+ )
236
+ reasons << 'Quarantine annotation is stale or belongs to a different physical definition.'
237
+ end
142
238
  end
143
239
 
144
- def quarantine_since(node)
240
+ def quarantine_status(node, classification)
145
241
  path = File.join(project.root, node.file)
146
242
  return nil unless File.exist?(path)
147
243
 
148
- window = File.readlines(path, chomp: true)[[node.line - 4, 0].max, 4] || []
149
- annotation = window.find { |line| line.include?('necropsy:quarantine') && line.match?(/\bsince=/) }
150
- return nil unless annotation
244
+ lines = @source_lines[path] ||= File.readlines(path, chomp: true)
245
+ annotation = node.line > 1 ? lines[node.line - 2] : nil
246
+ return nil unless annotation&.include?('necropsy:quarantine') && annotation.match?(/\bsince=/)
247
+
248
+ fingerprints = quarantine_fingerprints(annotation)
249
+ fingerprint_status = if fingerprints.nil?
250
+ :fingerprint_required
251
+ elsif fingerprints.include?(node.physical_fingerprint(classification))
252
+ :matched
253
+ else
254
+ :stale_fingerprint
255
+ end
256
+
257
+ raw_since = annotation[/\bsince=([^\s]+)/, 1]
258
+ return :invalid unless raw_since
259
+
260
+ since = Date.iso8601(raw_since)
261
+ return fingerprint_status unless fingerprint_status == :matched
262
+
263
+ since <= clock.date - project.config.quarantine_days ? :review_required : :active
264
+ rescue ArgumentError, TypeError
265
+ :invalid
266
+ rescue SystemCallError
267
+ nil
268
+ end
269
+
270
+ def quarantine_fingerprints(annotation)
271
+ single = annotation[/\bfingerprint=([0-9a-f]{64})(?:\s|$)/, 1]
272
+ multiple = annotation[/\bfingerprints=([0-9a-f]{64}(?:,[0-9a-f]{64})*)(?:\s|$)/, 1]
273
+ return [single] if single
274
+ return multiple.split(',') if multiple
275
+ return [] if annotation.match?(/\bfingerprints?=/)
151
276
 
152
- Date.iso8601(annotation[/\bsince=([0-9-]+)/, 1])
153
- rescue ArgumentError, SystemCallError
154
277
  nil
155
278
  end
156
279
  end