rigortype 0.1.11 → 0.1.13
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/lib/rigor/analysis/check_rules.rb +96 -3
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli/skill_command.rb +170 -0
- data/lib/rigor/cli.rb +37 -1
- data/lib/rigor/configuration/severity_profile.rb +3 -0
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +195 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
- data/sig/rigor/scope.rbs +23 -0
- data/skills/rigor-baseline-reduce/SKILL.md +100 -0
- data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
- data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
- data/skills/rigor-plugin-author/SKILL.md +95 -0
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
- data/skills/rigor-project-init/SKILL.md +129 -0
- data/skills/rigor-project-init/references/01-detect.md +101 -0
- data/skills/rigor-project-init/references/02-configure.md +185 -0
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
- data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
- metadata +22 -1
|
@@ -8,9 +8,11 @@ require_relative "../analysis/fact_store"
|
|
|
8
8
|
require_relative "../source/node_walker"
|
|
9
9
|
require_relative "block_parameter_binder"
|
|
10
10
|
require_relative "closure_escape_analyzer"
|
|
11
|
+
require_relative "indexed_narrowing"
|
|
11
12
|
require_relative "method_dispatcher"
|
|
12
13
|
require_relative "method_parameter_binder"
|
|
13
14
|
require_relative "multi_target_binder"
|
|
15
|
+
require_relative "mutation_widening"
|
|
14
16
|
require_relative "narrowing"
|
|
15
17
|
|
|
16
18
|
module Rigor
|
|
@@ -72,6 +74,7 @@ module Rigor
|
|
|
72
74
|
Prism::GlobalVariableOrWriteNode => :eval_global_or_write,
|
|
73
75
|
Prism::GlobalVariableAndWriteNode => :eval_global_and_write,
|
|
74
76
|
Prism::GlobalVariableOperatorWriteNode => :eval_global_operator_write,
|
|
77
|
+
Prism::IndexOrWriteNode => :eval_index_or_write,
|
|
75
78
|
Prism::MultiWriteNode => :eval_multi_write,
|
|
76
79
|
Prism::IfNode => :eval_if,
|
|
77
80
|
Prism::UnlessNode => :eval_unless,
|
|
@@ -300,6 +303,53 @@ module Rigor
|
|
|
300
303
|
end
|
|
301
304
|
end
|
|
302
305
|
|
|
306
|
+
# `receiver[key] ||= default` — the Redmine `Query#as_params`
|
|
307
|
+
# idiom (ROADMAP § Future cycles / Type-language / engine —
|
|
308
|
+
# "Indexed-collection narrowing through `Hash[k] ||= default`").
|
|
309
|
+
# After the `||=`, the next read at `receiver[key]` is known
|
|
310
|
+
# non-nil; the next `<<` / `[]=` / other mutator runs against
|
|
311
|
+
# a Tuple / Hash carrier instead of the `Constant[nil]` an
|
|
312
|
+
# empty `HashShape{}` lookup would otherwise fold to.
|
|
313
|
+
#
|
|
314
|
+
# The handler:
|
|
315
|
+
# 1. Types the equivalent `receiver[key]` read under the
|
|
316
|
+
# entry scope (so any previously-recorded narrowing for
|
|
317
|
+
# the same address is already applied).
|
|
318
|
+
# 2. Types the rvalue under the entry scope.
|
|
319
|
+
# 3. Computes `union(narrow_truthy(current), rhs)` — the
|
|
320
|
+
# standard `||=` result shape used by locals / ivars /
|
|
321
|
+
# cvars / globals.
|
|
322
|
+
# 4. Records the result type in the post-scope as a
|
|
323
|
+
# narrowing keyed on `(receiver_kind, receiver_name,
|
|
324
|
+
# literal_key)` when both receiver and key are stable
|
|
325
|
+
# (see {Inference::IndexedNarrowing}). Unstable shapes
|
|
326
|
+
# fall through to "no scope effect", matching the old
|
|
327
|
+
# `Prism::IndexOrWriteNode` default-branch behaviour.
|
|
328
|
+
#
|
|
329
|
+
# The expression value is the result type, matching Ruby's
|
|
330
|
+
# semantics: `(x = params[:f] ||= []); x` observes the
|
|
331
|
+
# post-`||=` value, not the rvalue alone.
|
|
332
|
+
def eval_index_or_write(node)
|
|
333
|
+
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
334
|
+
current_type = scope.type_of(node, tracer: tracer)
|
|
335
|
+
result_type = Type::Combinator.union(Narrowing.narrow_truthy(current_type), rhs_type)
|
|
336
|
+
|
|
337
|
+
key_node = first_index_argument(node)
|
|
338
|
+
address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
|
|
339
|
+
post = post_rhs
|
|
340
|
+
post = post.with_indexed_narrowing(*address, result_type) if address
|
|
341
|
+
|
|
342
|
+
[result_type, post]
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def first_index_argument(node)
|
|
346
|
+
args = node.arguments
|
|
347
|
+
return nil if args.nil?
|
|
348
|
+
|
|
349
|
+
list = args.respond_to?(:arguments) ? args.arguments : args
|
|
350
|
+
list.first
|
|
351
|
+
end
|
|
352
|
+
|
|
303
353
|
def dispatch_operator(current, rhs, operator)
|
|
304
354
|
result = MethodDispatcher.dispatch(
|
|
305
355
|
receiver_type: current,
|
|
@@ -519,8 +569,30 @@ module Rigor
|
|
|
519
569
|
# joined exit scope so locals bound exclusively in `ensure` stay
|
|
520
570
|
# observable.
|
|
521
571
|
def eval_begin(node)
|
|
522
|
-
|
|
523
|
-
|
|
572
|
+
entry = scope
|
|
573
|
+
primary_type, primary_scope = eval_begin_primary_under(node, entry)
|
|
574
|
+
rescue_chain = collect_rescue_chain_results(node.rescue_clause, entry)
|
|
575
|
+
|
|
576
|
+
# B2.1 — retry-edge widening. When any rescue body
|
|
577
|
+
# contains `Prism::RetryNode`, control re-enters the
|
|
578
|
+
# primary body with the rescue arm's rebinds visible.
|
|
579
|
+
# Today's flow loses that effect, so a counter like
|
|
580
|
+
# `tries = 0; ...; rescue; tries += 1; retry; end`
|
|
581
|
+
# observes `tries: Constant[0]` inside the body and any
|
|
582
|
+
# `tries > 100` predicate folds to always-falsey.
|
|
583
|
+
# The fix: widen rebound locals / ivars in any
|
|
584
|
+
# retry-emitting arm to their Nominal envelope (Constant
|
|
585
|
+
# → Nominal[<class>], Tuple → Array, HashShape → Hash),
|
|
586
|
+
# then re-evaluate primary body AND rescue chain once
|
|
587
|
+
# under the widened entry. Nominal envelope is the
|
|
588
|
+
# maximally widened form so the re-evaluation converges
|
|
589
|
+
# in one step.
|
|
590
|
+
widened_entry = widen_entry_for_retry(entry, rescue_chain)
|
|
591
|
+
if widened_entry
|
|
592
|
+
primary_type, primary_scope = eval_begin_primary_under(node, widened_entry)
|
|
593
|
+
rescue_chain = collect_rescue_chain_results(node.rescue_clause, widened_entry)
|
|
594
|
+
end
|
|
595
|
+
|
|
524
596
|
# Rescue arms whose body unconditionally exits (`return`,
|
|
525
597
|
# `next`, `break`, `raise`, `throw`, `exit`, `abort`,
|
|
526
598
|
# `fail`) contribute neither a type fragment NOR a scope
|
|
@@ -556,11 +628,15 @@ module Rigor
|
|
|
556
628
|
# body's scope effects still apply because the body did run
|
|
557
629
|
# before the else.
|
|
558
630
|
def eval_begin_primary(node)
|
|
631
|
+
eval_begin_primary_under(node, scope)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def eval_begin_primary_under(node, entry_scope)
|
|
559
635
|
body_type, body_scope =
|
|
560
636
|
if node.statements
|
|
561
|
-
sub_eval(node.statements,
|
|
637
|
+
sub_eval(node.statements, entry_scope)
|
|
562
638
|
else
|
|
563
|
-
[Type::Combinator.constant_of(nil),
|
|
639
|
+
[Type::Combinator.constant_of(nil), entry_scope]
|
|
564
640
|
end
|
|
565
641
|
|
|
566
642
|
if node.else_clause
|
|
@@ -571,6 +647,105 @@ module Rigor
|
|
|
571
647
|
end
|
|
572
648
|
end
|
|
573
649
|
|
|
650
|
+
# B2.1 — return a widened entry scope when at least one
|
|
651
|
+
# rescue arm in `rescue_chain` contains a `Prism::RetryNode`
|
|
652
|
+
# AND that arm rebinds at least one local or ivar relative
|
|
653
|
+
# to the original entry. Returns nil when no widening is
|
|
654
|
+
# needed (no retry, or no rebinds reachable across the
|
|
655
|
+
# retry edge).
|
|
656
|
+
#
|
|
657
|
+
# Always-safe: the widening can only LOSE precision; it
|
|
658
|
+
# never invents a fact (Nominal envelope is a superset of
|
|
659
|
+
# the Constant / shape carrier it widens from). Convergent
|
|
660
|
+
# in one step because Nominal envelope is the maximally
|
|
661
|
+
# widened form against the engine's current carrier set.
|
|
662
|
+
def widen_entry_for_retry(entry_scope, rescue_chain)
|
|
663
|
+
widened = nil
|
|
664
|
+
rescue_chain.each do |(_arm_type, arm_post_scope), arm_node|
|
|
665
|
+
next unless arm_contains_retry?(arm_node)
|
|
666
|
+
|
|
667
|
+
accumulator = widened || entry_scope
|
|
668
|
+
accumulator = absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
|
|
669
|
+
widened = accumulator
|
|
670
|
+
end
|
|
671
|
+
return nil if widened.nil? || widened == entry_scope
|
|
672
|
+
|
|
673
|
+
widened
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def arm_contains_retry?(node)
|
|
677
|
+
return false unless node.is_a?(Prism::Node)
|
|
678
|
+
return true if node.is_a?(Prism::RetryNode)
|
|
679
|
+
# Don't descend into nested blocks / defs / classes /
|
|
680
|
+
# modules — a `retry` inside a nested method body or
|
|
681
|
+
# block targets its own enclosing `begin`, not this one.
|
|
682
|
+
return false if node.is_a?(Prism::DefNode) ||
|
|
683
|
+
node.is_a?(Prism::ClassNode) ||
|
|
684
|
+
node.is_a?(Prism::ModuleNode) ||
|
|
685
|
+
node.is_a?(Prism::BlockNode)
|
|
686
|
+
|
|
687
|
+
node.compact_child_nodes.any? { |c| arm_contains_retry?(c) }
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
|
|
691
|
+
scope_acc = accumulator
|
|
692
|
+
# Walk every local visible in either side, compare types,
|
|
693
|
+
# widen to Nominal envelope on a difference.
|
|
694
|
+
local_keys = arm_post_scope.locals.keys | entry_scope.locals.keys
|
|
695
|
+
local_keys.each do |name|
|
|
696
|
+
pre = entry_scope.local(name)
|
|
697
|
+
post = arm_post_scope.local(name)
|
|
698
|
+
next if pre == post || post.nil?
|
|
699
|
+
|
|
700
|
+
widened = retry_widened_type(pre, post)
|
|
701
|
+
scope_acc = scope_acc.with_local(name, widened)
|
|
702
|
+
end
|
|
703
|
+
ivar_keys = arm_post_scope.ivars.keys | entry_scope.ivars.keys
|
|
704
|
+
ivar_keys.each do |name|
|
|
705
|
+
pre = entry_scope.ivar(name)
|
|
706
|
+
post = arm_post_scope.ivar(name)
|
|
707
|
+
next if pre == post || post.nil?
|
|
708
|
+
|
|
709
|
+
widened = retry_widened_type(pre, post)
|
|
710
|
+
scope_acc = scope_acc.with_ivar(name, widened)
|
|
711
|
+
end
|
|
712
|
+
scope_acc
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def retry_widened_type(pre, post)
|
|
716
|
+
# `pre` is nil when the local was introduced inside the
|
|
717
|
+
# rescue body. The retry edge brings it back into the
|
|
718
|
+
# primary body's entry — widen the post type itself.
|
|
719
|
+
envelope = nominal_envelope_for(post)
|
|
720
|
+
return envelope if pre.nil?
|
|
721
|
+
|
|
722
|
+
nominal_envelope_for(Type::Combinator.union(pre, envelope))
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Nominal envelope of a value type: widens Constant /
|
|
726
|
+
# Tuple / HashShape carriers to the underlying class's
|
|
727
|
+
# `Nominal`, preserving everything else (`Nominal`,
|
|
728
|
+
# `Union` of non-shape members, `Top`, `Dynamic`, `Bot`).
|
|
729
|
+
# Union members are walked individually.
|
|
730
|
+
def nominal_envelope_for(type)
|
|
731
|
+
members = type.is_a?(Type::Union) ? type.members : [type]
|
|
732
|
+
widened = members.map { |m| nominal_envelope_member(m) }
|
|
733
|
+
Type::Combinator.union(*widened)
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def nominal_envelope_member(member)
|
|
737
|
+
case member
|
|
738
|
+
when Type::Constant
|
|
739
|
+
Type::Combinator.nominal_of(member.value.class.name)
|
|
740
|
+
when Type::Tuple
|
|
741
|
+
MutationWidening.widen_tuple(member)
|
|
742
|
+
when Type::HashShape
|
|
743
|
+
MutationWidening.widen_hash_shape(member)
|
|
744
|
+
else
|
|
745
|
+
member
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
574
749
|
def collect_rescue_chain_results(rescue_node, entry_scope)
|
|
575
750
|
results = []
|
|
576
751
|
current = rescue_node
|
|
@@ -876,9 +1051,86 @@ module Rigor
|
|
|
876
1051
|
post_scope = apply_rbs_extended_assertions(node, post_scope)
|
|
877
1052
|
post_scope = apply_plugin_assertions(node, post_scope)
|
|
878
1053
|
post_scope = apply_rspec_matcher_narrowing(node, post_scope)
|
|
1054
|
+
# Flow-folding G1 / G2 — widen a local- or instance-variable
|
|
1055
|
+
# binding when the call is an in-place mutator on it (e.g.
|
|
1056
|
+
# `arms << x`, `@tags << hashtag`). Stops a literal-shape
|
|
1057
|
+
# carrier (`Tuple` / `HashShape`) from outliving its
|
|
1058
|
+
# justification when the value is mutated. Always-safe
|
|
1059
|
+
# (loses precision, never invents facts).
|
|
1060
|
+
post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
|
|
1061
|
+
# And the same widening for outer-scope locals / ivars
|
|
1062
|
+
# mutated inside the block body (`items.each { |x| arr << x }`):
|
|
1063
|
+
# the block lives in a child scope so without an explicit
|
|
1064
|
+
# propagation step the outer `arr` keeps its pre-mutation
|
|
1065
|
+
# binding. Sound for the same reason — only ever LOSES
|
|
1066
|
+
# precision — so blindly applying is safe regardless of
|
|
1067
|
+
# whether the block actually runs.
|
|
1068
|
+
post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
|
|
1069
|
+
# Indexed-collection narrowing — drop any
|
|
1070
|
+
# `receiver[key] ||= default` narrowing the analyzer
|
|
1071
|
+
# recorded earlier when an intervening `[]=` writes the
|
|
1072
|
+
# same slot or any other mutator runs against the
|
|
1073
|
+
# receiver. Always-safe (only forgets; never invents).
|
|
1074
|
+
post_scope = IndexedNarrowing.invalidate_after_call(call_node: node, current_scope: post_scope)
|
|
1075
|
+
# Single-hop method-chain narrowing — drop every
|
|
1076
|
+
# `(receiver, *)` chain narrowing rooted at the call's
|
|
1077
|
+
# outer stable receiver (any-call-against-the-root
|
|
1078
|
+
# invalidation rule, B2 from the slice's design
|
|
1079
|
+
# notes). Calls whose outer receiver is itself a chain
|
|
1080
|
+
# node (e.g. `x.last << y`) do NOT drop narrowings
|
|
1081
|
+
# keyed on `x` — only direct calls against the root
|
|
1082
|
+
# variable invalidate the chain.
|
|
1083
|
+
post_scope = IndexedNarrowing.invalidate_chain_after_call(call_node: node, current_scope: post_scope)
|
|
1084
|
+
# B2.2 — intervening method call ivar invalidation.
|
|
1085
|
+
# An implicit-self / self-receiver call could mutate any
|
|
1086
|
+
# ivar of the enclosing class (we cannot prove purity
|
|
1087
|
+
# without an effect system). Reset each ivar whose
|
|
1088
|
+
# current local binding has narrowed below the class-ivar
|
|
1089
|
+
# seed back to the seed itself, so a subsequent
|
|
1090
|
+
# `if @flag` predicate observes the seed's union (not the
|
|
1091
|
+
# pre-call narrowed value). Always-safe (only widens; no
|
|
1092
|
+
# new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
|
|
1093
|
+
# § "Flow-folding" — G2 intervening-call case.
|
|
1094
|
+
post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
|
|
879
1095
|
[call_type, post_scope]
|
|
880
1096
|
end
|
|
881
1097
|
|
|
1098
|
+
# Returns a scope with each ivar's narrowed local binding
|
|
1099
|
+
# widened back to its class-ivar seed value when the call
|
|
1100
|
+
# is one that could plausibly mutate ivars on the enclosing
|
|
1101
|
+
# class (implicit-self or explicit `self.foo`). External-
|
|
1102
|
+
# receiver calls (`obj.method`) cannot reach the caller's
|
|
1103
|
+
# ivars; they pass through unchanged.
|
|
1104
|
+
def invalidate_ivars_for_intervening_call(call_node, current_scope)
|
|
1105
|
+
return current_scope unless intervening_call_candidate?(call_node)
|
|
1106
|
+
|
|
1107
|
+
class_name = enclosing_class_name_for(current_scope.self_type)
|
|
1108
|
+
return current_scope if class_name.nil?
|
|
1109
|
+
|
|
1110
|
+
seed = current_scope.class_ivars_for(class_name)
|
|
1111
|
+
return current_scope if seed.empty?
|
|
1112
|
+
|
|
1113
|
+
seed.reduce(current_scope) do |acc, (ivar_name, seed_type)|
|
|
1114
|
+
local_type = current_scope.ivar(ivar_name)
|
|
1115
|
+
next acc if local_type.nil? || local_type == seed_type
|
|
1116
|
+
|
|
1117
|
+
acc.with_ivar(ivar_name, Type::Combinator.union(local_type, seed_type))
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
def intervening_call_candidate?(call_node)
|
|
1122
|
+
return false unless call_node.is_a?(Prism::CallNode)
|
|
1123
|
+
|
|
1124
|
+
receiver = call_node.receiver
|
|
1125
|
+
receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def enclosing_class_name_for(self_type)
|
|
1129
|
+
case self_type
|
|
1130
|
+
when Type::Nominal, Type::Singleton then self_type.class_name
|
|
1131
|
+
end
|
|
1132
|
+
end
|
|
1133
|
+
|
|
882
1134
|
def evaluate_def_arguments(call_node)
|
|
883
1135
|
args = call_node.arguments
|
|
884
1136
|
return unless args.respond_to?(:arguments)
|
data/lib/rigor/scope.rb
CHANGED
|
@@ -22,12 +22,50 @@ module Rigor
|
|
|
22
22
|
:discovered_classes, :in_source_constants, :discovered_methods,
|
|
23
23
|
:discovered_def_nodes, :discovered_method_visibilities,
|
|
24
24
|
:discovered_superclasses, :discovered_includes,
|
|
25
|
+
:indexed_narrowings, :method_chain_narrowings,
|
|
25
26
|
:source_path
|
|
26
27
|
|
|
28
|
+
# Narrowing key for an indexed read `receiver[key]` where both
|
|
29
|
+
# the receiver and the key are stable enough to address. The
|
|
30
|
+
# value of the map at this key is the narrowed type the next
|
|
31
|
+
# read at the same address MUST observe.
|
|
32
|
+
#
|
|
33
|
+
# - `receiver_kind` ∈ `{:local, :ivar}` — the analyzer only
|
|
34
|
+
# tracks reads against a local or instance variable today.
|
|
35
|
+
# - `receiver_name` is the variable's Symbol.
|
|
36
|
+
# - `key` is the Ruby value of the literal index (Symbol /
|
|
37
|
+
# String / Integer). Non-literal keys (`params[field]`) are
|
|
38
|
+
# not recorded; they have no stable address.
|
|
39
|
+
IndexedKey = Data.define(:receiver_kind, :receiver_name, :key)
|
|
40
|
+
|
|
41
|
+
# Narrowing key for a no-arg / no-block method-call chain
|
|
42
|
+
# `receiver.method_name` (a "single-hop" chain per A1 from the
|
|
43
|
+
# ROADMAP § Future cycles slice). The value of the map at this
|
|
44
|
+
# key is the narrowed type the next read of the same chain
|
|
45
|
+
# MUST observe — typically the post-`is_a?(C)` narrowing
|
|
46
|
+
# established on a predicate edge.
|
|
47
|
+
#
|
|
48
|
+
# - `receiver_kind` ∈ `{:local, :ivar}` — the analyzer only
|
|
49
|
+
# tracks chains rooted at a local or instance variable
|
|
50
|
+
# today (Law-of-Demeter-style single-hop).
|
|
51
|
+
# - `receiver_name` is the root variable's Symbol.
|
|
52
|
+
# - `method_name` is the no-arg method invoked on the root.
|
|
53
|
+
#
|
|
54
|
+
# Chains with arguments (`x.first(3)`), with a block
|
|
55
|
+
# (`x.detect { ... }`), or with intermediate links
|
|
56
|
+
# (`x.foo.bar`) are NOT recorded; each loses stability for
|
|
57
|
+
# different reasons (args / block alter the call's return;
|
|
58
|
+
# multi-hop loses the LoD guarantee).
|
|
59
|
+
ChainKey = Data.define(:receiver_kind, :receiver_name, :method_name)
|
|
60
|
+
|
|
27
61
|
EMPTY_DECLARED_TYPES = {}.compare_by_identity.freeze
|
|
28
62
|
EMPTY_VAR_BINDINGS = {}.freeze
|
|
29
63
|
EMPTY_CLASS_BINDINGS = {}.freeze
|
|
30
|
-
|
|
64
|
+
EMPTY_INDEXED_NARROWINGS = {}.freeze
|
|
65
|
+
EMPTY_CHAIN_NARROWINGS = {}.freeze
|
|
66
|
+
private_constant :EMPTY_DECLARED_TYPES, :EMPTY_VAR_BINDINGS,
|
|
67
|
+
:EMPTY_CLASS_BINDINGS, :EMPTY_INDEXED_NARROWINGS,
|
|
68
|
+
:EMPTY_CHAIN_NARROWINGS
|
|
31
69
|
|
|
32
70
|
class << self
|
|
33
71
|
def empty(environment: Environment.default, source_path: nil)
|
|
@@ -54,6 +92,8 @@ module Rigor
|
|
|
54
92
|
discovered_method_visibilities: EMPTY_CLASS_BINDINGS,
|
|
55
93
|
discovered_superclasses: EMPTY_CLASS_BINDINGS,
|
|
56
94
|
discovered_includes: EMPTY_CLASS_BINDINGS,
|
|
95
|
+
indexed_narrowings: EMPTY_INDEXED_NARROWINGS,
|
|
96
|
+
method_chain_narrowings: EMPTY_CHAIN_NARROWINGS,
|
|
57
97
|
source_path: nil
|
|
58
98
|
)
|
|
59
99
|
@environment = environment
|
|
@@ -74,6 +114,8 @@ module Rigor
|
|
|
74
114
|
@discovered_method_visibilities = discovered_method_visibilities
|
|
75
115
|
@discovered_superclasses = discovered_superclasses
|
|
76
116
|
@discovered_includes = discovered_includes
|
|
117
|
+
@indexed_narrowings = indexed_narrowings
|
|
118
|
+
@method_chain_narrowings = method_chain_narrowings
|
|
77
119
|
@source_path = source_path
|
|
78
120
|
freeze
|
|
79
121
|
end
|
|
@@ -85,7 +127,19 @@ module Rigor
|
|
|
85
127
|
def with_local(name, type)
|
|
86
128
|
new_locals = @locals.merge(name.to_sym => type).freeze
|
|
87
129
|
new_fact_store = fact_store.invalidate_target(Analysis::FactStore::Target.local(name))
|
|
88
|
-
|
|
130
|
+
# Rebinding `name` invalidates every "after `receiver[key]
|
|
131
|
+
# ||= default`" narrowing keyed on it — the slot at `name[*]`
|
|
132
|
+
# is reachable through the old binding only, so the
|
|
133
|
+
# next read against the new binding does not inherit the
|
|
134
|
+
# earlier non-nil guarantee. The same logic applies to
|
|
135
|
+
# method-chain narrowings: `x.last` after `x = something_new`
|
|
136
|
+
# is a call on the new binding and any prior `is_a?`-driven
|
|
137
|
+
# narrowing keyed on `(local, :x, :last)` no longer holds.
|
|
138
|
+
new_indexed_narrowings = drop_indexed_narrowings_for(:local, name)
|
|
139
|
+
new_chain_narrowings = drop_chain_narrowings_for(:local, name)
|
|
140
|
+
rebuild(locals: new_locals, fact_store: new_fact_store,
|
|
141
|
+
indexed_narrowings: new_indexed_narrowings,
|
|
142
|
+
method_chain_narrowings: new_chain_narrowings)
|
|
89
143
|
end
|
|
90
144
|
|
|
91
145
|
def with_fact(fact)
|
|
@@ -150,7 +204,11 @@ module Rigor
|
|
|
150
204
|
end
|
|
151
205
|
|
|
152
206
|
def with_ivar(name, type)
|
|
153
|
-
|
|
207
|
+
new_indexed_narrowings = drop_indexed_narrowings_for(:ivar, name)
|
|
208
|
+
new_chain_narrowings = drop_chain_narrowings_for(:ivar, name)
|
|
209
|
+
rebuild(ivars: @ivars.merge(name.to_sym => type).freeze,
|
|
210
|
+
indexed_narrowings: new_indexed_narrowings,
|
|
211
|
+
method_chain_narrowings: new_chain_narrowings)
|
|
154
212
|
end
|
|
155
213
|
|
|
156
214
|
def with_cvar(name, type)
|
|
@@ -251,6 +309,20 @@ module Rigor
|
|
|
251
309
|
table[method_name.to_sym] == kind
|
|
252
310
|
end
|
|
253
311
|
|
|
312
|
+
# ADR-34 § "Decision" — predicate identifying a toplevel-shaped
|
|
313
|
+
# scope (no enclosing `class` / `module` body). True at the top
|
|
314
|
+
# of a file AND inside a top-level `def` body (since toplevel
|
|
315
|
+
# defs leave `self_type` nil per the existing scope-construction
|
|
316
|
+
# contract, mirroring how ADR-24's `adoptable_self_call_result?`
|
|
317
|
+
# also keys on `self_type.nil?` for the same context). Used by
|
|
318
|
+
# `CheckRules#unresolved_toplevel_diagnostic` to gate the
|
|
319
|
+
# `call.unresolved-toplevel` rule so it fires only outside
|
|
320
|
+
# class / module bodies, where Rails-DSL metaprogramming
|
|
321
|
+
# leniency (ADR-24 WD3 → WD4) does not apply.
|
|
322
|
+
def toplevel?
|
|
323
|
+
@self_type.nil?
|
|
324
|
+
end
|
|
325
|
+
|
|
254
326
|
def with_discovered_methods(table)
|
|
255
327
|
rebuild(discovered_methods: table)
|
|
256
328
|
end
|
|
@@ -344,6 +416,79 @@ module Rigor
|
|
|
344
416
|
rebuild(discovered_method_visibilities: table)
|
|
345
417
|
end
|
|
346
418
|
|
|
419
|
+
# Closes the "`params[:f] ||= []; params[:f] << x`" precision
|
|
420
|
+
# gap (ROADMAP § Type-language / engine — indexed-collection
|
|
421
|
+
# narrowing through `Hash[k] ||= default`). After
|
|
422
|
+
# `receiver[key] ||= default`, the next read at `receiver[key]`
|
|
423
|
+
# is known non-nil; recording the post-`||=` type keyed on
|
|
424
|
+
# `(receiver_kind, receiver_name, literal_key)` lets the
|
|
425
|
+
# ExpressionTyper's `[]` dispatch hand back the narrowed
|
|
426
|
+
# type. Receiver-rebind and `[]=`/mutator invalidation rules
|
|
427
|
+
# are documented at the call sites in
|
|
428
|
+
# `Inference::StatementEvaluator`.
|
|
429
|
+
def indexed_narrowing(receiver_kind, receiver_name, key)
|
|
430
|
+
@indexed_narrowings[indexed_key(receiver_kind, receiver_name, key)]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def with_indexed_narrowing(receiver_kind, receiver_name, key, type)
|
|
434
|
+
new_table = @indexed_narrowings.merge(
|
|
435
|
+
indexed_key(receiver_kind, receiver_name, key) => type
|
|
436
|
+
).freeze
|
|
437
|
+
rebuild(indexed_narrowings: new_table)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def without_indexed_narrowing(receiver_kind, receiver_name, key)
|
|
441
|
+
lookup = indexed_key(receiver_kind, receiver_name, key)
|
|
442
|
+
return self unless @indexed_narrowings.key?(lookup)
|
|
443
|
+
|
|
444
|
+
new_table = @indexed_narrowings.reject { |k, _| k == lookup }.freeze
|
|
445
|
+
rebuild(indexed_narrowings: new_table)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def without_indexed_narrowings_for(receiver_kind, receiver_name)
|
|
449
|
+
new_table = drop_indexed_narrowings_for(receiver_kind, receiver_name)
|
|
450
|
+
return self if new_table.equal?(@indexed_narrowings)
|
|
451
|
+
|
|
452
|
+
rebuild(indexed_narrowings: new_table)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Closes the "stable receiver method-chain narrowing" gap
|
|
456
|
+
# (ROADMAP § Future cycles / Type-language / engine —
|
|
457
|
+
# "Method-call receiver narrowing across stable receivers";
|
|
458
|
+
# 2026-05-28 Redmine survey). After `if x.last.is_a?(Array)`
|
|
459
|
+
# the dominated body's `x.last` reads MUST observe the
|
|
460
|
+
# truthy-narrowed type; the same chain reaching the falsey
|
|
461
|
+
# edge observes the negative narrowing.
|
|
462
|
+
#
|
|
463
|
+
# Address shape mirrors {.indexed_narrowing}: stable root
|
|
464
|
+
# variable + no-arg single-hop method name. See
|
|
465
|
+
# {ChainKey} for the precise contract.
|
|
466
|
+
def method_chain_narrowing(receiver_kind, receiver_name, method_name)
|
|
467
|
+
@method_chain_narrowings[chain_key(receiver_kind, receiver_name, method_name)]
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def with_method_chain_narrowing(receiver_kind, receiver_name, method_name, type)
|
|
471
|
+
new_table = @method_chain_narrowings.merge(
|
|
472
|
+
chain_key(receiver_kind, receiver_name, method_name) => type
|
|
473
|
+
).freeze
|
|
474
|
+
rebuild(method_chain_narrowings: new_table)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def without_method_chain_narrowing(receiver_kind, receiver_name, method_name)
|
|
478
|
+
lookup = chain_key(receiver_kind, receiver_name, method_name)
|
|
479
|
+
return self unless @method_chain_narrowings.key?(lookup)
|
|
480
|
+
|
|
481
|
+
new_table = @method_chain_narrowings.reject { |k, _| k == lookup }.freeze
|
|
482
|
+
rebuild(method_chain_narrowings: new_table)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def without_method_chain_narrowings_for(receiver_kind, receiver_name)
|
|
486
|
+
new_table = drop_chain_narrowings_for(receiver_kind, receiver_name)
|
|
487
|
+
return self if new_table.equal?(@method_chain_narrowings)
|
|
488
|
+
|
|
489
|
+
rebuild(method_chain_narrowings: new_table)
|
|
490
|
+
end
|
|
491
|
+
|
|
347
492
|
def facts_for(target: nil, bucket: nil)
|
|
348
493
|
fact_store.facts_for(target: target, bucket: bucket)
|
|
349
494
|
end
|
|
@@ -395,7 +540,9 @@ module Rigor
|
|
|
395
540
|
self_type == other.self_type &&
|
|
396
541
|
@ivars == other.ivars &&
|
|
397
542
|
@cvars == other.cvars &&
|
|
398
|
-
@globals == other.globals
|
|
543
|
+
@globals == other.globals &&
|
|
544
|
+
@indexed_narrowings == other.indexed_narrowings &&
|
|
545
|
+
@method_chain_narrowings == other.method_chain_narrowings
|
|
399
546
|
end
|
|
400
547
|
alias eql? ==
|
|
401
548
|
|
|
@@ -414,6 +561,8 @@ module Rigor
|
|
|
414
561
|
discovered_method_visibilities: @discovered_method_visibilities,
|
|
415
562
|
discovered_superclasses: @discovered_superclasses,
|
|
416
563
|
discovered_includes: @discovered_includes,
|
|
564
|
+
indexed_narrowings: @indexed_narrowings,
|
|
565
|
+
method_chain_narrowings: @method_chain_narrowings,
|
|
417
566
|
source_path: @source_path
|
|
418
567
|
)
|
|
419
568
|
self.class.new(
|
|
@@ -430,6 +579,8 @@ module Rigor
|
|
|
430
579
|
discovered_method_visibilities: discovered_method_visibilities,
|
|
431
580
|
discovered_superclasses: discovered_superclasses,
|
|
432
581
|
discovered_includes: discovered_includes,
|
|
582
|
+
indexed_narrowings: indexed_narrowings,
|
|
583
|
+
method_chain_narrowings: method_chain_narrowings,
|
|
433
584
|
source_path: source_path
|
|
434
585
|
)
|
|
435
586
|
end
|
|
@@ -459,9 +610,49 @@ module Rigor
|
|
|
459
610
|
discovered_method_visibilities: discovered_method_visibilities,
|
|
460
611
|
discovered_superclasses: discovered_superclasses,
|
|
461
612
|
discovered_includes: discovered_includes,
|
|
613
|
+
indexed_narrowings: join_bindings(@indexed_narrowings, other.indexed_narrowings),
|
|
614
|
+
method_chain_narrowings: join_bindings(@method_chain_narrowings, other.method_chain_narrowings),
|
|
462
615
|
source_path: source_path
|
|
463
616
|
)
|
|
464
617
|
end
|
|
618
|
+
|
|
619
|
+
def indexed_key(receiver_kind, receiver_name, key)
|
|
620
|
+
IndexedKey.new(
|
|
621
|
+
receiver_kind: receiver_kind.to_sym,
|
|
622
|
+
receiver_name: receiver_name.to_sym,
|
|
623
|
+
key: key
|
|
624
|
+
)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def chain_key(receiver_kind, receiver_name, method_name)
|
|
628
|
+
ChainKey.new(
|
|
629
|
+
receiver_kind: receiver_kind.to_sym,
|
|
630
|
+
receiver_name: receiver_name.to_sym,
|
|
631
|
+
method_name: method_name.to_sym
|
|
632
|
+
)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def drop_indexed_narrowings_for(receiver_kind, receiver_name)
|
|
636
|
+
return @indexed_narrowings if @indexed_narrowings.empty?
|
|
637
|
+
|
|
638
|
+
sym_kind = receiver_kind.to_sym
|
|
639
|
+
sym_name = receiver_name.to_sym
|
|
640
|
+
filtered = @indexed_narrowings.reject do |k, _|
|
|
641
|
+
k.receiver_kind == sym_kind && k.receiver_name == sym_name
|
|
642
|
+
end
|
|
643
|
+
filtered.size == @indexed_narrowings.size ? @indexed_narrowings : filtered.freeze
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def drop_chain_narrowings_for(receiver_kind, receiver_name)
|
|
647
|
+
return @method_chain_narrowings if @method_chain_narrowings.empty?
|
|
648
|
+
|
|
649
|
+
sym_kind = receiver_kind.to_sym
|
|
650
|
+
sym_name = receiver_name.to_sym
|
|
651
|
+
filtered = @method_chain_narrowings.reject do |k, _|
|
|
652
|
+
k.receiver_kind == sym_kind && k.receiver_name == sym_name
|
|
653
|
+
end
|
|
654
|
+
filtered.size == @method_chain_narrowings.size ? @method_chain_narrowings : filtered.freeze
|
|
655
|
+
end
|
|
465
656
|
end
|
|
466
657
|
# rubocop:enable Metrics/ClassLength,Metrics/ParameterLists
|
|
467
658
|
end
|
data/lib/rigor/version.rb
CHANGED
|
@@ -40,6 +40,9 @@ module Rigor
|
|
|
40
40
|
deliver_later deliver_now deliver_later! deliver_now!
|
|
41
41
|
mail headers attachments default
|
|
42
42
|
with parameters
|
|
43
|
+
respond_to? respond_to_missing? method_defined?
|
|
44
|
+
public_send send __send__ public_method
|
|
45
|
+
method instance_method methods
|
|
43
46
|
].freeze
|
|
44
47
|
|
|
45
48
|
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
@@ -62,6 +65,14 @@ module Rigor
|
|
|
62
65
|
|
|
63
66
|
action_entry = class_entry.find_action(call_node.name)
|
|
64
67
|
if action_entry.nil?
|
|
68
|
+
# Skip `unknown-action` when the mailer's include
|
|
69
|
+
# set has any unresolved module — the unresolved
|
|
70
|
+
# module may legitimately define the action
|
|
71
|
+
# (gem-shipped concern, dynamically loaded
|
|
72
|
+
# mailer extension). Mirrors the same predicate
|
|
73
|
+
# `rigor-actionpack` uses for unknown-filter-method.
|
|
74
|
+
next if class_entry.unresolved_includes?
|
|
75
|
+
|
|
65
76
|
diagnostics << unknown_action_diagnostic(path, call_node, class_entry)
|
|
66
77
|
next
|
|
67
78
|
end
|
|
@@ -123,9 +134,19 @@ module Rigor
|
|
|
123
134
|
end
|
|
124
135
|
|
|
125
136
|
def arity_check(path, call_node, class_entry, action_entry)
|
|
126
|
-
|
|
137
|
+
args = call_node.arguments&.arguments || []
|
|
138
|
+
actual = args.size
|
|
127
139
|
return nil if action_entry.accepts?(actual)
|
|
128
140
|
|
|
141
|
+
# Trailing keyword-hash relaxation. `Notify.foo(uid,
|
|
142
|
+
# gid, success_count: 5)` is 3 positional args from
|
|
143
|
+
# Prism's perspective (2 + a KeywordHashNode); the
|
|
144
|
+
# action's `def foo(uid, gid, success_count:)` has
|
|
145
|
+
# arity 2. When the call's trailing arg is a kwargs
|
|
146
|
+
# hash, allow `(actual - 1) ≤ max_arity` so kwargs-
|
|
147
|
+
# carrying calls don't surface as wrong-arity.
|
|
148
|
+
return nil if args.last.is_a?(Prism::KeywordHashNode) && action_entry.accepts?(actual - 1)
|
|
149
|
+
|
|
129
150
|
location = call_node.location
|
|
130
151
|
Diagnostic.new(
|
|
131
152
|
path: path,
|