rigortype 0.1.18 → 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 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- 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 +169 -23
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +266 -63
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
- data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
- data/lib/rigor/analysis/runner.rb +58 -21
- data/lib/rigor/analysis/worker_session.rb +21 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +3 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +57 -1
- data/lib/rigor/environment/rbs_loader.rb +25 -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 +1052 -43
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- 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/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 +72 -1
- 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 +270 -37
- data/lib/rigor/inference/scope_indexer.rb +696 -25
- data/lib/rigor/inference/statement_evaluator.rb +963 -16
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/base.rb +235 -79
- 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 +59 -14
- data/lib/rigor/plugin/registry.rb +12 -11
- data/lib/rigor/scope/discovery_index.rb +2 -0
- data/lib/rigor/scope.rb +132 -6
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +29 -0
- data/lib/rigor/version.rb +1 -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.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- 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.rb +2 -13
- 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.rb +25 -0
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +10 -1
- 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-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 +7 -2
- 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
|
|
@@ -146,7 +151,8 @@ module Rigor
|
|
|
146
151
|
# entry is the one that reflects all flow-derived
|
|
147
152
|
# rebinds, so it MUST overwrite the first.
|
|
148
153
|
on_enter = ->(node, scope) { table[node] = scope }
|
|
149
|
-
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)
|
|
150
156
|
|
|
151
157
|
propagate(root, table, seeded_scope)
|
|
152
158
|
table
|
|
@@ -164,6 +170,9 @@ module Rigor
|
|
|
164
170
|
def_nodes = default_scope.discovered_def_nodes.merge(
|
|
165
171
|
build_discovered_def_nodes(root)
|
|
166
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) }
|
|
167
176
|
superclasses = default_scope.discovered_superclasses.merge(
|
|
168
177
|
build_discovered_superclasses(root)
|
|
169
178
|
)
|
|
@@ -185,6 +194,7 @@ module Rigor
|
|
|
185
194
|
seeded_scope.with_discovery(
|
|
186
195
|
seeded_scope.discovery.with(
|
|
187
196
|
discovered_def_nodes: def_nodes,
|
|
197
|
+
discovered_singleton_def_nodes: singleton_def_nodes,
|
|
188
198
|
discovered_superclasses: superclasses,
|
|
189
199
|
discovered_includes: includes,
|
|
190
200
|
discovered_method_visibilities: method_visibilities,
|
|
@@ -210,8 +220,15 @@ module Rigor
|
|
|
210
220
|
mutated_ivars = {}
|
|
211
221
|
read_before_write = {}
|
|
212
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)
|
|
213
230
|
walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
|
|
214
|
-
read_before_write, init_writes)
|
|
231
|
+
read_before_write, init_writes, method_assign_effects)
|
|
215
232
|
widen_mutated_ivar_entries!(accumulator, mutated_ivars)
|
|
216
233
|
contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
|
|
217
234
|
accumulator.transform_values(&:freeze).freeze
|
|
@@ -334,8 +351,8 @@ module Rigor
|
|
|
334
351
|
end
|
|
335
352
|
end
|
|
336
353
|
|
|
337
|
-
def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity
|
|
338
|
-
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)
|
|
339
356
|
return unless node.is_a?(Prism::Node)
|
|
340
357
|
|
|
341
358
|
case node
|
|
@@ -361,13 +378,13 @@ module Rigor
|
|
|
361
378
|
# read.
|
|
362
379
|
collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
|
|
363
380
|
walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
|
|
364
|
-
mutated_ivars, read_before_write, init_writes)
|
|
381
|
+
mutated_ivars, read_before_write, init_writes, method_assign_effects)
|
|
365
382
|
end
|
|
366
383
|
return
|
|
367
384
|
end
|
|
368
385
|
when Prism::DefNode
|
|
369
386
|
collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
|
|
370
|
-
mutated_ivars, read_before_write, init_writes)
|
|
387
|
+
mutated_ivars, read_before_write, init_writes, method_assign_effects)
|
|
371
388
|
return
|
|
372
389
|
when Prism::CallNode
|
|
373
390
|
if init_writes && !qualified_prefix.empty? &&
|
|
@@ -380,12 +397,12 @@ module Rigor
|
|
|
380
397
|
|
|
381
398
|
node.compact_child_nodes.each do |child|
|
|
382
399
|
walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
|
|
383
|
-
mutated_ivars, read_before_write, init_writes)
|
|
400
|
+
mutated_ivars, read_before_write, init_writes, method_assign_effects)
|
|
384
401
|
end
|
|
385
402
|
end
|
|
386
403
|
|
|
387
|
-
def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars,
|
|
388
|
-
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)
|
|
389
406
|
return if def_node.body.nil? || qualified_prefix.empty?
|
|
390
407
|
|
|
391
408
|
class_name = qualified_prefix.join("::")
|
|
@@ -399,7 +416,23 @@ module Rigor
|
|
|
399
416
|
end
|
|
400
417
|
body_scope = default_scope.with_self_type(self_type)
|
|
401
418
|
|
|
402
|
-
|
|
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)
|
|
403
436
|
|
|
404
437
|
# B2.3 — collect per-method evidence for the read-before-
|
|
405
438
|
# write nil contribution. The accumulator-level decision
|
|
@@ -589,13 +622,29 @@ module Rigor
|
|
|
589
622
|
private_constant :EMPTY_GUARDED_IVARS
|
|
590
623
|
|
|
591
624
|
def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
|
|
592
|
-
mutated_ivars = nil)
|
|
625
|
+
mutated_ivars = nil, dead_writes = nil)
|
|
593
626
|
return unless node.is_a?(Prism::Node)
|
|
594
627
|
|
|
595
|
-
if node.is_a?(Prism::InstanceVariableWriteNode)
|
|
628
|
+
if node.is_a?(Prism::InstanceVariableWriteNode) &&
|
|
629
|
+
!(dead_writes && dead_writes.include?(node.object_id))
|
|
596
630
|
record_ivar_write(node, scope, class_name, accumulator,
|
|
597
631
|
guarded: guarded_ivars.include?(node.name))
|
|
598
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
|
+
|
|
599
648
|
record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
|
|
600
649
|
|
|
601
650
|
# Don't recurse into nested defs, classes, or modules; their
|
|
@@ -603,12 +652,13 @@ module Rigor
|
|
|
603
652
|
return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
604
653
|
|
|
605
654
|
if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
606
|
-
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)
|
|
607
657
|
return
|
|
608
658
|
end
|
|
609
659
|
|
|
610
660
|
node.compact_child_nodes.each do |c|
|
|
611
|
-
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)
|
|
612
662
|
end
|
|
613
663
|
end
|
|
614
664
|
|
|
@@ -646,16 +696,22 @@ module Rigor
|
|
|
646
696
|
# reads of `@x` would then surface a nil-receiver FP. The
|
|
647
697
|
# ELSE branch is left ungarded so those reads continue to type
|
|
648
698
|
# as they did before this fix.
|
|
649
|
-
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)
|
|
650
701
|
then_guards = then_body_guarded_ivars(node)
|
|
651
702
|
then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
|
|
652
703
|
|
|
653
|
-
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)
|
|
654
706
|
if node.statements
|
|
655
|
-
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)
|
|
656
709
|
end
|
|
657
710
|
branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
|
|
658
|
-
|
|
711
|
+
return unless branch
|
|
712
|
+
|
|
713
|
+
gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars,
|
|
714
|
+
mutated_ivars, dead_writes)
|
|
659
715
|
end
|
|
660
716
|
|
|
661
717
|
# Returns the set of ivar names that, in the THEN body of this
|
|
@@ -723,6 +779,332 @@ module Rigor
|
|
|
723
779
|
end
|
|
724
780
|
end
|
|
725
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
|
+
|
|
726
1108
|
def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
|
|
727
1109
|
rvalue_type = scope.type_of(node.value)
|
|
728
1110
|
|
|
@@ -747,10 +1129,104 @@ module Rigor
|
|
|
747
1129
|
return if guarded && falsey_constant?(rvalue_type)
|
|
748
1130
|
|
|
749
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)
|
|
750
1139
|
accumulator[class_name] ||= {}
|
|
751
|
-
existing = accumulator[class_name][
|
|
752
|
-
accumulator[class_name][
|
|
753
|
-
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)
|
|
754
1230
|
end
|
|
755
1231
|
|
|
756
1232
|
def falsey_constant?(type)
|
|
@@ -1175,6 +1651,146 @@ module Rigor
|
|
|
1175
1651
|
accumulator[class_name][def_node.name] = def_node
|
|
1176
1652
|
end
|
|
1177
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
|
+
|
|
1178
1794
|
# ADR-24 slice 2 — per-class table mapping a fully
|
|
1179
1795
|
# qualified user class to its superclass name AS WRITTEN
|
|
1180
1796
|
# at the `class Foo < Bar` declaration. Only constant
|
|
@@ -1635,8 +2251,8 @@ module Rigor
|
|
|
1635
2251
|
# @return [Hash{Symbol => Hash}]
|
|
1636
2252
|
# `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
|
|
1637
2253
|
def discovered_def_index_for_paths(paths, buffer: nil)
|
|
1638
|
-
acc = { def_nodes: {},
|
|
1639
|
-
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: {} }
|
|
1640
2256
|
paths.each do |path|
|
|
1641
2257
|
physical = buffer ? buffer.resolve(path) : path
|
|
1642
2258
|
root = Prism.parse(File.read(physical), filepath: path).value
|
|
@@ -1655,7 +2271,7 @@ module Rigor
|
|
|
1655
2271
|
# intact while still letting `attr_reader :x` in one file
|
|
1656
2272
|
# suppress a false undefined-method for `obj.x` in another.
|
|
1657
2273
|
acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
|
|
1658
|
-
%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|
|
|
1659
2275
|
acc[key].each_value(&:freeze)
|
|
1660
2276
|
end
|
|
1661
2277
|
acc.transform_values(&:freeze)
|
|
@@ -1678,6 +2294,9 @@ module Rigor
|
|
|
1678
2294
|
# visibility declared in a sibling file.
|
|
1679
2295
|
def accumulate_project_index(acc, path, root)
|
|
1680
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
|
|
1681
2300
|
superclasses = build_discovered_superclasses(root)
|
|
1682
2301
|
includes = build_discovered_includes(root)
|
|
1683
2302
|
acc[:superclasses].merge!(superclasses)
|
|
@@ -1754,11 +2373,63 @@ module Rigor
|
|
|
1754
2373
|
when Prism::ModuleNode
|
|
1755
2374
|
name = qualified_name_for(node.constant_path)
|
|
1756
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)
|
|
1757
2378
|
end
|
|
1758
2379
|
|
|
1759
2380
|
node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
|
|
1760
2381
|
end
|
|
1761
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
|
+
|
|
1762
2433
|
# Walks the program once for `Prism::ModuleNode` and
|
|
1763
2434
|
# `Prism::ClassNode`, recording the `Singleton[<qualified>]`
|
|
1764
2435
|
# type for the outermost `constant_path` node of each
|