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
@@ -1,48 +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, ambiguity_limit: config.ambiguity_limit)
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
16
35
  rta_results = []
17
36
 
18
- apply_entry_points(graph, project)
19
- configured_analyzers.each do |analyzer|
20
- graph.add_profile(analyzer.profile)
21
- result = analyzer.analyze(graph, project)
22
- graph.apply_result(result)
23
- rta_results << result if analyzer.profile.name == :rta
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
24
48
  end
25
- rta_results.each { |result| graph.reconcile_rta_result(result) }
26
49
 
27
- reachability = Reachability::Engine.new(graph).call
28
- findings = Confidence::Scorer.new(graph: graph, reachability: reachability, project: project).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
+ )
29
75
  Report.new(
30
76
  root: root,
31
77
  graph: graph,
32
78
  findings: findings,
33
79
  reachability: reachability,
80
+ project: project,
81
+ source_snapshot: source_snapshot,
82
+ analysis_health: analysis_health,
34
83
  report_include_paths: config.report_include_paths,
35
- report_exclude_paths: config.report_exclude_paths
84
+ report_exclude_paths: config.report_exclude_paths,
85
+ performance_profile: performance
36
86
  )
37
87
  end
38
88
 
39
89
  private
40
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
+
41
250
  def apply_entry_points(graph, project)
42
251
  EntryPoints::Plain.new.apply(graph, project)
43
252
  EntryPoints::Rails.new.apply(graph, project)
44
253
  EntryPoints::Test.new.apply(graph, project)
45
- 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
46
263
  end
47
264
 
48
265
  def configured_analyzers
@@ -85,8 +302,6 @@ module Necropsy
85
302
  end
86
303
 
87
304
  def custom_analyzer_definition(entry)
88
- return [entry, nil] unless entry.is_a?(Hash)
89
-
90
305
  [entry.fetch('class'), entry['require']]
91
306
  end
92
307
 
@@ -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,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.2.0'
4
+ VERSION = '0.3.0'
5
5
  end