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,37 +1,265 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Necropsy
4
6
  class Runner
5
- attr_reader :root, :config, :analyzers
7
+ ANALYZER_ERROR_MESSAGE_BYTES = 500
8
+ INVALID_BLOCKER_KINDS = %i[
9
+ analyzer_failure blocker_invalid evidence_collision rails_route_health resolution_invalid unsound_rta_pruning
10
+ ].freeze
11
+ DEGRADED_BLOCKER_KINDS = %i[
12
+ reference_scan_incomplete reference_scope_incomplete source_discovery_incomplete
13
+ ].freeze
14
+
15
+ attr_reader :root, :config, :analyzers, :ignored_reference_paths, :clock
6
16
 
7
- def initialize(root:, config_path: nil, analyzers: nil)
17
+ def initialize(root:, config_path: nil, analyzers: nil, ignored_reference_paths: [], as_of: nil)
8
18
  @root = File.expand_path(root)
9
19
  @config = Configuration.load(root: @root, path: config_path)
10
20
  @analyzers = analyzers
21
+ @ignored_reference_paths = ignored_reference_paths
22
+ @clock = Clock.new(as_of: as_of)
11
23
  end
12
24
 
13
- def analyze
14
- project = Project.new(root: root, config: config)
15
- graph = CallGraph.new(project.scan_result)
25
+ def analyze(rta_pruning: config.rta_pruning, profile: false)
26
+ rta_pruning = normalize_rta_pruning(rta_pruning)
27
+ profiler = profile ? PerformanceProfiler.new : nil
28
+ project = measure_phase(profiler, 'project') { Project.new(root: root, config: config) }
29
+ source_snapshot = measure_phase(profiler, 'source_snapshot') { project.source_snapshot }
30
+ graph = measure_phase(profiler, 'scan') do
31
+ CallGraph.new(project.scan_result, ambiguity_limit: config.ambiguity_limit)
32
+ end
33
+ project.scope_blockers.each { |blocker| graph.add_blocker(blocker) }
34
+ graph.add_blocker(unsound_rta_pruning_blocker) if rta_pruning == :legacy
35
+ rta_results = []
16
36
 
17
- apply_entry_points(graph, project)
18
- configured_analyzers.each do |analyzer|
19
- graph.add_profile(analyzer.profile)
20
- graph.apply_result(analyzer.analyze(graph, project))
37
+ measure_phase(profiler, 'entry_points') { apply_entry_points(graph, project) }
38
+ configured_analyzers.each do |configured_analyzer|
39
+ analyzer_name = configured_analyzer.profile.name
40
+ analyzer_profile, result = measure_phase(profiler, "analyzer:#{analyzer_name}") do
41
+ run_analyzer(graph, project, configured_analyzer, rta_pruning)
42
+ end
43
+ rta_results << result if analyzer_profile&.name == :rta && result
44
+ end
45
+ graph.refresh_derived_state
46
+ measure_phase(profiler, 'reachability') do
47
+ rta_results.each { |result| graph.reconcile_rta_result(result) } if rta_pruning == :legacy
21
48
  end
22
49
 
23
- reachability = Reachability::Engine.new(graph).call
24
- findings = Confidence::Scorer.new(graph: graph, reachability: reachability, project: project).findings
25
- Report.new(root: root, graph: graph, findings: findings)
50
+ reachability = measure_phase(profiler, 'reachability_engine') { Reachability::Engine.new(graph).call }
51
+ LoadGraph.record_unrooted_units(graph: graph, reachability: reachability)
52
+ findings = measure_phase(profiler, 'scoring') do
53
+ scorer = Confidence::Scorer.new(graph: graph, reachability: reachability, project: project, clock: clock)
54
+ scorer.findings
55
+ end
56
+ barrier_matches = measure_phase(profiler, 'reference_barrier') do
57
+ ReferenceBarrier.new(
58
+ graph: graph,
59
+ project: project,
60
+ ignored_paths: ignored_reference_paths
61
+ ).apply(findings)
62
+ end
63
+ if barrier_matches.positive?
64
+ findings = Confidence::Scorer.new(
65
+ graph: graph, reachability: reachability, project: project, clock: clock
66
+ ).findings
67
+ end
68
+ final_source_snapshot = measure_phase(profiler, 'source_snapshot_verification') { project.fresh_source_snapshot }
69
+ analysis_health = build_analysis_health(graph, source_snapshot, final_source_snapshot)
70
+ source_snapshot = verified_source_snapshot(source_snapshot, final_source_snapshot)
71
+ performance = profiler&.report(
72
+ counts: graph.performance_counts,
73
+ report_index_size_bytes: report_index_size_bytes(graph)
74
+ )
75
+ Report.new(
76
+ root: root,
77
+ graph: graph,
78
+ findings: findings,
79
+ reachability: reachability,
80
+ project: project,
81
+ source_snapshot: source_snapshot,
82
+ analysis_health: analysis_health,
83
+ report_include_paths: config.report_include_paths,
84
+ report_exclude_paths: config.report_exclude_paths,
85
+ performance_profile: performance
86
+ )
26
87
  end
27
88
 
28
89
  private
29
90
 
91
+ def build_analysis_health(graph, initial_snapshot, final_snapshot)
92
+ reasons = snapshot_health_reasons(initial_snapshot, final_snapshot)
93
+ graph.blockers.each do |blocker|
94
+ severity = blocker_health_severity(blocker)
95
+ next unless severity
96
+
97
+ reasons << blocker_health_reason(blocker, severity)
98
+ end
99
+ AnalysisHealth.from_reasons(reasons.uniq { |reason| BoundedCanonicalizer.dump(reason) })
100
+ end
101
+
102
+ def snapshot_health_reasons(initial_snapshot, final_snapshot)
103
+ snapshots = { 'initial' => initial_snapshot, 'final' => final_snapshot }
104
+ unavailable = snapshots.filter_map do |phase, snapshot|
105
+ next if snapshot['status'] == 'complete'
106
+
107
+ { 'phase' => phase, 'reason' => snapshot['reason'], 'details' => snapshot.except('sha256') }
108
+ end
109
+ return [{ 'severity' => 'invalid', 'code' => 'source_snapshot_unavailable', 'snapshots' => unavailable }] if unavailable.any?
110
+ return [] if initial_snapshot['sha256'] == final_snapshot['sha256']
111
+
112
+ [{
113
+ 'severity' => 'invalid',
114
+ 'code' => 'source_changed_during_analysis',
115
+ 'initial_sha256' => initial_snapshot['sha256'],
116
+ 'final_sha256' => final_snapshot['sha256']
117
+ }]
118
+ end
119
+
120
+ def blocker_health_severity(blocker)
121
+ return :invalid if blocker.kind == :parse_incomplete && blocker.caller_domain == :runtime
122
+ return :invalid if INVALID_BLOCKER_KINDS.include?(blocker.kind)
123
+
124
+ :degraded if DEGRADED_BLOCKER_KINDS.include?(blocker.kind)
125
+ end
126
+
127
+ def blocker_health_reason(blocker, severity)
128
+ metadata = blocker.metadata
129
+ {
130
+ 'severity' => severity.to_s,
131
+ 'code' => blocker.kind.to_s,
132
+ 'message' => blocker.reason,
133
+ 'source' => blocker.source.respond_to?(:to_h) ? blocker.source.to_h : blocker.source.to_s,
134
+ 'file' => metadata['file'] || metadata[:file],
135
+ 'line' => metadata['line'] || metadata[:line]
136
+ }.compact
137
+ end
138
+
139
+ def verified_source_snapshot(initial_snapshot, final_snapshot)
140
+ status = if initial_snapshot['status'] != 'complete' || final_snapshot['status'] != 'complete'
141
+ 'unavailable'
142
+ elsif initial_snapshot['sha256'] == final_snapshot['sha256']
143
+ 'match'
144
+ else
145
+ 'mismatch'
146
+ end
147
+ initial_snapshot.merge(
148
+ 'verification' => {
149
+ 'status' => status,
150
+ 'final_status' => final_snapshot['status'],
151
+ 'final_sha256' => final_snapshot['sha256']
152
+ }
153
+ )
154
+ end
155
+
156
+ def measure_phase(profiler, name, &block)
157
+ return block.call unless profiler
158
+
159
+ profiler.measure(name, &block)
160
+ end
161
+
162
+ def report_index_size_bytes(graph)
163
+ node_bytes = graph.nodes.values.sum { |node| JSON.generate(node.to_h).bytesize }
164
+ call_site_bytes = graph.call_sites.sum { |site| JSON.generate(site.to_h).bytesize }
165
+ edge_bytes = graph.edges.sum { |edge| JSON.generate(edge.to_h).bytesize }
166
+ node_bytes + call_site_bytes + edge_bytes
167
+ end
168
+
169
+ def normalize_rta_pruning(value)
170
+ mode = value.to_s
171
+ return mode.to_sym if Configuration::RTA_PRUNING_MODES.include?(mode)
172
+
173
+ raise Error, "RTA pruning must be one of: #{Configuration::RTA_PRUNING_MODES.join(', ')}"
174
+ end
175
+
176
+ def analyzer_for_pruning(analyzer, mode)
177
+ return analyzer unless analyzer.is_a?(Analyzers::Static::RTA)
178
+
179
+ configured = analyzer.with_pruning(mode)
180
+ return configured unless mode == :rank_only
181
+
182
+ configured.without_redundant_edges
183
+ end
184
+
185
+ def run_analyzer(graph, project, configured_analyzer, rta_pruning)
186
+ analyzer = analyzer_for_pruning(configured_analyzer, rta_pruning)
187
+ profile = analyzer.profile
188
+ raise TypeError, "#{analyzer.class} returned an invalid analyzer profile" unless profile.is_a?(AnalyzerProfile)
189
+
190
+ graph.add_profile(profile)
191
+ result = analyzer.analyze(graph, project)
192
+ result = Analyzers::LegacyResultAdapter.new(graph: graph, profile: profile).adapt(result)
193
+ validate_result_capabilities!(analyzer, result)
194
+ graph.apply_result(result, refresh: false)
195
+ [profile, result]
196
+ rescue StandardError => e
197
+ graph.add_blocker(analyzer_failure_blocker(analyzer || configured_analyzer, profile, e))
198
+ [profile, nil]
199
+ end
200
+
201
+ def analyzer_failure_blocker(analyzer, profile, error)
202
+ analyzer_name = profile&.name&.to_s || analyzer.class.name
203
+ error_message = safe_error_message(error)
204
+ Blocker.new(
205
+ kind: :analyzer_failure,
206
+ scope_kind: :global,
207
+ scope_value: '*',
208
+ source: analyzer_name,
209
+ reason: "Analyzer #{analyzer_name} failed: #{error.class}: #{error_message}",
210
+ suggested_action: :fix_analyzer,
211
+ metadata: {
212
+ 'analyzer' => analyzer_name,
213
+ 'analyzer_class' => analyzer.class.name,
214
+ 'caller_domain' => 'runtime',
215
+ 'error_class' => error.class.name,
216
+ 'error_message' => error_message
217
+ }
218
+ )
219
+ end
220
+
221
+ def validate_result_capabilities!(analyzer, result)
222
+ complete = Array(result.resolutions).any? { |record| record.resolution.status == :complete }
223
+ return unless complete
224
+ return if Array(analyzer.capabilities).map(&:to_sym).include?(:complete_resolution)
225
+
226
+ raise TypeError, "#{analyzer.class} emitted complete resolution without complete_resolution capability"
227
+ end
228
+
229
+ def unsound_rta_pruning_blocker
230
+ Blocker.new(
231
+ kind: :unsound_rta_pruning,
232
+ scope_kind: :global,
233
+ scope_value: '*',
234
+ source: :configuration,
235
+ reason: 'Legacy RTA pruning can remove reachable targets without complete allocation evidence',
236
+ suggested_action: :use_rank_only_rta,
237
+ metadata: { 'caller_domain' => 'runtime', 'rta_pruning' => 'legacy' }
238
+ )
239
+ end
240
+
241
+ def safe_error_message(error)
242
+ message = error.message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
243
+ return message if message.bytesize <= ANALYZER_ERROR_MESSAGE_BYTES
244
+
245
+ "#{message.byteslice(0, ANALYZER_ERROR_MESSAGE_BYTES).to_s.scrub}\u2026"
246
+ rescue StandardError, SystemStackError
247
+ 'unavailable'
248
+ end
249
+
30
250
  def apply_entry_points(graph, project)
31
251
  EntryPoints::Plain.new.apply(graph, project)
32
252
  EntryPoints::Rails.new.apply(graph, project)
33
253
  EntryPoints::Test.new.apply(graph, project)
34
- graph.entrypoint_hints.each { |entry| graph.add_entry_point(entry.node_id, entry.reason) }
254
+ graph.entrypoint_hints.each do |entry|
255
+ graph.add_entry_point(
256
+ entry.node_id,
257
+ entry.reason,
258
+ domain: entry.domain,
259
+ evidence: entry.evidence
260
+ )
261
+ end
262
+ WorldPolicy.new(graph: graph, project: project).apply
35
263
  end
36
264
 
37
265
  def configured_analyzers
@@ -45,6 +273,8 @@ module Necropsy
45
273
  Analyzers::Static::CHA.new
46
274
  when 'rta'
47
275
  Analyzers::Static::RTA.new
276
+ else
277
+ raise Error, "Unknown static analyzer: #{name}"
48
278
  end
49
279
  end
50
280
 
@@ -60,13 +290,27 @@ module Necropsy
60
290
  end
61
291
 
62
292
  def custom_analyzers
63
- config.custom_analyzers.map do |class_name|
293
+ config.custom_analyzers.map do |entry|
294
+ class_name, required_path = custom_analyzer_definition(entry)
295
+ require_custom_analyzer(required_path) if required_path
64
296
  constantize(class_name).new
65
297
  rescue NameError => e
66
298
  raise Error, "Could not load custom analyzer #{class_name}: #{e.message}"
299
+ rescue LoadError => e
300
+ raise Error, "Could not require custom analyzer #{class_name}: #{e.message}"
67
301
  end
68
302
  end
69
303
 
304
+ def custom_analyzer_definition(entry)
305
+ [entry.fetch('class'), entry['require']]
306
+ end
307
+
308
+ def require_custom_analyzer(path)
309
+ expanded = File.expand_path(path, root)
310
+ expanded = "#{expanded}.rb" if !File.file?(expanded) && File.file?("#{expanded}.rb")
311
+ File.file?(expanded) ? require(expanded) : require(path)
312
+ end
313
+
70
314
  def constantize(class_name)
71
315
  class_name.split('::').reduce(Object) { |constant, name| constant.const_get(name) }
72
316
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module Necropsy
7
+ # Compares positive runtime target observations with the static target set.
8
+ #
9
+ # Runtime observations may discover a target that the static resolver did not
10
+ # enumerate. That is a safety signal and a useful regression fixture, but an
11
+ # unobserved static target is never removed from the model.
12
+ class RuntimeFeedback
13
+ SCHEMA_VERSION = 1
14
+ DEFAULT_FIXTURE_LIMIT = 32
15
+
16
+ def initialize(static_targets:, observed_targets:, max_fixtures: DEFAULT_FIXTURE_LIMIT)
17
+ @static_targets = normalize_static_targets(static_targets)
18
+ @observed_targets = normalize_observed_targets(observed_targets)
19
+ @max_fixtures = Integer(max_fixtures)
20
+ raise ArgumentError, 'max_fixtures must be non-negative' if @max_fixtures.negative?
21
+ rescue ArgumentError, TypeError => e
22
+ raise e unless e.message.start_with?('invalid value for Integer()', 'no implicit conversion of')
23
+
24
+ raise ArgumentError, 'max_fixtures must be a non-negative integer'
25
+ end
26
+
27
+ def call
28
+ observed_by_site = @observed_targets.group_by { |target| target.fetch('call_site_id') }
29
+ static_by_site = @static_targets
30
+ missing_static = @observed_targets.filter_map do |target|
31
+ static = static_by_site.fetch(target.fetch('call_site_id'), [])
32
+ next if static.include?(target.fetch('target_definition_id'))
33
+
34
+ target.merge(
35
+ 'classification' => 'missing_static_target',
36
+ 'safety' => 'safety_bug'
37
+ )
38
+ end
39
+ fixtures = missing_static.first(@max_fixtures).map { |target| fixture_candidate(target) }
40
+ unobserved_static = static_by_site.flat_map do |call_site_id, targets|
41
+ observed = observed_by_site.fetch(call_site_id, []).map { |target| target.fetch('target_definition_id') }
42
+ targets.reject { |target| observed.include?(target) }.map do |target|
43
+ { 'call_site_id' => call_site_id, 'target_definition_id' => target, 'informational' => true }
44
+ end
45
+ end
46
+
47
+ {
48
+ 'schema_version' => SCHEMA_VERSION,
49
+ 'policy' => 'positive_only',
50
+ 'comparison' => {
51
+ 'call_sites' => (static_by_site.keys | observed_by_site.keys).length,
52
+ 'static_target_count' => static_by_site.values.sum(&:length),
53
+ 'observed_target_count' => @observed_targets.length
54
+ },
55
+ 'missing_static_targets' => missing_static,
56
+ 'unexpected_targets' => missing_static,
57
+ 'unobserved_static_targets' => unobserved_static,
58
+ 'fixture_candidates' => fixtures,
59
+ 'fixture_exported' => false
60
+ }
61
+ end
62
+
63
+ def write_fixture_candidates(path)
64
+ payload = call
65
+ candidates = payload.fetch('fixture_candidates')
66
+ destination = File.expand_path(path)
67
+ FileUtils.mkdir_p(File.dirname(destination))
68
+ temporary = "#{destination}.tmp-#{Process.pid}-#{Thread.current.object_id}"
69
+ File.write(temporary, "#{JSON.pretty_generate(candidates)}\n")
70
+ File.rename(temporary, destination)
71
+ payload.merge('fixture_exported' => true, 'fixture_path' => destination)
72
+ rescue SystemCallError => e
73
+ raise Error, "Could not export runtime feedback fixtures: #{e.message}"
74
+ ensure
75
+ FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
76
+ end
77
+
78
+ def self.from_graph(graph, observed_targets:, max_fixtures: DEFAULT_FIXTURE_LIMIT)
79
+ static_targets = graph.resolution_records.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |record, result|
80
+ result[record.resolution.call_site_id].concat(record.resolution.target_definition_ids)
81
+ end
82
+ new(static_targets: static_targets, observed_targets: observed_targets, max_fixtures: max_fixtures)
83
+ end
84
+
85
+ private
86
+
87
+ def normalize_static_targets(value)
88
+ raise ArgumentError, 'static_targets must be a Hash' unless value.is_a?(Hash)
89
+
90
+ value.each_with_object({}) do |(call_site_id, targets), result|
91
+ site = identifier(call_site_id, 'call_site_id')
92
+ result[site] = Array(targets).map { |target| identifier(target, 'target_definition_id') }.uniq.sort
93
+ end.sort.to_h.freeze
94
+ end
95
+
96
+ def normalize_observed_targets(value)
97
+ Array(value).map do |target|
98
+ raise ArgumentError, 'observed target must be a Hash' unless target.is_a?(Hash)
99
+
100
+ data = target.to_h { |key, item| [key.to_s, item] }
101
+ call_site_id = identifier(data['call_site_id'], 'call_site_id')
102
+ target_definition_id = identifier(
103
+ data['target_definition_id'] || data['target_id'], 'target_definition_id'
104
+ )
105
+ normalized = {
106
+ 'call_site_id' => call_site_id,
107
+ 'target_definition_id' => target_definition_id
108
+ }
109
+ %w[receiver_class file line].each do |key|
110
+ normalized[key] = data[key] unless data[key].nil?
111
+ end
112
+ normalized
113
+ end.uniq.sort_by { |target| [target['call_site_id'], target['target_definition_id']] }.freeze
114
+ end
115
+
116
+ def fixture_candidate(target)
117
+ {
118
+ 'call_site_id' => target.fetch('call_site_id'),
119
+ 'target_definition_id' => target.fetch('target_definition_id'),
120
+ 'receiver_class' => target['receiver_class'],
121
+ 'source' => {
122
+ 'file' => target['file'],
123
+ 'line' => target['line']
124
+ }.compact,
125
+ 'reason' => 'runtime target was not present in the static target set'
126
+ }.compact
127
+ end
128
+
129
+ def identifier(value, field)
130
+ normalized = value.to_s
131
+ raise ArgumentError, "#{field} must not be empty" if normalized.empty?
132
+
133
+ normalized
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'prism'
5
+ require 'yaml'
6
+
7
+ module Necropsy
8
+ class SemanticsMatrix
9
+ SCHEMA_VERSION = 'necropsy.semantics.v1'
10
+ STATUSES = %w[exact partial blocked unsupported].freeze
11
+
12
+ EXACT_PRISM_NODES = %i[
13
+ AliasMethodNode ArgumentsNode ArrayNode AssocNode BeginNode BlockArgumentNode BlockLocalVariableNode
14
+ BlockParameterNode BlockParametersNode ClassNode ConstantPathNode ConstantReadNode ConstantTargetNode
15
+ DefNode FalseNode FloatNode ForwardingArgumentsNode ForwardingParameterNode HashNode ImaginaryNode
16
+ ImplicitNode ImplicitRestNode IntegerNode ItLocalVariableReadNode ItParametersNode KeywordHashNode
17
+ KeywordRestParameterNode LocalVariableReadNode LocalVariableTargetNode ModuleNode NilNode
18
+ NoKeywordsParameterNode NumberedParametersNode NumberedReferenceReadNode OptionalKeywordParameterNode
19
+ OptionalParameterNode ParametersNode ParenthesesNode RationalNode RegularExpressionNode
20
+ RequiredKeywordParameterNode RequiredParameterNode RestParameterNode SelfNode SourceEncodingNode
21
+ SourceFileNode SourceLineNode StatementsNode StringNode SymbolNode TrueNode
22
+ ].to_set.freeze
23
+
24
+ PARTIAL_PRISM_NODES = %i[
25
+ AliasGlobalVariableNode AlternationPatternNode AndNode ArrayPatternNode AssocSplatNode
26
+ BackReferenceReadNode BlockNode BreakNode CallAndWriteNode CallNode CallOperatorWriteNode CallOrWriteNode
27
+ CallTargetNode CapturePatternNode CaseMatchNode CaseNode ClassVariableAndWriteNode
28
+ ClassVariableOperatorWriteNode ClassVariableOrWriteNode ClassVariableReadNode ClassVariableTargetNode
29
+ ClassVariableWriteNode ConstantAndWriteNode ConstantOperatorWriteNode ConstantOrWriteNode
30
+ ConstantPathAndWriteNode ConstantPathOperatorWriteNode ConstantPathOrWriteNode ConstantPathTargetNode
31
+ ConstantPathWriteNode ConstantWriteNode DefinedNode ElseNode EmbeddedStatementsNode EmbeddedVariableNode
32
+ EnsureNode FindPatternNode FlipFlopNode ForNode ForwardingSuperNode GlobalVariableAndWriteNode
33
+ GlobalVariableOperatorWriteNode GlobalVariableOrWriteNode GlobalVariableReadNode GlobalVariableTargetNode
34
+ GlobalVariableWriteNode HashPatternNode IfNode InNode IndexAndWriteNode IndexOperatorWriteNode IndexOrWriteNode
35
+ IndexTargetNode InstanceVariableAndWriteNode InstanceVariableOperatorWriteNode InstanceVariableOrWriteNode
36
+ InstanceVariableReadNode InstanceVariableTargetNode InstanceVariableWriteNode InterpolatedMatchLastLineNode
37
+ InterpolatedRegularExpressionNode InterpolatedStringNode InterpolatedSymbolNode InterpolatedXStringNode
38
+ LambdaNode LocalVariableAndWriteNode LocalVariableOperatorWriteNode LocalVariableOrWriteNode
39
+ LocalVariableWriteNode MatchLastLineNode MatchPredicateNode MatchRequiredNode MatchWriteNode MultiTargetNode
40
+ MultiWriteNode NextNode OrNode PinnedExpressionNode PinnedVariableNode PostExecutionNode PreExecutionNode
41
+ ProgramNode RangeNode RedoNode RescueModifierNode RescueNode RetryNode ReturnNode ShareableConstantNode
42
+ SingletonClassNode SplatNode SuperNode UndefNode UnlessNode UntilNode WhenNode WhileNode XStringNode YieldNode
43
+ ].to_set.freeze
44
+
45
+ BLOCKED_PRISM_NODES = %i[MissingNode].to_set.freeze
46
+
47
+ RUBY_HOOK_STATUS = {
48
+ 'inherited' => 'partial',
49
+ 'included' => 'partial',
50
+ 'extended' => 'partial',
51
+ 'prepended' => 'partial',
52
+ 'method_added' => 'partial',
53
+ 'singleton_method_added' => 'partial',
54
+ 'const_missing' => 'partial',
55
+ 'method_missing' => 'partial',
56
+ 'respond_to_missing?' => 'partial'
57
+ }.freeze
58
+
59
+ def to_h
60
+ {
61
+ 'schema_version' => SCHEMA_VERSION,
62
+ 'runtime' => { 'ruby' => RUBY_VERSION, 'prism' => Prism::VERSION },
63
+ 'statuses' => STATUSES,
64
+ 'prism_nodes' => prism_nodes,
65
+ 'ruby_hooks' => ruby_hooks,
66
+ 'rails_dsl' => rails_dsl
67
+ }
68
+ end
69
+
70
+ def render(format: :human)
71
+ case format.to_sym
72
+ when :json then JSON.pretty_generate(to_h)
73
+ when :yaml then YAML.dump(to_h)
74
+ when :human then render_human
75
+ else raise Error, "semantics supports human, json, or yaml output (got #{format})"
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def prism_nodes
82
+ prism_node_classes.map do |name|
83
+ status = prism_status(name)
84
+ { 'name' => name.to_s, 'status' => status, 'reason' => prism_reason(status) }
85
+ end
86
+ end
87
+
88
+ def prism_node_classes
89
+ Prism.constants.filter_map do |name|
90
+ value = Prism.const_get(name)
91
+ name if value.is_a?(Class) && value < Prism::Node
92
+ end.sort_by(&:to_s)
93
+ end
94
+
95
+ def prism_status(name)
96
+ return 'exact' if EXACT_PRISM_NODES.include?(name)
97
+ return 'partial' if PARTIAL_PRISM_NODES.include?(name)
98
+ return 'blocked' if BLOCKED_PRISM_NODES.include?(name)
99
+
100
+ 'unsupported'
101
+ end
102
+
103
+ def prism_reason(status)
104
+ case status
105
+ when 'exact' then 'scanner preserves the node structure without narrowing dispatch unsafely'
106
+ when 'partial' then 'calls are traversed conservatively but full Ruby runtime semantics are not claimed'
107
+ when 'blocked' then 'analysis health or a semantic blocker prevents an actionable conclusion'
108
+ else 'no reviewed scanner semantics are registered for this Prism node version'
109
+ end
110
+ end
111
+
112
+ def ruby_hooks
113
+ Confidence::Scorer::RUBY_HOOKS.sort.map do |name|
114
+ {
115
+ 'name' => name,
116
+ 'status' => RUBY_HOOK_STATUS.fetch(name, 'unsupported'),
117
+ 'reason' => 'recognized as an implicit VM callback; confidence is lowered without claiming a runtime root'
118
+ }
119
+ end
120
+ end
121
+
122
+ def rails_dsl
123
+ entries = {}
124
+ AstScanner::RAILS_CALLBACK_MACROS.each { |name| entries[name.to_s] = dsl_entry(name, 'callback') }
125
+ AstScanner::RAILS_GENERATED_METHOD_MACROS.each do |name|
126
+ entries[name.to_s] = dsl_entry(name, 'generated_method')
127
+ end
128
+ EntryPoints::Rails::ROUTE_DSL_CALLS.each { |name| entries[name.to_s] = dsl_entry(name, 'route') }
129
+ entries.keys.sort.map { |name| entries.fetch(name) }
130
+ end
131
+
132
+ def dsl_entry(name, family)
133
+ reasons = {
134
+ 'callback' => 'literal callback methods and conditions are rooted; dynamic registration is blocked',
135
+ 'generated_method' => 'literal APIs are grouped by macro source; version-dependent surfaces remain conservative',
136
+ 'route' => 'Prism verifies route DSL calls and literal targets; dynamic targets create scoped blockers'
137
+ }
138
+ { 'name' => name.to_s, 'family' => family, 'status' => 'partial', 'reason' => reasons.fetch(family) }
139
+ end
140
+
141
+ def render_human
142
+ data = to_h
143
+ counts = data.fetch('prism_nodes').tally { |entry| entry.fetch('status') }
144
+ [
145
+ "Semantics matrix #{SCHEMA_VERSION}",
146
+ "Ruby #{RUBY_VERSION}; Prism #{Prism::VERSION}",
147
+ "Prism nodes: #{STATUSES.map { |status| "#{status}=#{counts.fetch(status, 0)}" }.join(', ')}",
148
+ "Ruby hooks: #{data.fetch('ruby_hooks').length}",
149
+ "Rails DSL entries: #{data.fetch('rails_dsl').length}"
150
+ ].join("\n")
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'necropsy'
4
+
5
+ root = ENV.fetch('NECROPSY_TRACE_ROOT', nil)
6
+ output = ENV.fetch('NECROPSY_TRACE_OUTPUT', nil)
7
+ sample_rate = ENV.fetch('NECROPSY_TRACE_SAMPLE_RATE', '1.0').to_f
8
+ merge = ENV['NECROPSY_TRACE_MERGE'] == '1'
9
+ run_id = ENV.fetch('NECROPSY_TRACE_RUN_ID', nil)
10
+
11
+ if root && output
12
+ Necropsy::Analyzers::Dynamic::TracePointCollector.install_at_exit(
13
+ root: root,
14
+ output: output,
15
+ sample_rate: sample_rate,
16
+ merge: merge,
17
+ run_id: run_id
18
+ )
19
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ TYPE_FACT_TRUST_LEVELS = %i[authoritative hint conflicting].freeze
5
+
6
+ TypeFact = Data.define(:subject, :types, :trust, :source, :complete) do
7
+ def initialize(subject:, types:, source:, trust: :hint, complete: false)
8
+ subject = subject.to_s
9
+ raise ArgumentError, 'type fact subject must not be empty' if subject.empty?
10
+
11
+ types = Array(types).map(&:to_s).reject(&:empty?).uniq.sort.freeze
12
+ raise ArgumentError, 'type fact requires at least one type' if types.empty?
13
+
14
+ trust = trust.to_sym
15
+ raise ArgumentError, "invalid type fact trust: #{trust.inspect}" unless TYPE_FACT_TRUST_LEVELS.include?(trust)
16
+
17
+ source = source.to_s
18
+ raise ArgumentError, 'type fact source must not be empty' if source.empty?
19
+
20
+ super(subject: subject.freeze, types: types, trust: trust, source: source.freeze, complete: complete == true)
21
+ end
22
+
23
+ def safe_for_resolution?
24
+ trust == :authoritative && complete
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ 'subject' => subject,
30
+ 'types' => types,
31
+ 'trust' => trust.to_s,
32
+ 'source' => source,
33
+ 'complete' => complete
34
+ }
35
+ end
36
+ end
37
+
38
+ module TypeProvider
39
+ module_function
40
+
41
+ def profile
42
+ AnalyzerProfile.new(
43
+ name: 'none', kind: :type, soundness: :hint,
44
+ description: 'No external type provider is enabled; core analysis remains independent.', version: '1',
45
+ assumptions: ['type facts are optional', 'hints never refute a target']
46
+ )
47
+ end
48
+
49
+ def facts(_project)
50
+ []
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Necropsy
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end