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
|
@@ -1,30 +1,91 @@
|
|
|
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
|
|
25
|
+
TOP_LEVEL_KEYS = %w[
|
|
26
|
+
analysis analyzers frameworks entry_points ci quarantine bench cache rta resolution paths report logging
|
|
27
|
+
implicit_callers
|
|
28
|
+
].freeze
|
|
29
|
+
NESTED_KEYS = {
|
|
30
|
+
'analysis' => %w[world load_roots],
|
|
31
|
+
'analyzers' => %w[static dynamic custom],
|
|
32
|
+
'analyzers.dynamic' => %w[coverage coverband trace_point],
|
|
33
|
+
'entry_points' => %w[extra],
|
|
34
|
+
'ci' => %w[baseline fail_on],
|
|
35
|
+
'quarantine' => %w[days expiry],
|
|
36
|
+
'bench' => %w[precision_threshold recall_threshold],
|
|
37
|
+
'cache' => %w[enabled path],
|
|
38
|
+
'rta' => %w[factory_methods pruning],
|
|
39
|
+
'resolution' => %w[ambiguity_limit],
|
|
40
|
+
'paths' => %w[analyze reference test include exclude],
|
|
41
|
+
'report' => %w[include exclude],
|
|
42
|
+
'logging' => %w[verbose]
|
|
43
|
+
}.freeze
|
|
44
|
+
DYNAMIC_KEYS = %w[source min_observation_days expected_source_revision keys key pattern connect_timeout read_timeout].freeze
|
|
45
|
+
IMPLICIT_CALLER_KEYS = %w[name_pattern owner_ancestors reason].freeze
|
|
46
|
+
CUSTOM_ANALYZER_KEYS = %w[class require trusted].freeze
|
|
9
47
|
|
|
10
48
|
attr_reader :root, :path, :data
|
|
11
49
|
|
|
12
50
|
def self.load(root:, path: nil)
|
|
13
51
|
config_path = path ? File.expand_path(path, root) : File.join(root, '.necropsy.yml')
|
|
14
|
-
data = File.exist?(config_path) ? YAML.
|
|
52
|
+
data = File.exist?(config_path) ? YAML.safe_load_file(config_path, permitted_classes: [Symbol], aliases: true) : {}
|
|
15
53
|
new(root: root, path: config_path, data: data || {})
|
|
54
|
+
rescue Psych::Exception => e
|
|
55
|
+
raise Error, "Could not parse configuration #{config_path}: #{e.message}"
|
|
16
56
|
end
|
|
17
57
|
|
|
18
58
|
def initialize(root:, path:, data:)
|
|
19
59
|
@root = File.expand_path(root)
|
|
20
60
|
@path = path
|
|
21
61
|
@data = stringify_keys(data)
|
|
62
|
+
validate!
|
|
22
63
|
end
|
|
23
64
|
|
|
24
65
|
def static_analyzers
|
|
25
66
|
Array(fetch('analyzers', 'static') || %w[name_resolution cha rta]).map(&:to_s)
|
|
26
67
|
end
|
|
27
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
|
+
|
|
28
89
|
def dynamic_config(name)
|
|
29
90
|
fetch('analyzers', 'dynamic', name.to_s) || {}
|
|
30
91
|
end
|
|
@@ -37,15 +98,17 @@ module Necropsy
|
|
|
37
98
|
Array(fetch('entry_points', 'extra')).compact.map(&:to_s)
|
|
38
99
|
end
|
|
39
100
|
|
|
40
|
-
def frameworks
|
|
41
|
-
|
|
42
|
-
end
|
|
101
|
+
def frameworks(reference_files: nil)
|
|
102
|
+
return @frameworks if defined?(@frameworks)
|
|
43
103
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
46
108
|
|
|
47
|
-
|
|
48
|
-
|
|
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')
|
|
49
112
|
end
|
|
50
113
|
|
|
51
114
|
def baseline_path
|
|
@@ -53,26 +116,43 @@ module Necropsy
|
|
|
53
116
|
end
|
|
54
117
|
|
|
55
118
|
def fail_on
|
|
56
|
-
(fetch('ci', 'fail_on') || DEFAULT_FAIL_ON).to_sym
|
|
119
|
+
level = (fetch('ci', 'fail_on') || DEFAULT_FAIL_ON).to_sym
|
|
120
|
+
return level if CONFIDENCE_LEVELS.key?(level)
|
|
121
|
+
|
|
122
|
+
raise Error, "Invalid ci.fail_on confidence level: #{level}"
|
|
57
123
|
end
|
|
58
124
|
|
|
59
125
|
def min_observation_days
|
|
60
126
|
coverage_days = fetch('analyzers', 'dynamic', 'coverage', 'min_observation_days')
|
|
61
127
|
coverband_days = fetch('analyzers', 'dynamic', 'coverband', 'min_observation_days')
|
|
62
|
-
(coverage_days || coverband_days || 30)
|
|
128
|
+
Integer(coverage_days || coverband_days || 30)
|
|
63
129
|
end
|
|
64
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
|
+
|
|
65
137
|
def quarantine_days
|
|
66
|
-
(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(', ')}"
|
|
67
147
|
end
|
|
68
148
|
|
|
69
149
|
def bench_precision_threshold
|
|
70
|
-
(fetch('bench', 'precision_threshold') || 0.85)
|
|
150
|
+
Float(fetch('bench', 'precision_threshold') || 0.85)
|
|
71
151
|
end
|
|
72
152
|
|
|
73
153
|
def bench_recall_threshold
|
|
74
154
|
value = fetch('bench', 'recall_threshold')
|
|
75
|
-
value
|
|
155
|
+
value && Float(value)
|
|
76
156
|
end
|
|
77
157
|
|
|
78
158
|
def cache_enabled?
|
|
@@ -81,25 +161,148 @@ module Necropsy
|
|
|
81
161
|
end
|
|
82
162
|
|
|
83
163
|
def cache_path
|
|
84
|
-
fetch('cache', 'path') || '.necropsy_cache/scan.
|
|
164
|
+
fetch('cache', 'path') || '.necropsy_cache/scan.json'
|
|
85
165
|
end
|
|
86
166
|
|
|
87
167
|
def scan_cache_key
|
|
88
|
-
data.except('cache')
|
|
168
|
+
data.except('cache', 'report')
|
|
89
169
|
end
|
|
90
170
|
|
|
91
171
|
def factory_methods
|
|
92
172
|
Array(fetch('rta', 'factory_methods') || %w[build create build_stubbed]).map(&:to_s)
|
|
93
173
|
end
|
|
94
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
|
+
|
|
183
|
+
def ambiguity_limit
|
|
184
|
+
value = fetch('resolution', 'ambiguity_limit')
|
|
185
|
+
return 4 if value.nil?
|
|
186
|
+
return Float::INFINITY if value.to_s == 'unlimited'
|
|
187
|
+
|
|
188
|
+
limit = Integer(value)
|
|
189
|
+
return limit if limit.positive?
|
|
190
|
+
|
|
191
|
+
raise Error, 'resolution.ambiguity_limit must be a positive integer or unlimited'
|
|
192
|
+
rescue ArgumentError, TypeError
|
|
193
|
+
raise Error, 'resolution.ambiguity_limit must be a positive integer or unlimited'
|
|
194
|
+
end
|
|
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
|
+
|
|
212
|
+
def include_paths
|
|
213
|
+
analyze_paths
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def legacy_include_paths?
|
|
217
|
+
!fetch('paths', 'include').nil? && fetch('paths', 'analyze').nil?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def exclude_paths
|
|
221
|
+
Array(fetch('paths', 'exclude')).map(&:to_s)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def report_include_paths
|
|
225
|
+
Array(fetch('report', 'include')).map(&:to_s)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def report_exclude_paths
|
|
229
|
+
Array(fetch('report', 'exclude')).map(&:to_s)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def implicit_callers
|
|
233
|
+
@implicit_callers ||= Array(data['implicit_callers']).map do |entry|
|
|
234
|
+
{
|
|
235
|
+
name_pattern: Regexp.new(entry.fetch('name_pattern')),
|
|
236
|
+
owner_ancestors: Array(entry['owner_ancestors']).map(&:to_s),
|
|
237
|
+
reason: entry['reason']&.to_s
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def verbose?
|
|
243
|
+
fetch('logging', 'verbose') == true
|
|
244
|
+
end
|
|
245
|
+
|
|
95
246
|
private
|
|
96
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
|
+
|
|
97
298
|
def fetch(*keys)
|
|
98
|
-
|
|
99
|
-
|
|
299
|
+
current = data
|
|
300
|
+
keys.each do |key|
|
|
301
|
+
return nil unless current.is_a?(Hash)
|
|
100
302
|
|
|
101
|
-
current[key.to_s]
|
|
303
|
+
current = current[key.to_s]
|
|
102
304
|
end
|
|
305
|
+
current
|
|
103
306
|
end
|
|
104
307
|
|
|
105
308
|
def stringify_keys(value)
|
|
@@ -112,5 +315,123 @@ module Necropsy
|
|
|
112
315
|
value
|
|
113
316
|
end
|
|
114
317
|
end
|
|
318
|
+
|
|
319
|
+
def validate!
|
|
320
|
+
validate_hash_keys(data, TOP_LEVEL_KEYS, 'configuration')
|
|
321
|
+
NESTED_KEYS.each do |path, allowed|
|
|
322
|
+
value = fetch(*path.split('.'))
|
|
323
|
+
validate_hash_keys(value, allowed, path) if value
|
|
324
|
+
end
|
|
325
|
+
%w[coverage coverband trace_point].each do |name|
|
|
326
|
+
value = fetch('analyzers', 'dynamic', name)
|
|
327
|
+
next unless value
|
|
328
|
+
|
|
329
|
+
validate_hash_keys(value, DYNAMIC_KEYS + REMOTE_DYNAMIC_KEYS, "analyzers.dynamic.#{name}")
|
|
330
|
+
validate_dynamic_limits!(value, name)
|
|
331
|
+
end
|
|
332
|
+
validate_custom_analyzers!
|
|
333
|
+
validate_implicit_callers!
|
|
334
|
+
world
|
|
335
|
+
load_roots
|
|
336
|
+
rta_pruning
|
|
337
|
+
ambiguity_limit
|
|
338
|
+
quarantine_expiry
|
|
339
|
+
validate_numeric_ranges!
|
|
340
|
+
validate_static_analyzers!
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def validate_hash_keys(value, allowed, location)
|
|
344
|
+
raise Error, "#{location} must be a mapping" unless value.is_a?(Hash)
|
|
345
|
+
|
|
346
|
+
unknown = value.keys - allowed
|
|
347
|
+
return if unknown.empty?
|
|
348
|
+
|
|
349
|
+
raise Error, "Unknown #{location} option#{'s' if unknown.length > 1}: #{unknown.join(', ')}"
|
|
350
|
+
end
|
|
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
|
+
|
|
413
|
+
def validate_custom_analyzers!
|
|
414
|
+
custom_analyzers.each do |entry|
|
|
415
|
+
raise Error, 'Each custom analyzer must be a mapping with class and trusted: true' unless entry.is_a?(Hash)
|
|
416
|
+
|
|
417
|
+
validate_hash_keys(entry, CUSTOM_ANALYZER_KEYS, 'custom analyzer')
|
|
418
|
+
raise Error, 'Custom analyzer mapping requires class' unless entry['class']
|
|
419
|
+
raise Error, 'Custom analyzer execution requires trusted: true' unless entry['trusted'] == true
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def validate_implicit_callers!
|
|
424
|
+
Array(data['implicit_callers']).each do |entry|
|
|
425
|
+
raise Error, 'Each implicit caller must be a mapping' unless entry.is_a?(Hash)
|
|
426
|
+
|
|
427
|
+
validate_hash_keys(entry, IMPLICIT_CALLER_KEYS, 'implicit caller')
|
|
428
|
+
pattern = entry['name_pattern']
|
|
429
|
+
raise Error, 'Implicit caller requires name_pattern' unless pattern.is_a?(String)
|
|
430
|
+
|
|
431
|
+
Regexp.new(pattern)
|
|
432
|
+
rescue RegexpError => e
|
|
433
|
+
raise Error, "Invalid implicit caller name_pattern: #{e.message}"
|
|
434
|
+
end
|
|
435
|
+
end
|
|
115
436
|
end
|
|
116
437
|
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
class ConventionRules
|
|
5
|
+
Rule = Data.define(:id, :family, :method_names, :owner_patterns, :ancestor_patterns, :frameworks) do
|
|
6
|
+
def initialize(id:, family:, methods: [], owner_patterns: [], ancestor_patterns: [], frameworks: [])
|
|
7
|
+
id = id.to_s
|
|
8
|
+
family = family.to_sym
|
|
9
|
+
raise ArgumentError, 'rule id must not be empty' if id.empty?
|
|
10
|
+
raise ArgumentError, 'rule family must not be empty' if family.to_s.empty?
|
|
11
|
+
raise ArgumentError, 'rule methods must be bounded strings' unless Array(methods).all? do |name|
|
|
12
|
+
name.is_a?(String) && !name.empty? && name.bytesize <= 128
|
|
13
|
+
end
|
|
14
|
+
raise ArgumentError, 'rule owner patterns must be bounded strings' unless Array(owner_patterns).all? do |pattern|
|
|
15
|
+
pattern.is_a?(String) && !pattern.empty? && pattern.bytesize <= 256
|
|
16
|
+
end
|
|
17
|
+
raise ArgumentError, 'rule ancestor patterns must be bounded strings' unless Array(ancestor_patterns).all? do |pattern|
|
|
18
|
+
pattern.is_a?(String) && !pattern.empty? && pattern.bytesize <= 256
|
|
19
|
+
end
|
|
20
|
+
raise ArgumentError, 'rule frameworks must be bounded strings' unless Array(frameworks).all? do |name|
|
|
21
|
+
name.is_a?(String) && !name.empty? && name.bytesize <= 64
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, 'rules may not be unscoped' if Array(owner_patterns).empty? && Array(ancestor_patterns).empty?
|
|
24
|
+
|
|
25
|
+
super(
|
|
26
|
+
id: id.freeze,
|
|
27
|
+
family: family,
|
|
28
|
+
method_names: Array(methods).map(&:to_s).uniq.sort.freeze,
|
|
29
|
+
owner_patterns: Array(owner_patterns).map(&:to_s).uniq.sort.freeze,
|
|
30
|
+
ancestor_patterns: Array(ancestor_patterns).map(&:to_s).uniq.sort.freeze,
|
|
31
|
+
frameworks: Array(frameworks).map(&:to_s).uniq.sort.freeze
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
BUILT_IN = [
|
|
37
|
+
Rule.new(
|
|
38
|
+
id: 'rubocop.event_callback', family: :ancestor_scoped_hook,
|
|
39
|
+
ancestor_patterns: ['RuboCop::Cop', 'RuboCop::Cop::Base'], frameworks: ['rubocop']
|
|
40
|
+
),
|
|
41
|
+
Rule.new(
|
|
42
|
+
id: 'rails.callback_symbol', family: :symbol_argument_method_reference,
|
|
43
|
+
methods: %w[after_action after_save before_action before_save validate],
|
|
44
|
+
owner_patterns: ['*Controller', '*Job', '*Mailer', '*Record', '*Validator'], frameworks: ['rails']
|
|
45
|
+
),
|
|
46
|
+
Rule.new(
|
|
47
|
+
id: 'rails.application_base', family: :ancestor_scoped_hook,
|
|
48
|
+
methods: %w[after_action after_save before_action before_save validate],
|
|
49
|
+
ancestor_patterns: %w[ApplicationController ApplicationJob ApplicationMailer ApplicationRecord],
|
|
50
|
+
frameworks: ['rails']
|
|
51
|
+
),
|
|
52
|
+
Rule.new(
|
|
53
|
+
id: 'rails.action_cable', family: :framework_runtime_hook,
|
|
54
|
+
methods: %w[receive subscribed unsubscribed], owner_patterns: ['*Channel'], frameworks: ['rails']
|
|
55
|
+
),
|
|
56
|
+
Rule.new(
|
|
57
|
+
id: 'rails.active_job', family: :framework_runtime_hook,
|
|
58
|
+
methods: %w[deserialize perform serialize], owner_patterns: ['*Job'], ancestor_patterns: ['ApplicationJob'],
|
|
59
|
+
frameworks: ['rails']
|
|
60
|
+
),
|
|
61
|
+
Rule.new(
|
|
62
|
+
id: 'sidekiq.worker', family: :framework_runtime_hook,
|
|
63
|
+
methods: ['perform'], ancestor_patterns: %w[Sidekiq::Job Sidekiq::Worker], frameworks: ['sidekiq']
|
|
64
|
+
),
|
|
65
|
+
Rule.new(
|
|
66
|
+
id: 'graphql.runtime', family: :framework_runtime_hook,
|
|
67
|
+
methods: %w[authorized? ready? resolve subscribed update],
|
|
68
|
+
owner_patterns: %w[*Mutation *Resolver *Subscription *Type],
|
|
69
|
+
ancestor_patterns: %w[GraphQL::Schema::Mutation GraphQL::Schema::Resolver GraphQL::Schema::Subscription],
|
|
70
|
+
frameworks: ['graphql']
|
|
71
|
+
),
|
|
72
|
+
Rule.new(
|
|
73
|
+
id: 'active_model_serializers.public_surface', family: :declarative_public_surface,
|
|
74
|
+
owner_patterns: ['*Serializer'], frameworks: ['active_model_serializers']
|
|
75
|
+
),
|
|
76
|
+
Rule.new(
|
|
77
|
+
id: 'blueprinter.public_surface', family: :declarative_public_surface,
|
|
78
|
+
owner_patterns: ['*Blueprint'], frameworks: ['blueprinter']
|
|
79
|
+
),
|
|
80
|
+
Rule.new(
|
|
81
|
+
id: 'view_component.runtime', family: :framework_runtime_hook,
|
|
82
|
+
methods: %w[before_render call render?], owner_patterns: ['*Component'], frameworks: ['view_component']
|
|
83
|
+
)
|
|
84
|
+
].freeze
|
|
85
|
+
|
|
86
|
+
MAX_RULES = 32
|
|
87
|
+
|
|
88
|
+
def initialize(rules: BUILT_IN)
|
|
89
|
+
@rules = Array(rules).freeze
|
|
90
|
+
validate_rules!
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def method_hit(owner:, method_name:, ancestors:, frameworks: [])
|
|
94
|
+
candidate = @rules.find do |rule|
|
|
95
|
+
next false unless rule.method_names.empty? || rule.method_names.include?(method_name.to_s)
|
|
96
|
+
next false unless framework_enabled?(rule, frameworks)
|
|
97
|
+
|
|
98
|
+
scoped_match?(rule, owner, ancestors)
|
|
99
|
+
end
|
|
100
|
+
hit(candidate, owner: owner, method_name: method_name) if candidate
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
attr_reader :rules
|
|
106
|
+
|
|
107
|
+
def validate_rules!
|
|
108
|
+
raise ArgumentError, "too many convention rules (max #{MAX_RULES})" if rules.length > MAX_RULES
|
|
109
|
+
|
|
110
|
+
ids = rules.map(&:id)
|
|
111
|
+
raise ArgumentError, 'convention rule ids must be unique' unless ids.uniq == ids
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def framework_enabled?(rule, frameworks)
|
|
115
|
+
rule.frameworks.empty? || rule.frameworks.intersect?(Array(frameworks).map(&:to_s))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def scoped_match?(rule, owner, ancestors)
|
|
119
|
+
owner = owner.to_s
|
|
120
|
+
ancestors = Array(ancestors).map(&:to_s)
|
|
121
|
+
owner_matches = rule.owner_patterns.any? { |pattern| File.fnmatch?(pattern, owner, File::FNM_EXTGLOB) }
|
|
122
|
+
ancestor_matches = rule.ancestor_patterns.any? do |pattern|
|
|
123
|
+
ancestors.any? { |candidate| candidate == pattern || candidate.start_with?("#{pattern}::") }
|
|
124
|
+
end
|
|
125
|
+
owner_matches || ancestor_matches
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def hit(rule, owner:, method_name:)
|
|
129
|
+
{
|
|
130
|
+
'rule_id' => rule.id,
|
|
131
|
+
'family' => rule.family.to_s,
|
|
132
|
+
'owner' => owner.to_s,
|
|
133
|
+
'method' => method_name.to_s,
|
|
134
|
+
'reason' => "#{rule.id} matched #{owner}##{method_name}"
|
|
135
|
+
}.freeze
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|