rigortype 0.1.5 → 0.1.7

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. metadata +56 -1
@@ -3,11 +3,16 @@
3
3
  require_relative "environment/class_registry"
4
4
  require_relative "environment/rbs_loader"
5
5
  require_relative "environment/reflection"
6
+ require_relative "environment/reporters"
7
+ require_relative "environment/hkt_registry_holder"
6
8
  require_relative "environment/bundle_sig_discovery"
7
9
  require_relative "environment/lockfile_resolver"
8
10
  require_relative "environment/rbs_collection_discovery"
9
11
  require_relative "environment/rbs_coverage_report"
10
12
  require_relative "inference/synthetic_method_index"
13
+ require_relative "inference/project_patched_methods"
14
+ require_relative "inference/hkt_registry"
15
+ require_relative "builtins/hkt_builtins"
11
16
  require_relative "type_node/name_scope"
12
17
  require_relative "type_node/resolver_chain"
13
18
 
@@ -57,8 +62,8 @@ module Rigor
57
62
  ].freeze
58
63
 
59
64
  attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index,
60
- :rbs_extended_reporter, :boundary_cross_reporter, :name_scope,
61
- :synthetic_method_index
65
+ :reporters, :name_scope,
66
+ :synthetic_method_index, :project_patched_methods
62
67
 
63
68
  # @param class_registry [Rigor::Environment::ClassRegistry]
64
69
  # @param rbs_loader [Rigor::Environment::RbsLoader, nil] when nil the
@@ -79,24 +84,105 @@ module Rigor
79
84
  # sources the dispatcher consults BELOW RBS dispatch.
80
85
  # When nil (the default), no dep-source contribution
81
86
  # participates and the dispatcher tier is a no-op.
82
- def initialize(class_registry: ClassRegistry.default, rbs_loader: nil,
87
+ def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, # rubocop:disable Metrics/ParameterLists
83
88
  plugin_registry: nil, dependency_source_index: nil,
84
89
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
85
- synthetic_method_index: nil)
90
+ synthetic_method_index: nil, project_patched_methods: nil,
91
+ hkt_registry: nil)
86
92
  @class_registry = class_registry
87
93
  @rbs_loader = rbs_loader
88
94
  @plugin_registry = plugin_registry
89
95
  @dependency_source_index = dependency_source_index
90
- @rbs_extended_reporter = rbs_extended_reporter
91
- @boundary_cross_reporter = boundary_cross_reporter
96
+ # ADR-pending — reporters live in a mutable container so
97
+ # long-lived integrations (LSP `ProjectContext`) can swap
98
+ # them per `Runner.run` without rebuilding the env. The
99
+ # existing `#rbs_extended_reporter` / `#boundary_cross_reporter`
100
+ # accessors below preserve the public lookup shape.
101
+ @reporters = Reporters.new(
102
+ rbs_extended: rbs_extended_reporter,
103
+ boundary_cross: boundary_cross_reporter
104
+ )
92
105
  @synthetic_method_index = synthetic_method_index || Inference::SyntheticMethodIndex::EMPTY
106
+ @project_patched_methods = project_patched_methods || Inference::ProjectPatchedMethods::EMPTY
107
+ # ADR-20 slice 2c + 2e — the per-env HKT registry
108
+ # consulted by the reducer when resolving `Type::App`
109
+ # carriers. Defaults to {Inference::HktRegistry::EMPTY};
110
+ # the {.default} / {.for_project} class methods seed it
111
+ # with the bundled builtins (`json::value`, …) plus any
112
+ # `%a{rigor:v1:hkt_register / hkt_define}` annotations
113
+ # the RBS loader exposes. The hkt_registry getter
114
+ # (defined below) MEMOIZES the result of merging the
115
+ # base with the RBS scan so the scan is paid at most
116
+ # once per Environment lifetime — and only when first
117
+ # consulted, leaving fast paths like `rigor check
118
+ # --cache-stats --no-stats` from doing the RBS env
119
+ # build at all.
120
+ @hkt_registry_base = hkt_registry || Inference::HktRegistry::EMPTY
121
+ @hkt_registry_holder = HktRegistryHolder.new
93
122
  @name_scope = build_name_scope
94
123
  freeze
95
124
  end
96
125
 
126
+ # ADR-20 slices 2e + 6 — lazy HKT registry getter.
127
+ # Merge order on first call: builtins (base) ← plugin
128
+ # manifest aggregation ← RBS env scan. Last-write-wins on
129
+ # URI collisions so user-authored `.rbs` overlays beat
130
+ # plugin entries, which beat the bundled JSON_VALUE.
131
+ # Memoised; single-threaded use only (under the Ractor
132
+ # pool path each worker has its own Environment so
133
+ # cross-worker mutation is impossible; the LSP
134
+ # single-publish-at-a-time invariant serialises here).
135
+ def hkt_registry
136
+ @hkt_registry_holder.fetch do
137
+ with_plugin_overlay = if @plugin_registry.respond_to?(:hkt_overlay_registry)
138
+ @hkt_registry_base.merge(@plugin_registry.hkt_overlay_registry)
139
+ else
140
+ @hkt_registry_base
141
+ end
142
+ Inference::HktRegistry.scan_rbs_loader(
143
+ @rbs_loader,
144
+ base: with_plugin_overlay,
145
+ reporter: rbs_extended_reporter
146
+ )
147
+ end
148
+ end
149
+
150
+ # Backwards-compatible reporter accessors — every existing
151
+ # consumer (rbs_extended, method_dispatcher) calls these. The
152
+ # frozen `@reporters` container is mutable for slot reassignment
153
+ # via {#attach_reporters!} below.
154
+ def rbs_extended_reporter
155
+ @reporters.rbs_extended
156
+ end
157
+
158
+ def boundary_cross_reporter
159
+ @reporters.boundary_cross
160
+ end
161
+
162
+ # Replaces the env's per-run reporter slots. Intended for
163
+ # long-lived integrations (LSP `ProjectContext`) that share one
164
+ # Environment instance across many `Runner.run` calls: each call
165
+ # attaches its own fresh reporter pair so per-call diagnostic
166
+ # events stay scoped to that call rather than accumulating
167
+ # across publishes.
168
+ #
169
+ # Single-threaded use only. Concurrent publishes against one
170
+ # Environment must serialise — the LSP `Server` debouncer +
171
+ # synchronized writer already enforces this for the editor
172
+ # path. The Ractor pool path builds a per-worker Environment
173
+ # and does not reach this surface.
174
+ def attach_reporters!(rbs_extended_reporter:, boundary_cross_reporter:)
175
+ @reporters.rbs_extended = rbs_extended_reporter
176
+ @reporters.boundary_cross = boundary_cross_reporter
177
+ nil
178
+ end
179
+
97
180
  class << self
98
181
  def default
99
- @default ||= new(rbs_loader: RbsLoader.default).freeze
182
+ @default ||= new(
183
+ rbs_loader: RbsLoader.default,
184
+ hkt_registry: Builtins::HktBuiltins.registry
185
+ ).freeze
100
186
  end
101
187
 
102
188
  # Builds an Environment that consults the project's local
@@ -127,7 +213,7 @@ module Rigor
127
213
  bundler_bundle_path: nil, bundler_auto_detect: false,
128
214
  bundler_lockfile: nil,
129
215
  rbs_collection_lockfile: nil, rbs_collection_auto_detect: false,
130
- synthetic_method_index: nil)
216
+ synthetic_method_index: nil, project_patched_methods: nil)
131
217
  resolved_paths = signature_paths || default_signature_paths(root)
132
218
  # O4 MVP — append per-gem `sig/` directories discovered
133
219
  # under the target project's bundler install root. Empty
@@ -173,13 +259,24 @@ module Rigor
173
259
  signature_paths: loader_signature_paths,
174
260
  cache_store: cache_store
175
261
  )
262
+ # ADR-20 slice 2c + 2e — seed hkt_registry with the
263
+ # bundled builtins. The Environment's `#hkt_registry`
264
+ # getter then LAZILY merges in the RBS env scan on
265
+ # first call so fast paths that don't consult HKT
266
+ # (e.g. `rigor check --cache-stats --no-stats`) don't
267
+ # pay the eager env-build cost up front. URI
268
+ # collisions let the user-authored overlay win over
269
+ # the bundled builtin (last-write-wins per ADR-20
270
+ # OQ3 tentative).
176
271
  new(
177
272
  rbs_loader: loader,
178
273
  plugin_registry: plugin_registry,
179
274
  dependency_source_index: dependency_source_index,
180
275
  rbs_extended_reporter: rbs_extended_reporter,
181
276
  boundary_cross_reporter: boundary_cross_reporter,
182
- synthetic_method_index: synthetic_method_index
277
+ synthetic_method_index: synthetic_method_index,
278
+ project_patched_methods: project_patched_methods,
279
+ hkt_registry: Builtins::HktBuiltins.registry
183
280
  )
184
281
  end
185
282
  # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
@@ -257,6 +354,19 @@ module Rigor
257
354
  @rbs_loader&.reflection
258
355
  end
259
356
 
357
+ # Returns true when the RBS environment carries the named
358
+ # declaration as a Module (not a Class). Used by the
359
+ # `user_class_fallback_receiver` tier to detect a module-mixin
360
+ # receiver (e.g. `PP::ObjectMixin`) so the dispatcher can route
361
+ # unresolved method calls through the `Nominal[Object]`
362
+ # fallback — every concrete includer of M honours Kernel /
363
+ # Object instance methods through its own ancestor chain.
364
+ def rbs_module?(name)
365
+ return false unless rbs_loader
366
+
367
+ rbs_loader.rbs_module?(name)
368
+ end
369
+
260
370
  # Compares two class/module names using analyzer-owned class data.
261
371
  # Returns `:equal`, `:subclass`, `:superclass`, `:disjoint`, or
262
372
  # `:unknown`. The static registry handles built-ins cheaply; the RBS
@@ -31,14 +31,20 @@ module Rigor
31
31
  #
32
32
  # ## Field set
33
33
  #
34
- # - `target_kind`: `:parameter` (call-site argument) or
35
- # `:self` (receiver). Future slices may extend the set
36
- # (`:local`, `:ivar`, `:result`); the merger is agnostic
37
- # to the concrete kinds and only requires equality.
34
+ # - `target_kind`: `:parameter` (call-site argument), `:self`
35
+ # (receiver), or `:local` (a named local in the surrounding
36
+ # scope). v0.1.8 Pillar 2 Slice 1 added `:local` so plugins
37
+ # recognising bespoke call shapes (`expect(x).to be_a(T)`)
38
+ # can narrow a specific scope-bound local without routing
39
+ # through the parameter-name lookup that requires an
40
+ # authoritative RBS sig on the called method. Future slices
41
+ # may extend further (`:ivar`, `:result`). The merger is
42
+ # agnostic to the concrete kinds and only requires equality.
38
43
  # - `target_name`: a `Symbol`. For `:parameter` it's the
39
44
  # declared parameter name. For `:self` it is the literal
40
45
  # `:self` symbol so the field stays non-nil and the merge
41
- # key is well-defined.
46
+ # key is well-defined. For `:local` it's the local-variable
47
+ # name (e.g. `:x` for `expect(x).to be_a(T)`).
42
48
  # - `type`: a `Rigor::Type::*` (Nominal, Refined,
43
49
  # IntegerRange, Difference, …) the fact narrows the
44
50
  # target toward (when `negative` is false) or away from
@@ -53,7 +59,7 @@ module Rigor
53
59
  # value {Element#target} keys on, so two facts that narrow
54
60
  # the same parameter from different contribution sources
55
61
  # land in the same merge bucket.
56
- FACT_VALID_TARGET_KINDS = %i[parameter self].freeze
62
+ FACT_VALID_TARGET_KINDS = %i[parameter self local].freeze
57
63
 
58
64
  class Fact < Data.define(:target_kind, :target_name, :type, :negative)
59
65
  def initialize(target_kind:, target_name:, type:, negative: false)
@@ -72,10 +78,14 @@ module Rigor
72
78
  end
73
79
 
74
80
  # Composite target identifier the merger keys on. `:self`
75
- # for self-targeted facts; otherwise `[:parameter, name]`
76
- # so two contributions that narrow the same parameter
77
- # (regardless of source family) land in the same merge
78
- # bucket.
81
+ # for self-targeted facts; otherwise `[kind, name]` so two
82
+ # contributions that narrow the same `(kind, name)` pair —
83
+ # regardless of source family land in the same merge
84
+ # bucket. `:local` and `:parameter` facts that name the
85
+ # same symbol stay in separate buckets, which is the
86
+ # correct semantics: a `:local` fact narrows the surrounding
87
+ # scope's named local, a `:parameter` fact narrows the
88
+ # call-site argument matching the parameter declaration.
79
89
  def target
80
90
  target_kind == :self ? :self : [target_kind, target_name]
81
91
  end
@@ -446,13 +446,45 @@ module Rigor
446
446
 
447
447
  def accepts_nominal_from_constant(self_type, constant, mode)
448
448
  ruby_class = resolve_class(self_type.class_name)
449
- if ruby_class.nil?
450
- return Type::AcceptsResult.maybe(
449
+ return constant_is_a_result(ruby_class, constant, self_type, mode) if ruby_class
450
+
451
+ # The host process may not have required the constant's
452
+ # declared self_type (e.g. `BigDecimal` since Ruby 3.4
453
+ # is no longer a default gem). Fall back to inspecting
454
+ # the value's own class ancestor chain — always loadable
455
+ # because the value already exists. Required for
456
+ # OverloadSelector to reject `Integer#+(BigDecimal) ->
457
+ # BigDecimal` overloads contributed by `bigdecimal`'s
458
+ # RBS reopening when the actual arg is a Constant<Integer>.
459
+ ancestor_names = constant.value.class.ancestors.map(&:name)
460
+ if ancestor_names.include?(self_type.class_name)
461
+ Type::AcceptsResult.yes(
451
462
  mode: mode,
452
- reasons: "class #{self_type.class_name} not loadable; cannot prove from Constant"
463
+ reasons: "Constant value class ancestors include #{self_type.class_name}"
464
+ )
465
+ else
466
+ Type::AcceptsResult.no(
467
+ mode: mode,
468
+ reasons: "Constant value class ancestors exclude #{self_type.class_name}"
453
469
  )
454
470
  end
471
+ end
472
+
473
+ def subtype_result_via_ancestors(actual_class, target_name, mode)
474
+ if actual_class.ancestors.map(&:name).include?(target_name)
475
+ Type::AcceptsResult.yes(
476
+ mode: mode,
477
+ reasons: "#{actual_class.name} ancestors include #{target_name}"
478
+ )
479
+ else
480
+ Type::AcceptsResult.no(
481
+ mode: mode,
482
+ reasons: "#{actual_class.name} ancestors exclude #{target_name} (target unloadable)"
483
+ )
484
+ end
485
+ end
455
486
 
487
+ def constant_is_a_result(ruby_class, constant, self_type, mode)
456
488
  if constant.value.is_a?(ruby_class)
457
489
  Type::AcceptsResult.yes(mode: mode, reasons: "Constant value is_a?(#{self_type.class_name})")
458
490
  else
@@ -794,6 +826,19 @@ module Rigor
794
826
 
795
827
  target_class = resolve_class(target_name)
796
828
  actual_class = resolve_class(actual_name)
829
+ # When only `actual` resolves, we can still rule out
830
+ # `actual <:= target` by inspecting `actual`'s ancestor
831
+ # chain. The canonical case: `target=BigDecimal` is not
832
+ # loadable in the host process (no `require` in rigor's
833
+ # own runtime), but `actual=Integer` IS, and Integer's
834
+ # ancestors do not include `BigDecimal`, so the subtype
835
+ # relation MUST be `:no` rather than the conservative
836
+ # `:maybe`. The reverse asymmetry (target resolves,
837
+ # actual doesn't) does not let us conclude anything —
838
+ # the unloaded `actual` could be an unrelated class or
839
+ # a subclass of `target` we can't see, so we still
840
+ # answer `:maybe` there.
841
+ return subtype_result_via_ancestors(actual_class, target_name, mode) if target_class.nil? && actual_class
797
842
  if target_class.nil? || actual_class.nil?
798
843
  return Type::AcceptsResult.maybe(
799
844
  mode: mode,
@@ -61,6 +61,10 @@ module Rigor
61
61
  Prism::RationalNode => :type_of_literal_value,
62
62
  Prism::SymbolNode => :symbol_type_for,
63
63
  Prism::StringNode => :string_type_for,
64
+ Prism::XStringNode => :type_of_xstring,
65
+ Prism::InterpolatedXStringNode => :type_of_xstring,
66
+ Prism::SourceFileNode => :type_of_source_file,
67
+ Prism::SourceLineNode => :type_of_source_line,
64
68
  Prism::TrueNode => :type_of_true,
65
69
  Prism::FalseNode => :type_of_false,
66
70
  Prism::NilNode => :type_of_nil,
@@ -144,6 +148,9 @@ module Rigor
144
148
  Prism::AliasMethodNode => :type_of_nil_value,
145
149
  Prism::AliasGlobalVariableNode => :type_of_nil_value,
146
150
  Prism::UndefNode => :type_of_nil_value,
151
+ Prism::PostExecutionNode => :type_of_nil_value,
152
+ Prism::ShareableConstantNode => :type_of_shareable_constant,
153
+ Prism::ImplicitNode => :type_of_implicit,
147
154
  Prism::ForwardingSuperNode => :type_of_dynamic_top,
148
155
  Prism::BlockArgumentNode => :type_of_non_value,
149
156
  # Parameters and blocks (non-value positions)
@@ -159,6 +166,7 @@ module Rigor
159
166
  Prism::ForwardingParameterNode => :type_of_non_value,
160
167
  Prism::NoKeywordsParameterNode => :type_of_non_value,
161
168
  Prism::ImplicitRestNode => :type_of_non_value,
169
+ Prism::ItParametersNode => :type_of_non_value,
162
170
  Prism::BlockNode => :type_of_dynamic_top,
163
171
  Prism::SplatNode => :type_of_non_value,
164
172
  # Control flow (Slice 3 phase 1): branch types are unioned, jumps
@@ -188,8 +196,8 @@ module Rigor
188
196
  Prism::UntilNode => :type_of_loop,
189
197
  Prism::ForNode => :type_of_dynamic_top,
190
198
  Prism::DefinedNode => :type_of_defined,
191
- Prism::NumberedReferenceReadNode => :type_of_string_or_nil,
192
- Prism::BackReferenceReadNode => :type_of_string_or_nil,
199
+ Prism::NumberedReferenceReadNode => :type_of_numbered_reference,
200
+ Prism::BackReferenceReadNode => :type_of_back_reference,
193
201
  Prism::MatchPredicateNode => :type_of_match_predicate,
194
202
  Prism::MatchRequiredNode => :type_of_match_required,
195
203
  Prism::MatchWriteNode => :type_of_dynamic_top,
@@ -339,6 +347,21 @@ module Rigor
339
347
  )
340
348
  end
341
349
 
350
+ # `$1` / `$2` / ... — numbered match-data globals. When the
351
+ # narrowing tier has bound a tighter type for this number
352
+ # (typically `String` after a `=~`-success guard like `unless
353
+ # /(\d+)/ =~ s; raise; end`), prefer the scope-bound type.
354
+ # Falls back to the default `String | nil`.
355
+ def type_of_numbered_reference(node)
356
+ scope.global(:"$#{node.number}") || type_of_string_or_nil(node)
357
+ end
358
+
359
+ # `$&` / `$'` / `$\`` / `$+` — symbolic back-references. Same
360
+ # narrowing model as numbered references.
361
+ def type_of_back_reference(node)
362
+ scope.global(node.name) || type_of_string_or_nil(node)
363
+ end
364
+
342
365
  # `expr in pattern` — pattern-match predicate. Returns `true`
343
366
  # when the pattern matches, `false` otherwise.
344
367
  def type_of_match_predicate(_node)
@@ -888,6 +911,45 @@ module Rigor
888
911
  Type::Combinator.constant_of(unescaped)
889
912
  end
890
913
 
914
+ # Backtick (`cmd`) and `%x{cmd}` invoke Kernel#` and always return a
915
+ # String. Even when the content is statically known, we widen to
916
+ # Nominal[String] because the runtime value depends on the
917
+ # subprocess output, not the source text.
918
+ def type_of_xstring(_node)
919
+ Type::Combinator.nominal_of(String)
920
+ end
921
+
922
+ # __FILE__ is the source file path. Always non-empty when
923
+ # parsing a real file (the path resolver gives the buffer
924
+ # name, which is at minimum `"(stdin)"` / `"-e"` / a real
925
+ # path — never the empty String). Widened to
926
+ # `non-empty-string` instead of `Nominal[String]` so
927
+ # downstream String-emptiness checks know the value cannot
928
+ # be `""`.
929
+ def type_of_source_file(_node)
930
+ Type::Combinator.non_empty_string
931
+ end
932
+
933
+ # __LINE__ is the line of the source literal. Ruby line
934
+ # numbers are 1-indexed, so `__LINE__` is always at least
935
+ # 1 — `positive-int` (Integer in `[1, +Inf)`) is the
936
+ # canonical refinement.
937
+ def type_of_source_line(_node)
938
+ Type::Combinator.positive_int
939
+ end
940
+
941
+ # `# shareable_constant_value:` magic comment wraps the next
942
+ # constant write. Type is the wrapped write's value.
943
+ def type_of_shareable_constant(node)
944
+ type_of(node.write)
945
+ end
946
+
947
+ # `{ x: }` shorthand hash. The implicit value is the call to
948
+ # `x` (or a local read of `x`). Delegate.
949
+ def type_of_implicit(node)
950
+ type_of(node.value)
951
+ end
952
+
891
953
  def local_read(node)
892
954
  scope.local(node.name) || dynamic_top
893
955
  end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-20 Slice 2a — node types for the parsed body of a
6
+ # type-function `Definition`. Each node represents one
7
+ # piece of a Rigor-side type expression that the reducer
8
+ # ({HktReducer}) walks against a concrete argument list.
9
+ #
10
+ # Slice 2a ships a programmatic constructor surface only:
11
+ # plugin and Rigor-bundled overlay authors build a body
12
+ # tree by hand using these node types. The string-grammar
13
+ # parser that reads `Definition#body` (the raw String slot
14
+ # already populated by Slice 1's `HktDirectives.parse_define`)
15
+ # into a tree is Slice 2b's deliverable; until it ships, the
16
+ # `body` String stays opaque and `body_tree` is the
17
+ # evaluable form.
18
+ #
19
+ # The five node types cover the JSON.parse and dry-monads
20
+ # use cases ADR-20 § Implementation slicing names as
21
+ # near-term adopters:
22
+ #
23
+ # - {TypeLeaf} — wraps a fully-built `Rigor::Type`
24
+ # (use for atoms like `nil`, `Constant<true>`,
25
+ # `Nominal[Integer]`).
26
+ # - {Param} — reference to a formal parameter
27
+ # declared in the enclosing `Definition#params` list
28
+ # (e.g. `K` in `json::value[K]`). The reducer
29
+ # substitutes from the application's `args`.
30
+ # - {AppRef} — abstract HKT application; the reducer
31
+ # resolves it via the registry, or returns the `App`
32
+ # carrier as-is when the reference is self-recursive
33
+ # (lazy "tying-the-knot" handling that lets recursive
34
+ # sums like `json::value` reduce without infinite
35
+ # expansion).
36
+ # - {Union} — N-ary union of arms.
37
+ # - {NominalApp} — parameterised nominal class
38
+ # (`Array[X]`, `Hash[K, V]`) whose type args are
39
+ # themselves body nodes.
40
+ #
41
+ # Every node is a frozen `Data.define` value; structural
42
+ # equality is by-field.
43
+ module HktBody
44
+ # Wraps a pre-built `Rigor::Type` value. Use for atoms
45
+ # that need no substitution (e.g. `Nominal[Integer]`,
46
+ # `Constant<nil>`).
47
+ TypeLeaf = Data.define(:type) do
48
+ def initialize(type:)
49
+ raise ArgumentError, "type must not be nil" if type.nil?
50
+
51
+ super
52
+ end
53
+ end
54
+
55
+ # Reference to a formal parameter the enclosing
56
+ # `Definition#params` declared. The reducer substitutes
57
+ # this node with the matching positional arg from the
58
+ # `App` being reduced; an unknown name raises during
59
+ # reduction (the parser, when it ships, MUST reject
60
+ # unknown names earlier).
61
+ Param = Data.define(:name) do
62
+ def initialize(name:)
63
+ raise ArgumentError, "name must be a Symbol, got #{name.class}" unless name.is_a?(Symbol)
64
+
65
+ super
66
+ end
67
+ end
68
+
69
+ # Abstract HKT application — the reducer's primary
70
+ # recursion point. `uri` is a namespaced Symbol
71
+ # matching some `Registration` in the registry; `args`
72
+ # is an Array of body nodes (each gets substituted /
73
+ # resolved before being used).
74
+ AppRef = Data.define(:uri, :args) do
75
+ def initialize(uri:, args:)
76
+ raise ArgumentError, "uri must be a Symbol, got #{uri.class}" unless uri.is_a?(Symbol)
77
+ raise ArgumentError, "uri must be namespaced as `:a::b`, got #{uri.inspect}" unless uri.to_s.include?("::")
78
+ raise ArgumentError, "args must be an Array, got #{args.class}" unless args.is_a?(Array)
79
+ raise ArgumentError, "args must be non-empty" if args.empty?
80
+
81
+ super(uri: uri, args: args.dup.freeze)
82
+ end
83
+ end
84
+
85
+ # N-ary union. The reducer builds the result through
86
+ # `Type::Combinator.union(*reduced_arms)` so
87
+ # normalization (flattening, dedup, Bot drop) applies.
88
+ Union = Data.define(:arms) do
89
+ def initialize(arms:)
90
+ raise ArgumentError, "arms must be an Array, got #{arms.class}" unless arms.is_a?(Array)
91
+ raise ArgumentError, "arms must be non-empty" if arms.empty?
92
+
93
+ super(arms: arms.dup.freeze)
94
+ end
95
+ end
96
+
97
+ # Parameterised nominal class. `class_name` is the
98
+ # Ruby class name (`"Array"`, `"Hash"`); `args` is an
99
+ # Array of body nodes for the type arguments. The
100
+ # reducer builds the result through
101
+ # `Type::Combinator.nominal_of(class_name, type_args:
102
+ # reduced_args)`.
103
+ NominalApp = Data.define(:class_name, :args) do
104
+ def initialize(class_name:, args:)
105
+ unless class_name.is_a?(String) && !class_name.empty?
106
+ raise ArgumentError, "class_name must be a non-empty String, got #{class_name.inspect}"
107
+ end
108
+ raise ArgumentError, "args must be an Array, got #{args.class}" unless args.is_a?(Array)
109
+ raise ArgumentError, "args must be non-empty (use TypeLeaf with Nominal for raw class refs)" if args.empty?
110
+
111
+ super(class_name: class_name, args: args.dup.freeze)
112
+ end
113
+ end
114
+
115
+ # ADR-20 § D3 — conditional type form. `test` is a
116
+ # {TestSubtype} / {TestEquality} / {TestMembership}
117
+ # value object the reducer evaluates against the
118
+ # current bindings; `then_branch` / `else_branch` are
119
+ # body nodes. The reducer's trinary handling:
120
+ #
121
+ # - test = `yes` → return the reduced `then_branch`.
122
+ # - test = `no` → return the reduced `else_branch`.
123
+ # - test = `maybe` → widen to the union of both
124
+ # reduced branches (per ADR-20 WD7 / robustness
125
+ # principle).
126
+ Conditional = Data.define(:test, :then_branch, :else_branch) do
127
+ def initialize(test:, then_branch:, else_branch:)
128
+ raise ArgumentError, "test must not be nil" if test.nil?
129
+ raise ArgumentError, "then_branch must not be nil" if then_branch.nil?
130
+ raise ArgumentError, "else_branch must not be nil" if else_branch.nil?
131
+
132
+ super
133
+ end
134
+ end
135
+
136
+ # `left <: right` — subtype check. `left` is typically
137
+ # a {Param} reference; `right` is any body expression.
138
+ TestSubtype = Data.define(:left, :right) do
139
+ def initialize(left:, right:)
140
+ raise ArgumentError, "left/right must not be nil" if left.nil? || right.nil?
141
+
142
+ super
143
+ end
144
+ end
145
+
146
+ # `left == right` — structural equality. Useful for
147
+ # discriminating against literal constants
148
+ # (`E == :symbol`).
149
+ TestEquality = Data.define(:left, :right) do
150
+ def initialize(left:, right:)
151
+ raise ArgumentError, "left/right must not be nil" if left.nil? || right.nil?
152
+
153
+ super
154
+ end
155
+ end
156
+
157
+ # `left in [opt1, opt2, ...]` — set membership. Each
158
+ # `option` is a body node; the test passes iff `left`
159
+ # is structurally equal to any of the options.
160
+ TestMembership = Data.define(:left, :options) do
161
+ def initialize(left:, options:)
162
+ raise ArgumentError, "left must not be nil" if left.nil?
163
+ raise ArgumentError, "options must be an Array, got #{options.class}" unless options.is_a?(Array)
164
+ raise ArgumentError, "options must be non-empty" if options.empty?
165
+
166
+ super(left: left, options: options.dup.freeze)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end