rigortype 0.1.4 → 0.1.6

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. metadata +92 -1
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../environment"
6
+ require_relative "../scope"
7
+ require_relative "../cache/store"
8
+ require_relative "../plugin"
9
+ require_relative "../rbs_extended/reporter"
10
+ require_relative "../reflection"
11
+ require_relative "../type/combinator"
12
+ require_relative "../inference/coverage_scanner"
13
+ require_relative "../inference/scope_indexer"
14
+ require_relative "../inference/method_dispatcher/file_folding"
15
+ require_relative "check_rules"
16
+ require_relative "dependency_source_inference"
17
+ require_relative "diagnostic"
18
+
19
+ module Rigor
20
+ module Analysis
21
+ # ADR-15 Phase 4a — per-worker analysis substrate.
22
+ # [ADR-15](../../../docs/adr/15-ractor-concurrency.md)
23
+ # § Phase 4 carves the eventual Ractor-isolated worker pool
24
+ # into three sub-phases; this is the substrate that 4b will
25
+ # wrap in `Ractor.new` and 4c will gate behind
26
+ # `RIGOR_RACTOR_WORKERS`. NO Ractor in the loop yet — 4a
27
+ # exists so the per-worker ownership boundary is testable in
28
+ # the absence of any Ractor coordination.
29
+ #
30
+ # The constructor takes only `Ractor.shareable?` inputs:
31
+ #
32
+ # - `configuration` — Phase 2a ({Rigor::Configuration} is
33
+ # `Ractor.shareable?`).
34
+ # - `cache_store` — frozen-shareable handle is NOT a precondition;
35
+ # future 4b workers build their OWN Store at the shared
36
+ # `cache_root` directory. 4a accepts an already-built Store
37
+ # for the no-Ractor coordinator path.
38
+ # - `plugin_blueprints` — Phase 3a
39
+ # (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
40
+ # - `explain` — Boolean.
41
+ #
42
+ # Internally the session OWNS (and never shares):
43
+ #
44
+ # - {Rigor::Plugin::Services} bound to the per-worker Store.
45
+ # - {Rigor::Plugin::Registry} materialised from the blueprints
46
+ # via {Rigor::Plugin::Registry.materialize}; each plugin
47
+ # instance, with its mutable per-run accumulators
48
+ # (`@reachable_absurd_nodes`, `*_index`, …) lives entirely
49
+ # inside this session.
50
+ # - {Rigor::RbsExtended::Reporter} +
51
+ # {Rigor::Analysis::DependencySourceInference::BoundaryCrossReporter}
52
+ # (Mutex-bearing; intentionally per-worker — the runner
53
+ # merges entries post-pool via {#drain_reporters}).
54
+ # - {Rigor::Environment} threaded with the per-worker reporters
55
+ # so reporter writes from inference / dispatcher accumulate
56
+ # into the worker's own state.
57
+ #
58
+ # Plugin `prepare` runs ONCE at construction time so each
59
+ # worker is "warm" by the time `#analyze` is first called. Any
60
+ # raise from `prepare` is captured into {#prepare_diagnostics}
61
+ # so the runner can surface them alongside the per-file
62
+ # diagnostic stream.
63
+ #
64
+ # Equivalence contract (proven by spec): given identical
65
+ # `(configuration, cache_store, plugin_blueprints)`, the
66
+ # multiset of diagnostics from
67
+ # `paths.flat_map { |p| session.analyze(p) }` plus
68
+ # {#prepare_diagnostics} plus reporter drains MUST equal the
69
+ # corresponding subset of {Rigor::Analysis::Runner#run}'s
70
+ # output (modulo severity-profile re-stamping, which the
71
+ # session leaves to the caller because it is a per-run
72
+ # aggregate concern).
73
+ class WorkerSession
74
+ attr_reader :configuration, :cache_store, :services, :plugin_registry,
75
+ :dependency_source_index, :environment,
76
+ :rbs_extended_reporter, :boundary_cross_reporter,
77
+ :prepare_diagnostics
78
+
79
+ # @param configuration [Rigor::Configuration]
80
+ # @param cache_store [Rigor::Cache::Store, nil] persistent
81
+ # cache the session exposes to plugin-side producers and
82
+ # the RBS loader. Pass `nil` to disable caching.
83
+ # @param plugin_blueprints [Array<Rigor::Plugin::Blueprint>]
84
+ # replay descriptors. Empty array yields a session with
85
+ # no plugin contributions.
86
+ # @param explain [Boolean] when true, `#analyze` additionally
87
+ # emits one `:info` `fallback` diagnostic per
88
+ # directly-unrecognised node, mirroring
89
+ # {Rigor::Analysis::Runner#explain_diagnostics}.
90
+ def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
91
+ plugin_blueprints: [], explain: false, buffer: nil)
92
+ @configuration = configuration
93
+ @cache_store = cache_store
94
+ @explain = explain
95
+ @buffer = buffer
96
+
97
+ # NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
98
+ # is process-global state. Writing it from a non-main
99
+ # Ractor would raise `Ractor::IsolationError`, so the
100
+ # session does NOT touch it — the CALLER (typically
101
+ # {Rigor::Analysis::Runner#run}) is responsible for
102
+ # setting it on the main Ractor before spawning the
103
+ # pool. The substrate stays Ractor-safe by construction.
104
+ @rbs_extended_reporter = RbsExtended::Reporter.new
105
+ @boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
106
+ @dependency_source_index = DependencySourceInference::Builder.build(configuration.dependencies)
107
+
108
+ @services = Plugin::Services.new(
109
+ reflection: Reflection,
110
+ type: Type::Combinator,
111
+ configuration: configuration,
112
+ cache_store: cache_store,
113
+ trust_policy: build_trust_policy
114
+ )
115
+ @plugin_registry = Plugin::Registry.materialize(
116
+ blueprints: plugin_blueprints, services: @services
117
+ )
118
+ @environment = Environment.for_project(
119
+ libraries: configuration.libraries,
120
+ signature_paths: configuration.signature_paths,
121
+ cache_store: cache_store,
122
+ plugin_registry: @plugin_registry,
123
+ dependency_source_index: @dependency_source_index,
124
+ rbs_extended_reporter: @rbs_extended_reporter,
125
+ boundary_cross_reporter: @boundary_cross_reporter,
126
+ bundler_bundle_path: configuration.bundler_bundle_path,
127
+ bundler_auto_detect: configuration.bundler_auto_detect,
128
+ bundler_lockfile: configuration.bundler_lockfile,
129
+ rbs_collection_lockfile: configuration.rbs_collection_lockfile,
130
+ rbs_collection_auto_detect: configuration.rbs_collection_auto_detect
131
+ )
132
+ @prepare_diagnostics = run_plugin_prepare.freeze
133
+ end
134
+
135
+ # Equivalent of {Rigor::Analysis::Runner#analyze_file} +
136
+ # `plugin_emitted_diagnostics` + `explain_diagnostics`.
137
+ # Returns a flat `Array<Diagnostic>` for the file. Severity
138
+ # profile re-stamping is intentionally NOT applied — that
139
+ # is a per-run aggregate concern handled by the caller.
140
+ def analyze(path)
141
+ parse_result = parse_source(path)
142
+ return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
143
+
144
+ scope = Scope.empty(environment: @environment, source_path: path)
145
+ index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
146
+ diagnostics = CheckRules.diagnose(
147
+ path: path,
148
+ root: parse_result.value,
149
+ scope_index: index,
150
+ comments: parse_result.comments,
151
+ disabled_rules: @configuration.disabled_rules
152
+ )
153
+ diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
154
+ diagnostics + explain_diagnostics(path, parse_result.value, scope)
155
+ rescue Errno::ENOENT => e
156
+ [analyzer_error(path, e.message)]
157
+ rescue StandardError => e
158
+ [analyzer_error(path, "internal analyzer error: #{e.class}: #{e.message}")]
159
+ end
160
+
161
+ # Read-once snapshot of the per-worker reporters so the
162
+ # caller (or the eventual Phase 4b pool aggregator) can
163
+ # merge into a single coordinator-side reporter. Both
164
+ # reporters dedupe at write time, so a post-hoc concat +
165
+ # de-dup at the entry-key level is sound.
166
+ def drain_reporters
167
+ {
168
+ rbs_extended: {
169
+ unresolved_payloads: @rbs_extended_reporter.unresolved_payloads,
170
+ lossy_projections: @rbs_extended_reporter.lossy_projections
171
+ },
172
+ boundary_cross: @boundary_cross_reporter.entries
173
+ }
174
+ end
175
+
176
+ private
177
+
178
+ # See {Runner#parse_source}. Same contract: if `@buffer`
179
+ # binds `path` to a physical file, read the physical bytes
180
+ # but stamp the parse buffer's `filepath:` as the LOGICAL
181
+ # path so downstream diagnostics carry the logical path.
182
+ def parse_source(path)
183
+ physical = @buffer ? @buffer.resolve(path) : path
184
+ return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
185
+
186
+ Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
187
+ end
188
+
189
+ # Mirrors {Runner#build_trust_policy}. Workers under Phase
190
+ # 4b will need the same trust derivation, and the
191
+ # configuration is already shareable, so deriving it inside
192
+ # the session keeps the substrate decoupled from the
193
+ # coordinator's helper.
194
+ def build_trust_policy
195
+ trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
196
+ roots = [Dir.pwd]
197
+ Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
198
+ trusted_gems.each do |gem_name|
199
+ path = trusted_gem_root(gem_name)
200
+ roots << path if path
201
+ end
202
+ @configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
203
+
204
+ Plugin::TrustPolicy.new(
205
+ trusted_gems: trusted_gems,
206
+ allowed_read_roots: roots,
207
+ network_policy: @configuration.plugins_io_network,
208
+ allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
209
+ )
210
+ end
211
+
212
+ def trusted_gem_name(entry)
213
+ case entry
214
+ when String then entry
215
+ when Hash then entry["gem"] || entry["id"]
216
+ end
217
+ end
218
+
219
+ def trusted_gem_root(gem_name)
220
+ return nil if gem_name.nil? || gem_name.empty?
221
+
222
+ spec = Gem.loaded_specs[gem_name]
223
+ spec&.full_gem_path # rigor:disable undefined-method
224
+ rescue StandardError
225
+ nil
226
+ end
227
+
228
+ def run_plugin_prepare
229
+ return [] if @plugin_registry.empty?
230
+
231
+ @plugin_registry.plugins.flat_map do |plugin|
232
+ plugin.prepare(plugin.services)
233
+ []
234
+ rescue StandardError => e
235
+ [plugin_prepare_error_diagnostic(plugin, e)]
236
+ end
237
+ end
238
+
239
+ def plugin_prepare_error_diagnostic(plugin, error)
240
+ plugin_id = safe_plugin_id(plugin)
241
+ Diagnostic.new(
242
+ path: ".rigor.yml",
243
+ line: 1,
244
+ column: 1,
245
+ message: "plugin #{plugin_id.inspect} raised during prepare: " \
246
+ "#{error.class}: #{error.message}",
247
+ severity: :error,
248
+ rule: "runtime-error",
249
+ source_family: :plugin_loader
250
+ )
251
+ end
252
+
253
+ def plugin_emitted_diagnostics(path, root, scope)
254
+ return [] if @plugin_registry.empty?
255
+
256
+ @plugin_registry.plugins.flat_map do |plugin|
257
+ collect_plugin_diagnostics(plugin, path, root, scope)
258
+ end
259
+ end
260
+
261
+ def collect_plugin_diagnostics(plugin, path, root, scope)
262
+ raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
263
+ Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
264
+ rescue StandardError => e
265
+ [plugin_runtime_error_diagnostic(path, plugin, e)]
266
+ end
267
+
268
+ def stamp_plugin_diagnostic(diagnostic, plugin_id)
269
+ Diagnostic.new(
270
+ path: diagnostic.path,
271
+ line: diagnostic.line,
272
+ column: diagnostic.column,
273
+ message: diagnostic.message,
274
+ severity: diagnostic.severity,
275
+ rule: diagnostic.rule,
276
+ source_family: "plugin.#{plugin_id}"
277
+ )
278
+ end
279
+
280
+ def plugin_runtime_error_diagnostic(path, plugin, error)
281
+ plugin_id = safe_plugin_id(plugin)
282
+ Diagnostic.new(
283
+ path: path,
284
+ line: 1,
285
+ column: 1,
286
+ message: "plugin #{plugin_id.inspect} raised during diagnostics_for_file: " \
287
+ "#{error.class}: #{error.message}",
288
+ severity: :error,
289
+ rule: "runtime-error",
290
+ source_family: :plugin_loader
291
+ )
292
+ end
293
+
294
+ def safe_plugin_id(plugin)
295
+ plugin.manifest.id
296
+ rescue StandardError
297
+ plugin.class.to_s
298
+ end
299
+
300
+ def explain_diagnostics(path, root, scope)
301
+ return [] unless @explain
302
+
303
+ result = Inference::CoverageScanner.new(scope: scope).scan(root)
304
+ result.events.map { |event| explain_diagnostic(path, event) }
305
+ end
306
+
307
+ def explain_diagnostic(path, event)
308
+ location = event.location
309
+ line = location ? location.start_line : 1
310
+ column = location ? location.start_column + 1 : 1
311
+ Diagnostic.new(
312
+ path: path,
313
+ line: line,
314
+ column: column,
315
+ message: "fail-soft fallback at #{event.node_class}: #{event.inner_type.describe(:short)}",
316
+ severity: :info,
317
+ rule: "fallback"
318
+ )
319
+ end
320
+
321
+ def parse_diagnostics(path, parse_result)
322
+ parse_result.errors.map do |error|
323
+ location = error.location
324
+ Diagnostic.new(
325
+ path: path,
326
+ line: location.start_line,
327
+ column: location.start_column + 1,
328
+ message: error.message,
329
+ severity: :error
330
+ )
331
+ end
332
+ end
333
+
334
+ def analyzer_error(path, message)
335
+ Diagnostic.new(path: path, line: 1, column: 1, message: message, severity: :error)
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../inference/hkt_registry"
4
+ require_relative "../inference/hkt_body"
5
+ require_relative "../inference/hkt_body_parser"
6
+
7
+ module Rigor
8
+ module Builtins
9
+ # ADR-20 slices 2c + 3 — Rigor-bundled Lightweight HKT
10
+ # registrations that ship with every analyzer instance.
11
+ # The set is intentionally small at v0.1.x: only the URIs
12
+ # whose payoff justifies hardcoded definitions. Plugin
13
+ # authors register more URIs through their manifests; user
14
+ # `.rbs` overlays register through the
15
+ # `%a{rigor:v1:hkt_register}` /
16
+ # `%a{rigor:v1:hkt_define}` annotations Slice 1 ships.
17
+ #
18
+ # Today's contents:
19
+ #
20
+ # - `json::value[K]` — the recursive sum stdlib's
21
+ # `JSON.parse` returns. Body:
22
+ #
23
+ # nil | true | false | Integer | Float | String
24
+ # | Array[App[json::value, K]]
25
+ # | Hash[K, App[json::value, K]]
26
+ #
27
+ # The reducer handles the self-recursive `App` nodes via
28
+ # lazy "tying-the-knot" (see {HktReducer}). `K = String`
29
+ # matches stdlib's default key handling; `K = Symbol`
30
+ # matches `symbolize_names: true`.
31
+ module HktBuiltins
32
+ module_function
33
+
34
+ # Built via the body-string parser (slice 2b/2c) so the
35
+ # bundled overlay exercises the same authoring surface
36
+ # third-party plugins use. The body matches what user
37
+ # `.rbs` overlays would write through a
38
+ # `%a{rigor:v1:hkt_define: ...body=...}` annotation.
39
+ JSON_VALUE_BODY = "nil | true | false | Integer | Float | String | " \
40
+ "Array[App[json::value, K]] | Hash[K, App[json::value, K]]"
41
+ private_constant :JSON_VALUE_BODY
42
+
43
+ def json_value_body_tree
44
+ Rigor::Inference::HktBodyParser.parse(JSON_VALUE_BODY, params: [:K])
45
+ end
46
+
47
+ # `csv::parsed[K]` — `Array[Array[K | nil]]` (CSV.parse's
48
+ # no-headers shape: an Array of rows; each row is an
49
+ # Array of optionally-nil cell values). When
50
+ # `headers: true` the runtime returns a `CSV::Table` /
51
+ # `CSV::Row` shape instead — that case is NOT covered
52
+ # by the bundled override (CSV::Row is its own class
53
+ # with Hash + Array access; a future slice may add a
54
+ # separate URI or a discriminator hook for it).
55
+ CSV_PARSED_BODY = "Array[Array[K | nil]]"
56
+ private_constant :CSV_PARSED_BODY
57
+
58
+ def csv_parsed_body_tree
59
+ Rigor::Inference::HktBodyParser.parse(CSV_PARSED_BODY, params: [:K])
60
+ end
61
+
62
+ def json_value_registration
63
+ Rigor::Inference::HktRegistry::Registration.new(
64
+ uri: :"json::value",
65
+ arity: 1,
66
+ variance: [:out],
67
+ bound: Rigor::Type::Combinator.untyped
68
+ )
69
+ end
70
+
71
+ def json_value_definition
72
+ Rigor::Inference::HktRegistry.definition_with_body_tree(
73
+ uri: :"json::value",
74
+ params: [:K],
75
+ body_tree: json_value_body_tree,
76
+ source_path: __FILE__,
77
+ source_line: __LINE__ - 5
78
+ )
79
+ end
80
+
81
+ def csv_parsed_registration
82
+ Rigor::Inference::HktRegistry::Registration.new(
83
+ uri: :"csv::parsed",
84
+ arity: 1,
85
+ variance: [:out],
86
+ bound: Rigor::Type::Combinator.untyped
87
+ )
88
+ end
89
+
90
+ def csv_parsed_definition
91
+ Rigor::Inference::HktRegistry.definition_with_body_tree(
92
+ uri: :"csv::parsed",
93
+ params: [:K],
94
+ body_tree: csv_parsed_body_tree,
95
+ source_path: __FILE__,
96
+ source_line: __LINE__ - 5
97
+ )
98
+ end
99
+
100
+ # @return [Rigor::Inference::HktRegistry] frozen registry
101
+ # pre-seeded with all bundled HKT registrations +
102
+ # bodies. Allocated fresh each call rather than
103
+ # memoised — memoisation through a module-level
104
+ # `@registry` ivar surfaces a `Ractor::IsolationError`
105
+ # in pool workers (the ivar's contents include
106
+ # `HktBody::AppRef` Symbol-keyed structures that the
107
+ # current Ractor shareability audit hasn't yet been
108
+ # walked through). The registry is small enough that
109
+ # per-Environment construction is acceptable; an
110
+ # eager-frozen constant is a future optimisation
111
+ # once ADR-15 phase 4b.x covers the dependency graph.
112
+ def registry
113
+ Rigor::Inference::HktRegistry.new(
114
+ registrations: [json_value_registration, csv_parsed_registration],
115
+ definitions: [json_value_definition, csv_parsed_definition]
116
+ )
117
+ end
118
+
119
+ # ADR-20 slice 3 — hardcoded `(class_name, method_name,
120
+ # kind) => HKT application` table consulted by the
121
+ # dispatcher's new HKT-builtin tier. Sits ABOVE
122
+ # `RbsDispatch.try_dispatch` so a known stdlib method
123
+ # (`JSON.parse`, `JSON.parse!`) gets the reduced HKT
124
+ # type instead of the upstream rbs gem's `untyped`
125
+ # return. The annotation-based `%a{rigor:v1:return:
126
+ # App[...]}` path (parsed by
127
+ # `RbsExtended.parse_return_type_override`) is the
128
+ # general extension surface for user-authored sigs;
129
+ # this table is the Rigor-bundled shortcut for the
130
+ # handful of stdlib methods whose RBS declarations
131
+ # cannot be cleanly overridden via RBS overlay merging.
132
+ #
133
+ # Each entry maps to a hash with `:uri` and `:args`
134
+ # (an array of Ruby class names). The dispatcher
135
+ # builds `Type::App.new(uri, args.map { Nominal })`,
136
+ # then reduces via the env's `hkt_registry` so the
137
+ # caller observes the unfolded form
138
+ # (`Union[nil, true, false, ..., Array[App[json::value,
139
+ # String]], Hash[String, App[json::value, String]]]`)
140
+ # rather than the opaque carrier.
141
+ JSON_VALUE_SPEC = {
142
+ uri: :"json::value",
143
+ args: ["String"],
144
+ discriminator: :json_symbolize_names,
145
+ post_reduce: nil
146
+ }.freeze
147
+ private_constant :JSON_VALUE_SPEC
148
+
149
+ # YAML / Psych.safe_load reuse the json::value reducer
150
+ # for the JSON-equivalent leaf set BUT additionally
151
+ # honour `permitted_classes: [<Class>, ...]` literal
152
+ # Array arguments, unioning each permitted class as an
153
+ # extra arm of the result. Slice 2c-bis behaviour.
154
+ YAML_SAFE_VALUE_SPEC = {
155
+ uri: :"json::value",
156
+ args: ["String"],
157
+ discriminator: :json_symbolize_names,
158
+ post_reduce: :yaml_permitted_classes
159
+ }.freeze
160
+ private_constant :YAML_SAFE_VALUE_SPEC
161
+
162
+ CSV_PARSED_SPEC = {
163
+ uri: :"csv::parsed",
164
+ args: ["String"],
165
+ discriminator: nil,
166
+ post_reduce: nil
167
+ }.freeze
168
+ private_constant :CSV_PARSED_SPEC
169
+
170
+ METHOD_RETURN_OVERRIDES = {
171
+ # JSON — stdlib's `json` library. Upstream rbs declares
172
+ # `(string, ?options) -> untyped`; the HKT-builtin tier
173
+ # tightens to the recursive `json::value[K]` union.
174
+ # `load_file` / `load_file!` share the `?options` slot
175
+ # so the `symbolize_names: true` discriminator applies
176
+ # to them too (just like `parse` / `load`).
177
+ ["JSON", :parse, :singleton] => JSON_VALUE_SPEC,
178
+ ["JSON", :parse!, :singleton] => JSON_VALUE_SPEC,
179
+ ["JSON", :load, :singleton] => JSON_VALUE_SPEC,
180
+ ["JSON", :load_file, :singleton] => JSON_VALUE_SPEC,
181
+ ["JSON", :load_file!, :singleton] => JSON_VALUE_SPEC,
182
+ # YAML.safe_load / Psych.safe_load — default
183
+ # `permitted_classes: []` admits exactly the JSON
184
+ # vocabulary (nil / true / false / Integer / Float /
185
+ # String / Array / Hash), so the json::value tree
186
+ # also describes them. When the call passes a literal
187
+ # `permitted_classes: [Date, Symbol, ...]` Array, the
188
+ # `:yaml_permitted_classes` post_reduce unions each
189
+ # named class into the result. Non-literal options
190
+ # (a variable, a constant reference, a `+ classes`
191
+ # concat) silently no-op and the caller observes the
192
+ # base json::value envelope only. YAML.load /
193
+ # YAML.unsafe_load deliberately stay out of the
194
+ # override table — they can return ANY Ruby object
195
+ # and have no useful HKT envelope.
196
+ ["YAML", :safe_load, :singleton] => YAML_SAFE_VALUE_SPEC,
197
+ ["YAML", :safe_load_file, :singleton] => YAML_SAFE_VALUE_SPEC,
198
+ ["Psych", :safe_load, :singleton] => YAML_SAFE_VALUE_SPEC,
199
+ ["Psych", :safe_load_file, :singleton] => YAML_SAFE_VALUE_SPEC,
200
+ # CSV.parse / CSV.read — no-headers shape only.
201
+ # Upstream rbs declares broader return shapes but
202
+ # the common case is `Array[Array[String?]]` which
203
+ # the `csv::parsed[String]` URI matches. The
204
+ # `headers: true` shape (`CSV::Table` of `CSV::Row`)
205
+ # is NOT covered — calls passing the option fall
206
+ # through to the upstream RBS type. CSV.foreach also
207
+ # falls through (it yields rows rather than
208
+ # returning a typed structure).
209
+ ["CSV", :parse, :singleton] => CSV_PARSED_SPEC,
210
+ ["CSV", :read, :singleton] => CSV_PARSED_SPEC
211
+ }.freeze
212
+
213
+ # @return [Rigor::Type, nil] the reduced HKT type for
214
+ # the given (class_name, method_name, kind) triple,
215
+ # or `nil` when no built-in override is registered.
216
+ # When `arg_types` is supplied AND the entry carries a
217
+ # `:discriminator` symbol, the discriminator may swap
218
+ # the spec's default args for an alternate (e.g.
219
+ # `JSON.parse(str, symbolize_names: true)` discriminates
220
+ # `K = Symbol` instead of the default `K = String`).
221
+ def method_return_override(class_name:, method_name:, kind:, arg_types: nil, hkt_registry: nil)
222
+ spec = METHOD_RETURN_OVERRIDES[[class_name, method_name.to_sym, kind]]
223
+ return nil unless spec
224
+
225
+ args = discriminated_args(spec, arg_types)
226
+ registration = hkt_registry&.registration(spec[:uri])
227
+ bound = registration&.bound || Rigor::Type::Combinator.untyped
228
+ app = Rigor::Type::App.new(spec[:uri], args, bound: bound)
229
+
230
+ reduced =
231
+ if hkt_registry.nil? || !hkt_registry.defined?(spec[:uri])
232
+ app
233
+ else
234
+ hkt_registry.reduce(app) || app
235
+ end
236
+
237
+ apply_post_reduce(spec[:post_reduce], reduced, arg_types)
238
+ end
239
+
240
+ # Per-spec discriminator dispatch. Slice 3 ships one
241
+ # built-in discriminator (`json_symbolize_names`) that
242
+ # observes the optional 2nd argument's `HashShape` for a
243
+ # literal `symbolize_names: true` entry. Plugin / Rigor-
244
+ # bundled callers wanting their own discriminators add a
245
+ # branch here.
246
+ def discriminated_args(spec, arg_types)
247
+ default_args = spec[:args].map { |n| Rigor::Type::Nominal.new(n) }
248
+ return default_args if arg_types.nil?
249
+ return default_args unless spec[:discriminator] == :json_symbolize_names
250
+ return default_args unless json_symbolize_names?(arg_types)
251
+
252
+ [Rigor::Type::Nominal.new("Symbol")]
253
+ end
254
+
255
+ # Returns true iff the call-site's 2nd argument is a
256
+ # `Type::HashShape` carrying a literal
257
+ # `symbolize_names: true` entry. Anything else
258
+ # (no second arg, non-HashShape, missing key, non-literal
259
+ # `true`) returns false so the default `K = String`
260
+ # branch wins.
261
+ def json_symbolize_names?(arg_types)
262
+ return false unless arg_types.is_a?(Array) && arg_types.size >= 2
263
+
264
+ opts = arg_types[1]
265
+ return false unless opts.is_a?(Rigor::Type::HashShape)
266
+
267
+ value = opts.pairs[:symbolize_names] || opts.pairs["symbolize_names"]
268
+ value.is_a?(Rigor::Type::Constant) && value.value == true
269
+ end
270
+
271
+ # Slice 2c-bis — post-reduce hook. Receives the already-
272
+ # reduced `Type` and the call-site's `arg_types`; returns
273
+ # a (possibly augmented) `Type`. `kind = nil` is the
274
+ # identity (passes the reduced type through unchanged).
275
+ # Only `:yaml_permitted_classes` is implemented today;
276
+ # plugin / Rigor-bundled callers wanting their own
277
+ # post-reduce hooks add a branch here.
278
+ def apply_post_reduce(kind, reduced, arg_types)
279
+ case kind
280
+ when :yaml_permitted_classes
281
+ augment_with_yaml_permitted_classes(reduced, arg_types)
282
+ else
283
+ # `nil` (no post-reduce declared) and any future
284
+ # unrecognised kind both pass the reduced type
285
+ # through unchanged. Unknown kinds are silently
286
+ # tolerated rather than raised because adding a
287
+ # new kind on a Rigor upgrade should not crash a
288
+ # stale METHOD_RETURN_OVERRIDES entry on the
289
+ # caller side.
290
+ reduced
291
+ end
292
+ end
293
+
294
+ # Inspects arg_types for a `permitted_classes: [<Class>,
295
+ # ...]` literal Array in the options Hash and unions
296
+ # each named class into the reduced result. Non-literal
297
+ # `permitted_classes:` values (a variable, a constant
298
+ # reference, a concat) silently no-op and the caller
299
+ # observes the base json::value envelope only. Defensive
300
+ # against the various ways Ruby literal arrays surface
301
+ # as Rigor types: `Tuple[Singleton<Date>]` for a single
302
+ # element, `Tuple[Singleton<Date>, Singleton<Symbol>]`
303
+ # for multiple, `Nominal[Array, [Singleton<...>]]` if
304
+ # the analyzer widened (rare for literal arrays).
305
+ def augment_with_yaml_permitted_classes(reduced, arg_types)
306
+ return reduced unless arg_types.is_a?(Array) && arg_types.size >= 2
307
+
308
+ opts = arg_types[1]
309
+ return reduced unless opts.is_a?(Rigor::Type::HashShape)
310
+
311
+ value = opts.pairs[:permitted_classes] || opts.pairs["permitted_classes"]
312
+ return reduced if value.nil?
313
+
314
+ extras = permitted_class_nominals(value)
315
+ return reduced if extras.empty?
316
+
317
+ Rigor::Type::Combinator.union(reduced, *extras)
318
+ end
319
+
320
+ # Extract Singleton-class elements from a Tuple or
321
+ # Array-shape carrier, mapping each to its Nominal
322
+ # counterpart. Returns an empty array when no static
323
+ # Singletons are reachable (e.g. value is `Dynamic[T]`,
324
+ # element types are non-Singleton, etc.).
325
+ def permitted_class_nominals(value)
326
+ candidates =
327
+ if value.is_a?(Rigor::Type::Tuple)
328
+ value.elements
329
+ elsif value.is_a?(Rigor::Type::Nominal) && value.class_name == "Array" && value.type_args.size == 1
330
+ element = value.type_args.first
331
+ element.is_a?(Rigor::Type::Union) ? element.members : [element]
332
+ else
333
+ []
334
+ end
335
+
336
+ candidates.filter_map do |c|
337
+ c.is_a?(Rigor::Type::Singleton) ? Rigor::Type::Nominal.new(c.class_name) : nil
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end
@@ -419,9 +419,13 @@ module Rigor
419
419
  elsif (literal = @scanner.scan(SIGNED_INT))
420
420
  TypeNode::IntegerLiteral.new(value: Integer(literal))
421
421
  elsif @scanner.scan(SYMBOL_LITERAL)
422
- TypeNode::SymbolLiteral.new(value: @scanner[:value].to_sym)
422
+ # StringScanner#[] accepts Symbol for named captures
423
+ # (Ruby behaviour); upstream RBS shim only declares the
424
+ # positional-capture (Integer) overload, so the
425
+ # argument-type-mismatch diagnostic is suppressed.
426
+ TypeNode::SymbolLiteral.new(value: @scanner[:value].to_sym) # rigor:disable argument-type-mismatch
423
427
  elsif @scanner.scan(STRING_LITERAL)
424
- TypeNode::StringLiteral.new(value: @scanner[:value])
428
+ TypeNode::StringLiteral.new(value: @scanner[:value]) # rigor:disable argument-type-mismatch
425
429
  else
426
430
  parse_type_ast
427
431
  end