rigortype 0.1.17 → 0.1.18

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
@@ -70,12 +70,15 @@ module Rigor
70
70
  "fold_platform_specific_paths" => false,
71
71
  "cache" => {
72
72
  "path" => ".rigor/cache",
73
- # LRU eviction cap in bytes. nil (the default) disables eviction;
74
- # the cache grows until the user runs `rigor check --clear-cache`.
75
- # Set to a positive integer (e.g. 536870912 for 512 MB) to keep the
76
- # cache bounded the least-recently-used entries are removed at the
77
- # end of each run when the total exceeds this limit.
78
- "max_bytes" => nil
73
+ # LRU eviction cap in bytes (ADR-54 WD3). The least-recently-used
74
+ # entries are removed at the end of a run when the total exceeds
75
+ # this limit. The 256 MB default exists to reap orphans entries
76
+ # whose content key nothing references any more (an rbs gem bump,
77
+ # a signature change) and that no run would otherwise ever delete;
78
+ # a full active per-project set is ~2 MB, so the cap never touches
79
+ # live entries. Set explicitly to `null` to disable eviction
80
+ # (pre-WD3 behaviour: the cache grows until `--clear-cache`).
81
+ "max_bytes" => 268_435_456
79
82
  },
80
83
  "plugins_io" => {
81
84
  "network" => "disabled",
@@ -559,29 +559,17 @@ module Rigor
559
559
  # definition cannot be built (RBS may raise on broken hierarchies;
560
560
  # we fail-soft and return nil so the caller can fall back).
561
561
  #
562
- # When `cache_store` is set, the loader fetches the per-class
563
- # definition through {Cache::RbsInstanceDefinitions.fetch} so
564
- # subsequent runs (and other loaders sharing the same Store)
565
- # skip the `RBS::DefinitionBuilder.build_instance` step.
566
- # In-memory `@instance_definition_cache` keeps the per-process
567
- # short-circuit on top.
562
+ # Built on demand from the (possibly cache-loaded) env; the
563
+ # in-memory `@instance_definition_cache` keeps the per-process
564
+ # short-circuit. ADR-54 WD1 retired the definitions disk blob:
565
+ # given a cached env, `Marshal.load`-ing every definition was
566
+ # measurably slower (and allocation-heavier) than rebuilding
567
+ # the ones a run actually touches.
568
568
  def instance_definition(class_name)
569
569
  key = class_name.to_s
570
570
  return @instance_definition_cache[key] if @instance_definition_cache.key?(key)
571
571
 
572
- @instance_definition_cache[key] = if cache_store
573
- cached_instance_definition(class_name)
574
- else
575
- build_instance_definition(class_name)
576
- end
577
- end
578
-
579
- # Public uncached accessor used by the cache producer
580
- # ({Rigor::Cache::RbsInstanceDefinitions}). Avoids the
581
- # `private_method_called` round-trip a `loader.send(...)`
582
- # callsite would require.
583
- def uncached_instance_definition(class_name)
584
- build_instance_definition(class_name)
572
+ @instance_definition_cache[key] = build_instance_definition(class_name)
585
573
  end
586
574
 
587
575
  # @return [RBS::Definition::Method, nil]
@@ -636,24 +624,14 @@ module Rigor
636
624
  # those inherited from `Class` and `Module` for class types.
637
625
  # Returns nil for unknown names and on RBS build errors (fail-soft).
638
626
  #
639
- # When `cache_store` is set, the loader fetches the per-class
640
- # singleton definition through
641
- # {Cache::RbsSingletonDefinitions.fetch}; the same caching
642
- # discipline as {#instance_definition}.
627
+ # Built on demand from the env with a per-process memo; the
628
+ # same on-demand discipline as {#instance_definition} (ADR-54
629
+ # WD1).
643
630
  def singleton_definition(class_name)
644
631
  key = class_name.to_s
645
632
  return @singleton_definition_cache[key] if @singleton_definition_cache.key?(key)
646
633
 
647
- @singleton_definition_cache[key] = if cache_store
648
- cached_singleton_definition(class_name)
649
- else
650
- build_singleton_definition(class_name)
651
- end
652
- end
653
-
654
- # Public uncached accessor used by the cache producer.
655
- def uncached_singleton_definition(class_name)
656
- build_singleton_definition(class_name)
634
+ @singleton_definition_cache[key] = build_singleton_definition(class_name)
657
635
  end
658
636
 
659
637
  # @return [RBS::Definition::Method, nil] the class method on
@@ -762,18 +740,20 @@ module Rigor
762
740
  end
763
741
 
764
742
  # ADR-15 Phase 4b.x — eagerly drives every cached
765
- # producer so a subsequent worker Ractor can serve all
766
- # of its RBS queries from the Marshal blob on disk
767
- # without ever calling `RBS::EnvironmentLoader.new`.
743
+ # producer (plus the eager definitions tables, computed
744
+ # from the cached env since ADR-54 WD1) so a subsequent
745
+ # worker Ractor can serve all of its RBS queries without
746
+ # ever calling `RBS::EnvironmentLoader.new`.
768
747
  # The loader path that calls `EnvironmentLoader.new`
769
748
  # transitively reads a chain of non-`Ractor.shareable?`
770
749
  # module constants
771
750
  # (`RBS::EnvironmentLoader::DEFAULT_CORE_ROOT`,
772
751
  # `RBS::Repository::DEFAULT_STDLIB_ROOT`,
773
752
  # `Gem::Requirement::DefaultRequirement`, …) and trips
774
- # `Ractor::IsolationError`. Pre-warming the cache on
775
- # the main Ractor and letting workers consult ONLY the
776
- # Marshal-loaded blob sidesteps the whole chain.
753
+ # `Ractor::IsolationError`. Pre-warming on the main
754
+ # Ractor env blob loaded, derived tables built — keeps
755
+ # workers off that chain (`RBS::DefinitionBuilder` over
756
+ # an already-built env does not touch it).
777
757
  #
778
758
  # No-op when `cache_store` is nil — without a Store the
779
759
  # worker has no choice but to build env via the loader,
@@ -793,6 +773,20 @@ module Rigor
793
773
  self
794
774
  end
795
775
 
776
+ # ADR-54 WD4 — the shared cache descriptor for every RBS-derived
777
+ # producer consulting this loader. Building it digests every
778
+ # `.rbs` file under `signature_paths` + the vendored gem sigs,
779
+ # and the result is identical across producers, so one build is
780
+ # memoised per loader (on `@state`, alongside `:env` — the env
781
+ # itself is loader-lifetime-memoised, so this adds no new
782
+ # staleness class).
783
+ def rbs_cache_descriptor
784
+ @state[:rbs_cache_descriptor] ||= begin
785
+ require_relative "../cache/rbs_descriptor"
786
+ Cache::RbsDescriptor.build(self)
787
+ end
788
+ end
789
+
796
790
  # ADR-15 Phase 2b — return the loader's read-only
797
791
  # query surface as a frozen, `Ractor.shareable?`
798
792
  # {Reflection} value object. Built lazily on first
@@ -969,43 +963,34 @@ module Rigor
969
963
  Cache::RbsEnvironment.fetch(loader: self, store: cache_store)
970
964
  end
971
965
 
972
- # Per-process Hash<String, RBS::Definition> for the instance
973
- # side. Loaded once on first miss through the
974
- # {Cache::RbsInstanceDefinitions} producer (single Marshal
975
- # blob); subsequent calls are pure Hash lookups. Cold runs
976
- # build every known class once and persist; warm runs (and
977
- # other loaders sharing the same Store) skip the
978
- # `RBS::DefinitionBuilder.build_instance` work entirely.
979
- def cached_instance_definition(class_name)
980
- instance_definitions_table[normalise_class_key(class_name)]
981
- end
982
-
966
+ # Full `Hash<String, RBS::Definition>` tables for the
967
+ # {#prewarm} / {#reflection} consumers (ADR-15 Phase 2b/4b.x),
968
+ # which need every definition materialised up front. Built
969
+ # from the (cached) env via `RBS::DefinitionBuilder` ADR-54
970
+ # WD1 retired the disk blobs these used to `Marshal.load`
971
+ # (building all definitions from a cached env is faster), so
972
+ # the eager-table cost is now a compute, not a load. Keys stay
973
+ # in `RBS::TypeName#to_s` form (top-level prefixed `"::Hash"`)
974
+ # — the shape {Environment::Reflection} documents.
983
975
  def instance_definitions_table
984
- @state[:instance_definitions_table] ||= begin
985
- require_relative "../cache/rbs_instance_definitions"
986
- fetch_or_compute_producer(Cache::RbsInstanceDefinitions)
976
+ @state[:instance_definitions_table] ||= build_definitions_table do |name|
977
+ build_instance_definition(name)
987
978
  end
988
979
  end
989
980
 
990
- def cached_singleton_definition(class_name)
991
- singleton_definitions_table[normalise_class_key(class_name)]
992
- end
993
-
994
981
  def singleton_definitions_table
995
- @state[:singleton_definitions_table] ||= begin
996
- require_relative "../cache/rbs_instance_definitions"
997
- fetch_or_compute_producer(Cache::RbsSingletonDefinitions)
982
+ @state[:singleton_definitions_table] ||= build_definitions_table do |name|
983
+ build_singleton_definition(name)
998
984
  end
999
985
  end
1000
986
 
1001
- # The cache producers persist class names in
1002
- # `RBS::TypeName#to_s` form (top-level prefixed
1003
- # `"::Hash"`); plain-name lookups (`"Hash"`) normalise
1004
- # before the Hash query so callers stay agnostic to the
1005
- # prefix.
1006
- def normalise_class_key(class_name)
1007
- s = class_name.to_s
1008
- s.start_with?("::") ? s : "::#{s}"
987
+ def build_definitions_table
988
+ table = {}
989
+ each_known_class_name do |name|
990
+ definition = yield(name)
991
+ table[name] = definition if definition
992
+ end
993
+ table
1009
994
  end
1010
995
 
1011
996
  def builder
@@ -77,7 +77,7 @@ module Rigor
77
77
  # @param plugin_registry [Rigor::Plugin::Registry, nil] v0.1.1
78
78
  # Track 2 slice 7. The per-run plugin registry the
79
79
  # inference engine consults at call sites for plugin
80
- # `#flow_contribution_for` overrides. When nil (the
80
+ # `dynamic_return` rules. When nil (the
81
81
  # default), no plugin-level return-type contribution
82
82
  # participates — useful for tests, the `Environment.default`
83
83
  # facade, and analyses that don't load plugins.
@@ -243,6 +243,12 @@ module Rigor
243
243
  when Type::HashShape
244
244
  accepts(self_type, project_hash_shape_to_nominal(other_type), mode: mode)
245
245
  .with_reason("projected HashShape to Nominal[Hash]")
246
+ when Type::DataInstance
247
+ # ADR-48: a class-tagged member instance is exactly one value of
248
+ # its tagging class, so it projects to that class's nominal (the
249
+ # anonymous local-bound form projects to `Data` itself).
250
+ accepts(self_type, project_data_instance_to_nominal(other_type), mode: mode)
251
+ .with_reason("projected DataInstance to Nominal[#{other_type.class_name || 'Data'}]")
246
252
  when Type::Difference, Type::Refined
247
253
  # A refinement carrier's value set is a subset of its
248
254
  # base. So if `self` (Nominal) accepts the base, it
@@ -376,6 +382,10 @@ module Rigor
376
382
  end
377
383
  end
378
384
 
385
+ def project_data_instance_to_nominal(instance)
386
+ Type::Combinator.nominal_of(instance.class_name || "Data")
387
+ end
388
+
379
389
  def project_hash_shape_to_nominal(shape)
380
390
  return Type::Combinator.nominal_of(Hash) if shape.pairs.empty?
381
391
 
@@ -8,6 +8,7 @@ require_relative "../analysis/self_call_resolution_recorder"
8
8
  require_relative "block_parameter_binder"
9
9
  require_relative "budget_trace"
10
10
  require_relative "fallback"
11
+ require_relative "flow_tracer"
11
12
  require_relative "indexed_narrowing"
12
13
  require_relative "macro_block_self_type"
13
14
  require_relative "method_dispatcher"
@@ -219,6 +220,15 @@ module Rigor
219
220
  end
220
221
 
221
222
  def type_of(node)
223
+ return untraced_type_of(node) unless FlowTracer.active?
224
+
225
+ # `rigor trace` — bracket the recursion with enter/result events.
226
+ # The tracer is observational only: the inferred type flows
227
+ # through unchanged (see FlowTracer's contract).
228
+ FlowTracer.trace_node(node) { untraced_type_of(node) }
229
+ end
230
+
231
+ def untraced_type_of(node)
222
232
  # Slice A-declarations. ScopeIndexer pre-fills
223
233
  # `scope.declared_types` for declaration-position nodes
224
234
  # (`module Foo` / `class Bar` headers) with the qualified
@@ -668,26 +678,17 @@ module Rigor
668
678
  end
669
679
 
670
680
  # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary
671
- # predicate expression under three-valued logic. Uses the
672
- # same {Narrowing} probe as `StatementEvaluator#eval_if`:
673
- # the predicate is truthy when its falsey fragment is `Bot`,
674
- # falsey when its truthy fragment is `Bot`. So
675
- # `Nominal[Integer]` (always truthy in Ruby), `Constant[nil]`,
676
- # and `Constant[false]` fold one branch; `Union[true, false]`,
677
- # `Dynamic[T]`, and `Top` keep both branches live.
681
+ # predicate expression under three-valued logic.
682
+ # {Narrowing.predicate_certainty} owns the judgment (the same
683
+ # one `StatementEvaluator#live_branch_for_if` reads on the
684
+ # scope side): `Nominal[Integer]` (always truthy in Ruby),
685
+ # `Constant[nil]`, and `Constant[false]` fold one branch;
686
+ # `Union[true, false]`, `Dynamic[T]`, and `Top` keep both
687
+ # branches live.
678
688
  def constant_predicate_polarity(predicate)
679
689
  return nil if predicate.nil?
680
690
 
681
- type = type_of(predicate)
682
- return nil if type.nil? || type.is_a?(Type::Bot)
683
-
684
- truthy_bot = Narrowing.narrow_truthy(type).is_a?(Type::Bot)
685
- falsey_bot = Narrowing.narrow_falsey(type).is_a?(Type::Bot)
686
-
687
- return :falsey if truthy_bot && !falsey_bot
688
- return :truthy if !truthy_bot && falsey_bot
689
-
690
- nil
691
+ Narrowing.predicate_certainty(type_of(predicate))
691
692
  end
692
693
 
693
694
  def type_of_else(node)
@@ -818,29 +819,14 @@ module Rigor
818
819
  # `:maybe` — the existing union fallback handles them.
819
820
  def case_when_pattern_certainty(subject_type, pattern_node)
820
821
  class_name = build_constant_path_name(pattern_node)
821
- return class_pattern_certainty(subject_type, class_name) if class_name
822
+ return Narrowing.class_pattern_certainty(subject_type, class_name, environment: scope.environment) if class_name
822
823
 
823
824
  literal = literal_pattern_value(pattern_node)
824
- return literal_pattern_certainty(subject_type, literal[:value]) if literal
825
+ return Narrowing.value_pattern_certainty(subject_type, literal[:value]) if literal
825
826
 
826
827
  :maybe
827
828
  end
828
829
 
829
- def class_pattern_certainty(subject_type, class_name)
830
- env = scope.environment
831
- truthy_bot = Narrowing.narrow_class(subject_type, class_name, environment: env).is_a?(Type::Bot)
832
- falsey_bot = Narrowing.narrow_not_class(subject_type, class_name, environment: env).is_a?(Type::Bot)
833
-
834
- return :no if truthy_bot && !falsey_bot
835
- return :yes if !truthy_bot && falsey_bot
836
-
837
- :maybe
838
- end
839
-
840
- VALUE_EQUALITY_CLASSES = [Integer, Float, Rational, Complex, String, Symbol,
841
- TrueClass, FalseClass, NilClass].freeze
842
- private_constant :VALUE_EQUALITY_CLASSES
843
-
844
830
  # Returns `{ value: v }` when `pattern_node` types to a
845
831
  # `Constant[v]` of a value-equality-safe class (so `===`
846
832
  # reduces to `==`), else nil. Wrapped in a hash so a literal
@@ -849,18 +835,11 @@ module Rigor
849
835
  def literal_pattern_value(pattern_node)
850
836
  type = type_of(pattern_node)
851
837
  return nil unless type.is_a?(Type::Constant)
852
- return nil unless VALUE_EQUALITY_CLASSES.any? { |klass| type.value.is_a?(klass) }
838
+ return nil unless Narrowing::VALUE_EQUALITY_CLASSES.any? { |klass| type.value.is_a?(klass) }
853
839
 
854
840
  { value: type.value }
855
841
  end
856
842
 
857
- def literal_pattern_certainty(subject_type, pattern_value)
858
- return :maybe unless subject_type.is_a?(Type::Constant)
859
- return :maybe unless VALUE_EQUALITY_CLASSES.any? { |klass| subject_type.value.is_a?(klass) }
860
-
861
- pattern_value == subject_type.value ? :yes : :no
862
- end
863
-
864
843
  # `when` clauses for `case` and `in` clauses for `case ... in` have
865
844
  # the same body shape; we reuse one handler for both Prism node
866
845
  # classes.
@@ -1565,7 +1544,7 @@ module Rigor
1565
1544
  # nil when the parameter shape is too complex for the
1566
1545
  # first-iteration binder (rest args, keyword args,
1567
1546
  # block params, etc.).
1568
- def build_user_method_body_scope(def_node, receiver, arg_types) # rubocop:disable Metrics/AbcSize
1547
+ def build_user_method_body_scope(def_node, receiver, arg_types)
1569
1548
  params = def_node.parameters
1570
1549
  required = params&.requireds || []
1571
1550
  return nil unless params.nil? || user_method_param_shape_simple?(params)
@@ -1578,30 +1557,17 @@ module Rigor
1578
1557
  locals = {}
1579
1558
  required.each_with_index { |param, index| locals[param.name.to_sym] = arg_types[index] }
1580
1559
 
1581
- # Construct the body scope in a SINGLE allocation. The previous
1560
+ # Construct the body scope in a SINGLE allocation the previous
1582
1561
  # `Scope.empty.with_*.with_*…` chain allocated a fresh frozen Scope
1583
- # per field ~12 throwaway Scopes to build one body scope, run per
1584
- # user-method-call inference, which made these `with_*` the dominant
1585
- # `Scope#rebuild` source. Each field here is a plain inherited
1586
- # reference (the project-wide indexes + self_type); every unset
1587
- # field defaults to the same empty binding the old chain left it at,
1588
- # so the result is identical (ADR-44).
1562
+ # per field, run per user-method-call inference (ADR-44). The
1563
+ # discovery index is inherited whole by reference (ADR-53 Track A);
1564
+ # the hand-copied per-field list this replaces had silently dropped
1565
+ # `data_member_layouts` and `discovered_method_visibilities`.
1589
1566
  Scope.new(
1590
1567
  environment: scope.environment,
1591
1568
  locals: locals.freeze,
1592
1569
  self_type: receiver,
1593
- declared_types: scope.declared_types,
1594
- discovered_classes: scope.discovered_classes,
1595
- in_source_constants: scope.in_source_constants,
1596
- class_ivars: scope.class_ivars,
1597
- class_cvars: scope.class_cvars,
1598
- program_globals: scope.program_globals,
1599
- discovered_methods: scope.discovered_methods,
1600
- discovered_def_nodes: scope.discovered_def_nodes,
1601
- discovered_def_sources: scope.discovered_def_sources,
1602
- discovered_superclasses: scope.discovered_superclasses,
1603
- discovered_includes: scope.discovered_includes,
1604
- discovered_class_sources: scope.discovered_class_sources
1570
+ discovery: scope.discovery
1605
1571
  )
1606
1572
  end
1607
1573
 
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Thread-local event recorder behind `rigor trace`: while a block runs
6
+ # under {record}, the inference engine emits a flat, ordered event
7
+ # stream describing HOW it typed the program — expression enter/result
8
+ # pairs, scope binds, union formation, and method-dispatch outcomes.
9
+ # The CLI replays that stream as a terminal animation (or dumps it as
10
+ # JSON); the engine itself never reads the events back, so recording
11
+ # is purely observational and MUST NOT change any inferred type.
12
+ #
13
+ # Modelled on {Analysis::DependencyRecorder}: thread-local state, a
14
+ # module-level activation count so the disabled fast path ({active?})
15
+ # is a plain integer read, and a frozen snapshot for consumers. The
16
+ # instrumented hot paths (`ExpressionTyper#type_of`,
17
+ # `Scope#with_local`, `Type::Combinator.union`,
18
+ # `MethodDispatcher.dispatch`) each guard their emit behind {active?},
19
+ # so a normal (non-tracing) run pays one integer comparison.
20
+ module FlowTracer
21
+ KEY = :__rigor_flow_tracer__
22
+ private_constant :KEY
23
+
24
+ # One animation-relevant moment.
25
+ #
26
+ # kind :enter | :result | :bind | :union | :dispatch
27
+ # depth expression-recursion depth at emit time (0 = statement level)
28
+ # location frozen Hash with :start_line/:start_column/:end_line/
29
+ # :end_column/:start_offset/:end_offset, or nil. Events
30
+ # without a node of their own (:bind, :union) inherit the
31
+ # innermost in-flight expression node's location so the
32
+ # replayer can still highlight the source being evaluated.
33
+ # stack frozen Array of short node-class names, outermost first
34
+ # data frozen kind-specific Hash (types pre-rendered as Strings
35
+ # via `describe(:short)` so events serialise to JSON as-is)
36
+ Event = Data.define(:kind, :depth, :location, :stack, :data)
37
+
38
+ # Mutable per-thread accumulator; only ever touched by the thread
39
+ # that activated it, so no locking is needed on the emit path.
40
+ class Recorder
41
+ attr_reader :events
42
+
43
+ def initialize
44
+ @events = []
45
+ @stack = []
46
+ end
47
+
48
+ # Brackets one `ExpressionTyper#type_of` recursion: emits :enter,
49
+ # runs the real inference, emits :result with the inferred type,
50
+ # and returns the type unchanged.
51
+ def node(node)
52
+ location = location_of(node)
53
+ name = short_name(node.class)
54
+ emit(:enter, location: location, data: { node: name })
55
+ @stack.push(node)
56
+ result = nil
57
+ begin
58
+ result = yield
59
+ ensure
60
+ @stack.pop
61
+ end
62
+ emit(:result, location: location, data: { node: name, type: FlowTracer.describe(result) })
63
+ result
64
+ end
65
+
66
+ def emit(kind, location: nil, data: {})
67
+ @events << Event.new(
68
+ kind: kind,
69
+ depth: @stack.size,
70
+ location: (location || current_location)&.freeze,
71
+ stack: @stack.map { |n| short_name(n.class) }.freeze,
72
+ data: data.freeze
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ def current_location
79
+ location_of(@stack.last)
80
+ end
81
+
82
+ def location_of(node)
83
+ return nil unless node.respond_to?(:location) && node.location
84
+
85
+ loc = node.location
86
+ {
87
+ start_line: loc.start_line, start_column: loc.start_column,
88
+ end_line: loc.end_line, end_column: loc.end_column,
89
+ start_offset: loc.start_offset, end_offset: loc.end_offset
90
+ }
91
+ end
92
+
93
+ def short_name(klass)
94
+ klass.name.to_s.split("::").last
95
+ end
96
+ end
97
+
98
+ @active_count = 0
99
+ @mutex = Mutex.new
100
+
101
+ module_function
102
+
103
+ # Activates recording on the current thread for the duration of the
104
+ # block and returns the frozen event list. Nests safely; restores
105
+ # the previous recorder on exit.
106
+ def record
107
+ previous = Thread.current[KEY]
108
+ recorder = Recorder.new
109
+ Thread.current[KEY] = recorder
110
+ @mutex.synchronize { @active_count += 1 }
111
+ yield
112
+ recorder.events.freeze
113
+ ensure
114
+ Thread.current[KEY] = previous
115
+ @mutex.synchronize { @active_count -= 1 }
116
+ end
117
+
118
+ # Plain integer read (GVL-atomic) — the disabled fast path.
119
+ def active?
120
+ @active_count.positive?
121
+ end
122
+
123
+ # Brackets one expression-typing recursion. Falls through to the
124
+ # bare block when the current thread is not recording (another
125
+ # thread may have flipped {active?}).
126
+ def trace_node(node, &)
127
+ recorder = Thread.current[KEY]
128
+ return yield unless recorder
129
+
130
+ recorder.node(node, &)
131
+ end
132
+
133
+ # `Scope#with_local` — the moment a local enters the scope.
134
+ def bind(name, type)
135
+ Thread.current[KEY]&.emit(:bind, data: { name: name.to_s, type: describe(type) })
136
+ end
137
+
138
+ # `Type::Combinator.union` — the moment branch types merge
139
+ # (including degenerate collapses like `1 | 1 → 1`).
140
+ def union(members, result)
141
+ Thread.current[KEY]&.emit(
142
+ :union,
143
+ data: { members: members.map { |m| describe(m) }.freeze, type: describe(result) }
144
+ )
145
+ end
146
+
147
+ # `MethodDispatcher.dispatch` — resolution or the fail-soft `nil`
148
+ # ("no rule matched"; the caller will widen to `Dynamic[Top]`).
149
+ def dispatch(receiver:, method_name:, args:, result:, location: nil)
150
+ recorder = Thread.current[KEY]
151
+ return unless recorder
152
+
153
+ recorder.emit(
154
+ :dispatch,
155
+ location: location && location_hash(location),
156
+ data: {
157
+ receiver: describe(receiver), method: method_name.to_s,
158
+ args: args.map { |a| describe(a) }.freeze,
159
+ type: result && describe(result), resolved: !result.nil?
160
+ }
161
+ )
162
+ end
163
+
164
+ def describe(type)
165
+ return "nil" if type.nil?
166
+ return type.describe(:short) if type.respond_to?(:describe)
167
+
168
+ type.inspect
169
+ end
170
+
171
+ def location_hash(loc)
172
+ {
173
+ start_line: loc.start_line, start_column: loc.start_column,
174
+ end_line: loc.end_line, end_column: loc.end_column,
175
+ start_offset: loc.start_offset, end_offset: loc.end_offset
176
+ }
177
+ end
178
+ end
179
+ end
180
+ end
@@ -51,11 +51,16 @@ module Rigor
51
51
  receiver_class_name = singleton_receiver_class_name(receiver_type)
52
52
  return nil if receiver_class_name.nil?
53
53
 
54
- verb = call_node.name
55
- registry.plugins.each do |plugin|
56
- plugin.manifest.block_as_methods.each do |entry| # rigor:disable undefined-method
57
- return instance_type_for(receiver_class_name, environment) if matches?(entry, verb, receiver_class_name,
58
- environment)
54
+ # ADR-52 WD1 — the verb-keyed table compiled at registry build
55
+ # replaces the per-call plugins × block_as_methods linear scan.
56
+ # Entries arrive in (plugin registration, declaration) order, so
57
+ # the first ancestry match below is the same entry the previous
58
+ # walk returned; the verb membership the old `matches?` checked
59
+ # is guaranteed by the table key.
60
+ entries = registry.contribution_index.block_entries_for(call_node.name)
61
+ entries.each do |entry|
62
+ if receiver_class_inherits_from?(receiver_class_name, entry.receiver_constraint, environment)
63
+ return instance_type_for(receiver_class_name, environment)
59
64
  end
60
65
  end
61
66
  nil
@@ -73,12 +78,6 @@ module Rigor
73
78
  receiver_type.class_name
74
79
  end
75
80
 
76
- def matches?(entry, verb, receiver_class_name, environment)
77
- return false unless entry.verbs.include?(verb)
78
-
79
- receiver_class_inherits_from?(receiver_class_name, entry.receiver_constraint, environment)
80
- end
81
-
82
81
  def receiver_class_inherits_from?(class_name, constraint, environment)
83
82
  return true if class_name == constraint
84
83
 
@@ -32,7 +32,16 @@ module Rigor
32
32
  # matches, accept the first arity-and-gradual-accept match
33
33
  # (the v0.1.1 behaviour). Alias / Interface / Intersection
34
34
  # params still reach this pass, so call sites whose only
35
- # candidate IS an alias-typed overload keep working.
35
+ # candidate IS an alias-typed overload keep working. One
36
+ # exclusion: an `untyped` argument does NOT gradually match
37
+ # a value-pinning param (`nil` / literal types — carriers
38
+ # that admit only specific values). Those overloads carry
39
+ # value-precise returns (`Kernel#Array: (nil) -> []`,
40
+ # `Regexp#=~: (nil) -> nil`) that would otherwise win purely
41
+ # by list position and inject false constants into the flow;
42
+ # they remain selectable when the argument PROVES the value
43
+ # (strict pass) or when no other overload matches (step 4's
44
+ # fallback picks the first overload regardless).
36
45
  # 4. If no overload matches at all, fall back to
37
46
  # `method_types.first` so existing call sites keep their
38
47
  # phase 1 / 2b behavior. This preserves the fail-soft
@@ -393,9 +402,32 @@ module Rigor
393
402
  instance_type: instance_type,
394
403
  type_vars: type_vars
395
404
  )
405
+ # An `untyped` arg gradually accepts against every param,
406
+ # so a value-pinning param would be "matched" with zero
407
+ # evidence and its value-precise return (`(nil) -> []`)
408
+ # would beat broader overloads purely by list position.
409
+ # Decline the pair; only the strict pass (where the arg
410
+ # proves the value) or the final first-overload fallback
411
+ # may select such an overload. (Pass 1 already skips
412
+ # untyped args entirely, so this only engages pass 2.)
413
+ return false if untyped_arg?(arg) && value_pinning?(param_type)
414
+
396
415
  result = param_type.accepts(arg, mode: :gradual)
397
416
  result.yes? || result.maybe?
398
417
  end
418
+
419
+ # A type that admits only specific VALUES rather than a
420
+ # class of values: a `Constant` carrier (RBS `nil` and
421
+ # literal types both translate to one) or a union made up
422
+ # entirely of them (`true | false`, `1 | 2`, `nil?`-style
423
+ # optionals of literals).
424
+ def value_pinning?(type)
425
+ case type
426
+ when Type::Constant then true
427
+ when Type::Union then type.members.all? { |member| value_pinning?(member) }
428
+ else false
429
+ end
430
+ end
399
431
  end
400
432
  end
401
433
  end