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,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class ReferenceBarrier
5
+ MAX_FILE_BYTES = 1_048_576
6
+ MAX_STREAM_FILE_BYTES = 16_777_216
7
+ MAX_TOTAL_SCAN_BYTES = 67_108_864
8
+ MAX_TOTAL_MATCHES = 10_000
9
+ MAX_SCAN_SECONDS = 5.0
10
+ MAX_MATCHES_PER_DEFINITION = 5
11
+ MAX_SNIPPET_BYTES = 240
12
+ SKIPPED_SAMPLE_LIMIT = 50
13
+ BINARY_EXTENSIONS = %w[
14
+ .7z .a .bundle .class .db .dll .dylib .eot .exe .gif .gz .ico .jar .jpeg .jpg .o .pdf .png .so .sqlite
15
+ .tar .ttf .webp .woff .woff2 .zip
16
+ ].freeze
17
+ GENERATED_PATH_PARTS = %w[generated dist].freeze
18
+ TOOL_METADATA_BASENAMES = %w[.necropsy.yml .necropsy_baseline.yml].freeze
19
+ GENERATED_MARKER = /(?:@generated|automatically generated|generated file|do not edit)/i
20
+ UNSAFE_SKIP_REASONS = %i[generated oversized unreadable scan_budget match_budget time_budget].freeze
21
+ COMMON_SHORT_NAMES = %w[call create destroy edit index new run show update].freeze
22
+ REFERENCE_DSL_KEYS = %w[action callback command function handler method perform task].freeze
23
+ TOKEN_PATTERN = /[A-Za-z_][A-Za-z0-9_]*(?:[!?=](?![A-Za-z0-9_]))?/
24
+
25
+ def initialize(graph:, project:, ignored_paths: [], monotonic_clock: nil)
26
+ @graph = graph
27
+ @project = project
28
+ @ignored_paths = Array(ignored_paths).compact.to_set { |path| File.expand_path(path, project.root) }
29
+ @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
30
+ end
31
+
32
+ def apply(findings)
33
+ @candidates = findings.reject { |finding| finding.blockers.any? }.map(&:node).sort_by(&:graph_id)
34
+ @candidate_index = build_candidate_index
35
+ @non_token_index = build_non_token_index
36
+ @non_token_pattern = build_non_token_pattern
37
+ @matches = Hash.new { |hash, key| hash[key] = [] }
38
+ @skipped_counts = Hash.new(0)
39
+ @skipped_samples = []
40
+ @unsafe_runtime_skips = []
41
+ @files_scanned = 0
42
+ @files_streamed = 0
43
+ @bytes_scanned = 0
44
+ @total_matches = 0
45
+ @truncated_matches = 0
46
+ @scan_deadline = monotonic_time + MAX_SCAN_SECONDS
47
+ files = project.non_ruby_reference_files
48
+ unless candidates.empty?
49
+ files.each do |file|
50
+ if time_budget_exceeded?
51
+ record_time_budget(project.relative_path(file))
52
+ elsif @match_budget_exceeded
53
+ record_match_budget(project.relative_path(file))
54
+ else
55
+ scan_file(file)
56
+ end
57
+ end
58
+ end
59
+ add_blockers
60
+ unsafe_blockers = add_unsafe_skip_blocker
61
+ record_diagnostic(files)
62
+ matches.values.sum(&:length) + unsafe_blockers
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :graph, :project, :candidates, :candidate_index, :non_token_index, :non_token_pattern, :matches,
68
+ :skipped_counts,
69
+ :skipped_samples, :ignored_paths, :unsafe_runtime_skips
70
+
71
+ def build_candidate_index
72
+ Hash.new { |hash, key| hash[key] = [] }.tap do |index|
73
+ candidates.each do |node|
74
+ reference_tokens(node.name).each { |token| index[token] << node }
75
+ end
76
+ end
77
+ end
78
+
79
+ def build_non_token_index
80
+ candidates.select { |node| reference_tokens(node.name).empty? }.group_by(&:name)
81
+ end
82
+
83
+ def build_non_token_pattern
84
+ return unless non_token_index.any?
85
+
86
+ alternatives = Regexp.union(non_token_index.keys.sort_by { |name| -name.length }).source
87
+ /(?<![[:alnum:]_])(?:#{alternatives})(?![[:alnum:]_])/u
88
+ end
89
+
90
+ def reference_tokens(name)
91
+ return [] unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*[!?=]?\z/)
92
+
93
+ [name, lower_camel_case(name)].uniq
94
+ end
95
+
96
+ def lower_camel_case(name)
97
+ base = name.delete_suffix('!').delete_suffix('?').delete_suffix('=')
98
+ suffix = name.delete_prefix(base)
99
+ parts = base.split('_')
100
+ return name if parts.length == 1
101
+
102
+ "#{parts.first}#{parts.drop(1).map(&:capitalize).join}#{suffix}"
103
+ end
104
+
105
+ def scan_file(path)
106
+ relative = project.relative_path(path)
107
+ return skip(relative, :tool_metadata) if tool_metadata?(relative)
108
+ return skip(relative, :generated) if generated_path?(relative)
109
+ return skip(relative, :binary) if BINARY_EXTENSIONS.include?(File.extname(relative).downcase)
110
+
111
+ size = File.size(path)
112
+ return skip(relative, :oversized) if size > MAX_STREAM_FILE_BYTES
113
+ return skip(relative, :scan_budget) if @bytes_scanned + size > MAX_TOTAL_SCAN_BYTES
114
+ return scan_large_file(path, relative, size) if size > MAX_FILE_BYTES
115
+
116
+ bytes = File.binread(path, MAX_FILE_BYTES + 1)
117
+ return skip(relative, :oversized) if bytes.bytesize > MAX_FILE_BYTES
118
+ return skip(relative, :binary) if bytes.include?("\0")
119
+
120
+ source = bytes.force_encoding(Encoding::UTF_8)
121
+ return skip(relative, :binary) unless source.valid_encoding?
122
+ return skip(relative, :generated) if generated_header?(source)
123
+
124
+ @bytes_scanned += bytes.bytesize
125
+ @files_scanned += 1
126
+ original_lines = source.lines
127
+ searchable_source(source, relative).each_line.with_index(1) do |line, line_number|
128
+ break if time_budget_exceeded?
129
+
130
+ scan_line(relative, line, line_number, display_line: original_lines.fetch(line_number - 1, line))
131
+ break if @match_budget_exceeded
132
+ end
133
+ record_time_budget(relative) if @time_budget_exceeded
134
+ record_match_budget(relative) if @match_budget_exceeded
135
+ rescue SystemCallError => e
136
+ skip(relative || path.to_s, :unreadable, error: e.class.name)
137
+ end
138
+
139
+ def scan_large_file(path, relative, size)
140
+ header = []
141
+ valid = true
142
+ File.open(path, 'rb') do |io|
143
+ io.each_line.with_index(1) do |bytes, line_number|
144
+ source = bytes.force_encoding(Encoding::UTF_8)
145
+ valid &&= !bytes.include?("\0") && source.valid_encoding?
146
+ header << source.dup if line_number <= 20
147
+ end
148
+ end
149
+ return skip(relative, :binary) unless valid
150
+ return skip(relative, :generated) if generated_header?(header.join)
151
+
152
+ @bytes_scanned += size
153
+ @files_scanned += 1
154
+ @files_streamed += 1
155
+ File.open(path, 'r:UTF-8') do |io|
156
+ io.each_line.with_index(1) do |line, line_number|
157
+ break if time_budget_exceeded?
158
+
159
+ scan_line(relative, searchable_source(line, relative), line_number, display_line: line)
160
+ break if @match_budget_exceeded
161
+ end
162
+ end
163
+ record_time_budget(relative) if @time_budget_exceeded
164
+ record_match_budget(relative) if @match_budget_exceeded
165
+ end
166
+
167
+ def generated_path?(relative)
168
+ parts = relative.split(File::SEPARATOR)
169
+ parts.intersect?(GENERATED_PATH_PARTS) || File.basename(relative).include?('.min.')
170
+ end
171
+
172
+ def generated_header?(source)
173
+ source.each_line.first(20).take_while do |line|
174
+ stripped = line.strip
175
+ stripped.empty? || stripped.start_with?('#', '//', '--', ';', '<!--', '<%#', '/*', '*')
176
+ end.any? { |line| line.match?(GENERATED_MARKER) }
177
+ end
178
+
179
+ def tool_metadata?(relative)
180
+ return true if TOOL_METADATA_BASENAMES.include?(File.basename(relative))
181
+
182
+ expanded = File.expand_path(relative, project.root)
183
+ configured = [
184
+ project.config.path,
185
+ File.expand_path(project.config.baseline_path, project.root)
186
+ ].compact.map { |path| File.expand_path(path) }
187
+ configured.include?(expanded) || ignored_paths.include?(expanded)
188
+ end
189
+
190
+ def searchable_source(source, file)
191
+ without_block_comments = source.gsub(%r{<%#.*?%>|<!--.*?-->|/\*.*?\*/}m) do |comment|
192
+ comment.gsub(/[^\n]/, ' ')
193
+ end
194
+ return without_block_comments unless File.extname(file).downcase == '.erb'
195
+
196
+ EmbeddedRuby.extract(without_block_comments)
197
+ end
198
+
199
+ def scan_line(file, line, line_number, display_line: line)
200
+ stripped = line.lstrip
201
+ return if inline_comment_markers(file).any? { |marker| stripped.start_with?(marker) }
202
+
203
+ searchable_line = strip_inline_comment(line, file)
204
+ searchable_line.scan(TOKEN_PATTERN).uniq.each do |token|
205
+ candidate_index.fetch(token, []).each do |node|
206
+ kind = reference_kind(searchable_line, token, node, file)
207
+ record_match(node, file, line_number, display_line, token, kind) if kind
208
+ end
209
+ end
210
+ return unless non_token_pattern
211
+
212
+ searchable_line.scan(non_token_pattern).uniq.each do |name|
213
+ non_token_index.fetch(name).each do |node|
214
+ kind = non_token_reference_kind(searchable_line, node)
215
+ record_match(node, file, line_number, display_line, node.name, kind) if kind
216
+ end
217
+ end
218
+ end
219
+
220
+ def strip_inline_comment(line, file)
221
+ strip_comment(line, inline_comment_markers(file))
222
+ end
223
+
224
+ def strip_comment(line, markers)
225
+ quote = nil
226
+ escaped = false
227
+ line.each_char.with_index do |character, index|
228
+ if quote
229
+ if escaped
230
+ escaped = false
231
+ elsif character == '\\'
232
+ escaped = true
233
+ elsif character == quote
234
+ quote = nil
235
+ end
236
+ next
237
+ end
238
+
239
+ if ['"', "'"].include?(character)
240
+ quote = character
241
+ next
242
+ end
243
+ next unless index.zero? || line[index - 1].match?(/\s/)
244
+
245
+ marker = markers.find { |candidate| line[index, candidate.length] == candidate }
246
+ if marker
247
+ prefix = line[0...index]
248
+ return line.end_with?("\n") ? "#{prefix}\n" : prefix
249
+ end
250
+ end
251
+ line
252
+ end
253
+
254
+ def inline_comment_markers(file)
255
+ extension = File.extname(file).downcase
256
+ markers = []
257
+ markers << '#' if %w[.bash .conf .env .gql .graphql .ini .properties .sh .toml .yaml .yml .zsh].include?(extension)
258
+ markers << '//' if %w[.c .cc .cpp .css .gql .graphql .h .hpp .js .jsx .scss .ts .tsx].include?(extension)
259
+ markers << '--' if extension == '.sql'
260
+ markers << ';' if extension == '.ini'
261
+ markers
262
+ end
263
+
264
+ def reference_kind(line, token, node, file)
265
+ qualified_kind, remaining, qualified = qualified_reference(line, token, node)
266
+ return qualified_kind if qualified_kind
267
+ return if qualified && !direct_name_reference?(remaining, token)
268
+ return 'symbol_or_string' if symbolic_or_string_reference?(remaining, token)
269
+ return 'structured_reference' if common_name?(node.name) && structured_reference?(remaining, token, file)
270
+ return if common_name?(node.name)
271
+
272
+ token == node.name ? 'method_name' : 'method_name_camelized'
273
+ end
274
+
275
+ def common_name?(name)
276
+ COMMON_SHORT_NAMES.include?(name)
277
+ end
278
+
279
+ def structured_reference?(line, token, file)
280
+ return true if File.extname(file).downcase == '.erb' && line.include?('<%')
281
+
282
+ keys = REFERENCE_DSL_KEYS.join('|')
283
+ line.match?(/(?:\A|[,{\s])(?:#{keys})\s*[:=]\s*#{Regexp.escape(token)}(?![A-Za-z0-9_])/i)
284
+ end
285
+
286
+ def qualified_reference(line, token, node)
287
+ pattern = qualified_pattern(token)
288
+ owners = line.scan(pattern).flatten
289
+ return [nil, line, false] if owners.empty?
290
+
291
+ expected = [node.owner, node.owner.to_s.split('::').last].compact.reject(&:empty?).uniq
292
+ return ['qualified_owner', line, true] if owners.intersect?(expected)
293
+
294
+ [nil, line.gsub(pattern, ' '), true]
295
+ end
296
+
297
+ def qualified_pattern(token)
298
+ /(?<![A-Za-z0-9_:])([A-Z][A-Za-z0-9_:]*)(?:#|\.)#{Regexp.escape(token)}(?![A-Za-z0-9_])/
299
+ end
300
+
301
+ def symbolic_or_string_reference?(line, token)
302
+ escaped = Regexp.escape(token)
303
+ line.match?(/(?:(?<![A-Za-z0-9_:]):#{escaped}(?![A-Za-z0-9_])|["']#{escaped}["'])/)
304
+ end
305
+
306
+ def non_token_reference_kind(line, node)
307
+ qualified_kind, remaining, qualified = qualified_reference(line, node.name, node)
308
+ return qualified_kind if qualified_kind
309
+ return if qualified && !direct_name_reference?(remaining, node.name)
310
+ return 'symbol_or_string' if symbolic_or_string_reference?(remaining, node.name)
311
+ return 'method_name' if direct_name_reference?(remaining, node.name)
312
+
313
+ nil
314
+ end
315
+
316
+ def direct_name_reference?(line, name)
317
+ line.match?(/(?<![[:alnum:]_])#{Regexp.escape(name)}(?![[:alnum:]_])/u)
318
+ end
319
+
320
+ def record_match(node, file, line_number, line, token, kind)
321
+ if @total_matches >= MAX_TOTAL_MATCHES
322
+ @match_budget_exceeded = true
323
+ return
324
+ end
325
+
326
+ node_matches = matches[node.graph_id]
327
+ if node_matches.length >= MAX_MATCHES_PER_DEFINITION
328
+ @truncated_matches += 1
329
+ return
330
+ end
331
+
332
+ node_matches << {
333
+ 'file' => file,
334
+ 'line' => line_number,
335
+ 'snippet' => bounded_snippet(line),
336
+ 'match_kind' => kind,
337
+ 'matched_text' => token
338
+ }
339
+ @total_matches += 1
340
+ end
341
+
342
+ def bounded_snippet(line)
343
+ snippet = line.strip.gsub(/\s+/, ' ')
344
+ return snippet if snippet.bytesize <= MAX_SNIPPET_BYTES
345
+
346
+ "#{snippet.byteslice(0, MAX_SNIPPET_BYTES).to_s.scrub}\u2026"
347
+ end
348
+
349
+ def skip(file, reason, error: nil)
350
+ skipped_counts[reason.to_s] += 1
351
+ sample = { 'file' => file, 'reason' => reason.to_s }
352
+ sample['error'] = error if error
353
+ unsafe_runtime_skips << sample if unsafe_skip?(file, reason)
354
+ return if skipped_samples.length >= SKIPPED_SAMPLE_LIMIT
355
+
356
+ skipped_samples << sample
357
+ end
358
+
359
+ def record_match_budget(file)
360
+ return if @match_budget_recorded
361
+
362
+ @match_budget_recorded = true
363
+ skip(file, :match_budget)
364
+ end
365
+
366
+ def record_time_budget(file)
367
+ return if @time_budget_recorded
368
+
369
+ @time_budget_recorded = true
370
+ skip(file, :time_budget)
371
+ end
372
+
373
+ def time_budget_exceeded?
374
+ return true if @time_budget_exceeded
375
+
376
+ @time_budget_exceeded = monotonic_time > @scan_deadline
377
+ end
378
+
379
+ def monotonic_time
380
+ Float(@monotonic_clock.call)
381
+ end
382
+
383
+ def unsafe_skip?(file, reason)
384
+ UNSAFE_SKIP_REASONS.include?(reason.to_sym) && reference_domain(file) == 'runtime'
385
+ end
386
+
387
+ def add_unsafe_skip_blocker
388
+ return 0 if unsafe_runtime_skips.empty?
389
+
390
+ graph.add_blocker(Blocker.new(
391
+ kind: :reference_scan_incomplete,
392
+ scope_kind: :global,
393
+ scope_value: '*',
394
+ source: :non_ruby_reference_barrier,
395
+ reason: "#{unsafe_runtime_skips.length} runtime reference files could not be searched safely",
396
+ suggested_action: :review_reference_scope,
397
+ metadata: {
398
+ 'caller_domain' => 'runtime',
399
+ 'skipped_file_count' => unsafe_runtime_skips.length,
400
+ 'skipped_counts' => unsafe_skip_counts,
401
+ 'files' => unsafe_runtime_skips.first(SKIPPED_SAMPLE_LIMIT)
402
+ }
403
+ ))
404
+ 1
405
+ end
406
+
407
+ def unsafe_skip_counts
408
+ unsafe_runtime_skips.group_by { |sample| sample.fetch('reason') }.transform_values(&:length)
409
+ end
410
+
411
+ def add_blockers
412
+ candidates.each do |node|
413
+ matches.fetch(node.graph_id, []).each do |match|
414
+ graph.add_blocker(reference_blocker(node, match))
415
+ end
416
+ end
417
+ end
418
+
419
+ def reference_blocker(node, match)
420
+ Blocker.new(
421
+ kind: :unparsed_external_reference,
422
+ scope_kind: :definition,
423
+ scope_value: node.graph_id,
424
+ source: :non_ruby_reference_barrier,
425
+ reason: "Unparsed non-Ruby text may reference #{node.symbol_id}",
426
+ suggested_action: :inspect_external_reference,
427
+ metadata: match.merge(
428
+ 'caller_domain' => reference_domain(match.fetch('file')),
429
+ 'message' => node.name,
430
+ 'symbol_id' => node.symbol_id,
431
+ 'definition_id' => node.graph_id
432
+ )
433
+ )
434
+ end
435
+
436
+ def reference_domain(relative)
437
+ project.test_file?(File.join(project.root, relative)) ? 'test' : 'runtime'
438
+ end
439
+
440
+ def record_diagnostic(files)
441
+ graph.observation['non_ruby_reference_barrier'] = {
442
+ 'scanner' => 'portable_ruby',
443
+ 'candidate_definitions' => candidates.length,
444
+ 'files_considered' => files.length,
445
+ 'files_scanned' => @files_scanned,
446
+ 'files_streamed' => @files_streamed,
447
+ 'bytes_scanned' => @bytes_scanned,
448
+ 'time_budget_seconds' => MAX_SCAN_SECONDS,
449
+ 'time_budget_exceeded' => @time_budget_exceeded == true,
450
+ 'matched_definitions' => matches.count { |_definition_id, entries| entries.any? },
451
+ 'matches' => matches.values.sum(&:length),
452
+ 'truncated_matches' => @truncated_matches,
453
+ 'skipped_counts' => skipped_counts.sort.to_h,
454
+ 'skipped_samples' => skipped_samples.sort_by { |sample| [sample.fetch('file'), sample.fetch('reason')] }
455
+ }
456
+ end
457
+ end
458
+ end
@@ -2,16 +2,37 @@
2
2
 
3
3
  require 'json'
4
4
  require 'yaml'
5
+ require 'digest'
5
6
 
6
7
  module Necropsy
7
8
  class Report
8
- attr_reader :root, :graph, :findings, :reachability
9
+ SCHEMA_VERSION = 2
10
+ SCHEMA_PATH = File.expand_path("../../schema/necropsy-report-v#{SCHEMA_VERSION}.schema.json", __dir__).freeze
11
+ ACTIONABLE_CLASSIFICATIONS = %i[unreachable unused].freeze
12
+ FINGERPRINT_COMPATIBILITY = {
13
+ 'fingerprint' => 'legacy logical symbol fingerprint retained for compatibility',
14
+ 'physical_fingerprint' => 'physical definition fingerprint for baselines and definition-level matching'
15
+ }.freeze
9
16
 
10
- def initialize(root:, graph:, findings:, reachability: nil, report_include_paths: [], report_exclude_paths: [])
17
+ attr_reader :root, :graph, :findings, :reachability, :project, :source_snapshot, :performance_profile,
18
+ :analysis_health
19
+
20
+ def self.schema_path
21
+ SCHEMA_PATH
22
+ end
23
+
24
+ def initialize(root:, graph:, findings:, reachability: nil, report_include_paths: [], report_exclude_paths: [],
25
+ project: nil, source_snapshot: nil, performance_profile: nil, analysis_health: nil)
11
26
  @root = root
12
27
  @graph = graph
13
- @findings = findings.sort_by { |finding| [finding.node.file, finding.node.line, finding.node.id] }
28
+ @findings = findings.sort_by do |finding|
29
+ [finding.node.file, finding.node.line, finding.node.id, finding.node.definition_id]
30
+ end
14
31
  @reachability = reachability
32
+ @project = project
33
+ @source_snapshot = source_snapshot
34
+ @performance_profile = performance_profile
35
+ @analysis_health = analysis_health || AnalysisHealth.complete
15
36
  @report_include_paths = report_include_paths
16
37
  @report_exclude_paths = report_exclude_paths
17
38
  end
@@ -20,12 +41,49 @@ module Necropsy
20
41
  reported_findings.select { |finding| finding.at_least?(min_confidence) }
21
42
  end
22
43
 
44
+ # Unlike the legacy dead_methods API, this excludes findings that exist to
45
+ # explain uncertainty or test-only reachability. Benchmarks and precision
46
+ # gates must measure only definitions that a user can actually review as a
47
+ # removal candidate.
48
+ def actionable_candidates(min_confidence: :low)
49
+ reported_findings.select do |finding|
50
+ ACTIONABLE_CLASSIFICATIONS.include?(finding.classification) && finding.at_least?(min_confidence)
51
+ end
52
+ end
53
+
54
+ def diagnostic_findings
55
+ reported_findings.reject { |finding| ACTIONABLE_CLASSIFICATIONS.include?(finding.classification) }
56
+ end
57
+
58
+ def reportable_findings
59
+ reported_findings.dup
60
+ end
61
+
62
+ def finding_for_definition(definition_id)
63
+ @findings_by_definition ||= findings.to_h { |finding| [finding.node.graph_id, finding] }.freeze
64
+ @findings_by_definition[definition_id.to_s]
65
+ end
66
+
67
+ def report_path?(path)
68
+ included_in_report?(path) && !excluded_from_report?(path)
69
+ end
70
+
71
+ def blocked_methods
72
+ reported_findings.select { |finding| finding.classification == :blocked }
73
+ end
74
+
23
75
  def to_h(include_graph: false)
24
76
  payload = {
77
+ 'schema_version' => SCHEMA_VERSION,
78
+ 'artifact_provenance' => artifact_provenance,
79
+ 'compatibility' => { 'finding_fingerprints' => FINGERPRINT_COMPATIBILITY },
25
80
  'root' => root,
81
+ 'analysis_health' => analysis_health.to_h,
26
82
  'summary' => summary,
27
83
  'findings' => reported_findings.map(&:to_h)
28
84
  }
85
+ payload['diagnostics'] = diagnostics unless diagnostics.empty?
86
+ payload['source_snapshot'] = source_snapshot if source_snapshot
29
87
  payload['graph'] = graph.to_h if include_graph
30
88
  payload
31
89
  end
@@ -43,24 +101,75 @@ module Necropsy
43
101
 
44
102
  def summary
45
103
  grouped = reported_findings.group_by(&:classification)
104
+ actionable = reported_findings.count { |finding| ACTIONABLE_CLASSIFICATIONS.include?(finding.classification) }
105
+ blocked = grouped.fetch(:blocked, []).length
46
106
  {
47
107
  'nodes' => graph.nodes.length,
48
108
  'edges' => graph.edges.length,
49
109
  'entry_points' => graph.entry_points.length,
110
+ 'incomplete_files' => graph.incomplete_files.length,
50
111
  'findings' => reported_findings.length,
112
+ 'actionable' => actionable,
113
+ 'diagnostic' => reported_findings.length - actionable - blocked,
114
+ 'health_failures' => analysis_health.reasons.length,
51
115
  'unreachable' => grouped.fetch(:unreachable, []).length,
52
116
  'unused' => grouped.fetch(:unused, []).length,
117
+ 'blocked' => blocked,
53
118
  'test_only_reachable' => grouped.fetch(:test_only_reachable, []).length
54
119
  }
55
120
  end
56
121
 
122
+ def diagnostics
123
+ result = {}
124
+ dynamic = graph.dynamic_evidence_diagnostic
125
+ result['dynamic_evidence'] = dynamic if dynamic
126
+ definition_resolution = graph.observation['definition_resolution']
127
+ result['definition_resolution'] = definition_resolution if definition_resolution
128
+ reference_barrier = graph.observation['non_ruby_reference_barrier']
129
+ result['non_ruby_reference_barrier'] = reference_barrier if reference_barrier
130
+ result['source_incompleteness'] = graph.source_incompleteness if graph.incomplete_files.any?
131
+ result['analysis_scope'] = graph.scope_diagnostics unless graph.scope_diagnostics.empty?
132
+ unrooted = graph.observation['unrooted_load_units']
133
+ result['unrooted_load_units'] = unrooted if unrooted && unrooted['count'].positive?
134
+ generated_macros = graph.observation['rails_generated_macros']
135
+ result['rails_generated_macros'] = generated_macros if generated_macros
136
+ result['performance'] = performance_profile if performance_profile
137
+ result
138
+ end
139
+
57
140
  private
58
141
 
59
142
  attr_reader :report_include_paths, :report_exclude_paths
60
143
 
144
+ def artifact_provenance
145
+ {
146
+ 'producer' => { 'name' => 'necropsy', 'version' => Necropsy::VERSION },
147
+ 'runtime' => {
148
+ 'ruby_engine' => RUBY_ENGINE,
149
+ 'ruby_version' => RUBY_VERSION,
150
+ 'prism_version' => Prism::VERSION
151
+ },
152
+ 'identity_schemas' => {
153
+ 'definition' => DefinitionIdentity::VERSION,
154
+ 'call_site' => CallSiteIdentity::VERSION
155
+ },
156
+ 'inputs' => {
157
+ 'configuration_sha256' => configuration_digest
158
+ }
159
+ }
160
+ end
161
+
162
+ def configuration_digest
163
+ return 'unavailable' unless project
164
+
165
+ Digest::SHA256.hexdigest(BoundedCanonicalizer.dump(project.config.scan_cache_key))
166
+ rescue BoundedCanonicalizer::Error, SystemStackError
167
+ 'unavailable'
168
+ end
169
+
61
170
  def reported_findings
62
171
  @reported_findings ||= findings.select do |finding|
63
- included_in_report?(finding.node.file) && !excluded_from_report?(finding.node.file)
172
+ report_path?(finding.node.file)
64
173
  end
65
174
  end
66
175