necropsy 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- metadata +85 -1
data/lib/necropsy/models.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 = {
|
|
@@ -10,18 +11,345 @@ module Necropsy
|
|
|
10
11
|
certain: 3
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
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
|
+
|
|
14
328
|
def method?
|
|
15
329
|
kind != :block_entry
|
|
16
330
|
end
|
|
17
331
|
|
|
332
|
+
def graph_id
|
|
333
|
+
definition_id
|
|
334
|
+
end
|
|
335
|
+
|
|
18
336
|
def fingerprint(classification)
|
|
19
|
-
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}")
|
|
20
344
|
end
|
|
21
345
|
|
|
22
346
|
def to_h
|
|
23
347
|
{
|
|
24
348
|
'id' => id,
|
|
349
|
+
'symbol_id' => symbol_id,
|
|
350
|
+
'definition_id' => definition_id,
|
|
351
|
+
'body_digest' => body_digest,
|
|
352
|
+
'ordinal' => ordinal,
|
|
25
353
|
'kind' => kind.to_s,
|
|
26
354
|
'file' => file,
|
|
27
355
|
'line' => line,
|
|
@@ -35,16 +363,145 @@ module Necropsy
|
|
|
35
363
|
end
|
|
36
364
|
end
|
|
37
365
|
|
|
38
|
-
|
|
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
|
+
|
|
39
472
|
def to_h
|
|
40
473
|
{
|
|
41
474
|
'analyzer' => analyzer.to_s,
|
|
42
475
|
'kind' => kind.to_s,
|
|
43
476
|
'weight' => weight,
|
|
44
477
|
'details' => details,
|
|
45
|
-
'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
|
|
46
487
|
}
|
|
47
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
|
|
48
505
|
end
|
|
49
506
|
|
|
50
507
|
Edge = Data.define(:caller_id, :callee_id, :evidences) do
|
|
@@ -52,20 +509,77 @@ module Necropsy
|
|
|
52
509
|
{
|
|
53
510
|
'caller_id' => caller_id,
|
|
54
511
|
'callee_id' => callee_id,
|
|
512
|
+
'evidence_ids' => evidences.filter_map(&:evidence_id).sort,
|
|
55
513
|
'evidences' => evidences.map(&:to_h)
|
|
56
514
|
}
|
|
57
515
|
end
|
|
58
516
|
end
|
|
59
517
|
|
|
60
|
-
|
|
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
|
+
|
|
61
560
|
def test?
|
|
62
|
-
|
|
561
|
+
domain == :test
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def runtime?
|
|
565
|
+
domain == :runtime
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def external?
|
|
569
|
+
domain == :external
|
|
63
570
|
end
|
|
64
571
|
|
|
65
572
|
def to_h
|
|
66
|
-
{
|
|
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
|
+
}
|
|
67
580
|
end
|
|
68
581
|
end
|
|
582
|
+
EntryPoint = Root
|
|
69
583
|
|
|
70
584
|
ClassInfo = Data.define(
|
|
71
585
|
:id,
|
|
@@ -77,8 +591,41 @@ module Necropsy
|
|
|
77
591
|
:includes,
|
|
78
592
|
:prepends,
|
|
79
593
|
:extends,
|
|
594
|
+
:singleton_includes,
|
|
595
|
+
:singleton_prepends,
|
|
80
596
|
:dynamic
|
|
81
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
|
+
|
|
82
629
|
def to_h
|
|
83
630
|
{
|
|
84
631
|
'id' => id,
|
|
@@ -90,12 +637,15 @@ module Necropsy
|
|
|
90
637
|
'includes' => includes,
|
|
91
638
|
'prepends' => prepends,
|
|
92
639
|
'extends' => extends,
|
|
640
|
+
'singleton_includes' => singleton_includes,
|
|
641
|
+
'singleton_prepends' => singleton_prepends,
|
|
93
642
|
'dynamic' => dynamic
|
|
94
643
|
}
|
|
95
644
|
end
|
|
96
645
|
end
|
|
97
646
|
|
|
98
647
|
CallSite = Data.define(
|
|
648
|
+
:call_site_id,
|
|
99
649
|
:caller_id,
|
|
100
650
|
:message,
|
|
101
651
|
:receiver_kind,
|
|
@@ -106,9 +656,52 @@ module Necropsy
|
|
|
106
656
|
:dynamic,
|
|
107
657
|
:metadata
|
|
108
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
|
+
|
|
109
700
|
def to_h
|
|
110
701
|
{
|
|
702
|
+
'call_site_id' => call_site_id,
|
|
111
703
|
'caller_id' => caller_id,
|
|
704
|
+
'caller_definition_id' => caller_definition_id,
|
|
112
705
|
'message' => message,
|
|
113
706
|
'receiver_kind' => receiver_kind.to_s,
|
|
114
707
|
'receiver_name' => receiver_name,
|
|
@@ -121,13 +714,133 @@ module Necropsy
|
|
|
121
714
|
end
|
|
122
715
|
end
|
|
123
716
|
|
|
124
|
-
|
|
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
|
+
|
|
125
836
|
def to_h
|
|
126
837
|
{
|
|
127
838
|
'name' => name.to_s,
|
|
128
839
|
'kind' => kind.to_s,
|
|
129
840
|
'soundness' => soundness.to_s,
|
|
130
|
-
'description' => description
|
|
841
|
+
'description' => description,
|
|
842
|
+
'version' => version,
|
|
843
|
+
'assumptions' => assumptions
|
|
131
844
|
}
|
|
132
845
|
end
|
|
133
846
|
end
|
|
@@ -135,19 +848,162 @@ module Necropsy
|
|
|
135
848
|
EdgeEvidence = Data.define(:caller_id, :callee_id, :evidence)
|
|
136
849
|
AliveEvidence = Data.define(:node_id, :evidence)
|
|
137
850
|
|
|
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
|
+
|
|
138
926
|
ScoreComponent = Data.define(:name, :value, :details) do
|
|
139
927
|
def to_h
|
|
140
928
|
{ 'name' => name, 'value' => value, 'details' => details }
|
|
141
929
|
end
|
|
142
930
|
end
|
|
143
931
|
|
|
144
|
-
AnalyzerResult = Data.define(
|
|
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
|
+
|
|
145
972
|
def self.empty
|
|
146
|
-
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')
|
|
147
999
|
end
|
|
148
1000
|
end
|
|
149
1001
|
|
|
150
|
-
Finding = Data.define(:node, :classification, :confidence, :score, :score_components, :reasons, :evidences) do
|
|
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
|
|
1006
|
+
|
|
151
1007
|
def at_least?(level)
|
|
152
1008
|
CONFIDENCE_LEVELS.fetch(confidence) >= CONFIDENCE_LEVELS.fetch(level)
|
|
153
1009
|
end
|
|
@@ -156,16 +1012,25 @@ module Necropsy
|
|
|
156
1012
|
node.fingerprint(classification)
|
|
157
1013
|
end
|
|
158
1014
|
|
|
1015
|
+
alias_method :logical_fingerprint, :fingerprint
|
|
1016
|
+
|
|
1017
|
+
def physical_fingerprint
|
|
1018
|
+
node.physical_fingerprint(classification)
|
|
1019
|
+
end
|
|
1020
|
+
|
|
159
1021
|
def to_h
|
|
160
1022
|
{
|
|
161
1023
|
'fingerprint' => fingerprint,
|
|
1024
|
+
'logical_fingerprint' => logical_fingerprint,
|
|
1025
|
+
'physical_fingerprint' => physical_fingerprint,
|
|
162
1026
|
'classification' => classification.to_s,
|
|
163
1027
|
'confidence' => confidence.to_s,
|
|
164
1028
|
'score' => score,
|
|
165
1029
|
'score_components' => score_components.map(&:to_h),
|
|
166
1030
|
'node' => node.to_h,
|
|
167
1031
|
'reasons' => reasons,
|
|
168
|
-
'evidences' => evidences.map(&:to_h)
|
|
1032
|
+
'evidences' => evidences.map(&:to_h),
|
|
1033
|
+
'blockers' => blockers.map(&:to_h)
|
|
169
1034
|
}
|
|
170
1035
|
end
|
|
171
1036
|
end
|