necropsy 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
@@ -2,45 +2,391 @@
2
2
 
3
3
  require 'yaml'
4
4
  require 'time'
5
+ require 'tempfile'
5
6
 
6
7
  module Necropsy
7
8
  module Guardrail
8
9
  class Baseline
9
- attr_reader :path, :fingerprints
10
+ SCHEMA_VERSION = 2
11
+ MIGRATION_SCHEMA_VERSION = 1
12
+ CLASSIFICATIONS = %w[unreachable unused blocked test_only_reachable].freeze
13
+
14
+ Comparison = Data.define(:matched_findings, :new_findings, :ambiguities, :review_report) do
15
+ def review_required?
16
+ ambiguities.any?
17
+ end
18
+ end
19
+
20
+ attr_reader :path, :fingerprints, :schema_version
10
21
 
11
22
  def self.load(path)
12
- return new(path: path, fingerprints: []) unless File.exist?(path)
23
+ return new(path: path, findings: []) unless File.exist?(path)
13
24
 
14
- payload = YAML.load_file(path) || {}
15
- new(path: path, fingerprints: Array(payload['findings']).map { |finding| finding['fingerprint'] })
25
+ payload = YAML.safe_load_file(path, aliases: false)
26
+ payload = {} if payload.nil?
27
+ raise Error, 'Baseline must contain a YAML mapping' unless payload.is_a?(Hash)
28
+
29
+ schema_version = schema_version_from(payload)
30
+ findings = payload.fetch('findings', [])
31
+ raise Error, 'Baseline findings must be an array' unless findings.is_a?(Array)
32
+
33
+ new(path: path, findings: findings, schema_version: schema_version)
16
34
  end
17
35
 
18
- def self.write(report, path:)
19
- findings = report.findings.map do |finding|
36
+ def self.schema_version_from(payload)
37
+ explicit = normalize_schema_version(payload['schema_version']) if payload.key?('schema_version')
38
+ legacy = normalize_schema_version(payload['version']) if payload.key?('version')
39
+ raise Error, "Conflicting baseline schema versions: #{explicit} and #{legacy}" if explicit && legacy && explicit != legacy
40
+
41
+ explicit || legacy || 1
42
+ end
43
+ private_class_method :schema_version_from
44
+
45
+ def self.normalize_schema_version(value)
46
+ normalized = if value.is_a?(Integer)
47
+ value
48
+ elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
49
+ value.to_i
50
+ end
51
+ raise Error, "Invalid baseline schema version: #{value.inspect}" unless normalized
52
+
53
+ normalized
54
+ end
55
+ private_class_method :normalize_schema_version
56
+
57
+ def self.write(report, path:, clock: Clock.new)
58
+ findings = report.actionable_candidates(min_confidence: :low).map do |finding|
20
59
  {
21
- 'fingerprint' => finding.fingerprint,
60
+ 'fingerprint' => finding.physical_fingerprint,
61
+ 'logical_fingerprint' => finding.logical_fingerprint,
22
62
  'classification' => finding.classification.to_s,
23
63
  'confidence' => finding.confidence.to_s,
24
64
  'node_id' => finding.node.id,
65
+ 'symbol_id' => finding.node.symbol_id,
66
+ 'definition_id' => finding.node.definition_id,
67
+ 'body_digest' => finding.node.body_digest,
25
68
  'file' => finding.node.file,
26
69
  'line' => finding.node.line
27
70
  }
28
71
  end
29
72
  payload = {
30
- 'version' => 1,
31
- 'generated_at' => Time.now.utc.iso8601,
73
+ 'schema_version' => SCHEMA_VERSION,
74
+ 'version' => SCHEMA_VERSION,
75
+ 'identity' => 'physical_definition',
76
+ 'generated_at' => clock.time.iso8601,
32
77
  'findings' => findings
33
78
  }
34
- File.write(path, payload.to_yaml)
79
+ atomic_write(path, payload.to_yaml)
80
+ end
81
+
82
+ def self.atomic_write(path, contents)
83
+ directory = File.dirname(path)
84
+ Tempfile.create([".#{File.basename(path)}", '.tmp'], directory) do |file|
85
+ file.write(contents)
86
+ file.flush
87
+ file.fsync
88
+ file.close
89
+ File.rename(file.path, path)
90
+ end
91
+ File.open(directory, 'rb', &:fsync)
92
+ rescue Errno::EINVAL, Errno::EISDIR
93
+ nil
35
94
  end
95
+ private_class_method :atomic_write
36
96
 
37
- def initialize(path:, fingerprints:)
97
+ def initialize(path:, findings:, schema_version: 1)
38
98
  @path = path
39
- @fingerprints = fingerprints
99
+ @schema_version = normalize_schema_version(schema_version)
100
+ raise Error, "Unsupported baseline schema version: #{@schema_version}" unless [1, SCHEMA_VERSION].include?(@schema_version)
101
+
102
+ @findings = findings.map do |finding|
103
+ raise Error, 'Baseline findings must be mappings' unless finding.is_a?(Hash)
104
+
105
+ finding.transform_keys(&:to_s).tap { |entry| validate_entry!(entry) }
106
+ end
107
+ validate_duplicate_identities!
108
+ @fingerprints = @findings.filter_map { |finding| finding['fingerprint'] }.to_set
40
109
  end
41
110
 
42
111
  def include?(finding)
43
- fingerprints.include?(finding.fingerprint)
112
+ fingerprint = schema_version == SCHEMA_VERSION ? finding.physical_fingerprint : finding.logical_fingerprint
113
+ fingerprints.include?(fingerprint)
114
+ end
115
+
116
+ def compare(findings, migration: false)
117
+ current = findings.sort_by { |finding| [finding.node.file, finding.node.line, finding.node.definition_id] }
118
+ current_index = build_current_index(current)
119
+ assignments = {}
120
+ assignment_indexes = {}
121
+ ambiguities = []
122
+
123
+ @findings.each_with_index do |entry, index|
124
+ resolution = resolve_entry(entry, current_index, migration: migration)
125
+ if resolution[:review_required]
126
+ ambiguities << ambiguity(entry, index, resolution, assignment_indexes)
127
+ next
128
+ end
129
+ next if resolution[:candidates].empty?
130
+
131
+ candidate = resolution[:candidates].first
132
+ candidate_key = candidate&.physical_fingerprint
133
+ if resolution[:candidates].one? && !assignment_indexes.key?(candidate_key)
134
+ assignments[index] = candidate
135
+ assignment_indexes[candidate_key] = index
136
+ else
137
+ ambiguities << ambiguity(entry, index, resolution, assignment_indexes)
138
+ end
139
+ end
140
+
141
+ matched = assignments.values.uniq
142
+ Comparison.new(
143
+ matched_findings: matched,
144
+ new_findings: current - matched,
145
+ ambiguities: ambiguities,
146
+ review_report: migration_review_report(ambiguities)
147
+ )
148
+ end
149
+
150
+ def migrate(findings)
151
+ compare(findings, migration: true)
152
+ end
153
+
154
+ def count_at_least(confidence)
155
+ threshold = CONFIDENCE_LEVELS.fetch(confidence)
156
+ @findings.count do |finding|
157
+ level = finding['confidence']&.to_sym
158
+ level && CONFIDENCE_LEVELS.fetch(level, -1) >= threshold
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def normalize_schema_version(value)
165
+ self.class.send(:normalize_schema_version, value)
166
+ end
167
+
168
+ def resolve_entry(entry, current_index, migration:)
169
+ return resolve_exact_entry(entry, current_index) unless migration
170
+ return resolve_v1_entry(entry, current_index) if schema_version == 1
171
+
172
+ strategies(entry).each do |strategy, value|
173
+ next if value.nil? || value.to_s.empty?
174
+
175
+ matches = indexed_matches(current_index, strategy, value, entry)
176
+ return { strategy: strategy, candidates: matches } unless matches.empty?
177
+ end
178
+ { strategy: 'unmatched', candidates: [] }
179
+ end
180
+
181
+ def resolve_exact_entry(entry, current_index)
182
+ if schema_version == 1
183
+ resolution = resolve_v1_entry(entry, current_index)
184
+ return resolution.merge(review_required: true, reason: 'legacy_baseline_requires_migration')
185
+ end
186
+
187
+ value = entry['definition_id'] || entry['fingerprint']
188
+ candidates = indexed_matches(current_index, 'exact', value, entry)
189
+ { strategy: 'exact', candidates: candidates }
190
+ end
191
+
192
+ def resolve_v1_entry(entry, current_index)
193
+ identity_matches = if present?(symbol_hint(entry))
194
+ indexed_symbol_path(current_index, entry)
195
+ else
196
+ []
197
+ end
198
+ return { strategy: 'logical_identity', candidates: identity_matches } if identity_matches.length > 1
199
+
200
+ if present?(entry['fingerprint'])
201
+ fingerprint_matches = current_index.fetch(:logical_fingerprint).fetch(entry['fingerprint'], [])
202
+ return { strategy: 'logical_fingerprint', candidates: fingerprint_matches } unless fingerprint_matches.empty?
203
+
204
+ return { strategy: 'unmatched', candidates: [] }
205
+ end
206
+
207
+ matches = identity_matches.select { |finding| same_classification?(entry, finding) }
208
+ matches.empty? ? { strategy: 'unmatched', candidates: [] } : { strategy: 'symbol_path_hint', candidates: matches }
209
+ end
210
+
211
+ def build_current_index(current)
212
+ {
213
+ definition_id: current.group_by { |finding| finding.node.definition_id },
214
+ physical_fingerprint: current.group_by(&:physical_fingerprint),
215
+ logical_fingerprint: current.group_by(&:logical_fingerprint),
216
+ body_digest: current.group_by { |finding| finding.node.body_digest },
217
+ symbol: current.group_by { |finding| finding.node.symbol_id },
218
+ symbol_path: current.group_by { |finding| symbol_path_key_for(finding) }
219
+ }
220
+ end
221
+
222
+ def indexed_matches(current_index, strategy, value, entry)
223
+ candidates = case strategy
224
+ when 'exact'
225
+ by_definition = current_index.fetch(:definition_id).fetch(value, [])
226
+ by_fingerprint = current_index.fetch(:physical_fingerprint).fetch(value, [])
227
+ by_definition + by_fingerprint
228
+ when 'body_digest'
229
+ current_index.fetch(:body_digest).fetch(value, []).select do |finding|
230
+ symbol_path_match?(entry, finding)
231
+ end
232
+ when 'symbol_path_hint'
233
+ indexed_symbol_path(current_index, entry)
234
+ else
235
+ []
236
+ end
237
+ candidates.uniq.select { |finding| same_classification?(entry, finding) }
238
+ end
239
+
240
+ def strategies(entry)
241
+ exact = entry['definition_id']
242
+ exact ||= entry['fingerprint'] if schema_version == SCHEMA_VERSION
243
+ [
244
+ ['exact', exact],
245
+ ['body_digest', entry['body_digest']],
246
+ ['symbol_path_hint', symbol_hint(entry)]
247
+ ]
248
+ end
249
+
250
+ def symbol_path_match?(entry, finding)
251
+ symbol = symbol_hint(entry)
252
+ return false unless symbol == finding.node.symbol_id
253
+
254
+ path = entry['file']
255
+ path.nil? || path.empty? || path == finding.node.file
256
+ end
257
+
258
+ def symbol_hint(entry)
259
+ entry['symbol_id'] || entry['node_id']
260
+ end
261
+
262
+ def symbol_path_key(entry)
263
+ [symbol_hint(entry), present?(entry['file']) ? entry['file'] : nil]
264
+ end
265
+
266
+ def indexed_symbol_path(current_index, entry)
267
+ return current_index.fetch(:symbol).fetch(symbol_hint(entry), []) unless present?(entry['file'])
268
+
269
+ current_index.fetch(:symbol_path).fetch(symbol_path_key(entry), [])
270
+ end
271
+
272
+ def symbol_path_key_for(finding)
273
+ [finding.node.symbol_id, finding.node.file]
274
+ end
275
+
276
+ def validate_entry!(entry)
277
+ string_fields = %w[
278
+ fingerprint logical_fingerprint classification confidence node_id symbol_id definition_id body_digest file
279
+ ]
280
+ string_fields.each do |field|
281
+ value = entry[field]
282
+ raise Error, "Baseline #{field} must be a string" unless value.nil? || value.is_a?(String)
283
+ end
284
+ line = entry['line']
285
+ raise Error, 'Baseline line must be a positive integer' unless line.nil? || (line.is_a?(Integer) && line.positive?)
286
+
287
+ classification = entry['classification']
288
+ raise Error, "Unknown baseline classification: #{classification}" if
289
+ classification && !CLASSIFICATIONS.include?(classification)
290
+
291
+ confidence = entry['confidence']
292
+ raise Error, "Unknown baseline confidence: #{confidence}" if
293
+ confidence && !CONFIDENCE_LEVELS.key?(confidence.to_sym)
294
+
295
+ validate_v1_fingerprint!(entry) if schema_version == 1
296
+ validate_v2_fingerprints!(entry) if schema_version == SCHEMA_VERSION
297
+ end
298
+
299
+ def validate_duplicate_identities!
300
+ identities = @findings.filter_map do |entry|
301
+ if schema_version == SCHEMA_VERSION && present?(entry['definition_id']) && present?(entry['classification'])
302
+ physical_fingerprint(entry['classification'], entry['definition_id'])
303
+ elsif present?(entry['fingerprint'])
304
+ entry['fingerprint']
305
+ end
306
+ end
307
+ duplicate = identities.tally.find { |_identity, count| count > 1 }&.first
308
+ raise Error, "Duplicate baseline physical identity: #{duplicate}" if duplicate
309
+ end
310
+
311
+ def validate_v1_fingerprint!(entry)
312
+ return unless present?(entry['fingerprint']) && present?(entry['classification']) && present?(symbol_hint(entry))
313
+
314
+ expected = logical_fingerprint(entry.fetch('classification'), symbol_hint(entry))
315
+ raise Error, 'Baseline v1 fingerprint contradicts its classification or symbol' unless entry['fingerprint'] == expected
316
+ end
317
+
318
+ def validate_v2_fingerprints!(entry)
319
+ raise Error, 'Baseline v2 entries require classification' unless present?(entry['classification'])
320
+
321
+ identity_fields = [entry['fingerprint'], entry['definition_id'], entry['body_digest'], symbol_hint(entry)]
322
+ raise Error, 'Baseline v2 entries require a physical identity or migration hint' unless identity_fields.any? { present?(_1) }
323
+
324
+ if present?(entry['fingerprint']) && present?(entry['definition_id'])
325
+ expected = physical_fingerprint(entry.fetch('classification'), entry.fetch('definition_id'))
326
+ raise Error, 'Baseline v2 fingerprint contradicts its classification or definition' unless entry['fingerprint'] == expected
327
+ end
328
+ return unless present?(entry['logical_fingerprint']) && present?(entry['classification']) && present?(symbol_hint(entry))
329
+
330
+ expected = logical_fingerprint(entry.fetch('classification'), symbol_hint(entry))
331
+ raise Error, 'Baseline logical fingerprint contradicts its classification or symbol' unless entry['logical_fingerprint'] == expected
332
+ end
333
+
334
+ def logical_fingerprint(classification, symbol_id)
335
+ Digest::SHA256.hexdigest("#{classification}:#{symbol_id}")
336
+ end
337
+
338
+ def physical_fingerprint(classification, definition_id)
339
+ Digest::SHA256.hexdigest("#{classification}:#{definition_id}")
340
+ end
341
+
342
+ def present?(value)
343
+ value.is_a?(String) && !value.empty?
344
+ end
345
+
346
+ def same_classification?(entry, finding)
347
+ classification = entry['classification']
348
+ classification.nil? || classification == finding.classification.to_s
349
+ end
350
+
351
+ def ambiguity(entry, index, resolution, assignment_indexes)
352
+ candidates = resolution[:candidates]
353
+ conflicts = candidates.filter_map { |finding| assignment_indexes[finding.physical_fingerprint] }.uniq.sort
354
+ {
355
+ 'baseline_index' => index,
356
+ 'strategy' => resolution[:strategy],
357
+ 'reason' => resolution[:reason] ||
358
+ (conflicts.empty? ? 'multiple_current_definitions' : 'current_definition_already_matched'),
359
+ 'conflicting_baseline_indexes' => conflicts,
360
+ 'baseline' => baseline_identity(entry),
361
+ 'candidates' => candidates.map { |finding| finding_identity(finding) }
362
+ }
363
+ end
364
+
365
+ def baseline_identity(entry)
366
+ entry.slice('fingerprint', 'logical_fingerprint', 'classification', 'node_id', 'symbol_id', 'definition_id',
367
+ 'body_digest', 'file', 'line')
368
+ end
369
+
370
+ def finding_identity(finding)
371
+ {
372
+ 'physical_fingerprint' => finding.physical_fingerprint,
373
+ 'classification' => finding.classification.to_s,
374
+ 'symbol_id' => finding.node.symbol_id,
375
+ 'definition_id' => finding.node.definition_id,
376
+ 'body_digest' => finding.node.body_digest,
377
+ 'file' => finding.node.file,
378
+ 'line' => finding.node.line
379
+ }
380
+ end
381
+
382
+ def migration_review_report(ambiguities)
383
+ {
384
+ 'schema_version' => MIGRATION_SCHEMA_VERSION,
385
+ 'baseline_schema_version' => schema_version,
386
+ 'baseline_path' => path,
387
+ 'review_required' => ambiguities.any?,
388
+ 'ambiguities' => ambiguities
389
+ }
44
390
  end
45
391
  end
46
392
  end
@@ -6,8 +6,11 @@ module Necropsy
6
6
  module Guardrail
7
7
  class Diff
8
8
  def self.changed_files(root:, diff_base:)
9
- stdout, status = Open3.capture2('git', '-C', root, 'diff', '--name-only', "#{diff_base}...HEAD")
10
- return Set.new unless status.success?
9
+ stdout, stderr, status = Open3.capture3('git', '-C', root, 'diff', '--name-only', "#{diff_base}...HEAD")
10
+ unless status.success?
11
+ detail = stderr.strip.empty? ? 'unknown git error' : stderr.strip
12
+ raise Error, "Could not determine changed files from #{diff_base}: #{detail}"
13
+ end
11
14
 
12
15
  stdout.lines.map(&:strip).reject(&:empty?).to_set
13
16
  end
@@ -1,24 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'date'
4
+ require 'digest'
5
+ require 'tempfile'
4
6
 
5
7
  module Necropsy
6
8
  module Guardrail
7
9
  class Quarantine
8
10
  ANNOTATION_PREFIX = '# necropsy:quarantine'
9
11
 
10
- def initialize(report:, root:)
12
+ def initialize(report:, root:, clock: Clock.new)
11
13
  @report = report
12
14
  @root = root
15
+ @clock = clock
13
16
  end
14
17
 
15
18
  def suggestions(min_confidence: :high)
16
- report.dead_methods(min_confidence: min_confidence).map do |finding|
19
+ findings = report.dead_methods(min_confidence: min_confidence)
20
+ grouped = findings.group_by { |finding| [finding.node.file, finding.node.line] }
21
+ source_digests = {}
22
+ grouped.sort_by { |(file, line), _entries| [file, line] }.map do |(file, line), entries|
23
+ entries = entries.sort_by { |finding| finding.node.definition_id }
24
+ fingerprints = entries.map(&:physical_fingerprint).uniq
25
+ path = source_path(file)
26
+ source_digests[path] ||= Digest::SHA256.file(path).hexdigest
17
27
  {
18
- finding: finding,
19
- annotation: "#{ANNOTATION_PREFIX} since=#{Date.today.iso8601}",
20
- path: File.join(root, finding.node.file),
21
- line: finding.node.line
28
+ finding: entries.first,
29
+ findings: entries.freeze,
30
+ annotation: "#{ANNOTATION_PREFIX} since=#{clock.date.iso8601} #{fingerprint_field(fingerprints)}",
31
+ fingerprints: fingerprints.freeze,
32
+ source_sha256: source_digests.fetch(path),
33
+ path: path,
34
+ line: line
22
35
  }
23
36
  end
24
37
  end
@@ -26,21 +39,99 @@ module Necropsy
26
39
  def write(min_confidence: :high)
27
40
  grouped = suggestions(min_confidence: min_confidence).group_by { |suggestion| suggestion[:path] }
28
41
  grouped.each do |path, entries|
29
- lines = File.readlines(path, chomp: true)
42
+ source = File.binread(path)
43
+ verify_source_digest!(path, source, entries)
44
+ newline = source[/\r\n|\n/] || "\n"
45
+ trailing_newline = source.end_with?("\r\n", "\n")
46
+ lines = source.split(/\r\n|\n/, -1)
47
+ lines.pop if trailing_newline
30
48
  entries.sort_by { |entry| -entry[:line] }.each do |entry|
31
49
  index = [entry[:line] - 1, 0].max
32
- next if lines[index - 1]&.include?(ANNOTATION_PREFIX)
50
+ if index.positive? && lines[index - 1]&.include?(ANNOTATION_PREFIX)
51
+ upgrade_existing_annotation!(lines, index - 1, entry)
52
+ next
53
+ end
33
54
 
34
55
  indent = lines[index][/^\s*/] || ''
35
56
  lines.insert(index, "#{indent}#{entry[:annotation]}")
36
57
  end
37
- File.write(path, "#{lines.join("\n")}\n")
58
+ rewritten = lines.join(newline)
59
+ rewritten << newline if trailing_newline
60
+ atomic_replace(path, rewritten, expected_sha256: entries.first.fetch(:source_sha256))
38
61
  end
39
62
  end
40
63
 
41
64
  private
42
65
 
43
- attr_reader :report, :root
66
+ attr_reader :report, :root, :clock
67
+
68
+ def source_path(relative)
69
+ expanded_root = File.expand_path(root)
70
+ path = File.expand_path(relative, expanded_root)
71
+ real_root = File.realpath(expanded_root)
72
+ real_path = File.realpath(path)
73
+ inside_root = real_path.start_with?("#{real_root}#{File::SEPARATOR}")
74
+ return path if path.start_with?("#{expanded_root}#{File::SEPARATOR}") && inside_root && File.file?(path)
75
+
76
+ raise Error, "Quarantine source is outside the project or missing: #{relative}"
77
+ rescue SystemCallError
78
+ raise Error, "Quarantine source is outside the project or missing: #{relative}"
79
+ end
80
+
81
+ def fingerprint_field(fingerprints)
82
+ return "fingerprint=#{fingerprints.first}" if fingerprints.one?
83
+
84
+ "fingerprints=#{fingerprints.join(',')}"
85
+ end
86
+
87
+ def verify_source_digest!(path, source, entries)
88
+ expected = entries.map { |entry| entry.fetch(:source_sha256) }.uniq
89
+ current = Digest::SHA256.hexdigest(source)
90
+ return if expected.one? && expected.first == current
91
+
92
+ raise Error, "Quarantine source changed after analysis: #{path}"
93
+ end
94
+
95
+ def upgrade_existing_annotation!(lines, annotation_index, entry)
96
+ annotation = lines.fetch(annotation_index)
97
+ expected = entry.fetch(:fingerprints)
98
+ return if annotation_fingerprints(annotation) == expected.sort
99
+
100
+ raise Error, "Existing quarantine fingerprint does not match #{entry[:path]}:#{entry[:line]}" if
101
+ annotation.match?(/\bfingerprints?=/)
102
+
103
+ lines[annotation_index] = "#{annotation} #{fingerprint_field(expected)}"
104
+ end
105
+
106
+ def annotation_fingerprints(annotation)
107
+ single = annotation[/\bfingerprint=([0-9a-f]{64})(?:\s|$)/, 1]
108
+ multiple = annotation[/\bfingerprints=([0-9a-f]{64}(?:,[0-9a-f]{64})*)(?:\s|$)/, 1]
109
+ Array(single || multiple&.split(',')).sort
110
+ end
111
+
112
+ def atomic_replace(path, contents, expected_sha256:)
113
+ directory = File.dirname(path)
114
+ mode = File.stat(path).mode & 0o7777
115
+ Tempfile.create([".#{File.basename(path)}", '.tmp'], directory) do |file|
116
+ file.binmode
117
+ file.write(contents)
118
+ file.flush
119
+ file.fsync
120
+ file.chmod(mode)
121
+ file.close
122
+ current = Digest::SHA256.file(path).hexdigest
123
+ raise Error, "Quarantine source changed while writing: #{path}" unless current == expected_sha256
124
+
125
+ File.rename(file.path, path)
126
+ end
127
+ fsync_directory(directory)
128
+ end
129
+
130
+ def fsync_directory(directory)
131
+ File.open(directory, 'rb', &:fsync)
132
+ rescue Errno::EINVAL, Errno::EISDIR
133
+ nil
134
+ end
44
135
  end
45
136
  end
46
137
  end