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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. 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.load_file(config_path) : {}
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
- Array(data['frameworks']).map(&:to_s)
42
- end
101
+ def frameworks(reference_files: nil)
102
+ return @frameworks if defined?(@frameworks)
43
103
 
44
- def rails_enabled?
45
- return true if frameworks.include?('rails')
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
- gemfiles = [File.join(root, 'Gemfile.lock'), File.join(root, 'Gemfile')]
48
- gemfiles.any? { |file| File.exist?(file) && File.read(file).match?(/(?:^|\s)rails(?:\s|\z|,)/) }
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).to_i
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).to_i
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).to_f
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&.to_f
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.yml'
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
- keys.reduce(data) do |current, key|
99
- break nil unless current.is_a?(Hash)
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