rigortype 0.1.17 → 0.1.19
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/README.md +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +187 -55
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +192 -0
- data/lib/rigor/plugin/registry.rb +264 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -49,9 +49,14 @@ module Rigor
|
|
|
49
49
|
# @param default_scope [Rigor::Scope] the scope used for the root,
|
|
50
50
|
# and the fallback returned for any Prism node not contained in
|
|
51
51
|
# `root`'s subtree.
|
|
52
|
+
# @param converged_loop_recording [Boolean] display-path flag —
|
|
53
|
+
# when true the evaluator re-records fixpoint-tracked loop
|
|
54
|
+
# bodies from their CONVERGED bindings so per-line probes
|
|
55
|
+
# (`rigor annotate`) reflect the post-writeback state, not the
|
|
56
|
+
# cap-N intermediate constants. Off for the check path.
|
|
52
57
|
# @return [Hash{Prism::Node => Rigor::Scope}] identity-comparing
|
|
53
58
|
# table whose default value is `default_scope`.
|
|
54
|
-
def index(root, default_scope:) # rubocop:disable Metrics/AbcSize
|
|
59
|
+
def index(root, default_scope:, converged_loop_recording: false) # rubocop:disable Metrics/AbcSize
|
|
55
60
|
# Slice A-declarations. Build the declaration overrides
|
|
56
61
|
# first so every scope handed to the StatementEvaluator
|
|
57
62
|
# already carries the table; structural sharing through
|
|
@@ -68,16 +73,17 @@ module Rigor
|
|
|
68
73
|
# collision — same-file declarations are the most
|
|
69
74
|
# specific authority.
|
|
70
75
|
merged_classes = default_scope.discovered_classes.merge(discovered_classes)
|
|
71
|
-
seeded_scope = default_scope
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
seeded_scope = default_scope.with_discovery(
|
|
77
|
+
default_scope.discovery.with(declared_types: declared_types,
|
|
78
|
+
discovered_classes: merged_classes)
|
|
79
|
+
)
|
|
74
80
|
|
|
75
81
|
# Slice 7 phase 2. Pre-pass over every class/module body
|
|
76
82
|
# to collect the per-class ivar accumulator. Seeded after
|
|
77
83
|
# declared_types so the rvalue typer in the pre-pass can
|
|
78
84
|
# see declaration overrides.
|
|
79
85
|
class_ivars = build_class_ivar_index(root, seeded_scope)
|
|
80
|
-
seeded_scope = seeded_scope.
|
|
86
|
+
seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_ivars: class_ivars))
|
|
81
87
|
|
|
82
88
|
# Slice 7 phase 6. Same pre-pass shape for cvars (per
|
|
83
89
|
# class) and globals (program-wide). Globals are also
|
|
@@ -86,9 +92,9 @@ module Rigor
|
|
|
86
92
|
# not enter a method body) observe the precise type
|
|
87
93
|
# without consulting the accumulator on every lookup.
|
|
88
94
|
class_cvars = build_class_cvar_index(root, seeded_scope)
|
|
89
|
-
seeded_scope = seeded_scope.
|
|
95
|
+
seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_cvars: class_cvars))
|
|
90
96
|
program_globals = build_program_global_index(root, seeded_scope)
|
|
91
|
-
seeded_scope = seeded_scope.
|
|
97
|
+
seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(program_globals: program_globals))
|
|
92
98
|
program_globals.each { |name, type| seeded_scope = seeded_scope.with_global(name, type) }
|
|
93
99
|
|
|
94
100
|
# Slice 7 phase 9. In-source constant value tracking.
|
|
@@ -99,7 +105,9 @@ module Rigor
|
|
|
99
105
|
# references resolve correctly. Multiple writes to the
|
|
100
106
|
# same qualified name union via `Type::Combinator.union`.
|
|
101
107
|
in_source_constants = build_in_source_constants(root, seeded_scope)
|
|
102
|
-
seeded_scope = seeded_scope.
|
|
108
|
+
seeded_scope = seeded_scope.with_discovery(
|
|
109
|
+
seeded_scope.discovery.with(in_source_constants: in_source_constants)
|
|
110
|
+
)
|
|
103
111
|
|
|
104
112
|
# Slice 7 phase 12. In-source method discovery. Walks
|
|
105
113
|
# every class/module body for `Prism::DefNode` and
|
|
@@ -115,7 +123,7 @@ module Rigor
|
|
|
115
123
|
discovered_methods = deep_merge_class_methods(
|
|
116
124
|
default_scope.discovered_methods, build_discovered_methods(root)
|
|
117
125
|
)
|
|
118
|
-
seeded_scope = seeded_scope.
|
|
126
|
+
seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(discovered_methods: discovered_methods))
|
|
119
127
|
|
|
120
128
|
# v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
|
|
121
129
|
# def nodes, the class -> superclass map, and the
|
|
@@ -143,7 +151,8 @@ module Rigor
|
|
|
143
151
|
# entry is the one that reflects all flow-derived
|
|
144
152
|
# rebinds, so it MUST overwrite the first.
|
|
145
153
|
on_enter = ->(node, scope) { table[node] = scope }
|
|
146
|
-
StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter
|
|
154
|
+
StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
|
|
155
|
+
converged_loop_recording: converged_loop_recording).evaluate(root)
|
|
147
156
|
|
|
148
157
|
propagate(root, table, seeded_scope)
|
|
149
158
|
table
|
|
@@ -161,6 +170,9 @@ module Rigor
|
|
|
161
170
|
def_nodes = default_scope.discovered_def_nodes.merge(
|
|
162
171
|
build_discovered_def_nodes(root)
|
|
163
172
|
) { |_class, cross_file, per_file| cross_file.merge(per_file) }
|
|
173
|
+
singleton_def_nodes = default_scope.discovered_singleton_def_nodes.merge(
|
|
174
|
+
build_discovered_singleton_def_nodes(root)
|
|
175
|
+
) { |_class, cross_file, per_file| cross_file.merge(per_file) }
|
|
164
176
|
superclasses = default_scope.discovered_superclasses.merge(
|
|
165
177
|
build_discovered_superclasses(root)
|
|
166
178
|
)
|
|
@@ -179,12 +191,16 @@ module Rigor
|
|
|
179
191
|
build_data_member_layouts(root)
|
|
180
192
|
)
|
|
181
193
|
|
|
182
|
-
seeded_scope
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
seeded_scope.with_discovery(
|
|
195
|
+
seeded_scope.discovery.with(
|
|
196
|
+
discovered_def_nodes: def_nodes,
|
|
197
|
+
discovered_singleton_def_nodes: singleton_def_nodes,
|
|
198
|
+
discovered_superclasses: superclasses,
|
|
199
|
+
discovered_includes: includes,
|
|
200
|
+
discovered_method_visibilities: method_visibilities,
|
|
201
|
+
data_member_layouts: data_member_layouts
|
|
202
|
+
)
|
|
203
|
+
)
|
|
188
204
|
end
|
|
189
205
|
|
|
190
206
|
# Slice 7 phase 2. Builds the class-level ivar accumulator
|
|
@@ -204,8 +220,15 @@ module Rigor
|
|
|
204
220
|
mutated_ivars = {}
|
|
205
221
|
read_before_write = {}
|
|
206
222
|
init_writes = {}
|
|
223
|
+
# WD3 — per-class summary of `{class_name => {method_name =>
|
|
224
|
+
# Set<ivar names definitely assigned non-nil on every
|
|
225
|
+
# completing path>}}`, consulted by `dead_transient_nil_writes`
|
|
226
|
+
# so a ctor that reassigns `@x` indirectly through an
|
|
227
|
+
# unconditional same-class method call (`mask!`) credits the
|
|
228
|
+
# overwrite. Built once per program here, memoised by class.
|
|
229
|
+
method_assign_effects = build_method_assign_effects(root)
|
|
207
230
|
walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
|
|
208
|
-
read_before_write, init_writes)
|
|
231
|
+
read_before_write, init_writes, method_assign_effects)
|
|
209
232
|
widen_mutated_ivar_entries!(accumulator, mutated_ivars)
|
|
210
233
|
contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
|
|
211
234
|
accumulator.transform_values(&:freeze).freeze
|
|
@@ -328,8 +351,8 @@ module Rigor
|
|
|
328
351
|
end
|
|
329
352
|
end
|
|
330
353
|
|
|
331
|
-
def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars,
|
|
332
|
-
read_before_write = nil, init_writes = nil)
|
|
354
|
+
def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists
|
|
355
|
+
read_before_write = nil, init_writes = nil, method_assign_effects = nil)
|
|
333
356
|
return unless node.is_a?(Prism::Node)
|
|
334
357
|
|
|
335
358
|
case node
|
|
@@ -355,24 +378,31 @@ module Rigor
|
|
|
355
378
|
# read.
|
|
356
379
|
collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
|
|
357
380
|
walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
|
|
358
|
-
mutated_ivars, read_before_write, init_writes)
|
|
381
|
+
mutated_ivars, read_before_write, init_writes, method_assign_effects)
|
|
359
382
|
end
|
|
360
383
|
return
|
|
361
384
|
end
|
|
362
385
|
when Prism::DefNode
|
|
363
386
|
collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
|
|
364
|
-
mutated_ivars, read_before_write, init_writes)
|
|
387
|
+
mutated_ivars, read_before_write, init_writes, method_assign_effects)
|
|
365
388
|
return
|
|
389
|
+
when Prism::CallNode
|
|
390
|
+
if init_writes && !qualified_prefix.empty? &&
|
|
391
|
+
node.block.is_a?(Prism::BlockNode) &&
|
|
392
|
+
block_initializer?(qualified_prefix.join("::"), node.name, default_scope)
|
|
393
|
+
collect_block_ivar_writes(node.block, qualified_prefix, default_scope,
|
|
394
|
+
accumulator, mutated_ivars, init_writes)
|
|
395
|
+
end
|
|
366
396
|
end
|
|
367
397
|
|
|
368
398
|
node.compact_child_nodes.each do |child|
|
|
369
399
|
walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
|
|
370
|
-
mutated_ivars, read_before_write, init_writes)
|
|
400
|
+
mutated_ivars, read_before_write, init_writes, method_assign_effects)
|
|
371
401
|
end
|
|
372
402
|
end
|
|
373
403
|
|
|
374
|
-
def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars,
|
|
375
|
-
read_before_write = nil, init_writes = nil)
|
|
404
|
+
def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/ParameterLists
|
|
405
|
+
read_before_write = nil, init_writes = nil, method_assign_effects = nil)
|
|
376
406
|
return if def_node.body.nil? || qualified_prefix.empty?
|
|
377
407
|
|
|
378
408
|
class_name = qualified_prefix.join("::")
|
|
@@ -386,7 +416,23 @@ module Rigor
|
|
|
386
416
|
end
|
|
387
417
|
body_scope = default_scope.with_self_type(self_type)
|
|
388
418
|
|
|
389
|
-
|
|
419
|
+
# C2 — transient `@x = nil` dead-write elimination. When a
|
|
420
|
+
# method body opens with an unconditional `@x = nil`
|
|
421
|
+
# (defensive init) and then *definitely* reassigns `@x` to a
|
|
422
|
+
# non-nil value on every completing path (a later
|
|
423
|
+
# unconditional statement-level write, OR an `if/else` whose
|
|
424
|
+
# both branches write `@x`), the opening nil is dead — it can
|
|
425
|
+
# never be observed at method exit. Recording it anyway folds
|
|
426
|
+
# a spurious `nil` constituent into the flow-insensitive
|
|
427
|
+
# class-ivar union, which then poisons reads in OTHER methods
|
|
428
|
+
# (e.g. ipaddr `IN4MASK ^ @mask_addr` rejects the resulting
|
|
429
|
+
# `Integer | nil`). The set holds the `object_id`s of the
|
|
430
|
+
# transient write nodes to skip; soundness is post-domination
|
|
431
|
+
# at the top statement level, so dropping the nil never hides
|
|
432
|
+
# a real runtime-nil read.
|
|
433
|
+
dead_writes = dead_transient_nil_writes(def_node.body, class_name, method_assign_effects)
|
|
434
|
+
gather_ivar_writes(def_node.body, body_scope, class_name, accumulator,
|
|
435
|
+
EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes)
|
|
390
436
|
|
|
391
437
|
# B2.3 — collect per-method evidence for the read-before-
|
|
392
438
|
# write nil contribution. The accumulator-level decision
|
|
@@ -399,6 +445,53 @@ module Rigor
|
|
|
399
445
|
collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
|
|
400
446
|
end
|
|
401
447
|
|
|
448
|
+
# ADR-38 block-form: collects ivar writes from a CallNode's
|
|
449
|
+
# block body (e.g. RSpec `before { @x = … }` / `let(:x) { … }`)
|
|
450
|
+
# and folds them into `init_writes`, suppressing the
|
|
451
|
+
# read-before-write nil contribution the same way a def-form
|
|
452
|
+
# initializer does. The block body is always treated as an
|
|
453
|
+
# initializer (the caller has already verified the method name
|
|
454
|
+
# is declared as a block_method initializer), so there is no
|
|
455
|
+
# read-before-write evidence collection step here.
|
|
456
|
+
def collect_block_ivar_writes(block_node, qualified_prefix, default_scope, accumulator,
|
|
457
|
+
mutated_ivars, init_writes)
|
|
458
|
+
return if block_node.body.nil? || qualified_prefix.empty?
|
|
459
|
+
|
|
460
|
+
class_name = qualified_prefix.join("::")
|
|
461
|
+
self_type = Type::Combinator.nominal_of(class_name)
|
|
462
|
+
body_scope = default_scope.with_self_type(self_type)
|
|
463
|
+
|
|
464
|
+
gather_ivar_writes(block_node.body, body_scope, class_name, accumulator,
|
|
465
|
+
EMPTY_GUARDED_IVARS, mutated_ivars)
|
|
466
|
+
|
|
467
|
+
seen_writes = Set.new
|
|
468
|
+
read_first = Set.new
|
|
469
|
+
detect_read_before_write(block_node.body, seen_writes, read_first)
|
|
470
|
+
init_set = (init_writes[class_name] ||= Set.new)
|
|
471
|
+
seen_writes.each { |name| init_set << name }
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# ADR-38 block-form gate: true when a loaded plugin declares
|
|
475
|
+
# `method_name` a block-form initializer for `class_name` (or
|
|
476
|
+
# an ancestor). Mirrors `additional_initializer?` but queries
|
|
477
|
+
# `covers_block_method?` instead of `covers_method?`.
|
|
478
|
+
def block_initializer?(class_name, method_name, default_scope)
|
|
479
|
+
return false if class_name.nil? || default_scope.nil?
|
|
480
|
+
|
|
481
|
+
environment = default_scope.environment
|
|
482
|
+
registry = environment&.plugin_registry
|
|
483
|
+
return false if registry.nil?
|
|
484
|
+
return false if registry.respond_to?(:empty?) && registry.empty?
|
|
485
|
+
return false unless registry.respond_to?(:additional_initializers)
|
|
486
|
+
|
|
487
|
+
registry.additional_initializers.any? do |entry|
|
|
488
|
+
entry.covers_block_method?(method_name) &&
|
|
489
|
+
class_matches_constraint?(class_name, entry.receiver_constraint, environment)
|
|
490
|
+
end
|
|
491
|
+
rescue StandardError
|
|
492
|
+
false
|
|
493
|
+
end
|
|
494
|
+
|
|
402
495
|
# Walks the method body in AST (== execution) order
|
|
403
496
|
# tracking ivar names whose first reference is a read.
|
|
404
497
|
# The set is unioned into the class-wide
|
|
@@ -529,13 +622,29 @@ module Rigor
|
|
|
529
622
|
private_constant :EMPTY_GUARDED_IVARS
|
|
530
623
|
|
|
531
624
|
def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
|
|
532
|
-
mutated_ivars = nil)
|
|
625
|
+
mutated_ivars = nil, dead_writes = nil)
|
|
533
626
|
return unless node.is_a?(Prism::Node)
|
|
534
627
|
|
|
535
|
-
if node.is_a?(Prism::InstanceVariableWriteNode)
|
|
628
|
+
if node.is_a?(Prism::InstanceVariableWriteNode) &&
|
|
629
|
+
!(dead_writes && dead_writes.include?(node.object_id))
|
|
536
630
|
record_ivar_write(node, scope, class_name, accumulator,
|
|
537
631
|
guarded: guarded_ivars.include?(node.name))
|
|
538
632
|
end
|
|
633
|
+
|
|
634
|
+
# N1 — parallel / multiple assignment (`old, @cb = @cb, block`,
|
|
635
|
+
# `@i, @o, @e, @thr = Open3.popen3(cmd)`). A direct
|
|
636
|
+
# `InstanceVariableWriteNode` is the only write form this
|
|
637
|
+
# collector handled, so an ivar appearing as a `MultiWriteNode`
|
|
638
|
+
# target was silently dropped from the class-ivar union — leaving
|
|
639
|
+
# it to seed as pure `nil` (from a sibling `@cb = nil` ctor write,
|
|
640
|
+
# or absent entirely) and false-fire `if @cb` always-falsey /
|
|
641
|
+
# `@thr.alive?` undefined-for-nil. Record each ivar target with
|
|
642
|
+
# its tuple-position RHS type where the RHS is array/tuple-shaped,
|
|
643
|
+
# else the unanalyzable floor (the same `Dynamic[top]` a single
|
|
644
|
+
# write to an unknown RHS records — an unanalyzable multi-write
|
|
645
|
+
# means unknown, not nil).
|
|
646
|
+
record_multi_write_ivars(node, scope, class_name, accumulator)
|
|
647
|
+
|
|
539
648
|
record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
|
|
540
649
|
|
|
541
650
|
# Don't recurse into nested defs, classes, or modules; their
|
|
@@ -543,12 +652,13 @@ module Rigor
|
|
|
543
652
|
return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
544
653
|
|
|
545
654
|
if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
546
|
-
walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
|
|
655
|
+
walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
|
|
656
|
+
mutated_ivars, dead_writes)
|
|
547
657
|
return
|
|
548
658
|
end
|
|
549
659
|
|
|
550
660
|
node.compact_child_nodes.each do |c|
|
|
551
|
-
gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
|
|
661
|
+
gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes)
|
|
552
662
|
end
|
|
553
663
|
end
|
|
554
664
|
|
|
@@ -586,16 +696,22 @@ module Rigor
|
|
|
586
696
|
# reads of `@x` would then surface a nil-receiver FP. The
|
|
587
697
|
# ELSE branch is left ungarded so those reads continue to type
|
|
588
698
|
# as they did before this fix.
|
|
589
|
-
def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
|
|
699
|
+
def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
|
|
700
|
+
mutated_ivars = nil, dead_writes = nil)
|
|
590
701
|
then_guards = then_body_guarded_ivars(node)
|
|
591
702
|
then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
|
|
592
703
|
|
|
593
|
-
gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars,
|
|
704
|
+
gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars,
|
|
705
|
+
mutated_ivars, dead_writes)
|
|
594
706
|
if node.statements
|
|
595
|
-
gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded,
|
|
707
|
+
gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded,
|
|
708
|
+
mutated_ivars, dead_writes)
|
|
596
709
|
end
|
|
597
710
|
branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
|
|
598
|
-
|
|
711
|
+
return unless branch
|
|
712
|
+
|
|
713
|
+
gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars,
|
|
714
|
+
mutated_ivars, dead_writes)
|
|
599
715
|
end
|
|
600
716
|
|
|
601
717
|
# Returns the set of ivar names that, in the THEN body of this
|
|
@@ -663,6 +779,332 @@ module Rigor
|
|
|
663
779
|
end
|
|
664
780
|
end
|
|
665
781
|
|
|
782
|
+
# C2 — returns a Set of `object_id`s for transient `@x = nil`
|
|
783
|
+
# writes that a later statement in the same method body
|
|
784
|
+
# *definitely* overwrites with a non-nil value on every
|
|
785
|
+
# completing path. Such a nil can never be the ivar's value at
|
|
786
|
+
# method exit, so it must not contribute a `nil` constituent to
|
|
787
|
+
# the (flow-insensitive) class-ivar union.
|
|
788
|
+
#
|
|
789
|
+
# Scope is deliberately narrow and post-domination-sound:
|
|
790
|
+
# - only the top-level statement sequence of the body is
|
|
791
|
+
# considered (no writes hidden inside loops / rescue / nested
|
|
792
|
+
# conditionals count as the "definite" overwrite, except the
|
|
793
|
+
# one structured `if/else` form below);
|
|
794
|
+
# - the killing statement is either an unconditional
|
|
795
|
+
# statement-level `@x = <non-nil>`, OR an `if/else` (with a
|
|
796
|
+
# real `else`) where BOTH branches' final top-level write to
|
|
797
|
+
# `@x` is non-nil. Both shapes overwrite `@x` on every path;
|
|
798
|
+
# - only `@x = nil` literal writes are ever marked dead — a
|
|
799
|
+
# non-nil transient is left untouched (it is already
|
|
800
|
+
# precision-additive in the union).
|
|
801
|
+
# WD3 — ADR-41-style hard cap on how deep the same-class-call
|
|
802
|
+
# definite-assignment crediting recurses (the ctor calls
|
|
803
|
+
# `mask!`, which could itself call another same-class helper).
|
|
804
|
+
# Cycle-guarded independently; the cap bounds even acyclic
|
|
805
|
+
# chains.
|
|
806
|
+
SAME_CLASS_CALL_DEPTH_CAP = 3
|
|
807
|
+
private_constant :SAME_CLASS_CALL_DEPTH_CAP
|
|
808
|
+
|
|
809
|
+
# WD3 — builds the per-class definite-assignment summary
|
|
810
|
+
# `{class_name => {method_name => Set<ivar names assigned
|
|
811
|
+
# non-nil on every completing path>}}`. Used so a ctor's
|
|
812
|
+
# `dead_transient_nil_writes` can credit an indirect overwrite
|
|
813
|
+
# through an unconditionally-called same-class method (ipaddr's
|
|
814
|
+
# `initialize` reassigns `@mask_addr` via `mask!`).
|
|
815
|
+
#
|
|
816
|
+
# Each method's set is computed by the same suffix
|
|
817
|
+
# definite-assignment analysis used for the ctor seed, run from
|
|
818
|
+
# the method body's first statement for every ivar the method
|
|
819
|
+
# writes anywhere. Same-class calls inside a method are credited
|
|
820
|
+
# transitively (depth-capped, cycle-guarded) so the resulting
|
|
821
|
+
# FLAT table is correct at depth 0 for the ctor lookup.
|
|
822
|
+
def build_method_assign_effects(root)
|
|
823
|
+
defs = collect_class_method_defs(root)
|
|
824
|
+
effects = {}
|
|
825
|
+
memo = {}.compare_by_identity
|
|
826
|
+
defs.each do |class_name, methods|
|
|
827
|
+
methods.each do |method_name, def_node|
|
|
828
|
+
assigns = method_definite_assigns(class_name, method_name, def_node, defs, effects, memo, 0)
|
|
829
|
+
(effects[class_name] ||= {})[method_name] = assigns unless assigns.empty?
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
effects.freeze
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Collects `{class_name => {method_name => DefNode}}` for every
|
|
836
|
+
# instance-method def in the program. Singleton defs (`def
|
|
837
|
+
# self.x`) are excluded — the ctor-call crediting only follows
|
|
838
|
+
# instance-method calls on `self`. Last def wins on redefinition.
|
|
839
|
+
def collect_class_method_defs(root, prefix = [], acc = {})
|
|
840
|
+
return acc unless root.is_a?(Prism::Node)
|
|
841
|
+
|
|
842
|
+
case root
|
|
843
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
844
|
+
name = qualified_name_for(root.constant_path)
|
|
845
|
+
if name && root.body
|
|
846
|
+
child = prefix + [name]
|
|
847
|
+
collect_class_method_defs(root.body, child, acc)
|
|
848
|
+
end
|
|
849
|
+
return acc
|
|
850
|
+
when Prism::DefNode
|
|
851
|
+
(acc[prefix.join("::")] ||= {})[root.name] = root unless prefix.empty? || root.receiver
|
|
852
|
+
return acc
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
root.compact_child_nodes.each { |c| collect_class_method_defs(c, prefix, acc) }
|
|
856
|
+
acc
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Computes the definite-assignment set for one method, memoised
|
|
860
|
+
# per def node. The `memo` cycle-guards: a method re-entered
|
|
861
|
+
# while its own summary is in progress contributes nothing
|
|
862
|
+
# (sound under-approximation), so mutual recursion terminates.
|
|
863
|
+
def method_definite_assigns(class_name, _method_name, def_node, defs, effects, memo, depth)
|
|
864
|
+
return Set.new if def_node.body.nil?
|
|
865
|
+
return memo[def_node] if memo.key?(def_node)
|
|
866
|
+
return Set.new if depth >= SAME_CLASS_CALL_DEPTH_CAP
|
|
867
|
+
|
|
868
|
+
memo[def_node] = Set.new # in-progress sentinel (cycle guard)
|
|
869
|
+
statements = top_level_statements(def_node.body)
|
|
870
|
+
candidates = ivar_write_targets(def_node.body)
|
|
871
|
+
# A transient `@x = nil` opener whose own method reassigns it
|
|
872
|
+
# later must still count `@x` as assigned for callers, so the
|
|
873
|
+
# crediting is computed at the BUILD-time depth.
|
|
874
|
+
resolver = MethodEffectResolver.new(self, class_name, defs, effects, memo, depth)
|
|
875
|
+
assigns = Set.new
|
|
876
|
+
candidates.each do |ivar|
|
|
877
|
+
assigns << ivar if suffix_definitely_assigns_with_resolver?(statements, 0, ivar, class_name, resolver, depth)
|
|
878
|
+
end
|
|
879
|
+
memo[def_node] = assigns
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
# Every ivar this body assigns a non-nil value to ANYWHERE (the
|
|
883
|
+
# candidate set for the method's definite-assignment scan).
|
|
884
|
+
def ivar_write_targets(node, acc = Set.new)
|
|
885
|
+
return acc unless node.is_a?(Prism::Node)
|
|
886
|
+
|
|
887
|
+
acc << node.name if node.is_a?(Prism::InstanceVariableWriteNode) && !nil_literal_value?(node.value)
|
|
888
|
+
node.compact_child_nodes.each { |c| ivar_write_targets(c, acc) }
|
|
889
|
+
acc
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Build-time variant of `suffix_definitely_assigns?` that resolves
|
|
893
|
+
# same-class calls through the lazy `resolver` (which recurses
|
|
894
|
+
# into `method_definite_assigns` for not-yet-computed callees)
|
|
895
|
+
# rather than the finished flat table.
|
|
896
|
+
def suffix_definitely_assigns_with_resolver?(statements, from, target, class_name, resolver, depth)
|
|
897
|
+
statements[from..].each do |stmt|
|
|
898
|
+
outcome = statement_assignment_outcome(stmt, target, class_name, resolver, depth, nil)
|
|
899
|
+
return true if outcome == :assigned
|
|
900
|
+
return false if outcome == :terminates_unassigned
|
|
901
|
+
end
|
|
902
|
+
false
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# Adapts `effects.dig(class, method)` for build-time crediting:
|
|
906
|
+
# when the callee summary is not yet in the flat table, compute
|
|
907
|
+
# it on demand (depth+1) via `method_definite_assigns`.
|
|
908
|
+
class MethodEffectResolver
|
|
909
|
+
def initialize(indexer, class_name, defs, effects, memo, depth)
|
|
910
|
+
@indexer = indexer
|
|
911
|
+
@class_name = class_name
|
|
912
|
+
@defs = defs
|
|
913
|
+
@effects = effects
|
|
914
|
+
@memo = memo
|
|
915
|
+
@depth = depth
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def dig(class_name, method_name)
|
|
919
|
+
existing = @effects.dig(class_name, method_name)
|
|
920
|
+
return existing if existing
|
|
921
|
+
|
|
922
|
+
def_node = @defs.dig(class_name, method_name)
|
|
923
|
+
return nil if def_node.nil?
|
|
924
|
+
|
|
925
|
+
@indexer.send(:method_definite_assigns, class_name, method_name, def_node, @defs, @effects, @memo,
|
|
926
|
+
@depth + 1)
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def dead_transient_nil_writes(body, class_name = nil, method_assign_effects = nil)
|
|
931
|
+
statements = top_level_statements(body)
|
|
932
|
+
return nil if statements.length < 2
|
|
933
|
+
|
|
934
|
+
dead = nil
|
|
935
|
+
|
|
936
|
+
statements.each_with_index do |stmt, i|
|
|
937
|
+
next unless stmt.is_a?(Prism::InstanceVariableWriteNode) && nil_literal_value?(stmt.value)
|
|
938
|
+
|
|
939
|
+
# The opening `@x = nil` is dead when every completing path
|
|
940
|
+
# of the SUFFIX after it (normal end OR early `return`,
|
|
941
|
+
# never a `raise`-terminated path) definitely reassigns
|
|
942
|
+
# `@x` non-nil. The suffix analysis credits an
|
|
943
|
+
# unconditionally-called same-class method's own definite
|
|
944
|
+
# assignments via `method_assign_effects`.
|
|
945
|
+
if suffix_definitely_assigns?(statements, i + 1, stmt.name, class_name, method_assign_effects)
|
|
946
|
+
(dead ||= Set.new) << stmt.object_id
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
dead
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def top_level_statements(body)
|
|
954
|
+
return [] if body.nil?
|
|
955
|
+
return body.body if body.is_a?(Prism::StatementsNode)
|
|
956
|
+
|
|
957
|
+
[body]
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def nil_literal_value?(node)
|
|
961
|
+
node.is_a?(Prism::NilNode)
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# True when, starting from `statements[from]`, EVERY path that
|
|
965
|
+
# completes the method (falls off the end OR hits an early
|
|
966
|
+
# `return`) definitely assigns `target` a non-nil value first.
|
|
967
|
+
# Paths terminated by `raise` are not completing paths and are
|
|
968
|
+
# ignored (they never observe the ivar at method exit). A path
|
|
969
|
+
# that can fall through `statements` without assigning fails.
|
|
970
|
+
def suffix_definitely_assigns?(statements, from, target, class_name, effects)
|
|
971
|
+
statements[from..].each do |stmt|
|
|
972
|
+
outcome = statement_assignment_outcome(stmt, target, class_name, effects, 0, nil)
|
|
973
|
+
# The statement assigned on every continuing path -> the
|
|
974
|
+
# suffix is satisfied no matter what follows.
|
|
975
|
+
return true if outcome == :assigned
|
|
976
|
+
# The statement terminates control here (return/raise) and
|
|
977
|
+
# the value it carried did not assign on every path -> some
|
|
978
|
+
# completing path reached exit without the assignment.
|
|
979
|
+
return false if outcome == :terminates_unassigned
|
|
980
|
+
# Otherwise (:falls_through_unassigned) keep scanning the
|
|
981
|
+
# remaining statements.
|
|
982
|
+
end
|
|
983
|
+
# Fell off the end with no definite assignment.
|
|
984
|
+
false
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Classifies a single statement's effect on `target`:
|
|
988
|
+
# :assigned — every path through the statement
|
|
989
|
+
# that continues OR returns assigns
|
|
990
|
+
# `target` non-nil (suffix is done);
|
|
991
|
+
# :terminates_unassigned — the statement ends the method
|
|
992
|
+
# (return/raise) on some path
|
|
993
|
+
# without a definite assignment, so
|
|
994
|
+
# a completing path escaped;
|
|
995
|
+
# :falls_through_unassigned — control may continue past it
|
|
996
|
+
# without the assignment (keep
|
|
997
|
+
# scanning the suffix).
|
|
998
|
+
def statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
|
|
999
|
+
case stmt
|
|
1000
|
+
when Prism::InstanceVariableWriteNode
|
|
1001
|
+
return :falls_through_unassigned if stmt.name != target
|
|
1002
|
+
|
|
1003
|
+
nil_literal_value?(stmt.value) ? :falls_through_unassigned : :assigned
|
|
1004
|
+
when Prism::CallNode
|
|
1005
|
+
if unconditional_call_assigns?(stmt, target, class_name, effects, depth, visiting)
|
|
1006
|
+
:assigned
|
|
1007
|
+
else
|
|
1008
|
+
:falls_through_unassigned
|
|
1009
|
+
end
|
|
1010
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
1011
|
+
conditional_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
|
|
1012
|
+
when Prism::CaseNode
|
|
1013
|
+
case_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
|
|
1014
|
+
when Prism::ReturnNode
|
|
1015
|
+
:terminates_unassigned
|
|
1016
|
+
else
|
|
1017
|
+
# Any other statement — including a bare `raise`/`fail`,
|
|
1018
|
+
# which terminates without a completing path that observes
|
|
1019
|
+
# the seed nil — is neutral: control either continues or the
|
|
1020
|
+
# path never reaches method exit. Keep scanning the suffix.
|
|
1021
|
+
:falls_through_unassigned
|
|
1022
|
+
end
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# True when a branch body (a StatementsNode / single node)
|
|
1026
|
+
# definitely assigns `target` non-nil on every path that
|
|
1027
|
+
# completes the method through it, OR terminates every path by
|
|
1028
|
+
# raise (vacuously safe — no completing path observes the seed
|
|
1029
|
+
# nil). Returns false if any path can complete/return without the
|
|
1030
|
+
# assignment.
|
|
1031
|
+
def branch_definitely_assigns?(branch, target, class_name, effects, depth, visiting)
|
|
1032
|
+
stmts = top_level_statements(branch)
|
|
1033
|
+
return false if stmts.empty?
|
|
1034
|
+
|
|
1035
|
+
stmts.each do |stmt|
|
|
1036
|
+
outcome = statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
|
|
1037
|
+
return true if outcome == :assigned
|
|
1038
|
+
return false if outcome == :terminates_unassigned
|
|
1039
|
+
end
|
|
1040
|
+
# Reached the end of the branch without a definite assignment;
|
|
1041
|
+
# safe only if the branch's last statement always raises (no
|
|
1042
|
+
# completing path falls out of it).
|
|
1043
|
+
always_raises?(stmts.last)
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# `if`/`unless` is a definite assignment of `target` only when
|
|
1047
|
+
# BOTH the then and else arms definitely assign (or raise-out).
|
|
1048
|
+
# A missing else arm means the fall-through path skips the
|
|
1049
|
+
# assignment -> not definite. Modifier-form `if`/`unless` (no
|
|
1050
|
+
# else, single predicate'd statement) likewise.
|
|
1051
|
+
def conditional_assignment_outcome(node, target, class_name, effects, depth, visiting)
|
|
1052
|
+
else_branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
|
|
1053
|
+
return :falls_through_unassigned unless else_branch.is_a?(Prism::ElseNode)
|
|
1054
|
+
return :falls_through_unassigned unless node.statements
|
|
1055
|
+
|
|
1056
|
+
then_ok = branch_definitely_assigns?(node.statements, target, class_name, effects, depth, visiting)
|
|
1057
|
+
else_ok = branch_definitely_assigns?(else_branch.statements, target, class_name, effects, depth, visiting)
|
|
1058
|
+
then_ok && else_ok ? :assigned : :falls_through_unassigned
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
# `case` is a definite assignment only when there is a real
|
|
1062
|
+
# `else` clause AND every `when`/`in` body plus the else body
|
|
1063
|
+
# definitely assigns (or raises-out). A missing else lets an
|
|
1064
|
+
# unmatched subject fall through unassigned.
|
|
1065
|
+
def case_assignment_outcome(node, target, class_name, effects, depth, visiting)
|
|
1066
|
+
else_clause = node.else_clause
|
|
1067
|
+
return :falls_through_unassigned unless else_clause.is_a?(Prism::ElseNode)
|
|
1068
|
+
|
|
1069
|
+
branches = node.conditions.map { |c| c.respond_to?(:statements) ? c.statements : nil }
|
|
1070
|
+
branches << else_clause.statements
|
|
1071
|
+
all_ok = branches.all? do |b|
|
|
1072
|
+
branch_definitely_assigns?(b, target, class_name, effects, depth, visiting)
|
|
1073
|
+
end
|
|
1074
|
+
all_ok ? :assigned : :falls_through_unassigned
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
# True when `node` (a single statement or its last statement) is
|
|
1078
|
+
# an unconditional `raise`/`fail` call that always terminates the
|
|
1079
|
+
# path — used to treat raise-terminated branches as
|
|
1080
|
+
# non-completing (they never observe the seed nil).
|
|
1081
|
+
def always_raises?(node)
|
|
1082
|
+
node = top_level_statements(node).last if node.is_a?(Prism::StatementsNode)
|
|
1083
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
1084
|
+
return false unless node.receiver.nil?
|
|
1085
|
+
|
|
1086
|
+
%i[raise fail].include?(node.name)
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# True when `call` is an unconditional, statement-level,
|
|
1090
|
+
# implicit-self (or `self.`) call to a SAME-CLASS method whose
|
|
1091
|
+
# definite-assignment summary includes `target`. Calls through a
|
|
1092
|
+
# block, on another receiver, or to an unresolved name contribute
|
|
1093
|
+
# nothing (the seed nil stays).
|
|
1094
|
+
def unconditional_call_assigns?(call, target, class_name, effects, depth, _visiting)
|
|
1095
|
+
return false if effects.nil? || class_name.nil?
|
|
1096
|
+
return false if depth >= SAME_CLASS_CALL_DEPTH_CAP
|
|
1097
|
+
return false unless call.is_a?(Prism::CallNode)
|
|
1098
|
+
return false unless call.block.nil?
|
|
1099
|
+
# Implicit self (`mask!(x)`) or explicit `self.mask!(x)` only.
|
|
1100
|
+
return false unless call.receiver.nil? || call.receiver.is_a?(Prism::SelfNode)
|
|
1101
|
+
|
|
1102
|
+
assigns = effects.dig(class_name, call.name)
|
|
1103
|
+
return false if assigns.nil?
|
|
1104
|
+
|
|
1105
|
+
assigns.include?(target)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
666
1108
|
def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
|
|
667
1109
|
rvalue_type = scope.type_of(node.value)
|
|
668
1110
|
|
|
@@ -687,10 +1129,104 @@ module Rigor
|
|
|
687
1129
|
return if guarded && falsey_constant?(rvalue_type)
|
|
688
1130
|
|
|
689
1131
|
rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded
|
|
1132
|
+
accumulate_ivar_type(accumulator, class_name, node.name, rvalue_type)
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Unions `type` into the class-ivar accumulator for `(class_name,
|
|
1136
|
+
# ivar_name)`. Shared by the single-write and multi-write
|
|
1137
|
+
# (parallel-assignment) collectors.
|
|
1138
|
+
def accumulate_ivar_type(accumulator, class_name, ivar_name, type)
|
|
690
1139
|
accumulator[class_name] ||= {}
|
|
691
|
-
existing = accumulator[class_name][
|
|
692
|
-
accumulator[class_name][
|
|
693
|
-
existing ? Type::Combinator.union(existing,
|
|
1140
|
+
existing = accumulator[class_name][ivar_name]
|
|
1141
|
+
accumulator[class_name][ivar_name] =
|
|
1142
|
+
existing ? Type::Combinator.union(existing, type) : type
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
# N1 — records each `InstanceVariableTargetNode` of a
|
|
1146
|
+
# `MultiWriteNode` (parallel / multiple assignment) into the
|
|
1147
|
+
# class-ivar union, with the best cheap per-slot type. When the RHS
|
|
1148
|
+
# is array/tuple-shaped (`Type::Tuple`) the ivar at position `i`
|
|
1149
|
+
# records the type of element `i`; otherwise — an unanalyzable RHS
|
|
1150
|
+
# such as `Open3.popen3(cmd)` typing to `Dynamic[top]` — every ivar
|
|
1151
|
+
# slot records that unanalyzable floor (NOT `nil`: a multi-write we
|
|
1152
|
+
# cannot decompose means the value is *unknown*, and `Dynamic[top]`
|
|
1153
|
+
# is the sound union constituent, mirroring what a single write to
|
|
1154
|
+
# an unknown RHS records). Nested targets (`(@a, @b), @c = …`)
|
|
1155
|
+
# recurse with the slot's type as the new RHS type.
|
|
1156
|
+
def record_multi_write_ivars(node, scope, class_name, accumulator)
|
|
1157
|
+
return unless node.is_a?(Prism::MultiWriteNode)
|
|
1158
|
+
|
|
1159
|
+
rhs_type = scope.type_of(node.value)
|
|
1160
|
+
record_multi_target_ivars(node, rhs_type, class_name, accumulator)
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
# Walks a `MultiWriteNode` / `MultiTargetNode` target tree against
|
|
1164
|
+
# `rhs_type`, recording ivar targets per slot. Mirrors
|
|
1165
|
+
# `MultiTargetBinder`'s tuple decomposition but for ivar (rather
|
|
1166
|
+
# than local-variable) targets.
|
|
1167
|
+
def record_multi_target_ivars(node, rhs_type, class_name, accumulator)
|
|
1168
|
+
lefts = node.lefts || []
|
|
1169
|
+
rest = node.rest
|
|
1170
|
+
rights = node.rights || []
|
|
1171
|
+
fronts, rest_type, backs =
|
|
1172
|
+
decompose_multi_write_rhs(rhs_type, lefts.size, rights.size, rest_present: !rest.nil?)
|
|
1173
|
+
|
|
1174
|
+
lefts.each_with_index { |t, i| record_multi_ivar_target(t, fronts[i], class_name, accumulator) }
|
|
1175
|
+
record_multi_ivar_rest(rest, rest_type, class_name, accumulator) if rest
|
|
1176
|
+
rights.each_with_index { |t, i| record_multi_ivar_target(t, backs[i], class_name, accumulator) }
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
def decompose_multi_write_rhs(rhs_type, front_count, back_count, rest_present:)
|
|
1180
|
+
if rhs_type.is_a?(Type::Tuple)
|
|
1181
|
+
elements = rhs_type.elements
|
|
1182
|
+
fronts = Array.new(front_count) { |i| multi_write_slot_type(elements, i) }
|
|
1183
|
+
if rest_present
|
|
1184
|
+
middle_end = [elements.size - back_count, front_count].max
|
|
1185
|
+
backs = Array.new(back_count) { |i| multi_write_slot_type(elements, middle_end + i) }
|
|
1186
|
+
[fronts, Type::Combinator.untyped, backs]
|
|
1187
|
+
else
|
|
1188
|
+
backs = Array.new(back_count) { |i| multi_write_slot_type(elements, front_count + i) }
|
|
1189
|
+
[fronts, nil, backs]
|
|
1190
|
+
end
|
|
1191
|
+
else
|
|
1192
|
+
# Unanalyzable / non-tuple RHS: every slot is the unknown floor.
|
|
1193
|
+
floor = Type::Combinator.untyped
|
|
1194
|
+
[Array.new(front_count) { floor }, rest_present ? floor : nil, Array.new(back_count) { floor }]
|
|
1195
|
+
end
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
# The per-slot type for index `i` of a tuple RHS. A missing slot
|
|
1199
|
+
# (over-destructure) is `nil` at runtime; a present slot keeps its
|
|
1200
|
+
# type. Unlike the local-variable binder we do NOT soften an
|
|
1201
|
+
# optional slot here — a class-ivar seed deliberately preserves a
|
|
1202
|
+
# genuine `T | nil`, and any spurious nil is removed by the
|
|
1203
|
+
# flow-side narrowing, not by dropping it at collection time.
|
|
1204
|
+
def multi_write_slot_type(elements, index)
|
|
1205
|
+
element = elements[index]
|
|
1206
|
+
return Type::Combinator.constant_of(nil) if element.nil?
|
|
1207
|
+
|
|
1208
|
+
element
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
def record_multi_ivar_target(target, type, class_name, accumulator)
|
|
1212
|
+
case target
|
|
1213
|
+
when Prism::InstanceVariableTargetNode
|
|
1214
|
+
accumulate_ivar_type(accumulator, class_name, target.name, type)
|
|
1215
|
+
when Prism::MultiTargetNode
|
|
1216
|
+
record_multi_target_ivars(target, type, class_name, accumulator)
|
|
1217
|
+
end
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def record_multi_ivar_rest(splat_node, _type, class_name, accumulator)
|
|
1221
|
+
return unless splat_node.is_a?(Prism::SplatNode)
|
|
1222
|
+
|
|
1223
|
+
expression = splat_node.expression
|
|
1224
|
+
return unless expression.is_a?(Prism::InstanceVariableTargetNode)
|
|
1225
|
+
|
|
1226
|
+
# A splat collects the middle slots into an Array; the precise
|
|
1227
|
+
# element type is not worth recovering here. Record the
|
|
1228
|
+
# unanalyzable floor (an Array of unknown), never nil.
|
|
1229
|
+
accumulate_ivar_type(accumulator, class_name, expression.name, Type::Combinator.untyped)
|
|
694
1230
|
end
|
|
695
1231
|
|
|
696
1232
|
def falsey_constant?(type)
|
|
@@ -1115,6 +1651,146 @@ module Rigor
|
|
|
1115
1651
|
accumulator[class_name][def_node.name] = def_node
|
|
1116
1652
|
end
|
|
1117
1653
|
|
|
1654
|
+
# Module-singleton call resolution (ADR-57 follow-up) — the
|
|
1655
|
+
# SINGLETON-side mirror of `build_discovered_def_nodes`. Records the
|
|
1656
|
+
# `Prism::DefNode` for every singleton-side method (`def self.x`,
|
|
1657
|
+
# `def Foo.x`, a `class << self` body, and a `module_function`
|
|
1658
|
+
# method) keyed by qualified class/module name → method → node, so
|
|
1659
|
+
# `ExpressionTyper` can re-type the body when a `Singleton[Foo]`
|
|
1660
|
+
# receiver dispatches `Foo.x`. The instance-side table is kept
|
|
1661
|
+
# singleton-free on purpose (its ancestor walk binds `self` as
|
|
1662
|
+
# `Nominal`), so the two never overlap except for `module_function`
|
|
1663
|
+
# defs, which are genuinely callable on both sides and so appear in
|
|
1664
|
+
# both tables. Top-level singleton defs (`def self.x` outside any
|
|
1665
|
+
# class — `self` is `main`) are not recorded; they have no constant
|
|
1666
|
+
# receiver to dispatch through.
|
|
1667
|
+
def build_discovered_singleton_def_nodes(root)
|
|
1668
|
+
accumulator = {}
|
|
1669
|
+
walk_singleton_def_nodes(root, [], false, accumulator)
|
|
1670
|
+
accumulator.transform_values(&:freeze).freeze
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
# Walks every node, entering class/module/singleton-class bodies via
|
|
1674
|
+
# {#walk_singleton_body} so a bare `module_function` toggle threads
|
|
1675
|
+
# correctly across the body's *sibling* statements (a child-by-child
|
|
1676
|
+
# recursion would reset it). At the top level / inside an arbitrary
|
|
1677
|
+
# node there is no `module_function` state to carry, so descent is a
|
|
1678
|
+
# plain per-child walk.
|
|
1679
|
+
def walk_singleton_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
|
|
1680
|
+
return unless node.is_a?(Prism::Node)
|
|
1681
|
+
|
|
1682
|
+
case node
|
|
1683
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
1684
|
+
name = qualified_name_for(node.constant_path)
|
|
1685
|
+
if name
|
|
1686
|
+
walk_singleton_body(node.body, qualified_prefix + [name], false, accumulator) if node.body
|
|
1687
|
+
return
|
|
1688
|
+
end
|
|
1689
|
+
when Prism::SingletonClassNode
|
|
1690
|
+
if node.body
|
|
1691
|
+
singleton_prefix = singleton_class_prefix(node, qualified_prefix)
|
|
1692
|
+
if singleton_prefix
|
|
1693
|
+
walk_singleton_body(node.body, singleton_prefix, true, accumulator)
|
|
1694
|
+
return
|
|
1695
|
+
end
|
|
1696
|
+
end
|
|
1697
|
+
when Prism::ConstantWriteNode
|
|
1698
|
+
if meta_new_block_body(node)
|
|
1699
|
+
child_prefix = qualified_prefix + [node.name.to_s]
|
|
1700
|
+
walk_singleton_body(meta_new_block_body(node), child_prefix, false, accumulator)
|
|
1701
|
+
return
|
|
1702
|
+
end
|
|
1703
|
+
when Prism::DefNode
|
|
1704
|
+
record_singleton_def_node(node, qualified_prefix, in_singleton_class, false, accumulator)
|
|
1705
|
+
return
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1708
|
+
node.compact_child_nodes.each do |child|
|
|
1709
|
+
walk_singleton_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
|
|
1710
|
+
end
|
|
1711
|
+
end
|
|
1712
|
+
|
|
1713
|
+
# Walks a class/module/singleton-class body's direct statements in
|
|
1714
|
+
# source order, threading the bare-`module_function` toggle: once a
|
|
1715
|
+
# bare `module_function` is seen, every subsequent `def` in the body
|
|
1716
|
+
# registers as a singleton method. Nested classes/modules/defs and
|
|
1717
|
+
# `module_function :a, :b` named forms recurse / record through the
|
|
1718
|
+
# general walker so the toggle stays scoped to its own body.
|
|
1719
|
+
def walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator)
|
|
1720
|
+
module_function_on = false
|
|
1721
|
+
statements_of(body).each do |stmt|
|
|
1722
|
+
if stmt.is_a?(Prism::CallNode) && module_function_toggle?(stmt)
|
|
1723
|
+
if bare_module_function?(stmt)
|
|
1724
|
+
module_function_on = true
|
|
1725
|
+
else
|
|
1726
|
+
record_module_function_names(stmt, qualified_prefix, body, accumulator)
|
|
1727
|
+
end
|
|
1728
|
+
next
|
|
1729
|
+
end
|
|
1730
|
+
if stmt.is_a?(Prism::DefNode)
|
|
1731
|
+
record_singleton_def_node(stmt, qualified_prefix, in_singleton_class, module_function_on, accumulator)
|
|
1732
|
+
next
|
|
1733
|
+
end
|
|
1734
|
+
walk_singleton_def_nodes(stmt, qualified_prefix, in_singleton_class, accumulator)
|
|
1735
|
+
end
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
# Direct statement children of a class/module body node (a
|
|
1739
|
+
# `Prism::StatementsNode`, a `Prism::BeginNode` wrapping one, or a
|
|
1740
|
+
# lone statement). Returns an empty list for an empty body.
|
|
1741
|
+
def statements_of(body)
|
|
1742
|
+
case body
|
|
1743
|
+
when Prism::StatementsNode then body.body
|
|
1744
|
+
when Prism::BeginNode then statements_of(body.statements)
|
|
1745
|
+
when nil then []
|
|
1746
|
+
else [body]
|
|
1747
|
+
end
|
|
1748
|
+
end
|
|
1749
|
+
|
|
1750
|
+
def record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator)
|
|
1751
|
+
singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class) || module_function_on
|
|
1752
|
+
return unless singleton
|
|
1753
|
+
return if qualified_prefix.empty?
|
|
1754
|
+
|
|
1755
|
+
class_name = qualified_prefix.join("::")
|
|
1756
|
+
(accumulator[class_name] ||= {})[def_node.name] = def_node
|
|
1757
|
+
end
|
|
1758
|
+
|
|
1759
|
+
# A bare `module_function` (no arguments) flips every following `def`
|
|
1760
|
+
# in the module body to module-function (instance + singleton) mode.
|
|
1761
|
+
def module_function_toggle?(node)
|
|
1762
|
+
node.name == :module_function && node.receiver.nil?
|
|
1763
|
+
end
|
|
1764
|
+
|
|
1765
|
+
def bare_module_function?(node)
|
|
1766
|
+
node.arguments.nil? || node.arguments.arguments.empty?
|
|
1767
|
+
end
|
|
1768
|
+
|
|
1769
|
+
# `module_function :a, :b` retro-marks named siblings (defined
|
|
1770
|
+
# earlier OR later in the same body) as module-functions. Resolves
|
|
1771
|
+
# each symbol-literal argument against the body's own `def`s and
|
|
1772
|
+
# registers the matching `DefNode` on the module's singleton side.
|
|
1773
|
+
# Non-symbol arguments and names with no matching `def` are skipped
|
|
1774
|
+
# (a miss degrades to today's `Dynamic`, never a false resolution).
|
|
1775
|
+
def record_module_function_names(node, qualified_prefix, body, accumulator)
|
|
1776
|
+
return if qualified_prefix.empty?
|
|
1777
|
+
|
|
1778
|
+
defs_by_name = statements_of(body).each_with_object({}) do |stmt, acc|
|
|
1779
|
+
acc[stmt.name] = stmt if stmt.is_a?(Prism::DefNode) && stmt.receiver.nil?
|
|
1780
|
+
end
|
|
1781
|
+
class_name = qualified_prefix.join("::")
|
|
1782
|
+
node.arguments&.arguments&.each do |arg|
|
|
1783
|
+
name = symbol_argument_name(arg)
|
|
1784
|
+
def_node = name && defs_by_name[name]
|
|
1785
|
+
(accumulator[class_name] ||= {})[name] = def_node if def_node
|
|
1786
|
+
end
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
# The Symbol value of a `:name` / `"name"` literal argument, or nil.
|
|
1790
|
+
def symbol_argument_name(arg)
|
|
1791
|
+
arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
|
|
1792
|
+
end
|
|
1793
|
+
|
|
1118
1794
|
# ADR-24 slice 2 — per-class table mapping a fully
|
|
1119
1795
|
# qualified user class to its superclass name AS WRITTEN
|
|
1120
1796
|
# at the `class Foo < Bar` declaration. Only constant
|
|
@@ -1575,8 +2251,8 @@ module Rigor
|
|
|
1575
2251
|
# @return [Hash{Symbol => Hash}]
|
|
1576
2252
|
# `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
|
|
1577
2253
|
def discovered_def_index_for_paths(paths, buffer: nil)
|
|
1578
|
-
acc = { def_nodes: {},
|
|
1579
|
-
class_sources: {}, data_member_layouts: {} }
|
|
2254
|
+
acc = { def_nodes: {}, singleton_def_nodes: {}, def_sources: {}, superclasses: {}, includes: {},
|
|
2255
|
+
method_visibilities: {}, methods: {}, class_sources: {}, data_member_layouts: {} }
|
|
1580
2256
|
paths.each do |path|
|
|
1581
2257
|
physical = buffer ? buffer.resolve(path) : path
|
|
1582
2258
|
root = Prism.parse(File.read(physical), filepath: path).value
|
|
@@ -1595,7 +2271,7 @@ module Rigor
|
|
|
1595
2271
|
# intact while still letting `attr_reader :x` in one file
|
|
1596
2272
|
# suppress a false undefined-method for `obj.x` in another.
|
|
1597
2273
|
acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
|
|
1598
|
-
%i[def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
|
|
2274
|
+
%i[def_nodes singleton_def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
|
|
1599
2275
|
acc[key].each_value(&:freeze)
|
|
1600
2276
|
end
|
|
1601
2277
|
acc.transform_values(&:freeze)
|
|
@@ -1618,6 +2294,9 @@ module Rigor
|
|
|
1618
2294
|
# visibility declared in a sibling file.
|
|
1619
2295
|
def accumulate_project_index(acc, path, root)
|
|
1620
2296
|
merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
|
|
2297
|
+
build_discovered_singleton_def_nodes(root).each do |class_name, methods|
|
|
2298
|
+
(acc[:singleton_def_nodes][class_name] ||= {}).merge!(methods)
|
|
2299
|
+
end
|
|
1621
2300
|
superclasses = build_discovered_superclasses(root)
|
|
1622
2301
|
includes = build_discovered_includes(root)
|
|
1623
2302
|
acc[:superclasses].merge!(superclasses)
|
|
@@ -1694,11 +2373,63 @@ module Rigor
|
|
|
1694
2373
|
when Prism::ModuleNode
|
|
1695
2374
|
name = qualified_name_for(node.constant_path)
|
|
1696
2375
|
return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body
|
|
2376
|
+
when Prism::ConstantWriteNode
|
|
2377
|
+
record_class_new_constant_decl(node, qualified_prefix, accumulator)
|
|
1697
2378
|
end
|
|
1698
2379
|
|
|
1699
2380
|
node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
|
|
1700
2381
|
end
|
|
1701
2382
|
|
|
2383
|
+
# T1 (template-corpora survey) — record a `Const = Class.new(Super)`
|
|
2384
|
+
# (and the bare `Class.new` / `Module.new`) class-creating constant
|
|
2385
|
+
# in the cross-file discovery table so a reference to `Const` from
|
|
2386
|
+
# ANOTHER file under the same namespace resolves to the project
|
|
2387
|
+
# class instead of falling through to a core same-named class
|
|
2388
|
+
# (`Liquid::SyntaxError = Class.new(Error)` referenced in a sibling
|
|
2389
|
+
# file's `rescue SyntaxError => e`, which otherwise resolved to core
|
|
2390
|
+
# `::SyntaxError`). Mirrors the single-file `in_source_constants`
|
|
2391
|
+
# answer, which types `Class.new(Super)` as `Singleton[Super]` (the
|
|
2392
|
+
# constructed class answers method lookups through Super's chain).
|
|
2393
|
+
# The superclass name is resolved lexically against the enclosing
|
|
2394
|
+
# prefix; a bare `Class.new` with no superclass (or `Module.new`)
|
|
2395
|
+
# types as `Singleton[Const]` itself. The block form is left to the
|
|
2396
|
+
# existing `meta_new_block_body` machinery — only the plain
|
|
2397
|
+
# `Class.new(Super)` constant (the namespaced-sibling-error idiom)
|
|
2398
|
+
# is added here.
|
|
2399
|
+
def record_class_new_constant_decl(node, qualified_prefix, accumulator)
|
|
2400
|
+
rvalue = node.value
|
|
2401
|
+
return unless class_new_call?(rvalue) || module_new_call?(rvalue)
|
|
2402
|
+
return if rvalue.block # block form: handled by meta_new_block_body walks
|
|
2403
|
+
|
|
2404
|
+
full = (qualified_prefix + [node.name.to_s]).join("::")
|
|
2405
|
+
super_name = class_new_superclass_name(rvalue, qualified_prefix, accumulator)
|
|
2406
|
+
accumulator[full] = Type::Combinator.singleton_of(super_name || full)
|
|
2407
|
+
end
|
|
2408
|
+
|
|
2409
|
+
# Lexically-qualified name of a `Class.new(Super)` superclass
|
|
2410
|
+
# argument, or nil when there is no positional superclass (a bare
|
|
2411
|
+
# `Class.new` / `Module.new`). When the unqualified super name is a
|
|
2412
|
+
# class already discovered under an enclosing-prefix segment, the
|
|
2413
|
+
# qualified form is returned (so `Class.new(Error)` inside `module M`
|
|
2414
|
+
# resolves to `M::Error`); otherwise the literal name is returned
|
|
2415
|
+
# (covering a core / RBS-known superclass spelled bare).
|
|
2416
|
+
def class_new_superclass_name(call_node, qualified_prefix, accumulator)
|
|
2417
|
+
arg = call_node.arguments&.arguments&.first
|
|
2418
|
+
return nil if arg.nil?
|
|
2419
|
+
|
|
2420
|
+
raw = qualified_name_for(arg)
|
|
2421
|
+
return nil if raw.nil?
|
|
2422
|
+
|
|
2423
|
+
prefix = qualified_prefix.dup
|
|
2424
|
+
until prefix.empty?
|
|
2425
|
+
candidate = (prefix + [raw]).join("::")
|
|
2426
|
+
return candidate if accumulator.key?(candidate)
|
|
2427
|
+
|
|
2428
|
+
prefix.pop
|
|
2429
|
+
end
|
|
2430
|
+
raw
|
|
2431
|
+
end
|
|
2432
|
+
|
|
1702
2433
|
# Walks the program once for `Prism::ModuleNode` and
|
|
1703
2434
|
# `Prism::ClassNode`, recording the `Singleton[<qualified>]`
|
|
1704
2435
|
# type for the outermost `constant_path` node of each
|