rigortype 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. metadata +25 -1
@@ -440,6 +440,20 @@ module Rigor
440
440
  Source::ConstantPath.rooted?(node) ? "::#{name}" : name
441
441
  end
442
442
 
443
+ # Issue #1446 — the classes a class guard narrowed the receiver `node` to where the guard left it `bot`
444
+ # ({Scope#bot_guard_classes_for}), or nil: a node that is not a local, instance-variable, global or constant
445
+ # read, or one no such guard narrowed.
446
+ def bot_guard_classes_of(node, scope)
447
+ case node
448
+ when Prism::LocalVariableReadNode then scope.bot_guard_classes_for(:local, node.name)
449
+ when Prism::InstanceVariableReadNode then scope.bot_guard_classes_for(:ivar, node.name)
450
+ when Prism::GlobalVariableReadNode then scope.bot_guard_classes_for(:global, node.name)
451
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
452
+ key = constant_key(node)
453
+ key && scope.bot_guard_classes_for(:constant, key)
454
+ end
455
+ end
456
+
443
457
  # Public predicate analyser. Returns `[truthy_scope, falsey_scope]`, always; when no
444
458
  # narrowing rule matches the predicate node both entries are the receiver scope unchanged.
445
459
  def predicate_scopes(node, scope)
@@ -504,15 +518,20 @@ module Rigor
504
518
  # a different regex. Applied even when the subject is not a narrowable local read.
505
519
  body_scope = apply_when_regex_globals(conditions, scope)
506
520
 
507
- # Issue #1429 — a global or constant subject (`case $stdout when StringIO`) narrows as a local does.
521
+ # Issue #1429 — a global or constant subject (`case $stdout when StringIO`) narrows as a local does, and so
522
+ # does an instance variable (#1446).
508
523
  slot = receiver_slot(subject, scope, kinds: CASE_SUBJECT_KINDS)
509
524
  return [body_scope, scope] if slot.nil?
510
525
 
511
526
  truthy_type, falsey_type = case_when_types(scope, slot.current, conditions)
512
- [narrow_slot(body_scope, slot, truthy_type), narrow_slot(scope, slot, falsey_type)]
527
+ classes = conditions.map do |condition|
528
+ lexical_class_name(condition, scope) || case_equality_target_class(condition)
529
+ end
530
+ body_scope = record_bot_guard(narrow_guarded_slot(body_scope, slot, truthy_type), slot, truthy_type, classes)
531
+ [body_scope, narrow_guarded_slot(scope, slot, falsey_type)]
513
532
  end
514
533
 
515
- CASE_SUBJECT_KINDS = %i[local global constant].freeze
534
+ CASE_SUBJECT_KINDS = %i[local ivar global constant].freeze
516
535
  private_constant :CASE_SUBJECT_KINDS
517
536
 
518
537
  # When the clause has exactly one `RegularExpressionNode` literal condition, narrow the
@@ -979,8 +998,9 @@ module Rigor
979
998
  end
980
999
 
981
1000
  GLOBAL_AND_CONSTANT = %i[global constant].freeze
1001
+ CLASS_GUARD_SLOT_KINDS = %i[ivar global constant].freeze
982
1002
  ALL_SLOT_KINDS = %i[local ivar global constant].freeze
983
- private_constant :GLOBAL_AND_CONSTANT, :ALL_SLOT_KINDS
1003
+ private_constant :GLOBAL_AND_CONSTANT, :CLASS_GUARD_SLOT_KINDS, :ALL_SLOT_KINDS
984
1004
 
985
1005
  # The {ReceiverSlot} `node` reads, among `kinds`, or nil. A local or instance variable needs a binding. A
986
1006
  # global the scope does not bind is read as it reads unbound, and a constant reference as it resolves
@@ -1029,6 +1049,25 @@ module Rigor
1029
1049
  end
1030
1050
  end
1031
1051
 
1052
+ # Issue #1446 — {#narrow_slot} for a class guard: an instance variable narrows with its pre-guard type
1053
+ # recorded, as a global does, since code that may reach `self` may rebind it. The other guards on an instance
1054
+ # variable keep {#narrow_slot}'s unrecorded binding.
1055
+ def narrow_guarded_slot(scope, slot, type)
1056
+ return narrow_slot(scope, slot, type) unless slot.kind == :ivar
1057
+ return scope if type == slot.current
1058
+
1059
+ scope.with_guarded_ivar(slot.name, type, slot.current)
1060
+ end
1061
+
1062
+ # Issue #1446 — `scope`, the guard's truthy edge, with `class_names` recorded for `slot` when the guard left
1063
+ # it `bot` ({Scope#with_bot_guard_classes}): the value that passes it is one of those classes. Nothing is
1064
+ # recorded when a condition names no class.
1065
+ def record_bot_guard(scope, slot, type, class_names)
1066
+ return scope unless type.is_a?(Type::Bot) && !class_names.empty? && class_names.none?(&:nil?)
1067
+
1068
+ scope.with_bot_guard_classes(slot.kind, slot.name, class_names)
1069
+ end
1070
+
1032
1071
  def narrow_global_slot(scope, slot, type)
1033
1072
  return scope if type == slot.current
1034
1073
 
@@ -1234,7 +1273,7 @@ module Rigor
1234
1273
  end
1235
1274
 
1236
1275
  def simple_dispatch_name?(name)
1237
- %i[nil? ! is_a? kind_of? instance_of? == != === =~ match? key? has_key? empty? any?
1276
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~ !~ match? key? has_key? empty? any?
1238
1277
  none? respond_to? nan? finite?].include?(name)
1239
1278
  end
1240
1279
 
@@ -1245,7 +1284,7 @@ module Rigor
1245
1284
  when :instance_of? then analyse_class_predicate(node, scope, exact: true)
1246
1285
  when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
1247
1286
  when :=== then analyse_case_equality_predicate(node, scope)
1248
- when :=~ then analyse_regex_match_predicate(node, scope)
1287
+ when :=~, :!~ then dispatch_regex_match_predicate(node, scope, name)
1249
1288
  when :match? then analyse_whole_regex_match_predicate(node, scope)
1250
1289
  when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
1251
1290
  when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
@@ -1254,6 +1293,10 @@ module Rigor
1254
1293
  end
1255
1294
  end
1256
1295
 
1296
+ def dispatch_regex_match_predicate(node, scope, name)
1297
+ name == :=~ ? analyse_regex_match_predicate(node, scope) : analyse_regex_not_match_predicate(node, scope)
1298
+ end
1299
+
1257
1300
  # T3 (template-corpora survey) — `recv.respond_to?(sym)` truthy edge narrows `recv`
1258
1301
  # non-nil. `nil.respond_to?(m)` is `false` for every method `m` that `NilClass` does not
1259
1302
  # define, so a truthy `respond_to?` proves the receiver was not `nil` UNLESS the queried
@@ -1532,7 +1575,9 @@ module Rigor
1532
1575
  #
1533
1576
  # Returns nil (no narrowing) when the receiver / argument pair does not resolve to exactly one
1534
1577
  # regex pattern we can count (see {#regex_match_pattern}).
1535
- def analyse_regex_match_predicate(node, scope)
1578
+ #
1579
+ # `refine: false` leaves out the whole-receiver refinement, which names a String.
1580
+ def analyse_regex_match_predicate(node, scope, refine: true)
1536
1581
  return nil if node.arguments.nil?
1537
1582
  return nil unless node.arguments.arguments.size == 1
1538
1583
 
@@ -1552,10 +1597,96 @@ module Rigor
1552
1597
  # match-global logic above. A successful `str =~ /\A\d+\z/` proves the WHOLE string
1553
1598
  # matched, so the string operand narrows to the imported refinement the anchored pattern
1554
1599
  # names. The falsey edge is untouched: a failed match proves nothing about the shape.
1555
- truthy = apply_whole_receiver_refinement(truthy, node.receiver, arg, pattern.source, scope)
1600
+ truthy = apply_whole_receiver_refinement(truthy, node.receiver, arg, pattern.source, scope) if refine
1556
1601
  [truthy, falsey]
1557
1602
  end
1558
1603
 
1604
+ # Issue #1379 — `subject !~ pattern` is `Kernel#!~`, which returns `!(subject =~ pattern)`. It runs the same
1605
+ # match in the caller's frame, so its edges are {#analyse_regex_match_predicate}'s swapped: the falsey edge is
1606
+ # the match edge (`$~` a `MatchData`, an unconditional `$N` a `String`, and the whole-receiver refinement), the
1607
+ # truthy edge the no-match edge (`$~` and `$N` nil). The same patterns are recognised and the same ones
1608
+ # declined (a non-regex operand, an ambiguous constant, `//x`).
1609
+ #
1610
+ # Unlike `=~`'s, an edge here is narrowed only when the receiver proves what the frame holds on it
1611
+ # ({#not_match_receiver_edges}). An edge it cannot vouch for keeps the match globals forgotten, since the call
1612
+ # may still have rebound them. The refinement is kept only when the local it would narrow is known to be a
1613
+ # String on the match edge ({#refinable_operand?}).
1614
+ def analyse_regex_not_match_predicate(node, scope)
1615
+ edges = analyse_regex_match_predicate(node, scope, refine: refinable_operand?(node, scope))
1616
+ return nil if edges.nil?
1617
+
1618
+ match, no_match = edges
1619
+ forgotten = scope.forget_match_globals
1620
+ case not_match_receiver_edges(node, scope)
1621
+ when :both then [no_match, match]
1622
+ when :match then [forgotten, match]
1623
+ else [forgotten, forgotten]
1624
+ end
1625
+ end
1626
+
1627
+ # Which edges of `recv !~ pattern` the receiver lets {#analyse_regex_not_match_predicate} narrow. `Kernel#!~`
1628
+ # dispatches `=~` on the receiver:
1629
+ #
1630
+ # - A Regexp receiver (the pattern on the left) runs `Regexp#=~`, which sets `$~` on both outcomes, a `nil`
1631
+ # argument included: `:both`.
1632
+ # - `String#=~` and `Symbol#=~` do the same: `:both` when every member of the receiver's type is a String or a
1633
+ # Symbol.
1634
+ # - `NilClass#=~` answers nil and leaves `$~` alone, so a nil receiver reaches the no-match edge with the
1635
+ # previous match still bound; it never reaches the match edge. A `Dynamic` receiver may be nil at run time
1636
+ # whatever its static facet says: `:match` when every member is a String, a Symbol, nil, or a `Dynamic`
1637
+ # that is untyped or whose facet holds only those.
1638
+ # - Any other receiver may define `=~` in Ruby, which binds `$~` in its own frame, not the caller's: nil.
1639
+ #
1640
+ # A safe-navigation `recv&.!~(pattern)` answers nil without matching on a nil receiver, so it declines too.
1641
+ def not_match_receiver_edges(node, scope)
1642
+ return nil if node.receiver.nil? || node.safe_navigation?
1643
+ return :both if regex_operand_pattern(node.receiver, scope).is_a?(RegexMatchPattern)
1644
+
1645
+ kinds = not_match_member_kinds(scope.type_of(node.receiver), scope)
1646
+ return :both if kinds.all? { |kind| %i[string symbol].include?(kind) }
1647
+
1648
+ :match if kinds.all? { |kind| %i[string symbol nil dynamic].include?(kind) }
1649
+ end
1650
+
1651
+ # Whether the local operand {#apply_whole_receiver_refinement} would narrow holds only Strings and nil, which
1652
+ # the match edge excludes. The refinement names a String, so a member that may be a Symbol — a Symbol, or an
1653
+ # untyped `Dynamic` that `Kernel#!~` accepts as the receiver or `Regexp#=~` as the argument — keeps it out:
1654
+ # `return if raw !~ /\A\d+\z/` on an untyped `raw` must not make `raw.to_proc` an undefined method.
1655
+ def refinable_operand?(node, scope)
1656
+ operand = [node.receiver, *node.arguments&.arguments].find { |o| o.is_a?(Prism::LocalVariableReadNode) }
1657
+ type = operand && scope.local(operand.name)
1658
+ return true if type.nil?
1659
+
1660
+ not_match_member_kinds(type, scope).all? { |kind| %i[string nil].include?(kind) }
1661
+ end
1662
+
1663
+ def not_match_member_kinds(type, scope)
1664
+ (type.is_a?(Type::Union) ? type.members : [type]).map { |member| not_match_member_kind(member, scope) }
1665
+ end
1666
+
1667
+ # `:string` or `:symbol` for a member {.narrow_not_class} proves a String or a Symbol (a value or refinement of
1668
+ # either, or a class the environment orders below it; a project subclass it cannot order reads as neither),
1669
+ # `:nil`, `:dynamic` for a `Dynamic` whose facet is untyped or holds only Strings, Symbols and nil, or nil for
1670
+ # anything else.
1671
+ def not_match_member_kind(member, scope)
1672
+ return not_match_dynamic_kind(member, scope) if member.is_a?(Type::Dynamic)
1673
+ return :nil if narrow_nil(member) == member && !member.is_a?(Type::Bot)
1674
+
1675
+ environment = scope.environment
1676
+ { "String" => :string, "Symbol" => :symbol }.each do |class_name, kind|
1677
+ return kind if narrow_not_class(member, class_name, environment: environment, scope: scope).is_a?(Type::Bot)
1678
+ end
1679
+ nil
1680
+ end
1681
+
1682
+ def not_match_dynamic_kind(member, scope)
1683
+ facet = member.static_facet
1684
+ return :dynamic if facet.is_a?(Type::Top)
1685
+
1686
+ facet_kinds = not_match_member_kinds(facet, scope)
1687
+ :dynamic if facet_kinds.all? { |kind| %i[string symbol nil].include?(kind) }
1688
+ end
1689
+
1559
1690
  # `str.match?(/\A\d+\z/)` — `String#match?` is a pure boolean predicate whose truthy edge
1560
1691
  # proves a property of the ENTIRE receiver. When the argument is a fully `\A…\z`-anchored
1561
1692
  # single-char-class pattern recognised by {RegexRefinement.for_whole_pattern}, narrow the
@@ -2239,9 +2370,10 @@ module Rigor
2239
2370
  end
2240
2371
 
2241
2372
  # Issue #1429 — `$stdout.is_a?(StringIO)`, `STDOUT.kind_of?(StringIO)`: a global or constant receiver
2242
- # narrows as a local does ({#class_predicate_scopes}), with the pre-guard type recorded ({#narrow_slot}).
2373
+ # narrows as a local does ({#class_predicate_scopes}), with the pre-guard type recorded ({#narrow_slot}). So
2374
+ # does an instance variable (#1446, {#narrow_guarded_slot}).
2243
2375
  def analyse_class_predicate_on_slot(receiver, scope, class_name, exact)
2244
- slot = receiver_slot(receiver, scope, kinds: GLOBAL_AND_CONSTANT)
2376
+ slot = receiver_slot(receiver, scope, kinds: CLASS_GUARD_SLOT_KINDS)
2245
2377
  return nil if slot.nil?
2246
2378
 
2247
2379
  class_slot_scopes(scope, slot, class_name, exact: exact)
@@ -2250,11 +2382,9 @@ module Rigor
2250
2382
  def class_slot_scopes(scope, slot, class_name, exact:)
2251
2383
  environment = scope.environment
2252
2384
  truthy = narrow_class(slot.current, class_name, exact: exact, environment: environment, scope: scope)
2253
- [
2254
- narrow_slot(scope, slot, truthy),
2255
- narrow_slot(scope, slot, narrow_not_class(slot.current, class_name, exact: exact,
2256
- environment: environment, scope: scope))
2257
- ]
2385
+ falsey = narrow_not_class(slot.current, class_name, exact: exact, environment: environment, scope: scope)
2386
+ truthy_scope = record_bot_guard(narrow_guarded_slot(scope, slot, truthy), slot, truthy, [class_name])
2387
+ [truthy_scope, narrow_guarded_slot(scope, slot, falsey)]
2258
2388
  end
2259
2389
 
2260
2390
  # The class name a `is_a?` / `kind_of?` / `instance_of?` argument denotes: the top-level
@@ -2429,12 +2559,12 @@ module Rigor
2429
2559
  end
2430
2560
 
2431
2561
  # Issue #1429 — `StringIO === $stdout`, `StringIO === STDOUT`: the class-constant receiver form on a global or
2432
- # constant argument. The Range / Regexp receivers stay local-only.
2562
+ # constant argument, and on an instance variable (#1446). The Range / Regexp receivers stay local-only.
2433
2563
  def analyse_case_equality_on_slot(receiver, arg, scope)
2434
2564
  class_name = lexical_class_name(receiver, scope)
2435
2565
  return nil if class_name.nil?
2436
2566
 
2437
- slot = receiver_slot(arg, scope, kinds: GLOBAL_AND_CONSTANT)
2567
+ slot = receiver_slot(arg, scope, kinds: CLASS_GUARD_SLOT_KINDS)
2438
2568
  return nil if slot.nil?
2439
2569
 
2440
2570
  class_slot_scopes(scope, slot, class_name, exact: false)
@@ -245,13 +245,16 @@ module Rigor
245
245
  tables[:discovered_classes] = classes unless classes.empty?
246
246
  DISCOVERY_FIELD.each do |index_key, field|
247
247
  table = def_index.fetch(index_key)
248
- tables[field] = table unless table.empty?
248
+ tables[field] = table unless table.empty? && !Scope::DiscoveryIndex.paired?(field)
249
249
  end
250
- tables
251
- rescue StandardError
250
+ # ADR-119 WD1 — the pairs travel together: a pair is dropped only when both halves are empty. A sibling whose
251
+ # member this collector does not seed completes to that member's empty value, which the base scope holds.
252
+ Scope::DiscoveryIndex.compact_pairs(tables.merge(def_index.fetch(:siblings)))
253
+ rescue StandardError => e
252
254
  # Discovery is best-effort; a malformed corner of the project must not crash the
253
- # protection scan. Without discovery the collector simply resolves fewer call sites.
254
- {}
255
+ # protection scan. Without discovery the collector simply resolves fewer call sites. A broken
256
+ # walk contract is not such a corner, and an empty seed would hide it.
257
+ e.is_a?(DeclarationWalk::ContractError) ? raise : {}
255
258
  end
256
259
 
257
260
  DISCOVERY_FIELD = {
@@ -260,7 +263,7 @@ module Rigor
260
263
  def_sources: :discovered_def_sources,
261
264
  superclasses: :discovered_superclasses,
262
265
  header_nestings: :discovered_header_nestings,
263
- includes: :discovered_includes,
266
+ includes: :discovered_includes, unpositioned_mixins: :unpositioned_mixins,
264
267
  class_sources: :discovered_class_sources,
265
268
  method_visibilities: :discovered_method_visibilities,
266
269
  methods: :discovered_methods,
@@ -359,9 +362,7 @@ module Rigor
359
362
  end
360
363
 
361
364
  def lookup_def(scope, class_name, method, kind)
362
- table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
363
- per_class = table[class_name]
364
- per_class && per_class[method]
365
+ scope.def_node_slot(class_name, method, kind)
365
366
  end
366
367
 
367
368
  # The leading required-positional parameters — the ones a call's leading positional arguments
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module ScopeIndexer
8
+ # ADR-119 WD3 — which of a file's method contributions are `possible`: the classifier the def-contribution
9
+ # producers ({ScopeIndexer#build_methods_and_def_nodes}, {ScopeIndexer#build_discovered_singleton_def_nodes})
10
+ # consult to fill `possible_discovered_methods` and the `contested_*` siblings.
11
+ #
12
+ # A contribution is `certain` when its statement executes whenever the file's top level executes. The certain
13
+ # regions are:
14
+ #
15
+ # - the program's statements, and the bodies of `class`, `module` and `class << self` declarations that are
16
+ # themselves certain;
17
+ # - the block of a meta-new constant write (`K = Class.new do … end`, `Module.new`, `Struct.new`,
18
+ # `Data.define`, {ScopeIndexer#meta_new_block_call}) and of a bare factory call
19
+ # ({AnonymousMetaClass.block_form_receiver}), when the write or the call is certain: Ruby runs the block once,
20
+ # as the new class's body (ADR-119 WD2, "Conditional definers");
21
+ # - a `begin` WITHOUT a `rescue` clause: its main statements and its `ensure` clause;
22
+ # - the receiver and arguments of a certain call, so `private def x` and `memoize def x` stay certain.
23
+ #
24
+ # Everything else is `possible`: the branches of `if` / `unless` / `case` and the modifiers, loop bodies, the
25
+ # right operand of `&&` / `||`, every other block and every lambda, `BEGIN` / `END`, a `class X … end if Y`
26
+ # reopening, and the WHOLE of a `begin` that has a `rescue` clause, its main statements included. That last
27
+ # rule refines WD3's wording: a rescued raise skips every statement after the raising one, so the main body of
28
+ # a rescuing `begin` is not certain either, while a `begin` with no rescue either runs its main statements
29
+ # and `ensure` or propagates the raise out of the file's top level. A `def` inside a method body is never
30
+ # recorded by the walks (they return at a `DefNode`), so the classifier does not descend there either.
31
+ #
32
+ # The answer is an identity Set of the POSSIBLE contribution nodes — the nodes a producer records from — and
33
+ # {EMPTY} when the file has none, so a file written entirely in certain regions allocates nothing. It is
34
+ # separate from {MixinAccumulator}, which answers a different question (whether an edge's POSITION is a fact).
35
+ module Certainty
36
+ # The answer for a file with no possible contribution.
37
+ EMPTY = Set.new.compare_by_identity.freeze
38
+
39
+ # The nodes a def-contribution producer records from: a `def`, an `alias` / `undef`, a call (`attr_*`,
40
+ # `define_method`, `alias_method`, `module_function :x`, a factory's members), and the declarations whose
41
+ # header records members (`class X < Struct.new(:a)`, `X = Struct.new(:a) do … end`).
42
+ CONTRIBUTIONS = Set[
43
+ Prism::DefNode, Prism::AliasMethodNode, Prism::UndefNode, Prism::CallNode, Prism::ClassNode,
44
+ Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode,
45
+ Prism::ConstantPathOrWriteNode
46
+ ].freeze
47
+
48
+ CONSTANT_WRITES = Set[
49
+ Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode,
50
+ Prism::ConstantPathOrWriteNode
51
+ ].freeze
52
+
53
+ # The nodes whose whole subtree is possible.
54
+ POSSIBLE_ROOTS = Set[
55
+ Prism::BlockNode, Prism::LambdaNode, Prism::PreExecutionNode, Prism::PostExecutionNode,
56
+ Prism::RescueModifierNode
57
+ ].freeze
58
+ private_constant :CONSTANT_WRITES, :POSSIBLE_ROOTS
59
+
60
+ module_function
61
+
62
+ # The possible contribution nodes of the tree under `root`, an identity Set ({EMPTY} when none).
63
+ def possible_nodes(root)
64
+ classifier = Classifier.new
65
+ classifier.certain(root)
66
+ classifier.result
67
+ end
68
+
69
+ # Whether `node` was classified possible in `possible` (a {possible_nodes} answer).
70
+ def possible?(possible, node)
71
+ !possible.empty? && possible.include?(node)
72
+ end
73
+
74
+ # One classification walk. `certain` descends a certain region; `possible` marks every contribution in a
75
+ # subtree, since nothing below a possible node is certain.
76
+ class Classifier
77
+ def initialize
78
+ @possible = nil
79
+ end
80
+
81
+ def result
82
+ @possible ? @possible.freeze : EMPTY
83
+ end
84
+
85
+ def certain(node) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
86
+ return unless node.is_a?(Prism::Node)
87
+ return possible(node) if POSSIBLE_ROOTS.include?(node.class)
88
+
89
+ case node
90
+ when Prism::DefNode then return
91
+ when Prism::IfNode then return branch(node.predicate, node.statements, node.subsequent)
92
+ when Prism::UnlessNode then return branch(node.predicate, node.statements, node.else_clause)
93
+ when Prism::CaseNode, Prism::CaseMatchNode then return case_branches(node)
94
+ when Prism::WhileNode, Prism::UntilNode then return branch(node.predicate, node.statements)
95
+ when Prism::ForNode then return branch(node.collection, node.index, node.statements)
96
+ when Prism::AndNode, Prism::OrNode then return branch(node.left, node.right)
97
+ when Prism::BeginNode then return begin_block(node)
98
+ when Prism::CallNode then return call(node)
99
+ end
100
+ return if CONSTANT_WRITES.include?(node.class) && meta_new_write?(node)
101
+
102
+ node.rigor_each_child { |child| certain(child) }
103
+ end
104
+
105
+ # Marks every contribution of the subtree.
106
+ def possible(node)
107
+ return unless node.is_a?(Prism::Node)
108
+
109
+ (@possible ||= Set.new.compare_by_identity) << node if CONTRIBUTIONS.include?(node.class)
110
+ return if node.is_a?(Prism::DefNode)
111
+
112
+ node.rigor_each_child { |child| possible(child) }
113
+ end
114
+
115
+ private
116
+
117
+ # The first node runs whenever the construct does; the rest may not.
118
+ def branch(first, *rest)
119
+ certain(first)
120
+ rest.each { |node| possible(node) }
121
+ end
122
+
123
+ def case_branches(node)
124
+ certain(node.predicate)
125
+ node.conditions.each { |clause| possible(clause) }
126
+ possible(node.else_clause)
127
+ end
128
+
129
+ # Without a rescue the main statements run (or the raise leaves the file) and `ensure` runs; with one, a
130
+ # rescued raise skips every statement after the raising one, so all of it is possible.
131
+ def begin_block(node)
132
+ return possible_children(node) if node.rescue_clause
133
+
134
+ certain(node.statements)
135
+ possible(node.else_clause)
136
+ certain(node.ensure_clause)
137
+ end
138
+
139
+ def possible_children(node)
140
+ node.rigor_each_child { |child| possible(child) }
141
+ end
142
+
143
+ # The receiver and arguments of a certain call run; its block runs as a class body only for a bare
144
+ # factory call. A safe-navigation call may skip its arguments.
145
+ def call(node)
146
+ certain(node.receiver)
147
+ if node.safe_navigation?
148
+ possible(node.arguments)
149
+ possible(node.block)
150
+ return
151
+ end
152
+ certain(node.arguments)
153
+ block = node.block
154
+ if block.is_a?(Prism::BlockNode) && AnonymousMetaClass.block_form_receiver(node)
155
+ certain(block.body)
156
+ else
157
+ certain(block)
158
+ end
159
+ end
160
+
161
+ # A meta-new constant write: the factory's receiver and arguments, and its block as the class body.
162
+ def meta_new_write?(node)
163
+ call = ScopeIndexer.meta_new_block_call(node)
164
+ return false unless call
165
+
166
+ certain(call.receiver)
167
+ certain(call.arguments)
168
+ block = call.block
169
+ certain(block.is_a?(Prism::BlockNode) ? block.body : block)
170
+ true
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../declaration_walk/traversal"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module ScopeIndexer
8
+ # The `class_cvars` discovery table (slice 7 phase 6) as a {DeclarationWalk} collector: every
9
+ # `@@x = …` a `def` body writes, typed under the census scope and unioned per class. Ruby cvars are
10
+ # shared by the instance and singleton facets, so every `def` counts, `def self.x` included.
11
+ class ClassCvarsCollector
12
+ include DeclarationWalk::Collector
13
+
14
+ def initialize
15
+ @table = {}
16
+ end
17
+
18
+ # `@@x` in a `def` resolves through the LEXICAL cref: `Module.nesting` is the same inside a meta-new or
19
+ # eval block, so the context's `prefix` keys the write and its rebound `self` never does —
20
+ # `K = Class.new { def m = @@x }` inside `class C` writes `C::@@x`. The walk goes no further in: the
21
+ # gather reads the body with `def`, `class` and `module` as barriers, and nothing below a `def` reaches
22
+ # this table.
23
+ def on_def(node, context)
24
+ ScopeIndexer.collect_def_cvar_writes(node, context.prefix, context.scope, @table)
25
+ DeclarationWalk::DECLINE
26
+ end
27
+
28
+ # The table as the discovery index holds it: each class's entry frozen, then the whole.
29
+ def table
30
+ @table.transform_values(&:freeze).freeze
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../declaration_walk/traversal"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module ScopeIndexer
8
+ # Issue #681's `{Prism::DefNode => Module.nesting}` table as a {DeclarationWalk} collector: the chain every
9
+ # `def` is declared under, keyed by node identity. A top-level `def` records the empty chain (#716); a `def`
10
+ # below a header that renders no name records nothing, because no chain survives it.
11
+ class DefNestingsCollector
12
+ include DeclarationWalk::Collector
13
+
14
+ # The rules `walk_def_nestings` answered differently from the walk, kept so the table stays
15
+ # byte-identical (ADR-116 WD5). Each is a legacy answer, and #1521 tracks converging it:
16
+ #
17
+ # - `factory_block: :ordinary_call` — no bare-factory arm, so a `self::` header in a `Class.new { … }`
18
+ # block anchors on the enclosing `self` and the block's parameters are walked (#1521 item 8).
19
+ # - `lexical_prefix: :nesting_head` — the walker threads no qualified prefix; its meta-new and eval
20
+ # splits resolve against `nesting.first` split into segments, which differs from the walk's prefix
21
+ # below a compact header and below an unnameable cref (#1521 item 1).
22
+ # - `unrendered_header: :body_with_lost_nesting` — a header that renders no name (a parse error) walks
23
+ # its body alone, with the chain lost unless an unnameable cref encloses it (#1521 item 3).
24
+ VARIANTS = {
25
+ factory_block: :ordinary_call,
26
+ lexical_prefix: :nesting_head,
27
+ unrendered_header: :body_with_lost_nesting
28
+ }.freeze
29
+
30
+ def initialize
31
+ @table = {}.compare_by_identity
32
+ end
33
+
34
+ # Nothing below a `def` declares a class or module, so the walk goes no further in for this table.
35
+ def on_def(node, context)
36
+ nesting = context.nesting
37
+ @table[node] = nesting unless nesting.nil?
38
+ DeclarationWalk::DECLINE
39
+ end
40
+
41
+ def table
42
+ @table.freeze
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ module ScopeIndexer
6
+ # Issue #1507 — a file's issue #681 def-nesting table read over the cross-file seed rather than merged into a
7
+ # copy of it. The copy inserted every `def` in the PROJECT once per analysed file (263 ms of an 18.95 s cold
8
+ # Mastodon run); a lookup probes at most both layers, and only for a `def` whose body is re-walked.
9
+ #
10
+ # Both layers are keyed by node identity, so a same-file declaration and its cross-file twin are distinct keys
11
+ # and the order cannot change an answer. The file layer is read first all the same, which is what the merge it
12
+ # replaces did (`merge!(seed)`, then `merge!(file)`), so the answer holds even if a shared parse ever made the
13
+ # keys overlap.
14
+ #
15
+ # It answers what the table's readers ask — `[]` ({ExpressionTyper#recorded_def_nesting}) and `empty?` — and
16
+ # is deliberately not a Hash: `each`, `key?`, `size` or `==` would have to see both layers, and a Hash holding
17
+ # one of them would answer those wrong instead of raising.
18
+ class LayeredDefNestings
19
+ def initialize(file, seed)
20
+ @file = file
21
+ @seed = seed
22
+ freeze
23
+ end
24
+
25
+ # `fetch` rather than `||`, so presence in the file layer decides, as the later `merge!` did.
26
+ def [](def_node)
27
+ @file.fetch(def_node) { @seed[def_node] }
28
+ end
29
+
30
+ def empty?
31
+ @file.empty? && @seed.empty?
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../declaration_walk/traversal"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module ScopeIndexer
8
+ # ADR-48's two member-layout tables as one {DeclarationWalk} collector: the ordered members of each
9
+ # `Data.define` class and of each `Struct.new` class (with its `keyword_init:` flag), in both the subclass
10
+ # form (`class Point < Data.define(:x, :y)`) and the constant form (`Point = Data.define(:x, :y)`). The
11
+ # legacy walkers built the two tables in two walks with the same rules; one collector builds both.
12
+ class MemberLayoutsCollector
13
+ include DeclarationWalk::Collector
14
+
15
+ # The rules the member-layout walkers answered differently from the walk, kept so both tables stay
16
+ # byte-identical (ADR-116 WD5). Each is a legacy answer, and #1521 tracks converging it:
17
+ #
18
+ # - `factory_block: :ordinary_call` — no bare-factory arm, so a `self::` header in a `Class.new { … }`
19
+ # block anchors on the enclosing `self` and the block's parameters are walked (#1521 item 8).
20
+ # - `unrendered_header: :skip` — a header that renders no name (a parse error) stops the walk there:
21
+ # neither its parts nor its body are walked (#1521 item 3).
22
+ VARIANTS = { factory_block: :ordinary_call, unrendered_header: :skip }.freeze
23
+
24
+ def initialize
25
+ @data = {}
26
+ @struct = {}
27
+ end
28
+
29
+ # A `class Point < Data.define(:x, :y)` header, recorded under the class it names; an unnameable
30
+ # header (`class D` below `class <<` opens `#<Class:C>::D`, which no name spells) records nothing.
31
+ def on_declaration(node, _context, body)
32
+ if node.is_a?(Prism::ClassNode) && !body.singleton_cref
33
+ ScopeIndexer.record_data_member_layout(@data, body.prefix, node.superclass, allow_outer_block: false)
34
+ ScopeIndexer.record_struct_member_layout(@struct, body.prefix, node.superclass, allow_outer_block: false)
35
+ end
36
+ DeclarationWalk::DESCEND
37
+ end
38
+
39
+ # A `Point = Data.define(:x, :y)` write, recorded under the class the write names. Below an unnameable
40
+ # cref only a path write still names one.
41
+ def on_constant_write(node, context)
42
+ self_owner = context.self_owner
43
+ unless context.singleton_cref && !ScopeIndexer.meta_new_path_target_nameable?(node, self_owner)
44
+ child_prefix = ScopeIndexer.meta_new_child_prefix(node, context.prefix, self_owner)
45
+ end
46
+ if child_prefix
47
+ rvalue = ScopeIndexer.meta_new_rvalue(node)
48
+ ScopeIndexer.record_data_member_layout(@data, child_prefix, rvalue)
49
+ ScopeIndexer.record_struct_member_layout(@struct, child_prefix, rvalue)
50
+ end
51
+ DeclarationWalk::DESCEND
52
+ end
53
+
54
+ # `[data_member_layouts, struct_member_layouts]`, each frozen.
55
+ def tables
56
+ [@data.freeze, @struct.freeze]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end