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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. 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 each call coerce
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
- classification = classification_for(node)
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.id).flat_map(&:evidences) + graph.alive_evidences(node.id)
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.id)
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 reachability.test_paths.key?(node.id)
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 graph.uncertainties(node.id).any?
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
- if classification == :unreachable && graph.dynamic_enabled? && !graph.dynamic_alive?(node.id)
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
- if classification == :unreachable && quarantine_expired?(node)
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.id]
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(&:id)
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
- until queue.empty?
175
- node_id = queue.shift
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 observation_days
207
- graph.observation.values.filter_map do |value|
208
- next unless value.is_a?(Hash)
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
- since <= Date.today - project.config.quarantine_days
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 quarantine_since(node)
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
- window = lines[[node.line - 4, 0].max, 4] || []
227
- annotation = window.find { |line| line.include?('necropsy:quarantine') && line.match?(/\bsince=/) }
228
- return nil unless annotation
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 implicit_callers
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
- Array(data['frameworks']).map(&:to_s)
64
- end
101
+ def frameworks(reference_files: nil)
102
+ return @frameworks if defined?(@frameworks)
65
103
 
66
- def rails_enabled?
67
- 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
68
108
 
69
- gemfiles = [File.join(root, 'Gemfile.lock'), File.join(root, 'Gemfile')]
70
- 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')
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).to_i
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).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(', ')}"
92
147
  end
93
148
 
94
149
  def bench_precision_threshold
95
- (fetch('bench', 'precision_threshold') || 0.85).to_f
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&.to_f
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
- Array(fetch('paths', 'include')).map(&:to_s)
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
- validate_hash_keys(value, DYNAMIC_KEYS, "analyzers.dynamic.#{name}") if value
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
- next if entry.is_a?(String)
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, %w[class require], 'custom analyzer')
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