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,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class DefinitionIndex
5
+ include Enumerable
6
+
7
+ Lookup = Data.define(:identifier, :status, :definitions) do
8
+ def exact?
9
+ status == :exact
10
+ end
11
+
12
+ def unique?
13
+ status == :unique
14
+ end
15
+
16
+ def ambiguous?
17
+ status == :ambiguous
18
+ end
19
+
20
+ def missing?
21
+ status == :missing
22
+ end
23
+
24
+ def node
25
+ definitions.first if exact? || unique?
26
+ end
27
+ end
28
+
29
+ class AmbiguousDefinitionError < KeyError
30
+ attr_reader :lookup
31
+
32
+ def initialize(lookup)
33
+ @lookup = lookup
34
+ super("#{lookup.identifier.inspect} matches #{lookup.definitions.length} physical definitions")
35
+ end
36
+ end
37
+
38
+ UNDEFINED = Object.new.freeze
39
+ private_constant :UNDEFINED
40
+
41
+ def initialize(definitions = [])
42
+ @by_definition_id = {}
43
+ @by_symbol_id = Hash.new { |hash, key| hash[key] = [] }
44
+ definitions.each { |definition| add(definition) }
45
+ end
46
+
47
+ def add(definition)
48
+ existing = @by_definition_id[definition.graph_id]
49
+ return existing if existing == definition
50
+ raise ArgumentError, "duplicate physical definition ID: #{definition.graph_id}" if existing
51
+
52
+ @by_definition_id[definition.graph_id] = definition
53
+ @by_symbol_id[definition.symbol_id] << definition
54
+ definition
55
+ end
56
+
57
+ def exact(definition_id)
58
+ @by_definition_id[definition_id]
59
+ end
60
+
61
+ def definitions_for(symbol_id)
62
+ sorted(@by_symbol_id.fetch(symbol_id, [])).freeze
63
+ end
64
+
65
+ def lookup(identifier)
66
+ definitions = definitions_for(identifier)
67
+ unless definitions.empty?
68
+ status = definitions.one? ? :unique : :ambiguous
69
+ return Lookup.new(identifier: identifier, status: status, definitions: definitions)
70
+ end
71
+
72
+ exact_match = exact(identifier)
73
+ return Lookup.new(identifier: identifier, status: :exact, definitions: [exact_match].freeze) if exact_match
74
+
75
+ Lookup.new(identifier: identifier, status: :missing, definitions: definitions)
76
+ end
77
+
78
+ def [](identifier)
79
+ result = lookup(identifier)
80
+ raise AmbiguousDefinitionError, result if result.ambiguous?
81
+
82
+ result.node
83
+ end
84
+
85
+ def resolve(identifier)
86
+ lookup(identifier).definitions
87
+ end
88
+
89
+ def fetch(identifier, default = UNDEFINED)
90
+ result = lookup(identifier)
91
+ raise AmbiguousDefinitionError, result if result.ambiguous?
92
+ return result.node unless result.missing?
93
+ return yield(identifier) if block_given?
94
+ return default unless default.equal?(UNDEFINED)
95
+
96
+ raise KeyError, "key not found: #{identifier.inspect}"
97
+ end
98
+
99
+ def key?(identifier)
100
+ !lookup(identifier).missing?
101
+ end
102
+
103
+ def each
104
+ return enum_for(:each) unless block_given?
105
+
106
+ values.each { |definition| yield(definition.graph_id, definition) }
107
+ self
108
+ end
109
+
110
+ def each_key
111
+ return enum_for(:each_key) unless block_given?
112
+
113
+ values.each { |definition| yield definition.graph_id }
114
+ self
115
+ end
116
+
117
+ def each_value(&block)
118
+ return enum_for(:each_value) unless block
119
+
120
+ values.each(&block)
121
+ self
122
+ end
123
+
124
+ def keys
125
+ values.map(&:graph_id)
126
+ end
127
+
128
+ def values
129
+ sorted(@by_definition_id.values)
130
+ end
131
+
132
+ def length
133
+ @by_definition_id.length
134
+ end
135
+ alias size length
136
+
137
+ def empty?
138
+ @by_definition_id.empty?
139
+ end
140
+
141
+ private
142
+
143
+ def sorted(definitions)
144
+ definitions.sort_by do |definition|
145
+ [definition.symbol_id.to_s, definition.file.to_s, definition.line.to_i, definition.graph_id.to_s]
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module DynamicEvidenceTracking
5
+ SAMPLE_LIMIT = 5
6
+ RESOLUTION_STATUSES = %w[exact unique ambiguous missing].freeze
7
+
8
+ def dynamic_observation?
9
+ observation.key?('dynamic_evidence')
10
+ end
11
+
12
+ def dynamic_evidence_diagnostic
13
+ observation['dynamic_evidence']
14
+ end
15
+
16
+ private
17
+
18
+ def dynamic_result?(result)
19
+ return true unless result.alive_evidences.empty?
20
+
21
+ dynamic_profiles = profiles.select { |profile| profile.kind == :dynamic }.map { |profile| profile.name.to_s }
22
+ evidence_analyzers = result.edge_evidences.map { |edge| edge.evidence.analyzer.to_s }
23
+ return true if dynamic_profiles.intersect?(evidence_analyzers)
24
+
25
+ observation_keys = result.observation.keys.map(&:to_s)
26
+ observation_keys.intersect?(dynamic_profiles) || observation_keys.intersect?(%w[coverage coverband trace_point])
27
+ end
28
+
29
+ def record_dynamic_evidence(result, alive_matches, edge_matches)
30
+ diagnostic = observation['dynamic_evidence'] ||= empty_dynamic_evidence_diagnostic
31
+ unmatched_nodes = result.alive_evidences.zip(alive_matches).filter_map do |_alive, resolution|
32
+ dynamic_reference_label(resolution) if missing_dynamic_resolution?(resolution)
33
+ end
34
+ incomplete_nodes = alive_matches.filter_map do |resolution|
35
+ dynamic_reference_label(resolution) unless precise_dynamic_resolution?(resolution)
36
+ end
37
+ incomplete_edges = edge_matches.filter_map { |match| incomplete_edge_sample(match) }
38
+
39
+ update_dynamic_node_counts(diagnostic, alive_matches)
40
+ update_dynamic_edge_counts(diagnostic, edge_matches)
41
+ update_dynamic_resolution_counts(diagnostic, alive_matches, edge_matches)
42
+ append_dynamic_samples(diagnostic, 'nodes', unmatched_nodes)
43
+ append_dynamic_samples(diagnostic, 'edges', incomplete_edges)
44
+ append_resolution_samples(diagnostic, alive_matches, edge_matches)
45
+ warn_unmatched_dynamic_evidence(alive_matches, incomplete_nodes)
46
+ warn_unmatched_dynamic_edges(edge_matches, incomplete_edges)
47
+ end
48
+
49
+ def empty_dynamic_evidence_diagnostic
50
+ {
51
+ 'policy' => 'positive_only',
52
+ 'runtime_unobserved' => 'informational_only',
53
+ 'attempted' => { 'nodes' => 0, 'edges' => 0 },
54
+ 'matched' => { 'nodes' => 0, 'edges' => 0 },
55
+ 'partially_matched' => { 'nodes' => 0, 'edges' => 0 },
56
+ 'unmatched' => { 'nodes' => 0, 'edges' => 0 },
57
+ 'unmatched_samples' => { 'nodes' => [], 'edges' => [] },
58
+ 'resolution' => {
59
+ 'nodes' => empty_resolution_counts,
60
+ 'edge_endpoints' => empty_resolution_counts
61
+ },
62
+ 'resolution_samples' => { 'nodes' => [], 'edge_endpoints' => [] }
63
+ }
64
+ end
65
+
66
+ def empty_resolution_counts
67
+ RESOLUTION_STATUSES.to_h { |status| [status, 0] }
68
+ end
69
+
70
+ def update_dynamic_node_counts(diagnostic, resolutions)
71
+ diagnostic['attempted']['nodes'] += resolutions.length
72
+ diagnostic['matched']['nodes'] += resolutions.count { |resolution| precise_dynamic_resolution?(resolution) }
73
+ diagnostic['partially_matched']['nodes'] += resolutions.count { |resolution| ambiguous_dynamic_resolution?(resolution) }
74
+ diagnostic['unmatched']['nodes'] += resolutions.count { |resolution| missing_dynamic_resolution?(resolution) }
75
+ end
76
+
77
+ def update_dynamic_edge_counts(diagnostic, matches)
78
+ diagnostic['attempted']['edges'] += matches.length
79
+ diagnostic['matched']['edges'] += matches.count { |match| fully_matched_edge?(match) }
80
+ diagnostic['partially_matched']['edges'] += matches.count { |match| partially_matched_edge?(match) }
81
+ diagnostic['unmatched']['edges'] += matches.count { |match| unmatched_edge?(match) }
82
+ end
83
+
84
+ def update_dynamic_resolution_counts(diagnostic, nodes, edges)
85
+ nodes.each { |resolution| increment_resolution_count(diagnostic, 'nodes', resolution) }
86
+ edges.each do |match|
87
+ increment_resolution_count(diagnostic, 'edge_endpoints', match.fetch(:caller))
88
+ increment_resolution_count(diagnostic, 'edge_endpoints', match.fetch(:callee))
89
+ end
90
+ end
91
+
92
+ def increment_resolution_count(diagnostic, kind, resolution)
93
+ diagnostic['resolution'][kind][resolution.fetch(:status).to_s] += 1
94
+ end
95
+
96
+ def fully_matched_edge?(match)
97
+ match.fetch(:materialized)
98
+ end
99
+
100
+ def partially_matched_edge?(match)
101
+ !fully_matched_edge?(match) && !unmatched_edge?(match)
102
+ end
103
+
104
+ def unmatched_edge?(match)
105
+ missing_dynamic_resolution?(match.fetch(:caller)) && missing_dynamic_resolution?(match.fetch(:callee))
106
+ end
107
+
108
+ def ambiguous_dynamic_resolution?(resolution)
109
+ resolution.fetch(:status) == :ambiguous
110
+ end
111
+
112
+ def missing_dynamic_resolution?(resolution)
113
+ resolution.fetch(:status) == :missing
114
+ end
115
+
116
+ def incomplete_edge_sample(match)
117
+ return if fully_matched_edge?(match)
118
+
119
+ caller = match.fetch(:caller)
120
+ callee = match.fetch(:callee)
121
+ incomplete = []
122
+ missing = dynamic_endpoints_with_status(caller, callee, :missing)
123
+ ambiguous = dynamic_endpoints_with_status(caller, callee, :ambiguous)
124
+ incomplete << "unmatched #{missing.join(', ')}" unless missing.empty?
125
+ incomplete << "ambiguous #{ambiguous.join(', ')}" unless ambiguous.empty?
126
+ "#{dynamic_reference_label(caller)} -> #{dynamic_reference_label(callee)} (#{incomplete.join(', ')})"
127
+ end
128
+
129
+ def dynamic_endpoints_with_status(caller, callee, status)
130
+ { 'caller' => caller, 'callee' => callee }.filter_map do |endpoint, resolution|
131
+ "#{endpoint}: #{dynamic_reference_label(resolution)}" if resolution.fetch(:status) == status
132
+ end
133
+ end
134
+
135
+ def dynamic_reference_label(resolution)
136
+ reference = resolution.fetch(:reference)
137
+ return reference.fetch('identifier') if reference.key?('identifier')
138
+
139
+ identifier = reference['definition_id'] || reference['symbol_id'] || '<invalid>'
140
+ location = [reference['file'], reference['line']].compact.join(':')
141
+ location.empty? ? identifier : "#{identifier} @ #{location}"
142
+ end
143
+
144
+ def append_dynamic_samples(diagnostic, kind, samples)
145
+ combined = diagnostic['unmatched_samples'][kind] + samples
146
+ diagnostic['unmatched_samples'][kind] = combined.uniq.sort.first(SAMPLE_LIMIT)
147
+ end
148
+
149
+ def append_resolution_samples(diagnostic, nodes, edges)
150
+ node_samples = nodes.map { |resolution| resolution_sample(resolution) }
151
+ endpoint_samples = edges.flat_map do |match|
152
+ %i[caller callee].map do |endpoint|
153
+ resolution_sample(match.fetch(endpoint)).merge('endpoint' => endpoint.to_s)
154
+ end
155
+ end
156
+ append_bounded_resolution_samples(diagnostic, 'nodes', node_samples)
157
+ append_bounded_resolution_samples(diagnostic, 'edge_endpoints', endpoint_samples)
158
+ end
159
+
160
+ def resolution_sample(resolution)
161
+ {
162
+ 'reference' => resolution.fetch(:reference),
163
+ 'status' => resolution.fetch(:status).to_s,
164
+ 'definition_ids' => resolution.fetch(:definitions).map(&:graph_id).sort
165
+ }
166
+ end
167
+
168
+ def append_bounded_resolution_samples(diagnostic, kind, samples)
169
+ combined = diagnostic['resolution_samples'][kind] + samples
170
+ diagnostic['resolution_samples'][kind] = combined.uniq.sort_by do |sample|
171
+ canonical_dynamic_sample(sample)
172
+ end.first(SAMPLE_LIMIT)
173
+ end
174
+
175
+ def canonical_dynamic_sample(value)
176
+ case value
177
+ when Hash
178
+ value.keys.sort.map { |key| [key, canonical_dynamic_sample(value.fetch(key))] }
179
+ when Array
180
+ value.map { |item| canonical_dynamic_sample(item) }
181
+ else
182
+ value.to_s
183
+ end
184
+ end
185
+
186
+ def warn_unmatched_dynamic_evidence(matches, samples)
187
+ return if samples.empty?
188
+
189
+ precise = matches.count { |resolution| precise_dynamic_resolution?(resolution) }
190
+ ambiguous = matches.count { |resolution| ambiguous_dynamic_resolution?(resolution) }
191
+ missing = matches.count { |resolution| missing_dynamic_resolution?(resolution) }
192
+ warn "Necropsy matched #{precise} of #{matches.length} dynamic node IDs precisely; ambiguous #{ambiguous}; " \
193
+ "missing #{missing}; unresolved: #{samples.first(SAMPLE_LIMIT).join(', ')}"
194
+ end
195
+
196
+ def warn_unmatched_dynamic_edges(matches, samples)
197
+ return if samples.empty?
198
+
199
+ full = matches.count { |match| fully_matched_edge?(match) }
200
+ partial = matches.count { |match| partially_matched_edge?(match) }
201
+ unmatched = matches.count { |match| unmatched_edge?(match) }
202
+ warn "Necropsy fully matched #{full} of #{matches.length} dynamic edges; partially matched #{partial}; " \
203
+ "unmatched #{unmatched}; unmatched endpoints: #{samples.first(SAMPLE_LIMIT).join(', ')}"
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Necropsy
6
+ module EvidenceStore
7
+ PROJECTIONS = %i[exact conservative observed].freeze
8
+
9
+ def self.normalize_projection(projection)
10
+ normalized = projection.to_sym if projection.respond_to?(:to_sym)
11
+ return normalized if PROJECTIONS.include?(normalized)
12
+
13
+ raise ArgumentError, "projection must be one of: #{PROJECTIONS.join(', ')}"
14
+ end
15
+
16
+ def evidence_records
17
+ @evidence_records_by_id.values.sort_by(&:evidence_id).freeze
18
+ end
19
+
20
+ def evidence_record(evidence_id)
21
+ @evidence_records_by_id[evidence_id.to_s]
22
+ end
23
+
24
+ def evidence_collisions
25
+ @evidence_collision_payloads.keys.sort.map do |evidence_id|
26
+ {
27
+ 'evidence_id' => evidence_id,
28
+ 'payload_sha256s' => @evidence_collision_payloads.fetch(evidence_id).to_a.sort,
29
+ 'domains' => @evidence_domains.fetch(evidence_id, Set.new).map(&:to_s).sort
30
+ }
31
+ end.freeze
32
+ end
33
+
34
+ private
35
+
36
+ def initialize_evidence_store
37
+ @evidence_records_by_id = {}
38
+ @evidence_payloads_by_id = {}
39
+ @evidence_payloads_by_object = {}.compare_by_identity
40
+ @evidence_domains = Hash.new { |hash, key| hash[key] = Set.new }
41
+ @quarantined_evidence_ids = Set.new
42
+ @evidence_collision_payloads = Hash.new { |hash, key| hash[key] = Set.new }
43
+ end
44
+
45
+ def register_evidence(evidence, domain: :runtime, canonical_payload: nil)
46
+ record = evidence_with_identity(evidence)
47
+ evidence_id = record.evidence_id
48
+ payload = canonical_payload || canonical_evidence_payload(record)
49
+ @evidence_payloads_by_object[record] = [evidence_id, payload]
50
+ @evidence_domains[evidence_id] << domain.to_sym
51
+
52
+ if @quarantined_evidence_ids.include?(evidence_id)
53
+ @evidence_collision_payloads[evidence_id] << Digest::SHA256.hexdigest(payload)
54
+ rebuild_evidence_collision_state
55
+ return
56
+ end
57
+
58
+ existing_payload = @evidence_payloads_by_id[evidence_id]
59
+ if existing_payload && existing_payload != payload
60
+ @evidence_collision_payloads[evidence_id].merge(
61
+ [Digest::SHA256.hexdigest(existing_payload), Digest::SHA256.hexdigest(payload)]
62
+ )
63
+ quarantine_evidence(evidence_id)
64
+ return
65
+ end
66
+
67
+ @evidence_records_by_id[evidence_id] ||= record
68
+ @evidence_payloads_by_id[evidence_id] ||= payload
69
+ refresh_evidence_store_observation if observation.key?('evidence_store')
70
+ evidence_id
71
+ end
72
+
73
+ def evidence_with_identity(evidence)
74
+ return evidence if evidence.evidence_id
75
+
76
+ evidence.with(evidence_id: EvidenceIdentity.generate(evidence.to_h.except('evidence_id')))
77
+ end
78
+
79
+ def canonical_evidence_payload(evidence)
80
+ BoundedCanonicalizer.dump(evidence.to_h.except('evidence_id'))
81
+ end
82
+
83
+ def quarantine_evidence(evidence_id)
84
+ @quarantined_evidence_ids << evidence_id
85
+ @evidence_records_by_id.delete(evidence_id)
86
+ @evidence_payloads_by_id.delete(evidence_id)
87
+ remove_evidence_references(evidence_id)
88
+ rebuild_evidence_collision_state
89
+ end
90
+
91
+ def remove_evidence_references(evidence_id)
92
+ @edges.each_value do |callees|
93
+ callees.each_value { |evidence_ids| evidence_ids.delete(evidence_id) }
94
+ callees.delete_if { |_callee_id, evidence_ids| evidence_ids.empty? }
95
+ end
96
+ @edges.delete_if { |_caller_id, callees| callees.empty? }
97
+ @dynamic_alive.each_value { |evidence_ids| evidence_ids.delete(evidence_id) }
98
+ @dynamic_alive.delete_if { |_node_id, evidence_ids| evidence_ids.empty? }
99
+ rebuild_incoming_edges
100
+ end
101
+
102
+ def rebuild_evidence_collision_state
103
+ remove_blockers_matching do |blocker|
104
+ blocker.kind == :evidence_collision && blocker.source == :evidence_store
105
+ end
106
+ @evidence_collision_payloads.keys.sort.each do |evidence_id|
107
+ @evidence_domains.fetch(evidence_id).to_a.sort.each do |domain|
108
+ add_blocker(evidence_collision_blocker(evidence_id, domain))
109
+ end
110
+ end
111
+ refresh_evidence_store_observation
112
+ end
113
+
114
+ def refresh_evidence_store_observation
115
+ observation['evidence_store'] = {
116
+ 'record_count' => @evidence_records_by_id.length,
117
+ 'collision_count' => @evidence_collision_payloads.length,
118
+ 'collisions' => evidence_collisions.first(5)
119
+ }
120
+ end
121
+
122
+ def evidence_collision_blocker(evidence_id, domain)
123
+ Blocker.new(
124
+ kind: :evidence_collision,
125
+ scope_kind: :global,
126
+ scope_value: '*',
127
+ source: :evidence_store,
128
+ reason: 'One evidence ID was supplied with conflicting payloads',
129
+ suggested_action: :fix_analyzer,
130
+ metadata: {
131
+ 'caller_domain' => domain.to_s,
132
+ 'receiver_kind' => 'implicit',
133
+ 'evidence_id' => evidence_id,
134
+ 'evidence_store' => true
135
+ }
136
+ )
137
+ end
138
+
139
+ def projected_evidence_records(evidence_ids, projection:, scope: nil)
140
+ projection = normalize_projection(projection)
141
+ evidence_ids.filter_map { |evidence_id| evidence_record(evidence_id) }
142
+ .select { |evidence| evidence_in_projection?(evidence, projection, scope) }
143
+ .sort_by(&:evidence_id)
144
+ end
145
+
146
+ def projected_evidence_ids(evidence_ids, projection:, scope: nil)
147
+ projected_evidence_records(evidence_ids, projection: projection, scope: scope).map(&:evidence_id).freeze
148
+ end
149
+
150
+ def normalize_projection(projection)
151
+ EvidenceStore.normalize_projection(projection)
152
+ end
153
+
154
+ def evidence_payload_registered?(evidence, canonical_payload: nil)
155
+ evidence_id = evidence.evidence_id
156
+ cached = @evidence_payloads_by_object[evidence]
157
+ return true if cached && cached[0] == evidence_id && @evidence_payloads_by_id[evidence_id] == cached[1]
158
+
159
+ payload = canonical_payload || canonical_evidence_payload(evidence)
160
+ return true if @evidence_payloads_by_id[evidence_id] == payload
161
+ return false unless @quarantined_evidence_ids.include?(evidence_id)
162
+
163
+ @evidence_collision_payloads.fetch(evidence_id).include?(Digest::SHA256.hexdigest(payload))
164
+ end
165
+
166
+ def evidence_in_projection?(evidence, projection, requested_scope)
167
+ grade = evidence.grade&.to_sym
168
+ case projection
169
+ when :conservative
170
+ true
171
+ when :exact
172
+ grade == :exact || (grade == :observed && verified_observed_scope?(evidence.scope, requested_scope))
173
+ when :observed
174
+ grade == :observed && evidence_scope_matches?(evidence.scope, requested_scope)
175
+ end
176
+ end
177
+
178
+ def verified_observed_scope?(actual_scope, requested_scope)
179
+ return false unless requested_scope.is_a?(Hash)
180
+
181
+ actual_status = actual_scope.is_a?(Hash) && actual_scope['source_revision_status']
182
+ return false if %w[mismatch stale].include?(actual_status.to_s)
183
+
184
+ revision = requested_scope['revision'] || requested_scope[:revision]
185
+ revision && evidence_scope_matches?(actual_scope, requested_scope)
186
+ end
187
+
188
+ def evidence_scope_matches?(actual_scope, requested_scope)
189
+ return true if requested_scope.nil?
190
+
191
+ actual_scope = actual_scope.to_h if actual_scope.respond_to?(:to_h)
192
+ requested_scope = requested_scope.to_h if requested_scope.respond_to?(:to_h)
193
+ return canonical_scope(actual_scope) == canonical_scope(requested_scope) unless requested_scope.is_a?(Hash)
194
+ return false unless actual_scope.is_a?(Hash)
195
+
196
+ requested_scope.all? do |key, expected|
197
+ actual_key = [key, key.to_s, key.respond_to?(:to_sym) && key.to_sym].find do |candidate|
198
+ candidate && actual_scope.key?(candidate)
199
+ end
200
+ next false unless actual_key
201
+
202
+ actual = actual_scope.fetch(actual_key)
203
+ evidence_scope_matches?(actual, expected)
204
+ end
205
+ rescue KeyError, NoMethodError, BoundedCanonicalizer::Error
206
+ false
207
+ end
208
+
209
+ def canonical_scope(value)
210
+ BoundedCanonicalizer.dump(value)
211
+ end
212
+ end
213
+ end