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
@@ -0,0 +1,460 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Necropsy
6
+ class FlowInterpreter
7
+ DEFAULT_MAX_STEPS = 512
8
+ MAX_ATOMS = 8
9
+ class StepBudgetExceeded < StandardError; end
10
+ ControlTransfer = Data.define(:kind, :fact)
11
+
12
+ def initialize(constant_resolver:, max_steps: DEFAULT_MAX_STEPS)
13
+ @constant_resolver = constant_resolver
14
+ @max_steps = Integer(max_steps)
15
+ end
16
+
17
+ def analyze(body)
18
+ @locals = {}
19
+ @receiver_facts = {}.compare_by_identity
20
+ @value_facts = {}.compare_by_identity
21
+ @issues = []
22
+ @return_facts = []
23
+ @steps = 0
24
+ value = evaluate(body)
25
+ FlowResult.new(
26
+ receiver_facts: @receiver_facts,
27
+ value_facts: @value_facts,
28
+ return_fact: return_fact(value),
29
+ issues: @issues,
30
+ steps: @steps
31
+ )
32
+ rescue StepBudgetExceeded
33
+ FlowResult.new(
34
+ receiver_facts: @receiver_facts,
35
+ value_facts: @value_facts,
36
+ return_fact: ValueFact.unknown(:step_budget),
37
+ issues: [*@issues, 'step_budget'],
38
+ steps: @steps
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def evaluate(node)
45
+ return ValueFact.unknown(:missing_value) unless node
46
+
47
+ consume_step!
48
+ value = case node
49
+ when Prism::StatementsNode
50
+ evaluate_statements(node)
51
+ when Prism::EmbeddedStatementsNode
52
+ evaluate(node.statements)
53
+ when Prism::ParenthesesNode
54
+ evaluate(node.body)
55
+ when Prism::IfNode
56
+ evaluate_if(node)
57
+ when Prism::CaseNode
58
+ evaluate_case(node)
59
+ when Prism::ElseNode
60
+ evaluate_clause(node)
61
+ when Prism::WhenNode
62
+ evaluate_when(node)
63
+ when Prism::AndNode, Prism::OrNode
64
+ evaluate_logical(node)
65
+ when Prism::LocalVariableWriteNode
66
+ assign_local(node)
67
+ when Prism::LocalVariableReadNode
68
+ @locals.fetch(node.name, ValueFact.unknown(:unbound_local))
69
+ when Prism::CallNode
70
+ evaluate_call(node)
71
+ when Prism::ReturnNode
72
+ evaluate_return(node)
73
+ when Prism::BreakNode
74
+ evaluate_transfer(node, :break)
75
+ when Prism::NextNode
76
+ evaluate_transfer(node, :next)
77
+ when Prism::ArrayNode
78
+ evaluate_array(node)
79
+ when Prism::HashNode
80
+ evaluate_hash(node)
81
+ when Prism::LambdaNode, Prism::BlockNode
82
+ evaluate_callable(node)
83
+ when Prism::NilNode
84
+ ValueFact.new(kind: :nil, exact: true, nilable: true, origin: :literal)
85
+ when Prism::TrueNode, Prism::FalseNode
86
+ ValueFact.new(kind: :boolean, values: [node.type.to_s.delete_suffix('_node')], origin: :literal)
87
+ when Prism::SymbolNode
88
+ ValueFact.new(kind: :symbol_set, values: [node.value.to_s], origin: :literal)
89
+ when Prism::StringNode
90
+ ValueFact.new(kind: :string_set, values: [node.content], origin: :literal)
91
+ when Prism::InterpolatedStringNode, Prism::InterpolatedSymbolNode
92
+ evaluate_interpolated(node)
93
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
94
+ class_object(node)
95
+ else
96
+ evaluate_unsupported(node)
97
+ end
98
+ @value_facts[node] = value.fact if transfer?(value)
99
+ @value_facts[node] = value unless transfer?(value)
100
+ value
101
+ end
102
+
103
+ def evaluate_statements(node)
104
+ value = ValueFact.unknown(:empty_statements)
105
+ node.body.each do |child|
106
+ value = evaluate(child)
107
+ break if transfer?(value)
108
+ end
109
+ value
110
+ end
111
+
112
+ def evaluate_clause(node)
113
+ evaluate(node.statements)
114
+ end
115
+
116
+ def evaluate_if(node)
117
+ predicate = evaluate(node.predicate)
118
+ return predicate if transfer?(predicate)
119
+
120
+ branches = [evaluate_branch(node.statements)]
121
+ branches << (node.subsequent ? evaluate_branch(node.subsequent) : implicit_branch)
122
+ join_branch_results(branches)
123
+ end
124
+
125
+ def evaluate_case(node)
126
+ predicate = evaluate(node.predicate) if node.predicate
127
+ return predicate if transfer?(predicate)
128
+
129
+ branches = node.conditions.map { |condition| evaluate_branch(condition) }
130
+ branches << if node.else_clause
131
+ evaluate_branch(node.else_clause)
132
+ else
133
+ implicit_branch
134
+ end
135
+ join_branch_results(branches)
136
+ end
137
+
138
+ def evaluate_when(node)
139
+ node.conditions.each do |condition|
140
+ value = evaluate(condition)
141
+ return value if transfer?(value)
142
+ end
143
+ evaluate(node.statements)
144
+ end
145
+
146
+ def evaluate_branch(branch)
147
+ previous = @locals
148
+ @locals = previous.dup
149
+ value = evaluate(branch)
150
+ [value, @locals, transfer?(value) ? value.kind : nil]
151
+ ensure
152
+ @locals = previous
153
+ end
154
+
155
+ def implicit_branch
156
+ [nil_fact, @locals.dup, nil]
157
+ end
158
+
159
+ def join_branch_results(branches)
160
+ normal_branches = branches.reject { |branch| branch[2] }
161
+ return join_transfers(branches) if normal_branches.empty?
162
+
163
+ values = normal_branches.map(&:first)
164
+ branch_locals = normal_branches.map { |branch| branch[1] }
165
+ names = branch_locals.flat_map(&:keys).uniq
166
+ @locals = names.to_h do |name|
167
+ join = join_facts(branch_locals.map { |locals| locals.fetch(name, ValueFact.unknown(:branch_unbound)) })
168
+ [name, join]
169
+ end
170
+ join_facts(values)
171
+ end
172
+
173
+ def evaluate_logical(node)
174
+ left = evaluate(node.left)
175
+ return left if transfer?(left)
176
+
177
+ right_branch = evaluate_branch(node.right)
178
+ join_branch_results([[left, @locals.dup, nil], right_branch])
179
+ end
180
+
181
+ def assign_local(node)
182
+ value = evaluate(node.value)
183
+ return value if transfer?(value)
184
+
185
+ @locals[node.name] = value
186
+ value
187
+ end
188
+
189
+ def evaluate_call(node)
190
+ receiver_fact = evaluate(node.receiver) if node.receiver
191
+ return receiver_fact if transfer?(receiver_fact)
192
+
193
+ arguments = Array(node.arguments&.arguments)
194
+ arguments.each do |argument|
195
+ argument_fact = evaluate(argument)
196
+ return argument_fact if transfer?(argument_fact)
197
+ end
198
+ @receiver_facts[node.receiver] = receiver_fact if node.receiver
199
+ return ControlTransfer.new(kind: :raise, fact: ValueFact.unknown(:raised)) if node.name == :raise && !node.receiver
200
+ return transparent_wrapper(node) if transparent_wrapper?(node)
201
+ return container_lookup(receiver_fact, arguments.first) if node.name == :[] && receiver_fact
202
+ return callable_result(receiver_fact) if node.name == :call && receiver_fact
203
+ return direct_constructor(node) if node.name == :new && receiver_fact&.kind == :class_object
204
+ return concatenate_strings(receiver_fact, arguments.first) if node.name == :+ && receiver_fact
205
+
206
+ ValueFact.unknown(:call_result)
207
+ end
208
+
209
+ def concatenate_strings(receiver_fact, argument)
210
+ argument_fact = @value_facts[argument]
211
+ return ValueFact.unknown(:dynamic_string_concat) unless receiver_fact.exact && argument_fact&.exact
212
+ return ValueFact.unknown(:dynamic_string_concat) unless
213
+ [receiver_fact.kind, argument_fact.kind].all? { |kind| %i[string_set symbol_set].include?(kind) }
214
+
215
+ values = receiver_fact.values.product(argument_fact.values).map { |left, right| "#{left}#{right}" }
216
+ return ValueFact.unknown(:string_product_budget) if values.length > MAX_ATOMS
217
+
218
+ ValueFact.new(kind: :string_set, values: values, origin: :string_concat)
219
+ end
220
+
221
+ def evaluate_interpolated(node)
222
+ parts = node.parts.map do |part|
223
+ fact = evaluate(part)
224
+ if part.is_a?(Prism::StringNode)
225
+ [part.content.to_s]
226
+ elsif fact.exact && %i[string_set symbol_set].include?(fact.kind)
227
+ fact.values
228
+ end
229
+ end
230
+ return ValueFact.unknown(:dynamic_interpolation) if parts.any?(&:nil?)
231
+
232
+ values = parts.reduce(['']) do |prefixes, suffixes|
233
+ next [] if prefixes.length * suffixes.length > MAX_ATOMS
234
+
235
+ prefixes.product(suffixes).map { |prefix, suffix| "#{prefix}#{suffix}" }
236
+ end
237
+ return ValueFact.unknown(:string_product_budget) if values.empty? || values.length > MAX_ATOMS
238
+
239
+ ValueFact.new(kind: :string_set, values: values, origin: :interpolation)
240
+ end
241
+
242
+ def transparent_wrapper?(node)
243
+ node.receiver && constant_name(node.receiver) == 'T' && %i[let cast must unsafe].include?(node.name)
244
+ end
245
+
246
+ def transparent_wrapper(node)
247
+ argument = Array(node.arguments&.arguments).first
248
+ argument ? (@value_facts[argument] || evaluate(argument)) : ValueFact.unknown(:wrapper_argument)
249
+ end
250
+
251
+ def evaluate_return(node)
252
+ argument = Array(node.arguments&.arguments).first
253
+ value = argument ? evaluate(argument) : nil_fact
254
+ return value if transfer?(value)
255
+
256
+ @return_facts << value
257
+ ControlTransfer.new(kind: :return, fact: value)
258
+ end
259
+
260
+ def evaluate_transfer(node, kind)
261
+ argument = Array(node.arguments&.arguments).first
262
+ value = argument ? evaluate(argument) : nil_fact
263
+ return value if transfer?(value)
264
+
265
+ ControlTransfer.new(kind: kind, fact: value)
266
+ end
267
+
268
+ def evaluate_array(node)
269
+ elements = node.elements
270
+ return ValueFact.unknown(:array_splat) if node.contains_splat?
271
+
272
+ element_facts = elements.map do |element|
273
+ fact = evaluate(element)
274
+ return fact if transfer?(fact)
275
+
276
+ fact
277
+ end
278
+ return ValueFact.unknown(:array_budget) if elements.length > MAX_ATOMS
279
+
280
+ ValueFact.new(kind: :container, exact: true, origin: :literal_array,
281
+ summary: {
282
+ 'type' => 'array',
283
+ 'size' => elements.length,
284
+ 'element_fact' => join_facts(element_facts).to_h
285
+ })
286
+ end
287
+
288
+ def evaluate_hash(node)
289
+ elements = node.elements
290
+ return ValueFact.unknown(:hash_budget) if elements.length > MAX_ATOMS
291
+
292
+ complete = true
293
+ entries = { 'symbol' => {}, 'string' => {} }
294
+ elements.each do |element|
295
+ unless element.is_a?(Prism::AssocNode)
296
+ complete = false
297
+ evaluate_children(element)
298
+ next
299
+ end
300
+
301
+ key = literal_key(element.key)
302
+ value = evaluate(element.value)
303
+ return value if transfer?(value)
304
+
305
+ unless key
306
+ complete = false
307
+ evaluate(element.key)
308
+ next
309
+ end
310
+
311
+ kind, key_value = key
312
+ entries.fetch(kind)[key_value] = value.to_h
313
+ end
314
+ ValueFact.new(kind: :container, exact: complete, origin: :literal_hash,
315
+ summary: { 'type' => 'hash', 'size' => elements.length, 'entries' => entries })
316
+ end
317
+
318
+ def evaluate_callable(node)
319
+ previous_returns = @return_facts
320
+ @return_facts = []
321
+ value, = evaluate_branch(node.body)
322
+ value = return_fact(value)
323
+ ValueFact.new(kind: :callable_set, values: ['block'], exact: true, origin: :literal_callable,
324
+ summary: { 'return_fact' => value.to_h })
325
+ ensure
326
+ @return_facts = previous_returns
327
+ end
328
+
329
+ def container_lookup(receiver_fact, argument)
330
+ return ValueFact.unknown(:not_a_container) unless receiver_fact.kind == :container && receiver_fact.exact
331
+
332
+ key_fact = @value_facts[argument]
333
+ return ValueFact.unknown(:dynamic_container_key) unless
334
+ key_fact&.exact && %i[symbol_set string_set].include?(key_fact.kind)
335
+
336
+ kind = key_fact.kind == :symbol_set ? 'symbol' : 'string'
337
+ entries = receiver_fact.summary&.dig('entries', kind) || {}
338
+ values = key_fact.values.filter_map { |key| entries[key] }.map { |entry| ValueFact.from_h(entry) }
339
+ return ValueFact.unknown(:missing_container_key) unless values.length == key_fact.values.length
340
+
341
+ join_facts(values)
342
+ end
343
+
344
+ def callable_result(receiver_fact)
345
+ return ValueFact.unknown(:not_callable) unless receiver_fact.kind == :callable_set
346
+
347
+ return_fact = receiver_fact.summary&.fetch('return_fact', nil)
348
+ return_fact.is_a?(Hash) ? ValueFact.from_h(return_fact) : ValueFact.unknown(:unknown_callable_return)
349
+ end
350
+
351
+ def literal_key(node)
352
+ case node
353
+ when Prism::SymbolNode
354
+ ['symbol', node.value.to_s]
355
+ when Prism::StringNode
356
+ ['string', node.content.to_s]
357
+ end
358
+ end
359
+
360
+ def evaluate_unsupported(node)
361
+ written_names = local_writes(node)
362
+ written_names.each { |name| @locals[name] = ValueFact.unknown(node.type) }
363
+ evaluate_children(node)
364
+ written_names.each { |name| @locals[name] = ValueFact.unknown(node.type) }
365
+ ValueFact.unknown(node.type)
366
+ end
367
+
368
+ def evaluate_children(node)
369
+ node.child_nodes.compact.each do |child|
370
+ value = evaluate(child)
371
+ break if transfer?(value)
372
+ end
373
+ end
374
+
375
+ def local_writes(node)
376
+ pending = [node]
377
+ names = []
378
+ until pending.empty?
379
+ child = pending.pop
380
+ names << child.name if child.type.to_s.match?(/\Alocal_variable_(?:.+_)?write_node\z/)
381
+ pending.concat(child.child_nodes.compact)
382
+ end
383
+ names.uniq
384
+ end
385
+
386
+ def join_transfers(branches)
387
+ kinds = branches.map { |branch| branch[2] }.uniq
388
+ return ControlTransfer.new(kind: :unknown, fact: ValueFact.unknown(:mixed_control_transfer)) unless kinds.one?
389
+
390
+ ControlTransfer.new(kind: kinds.first, fact: join_facts(branches.map(&:first).map(&:fact)))
391
+ end
392
+
393
+ def transfer?(value)
394
+ value.is_a?(ControlTransfer)
395
+ end
396
+
397
+ def return_fact(value)
398
+ normal_value = transfer?(value) ? nil : value
399
+ facts = [*@return_facts, normal_value].compact
400
+ return value.fact if facts.empty? && transfer?(value)
401
+ return facts.first if facts.one?
402
+
403
+ join_facts(facts)
404
+ end
405
+
406
+ def nil_fact
407
+ ValueFact.new(kind: :nil, exact: true, nilable: true, origin: :implicit_nil)
408
+ end
409
+
410
+ def join_facts(facts)
411
+ facts = Array(facts).compact
412
+ return ValueFact.unknown(:empty_join) if facts.empty?
413
+ return ValueFact.unknown(:unknown_join) if facts.any? { |fact| fact.kind == :unknown || !fact.exact }
414
+
415
+ kinds = facts.map(&:kind).uniq
416
+ return ValueFact.unknown(:mixed_join) unless kinds.one?
417
+
418
+ values = facts.flat_map(&:values).uniq.sort
419
+ return ValueFact.unknown(:atom_budget) if values.length > MAX_ATOMS
420
+
421
+ ValueFact.new(
422
+ kind: kinds.first,
423
+ values: values,
424
+ exact: true,
425
+ nilable: facts.any?(&:nilable),
426
+ origin: :branch_join,
427
+ summary: facts.map(&:summary).compact.uniq.one? ? facts.first.summary : nil
428
+ )
429
+ end
430
+
431
+ def class_object(node)
432
+ name = constant_name(node)
433
+ return ValueFact.unknown(:dynamic_constant) unless name
434
+
435
+ ValueFact.new(kind: :class_object, values: [@constant_resolver.call(name)], origin: :constant)
436
+ end
437
+
438
+ def direct_constructor(node)
439
+ owner = constant_name(node.receiver)
440
+ return ValueFact.unknown(:dynamic_constructor) unless owner
441
+
442
+ ValueFact.instance_types([@constant_resolver.call(owner)])
443
+ end
444
+
445
+ def constant_name(node)
446
+ case node
447
+ when Prism::ConstantReadNode
448
+ node.name.to_s
449
+ when Prism::ConstantPathNode
450
+ prefix = node.parent ? constant_name(node.parent) : ''
451
+ [prefix, node.name.to_s].join('::')
452
+ end
453
+ end
454
+
455
+ def consume_step!
456
+ @steps += 1
457
+ raise StepBudgetExceeded if @steps > @max_steps
458
+ end
459
+ end
460
+ end