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.
- checksums.yaml +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- 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 +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- 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 +225 -20
- 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 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- 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 +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- 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 +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- 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 +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- 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 +1234 -62
- 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 +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -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 +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- metadata +106 -2
data/lib/necropsy/models.rb
CHANGED
|
@@ -1,20 +1,355 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
|
+
require_relative 'bounded_canonicalizer'
|
|
4
5
|
|
|
5
6
|
module Necropsy
|
|
6
|
-
|
|
7
|
+
CONFIDENCE_LEVELS = {
|
|
8
|
+
low: 0,
|
|
9
|
+
medium: 1,
|
|
10
|
+
high: 2,
|
|
11
|
+
certain: 3
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
UNKNOWN_SCOPE_KINDS = %i[definition symbol message owner namespace file global].freeze
|
|
15
|
+
UNKNOWN_SCOPE_MATCHES = %i[exact glob].freeze
|
|
16
|
+
RESOLUTION_STATUSES = %i[complete partial unknown].freeze
|
|
17
|
+
EVIDENCE_GRADES = %i[exact conservative observed heuristic].freeze
|
|
18
|
+
ANALYZER_KINDS = %i[static dynamic type].freeze
|
|
19
|
+
ANALYZER_SOUNDNESS = %i[unsound conservative partial observational hint].freeze
|
|
20
|
+
EVIDENCE_MAX_DETAILS_BYTES = 4_096
|
|
21
|
+
EVIDENCE_MAX_METADATA_DEPTH = 32
|
|
22
|
+
EVIDENCE_MAX_METADATA_ITEMS = 10_000
|
|
23
|
+
EVIDENCE_MAX_METADATA_STRING_BYTES = 65_536
|
|
24
|
+
EVIDENCE_MAX_METADATA_BYTES = 1_048_576
|
|
25
|
+
PROFILE_MAX_DESCRIPTION_BYTES = 4_096
|
|
26
|
+
PROFILE_MAX_VERSION_BYTES = 128
|
|
27
|
+
PROFILE_MAX_ASSUMPTIONS = 64
|
|
28
|
+
PROFILE_MAX_ASSUMPTION_BYTES = 512
|
|
29
|
+
ROOT_DOMAINS = %i[runtime test external].freeze
|
|
30
|
+
private_constant :UNKNOWN_SCOPE_KINDS, :UNKNOWN_SCOPE_MATCHES, :RESOLUTION_STATUSES, :EVIDENCE_GRADES,
|
|
31
|
+
:ANALYZER_KINDS, :ANALYZER_SOUNDNESS, :ROOT_DOMAINS
|
|
32
|
+
private_constant :EVIDENCE_MAX_DETAILS_BYTES, :EVIDENCE_MAX_METADATA_DEPTH, :EVIDENCE_MAX_METADATA_ITEMS,
|
|
33
|
+
:EVIDENCE_MAX_METADATA_STRING_BYTES, :EVIDENCE_MAX_METADATA_BYTES,
|
|
34
|
+
:PROFILE_MAX_DESCRIPTION_BYTES, :PROFILE_MAX_VERSION_BYTES, :PROFILE_MAX_ASSUMPTIONS,
|
|
35
|
+
:PROFILE_MAX_ASSUMPTION_BYTES
|
|
36
|
+
|
|
37
|
+
module ModelNormalization
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def attributes(value, model_name)
|
|
41
|
+
raise ArgumentError, "#{model_name} must be loaded from a Hash" unless value.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
value.to_h { |key, item| [key.to_s, item] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def identifier(value, field)
|
|
47
|
+
normalized = value.to_s
|
|
48
|
+
raise ArgumentError, "#{field} must not be empty" if normalized.empty?
|
|
49
|
+
|
|
50
|
+
normalized.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def string_list(values, field)
|
|
54
|
+
list(values).map { |value| identifier(value, field) }.uniq.sort.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def scope_value(value)
|
|
58
|
+
normalized = if value.is_a?(Array)
|
|
59
|
+
string_list(value, 'scope_value')
|
|
60
|
+
else
|
|
61
|
+
identifier(value, 'scope_value')
|
|
62
|
+
end
|
|
63
|
+
raise ArgumentError, 'scope_value must not be empty' if normalized.respond_to?(:empty?) && normalized.empty?
|
|
64
|
+
|
|
65
|
+
normalized
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def list(values)
|
|
69
|
+
return [] if values.nil?
|
|
70
|
+
|
|
71
|
+
values.is_a?(Array) ? values : [values]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def canonical(value)
|
|
75
|
+
case value
|
|
76
|
+
when nil
|
|
77
|
+
'nil'
|
|
78
|
+
when true, false, Numeric
|
|
79
|
+
"#{value.class}:#{value}"
|
|
80
|
+
when Symbol
|
|
81
|
+
"symbol:#{canonical_string(value)}"
|
|
82
|
+
when String
|
|
83
|
+
"string:#{canonical_string(value)}"
|
|
84
|
+
when Array
|
|
85
|
+
"array:[#{value.map { |item| canonical(item) }.join(',')}]"
|
|
86
|
+
when Hash
|
|
87
|
+
pairs = value.map { |key, item| [canonical(key), canonical(item)] }.sort_by(&:first)
|
|
88
|
+
"hash:{#{pairs.map { |key, item| "#{key}=#{item}" }.join(',')}}"
|
|
89
|
+
else
|
|
90
|
+
return canonical(value.to_h) if value.respond_to?(:to_h)
|
|
91
|
+
|
|
92
|
+
"#{value.class}:#{canonical_string(value)}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def canonical_string(value)
|
|
97
|
+
string = value.to_s
|
|
98
|
+
"#{string.bytesize}:#{string}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
private_constant :ModelNormalization
|
|
102
|
+
|
|
103
|
+
UnknownScope = Data.define(:scope_kind, :scope_value, :match) do
|
|
104
|
+
def initialize(scope_kind:, scope_value:, match:)
|
|
105
|
+
scope_kind = scope_kind.to_sym if scope_kind.respond_to?(:to_sym)
|
|
106
|
+
match = match.to_sym if match.respond_to?(:to_sym)
|
|
107
|
+
raise ArgumentError, "invalid unknown scope kind: #{scope_kind.inspect}" unless UNKNOWN_SCOPE_KINDS.include?(scope_kind)
|
|
108
|
+
raise ArgumentError, "invalid unknown scope match: #{match.inspect}" unless UNKNOWN_SCOPE_MATCHES.include?(match)
|
|
109
|
+
|
|
110
|
+
scope_value = ModelNormalization.scope_value(scope_value)
|
|
111
|
+
super
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.from_h(attributes)
|
|
115
|
+
data = ModelNormalization.attributes(attributes, name)
|
|
116
|
+
new(
|
|
117
|
+
scope_kind: data.fetch('scope_kind'),
|
|
118
|
+
scope_value: data.fetch('scope_value'),
|
|
119
|
+
match: data.fetch('match')
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def to_h
|
|
124
|
+
{
|
|
125
|
+
'scope_kind' => scope_kind.to_s,
|
|
126
|
+
'scope_value' => scope_value,
|
|
127
|
+
'match' => match.to_s
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
RejectedTarget = Data.define(:definition_id, :reason, :evidence_ids) do
|
|
133
|
+
def initialize(definition_id:, reason:, evidence_ids: [])
|
|
134
|
+
definition_id = ModelNormalization.identifier(definition_id, 'definition_id')
|
|
135
|
+
reason = ModelNormalization.identifier(reason, 'reason')
|
|
136
|
+
evidence_ids = ModelNormalization.string_list(evidence_ids, 'evidence_id')
|
|
137
|
+
super
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.from_h(attributes)
|
|
141
|
+
data = ModelNormalization.attributes(attributes, name)
|
|
142
|
+
new(
|
|
143
|
+
definition_id: data.fetch('definition_id'),
|
|
144
|
+
reason: data.fetch('reason'),
|
|
145
|
+
evidence_ids: data.fetch('evidence_ids', [])
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_h
|
|
150
|
+
{
|
|
151
|
+
'definition_id' => definition_id,
|
|
152
|
+
'reason' => reason,
|
|
153
|
+
'evidence_ids' => evidence_ids
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Resolution = Data.define(
|
|
159
|
+
:call_site_id,
|
|
160
|
+
:target_definition_ids,
|
|
161
|
+
:status,
|
|
162
|
+
:unknown_scope,
|
|
163
|
+
:rejected_targets,
|
|
164
|
+
:evidence_ids
|
|
165
|
+
) do
|
|
166
|
+
def initialize(call_site_id:, target_definition_ids:, status:, unknown_scope: nil, rejected_targets: [],
|
|
167
|
+
evidence_ids: [])
|
|
168
|
+
call_site_id = ModelNormalization.identifier(call_site_id, 'call_site_id')
|
|
169
|
+
target_definition_ids = ModelNormalization.string_list(target_definition_ids, 'target_definition_id')
|
|
170
|
+
status = status.to_sym if status.respond_to?(:to_sym)
|
|
171
|
+
raise ArgumentError, "invalid resolution status: #{status.inspect}" unless RESOLUTION_STATUSES.include?(status)
|
|
172
|
+
|
|
173
|
+
unknown_scope = normalize_unknown_scope(unknown_scope)
|
|
174
|
+
rejected_targets = normalize_rejected_targets(rejected_targets)
|
|
175
|
+
evidence_ids = ModelNormalization.string_list(evidence_ids, 'evidence_id')
|
|
176
|
+
validate_state!(status, target_definition_ids, unknown_scope)
|
|
177
|
+
super
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.from_h(attributes)
|
|
181
|
+
data = ModelNormalization.attributes(attributes, name)
|
|
182
|
+
new(
|
|
183
|
+
call_site_id: data.fetch('call_site_id'),
|
|
184
|
+
target_definition_ids: data.fetch('target_definition_ids', []),
|
|
185
|
+
status: data.fetch('status'),
|
|
186
|
+
unknown_scope: data['unknown_scope'],
|
|
187
|
+
rejected_targets: data.fetch('rejected_targets', []),
|
|
188
|
+
evidence_ids: data.fetch('evidence_ids', [])
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def to_h
|
|
193
|
+
{
|
|
194
|
+
'call_site_id' => call_site_id,
|
|
195
|
+
'target_definition_ids' => target_definition_ids,
|
|
196
|
+
'status' => status.to_s,
|
|
197
|
+
'unknown_scope' => unknown_scope&.to_h,
|
|
198
|
+
'rejected_targets' => rejected_targets.map(&:to_h),
|
|
199
|
+
'evidence_ids' => evidence_ids
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def normalize_unknown_scope(value)
|
|
206
|
+
return if value.nil?
|
|
207
|
+
return value if value.is_a?(UnknownScope)
|
|
208
|
+
|
|
209
|
+
UnknownScope.from_h(value)
|
|
210
|
+
rescue NoMethodError
|
|
211
|
+
raise ArgumentError, 'unknown_scope must be an UnknownScope or Hash'
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def normalize_rejected_targets(values)
|
|
215
|
+
ModelNormalization.list(values).map do |value|
|
|
216
|
+
value.is_a?(RejectedTarget) ? value : RejectedTarget.from_h(value)
|
|
217
|
+
end.uniq.sort_by do |target|
|
|
218
|
+
[target.definition_id, target.reason, target.evidence_ids]
|
|
219
|
+
end.freeze
|
|
220
|
+
rescue NoMethodError
|
|
221
|
+
raise ArgumentError, 'rejected_targets must contain RejectedTarget values or Hashes'
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate_state!(resolution_status, targets, scope)
|
|
225
|
+
if resolution_status == :complete
|
|
226
|
+
raise ArgumentError, 'complete resolution must not have an unknown scope' if scope
|
|
227
|
+
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
raise ArgumentError, "#{resolution_status} resolution requires an unknown scope" unless scope
|
|
232
|
+
|
|
233
|
+
if resolution_status == :partial
|
|
234
|
+
raise ArgumentError, 'partial resolution requires at least one target' if targets.empty?
|
|
235
|
+
elsif targets.any?
|
|
236
|
+
raise ArgumentError, 'unknown resolution must not have targets'
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
ResolutionRecord = Data.define(:resolution, :producer, :producer_version, :assumptions) do
|
|
242
|
+
def initialize(resolution:, producer:, producer_version: nil, assumptions: [])
|
|
243
|
+
resolution = normalize_resolution(resolution)
|
|
244
|
+
producer = ModelNormalization.identifier(producer, 'producer')
|
|
245
|
+
producer_version = ModelNormalization.identifier(producer_version, 'producer_version') if producer_version
|
|
246
|
+
assumptions = ModelNormalization.string_list(assumptions, 'assumption')
|
|
247
|
+
super
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.from_h(attributes)
|
|
251
|
+
data = ModelNormalization.attributes(attributes, name)
|
|
252
|
+
new(
|
|
253
|
+
resolution: data.fetch('resolution'),
|
|
254
|
+
producer: data.fetch('producer'),
|
|
255
|
+
producer_version: data['producer_version'],
|
|
256
|
+
assumptions: data.fetch('assumptions', [])
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def identity_key
|
|
261
|
+
[resolution.call_site_id, producer, producer_version, assumptions].freeze
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def to_h
|
|
265
|
+
{
|
|
266
|
+
'resolution' => resolution.to_h,
|
|
267
|
+
'producer' => producer,
|
|
268
|
+
'producer_version' => producer_version,
|
|
269
|
+
'assumptions' => assumptions
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def normalize_resolution(value)
|
|
276
|
+
return value if value.is_a?(Resolution)
|
|
277
|
+
|
|
278
|
+
Resolution.from_h(value)
|
|
279
|
+
rescue NoMethodError
|
|
280
|
+
raise ArgumentError, 'resolution must be a Resolution or Hash'
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
Node = Data.define(
|
|
285
|
+
:id,
|
|
286
|
+
:symbol_id,
|
|
287
|
+
:definition_id,
|
|
288
|
+
:body_digest,
|
|
289
|
+
:ordinal,
|
|
290
|
+
:kind,
|
|
291
|
+
:file,
|
|
292
|
+
:line,
|
|
293
|
+
:end_line,
|
|
294
|
+
:defined_via,
|
|
295
|
+
:owner,
|
|
296
|
+
:name,
|
|
297
|
+
:test,
|
|
298
|
+
:visibility
|
|
299
|
+
) do
|
|
300
|
+
class << self
|
|
301
|
+
alias_method :data_new, :new
|
|
302
|
+
|
|
303
|
+
def new(*values, **attributes)
|
|
304
|
+
compatible_new(*values, **attributes)
|
|
305
|
+
end
|
|
306
|
+
alias_method :[], :new
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
def compatible_new(*values, **attributes)
|
|
311
|
+
return data_new(*values, **attributes) unless values.length == 10 && attributes.empty?
|
|
312
|
+
|
|
313
|
+
id, kind, file, line, end_line, defined_via, owner, name, test, visibility = values
|
|
314
|
+
data_new(
|
|
315
|
+
id: id, kind: kind, file: file, line: line, end_line: end_line, defined_via: defined_via,
|
|
316
|
+
owner: owner, name: name, test: test, visibility: visibility
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private :data_new, :compatible_new
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def initialize(id:, kind:, file:, line:, end_line:, defined_via:, owner:, name:, test:, visibility:,
|
|
324
|
+
symbol_id: id, definition_id: id, body_digest: nil, ordinal: 0)
|
|
325
|
+
super
|
|
326
|
+
end
|
|
327
|
+
|
|
7
328
|
def method?
|
|
8
329
|
kind != :block_entry
|
|
9
330
|
end
|
|
10
331
|
|
|
332
|
+
def graph_id
|
|
333
|
+
definition_id
|
|
334
|
+
end
|
|
335
|
+
|
|
11
336
|
def fingerprint(classification)
|
|
12
|
-
Digest::SHA256.hexdigest("#{classification}:#{
|
|
337
|
+
Digest::SHA256.hexdigest("#{classification}:#{symbol_id}")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
alias_method :logical_fingerprint, :fingerprint
|
|
341
|
+
|
|
342
|
+
def physical_fingerprint(classification)
|
|
343
|
+
Digest::SHA256.hexdigest("#{classification}:#{definition_id}")
|
|
13
344
|
end
|
|
14
345
|
|
|
15
346
|
def to_h
|
|
16
347
|
{
|
|
17
348
|
'id' => id,
|
|
349
|
+
'symbol_id' => symbol_id,
|
|
350
|
+
'definition_id' => definition_id,
|
|
351
|
+
'body_digest' => body_digest,
|
|
352
|
+
'ordinal' => ordinal,
|
|
18
353
|
'kind' => kind.to_s,
|
|
19
354
|
'file' => file,
|
|
20
355
|
'line' => line,
|
|
@@ -22,21 +357,151 @@ module Necropsy
|
|
|
22
357
|
'defined_via' => defined_via.to_s,
|
|
23
358
|
'owner' => owner,
|
|
24
359
|
'name' => name,
|
|
25
|
-
'test' => test
|
|
360
|
+
'test' => test,
|
|
361
|
+
'visibility' => visibility.to_s
|
|
26
362
|
}
|
|
27
363
|
end
|
|
28
364
|
end
|
|
29
365
|
|
|
30
|
-
|
|
366
|
+
MethodLookup = Data.define(
|
|
367
|
+
:targets,
|
|
368
|
+
:status,
|
|
369
|
+
:rejected_targets,
|
|
370
|
+
:lookup_chain,
|
|
371
|
+
:reason
|
|
372
|
+
) do
|
|
373
|
+
def initialize(targets:, status:, rejected_targets: [], lookup_chain: [], reason: nil)
|
|
374
|
+
status = status.to_sym
|
|
375
|
+
raise ArgumentError, "invalid method lookup status: #{status.inspect}" unless RESOLUTION_STATUSES.include?(status)
|
|
376
|
+
|
|
377
|
+
targets = Array(targets).uniq(&:graph_id).sort_by(&:graph_id).freeze
|
|
378
|
+
rejected_targets = Array(rejected_targets).uniq.sort_by do |target|
|
|
379
|
+
[target.definition_id, target.reason, target.evidence_ids]
|
|
380
|
+
end.freeze
|
|
381
|
+
lookup_chain = Array(lookup_chain).map(&:to_s).freeze
|
|
382
|
+
super
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def complete?
|
|
386
|
+
status == :complete
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def partial?
|
|
390
|
+
status == :partial
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def unknown?
|
|
394
|
+
status == :unknown
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def to_h
|
|
398
|
+
{
|
|
399
|
+
'target_definition_ids' => targets.map(&:graph_id),
|
|
400
|
+
'status' => status.to_s,
|
|
401
|
+
'rejected_targets' => rejected_targets.map(&:to_h),
|
|
402
|
+
'lookup_chain' => lookup_chain,
|
|
403
|
+
'reason' => reason
|
|
404
|
+
}
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
Evidence = Data.define(
|
|
409
|
+
:analyzer,
|
|
410
|
+
:kind,
|
|
411
|
+
:weight,
|
|
412
|
+
:details,
|
|
413
|
+
:metadata,
|
|
414
|
+
:evidence_id,
|
|
415
|
+
:producer,
|
|
416
|
+
:producer_version,
|
|
417
|
+
:grade,
|
|
418
|
+
:relation,
|
|
419
|
+
:source,
|
|
420
|
+
:assumptions,
|
|
421
|
+
:scope
|
|
422
|
+
) do
|
|
423
|
+
class << self
|
|
424
|
+
alias_method :data_new, :new
|
|
425
|
+
|
|
426
|
+
def new(*values, **attributes)
|
|
427
|
+
compatible_new(*values, **attributes)
|
|
428
|
+
end
|
|
429
|
+
alias_method :[], :new
|
|
430
|
+
|
|
431
|
+
private
|
|
432
|
+
|
|
433
|
+
def compatible_new(*values, **attributes)
|
|
434
|
+
return data_new(*values, **attributes) unless values.length == 5 && attributes.empty?
|
|
435
|
+
|
|
436
|
+
analyzer, kind, weight, details, metadata = values
|
|
437
|
+
data_new(analyzer: analyzer, kind: kind, weight: weight, details: details, metadata: metadata)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
private :data_new, :compatible_new
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def initialize(analyzer:, kind:, weight:, details:, metadata:, evidence_id: nil, producer: nil,
|
|
444
|
+
producer_version: nil, grade: nil, relation: nil, source: nil, assumptions: [], scope: nil)
|
|
445
|
+
raise ArgumentError, 'evidence analyzer must not be empty' if analyzer.to_s.empty?
|
|
446
|
+
raise ArgumentError, 'evidence kind must not be empty' if kind.to_s.empty?
|
|
447
|
+
|
|
448
|
+
weight = Float(weight)
|
|
449
|
+
raise ArgumentError, 'evidence weight must be finite' unless weight.finite?
|
|
450
|
+
|
|
451
|
+
valid_details = details.is_a?(String) && details.bytesize <= EVIDENCE_MAX_DETAILS_BYTES
|
|
452
|
+
raise ArgumentError, 'evidence details must be a bounded string' unless valid_details
|
|
453
|
+
raise ArgumentError, 'evidence metadata must be a mapping' unless metadata.is_a?(Hash)
|
|
454
|
+
|
|
455
|
+
grade = grade.to_sym if grade.respond_to?(:to_sym)
|
|
456
|
+
raise ArgumentError, "invalid evidence grade: #{grade.inspect}" unless grade.nil? || EVIDENCE_GRADES.include?(grade)
|
|
457
|
+
|
|
458
|
+
evidence_id = ModelNormalization.identifier(evidence_id, 'evidence_id') unless evidence_id.nil?
|
|
459
|
+
assumptions = ModelNormalization.string_list(assumptions, 'assumption')
|
|
460
|
+
if grade.nil? && provenance_present?(producer, producer_version, relation, source, assumptions, scope)
|
|
461
|
+
raise ArgumentError, 'evidence grade may be nil only for legacy evidence'
|
|
462
|
+
end
|
|
463
|
+
if grade && [producer, producer_version, relation, source, scope].any?(&:nil?)
|
|
464
|
+
raise ArgumentError, 'graded evidence requires producer, producer_version, relation, source, and scope'
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
validate_bounded_payload!(metadata: metadata, source: source, scope: scope)
|
|
468
|
+
|
|
469
|
+
super
|
|
470
|
+
end
|
|
471
|
+
|
|
31
472
|
def to_h
|
|
32
473
|
{
|
|
33
474
|
'analyzer' => analyzer.to_s,
|
|
34
475
|
'kind' => kind.to_s,
|
|
35
476
|
'weight' => weight,
|
|
36
477
|
'details' => details,
|
|
37
|
-
'metadata' => metadata
|
|
478
|
+
'metadata' => metadata,
|
|
479
|
+
'evidence_id' => evidence_id,
|
|
480
|
+
'producer' => producer&.to_s,
|
|
481
|
+
'producer_version' => producer_version&.to_s,
|
|
482
|
+
'grade' => grade&.to_s,
|
|
483
|
+
'relation' => relation&.to_s,
|
|
484
|
+
'source' => source.respond_to?(:to_h) ? source.to_h : source,
|
|
485
|
+
'assumptions' => assumptions,
|
|
486
|
+
'scope' => scope.respond_to?(:to_h) ? scope.to_h : scope
|
|
38
487
|
}
|
|
39
488
|
end
|
|
489
|
+
|
|
490
|
+
private
|
|
491
|
+
|
|
492
|
+
def validate_bounded_payload!(value)
|
|
493
|
+
BoundedCanonicalizer.dump(
|
|
494
|
+
value,
|
|
495
|
+
max_depth: EVIDENCE_MAX_METADATA_DEPTH,
|
|
496
|
+
max_items: EVIDENCE_MAX_METADATA_ITEMS,
|
|
497
|
+
max_string_bytes: EVIDENCE_MAX_METADATA_STRING_BYTES,
|
|
498
|
+
max_total_bytes: EVIDENCE_MAX_METADATA_BYTES
|
|
499
|
+
)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def provenance_present?(producer, producer_version, relation, source, assumptions, scope)
|
|
503
|
+
[producer, producer_version, relation, source, scope].any? { |value| !value.nil? } || assumptions.any?
|
|
504
|
+
end
|
|
40
505
|
end
|
|
41
506
|
|
|
42
507
|
Edge = Data.define(:caller_id, :callee_id, :evidences) do
|
|
@@ -44,20 +509,77 @@ module Necropsy
|
|
|
44
509
|
{
|
|
45
510
|
'caller_id' => caller_id,
|
|
46
511
|
'callee_id' => callee_id,
|
|
512
|
+
'evidence_ids' => evidences.filter_map(&:evidence_id).sort,
|
|
47
513
|
'evidences' => evidences.map(&:to_h)
|
|
48
514
|
}
|
|
49
515
|
end
|
|
50
516
|
end
|
|
51
517
|
|
|
52
|
-
|
|
518
|
+
EdgeRelation = Data.define(:caller_id, :callee_id, :evidence_ids, :projection) do
|
|
519
|
+
def to_h
|
|
520
|
+
{
|
|
521
|
+
'caller_id' => caller_id,
|
|
522
|
+
'callee_id' => callee_id,
|
|
523
|
+
'evidence_ids' => evidence_ids,
|
|
524
|
+
'projection' => projection.to_s
|
|
525
|
+
}
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
Root = Data.define(:definition_id, :domain, :reason, :evidence) do
|
|
530
|
+
class << self
|
|
531
|
+
alias_method :data_new, :new
|
|
532
|
+
|
|
533
|
+
def new(*values, **attributes)
|
|
534
|
+
return data_new(node_id: values[0], reason: values[1]) if values.length == 2 && attributes.empty?
|
|
535
|
+
|
|
536
|
+
data_new(*values, **attributes)
|
|
537
|
+
end
|
|
538
|
+
alias_method :[], :new
|
|
539
|
+
|
|
540
|
+
private :data_new
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def initialize(reason:, definition_id: nil, node_id: nil, domain: nil, evidence: nil)
|
|
544
|
+
definition_id ||= node_id
|
|
545
|
+
definition_id = ModelNormalization.identifier(definition_id, 'definition_id')
|
|
546
|
+
reason = ModelNormalization.identifier(reason, 'reason').to_sym
|
|
547
|
+
domain ||= reason == :test_suite ? :test : :runtime
|
|
548
|
+
domain = domain.to_sym if domain.respond_to?(:to_sym)
|
|
549
|
+
raise ArgumentError, "invalid root domain: #{domain.inspect}" unless ROOT_DOMAINS.include?(domain)
|
|
550
|
+
|
|
551
|
+
evidence ||= { 'type' => 'entry_point', 'reason' => reason.to_s }
|
|
552
|
+
evidence = evidence.to_h if evidence.respond_to?(:to_h)
|
|
553
|
+
raise ArgumentError, 'root evidence must be a Hash' unless evidence.is_a?(Hash)
|
|
554
|
+
|
|
555
|
+
super(definition_id: definition_id, domain: domain, reason: reason, evidence: evidence.freeze)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
alias_method :node_id, :definition_id
|
|
559
|
+
|
|
53
560
|
def test?
|
|
54
|
-
|
|
561
|
+
domain == :test
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def runtime?
|
|
565
|
+
domain == :runtime
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def external?
|
|
569
|
+
domain == :external
|
|
55
570
|
end
|
|
56
571
|
|
|
57
572
|
def to_h
|
|
58
|
-
{
|
|
573
|
+
{
|
|
574
|
+
'node_id' => node_id,
|
|
575
|
+
'definition_id' => definition_id,
|
|
576
|
+
'domain' => domain.to_s,
|
|
577
|
+
'reason' => reason.to_s,
|
|
578
|
+
'evidence' => evidence
|
|
579
|
+
}
|
|
59
580
|
end
|
|
60
581
|
end
|
|
582
|
+
EntryPoint = Root
|
|
61
583
|
|
|
62
584
|
ClassInfo = Data.define(
|
|
63
585
|
:id,
|
|
@@ -69,8 +591,41 @@ module Necropsy
|
|
|
69
591
|
:includes,
|
|
70
592
|
:prepends,
|
|
71
593
|
:extends,
|
|
594
|
+
:singleton_includes,
|
|
595
|
+
:singleton_prepends,
|
|
72
596
|
:dynamic
|
|
73
597
|
) do
|
|
598
|
+
class << self
|
|
599
|
+
alias_method :data_new, :new
|
|
600
|
+
|
|
601
|
+
def new(*values, **attributes)
|
|
602
|
+
return data_new(*values, **attributes) unless values.length == 10 && attributes.empty?
|
|
603
|
+
|
|
604
|
+
id, kind, file, line, superclass, superclass_candidates, includes, prepends, extends, dynamic = values
|
|
605
|
+
data_new(
|
|
606
|
+
id: id,
|
|
607
|
+
kind: kind,
|
|
608
|
+
file: file,
|
|
609
|
+
line: line,
|
|
610
|
+
superclass: superclass,
|
|
611
|
+
superclass_candidates: superclass_candidates,
|
|
612
|
+
includes: includes,
|
|
613
|
+
prepends: prepends,
|
|
614
|
+
extends: extends,
|
|
615
|
+
dynamic: dynamic
|
|
616
|
+
)
|
|
617
|
+
end
|
|
618
|
+
alias_method :[], :new
|
|
619
|
+
|
|
620
|
+
private :data_new
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def initialize(id:, kind:, file:, line:, superclass:, superclass_candidates:, includes:, prepends:, extends:,
|
|
624
|
+
dynamic:, singleton_includes: [], singleton_prepends: [])
|
|
625
|
+
singleton_includes = extends if singleton_includes.empty? && !extends.empty?
|
|
626
|
+
super
|
|
627
|
+
end
|
|
628
|
+
|
|
74
629
|
def to_h
|
|
75
630
|
{
|
|
76
631
|
'id' => id,
|
|
@@ -82,12 +637,15 @@ module Necropsy
|
|
|
82
637
|
'includes' => includes,
|
|
83
638
|
'prepends' => prepends,
|
|
84
639
|
'extends' => extends,
|
|
640
|
+
'singleton_includes' => singleton_includes,
|
|
641
|
+
'singleton_prepends' => singleton_prepends,
|
|
85
642
|
'dynamic' => dynamic
|
|
86
643
|
}
|
|
87
644
|
end
|
|
88
645
|
end
|
|
89
646
|
|
|
90
647
|
CallSite = Data.define(
|
|
648
|
+
:call_site_id,
|
|
91
649
|
:caller_id,
|
|
92
650
|
:message,
|
|
93
651
|
:receiver_kind,
|
|
@@ -98,9 +656,52 @@ module Necropsy
|
|
|
98
656
|
:dynamic,
|
|
99
657
|
:metadata
|
|
100
658
|
) do
|
|
659
|
+
class << self
|
|
660
|
+
alias_method :data_new, :new
|
|
661
|
+
|
|
662
|
+
def new(*values, **attributes)
|
|
663
|
+
compatible_new(*values, **attributes)
|
|
664
|
+
end
|
|
665
|
+
alias_method :[], :new
|
|
666
|
+
|
|
667
|
+
private
|
|
668
|
+
|
|
669
|
+
def compatible_new(*values, **attributes)
|
|
670
|
+
return data_new(*values, **attributes) unless values.length == 9 && attributes.empty?
|
|
671
|
+
|
|
672
|
+
caller_id, message, receiver_kind, receiver_name, file, line, test, dynamic, metadata = values
|
|
673
|
+
data_new(
|
|
674
|
+
caller_id: caller_id, message: message, receiver_kind: receiver_kind, receiver_name: receiver_name,
|
|
675
|
+
file: file, line: line, test: test, dynamic: dynamic, metadata: metadata
|
|
676
|
+
)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
private :data_new, :compatible_new
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def initialize(caller_id:, message:, receiver_kind:, receiver_name:, file:, line:, test:, dynamic:, metadata:,
|
|
683
|
+
call_site_id: nil)
|
|
684
|
+
call_site_id ||= CallSiteIdentity.legacy_id(
|
|
685
|
+
caller_definition_id: caller_id,
|
|
686
|
+
message: message,
|
|
687
|
+
receiver_kind: receiver_kind,
|
|
688
|
+
receiver_name: receiver_name,
|
|
689
|
+
file: file,
|
|
690
|
+
line: line,
|
|
691
|
+
test: test,
|
|
692
|
+
dynamic: dynamic,
|
|
693
|
+
metadata: metadata
|
|
694
|
+
)
|
|
695
|
+
super
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
alias_method :caller_definition_id, :caller_id
|
|
699
|
+
|
|
101
700
|
def to_h
|
|
102
701
|
{
|
|
702
|
+
'call_site_id' => call_site_id,
|
|
103
703
|
'caller_id' => caller_id,
|
|
704
|
+
'caller_definition_id' => caller_definition_id,
|
|
104
705
|
'message' => message,
|
|
105
706
|
'receiver_kind' => receiver_kind.to_s,
|
|
106
707
|
'receiver_name' => receiver_name,
|
|
@@ -113,13 +714,133 @@ module Necropsy
|
|
|
113
714
|
end
|
|
114
715
|
end
|
|
115
716
|
|
|
116
|
-
|
|
717
|
+
VALUE_FACT_KINDS = %i[
|
|
718
|
+
class_object instance_types symbol_set string_set callable_set container nil boolean unknown
|
|
719
|
+
].freeze
|
|
720
|
+
|
|
721
|
+
ValueFact = Data.define(:kind, :values, :exact, :nilable, :origin, :summary) do
|
|
722
|
+
def initialize(kind:, values: [], exact: true, nilable: false, origin: nil, summary: nil)
|
|
723
|
+
kind = kind.to_sym
|
|
724
|
+
raise ArgumentError, "invalid value fact kind: #{kind.inspect}" unless VALUE_FACT_KINDS.include?(kind)
|
|
725
|
+
|
|
726
|
+
values = Array(values).map(&:to_s).uniq.sort.freeze
|
|
727
|
+
exact = (exact == true)
|
|
728
|
+
nilable = (nilable == true)
|
|
729
|
+
origin = origin&.to_s
|
|
730
|
+
super
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def self.instance_types(types, origin: :direct_constructor, nilable: false)
|
|
734
|
+
new(kind: :instance_types, values: types, exact: true, nilable: nilable, origin: origin)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def self.unknown(origin = :unsupported)
|
|
738
|
+
new(kind: :unknown, exact: false, origin: origin)
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def self.from_h(attributes)
|
|
742
|
+
data = attributes.transform_keys(&:to_s)
|
|
743
|
+
new(
|
|
744
|
+
kind: data.fetch('kind'),
|
|
745
|
+
values: data.fetch('values', []),
|
|
746
|
+
exact: data.fetch('exact', false),
|
|
747
|
+
nilable: data.fetch('nilable', false),
|
|
748
|
+
origin: data['origin'],
|
|
749
|
+
summary: data['summary']
|
|
750
|
+
)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def exact_instance_types?
|
|
754
|
+
kind == :instance_types && exact && !values.empty?
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def to_h
|
|
758
|
+
{
|
|
759
|
+
'kind' => kind.to_s,
|
|
760
|
+
'values' => values,
|
|
761
|
+
'exact' => exact,
|
|
762
|
+
'nilable' => nilable,
|
|
763
|
+
'origin' => origin,
|
|
764
|
+
'summary' => summary
|
|
765
|
+
}
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
FlowResult = Data.define(:receiver_facts, :value_facts, :return_fact, :issues, :steps) do
|
|
770
|
+
def initialize(receiver_facts: {}, value_facts: {}, return_fact: ValueFact.unknown(:no_direct_return), issues: [], steps: 0)
|
|
771
|
+
receiver_facts = receiver_facts.dup.compare_by_identity.freeze
|
|
772
|
+
value_facts = value_facts.dup.compare_by_identity.freeze
|
|
773
|
+
issues = Array(issues).map(&:to_s).uniq.sort.freeze
|
|
774
|
+
steps = Integer(steps)
|
|
775
|
+
super
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def fact_for(node)
|
|
779
|
+
value_facts[node] || receiver_facts[node]
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def to_h
|
|
783
|
+
{
|
|
784
|
+
'receiver_facts' => receiver_facts.values.map(&:to_h),
|
|
785
|
+
'value_fact_count' => value_facts.length,
|
|
786
|
+
'return_fact' => return_fact.to_h,
|
|
787
|
+
'issues' => issues,
|
|
788
|
+
'steps' => steps
|
|
789
|
+
}
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
AnalyzerProfile = Data.define(:name, :kind, :soundness, :description, :version, :assumptions) do
|
|
794
|
+
class << self
|
|
795
|
+
alias_method :data_new, :new
|
|
796
|
+
|
|
797
|
+
def new(*values, **attributes)
|
|
798
|
+
compatible_new(*values, **attributes)
|
|
799
|
+
end
|
|
800
|
+
alias_method :[], :new
|
|
801
|
+
|
|
802
|
+
private
|
|
803
|
+
|
|
804
|
+
def compatible_new(*values, **attributes)
|
|
805
|
+
return data_new(*values, **attributes) unless values.length == 4 && attributes.empty?
|
|
806
|
+
|
|
807
|
+
name, kind, soundness, description = values
|
|
808
|
+
data_new(name: name, kind: kind, soundness: soundness, description: description)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
private :data_new, :compatible_new
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def initialize(name:, kind:, soundness:, description:, version: nil, assumptions: [])
|
|
815
|
+
ModelNormalization.identifier(name, 'analyzer profile name')
|
|
816
|
+
kind = kind.to_sym if kind.respond_to?(:to_sym)
|
|
817
|
+
soundness = soundness.to_sym if soundness.respond_to?(:to_sym)
|
|
818
|
+
raise ArgumentError, "invalid analyzer kind: #{kind.inspect}" unless ANALYZER_KINDS.include?(kind)
|
|
819
|
+
raise ArgumentError, "invalid analyzer soundness: #{soundness.inspect}" unless ANALYZER_SOUNDNESS.include?(soundness)
|
|
820
|
+
unless description.is_a?(String) && !description.empty? && description.bytesize <= PROFILE_MAX_DESCRIPTION_BYTES
|
|
821
|
+
raise ArgumentError, 'analyzer description must be a bounded non-empty string'
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
version = ModelNormalization.identifier(version, 'version') if version
|
|
825
|
+
raise ArgumentError, 'analyzer version is too long' if version&.bytesize.to_i > PROFILE_MAX_VERSION_BYTES
|
|
826
|
+
|
|
827
|
+
assumptions = ModelNormalization.string_list(assumptions, 'assumption')
|
|
828
|
+
if assumptions.length > PROFILE_MAX_ASSUMPTIONS ||
|
|
829
|
+
assumptions.any? { |assumption| assumption.bytesize > PROFILE_MAX_ASSUMPTION_BYTES }
|
|
830
|
+
raise ArgumentError, 'analyzer assumptions must be bounded'
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
super
|
|
834
|
+
end
|
|
835
|
+
|
|
117
836
|
def to_h
|
|
118
837
|
{
|
|
119
838
|
'name' => name.to_s,
|
|
120
839
|
'kind' => kind.to_s,
|
|
121
840
|
'soundness' => soundness.to_s,
|
|
122
|
-
'description' => description
|
|
841
|
+
'description' => description,
|
|
842
|
+
'version' => version,
|
|
843
|
+
'assumptions' => assumptions
|
|
123
844
|
}
|
|
124
845
|
end
|
|
125
846
|
end
|
|
@@ -127,37 +848,189 @@ module Necropsy
|
|
|
127
848
|
EdgeEvidence = Data.define(:caller_id, :callee_id, :evidence)
|
|
128
849
|
AliveEvidence = Data.define(:node_id, :evidence)
|
|
129
850
|
|
|
130
|
-
|
|
851
|
+
SourceError = Data.define(:file, :line, :message, :type) do
|
|
852
|
+
def to_h
|
|
853
|
+
{
|
|
854
|
+
'file' => file,
|
|
855
|
+
'line' => line,
|
|
856
|
+
'message' => message,
|
|
857
|
+
'type' => type.to_s
|
|
858
|
+
}
|
|
859
|
+
end
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
AnalysisHealth = Data.define(:status, :reasons) do
|
|
863
|
+
def self.from_reasons(reasons)
|
|
864
|
+
normalized = Array(reasons).map { |reason| normalize_reason(reason) }
|
|
865
|
+
status = normalized.any? { |reason| reason.fetch('severity') == 'invalid' } ? :invalid : :degraded
|
|
866
|
+
status = :complete if normalized.empty?
|
|
867
|
+
new(status: status || :complete, reasons: normalized)
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def self.complete
|
|
871
|
+
new(status: :complete, reasons: [])
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def self.normalize_reason(reason)
|
|
875
|
+
normalized = reason.to_h.transform_keys(&:to_s)
|
|
876
|
+
severity = normalized.fetch('severity').to_sym
|
|
877
|
+
raise ArgumentError, "invalid analysis health severity: #{severity}" unless %i[degraded invalid].include?(severity)
|
|
878
|
+
raise ArgumentError, 'analysis health reason requires a code' if normalized.fetch('code', '').to_s.empty?
|
|
879
|
+
|
|
880
|
+
normalized.merge('severity' => severity.to_s)
|
|
881
|
+
end
|
|
882
|
+
private_class_method :normalize_reason
|
|
883
|
+
|
|
884
|
+
def initialize(status:, reasons: [])
|
|
885
|
+
normalized_status = status.to_sym
|
|
886
|
+
raise ArgumentError, "invalid analysis health status: #{status}" unless %i[complete degraded invalid].include?(normalized_status)
|
|
887
|
+
|
|
888
|
+
super(status: normalized_status, reasons: reasons.freeze)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def complete?
|
|
892
|
+
status == :complete
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def to_h
|
|
896
|
+
{ 'status' => status.to_s, 'reasons' => reasons }
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
Blocker = Data.define(:kind, :scope_kind, :scope_value, :source, :reason, :suggested_action, :metadata) do
|
|
901
|
+
def initialize(kind:, scope_kind:, scope_value:, source:, reason:, suggested_action: :review, metadata: {})
|
|
902
|
+
super
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def message
|
|
906
|
+
metadata['message'] || metadata[:message] || (scope_value if %i[message symbol].include?(scope_kind.to_sym))
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def caller_domain
|
|
910
|
+
(metadata['caller_domain'] || metadata[:caller_domain] || :runtime).to_sym
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def to_h
|
|
914
|
+
{
|
|
915
|
+
'kind' => kind.to_s,
|
|
916
|
+
'scope_kind' => scope_kind.to_s,
|
|
917
|
+
'scope_value' => scope_value,
|
|
918
|
+
'source' => source.respond_to?(:to_h) ? source.to_h : source.to_s,
|
|
919
|
+
'reason' => reason,
|
|
920
|
+
'suggested_action' => suggested_action.to_s,
|
|
921
|
+
'metadata' => metadata
|
|
922
|
+
}
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
ScoreComponent = Data.define(:name, :value, :details) do
|
|
927
|
+
def to_h
|
|
928
|
+
{ 'name' => name, 'value' => value, 'details' => details }
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
AnalyzerResult = Data.define(
|
|
933
|
+
:edge_evidences,
|
|
934
|
+
:alive_evidences,
|
|
935
|
+
:uncertainties,
|
|
936
|
+
:observation,
|
|
937
|
+
:blockers,
|
|
938
|
+
:resolutions,
|
|
939
|
+
:evidences,
|
|
940
|
+
:derived_call_sites
|
|
941
|
+
) do
|
|
942
|
+
class << self
|
|
943
|
+
alias_method :data_new, :new
|
|
944
|
+
|
|
945
|
+
def new(*values, **attributes)
|
|
946
|
+
compatible_new(*values, **attributes)
|
|
947
|
+
end
|
|
948
|
+
alias_method :[], :new
|
|
949
|
+
|
|
950
|
+
private
|
|
951
|
+
|
|
952
|
+
def compatible_new(*values, **attributes)
|
|
953
|
+
return data_new(*values, []) if attributes.empty? && values.length == 7
|
|
954
|
+
|
|
955
|
+
return data_new(*values, **attributes) unless attributes.empty? && [4, 5].include?(values.length)
|
|
956
|
+
|
|
957
|
+
edge_evidences, alive_evidences, uncertainties, observation, blockers = values
|
|
958
|
+
blockers = [] if values.length == 4
|
|
959
|
+
data_new(edge_evidences: edge_evidences, alive_evidences: alive_evidences, uncertainties: uncertainties, observation: observation,
|
|
960
|
+
blockers: blockers)
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
private :data_new, :compatible_new
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def initialize(edge_evidences:, alive_evidences:, uncertainties:, observation:, blockers: [], resolutions: nil,
|
|
967
|
+
evidences: [], derived_call_sites: [])
|
|
968
|
+
resolutions = normalize_resolutions(resolutions) unless resolutions.nil?
|
|
969
|
+
super
|
|
970
|
+
end
|
|
971
|
+
|
|
131
972
|
def self.empty
|
|
132
|
-
new(
|
|
973
|
+
new(
|
|
974
|
+
edge_evidences: [], alive_evidences: [], uncertainties: {}, observation: {}, blockers: [],
|
|
975
|
+
resolutions: [], evidences: [], derived_call_sites: []
|
|
976
|
+
)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
private
|
|
980
|
+
|
|
981
|
+
def normalize_resolutions(values)
|
|
982
|
+
ModelNormalization.list(values).map do |value|
|
|
983
|
+
normalize_resolution_record(value)
|
|
984
|
+
end.sort_by { |record| ModelNormalization.canonical(record.to_h) }.freeze
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def normalize_resolution_record(value)
|
|
988
|
+
return value if value.is_a?(ResolutionRecord)
|
|
989
|
+
return legacy_resolution_record(value) if value.is_a?(Resolution)
|
|
990
|
+
|
|
991
|
+
data = ModelNormalization.attributes(value, 'AnalyzerResult resolution')
|
|
992
|
+
return ResolutionRecord.from_h(data) if data.key?('resolution')
|
|
993
|
+
|
|
994
|
+
legacy_resolution_record(Resolution.from_h(data))
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
def legacy_resolution_record(resolution)
|
|
998
|
+
ResolutionRecord.new(resolution: resolution, producer: 'legacy')
|
|
133
999
|
end
|
|
134
1000
|
end
|
|
135
1001
|
|
|
136
|
-
Finding = Data.define(:node, :classification, :confidence, :score, :reasons, :evidences) do
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
high: 2,
|
|
141
|
-
certain: 3
|
|
142
|
-
}.freeze
|
|
1002
|
+
Finding = Data.define(:node, :classification, :confidence, :score, :score_components, :reasons, :evidences, :blockers) do
|
|
1003
|
+
def initialize(node:, classification:, confidence:, score:, score_components:, reasons:, evidences:, blockers: [])
|
|
1004
|
+
super
|
|
1005
|
+
end
|
|
143
1006
|
|
|
144
1007
|
def at_least?(level)
|
|
145
|
-
|
|
1008
|
+
CONFIDENCE_LEVELS.fetch(confidence) >= CONFIDENCE_LEVELS.fetch(level)
|
|
146
1009
|
end
|
|
147
1010
|
|
|
148
1011
|
def fingerprint
|
|
149
1012
|
node.fingerprint(classification)
|
|
150
1013
|
end
|
|
151
1014
|
|
|
1015
|
+
alias_method :logical_fingerprint, :fingerprint
|
|
1016
|
+
|
|
1017
|
+
def physical_fingerprint
|
|
1018
|
+
node.physical_fingerprint(classification)
|
|
1019
|
+
end
|
|
1020
|
+
|
|
152
1021
|
def to_h
|
|
153
1022
|
{
|
|
154
1023
|
'fingerprint' => fingerprint,
|
|
1024
|
+
'logical_fingerprint' => logical_fingerprint,
|
|
1025
|
+
'physical_fingerprint' => physical_fingerprint,
|
|
155
1026
|
'classification' => classification.to_s,
|
|
156
1027
|
'confidence' => confidence.to_s,
|
|
157
1028
|
'score' => score,
|
|
1029
|
+
'score_components' => score_components.map(&:to_h),
|
|
158
1030
|
'node' => node.to_h,
|
|
159
1031
|
'reasons' => reasons,
|
|
160
|
-
'evidences' => evidences.map(&:to_h)
|
|
1032
|
+
'evidences' => evidences.map(&:to_h),
|
|
1033
|
+
'blockers' => blockers.map(&:to_h)
|
|
161
1034
|
}
|
|
162
1035
|
end
|
|
163
1036
|
end
|