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
|
@@ -10,7 +10,7 @@ module Necropsy
|
|
|
10
10
|
const_missing method_missing respond_to_missing?
|
|
11
11
|
].freeze
|
|
12
12
|
RUBY_PROTOCOLS = %w[
|
|
13
|
-
== eql? hash <=> to_s to_str to_a to_h to_proc inspect
|
|
13
|
+
== eql? hash <=> to_s to_str to_a to_h to_proc inspect coerce
|
|
14
14
|
succ initialize_copy marshal_dump marshal_load
|
|
15
15
|
].freeze
|
|
16
16
|
BUILTIN_IMPLICIT_CALLERS = [
|
|
@@ -21,21 +21,25 @@ module Necropsy
|
|
|
21
21
|
}
|
|
22
22
|
].freeze
|
|
23
23
|
|
|
24
|
-
def initialize(graph:, reachability:, project:)
|
|
24
|
+
def initialize(graph:, reachability:, project:, clock: Clock.new)
|
|
25
25
|
@graph = graph
|
|
26
26
|
@reachability = reachability
|
|
27
27
|
@project = project
|
|
28
|
+
@clock = clock
|
|
28
29
|
@source_lines = {}
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def findings
|
|
32
33
|
graph.method_nodes.filter_map do |node|
|
|
33
34
|
next if node.test
|
|
35
|
+
next unless graph.analyze_source?(node.file)
|
|
36
|
+
next if rails_generated?(node)
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
blockers = graph.matching_blockers(node)
|
|
39
|
+
classification = classification_for(node, blockers)
|
|
36
40
|
next unless classification
|
|
37
41
|
|
|
38
|
-
score, level, reasons, score_components = score_for(node, classification)
|
|
42
|
+
score, level, reasons, score_components = score_for(node, classification, blockers)
|
|
39
43
|
Finding.new(
|
|
40
44
|
node: node,
|
|
41
45
|
classification: classification,
|
|
@@ -43,35 +47,41 @@ module Necropsy
|
|
|
43
47
|
score: score,
|
|
44
48
|
score_components: score_components,
|
|
45
49
|
reasons: reasons,
|
|
46
|
-
evidences: graph.incoming_edges(node.
|
|
50
|
+
evidences: graph.incoming_edges(node.graph_id).flat_map(&:evidences) + graph.alive_evidences(node.graph_id),
|
|
51
|
+
blockers: blockers
|
|
47
52
|
)
|
|
48
53
|
end
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
private
|
|
52
57
|
|
|
53
|
-
attr_reader :graph, :reachability, :project
|
|
58
|
+
attr_reader :graph, :reachability, :project, :clock
|
|
54
59
|
|
|
55
|
-
def classification_for(node)
|
|
56
|
-
return nil if graph.dynamic_alive?(node.
|
|
57
|
-
|
|
58
|
-
if reachability.runtime_paths.key?(node.id)
|
|
59
|
-
return :unused if graph.dynamic_enabled? && !generated_accessor?(node)
|
|
60
|
+
def classification_for(node, blockers)
|
|
61
|
+
return nil if graph.dynamic_alive?(node.graph_id)
|
|
60
62
|
|
|
63
|
+
if reachability.runtime_paths.key?(node.graph_id) || reachability.external_paths.key?(node.graph_id)
|
|
61
64
|
nil
|
|
62
|
-
elsif
|
|
65
|
+
elsif blockers.any?
|
|
66
|
+
:blocked
|
|
67
|
+
elsif reachability.test_paths.key?(node.graph_id)
|
|
63
68
|
:test_only_reachable
|
|
64
69
|
else
|
|
65
70
|
:unreachable
|
|
66
71
|
end
|
|
67
72
|
end
|
|
68
73
|
|
|
69
|
-
def score_for(node, classification)
|
|
74
|
+
def score_for(node, classification, blockers)
|
|
70
75
|
reasons = []
|
|
71
76
|
score = base_score(classification)
|
|
72
77
|
components = [score_component("base(#{classification})", score, 'Base classification score')]
|
|
73
78
|
|
|
74
|
-
if
|
|
79
|
+
if blockers.any?
|
|
80
|
+
components << score_component('matching_blocker', 0.0, 'Incomplete runtime dispatch matches this definition')
|
|
81
|
+
reasons << "Blocked by #{blockers.length} unresolved runtime dispatch#{'es' unless blockers.one?}."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if graph.uncertainties(node.graph_id).any?
|
|
75
85
|
score -= 0.35
|
|
76
86
|
components << score_component('near_metaprogramming', -0.35, 'Unresolved metaprogramming nearby')
|
|
77
87
|
reasons << 'Lowered because this node is near unresolved metaprogramming.'
|
|
@@ -95,29 +105,9 @@ module Necropsy
|
|
|
95
105
|
reasons << "Lowered because #{reason}."
|
|
96
106
|
end
|
|
97
107
|
|
|
98
|
-
|
|
99
|
-
score += 0.25
|
|
100
|
-
components << score_component('absent_from_dynamic', 0.25, 'Absent from dynamic observations')
|
|
101
|
-
reasons << 'Static unreachable and absent from dynamic observations.'
|
|
102
|
-
end
|
|
108
|
+
add_runtime_unobserved_annotation(node, reasons, components)
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
raised_score = [score, 0.95].max
|
|
106
|
-
components << score_component('expired_quarantine', raised_score - score, 'Expired without alive evidence')
|
|
107
|
-
score = raised_score
|
|
108
|
-
reasons << 'Raised because quarantine annotation has expired without alive evidence.'
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
if classification == :unused
|
|
112
|
-
days = observation_days
|
|
113
|
-
if days && days < project.config.min_observation_days
|
|
114
|
-
score -= 0.3
|
|
115
|
-
components << score_component('short_observation', -0.3, "Observed for only #{days} days")
|
|
116
|
-
reasons << "Observation window is #{days} days, below the configured minimum."
|
|
117
|
-
else
|
|
118
|
-
reasons << 'Reachable statically but never observed dynamically.'
|
|
119
|
-
end
|
|
120
|
-
end
|
|
110
|
+
add_quarantine_annotation(node, classification, reasons, components)
|
|
121
111
|
|
|
122
112
|
clamped_score = score.clamp(0.0, 1.0)
|
|
123
113
|
components << score_component('score_clamp', clamped_score - score, 'Clamped to the 0.0–1.0 range') if clamped_score != score
|
|
@@ -128,7 +118,17 @@ module Necropsy
|
|
|
128
118
|
ScoreComponent.new(name: name, value: value.round(4), details: details)
|
|
129
119
|
end
|
|
130
120
|
|
|
121
|
+
def add_runtime_unobserved_annotation(node, reasons, components)
|
|
122
|
+
return unless graph.dynamic_observation?
|
|
123
|
+
return if graph.dynamic_alive?(node.graph_id)
|
|
124
|
+
|
|
125
|
+
components << score_component('runtime_unobserved', 0.0, 'Informational only; deadness is unchanged')
|
|
126
|
+
reasons << 'Runtime observations did not include this definition; classification and confidence are unchanged.'
|
|
127
|
+
end
|
|
128
|
+
|
|
131
129
|
def base_score(classification)
|
|
130
|
+
return 0.25 if classification == :blocked
|
|
131
|
+
|
|
132
132
|
case classification
|
|
133
133
|
when :unreachable
|
|
134
134
|
0.62
|
|
@@ -145,6 +145,10 @@ module Necropsy
|
|
|
145
145
|
%i[attr_reader attr_writer attr_accessor struct_new data_define].include?(node.defined_via)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
def rails_generated?(node)
|
|
149
|
+
node.defined_via.to_s.start_with?('rails_')
|
|
150
|
+
end
|
|
151
|
+
|
|
148
152
|
def implicit_call_reason(node)
|
|
149
153
|
return "#{node.name} is a Ruby hook invoked by the VM" if RUBY_HOOKS.include?(node.name)
|
|
150
154
|
return "#{node.name} is a Ruby protocol method invoked implicitly" if RUBY_PROTOCOLS.include?(node.name)
|
|
@@ -161,18 +165,20 @@ module Necropsy
|
|
|
161
165
|
direct_reason = implicit_call_reason(node)
|
|
162
166
|
return direct_reason if direct_reason
|
|
163
167
|
|
|
164
|
-
root_id = implicit_paths[node.
|
|
168
|
+
root_id = implicit_paths[node.graph_id]
|
|
165
169
|
"it is reachable from implicitly invoked #{root_id}" if root_id
|
|
166
170
|
end
|
|
167
171
|
|
|
168
172
|
def implicit_paths
|
|
169
173
|
@implicit_paths ||= begin
|
|
170
|
-
roots = graph.method_nodes.reject(&:test).select { |node| implicit_call_reason(node) }.map(&:
|
|
174
|
+
roots = graph.method_nodes.reject(&:test).select { |node| implicit_call_reason(node) }.map(&:graph_id)
|
|
171
175
|
paths = roots.to_h { |node_id| [node_id, node_id] }
|
|
172
176
|
queue = roots.dup
|
|
177
|
+
head = 0
|
|
173
178
|
|
|
174
|
-
|
|
175
|
-
node_id = queue.
|
|
179
|
+
while head < queue.length
|
|
180
|
+
node_id = queue.fetch(head)
|
|
181
|
+
head += 1
|
|
176
182
|
graph.edges_from(node_id).each_key do |callee_id|
|
|
177
183
|
next if paths.key?(callee_id)
|
|
178
184
|
|
|
@@ -203,32 +209,71 @@ module Necropsy
|
|
|
203
209
|
:low
|
|
204
210
|
end
|
|
205
211
|
|
|
206
|
-
def
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
value['days'] || value[:days] || value['observation_days'] || value[:observation_days]
|
|
211
|
-
end.map(&:to_i).max
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def quarantine_expired?(node)
|
|
215
|
-
since = quarantine_since(node)
|
|
216
|
-
return false unless since
|
|
212
|
+
def add_quarantine_annotation(node, classification, reasons, components)
|
|
213
|
+
status = quarantine_status(node, classification)
|
|
214
|
+
return unless status
|
|
217
215
|
|
|
218
|
-
|
|
216
|
+
case status
|
|
217
|
+
when :review_required
|
|
218
|
+
components << score_component(
|
|
219
|
+
'quarantine_review_required', 0.0, 'Quarantine expired; deadness is unchanged'
|
|
220
|
+
)
|
|
221
|
+
reasons << 'Quarantine annotation has expired and requires review; classification and confidence are unchanged.'
|
|
222
|
+
when :invalid
|
|
223
|
+
components << score_component(
|
|
224
|
+
'quarantine_invalid_date', 0.0, 'Invalid quarantine date; deadness is unchanged'
|
|
225
|
+
)
|
|
226
|
+
reasons << 'Quarantine annotation has an invalid since date; classification and confidence are unchanged.'
|
|
227
|
+
when :fingerprint_required
|
|
228
|
+
components << score_component(
|
|
229
|
+
'quarantine_fingerprint_required', 0.0, 'Legacy quarantine requires an exact physical fingerprint'
|
|
230
|
+
)
|
|
231
|
+
reasons << 'Quarantine annotation requires a physical fingerprint before it can apply to this definition.'
|
|
232
|
+
when :stale_fingerprint
|
|
233
|
+
components << score_component(
|
|
234
|
+
'quarantine_stale_fingerprint', 0.0, 'Quarantine fingerprint does not match this physical definition'
|
|
235
|
+
)
|
|
236
|
+
reasons << 'Quarantine annotation is stale or belongs to a different physical definition.'
|
|
237
|
+
end
|
|
219
238
|
end
|
|
220
239
|
|
|
221
|
-
def
|
|
240
|
+
def quarantine_status(node, classification)
|
|
222
241
|
path = File.join(project.root, node.file)
|
|
223
242
|
return nil unless File.exist?(path)
|
|
224
243
|
|
|
225
244
|
lines = @source_lines[path] ||= File.readlines(path, chomp: true)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
245
|
+
annotation = node.line > 1 ? lines[node.line - 2] : nil
|
|
246
|
+
return nil unless annotation&.include?('necropsy:quarantine') && annotation.match?(/\bsince=/)
|
|
247
|
+
|
|
248
|
+
fingerprints = quarantine_fingerprints(annotation)
|
|
249
|
+
fingerprint_status = if fingerprints.nil?
|
|
250
|
+
:fingerprint_required
|
|
251
|
+
elsif fingerprints.include?(node.physical_fingerprint(classification))
|
|
252
|
+
:matched
|
|
253
|
+
else
|
|
254
|
+
:stale_fingerprint
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
raw_since = annotation[/\bsince=([^\s]+)/, 1]
|
|
258
|
+
return :invalid unless raw_since
|
|
259
|
+
|
|
260
|
+
since = Date.iso8601(raw_since)
|
|
261
|
+
return fingerprint_status unless fingerprint_status == :matched
|
|
262
|
+
|
|
263
|
+
since <= clock.date - project.config.quarantine_days ? :review_required : :active
|
|
264
|
+
rescue ArgumentError, TypeError
|
|
265
|
+
:invalid
|
|
266
|
+
rescue SystemCallError
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def quarantine_fingerprints(annotation)
|
|
271
|
+
single = annotation[/\bfingerprint=([0-9a-f]{64})(?:\s|$)/, 1]
|
|
272
|
+
multiple = annotation[/\bfingerprints=([0-9a-f]{64}(?:,[0-9a-f]{64})*)(?:\s|$)/, 1]
|
|
273
|
+
return [single] if single
|
|
274
|
+
return multiple.split(',') if multiple
|
|
275
|
+
return [] if annotation.match?(/\bfingerprints?=/)
|
|
229
276
|
|
|
230
|
-
Date.iso8601(annotation[/\bsince=([0-9-]+)/, 1])
|
|
231
|
-
rescue ArgumentError, SystemCallError
|
|
232
277
|
nil
|
|
233
278
|
end
|
|
234
279
|
end
|
|
@@ -1,30 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'prism'
|
|
4
6
|
|
|
5
7
|
module Necropsy
|
|
6
8
|
class Configuration
|
|
7
9
|
DEFAULT_FAIL_ON = :high
|
|
8
10
|
DEFAULT_BASELINE = '.necropsy_baseline.yml'
|
|
11
|
+
RTA_PRUNING_MODES = %w[rank_only legacy].freeze
|
|
12
|
+
STATIC_ANALYZER_ORDER = %w[name_resolution cha rta].freeze
|
|
13
|
+
QUARANTINE_EXPIRY_POLICIES = %w[warn fail ignore].freeze
|
|
14
|
+
WORLD_MODES = %w[application library].freeze
|
|
15
|
+
LOAD_ROOT_POLICIES = %w[known all].freeze
|
|
16
|
+
FRAMEWORK_GEMS = {
|
|
17
|
+
'rails' => 'rails',
|
|
18
|
+
'rubocop' => 'rubocop',
|
|
19
|
+
'sidekiq' => 'sidekiq',
|
|
20
|
+
'graphql' => 'graphql',
|
|
21
|
+
'view_component' => 'view_component',
|
|
22
|
+
'active_model_serializers' => 'active_model_serializers',
|
|
23
|
+
'blueprinter' => 'blueprinter'
|
|
24
|
+
}.freeze
|
|
9
25
|
TOP_LEVEL_KEYS = %w[
|
|
10
|
-
analyzers frameworks entry_points ci quarantine bench cache rta resolution paths report logging
|
|
26
|
+
analysis analyzers frameworks entry_points ci quarantine bench cache rta resolution paths report logging
|
|
27
|
+
implicit_callers
|
|
11
28
|
].freeze
|
|
12
29
|
NESTED_KEYS = {
|
|
30
|
+
'analysis' => %w[world load_roots],
|
|
13
31
|
'analyzers' => %w[static dynamic custom],
|
|
14
32
|
'analyzers.dynamic' => %w[coverage coverband trace_point],
|
|
15
33
|
'entry_points' => %w[extra],
|
|
16
34
|
'ci' => %w[baseline fail_on],
|
|
17
|
-
'quarantine' => %w[days],
|
|
35
|
+
'quarantine' => %w[days expiry],
|
|
18
36
|
'bench' => %w[precision_threshold recall_threshold],
|
|
19
37
|
'cache' => %w[enabled path],
|
|
20
|
-
'rta' => %w[factory_methods],
|
|
38
|
+
'rta' => %w[factory_methods pruning],
|
|
21
39
|
'resolution' => %w[ambiguity_limit],
|
|
22
|
-
'paths' => %w[include exclude],
|
|
40
|
+
'paths' => %w[analyze reference test include exclude],
|
|
23
41
|
'report' => %w[include exclude],
|
|
24
42
|
'logging' => %w[verbose]
|
|
25
43
|
}.freeze
|
|
26
|
-
DYNAMIC_KEYS = %w[source min_observation_days keys key pattern connect_timeout read_timeout].freeze
|
|
44
|
+
DYNAMIC_KEYS = %w[source min_observation_days expected_source_revision keys key pattern connect_timeout read_timeout].freeze
|
|
27
45
|
IMPLICIT_CALLER_KEYS = %w[name_pattern owner_ancestors reason].freeze
|
|
46
|
+
CUSTOM_ANALYZER_KEYS = %w[class require trusted].freeze
|
|
28
47
|
|
|
29
48
|
attr_reader :root, :path, :data
|
|
30
49
|
|
|
@@ -47,6 +66,26 @@ module Necropsy
|
|
|
47
66
|
Array(fetch('analyzers', 'static') || %w[name_resolution cha rta]).map(&:to_s)
|
|
48
67
|
end
|
|
49
68
|
|
|
69
|
+
def world
|
|
70
|
+
configured = fetch('analysis', 'world')
|
|
71
|
+
mode = configured.nil? ? 'application' : configured.to_s
|
|
72
|
+
return mode.to_sym if WORLD_MODES.include?(mode)
|
|
73
|
+
|
|
74
|
+
raise Error, "analysis.world must be one of: #{WORLD_MODES.join(', ')}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def library_world?
|
|
78
|
+
world == :library
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def load_roots
|
|
82
|
+
configured = fetch('analysis', 'load_roots')
|
|
83
|
+
policy = configured.nil? ? 'known' : configured.to_s
|
|
84
|
+
return policy.to_sym if LOAD_ROOT_POLICIES.include?(policy)
|
|
85
|
+
|
|
86
|
+
raise Error, "analysis.load_roots must be one of: #{LOAD_ROOT_POLICIES.join(', ')}"
|
|
87
|
+
end
|
|
88
|
+
|
|
50
89
|
def dynamic_config(name)
|
|
51
90
|
fetch('analyzers', 'dynamic', name.to_s) || {}
|
|
52
91
|
end
|
|
@@ -59,15 +98,17 @@ module Necropsy
|
|
|
59
98
|
Array(fetch('entry_points', 'extra')).compact.map(&:to_s)
|
|
60
99
|
end
|
|
61
100
|
|
|
62
|
-
def frameworks
|
|
63
|
-
|
|
64
|
-
end
|
|
101
|
+
def frameworks(reference_files: nil)
|
|
102
|
+
return @frameworks if defined?(@frameworks)
|
|
65
103
|
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
reference_files ||= Project.new(root: root, config: self).reference_files
|
|
105
|
+
configured = Array(data['frameworks']).map(&:to_s)
|
|
106
|
+
@frameworks = (configured + detected_frameworks(reference_files)).uniq.sort.freeze
|
|
107
|
+
end
|
|
68
108
|
|
|
69
|
-
|
|
70
|
-
|
|
109
|
+
def rails_enabled?(reference_files: nil)
|
|
110
|
+
reference_files ||= Project.new(root: root, config: self).reference_files
|
|
111
|
+
frameworks(reference_files: reference_files).include?('rails')
|
|
71
112
|
end
|
|
72
113
|
|
|
73
114
|
def baseline_path
|
|
@@ -84,20 +125,34 @@ module Necropsy
|
|
|
84
125
|
def min_observation_days
|
|
85
126
|
coverage_days = fetch('analyzers', 'dynamic', 'coverage', 'min_observation_days')
|
|
86
127
|
coverband_days = fetch('analyzers', 'dynamic', 'coverband', 'min_observation_days')
|
|
87
|
-
(coverage_days || coverband_days || 30)
|
|
128
|
+
Integer(coverage_days || coverband_days || 30)
|
|
88
129
|
end
|
|
89
130
|
|
|
131
|
+
REMOTE_DYNAMIC_KEYS = %w[
|
|
132
|
+
total_timeout max_response_bytes max_bulk_bytes max_array_elements max_resp_depth max_keys max_payload_depth
|
|
133
|
+
].freeze
|
|
134
|
+
DYNAMIC_TIMEOUT_KEYS = %w[connect_timeout read_timeout total_timeout].freeze
|
|
135
|
+
DYNAMIC_LIMIT_KEYS = (REMOTE_DYNAMIC_KEYS - %w[total_timeout]).freeze
|
|
136
|
+
|
|
90
137
|
def quarantine_days
|
|
91
|
-
(fetch('quarantine', 'days') || 30)
|
|
138
|
+
Integer(fetch('quarantine', 'days') || 30)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def quarantine_expiry
|
|
142
|
+
configured = fetch('quarantine', 'expiry')
|
|
143
|
+
policy = configured.nil? ? 'warn' : configured.to_s
|
|
144
|
+
return policy.to_sym if QUARANTINE_EXPIRY_POLICIES.include?(policy)
|
|
145
|
+
|
|
146
|
+
raise Error, "quarantine.expiry must be one of: #{QUARANTINE_EXPIRY_POLICIES.join(', ')}"
|
|
92
147
|
end
|
|
93
148
|
|
|
94
149
|
def bench_precision_threshold
|
|
95
|
-
(fetch('bench', 'precision_threshold') || 0.85)
|
|
150
|
+
Float(fetch('bench', 'precision_threshold') || 0.85)
|
|
96
151
|
end
|
|
97
152
|
|
|
98
153
|
def bench_recall_threshold
|
|
99
154
|
value = fetch('bench', 'recall_threshold')
|
|
100
|
-
value
|
|
155
|
+
value && Float(value)
|
|
101
156
|
end
|
|
102
157
|
|
|
103
158
|
def cache_enabled?
|
|
@@ -117,6 +172,14 @@ module Necropsy
|
|
|
117
172
|
Array(fetch('rta', 'factory_methods') || %w[build create build_stubbed]).map(&:to_s)
|
|
118
173
|
end
|
|
119
174
|
|
|
175
|
+
def rta_pruning
|
|
176
|
+
configured = fetch('rta', 'pruning')
|
|
177
|
+
mode = configured.nil? ? 'rank_only' : configured.to_s
|
|
178
|
+
return mode.to_sym if RTA_PRUNING_MODES.include?(mode)
|
|
179
|
+
|
|
180
|
+
raise Error, "rta.pruning must be one of: #{RTA_PRUNING_MODES.join(', ')}"
|
|
181
|
+
end
|
|
182
|
+
|
|
120
183
|
def ambiguity_limit
|
|
121
184
|
value = fetch('resolution', 'ambiguity_limit')
|
|
122
185
|
return 4 if value.nil?
|
|
@@ -130,8 +193,28 @@ module Necropsy
|
|
|
130
193
|
raise Error, 'resolution.ambiguity_limit must be a positive integer or unlimited'
|
|
131
194
|
end
|
|
132
195
|
|
|
196
|
+
def analyze_paths
|
|
197
|
+
configured = fetch('paths', 'analyze')
|
|
198
|
+
configured = fetch('paths', 'include') if configured.nil?
|
|
199
|
+
Array(configured).map(&:to_s)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def reference_paths
|
|
203
|
+
configured = fetch('paths', 'reference')
|
|
204
|
+
Array(configured.nil? ? ['**/*'] : configured).map(&:to_s)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def test_paths
|
|
208
|
+
configured = fetch('paths', 'test')
|
|
209
|
+
Array(configured.nil? ? %w[spec/** test/**] : configured).map(&:to_s)
|
|
210
|
+
end
|
|
211
|
+
|
|
133
212
|
def include_paths
|
|
134
|
-
|
|
213
|
+
analyze_paths
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def legacy_include_paths?
|
|
217
|
+
!fetch('paths', 'include').nil? && fetch('paths', 'analyze').nil?
|
|
135
218
|
end
|
|
136
219
|
|
|
137
220
|
def exclude_paths
|
|
@@ -162,6 +245,56 @@ module Necropsy
|
|
|
162
245
|
|
|
163
246
|
private
|
|
164
247
|
|
|
248
|
+
def detected_frameworks(reference_files)
|
|
249
|
+
dependency_files = reference_files.select do |file|
|
|
250
|
+
relative = relative_path_from_root(file)
|
|
251
|
+
%w[Gemfile Gemfile.lock].include?(relative) || relative.end_with?('.gemspec')
|
|
252
|
+
end
|
|
253
|
+
dependencies = dependency_files.flat_map { |file| dependency_names(file) }.to_set
|
|
254
|
+
FRAMEWORK_GEMS.filter_map do |gem_name, framework|
|
|
255
|
+
framework if dependencies.include?(gem_name)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def dependency_names(file)
|
|
260
|
+
source = File.read(file)
|
|
261
|
+
return locked_dependency_names(source) if File.basename(file) == 'Gemfile.lock'
|
|
262
|
+
|
|
263
|
+
ruby_dependency_names(source)
|
|
264
|
+
rescue SystemCallError, EncodingError
|
|
265
|
+
[]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def locked_dependency_names(source)
|
|
269
|
+
source.each_line.filter_map do |line|
|
|
270
|
+
line[/\A ([A-Za-z0-9_.-]+) \(/, 1]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def ruby_dependency_names(source)
|
|
275
|
+
result = Prism.parse(source)
|
|
276
|
+
return [] if result.failure?
|
|
277
|
+
|
|
278
|
+
pending = [result.value]
|
|
279
|
+
names = []
|
|
280
|
+
until pending.empty?
|
|
281
|
+
node = pending.pop
|
|
282
|
+
if node.is_a?(Prism::CallNode) &&
|
|
283
|
+
%i[gem add_dependency add_runtime_dependency add_development_dependency].include?(node.name)
|
|
284
|
+
argument = Array(node.arguments&.arguments).first
|
|
285
|
+
names << argument.content if argument.is_a?(Prism::StringNode)
|
|
286
|
+
end
|
|
287
|
+
pending.concat(node.child_nodes.compact)
|
|
288
|
+
end
|
|
289
|
+
names
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def relative_path_from_root(file)
|
|
293
|
+
Pathname.new(File.expand_path(file)).relative_path_from(Pathname.new(root)).to_s
|
|
294
|
+
rescue ArgumentError
|
|
295
|
+
file.to_s
|
|
296
|
+
end
|
|
297
|
+
|
|
165
298
|
def fetch(*keys)
|
|
166
299
|
current = data
|
|
167
300
|
keys.each do |key|
|
|
@@ -191,11 +324,20 @@ module Necropsy
|
|
|
191
324
|
end
|
|
192
325
|
%w[coverage coverband trace_point].each do |name|
|
|
193
326
|
value = fetch('analyzers', 'dynamic', name)
|
|
194
|
-
|
|
327
|
+
next unless value
|
|
328
|
+
|
|
329
|
+
validate_hash_keys(value, DYNAMIC_KEYS + REMOTE_DYNAMIC_KEYS, "analyzers.dynamic.#{name}")
|
|
330
|
+
validate_dynamic_limits!(value, name)
|
|
195
331
|
end
|
|
196
332
|
validate_custom_analyzers!
|
|
197
333
|
validate_implicit_callers!
|
|
334
|
+
world
|
|
335
|
+
load_roots
|
|
336
|
+
rta_pruning
|
|
198
337
|
ambiguity_limit
|
|
338
|
+
quarantine_expiry
|
|
339
|
+
validate_numeric_ranges!
|
|
340
|
+
validate_static_analyzers!
|
|
199
341
|
end
|
|
200
342
|
|
|
201
343
|
def validate_hash_keys(value, allowed, location)
|
|
@@ -207,13 +349,74 @@ module Necropsy
|
|
|
207
349
|
raise Error, "Unknown #{location} option#{'s' if unknown.length > 1}: #{unknown.join(', ')}"
|
|
208
350
|
end
|
|
209
351
|
|
|
352
|
+
def validate_dynamic_limits!(value, name)
|
|
353
|
+
DYNAMIC_TIMEOUT_KEYS.each do |key|
|
|
354
|
+
next unless value.key?(key)
|
|
355
|
+
|
|
356
|
+
parsed = Float(value[key])
|
|
357
|
+
dynamic_limit_error!(name, key) unless parsed.positive? && parsed.finite?
|
|
358
|
+
rescue ArgumentError, TypeError
|
|
359
|
+
dynamic_limit_error!(name, key)
|
|
360
|
+
end
|
|
361
|
+
DYNAMIC_LIMIT_KEYS.each do |key|
|
|
362
|
+
next unless value.key?(key)
|
|
363
|
+
|
|
364
|
+
dynamic_limit_error!(name, key) unless Integer(value[key]).positive?
|
|
365
|
+
rescue ArgumentError, TypeError
|
|
366
|
+
dynamic_limit_error!(name, key)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def validate_numeric_ranges!
|
|
371
|
+
validate_unit_interval!('bench.precision_threshold', bench_precision_threshold)
|
|
372
|
+
recall = bench_recall_threshold
|
|
373
|
+
validate_unit_interval!('bench.recall_threshold', recall) if recall
|
|
374
|
+
validate_positive_integer!('quarantine.days', fetch('quarantine', 'days') || 30)
|
|
375
|
+
%w[coverage coverband trace_point].each do |name|
|
|
376
|
+
value = fetch('analyzers', 'dynamic', name, 'min_observation_days')
|
|
377
|
+
validate_positive_integer!("analyzers.dynamic.#{name}.min_observation_days", value) unless value.nil?
|
|
378
|
+
end
|
|
379
|
+
rescue ArgumentError, TypeError
|
|
380
|
+
raise Error, 'Numeric configuration values must use their documented finite ranges'
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def validate_unit_interval!(name, value)
|
|
384
|
+
raise Error, "#{name} must be a finite number between 0 and 1" unless value.finite? && value.between?(0.0, 1.0)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def validate_positive_integer!(name, value)
|
|
388
|
+
valid_form = value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A\+?\d+\z/))
|
|
389
|
+
raise Error, "#{name} must be a positive integer" unless valid_form
|
|
390
|
+
|
|
391
|
+
parsed = Integer(value)
|
|
392
|
+
raise Error, "#{name} must be a positive integer" unless parsed.positive?
|
|
393
|
+
rescue ArgumentError, TypeError
|
|
394
|
+
raise Error, "#{name} must be a positive integer"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def validate_static_analyzers!
|
|
398
|
+
configured = static_analyzers
|
|
399
|
+
duplicates = configured.tally.select { |_name, count| count > 1 }.keys
|
|
400
|
+
raise Error, "Duplicate static analyzers: #{duplicates.sort.join(', ')}" if duplicates.any?
|
|
401
|
+
|
|
402
|
+
unknown = configured - STATIC_ANALYZER_ORDER
|
|
403
|
+
raise Error, "Unknown static analyzer: #{unknown.first}" if unknown.any?
|
|
404
|
+
|
|
405
|
+
positions = configured.map { |name| STATIC_ANALYZER_ORDER.index(name) }
|
|
406
|
+
raise Error, 'Static analyzers must run in name_resolution, cha, rta dependency order' unless positions == positions.sort
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def dynamic_limit_error!(name, key)
|
|
410
|
+
raise Error, "analyzers.dynamic.#{name}.#{key} must be a finite positive number"
|
|
411
|
+
end
|
|
412
|
+
|
|
210
413
|
def validate_custom_analyzers!
|
|
211
414
|
custom_analyzers.each do |entry|
|
|
212
|
-
|
|
213
|
-
raise Error, 'Each custom analyzer must be a class name or a mapping with class and require' unless entry.is_a?(Hash)
|
|
415
|
+
raise Error, 'Each custom analyzer must be a mapping with class and trusted: true' unless entry.is_a?(Hash)
|
|
214
416
|
|
|
215
|
-
validate_hash_keys(entry,
|
|
417
|
+
validate_hash_keys(entry, CUSTOM_ANALYZER_KEYS, 'custom analyzer')
|
|
216
418
|
raise Error, 'Custom analyzer mapping requires class' unless entry['class']
|
|
419
|
+
raise Error, 'Custom analyzer execution requires trusted: true' unless entry['trusted'] == true
|
|
217
420
|
end
|
|
218
421
|
end
|
|
219
422
|
|