rigortype 0.1.16 → 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 (180) 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/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -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
 
@@ -69,7 +69,11 @@ module Rigor
69
69
  # the call's block. `nil` means "no block at the call site"
70
70
  # and disqualifies every rule here.
71
71
  # @return [Rigor::Type, nil]
72
- def try_fold(receiver:, method_name:, args:, block_type:)
72
+ def try_dispatch(context)
73
+ receiver = context.receiver
74
+ method_name = context.method_name
75
+ args = context.args
76
+ block_type = context.block_type
73
77
  return nil if receiver.nil? || block_type.nil?
74
78
 
75
79
  truthiness = constant_truthiness(block_type)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ module MethodDispatcher
6
+ # Immutable value object carrying everything a dispatch tier needs
7
+ # to fold a single call site. Built once per `MethodDispatcher.dispatch`
8
+ # and threaded — unchanged — through every tier, replacing the
9
+ # `(receiver:, method_name:, args:, …)` keyword quartet that each
10
+ # tier used to redeclare (several with `# rubocop:disable
11
+ # Metrics/ParameterLists`).
12
+ #
13
+ # Every tier satisfies one interface — `try_dispatch(CallContext) ->
14
+ # Rigor::Type?` (the `_DispatchTier` RBS interface). Pure tiers
15
+ # (the singleton folders, ConstantFolding, ShapeDispatch, …) read
16
+ # only `receiver` / `method_name` / `args` and ignore the rest;
17
+ # the RBS / backward / block-param tiers consult the wider context.
18
+ #
19
+ # Derived call sites (the user-class fallback's `public_only` RBS
20
+ # retry, the Tier-B origin-module redispatch) use `Data#with` to
21
+ # copy the base context with the few fields that differ rather than
22
+ # rebuilding the quartet by hand.
23
+ #
24
+ # Fields:
25
+ # - `receiver` — the receiver `Rigor::Type` (nil short-circuits)
26
+ # - `method_name` — the called selector (Symbol)
27
+ # - `args` — positional argument `Rigor::Type`s
28
+ # - `block_type` — the block's `Rigor::Type`, or nil
29
+ # - `environment` — the analysis `Environment` (RBS loader, …)
30
+ # - `call_node` — the Prism call node, when available
31
+ # - `scope` — the enclosing `Scope` (discovered methods, …)
32
+ # - `self_type_override` — receiver to attribute private dispatch to
33
+ # - `public_only` — suppress private-method resolution (explicit, non-self receiver)
34
+ CallContext = Data.define(
35
+ :receiver, :method_name, :args,
36
+ :block_type, :environment, :call_node, :scope,
37
+ :self_type_override, :public_only
38
+ ) do
39
+ # Keyword factory with nil/false defaults for the optional
40
+ # context fields, so a caller that only has the call quartet
41
+ # (the common precise-tier path) need not spell out the rest.
42
+ #
43
+ # This is the single place the call-context field list is
44
+ # enumerated — the whole point of the value object is to absorb
45
+ # the wide keyword list the tiers used to each redeclare. The
46
+ # ParameterLists disable here retires the per-tier disables (the
47
+ # `RbsDispatch` quartet-plus signatures) rather than adding to
48
+ # them.
49
+ def self.build(receiver:, method_name:, args:, # rubocop:disable Metrics/ParameterLists
50
+ block_type: nil, environment: nil, call_node: nil,
51
+ scope: nil, self_type_override: nil, public_only: false)
52
+ # Positional `new` (field-definition order) avoids the keyword
53
+ # hash a `new(receiver:, …)` call allocates — this runs once per
54
+ # dispatch and was a top allocation site. Order MUST track the
55
+ # `Data.define` field list above.
56
+ new(
57
+ receiver, method_name, args,
58
+ block_type, environment, call_node, scope,
59
+ self_type_override, public_only
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "cgi/util"
4
4
  require_relative "../../type"
5
+ require_relative "singleton_folding"
5
6
 
6
7
  module Rigor
7
8
  module Inference
@@ -63,22 +64,21 @@ module Rigor
63
64
  module_function
64
65
 
65
66
  # @return [Rigor::Type, nil] folded result, or nil to defer.
66
- def try_dispatch(receiver:, method_name:, args:)
67
- return nil unless dispatch_target?(receiver)
67
+ def try_dispatch(context)
68
+ receiver = context.receiver
69
+ method_name = context.method_name
70
+ args = context.args
71
+ return nil unless SingletonFolding.receiver?(receiver, "CGI")
68
72
  return nil unless CGI_ALL_ESCAPE_METHODS.include?(method_name)
69
73
 
70
74
  fold_cgi_call(method_name, args)
71
75
  end
72
76
 
73
- def dispatch_target?(receiver)
74
- receiver.is_a?(Type::Singleton) && receiver.class_name == "CGI"
75
- end
76
-
77
77
  def fold_cgi_call(method_name, args)
78
78
  return nil if args.empty?
79
- return nil unless args.first.is_a?(Type::Constant) && args.first.value.is_a?(String)
80
79
 
81
- str = args.first.value
80
+ str = SingletonFolding.constant_string(args.first)
81
+ return nil if str.nil?
82
82
 
83
83
  if CGI_ELEMENT_ESCAPE_METHODS.include?(method_name)
84
84
  fold_cgi_element(method_name, str, args.drop(1))
@@ -94,9 +94,10 @@ module Rigor
94
94
  # must be `Constant[String]` element names.
95
95
  def fold_cgi_element(method_name, str, element_args)
96
96
  elements = element_args.map do |arg|
97
- return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
97
+ value = SingletonFolding.constant_string(arg)
98
+ return nil if value.nil?
98
99
 
99
- arg.value
100
+ value
100
101
  end
101
102
 
102
103
  Type::Combinator.constant_of(CGI.public_send(method_name, str, *elements))
@@ -57,7 +57,10 @@ module Rigor
57
57
  :+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>,
58
58
  :start_with?, :end_with?, :include?,
59
59
  :delete_prefix, :delete_suffix,
60
- :match?, :index, :rindex, :center, :ljust, :rjust
60
+ :match?, :index, :rindex, :center, :ljust, :rjust,
61
+ # 1-arg pure transforms/queries whose output never exceeds the
62
+ # input: `delete`/`squeeze` shrink the string, `count` → Integer.
63
+ :delete, :count, :squeeze
61
64
  ].freeze
62
65
  SYMBOL_BINARY = Set[:==, :!=, :<=>, :<, :<=, :>, :>=].freeze
63
66
  BOOL_BINARY = Set[:&, :|, :^, :==, :!=, :===].freeze
@@ -98,7 +101,7 @@ module Rigor
98
101
  STRING_UNARY = Set[
99
102
  :upcase, :downcase, :capitalize, :swapcase,
100
103
  :reverse, :length, :size, :bytesize,
101
- :empty?, :strip, :lstrip, :rstrip, :chomp, :chop,
104
+ :empty?, :strip, :lstrip, :rstrip, :chomp, :chop, :squeeze,
102
105
  :to_s, :to_str, :to_sym, :intern,
103
106
  :to_i, :to_f, :ord, :chr, :hex, :oct, :succ, :next,
104
107
  :inspect, :hash
@@ -132,7 +135,10 @@ module Rigor
132
135
  UNION_FOLD_OUTPUT_LIMIT = 8
133
136
 
134
137
  # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
135
- def try_fold(receiver:, method_name:, args:)
138
+ def try_dispatch(context)
139
+ receiver = context.receiver
140
+ method_name = context.method_name
141
+ args = context.args
136
142
  # v0.0.7 — `String#%` against a `Tuple` / `HashShape`
137
143
  # argument runs Ruby's format-string engine when both
138
144
  # sides are statically constant. The standard
@@ -1150,7 +1156,7 @@ module Rigor
1150
1156
  #
1151
1157
  # Resolution order:
1152
1158
  #
1153
- # 1. Primary class catalog (e.g. NumericCatalog for an
1159
+ # 1. Primary class catalog (e.g. NUMERIC_CATALOG for an
1154
1160
  # Integer receiver). When the catalog has an entry —
1155
1161
  # even one classified `:dispatch` — that answer wins.
1156
1162
  # The class's direct `rb_define_method` registration is
@@ -1210,8 +1216,8 @@ module Rigor
1210
1216
  # arm first and the catalog would consult the Date entry
1211
1217
  # in `DATE_CATALOG` for the wrong class.
1212
1218
  CATALOG_BY_CLASS = Ractor.make_shareable([
1213
- [Integer, [Builtins::NumericCatalog, "Integer"]],
1214
- [Float, [Builtins::NumericCatalog, "Float"]],
1219
+ [Integer, [Builtins::NUMERIC_CATALOG, "Integer"]],
1220
+ [Float, [Builtins::NUMERIC_CATALOG, "Float"]],
1215
1221
  [String, [Builtins::STRING_CATALOG, "String"]],
1216
1222
  [Symbol, [Builtins::STRING_CATALOG, "Symbol"]],
1217
1223
  [Array, [Builtins::ARRAY_CATALOG, "Array"]],
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+ require_relative "singleton_folding"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # ADR-48 — `Data.define` value folding. Three responsibilities, all
10
+ # gated on a fully-decidable shape and degrading to today's behaviour
11
+ # (no carrier / the `Data` nominal) the moment a premise is uncertain,
12
+ # so the tier is precision-additive and adds no false-positive
13
+ # surface:
14
+ #
15
+ # 1. `Data.define(:x, :y)` on a `Singleton[Data]` receiver with
16
+ # literal-Symbol args and NO block -> `DataClass{members: [...]}`.
17
+ # A block (`Data.define(:x) do ... end`) defers (slice 4 hardens
18
+ # the block-body case); non-literal members (`Data.define(*names)`)
19
+ # defer.
20
+ # 2. `.new` / `.[]` on a `DataClass` receiver -> a `DataInstance`
21
+ # whose member map is built from the call's positional or keyword
22
+ # arguments. An arity / key mismatch degrades to the `Data` (or the
23
+ # tagged class) nominal rather than a wrong member map.
24
+ # 3. member reads + `[]` / `to_h` / `deconstruct` / `deconstruct_keys`
25
+ # / `members` / `with` on a `DataInstance` receiver -> the precise
26
+ # projected type. Unhandled methods return nil so the pipeline
27
+ # projects the instance to its nominal through RbsDispatch.
28
+ #
29
+ # See docs/adr/48-data-struct-value-folding.md.
30
+ module DataFolding
31
+ module_function
32
+
33
+ # @return [Rigor::Type, nil] the folded result, or nil to defer.
34
+ def try_dispatch(context)
35
+ receiver = context.receiver
36
+
37
+ return fold_define(context) if SingletonFolding.receiver?(receiver, "Data")
38
+
39
+ case receiver
40
+ when Type::DataClass
41
+ materialize_instance(receiver.members, receiver.class_name, context)
42
+ when Type::DataInstance
43
+ fold_instance(receiver, context)
44
+ when Type::Singleton
45
+ fold_named_new(receiver, context)
46
+ end
47
+ end
48
+
49
+ # A `Data.define` value object assigned to a constant (or a
50
+ # `class Point < Data.define(...)` subclass) is canonicalised by the
51
+ # engine to `Singleton[Point]`, not a `DataClass` — so its member
52
+ # layout is read from the project side-table the scope indexer built
53
+ # (`Scope#data_member_layout`) rather than from the receiver carrier.
54
+ def fold_named_new(singleton, context)
55
+ scope = context.scope
56
+ return nil if scope.nil?
57
+
58
+ members = scope.data_member_layout(singleton.class_name)
59
+ return nil if members.nil?
60
+
61
+ materialize_instance(members, singleton.class_name, context)
62
+ end
63
+
64
+ # --- 1. Data.define(:x, :y) -------------------------------------
65
+
66
+ def fold_define(context)
67
+ return nil unless context.method_name == :define
68
+ # Block-form (`Data.define(:x) do ... end`) defers — slice 4.
69
+ return nil unless context.block_type.nil?
70
+
71
+ members = member_names_from_args(context.args)
72
+ return nil if members.nil? || members.empty?
73
+
74
+ Type::Combinator.data_class_of(members: members)
75
+ end
76
+
77
+ # The ordered Symbol member names, or nil when any argument is not
78
+ # a literal `Constant[Symbol]` (a splat or dynamic name).
79
+ def member_names_from_args(args)
80
+ names = args.map do |arg|
81
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Symbol)
82
+
83
+ arg.value
84
+ end
85
+ return nil unless names.uniq.size == names.size
86
+
87
+ names
88
+ end
89
+
90
+ # --- 2. Point.new(...) / Point[...] -----------------------------
91
+
92
+ def materialize_instance(members, class_name, context)
93
+ method_name = context.method_name
94
+ return nil unless %i[new []].include?(method_name)
95
+
96
+ map = member_map_for_new(members, context)
97
+ return degraded_instance(class_name) if map.nil?
98
+
99
+ Type::Combinator.data_instance_of(members: map, class_name: class_name)
100
+ end
101
+
102
+ # Builds the member -> type map from the call's arguments, honouring
103
+ # the keyword vs positional distinction read off the call node. nil
104
+ # when the arguments cannot soundly populate every member.
105
+ def member_map_for_new(members, context)
106
+ if keyword_new?(context)
107
+ keyword_member_map(members, context.args)
108
+ else
109
+ positional_member_map(members, context.args)
110
+ end
111
+ end
112
+
113
+ # `Point.new(x: 1, y: 2)` arrives as a single trailing `HashShape`
114
+ # arg whose call node is a `KeywordHashNode`. Distinguishing it from
115
+ # a positional hash (`Point.new({x: 1})`, a `HashNode`) needs the
116
+ # call node, since both type to a `HashShape`.
117
+ def keyword_new?(context)
118
+ node = context.call_node
119
+ return false if node.nil?
120
+
121
+ arguments = node.arguments&.arguments
122
+ return false if arguments.nil? || arguments.empty?
123
+
124
+ arguments.last.is_a?(Prism::KeywordHashNode)
125
+ end
126
+
127
+ def keyword_member_map(members, args)
128
+ return nil unless args.size == 1
129
+
130
+ shape = args.first
131
+ return nil unless shape.is_a?(Type::HashShape) && shape.closed?
132
+ return nil unless shape.optional_keys.empty?
133
+ return nil unless shape.pairs.keys.sort == members.sort
134
+
135
+ members.to_h { |name| [name, shape.pairs.fetch(name)] }
136
+ end
137
+
138
+ def positional_member_map(members, args)
139
+ return nil unless args.size == members.size
140
+
141
+ members.zip(args).to_h
142
+ end
143
+
144
+ # A `.new` whose arguments do not fold to a precise map still has a
145
+ # sound, more-precise-than-Dynamic answer: an instance of the
146
+ # tagged class (or the `Data` supertype).
147
+ def degraded_instance(class_name)
148
+ Type::Combinator.nominal_of(class_name || "Data")
149
+ end
150
+
151
+ # --- 3. inst.x / inst[...] / inst.to_h / ... --------------------
152
+
153
+ def fold_instance(instance, context)
154
+ method_name = context.method_name
155
+ args = context.args
156
+ members = instance.members
157
+
158
+ if members.key?(method_name) && args.empty? && !reader_overridden?(instance, method_name, context.scope)
159
+ return members.fetch(method_name)
160
+ end
161
+
162
+ case method_name
163
+ when :[] then instance_index(instance, args)
164
+ when :to_h, :to_hash then instance_to_h(instance)
165
+ when :deconstruct then instance_deconstruct(instance)
166
+ when :deconstruct_keys then instance_deconstruct_keys(instance, args)
167
+ when :members then instance_members(instance)
168
+ when :with then instance_with(instance, args)
169
+ end
170
+ end
171
+
172
+ # A `Data.define` class body (the `class Point < Data.define(:x);
173
+ # def x; …; end; end` subclass body, or a `Const = Data.define(:x) do
174
+ # def x; …; end; end` block) can redefine a member's synthesised
175
+ # reader. When it does, `inst.x` runs that `def`, NOT the member, so
176
+ # folding the read to the member type would be unsound (a downstream
177
+ # FP). Both named forms register the override as a real `def` node
178
+ # under the class name, so an entry in the project def-node table is
179
+ # the discriminator (the synthesised reader has no def node). The
180
+ # value accessors `[]` / `to_h` / `deconstruct` bypass the reader and
181
+ # stay foldable, so this gate is on the bare member read only.
182
+ def reader_overridden?(instance, method_name, scope)
183
+ class_name = instance.class_name
184
+ return false if class_name.nil? || scope.nil?
185
+
186
+ !scope.user_def_for(class_name, method_name).nil?
187
+ end
188
+
189
+ def instance_index(instance, args)
190
+ return nil unless args.size == 1
191
+
192
+ arg = args.first
193
+ return nil unless arg.is_a?(Type::Constant)
194
+
195
+ key = arg.value
196
+ case key
197
+ when Symbol
198
+ instance.members[key]
199
+ when Integer
200
+ values = instance.members.values
201
+ idx = key.negative? ? key + values.size : key
202
+ values[idx] if idx && idx >= 0 && idx < values.size
203
+ end
204
+ end
205
+
206
+ def instance_to_h(instance)
207
+ Type::Combinator.hash_shape_of(instance.members.dup)
208
+ end
209
+
210
+ def instance_deconstruct(instance)
211
+ Type::Combinator.tuple_of(*instance.members.values)
212
+ end
213
+
214
+ # `deconstruct_keys(nil)` / `deconstruct_keys([:x])` both yield a
215
+ # subset of the member map; the conservative, always-correct answer
216
+ # is the full closed member shape.
217
+ def instance_deconstruct_keys(instance, args)
218
+ return nil unless args.size <= 1
219
+
220
+ Type::Combinator.hash_shape_of(instance.members.dup)
221
+ end
222
+
223
+ def instance_members(instance)
224
+ Type::Combinator.tuple_of(*instance.member_names.map { |name| Type::Combinator.constant_of(name) })
225
+ end
226
+
227
+ # `Data#with(x: 9)` returns a new frozen copy with the named members
228
+ # overridden. Only a closed keyword `HashShape` whose keys are a
229
+ # subset of the members folds; anything else defers (RBS resolves
230
+ # `with` to `self`, returning the unchanged instance type).
231
+ def instance_with(instance, args)
232
+ return instance if args.empty?
233
+ return nil unless args.size == 1
234
+
235
+ shape = args.first
236
+ return nil unless shape.is_a?(Type::HashShape) && shape.closed?
237
+ return nil unless shape.optional_keys.empty?
238
+ return nil unless shape.pairs.keys.all? { |key| instance.members.key?(key) }
239
+
240
+ merged = instance.members.merge(shape.pairs)
241
+ Type::Combinator.data_instance_of(members: merged, class_name: instance.class_name)
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../type"
4
+ require_relative "singleton_folding"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -90,7 +91,10 @@ module Rigor
90
91
 
91
92
  # @return [Rigor::Type, nil] folded result, or nil to defer
92
93
  # to the next dispatcher tier.
93
- def try_dispatch(receiver:, method_name:, args:)
94
+ def try_dispatch(context)
95
+ receiver = context.receiver
96
+ method_name = context.method_name
97
+ args = context.args
94
98
  return nil unless dispatch_target?(receiver)
95
99
  return nil unless FILE_PURE_CLASS_METHODS.include?(method_name)
96
100
  return nil if platform_specific_skip?(method_name)
@@ -107,7 +111,7 @@ module Rigor
107
111
  end
108
112
 
109
113
  def dispatch_target?(receiver)
110
- receiver.is_a?(Type::Singleton) && receiver.class_name == "File"
114
+ SingletonFolding.receiver?(receiver, "File")
111
115
  end
112
116
 
113
117
  def constant_string_args(args)