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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- 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
|
data/lib/necropsy/report.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
172
|
+
report_path?(finding.node.file)
|
|
64
173
|
end
|
|
65
174
|
end
|
|
66
175
|
|