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
@@ -2,46 +2,153 @@
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
23
  return new(path: path, findings: []) unless File.exist?(path)
13
24
 
14
- payload = YAML.safe_load_file(path, aliases: false) || {}
15
- new(path: path, findings: Array(payload['findings']))
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)
35
80
  end
36
81
 
37
- def initialize(path:, findings:)
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
94
+ end
95
+ private_class_method :atomic_write
96
+
97
+ def initialize(path:, findings:, schema_version: 1)
38
98
  @path = path
39
- @findings = findings
40
- @fingerprints = findings.filter_map { |finding| finding['fingerprint'] }.to_set
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
41
109
  end
42
110
 
43
111
  def include?(finding)
44
- 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)
45
152
  end
46
153
 
47
154
  def count_at_least(confidence)
@@ -51,6 +158,236 @@ module Necropsy
51
158
  level && CONFIDENCE_LEVELS.fetch(level, -1) >= threshold
52
159
  end
53
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
+ }
390
+ end
54
391
  end
55
392
  end
56
393
  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
@@ -27,26 +40,98 @@ module Necropsy
27
40
  grouped = suggestions(min_confidence: min_confidence).group_by { |suggestion| suggestion[:path] }
28
41
  grouped.each do |path, entries|
29
42
  source = File.binread(path)
43
+ verify_source_digest!(path, source, entries)
30
44
  newline = source[/\r\n|\n/] || "\n"
31
45
  trailing_newline = source.end_with?("\r\n", "\n")
32
46
  lines = source.split(/\r\n|\n/, -1)
33
47
  lines.pop if trailing_newline
34
48
  entries.sort_by { |entry| -entry[:line] }.each do |entry|
35
49
  index = [entry[:line] - 1, 0].max
36
- next if index.positive? && 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
37
54
 
38
55
  indent = lines[index][/^\s*/] || ''
39
56
  lines.insert(index, "#{indent}#{entry[:annotation]}")
40
57
  end
41
58
  rewritten = lines.join(newline)
42
59
  rewritten << newline if trailing_newline
43
- File.binwrite(path, rewritten)
60
+ atomic_replace(path, rewritten, expected_sha256: entries.first.fetch(:source_sha256))
44
61
  end
45
62
  end
46
63
 
47
64
  private
48
65
 
49
- 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
50
135
  end
51
136
  end
52
137
  end