rigortype 0.1.3 → 0.1.5

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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +154 -33
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
  9. data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +26 -6
  12. data/lib/rigor/analysis/result.rb +11 -3
  13. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  14. data/lib/rigor/analysis/run_stats.rb +193 -0
  15. data/lib/rigor/analysis/runner.rb +498 -12
  16. data/lib/rigor/analysis/worker_session.rb +327 -0
  17. data/lib/rigor/builtins/imported_refinements.rb +364 -55
  18. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  19. data/lib/rigor/cache/descriptor.rb +1 -1
  20. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  21. data/lib/rigor/cache/store.rb +39 -6
  22. data/lib/rigor/cli/diff_command.rb +1 -1
  23. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  24. data/lib/rigor/cli/type_of_command.rb +1 -1
  25. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  26. data/lib/rigor/cli/type_scan_report.rb +2 -2
  27. data/lib/rigor/cli.rb +61 -3
  28. data/lib/rigor/configuration/dependencies.rb +2 -2
  29. data/lib/rigor/configuration.rb +131 -6
  30. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  31. data/lib/rigor/environment/class_registry.rb +12 -3
  32. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  33. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  34. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  35. data/lib/rigor/environment/rbs_loader.rb +194 -6
  36. data/lib/rigor/environment/reflection.rb +152 -0
  37. data/lib/rigor/environment.rb +109 -6
  38. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  39. data/lib/rigor/flow_contribution/element.rb +1 -1
  40. data/lib/rigor/flow_contribution/fact.rb +1 -1
  41. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  42. data/lib/rigor/flow_contribution/merger.rb +3 -3
  43. data/lib/rigor/flow_contribution.rb +2 -2
  44. data/lib/rigor/inference/acceptance.rb +35 -1
  45. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  46. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  47. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  48. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  49. data/lib/rigor/inference/expression_typer.rb +77 -11
  50. data/lib/rigor/inference/fallback.rb +1 -1
  51. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  52. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  53. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -41
  54. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  55. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  56. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  57. data/lib/rigor/inference/method_dispatcher/method_folding.rb +135 -0
  58. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +7 -12
  59. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  60. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -44
  61. data/lib/rigor/inference/method_dispatcher.rb +274 -5
  62. data/lib/rigor/inference/method_parameter_binder.rb +22 -14
  63. data/lib/rigor/inference/narrowing.rb +129 -12
  64. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  65. data/lib/rigor/inference/scope_indexer.rb +14 -9
  66. data/lib/rigor/inference/statement_evaluator.rb +7 -7
  67. data/lib/rigor/inference/synthetic_method.rb +86 -0
  68. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  69. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  70. data/lib/rigor/plugin/blueprint.rb +60 -0
  71. data/lib/rigor/plugin/io_boundary.rb +0 -2
  72. data/lib/rigor/plugin/loader.rb +5 -3
  73. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  74. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  75. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  76. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  77. data/lib/rigor/plugin/macro.rb +31 -0
  78. data/lib/rigor/plugin/manifest.rb +102 -10
  79. data/lib/rigor/plugin/registry.rb +43 -2
  80. data/lib/rigor/plugin/services.rb +1 -1
  81. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  82. data/lib/rigor/plugin.rb +2 -0
  83. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  84. data/lib/rigor/rbs_extended.rb +131 -32
  85. data/lib/rigor/scope.rb +25 -8
  86. data/lib/rigor/sig_gen/classification.rb +36 -0
  87. data/lib/rigor/sig_gen/generator.rb +1048 -0
  88. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  89. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  90. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  91. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  92. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  93. data/lib/rigor/sig_gen/renderer.rb +157 -0
  94. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  95. data/lib/rigor/sig_gen/write_result.rb +48 -0
  96. data/lib/rigor/sig_gen/writer.rb +530 -0
  97. data/lib/rigor/sig_gen.rb +25 -0
  98. data/lib/rigor/trinary.rb +15 -11
  99. data/lib/rigor/type/bot.rb +6 -3
  100. data/lib/rigor/type/bound_method.rb +79 -0
  101. data/lib/rigor/type/combinator.rb +207 -3
  102. data/lib/rigor/type/constant.rb +13 -0
  103. data/lib/rigor/type/hash_shape.rb +0 -2
  104. data/lib/rigor/type/integer_range.rb +7 -7
  105. data/lib/rigor/type/refined.rb +18 -12
  106. data/lib/rigor/type/top.rb +4 -3
  107. data/lib/rigor/type/union.rb +20 -1
  108. data/lib/rigor/type.rb +1 -0
  109. data/lib/rigor/type_node/generic.rb +68 -0
  110. data/lib/rigor/type_node/identifier.rb +38 -0
  111. data/lib/rigor/type_node/indexed_access.rb +41 -0
  112. data/lib/rigor/type_node/integer_literal.rb +29 -0
  113. data/lib/rigor/type_node/name_scope.rb +52 -0
  114. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  115. data/lib/rigor/type_node/string_literal.rb +32 -0
  116. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  117. data/lib/rigor/type_node/union.rb +42 -0
  118. data/lib/rigor/type_node.rb +29 -0
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +2 -0
  121. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  122. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  123. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  124. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  125. data/sig/rigor/cli/diff_command.rbs +4 -0
  126. data/sig/rigor/cli/explain_command.rbs +4 -0
  127. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  128. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  129. data/sig/rigor/environment.rbs +8 -2
  130. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  131. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  132. data/sig/rigor/inference/builtins.rbs +2 -0
  133. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  134. data/sig/rigor/plugin/base.rbs +6 -0
  135. data/sig/rigor/plugin/blueprint.rbs +7 -0
  136. data/sig/rigor/plugin/fact_store.rbs +11 -0
  137. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  138. data/sig/rigor/plugin/load_error.rbs +6 -0
  139. data/sig/rigor/plugin/loader.rbs +20 -0
  140. data/sig/rigor/plugin/manifest.rbs +9 -0
  141. data/sig/rigor/plugin/registry.rbs +16 -0
  142. data/sig/rigor/plugin/services.rbs +3 -0
  143. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  144. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  145. data/sig/rigor/plugin.rbs +8 -0
  146. data/sig/rigor/scope.rbs +4 -2
  147. data/sig/rigor/type.rbs +28 -6
  148. data/sig/rigor.rbs +35 -2
  149. metadata +90 -1
@@ -3,6 +3,7 @@
3
3
  require "strscan"
4
4
 
5
5
  require_relative "../type"
6
+ require_relative "../type_node"
6
7
 
7
8
  module Rigor
8
9
  module Builtins
@@ -118,6 +119,38 @@ module Rigor
118
119
  return nil unless args.size == 1
119
120
 
120
121
  Type::Combinator.int_mask_of(args.first)
122
+ },
123
+ # ADR-13 § "Canonical type-function additions" — five
124
+ # shape-projection type functions that the
125
+ # `rigor-typescript-utility-types` plugin (and any other
126
+ # plugin that ships a shape-projection vocabulary) maps
127
+ # onto. Phase A handles `HashShape` carriers; non-shape
128
+ # inputs return the input unchanged (the lossy-projection
129
+ # diagnostic lands in slice 5).
130
+ "pick_of" => lambda { |args|
131
+ return nil unless args.size == 2
132
+
133
+ Type::Combinator.pick_of(args[0], args[1])
134
+ },
135
+ "omit_of" => lambda { |args|
136
+ return nil unless args.size == 2
137
+
138
+ Type::Combinator.omit_of(args[0], args[1])
139
+ },
140
+ "partial_of" => lambda { |args|
141
+ return nil unless args.size == 1
142
+
143
+ Type::Combinator.partial_of(args.first)
144
+ },
145
+ "required_of" => lambda { |args|
146
+ return nil unless args.size == 1
147
+
148
+ Type::Combinator.required_of(args.first)
149
+ },
150
+ "readonly_of" => lambda { |args|
151
+ return nil unless args.size == 1
152
+
153
+ Type::Combinator.readonly_of(args.first)
121
154
  }
122
155
  }.freeze
123
156
  private_constant :PARAMETERISED_TYPE_BUILDERS
@@ -151,11 +184,46 @@ module Rigor
151
184
  # `rigor:v1:return:` (or sibling) directive. Accepts
152
185
  # the bare-name forms `lookup` already handles plus the
153
186
  # parameterised forms documented on {Parser}.
187
+ # @param name_scope [Rigor::TypeNode::NameScope, nil]
188
+ # ADR-13 slice 3 — when provided, the parser consults the
189
+ # scope's `#resolver` chain after the built-in registry
190
+ # and built-in parametric forms but before the RBS Nominal
191
+ # fallback. `nil` (default) preserves the slice-1 / slice-2
192
+ # behaviour of consulting only built-ins + RBS.
193
+ # @param reporter [Rigor::RbsExtended::Reporter, nil]
194
+ # ADR-13 slice 3b — collector that the Resolver feeds
195
+ # `dynamic.shape.lossy-projection` events into when a
196
+ # shape-projection head (`pick_of`, `omit_of`,
197
+ # `partial_of`, `required_of`, `readonly_of`) is applied
198
+ # to a carrier that does not preserve shape information.
199
+ # `nil` (default) suppresses event accumulation; legacy
200
+ # call sites that have no reporter to thread keep the
201
+ # pre-slice-3b silent fall-through.
202
+ # @param source_location [RBS::Location, nil] location
203
+ # attribution for the events the Resolver records. Carries
204
+ # the annotation's filename / line / column so the runner
205
+ # can stamp diagnostics with the user-visible source site.
154
206
  # @return [Rigor::Type, nil] the resolved refinement
155
207
  # carrier, or `nil` when the payload is unparseable or
156
- # names a refinement / class not in the registry.
157
- def parse(payload)
158
- Parser.new(payload.to_s).parse
208
+ # names a refinement / class no registered source resolved.
209
+ def parse(payload, name_scope: nil, reporter: nil, source_location: nil)
210
+ Parser.new(
211
+ payload.to_s,
212
+ name_scope: name_scope,
213
+ reporter: reporter,
214
+ source_location: source_location
215
+ ).parse
216
+ end
217
+
218
+ # Builder helpers reachable from the Resolver. They live on
219
+ # the module so the Resolver does not have to import the
220
+ # `private_constant` builder hashes.
221
+ def parametric_type_builder(name)
222
+ PARAMETERISED_TYPE_BUILDERS[name]
223
+ end
224
+
225
+ def parametric_int_builder(name)
226
+ PARAMETERISED_INT_BUILDERS[name]
159
227
  end
160
228
 
161
229
  def known?(name)
@@ -185,23 +253,38 @@ module Rigor
185
253
  # soft (returns `nil` from `parse`) on any deviation so the
186
254
  # `RBS::Extended` directive site can fall back to the
187
255
  # RBS-declared type rather than crash on a typo.
188
- class Parser # rubocop:disable Metrics/ClassLength
189
- def initialize(input)
256
+ #
257
+ # ADR-13 slice 3 split the original "scan + resolve" loop
258
+ # into two passes: the parser emits a {Rigor::TypeNode} AST,
259
+ # and a sibling {Resolver} walks the AST to produce a
260
+ # {Rigor::Type} carrier — consulting the built-in registry,
261
+ # the plugin {Rigor::TypeNode::ResolverChain}, and finally
262
+ # the RBS Nominal fallback in that order. Plugin resolvers
263
+ # never see partial parses.
264
+ class Parser
265
+ def initialize(input, name_scope: nil, reporter: nil, source_location: nil)
190
266
  @scanner = StringScanner.new(input.strip)
267
+ @resolver = Resolver.new(
268
+ name_scope: name_scope,
269
+ reporter: reporter,
270
+ source_location: source_location
271
+ )
191
272
  end
192
273
 
193
274
  def parse
194
- type = parse_type
195
- return nil if type.nil?
196
-
197
- # v0.0.7 — trailing `[K]` indexed-access projects
198
- # into the parsed type. Multiple `[K]` segments
199
- # chain (`Tuple[A, B, C][1][0]`).
200
- type = parse_indexed_access_chain(type)
201
- return nil if type.nil?
275
+ ast = parse_type_ast
276
+ return nil if ast.nil?
277
+
278
+ # v0.0.7 — trailing `[K]` indexed-access projects into
279
+ # the parsed type. Multiple `[K]` segments chain
280
+ # (`Tuple[A, B, C][1][0]`). Each segment wraps the
281
+ # previous AST in an {IndexedAccess} node so the chain
282
+ # composes cleanly through the resolver pass.
283
+ ast = parse_indexed_access_chain_ast(ast)
284
+ return nil if ast.nil?
202
285
  return nil unless @scanner.eos?
203
286
 
204
- type
287
+ @resolver.resolve_ast(ast)
205
288
  end
206
289
 
207
290
  private
@@ -214,68 +297,70 @@ module Rigor
214
297
  SIMPLE_NAME = /[a-z][a-z0-9_-]*/
215
298
  CLASS_NAME = /[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
216
299
  SIGNED_INT = /-?\d+/
217
- private_constant :SIMPLE_NAME, :CLASS_NAME, :SIGNED_INT
218
-
219
- def parse_type
300
+ # ADR-13 follow-up — literal-key tokens at type-arg
301
+ # position. Symbol literals match Ruby's bare-symbol
302
+ # identifier shape (`:name`); string literals are
303
+ # double-quoted without escape sequences (the most
304
+ # common TS-style key-union shape). The `?<value>`
305
+ # capture lets the parser pull the inner text without
306
+ # post-stripping the delimiter.
307
+ SYMBOL_LITERAL = /:(?<value>[a-zA-Z_][a-zA-Z0-9_]*[?!=]?)/
308
+ STRING_LITERAL = /"(?<value>[^"\\]*)"/
309
+ private_constant :SIMPLE_NAME, :CLASS_NAME, :SIGNED_INT, :SYMBOL_LITERAL, :STRING_LITERAL
310
+
311
+ def parse_type_ast
220
312
  if (class_name = @scanner.scan(CLASS_NAME))
221
- return parse_class_arg_tail(class_name)
313
+ return parse_class_arg_tail_ast(class_name)
222
314
  end
223
315
 
224
316
  name = @scanner.scan(SIMPLE_NAME)
225
317
  return nil if name.nil?
226
318
 
227
319
  case @scanner.peek(1)
228
- when "[" then parse_parametric_type_args(name)
229
- when "<" then parse_parametric_int_bounds(name)
230
- else ImportedRefinements.lookup(name)
320
+ when "[" then parse_bracket_args_ast(name)
321
+ when "<" then parse_angle_bounds_ast(name)
322
+ else TypeNode::Identifier.new(name: name)
231
323
  end
232
324
  end
233
325
 
234
- # `T[K]` — keep applying `[K]` indexes until no more
235
- # opening brackets are present. Each index consumes one
236
- # type argument; multi-arg `[K1, K2]` fails (the spec
237
- # specifies a single key).
238
- def parse_indexed_access_chain(type)
326
+ def parse_indexed_access_chain_ast(ast)
239
327
  loop do
240
328
  skip_ws
241
329
  break unless @scanner.peek(1) == "["
242
330
 
243
331
  @scanner.getch
244
- args = parse_type_arg_list
332
+ args = parse_type_arg_list_ast
245
333
  return nil if args.nil? || args.size != 1
246
334
  return nil unless @scanner.getch == "]"
247
335
 
248
- type = Type::Combinator.indexed_access(type, args.first)
336
+ ast = TypeNode::IndexedAccess.new(receiver: ast, key: args.first)
249
337
  end
250
- type
338
+ ast
251
339
  end
252
340
 
253
- def parse_parametric_type_args(name)
254
- builder = PARAMETERISED_TYPE_BUILDERS[name]
255
- return nil if builder.nil?
256
-
341
+ def parse_bracket_args_ast(name)
257
342
  @scanner.getch # consume '['
258
- args = parse_type_arg_list
343
+ args = parse_type_arg_list_ast
259
344
  return nil if args.nil?
260
345
  return nil unless @scanner.getch == "]"
261
346
 
262
- builder.call(args)
347
+ TypeNode::Generic.new(head: name, args: args)
263
348
  end
264
349
 
265
- def parse_parametric_int_bounds(name)
266
- builder = PARAMETERISED_INT_BUILDERS[name]
267
- return nil if builder.nil?
268
-
350
+ def parse_angle_bounds_ast(name)
269
351
  @scanner.getch # consume '<'
270
352
  bounds = parse_int_bound_list
271
353
  return nil if bounds.nil?
272
354
  return nil unless @scanner.getch == ">"
273
355
 
274
- builder.call(bounds)
356
+ TypeNode::Generic.new(
357
+ head: name,
358
+ args: bounds.map { |b| TypeNode::IntegerLiteral.new(value: b) }
359
+ )
275
360
  end
276
361
 
277
- def parse_type_arg_list
278
- collect_separated_list { parse_type_arg }
362
+ def parse_type_arg_list_ast
363
+ collect_separated_list { parse_type_arg_ast }
279
364
  end
280
365
 
281
366
  def parse_int_bound_list
@@ -298,34 +383,67 @@ module Rigor
298
383
  items
299
384
  end
300
385
 
301
- def parse_type_arg
386
+ # ADR-13 follow-up — admits `:a | "b"` literal-union
387
+ # forms by parsing a single arg first, then optionally
388
+ # folding a chain of `| <single_arg>` into a
389
+ # {TypeNode::Union}. Leaves the existing single-arg
390
+ # path bit-for-bit untouched when no `|` follows.
391
+ def parse_type_arg_ast
302
392
  skip_ws
393
+ first = parse_single_type_arg_ast
394
+ return nil if first.nil?
395
+
396
+ members = [first]
397
+ loop do
398
+ skip_ws
399
+ break unless @scanner.peek(1) == "|"
400
+
401
+ @scanner.getch
402
+ skip_ws
403
+ following = parse_single_type_arg_ast
404
+ return nil if following.nil?
405
+
406
+ members << following
407
+ end
408
+
409
+ # Engine cannot see the `members << following`
410
+ # mutation inside the loop, so it folds the
411
+ # `size == 1` guard to a constant; the loop body
412
+ # actually grows the tuple, so the guard is real.
413
+ members.size == 1 ? first : TypeNode::Union.new(nodes: members) # rigor:disable flow.always-truthy-condition
414
+ end
415
+
416
+ def parse_single_type_arg_ast
303
417
  if (class_name = @scanner.scan(CLASS_NAME))
304
- parse_class_arg_tail(class_name)
418
+ parse_class_arg_tail_ast(class_name)
305
419
  elsif (literal = @scanner.scan(SIGNED_INT))
306
- # Integer-literal arg, used by `int_mask[1, 2, 4]`.
307
- # Wrapped as `Constant<Integer>` so type-arg builders
308
- # see a uniform `Array<Type::t>`.
309
- Type::Combinator.constant_of(Integer(literal))
420
+ TypeNode::IntegerLiteral.new(value: Integer(literal))
421
+ elsif @scanner.scan(SYMBOL_LITERAL)
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
427
+ elsif @scanner.scan(STRING_LITERAL)
428
+ TypeNode::StringLiteral.new(value: @scanner[:value]) # rigor:disable argument-type-mismatch
310
429
  else
311
- parse_type
430
+ parse_type_ast
312
431
  end
313
432
  end
314
433
 
315
434
  # Class-name-headed type argument with optional `[T_1,
316
435
  # …]` type-args tail. Used so `key_of[Hash[Symbol,
317
436
  # Integer]]` parses as the projection of a parameterised
318
- # nominal carrier rather than rejecting the inner
319
- # brackets.
320
- def parse_class_arg_tail(class_name)
321
- return Type::Combinator.nominal_of(class_name) unless @scanner.peek(1) == "["
437
+ # nominal carrier rather than rejecting the inner brackets.
438
+ def parse_class_arg_tail_ast(class_name)
439
+ return TypeNode::Identifier.new(name: class_name) unless @scanner.peek(1) == "["
322
440
 
323
441
  @scanner.getch # consume '['
324
- args = parse_type_arg_list
442
+ args = parse_type_arg_list_ast
325
443
  return nil if args.nil?
326
444
  return nil unless @scanner.getch == "]"
327
445
 
328
- Type::Combinator.nominal_of(class_name, type_args: args)
446
+ TypeNode::Generic.new(head: class_name, args: args)
329
447
  end
330
448
 
331
449
  def parse_int_bound
@@ -341,6 +459,197 @@ module Rigor
341
459
  end
342
460
  end
343
461
  private_constant :Parser
462
+
463
+ # AST → {Rigor::Type} resolver. ADR-13's resolution order
464
+ # for every named-type production:
465
+ #
466
+ # 1. Built-in `ImportedRefinements.lookup` (no-arg
467
+ # refinements like `non-empty-string`).
468
+ # 2. Built-in parametric builders
469
+ # (`PARAMETERISED_TYPE_BUILDERS` for `[...]` forms,
470
+ # `PARAMETERISED_INT_BUILDERS` for `<...>` forms).
471
+ # 3. Plugin resolver chain from the supplied
472
+ # {Rigor::TypeNode::NameScope}, if any.
473
+ # 4. RBS Nominal fallback for class-shaped names
474
+ # (PascalCase head, with or without type args).
475
+ #
476
+ # Returns `nil` when every step declined — preserves the
477
+ # parser's fail-soft contract so callers fall back to the
478
+ # RBS-declared type instead of raising.
479
+ class Resolver
480
+ # ADR-13 slice 3b — heads that consume a shape-bearing
481
+ # first argument. When the first arg is not a `HashShape`
482
+ # / `Tuple` (per {Rigor::Type::Combinator.shape_projection_lossy?}),
483
+ # the projection degrades to "input unchanged" and the
484
+ # Resolver records a `dynamic.shape.lossy-projection`
485
+ # event on the reporter (if any).
486
+ SHAPE_PROJECTION_HEADS = %w[pick_of omit_of partial_of required_of readonly_of].freeze
487
+ private_constant :SHAPE_PROJECTION_HEADS
488
+
489
+ def initialize(name_scope: nil, reporter: nil, source_location: nil)
490
+ @chain = name_scope&.resolver
491
+ @class_context = name_scope&.class_context
492
+ @type_alias_table = name_scope&.type_alias_table || {}
493
+ @reporter = reporter
494
+ @source_location = source_location
495
+ end
496
+
497
+ # ADR-13 follow-up — every leaf-literal AST node
498
+ # (`IntegerLiteral` / `SymbolLiteral` / `StringLiteral`)
499
+ # carries a Ruby value that lifts directly to a
500
+ # `Constant<value>` carrier through the same helper.
501
+ LITERAL_AST_NODES = [
502
+ TypeNode::IntegerLiteral, TypeNode::SymbolLiteral, TypeNode::StringLiteral
503
+ ].freeze
504
+ private_constant :LITERAL_AST_NODES
505
+
506
+ def resolve_ast(node)
507
+ case node
508
+ when TypeNode::Identifier then resolve_identifier(node)
509
+ when TypeNode::Generic then resolve_generic(node)
510
+ when TypeNode::IndexedAccess then resolve_indexed_access(node)
511
+ when TypeNode::Union then resolve_union(node)
512
+ when *LITERAL_AST_NODES then Type::Combinator.constant_of(node.value)
513
+ end
514
+ end
515
+
516
+ # ADR-13 follow-up — resolves each node recursively and
517
+ # folds into a `Type::Combinator.union(...)`. When any
518
+ # node resolves to `nil` (unknown name, plugin decline,
519
+ # RBS Nominal fallback miss), the whole union collapses
520
+ # to `nil` so the caller falls back to the underlying
521
+ # RBS-declared type rather than a half-resolved Union
522
+ # carrier.
523
+ def resolve_union(node)
524
+ resolved = node.nodes.map { |child| resolve_ast(child) }
525
+ return nil if resolved.any?(&:nil?)
526
+
527
+ Type::Combinator.union(*resolved)
528
+ end
529
+
530
+ # Public {Rigor::Plugin::TypeNodeResolver}-shaped interface
531
+ # so a {Rigor::TypeNode::NameScope} can point its
532
+ # `#resolver` at the Resolver itself. Plugin resolvers
533
+ # call `scope.resolver.resolve(arg, scope)` to recursively
534
+ # resolve a nested argument through the FULL pass
535
+ # (built-in registry → plugin chain → RBS fallback), not
536
+ # just back through the chain. The `_scope` argument is
537
+ # ignored — the Resolver owns the scope state internally.
538
+ def resolve(node, _scope)
539
+ resolve_ast(node)
540
+ end
541
+
542
+ private
543
+
544
+ CLASS_SHAPED_HEAD = /\A[A-Z]/
545
+ private_constant :CLASS_SHAPED_HEAD
546
+
547
+ def resolve_identifier(node)
548
+ if class_shaped?(node.name)
549
+ chain_type = consult_chain(node)
550
+ return chain_type unless chain_type.nil?
551
+
552
+ return Type::Combinator.nominal_of(node.name)
553
+ end
554
+
555
+ builtin = ImportedRefinements.lookup(node.name)
556
+ return builtin unless builtin.nil?
557
+
558
+ consult_chain(node)
559
+ end
560
+
561
+ def resolve_generic(node)
562
+ builtin = try_builtin_parametric(node)
563
+ return builtin unless builtin.nil?
564
+
565
+ chain_type = consult_chain(node)
566
+ return chain_type unless chain_type.nil?
567
+
568
+ return nil unless class_shaped?(node.head)
569
+
570
+ args = resolve_args(node.args)
571
+ return nil if args.nil?
572
+
573
+ Type::Combinator.nominal_of(node.head, type_args: args)
574
+ end
575
+
576
+ def resolve_indexed_access(node)
577
+ receiver = resolve_ast(node.receiver)
578
+ return nil if receiver.nil?
579
+
580
+ key = resolve_ast(node.key)
581
+ return nil if key.nil?
582
+
583
+ Type::Combinator.indexed_access(receiver, key)
584
+ end
585
+
586
+ def try_builtin_parametric(node)
587
+ try_parametric_type_builder(node) || try_parametric_int_builder(node)
588
+ end
589
+
590
+ def try_parametric_type_builder(node)
591
+ builder = ImportedRefinements.parametric_type_builder(node.head)
592
+ return nil if builder.nil?
593
+
594
+ args = resolve_args(node.args)
595
+ return nil if args.nil?
596
+
597
+ result = builder.call(args)
598
+ record_lossy_projection_if_applicable(node, args, result)
599
+ result
600
+ end
601
+
602
+ # ADR-13 slice 3b — record one `dynamic.shape.lossy-projection`
603
+ # event per (head, source_location) pair when the projection
604
+ # actually degraded. The builders return the source carrier
605
+ # unchanged on non-HashShape / non-Tuple receivers (see
606
+ # `Type::Combinator.pick_of` / `omit_of` and the
607
+ # HashShape-only `partial_of` / `required_of` /
608
+ # `readonly_of`), so detection is "first arg was lossy".
609
+ def record_lossy_projection_if_applicable(node, args, result)
610
+ return if @reporter.nil?
611
+ return if result.nil?
612
+ return unless SHAPE_PROJECTION_HEADS.include?(node.head)
613
+ return if args.empty?
614
+ return unless Type::Combinator.shape_projection_lossy?(args.first)
615
+
616
+ @reporter.record_lossy_projection(
617
+ head: node.head,
618
+ source_location: @source_location
619
+ )
620
+ end
621
+
622
+ def try_parametric_int_builder(node)
623
+ builder = ImportedRefinements.parametric_int_builder(node.head)
624
+ return nil if builder.nil?
625
+
626
+ bounds = node.args.map { |a| a.is_a?(TypeNode::IntegerLiteral) ? a.value : nil }
627
+ return nil if bounds.any?(&:nil?)
628
+
629
+ builder.call(bounds)
630
+ end
631
+
632
+ def resolve_args(args)
633
+ resolved = args.map { |a| resolve_ast(a) }
634
+ resolved.any?(&:nil?) ? nil : resolved
635
+ end
636
+
637
+ def consult_chain(node)
638
+ return nil if @chain.nil?
639
+
640
+ scope = TypeNode::NameScope.new(
641
+ resolver: self,
642
+ class_context: @class_context,
643
+ type_alias_table: @type_alias_table
644
+ )
645
+ @chain.resolve(node, scope)
646
+ end
647
+
648
+ def class_shaped?(name)
649
+ name.match?(CLASS_SHAPED_HEAD)
650
+ end
651
+ end
652
+ private_constant :Resolver
344
653
  end
345
654
  end
346
655
  end
@@ -9,7 +9,7 @@ module Rigor
9
9
  # (`decimal-int-string`, `hex-int-string`, `octal-int-string`,
10
10
  # `lowercase-string`, `uppercase-string`, `numeric-string`).
11
11
  # See `docs/type-specification/imported-built-in-types.md` for
12
- # the registry the refinements come from and `docs/MILESTONES.md`
12
+ # the registry the refinements come from and `docs/ROADMAP.md`
13
13
  # § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of
14
14
  # this recogniser.
15
15
  #
@@ -47,17 +47,22 @@ module Rigor
47
47
  QUANTIFIER_SOURCE = '(?:\+|\{\d+(?:,\d+)?\})'
48
48
  private_constant :QUANTIFIER_SOURCE
49
49
 
50
- RULES = [
51
- [/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
52
- [/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
53
- [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
54
- [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
55
- [/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
56
- [/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
57
- [/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
58
- [/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
59
- [/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
60
- ].freeze
50
+ # ADR-15 Phase 4b.x — `Ractor.make_shareable` (not `.freeze`)
51
+ # because the outer Array contains two-element `[Regexp, Symbol]`
52
+ # rows whose inner Arrays are not frozen by the outer freeze.
53
+ # A worker Ractor iterating `RULES.find { ... }` would trip
54
+ # `Ractor::IsolationError` on the first row access.
55
+ RULES = Ractor.make_shareable([
56
+ [/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
57
+ [/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
58
+ [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
59
+ [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
60
+ [/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
61
+ [/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
62
+ [/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
63
+ [/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
64
+ [/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
65
+ ])
61
66
  private_constant :RULES
62
67
 
63
68
  BOUND_RE = /\{(\d+)(?:,(\d+))?\}\z/
@@ -21,7 +21,7 @@ module Rigor
21
21
  # See ADR-2 § "Registration, Configuration, and Caching" for
22
22
  # the design rationale and ADR-6 for the storage backend
23
23
  # decisions that consume this schema.
24
- class Descriptor # rubocop:disable Metrics/ClassLength
24
+ class Descriptor
25
25
  # Bumped on incompatible schema changes. The storage layer
26
26
  # mixes this into the cache key, so a bump implicitly
27
27
  # invalidates every cached value. v2 added the
@@ -28,7 +28,9 @@ module Rigor
28
28
  end
29
29
 
30
30
  def self.file_entries(loader)
31
- loader.signature_paths.flat_map do |root|
31
+ roots = loader.signature_paths +
32
+ Rigor::Environment::RbsLoader.vendored_gem_sig_paths
33
+ roots.flat_map do |root|
32
34
  next [] unless root.directory?
33
35
 
34
36
  Dir.glob(root.join("**", "*.rbs")).map do |path|
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require "monitor"
6
7
  require "securerandom"
7
8
 
8
9
  require_relative "descriptor"
@@ -36,6 +37,24 @@ module Rigor
36
37
  @misses = 0
37
38
  @writes = 0
38
39
  @by_producer = Hash.new { |h, k| h[k] = { hits: 0, misses: 0, writes: 0 } }
40
+ # Process-level in-memory layer keyed by
41
+ # `(producer_id, cache_key)`. Avoids the disk read +
42
+ # `Marshal.load` cost (the dominant share of repeated
43
+ # cache-hit calls per stackprof) when many short-lived
44
+ # `Analysis::Runner` instances share one `Store` — the
45
+ # spec process, the LSP daemon's repeated re-check
46
+ # path, and any other "many runs, same project" loop.
47
+ # Keys are content-derived (descriptor digests), so
48
+ # cross-fixture contamination is impossible.
49
+ @memo = {}
50
+ # `Analysis::Runner` walks files concurrently (file-
51
+ # level parallelism); the per-file workers share one
52
+ # Store. The monitor guards `@memo` + the counter
53
+ # hashes against concurrent writes. The Monitor is
54
+ # re-entrant so producer blocks can recursively
55
+ # consult the Store (e.g. one cache layer building on
56
+ # another) without dead-locking.
57
+ @monitor = Monitor.new
39
58
  end
40
59
 
41
60
  attr_reader :root
@@ -49,8 +68,10 @@ module Rigor
49
68
  #
50
69
  # @return [Hash] `{ hits:, misses:, writes:, by_producer: { id => { hits:, misses:, writes: } } }`
51
70
  def stats
52
- per_producer = @by_producer.transform_values { |counts| counts.dup.freeze }.freeze
53
- { hits: @hits, misses: @misses, writes: @writes, by_producer: per_producer }.freeze
71
+ @monitor.synchronize do
72
+ per_producer = @by_producer.transform_values { |counts| counts.dup.freeze }.freeze
73
+ { hits: @hits, misses: @misses, writes: @writes, by_producer: per_producer }.freeze
74
+ end
54
75
  end
55
76
 
56
77
  # Walks the on-disk cache rooted at `root` and reports a
@@ -128,18 +149,30 @@ module Rigor
128
149
  ensure_schema_version!
129
150
 
130
151
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
131
- path = entry_path(producer_id, key)
152
+ memo_key = [producer_id, key].freeze
153
+ memoed = @monitor.synchronize { @memo[memo_key] if @memo.key?(memo_key) }
154
+ unless memoed.nil?
155
+ @monitor.synchronize { record(:hits, producer_id) }
156
+ return memoed
157
+ end
132
158
 
159
+ path = entry_path(producer_id, key)
133
160
  cached = read_entry(path, deserialize: deserialize)
134
161
  unless cached.nil?
135
- record(:hits, producer_id)
162
+ @monitor.synchronize do
163
+ record(:hits, producer_id)
164
+ @memo[memo_key] = cached.value
165
+ end
136
166
  return cached.value
137
167
  end
138
168
 
139
- record(:misses, producer_id)
140
169
  value = block.call
141
170
  write_entry(path, descriptor, value, serialize: serialize)
142
- record(:writes, producer_id)
171
+ @monitor.synchronize do
172
+ record(:misses, producer_id)
173
+ record(:writes, producer_id)
174
+ @memo[memo_key] = value
175
+ end
143
176
  value
144
177
  end
145
178
 
@@ -29,7 +29,7 @@ module Rigor
29
29
  # is `1` when any new diagnostic appears, `0` otherwise —
30
30
  # so adding new errors fails CI but legacy errors recorded
31
31
  # in the baseline don't.
32
- class DiffCommand # rubocop:disable Metrics/ClassLength
32
+ class DiffCommand
33
33
  USAGE = "Usage: rigor diff [options] <baseline.json> [paths...]"
34
34
 
35
35
  def initialize(argv:, out:, err:)