rigortype 0.3.1 → 0.3.3

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/04-diagnostics.md +8 -0
  15. data/docs/manual/12-caching.md +17 -5
  16. data/docs/manual/15-type-protection-coverage.md +122 -1
  17. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  19. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  20. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  21. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  22. data/lib/rigor/analysis/check_rules.rb +107 -28
  23. data/lib/rigor/analysis/incremental_session.rb +156 -11
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  25. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  26. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  27. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  28. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  30. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  31. data/lib/rigor/analysis/runner.rb +78 -7
  32. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  33. data/lib/rigor/bleeding_edge.rb +132 -7
  34. data/lib/rigor/cache/descriptor.rb +6 -1
  35. data/lib/rigor/cache/engine_source.rb +162 -0
  36. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  37. data/lib/rigor/cli/check_command.rb +49 -28
  38. data/lib/rigor/cli/coverage_command.rb +15 -2
  39. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  40. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  41. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  42. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  43. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  44. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  45. data/lib/rigor/cli/protection_renderer.rb +13 -0
  46. data/lib/rigor/cli/protection_report.rb +11 -3
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  48. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  49. data/lib/rigor/configuration.rb +45 -6
  50. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  51. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  52. data/lib/rigor/environment/rbs_loader.rb +434 -75
  53. data/lib/rigor/environment.rb +28 -13
  54. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  55. data/lib/rigor/inference/expression_typer.rb +62 -4
  56. data/lib/rigor/inference/fork_map.rb +47 -4
  57. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  58. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  59. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  60. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
  61. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  62. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
  63. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  64. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  65. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  66. data/lib/rigor/inference/mutation_widening.rb +30 -25
  67. data/lib/rigor/inference/narrowing.rb +3 -1
  68. data/lib/rigor/inference/optimistic_origin.rb +123 -0
  69. data/lib/rigor/inference/protection_scanner.rb +18 -2
  70. data/lib/rigor/inference/receiver_alias.rb +57 -0
  71. data/lib/rigor/inference/scope_indexer.rb +197 -55
  72. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  73. data/lib/rigor/inference/statement_evaluator.rb +55 -4
  74. data/lib/rigor/language_server/buffer_table.rb +22 -0
  75. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  76. data/lib/rigor/language_server/project_context.rb +45 -0
  77. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  78. data/lib/rigor/language_server/server.rb +20 -1
  79. data/lib/rigor/language_server.rb +1 -0
  80. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  81. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  82. data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
  83. data/lib/rigor/protection/dependency_closure.rb +59 -0
  84. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  85. data/lib/rigor/protection/discovery_seed.rb +116 -0
  86. data/lib/rigor/protection/kill_signature.rb +31 -0
  87. data/lib/rigor/protection/mutation_cache.rb +355 -0
  88. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  89. data/lib/rigor/protection/mutator.rb +26 -4
  90. data/lib/rigor/runtime/jit.rb +63 -1
  91. data/lib/rigor/scope/discovery_index.rb +9 -0
  92. data/lib/rigor/scope.rb +114 -10
  93. data/lib/rigor/sig_gen/generator.rb +130 -31
  94. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  95. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  96. data/lib/rigor/sig_gen/renderer.rb +20 -1
  97. data/lib/rigor/sig_gen/write_result.rb +6 -4
  98. data/lib/rigor/sig_gen/writer.rb +12 -0
  99. data/lib/rigor/sig_gen.rb +1 -0
  100. data/lib/rigor/source/node_walker.rb +12 -0
  101. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  102. data/lib/rigor/type/nominal.rb +5 -2
  103. data/lib/rigor/type/singleton.rb +4 -1
  104. data/lib/rigor/version.rb +1 -1
  105. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  106. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  107. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  108. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  109. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  110. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  111. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  112. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  113. data/sig/rigor/inference/void_origin.rbs +6 -3
  114. data/sig/rigor/scope.rbs +12 -0
  115. data/sig/rigor.rbs +4 -0
  116. metadata +21 -1
@@ -5,6 +5,7 @@ require_relative "../../type"
5
5
  require_relative "../../rbs_extended"
6
6
  require_relative "../rbs_type_translator"
7
7
  require_relative "../void_origin"
8
+ require_relative "../optimistic_origin"
8
9
  require_relative "overload_selector"
9
10
 
10
11
  module Rigor
@@ -43,8 +44,11 @@ module Rigor
43
44
  # * `block_type:` is ignored; method types that constrain the block return type are not yet honored.
44
45
  # * Keyword arguments are not threaded through call_arg_types, so overloads with required keywords
45
46
  # are skipped (they cannot match the empty kwargs we send).
46
- # * Method-level type parameters (e.g., `def foo[T]: (T) -> T`) are not bound; their variables remain
47
- # `Dynamic[Top]` after substitution.
47
+ # * Method-level type parameters bind only from two positions: the block return type (Slice 6 phase C)
48
+ # and a positional parameter whose declared type is EXACTLY a type variable (issue #303 —
49
+ # `def foo[T]: (T) -> T` binds `T` from the first argument, and carries it into a generic return
50
+ # such as `-> Array[T]`). A variable reachable only through a container position (`Array[T] arg`),
51
+ # a rest positional (`*T`), or a keyword parameter is still unbound and degrades to `Dynamic[Top]`.
48
52
  #
49
53
  # See docs/adr/4-type-inference-engine.md for the broader plan.
50
54
  # rubocop:disable Metrics/ModuleLength
@@ -60,6 +64,14 @@ module Rigor
60
64
  # objects answer to methods their RBS omits (`ActionController::Base`, `Hash`, …).
61
65
  ALLOWED_RBS_COMPLETE_ANCESTORS = ["Rigor::Plugin::Base"].freeze
62
66
 
67
+ # Shared empty returns for the argument-position type-variable binding (issue #303). The
68
+ # no-candidate answer is by far the common case — every non-generic overload takes it — so it must
69
+ # not allocate.
70
+ EMPTY_TYPE_VARS = {}.freeze
71
+ private_constant :EMPTY_TYPE_VARS
72
+ EMPTY_TYPE_PARAM_NAMES = [].freeze
73
+ private_constant :EMPTY_TYPE_PARAM_NAMES
74
+
63
75
  # @param receiver [Rigor::Type]
64
76
  # @param method_name [Symbol]
65
77
  # @param args [Array<Rigor::Type>]
@@ -354,16 +366,9 @@ module Rigor
354
366
  )
355
367
  return nil unless method_type
356
368
 
357
- # ADR-100 WD2/WD3 — the return-typing tier is where `void → top` widens, so it is the one place
358
- # that still knows the RBS return was an author-declared `-> void` before the translator erases
359
- # it to a plain `top`. Record the recovery on the scope's `void_origins` side-table, keyed by the
360
- # call node, so the `static.value-use.void` check rule can fire when this `top` is used in value
361
- # context. Recording is gated on `scope` && `call_node` being present, which naturally scopes it
362
- # to the *direct*-RBS dispatch (the receiver's own resolvable class): the user-class / Object
363
- # ancestor fallback nils both out (WD4 defers that, murkier, surface).
364
- record_void_recovery(method_type, scope, call_node, [class_name, method_name, kind])
365
-
366
- full_type_vars = compose_block_type_vars(method_type, type_vars, block_type)
369
+ call_site = [class_name, method_name, kind]
370
+ record_dispatch_provenance(method_definition, method_type, scope, call_node, call_site)
371
+ full_type_vars = compose_type_vars(method_type, type_vars, args, block_type, scope, call_node, call_site)
367
372
 
368
373
  RbsTypeTranslator.translate(
369
374
  method_type.type.return_type,
@@ -373,6 +378,34 @@ module Rigor
373
378
  )
374
379
  end
375
380
 
381
+ # The two provenance side-tables the return-typing tier is the last place able to populate, recorded
382
+ # together because they share one gate (`scope` && `call_node`) and one call site.
383
+ #
384
+ # ADR-100 WD2/WD3 — the return-typing tier is where `void → top` widens, so it is the one place that
385
+ # still knows the RBS return was an author-declared `-> void` before the translator erases it to a
386
+ # plain `top`. The recovery lands on the scope's `void_origins` side-table, keyed by the call node,
387
+ # so the `static.value-use.void` check rule can fire when this `top` is used in value context. The
388
+ # gate naturally scopes it to the *direct*-RBS dispatch (the receiver's own resolvable class): the
389
+ # user-class / Object ancestor fallback nils both out (WD4 defers that, murkier, surface).
390
+ #
391
+ # Issue #286 — the same call site is the one place that still knows the selected overload spelled
392
+ # its miss as `%a{implicitly-returns-nil}` rather than as `?`; the translator reads the return type
393
+ # only, by the deliberate choice the spec records. Mark the result so a certainty judgment
394
+ # downstream can tell "nil-free because of its class" from "nil-free because we bet".
395
+ def record_dispatch_provenance(method_definition, method_type, scope, call_node, call_site)
396
+ record_void_recovery(method_type, scope, call_node, call_site)
397
+ record_optimistic_nil_free(method_definition, method_type, scope, call_node)
398
+ end
399
+
400
+ # The two method-level type-parameter binding positions, layered in precedence order over the
401
+ # receiver-derived `type_vars`: the block return type first, then the argument positions (which
402
+ # never displace an existing key). See {#compose_arg_type_vars} for the argument envelope.
403
+ def compose_type_vars(method_type, type_vars, args, block_type, scope, call_node, call_site)
404
+ vars = compose_block_type_vars(method_type, type_vars, block_type)
405
+ compose_arg_type_vars(method_type, vars, args, scope: scope, call_node: call_node,
406
+ call_site: call_site)
407
+ end
408
+
376
409
  # Record the `void → top` recovery when the selected overload declares `-> void` and both `scope` and
377
410
  # `call_node` are present (the direct-dispatch path). `void_site` is the `[class_name, method_name,
378
411
  # kind]` triple the {VoidOrigin} carries.
@@ -386,6 +419,16 @@ module Rigor
386
419
  )
387
420
  end
388
421
 
422
+ # Issue #286 — record that this value's nil-freeness is a bet rather than a class property. Gated on
423
+ # `scope` && `call_node` exactly as {#record_void_recovery} is, which scopes it to the direct-RBS
424
+ # dispatch path.
425
+ def record_optimistic_nil_free(method_definition, method_type, scope, call_node)
426
+ return unless scope && call_node
427
+ return unless OptimisticOrigin.optimistic_overload?(method_definition, method_type)
428
+
429
+ scope.record_optimistic_origin(call_node, OptimisticOrigin::IMPLICITLY_RETURNS_NIL)
430
+ end
431
+
389
432
  def void_return?(method_type)
390
433
  fun = method_type.type
391
434
  fun.respond_to?(:return_type) && fun.return_type.is_a?(RBS::Types::Bases::Void)
@@ -418,6 +461,109 @@ module Rigor
418
461
  type_vars.merge(block_var_name => block_type)
419
462
  end
420
463
 
464
+ # Issue #303 — bind method-level type parameters from ARGUMENT positions, layering on top of the
465
+ # receiver-derived and block-derived `type_vars` exactly as {#compose_block_type_vars} does, so
466
+ # `Ractor.make_shareable("x")` (`[T] (T) -> T`) answers `"x"` instead of `Dynamic[top]`.
467
+ #
468
+ # Envelope, deliberately the narrowest sound shape:
469
+ #
470
+ # * only a positional parameter (required or optional) whose declared type is EXACTLY
471
+ # `RBS::Types::Variable` — no container walk, so `(Array[T]) -> T` still degrades;
472
+ # * only names the SELECTED overload declares in its own `type_params` (a class-level variable
473
+ # keeps its receiver-derived binding);
474
+ # * an existing key wins, so the receiver and the block return type both outrank an argument;
475
+ # * a `Dynamic[Top]` argument carries no evidence and contributes nothing;
476
+ # * repeated occurrences of one variable union their arguments.
477
+ #
478
+ # See {#arg_binding_permitted?} for why the contribution is gated on the call site.
479
+ def compose_arg_type_vars(method_type, type_vars, args, scope:, call_node:, call_site:)
480
+ bindings = arg_type_var_bindings(method_type, type_vars, args)
481
+ return type_vars if bindings.empty?
482
+ return type_vars unless arg_binding_permitted?(scope, call_node, call_site)
483
+
484
+ type_vars.merge(bindings)
485
+ end
486
+
487
+ # The candidate bindings, computed before the guard so a non-generic overload (the overwhelming
488
+ # majority) never pays for the `scope` probes.
489
+ def arg_type_var_bindings(method_type, type_vars, args)
490
+ declared = declared_type_param_names(method_type)
491
+ return EMPTY_TYPE_VARS if declared.empty? || args.empty?
492
+
493
+ fun = method_type.type
494
+ return EMPTY_TYPE_VARS unless fun.respond_to?(:required_positionals)
495
+
496
+ positionals = fun.required_positionals + fun.optional_positionals
497
+ positionals.zip(args).each_with_object({}) do |(param, arg), bindings|
498
+ next if arg.nil?
499
+
500
+ name = variable_param_name(param, declared, type_vars)
501
+ next if name.nil?
502
+ next if no_static_evidence?(arg)
503
+
504
+ bindings[name] = bindings.key?(name) ? Type::Combinator.union(bindings[name], arg) : arg
505
+ end
506
+ end
507
+
508
+ def declared_type_param_names(method_type)
509
+ params = method_type.respond_to?(:type_params) ? method_type.type_params : nil
510
+ return EMPTY_TYPE_PARAM_NAMES if params.nil? || params.empty?
511
+
512
+ params.map(&:name)
513
+ end
514
+
515
+ # The parameter's binding name when it is spelled as a bare method-level type variable that is
516
+ # still unbound; nil for every other shape.
517
+ def variable_param_name(param, declared, type_vars)
518
+ declared_type = param.type
519
+ return nil unless declared_type.is_a?(RBS::Types::Variable)
520
+
521
+ name = declared_type.name
522
+ return nil unless declared.include?(name)
523
+ return nil if type_vars.key?(name)
524
+
525
+ name
526
+ end
527
+
528
+ # `Dynamic[top]` is the engine's "we could not tell" answer, so binding a variable to it would
529
+ # dress up an absence of evidence as an inference. The variable stays unbound and degrades as it
530
+ # did before, which is the same value anyway.
531
+ def no_static_evidence?(arg)
532
+ arg.is_a?(Type::Dynamic) && arg.static_facet.is_a?(Type::Top)
533
+ end
534
+
535
+ # Issue #303 — the FP guard on the argument-position binding.
536
+ #
537
+ # Binding an argument makes the RESULT precise, which turns a benign mis-resolution into a
538
+ # confidently wrong type. The shape that matters is a user method shadowing the RBS method this
539
+ # dispatch resolved: `spec/integration/fixtures/kernel_functions.rb`'s `def p(node)` self-send
540
+ # resolves through `Kernel#p: [T] (T) -> T`, and binding `T` would type `p(1)` as `1` when the
541
+ # real method returns a String.
542
+ #
543
+ # Two gates, cheapest first.
544
+ #
545
+ # 1. `scope` && `call_node` present, the same evidence surface {#record_void_recovery} reads.
546
+ # DIAGNOSED, not assumed: that fixture's `p(1)` reaches here through
547
+ # `MethodDispatcher#try_user_class_fallback`, which dispatches with `scope: nil, call_node:
548
+ # nil` — so the presence gate alone already declines every call routed through the Object /
549
+ # Kernel ancestor fallback, the path a class with no RBS of its own always takes.
550
+ # 2. The explicit redefinition probe, in the shape `KernelDispatch#user_redefined?` established.
551
+ # The presence gate does NOT cover the direct-dispatch spelling of the same hazard — a
552
+ # top-level `def p(x)` called from the top level resolves `Nominal[Object]` DIRECTLY, with
553
+ # scope and call node both live — so a discovered top-level def, or a discovered method on the
554
+ # resolved class itself, declines too. Over-wide by construction (a project class that also
555
+ # has RBS declines for its own self-sends, losing precision it could have kept), and
556
+ # deliberately so: the cost is a `Dynamic[top]` that was already there.
557
+ def arg_binding_permitted?(scope, call_node, call_site)
558
+ return false if scope.nil? || call_node.nil?
559
+
560
+ class_name, method_name, kind = call_site
561
+ return false if method_name.nil?
562
+ return false if scope.top_level_def_for(method_name)
563
+
564
+ !scope.discovered_method?(class_name, method_name, kind)
565
+ end
566
+
421
567
  def method_type_block_return_variable(method_type)
422
568
  return_variable = block_return_variable(method_type)
423
569
  return nil if return_variable.nil?
@@ -14,17 +14,38 @@ module Rigor
14
14
  # * `new(str)` / `new(str, opts)` — constructs a Regexp at fold time when the pattern argument is a
15
15
  # `Constant[String]`. The optional second argument may be a `Constant[Integer]` (flag bits), a
16
16
  # `Constant[true/false]` (IGNORECASE shorthand), or absent. Returns `Constant[Regexp]`.
17
+ # * `compile(str)` / `compile(str, opts)` — `Regexp.compile` is a documented alias of `Regexp.new`
18
+ # (same C entry point, `rb_reg_s_new`); shares `fold_new` verbatim.
19
+ # * `union(*patterns)` / `union(array)` — builds the combined pattern at inference time when every
20
+ # element is a `Constant[String]` or `Constant[Regexp]`. Delegates to the real `Regexp.union` so the
21
+ # zero-arg (`/(?!)/`), single-array-argument, and pre-built-Regexp-argument forms all match Ruby's
22
+ # own semantics without separate case analysis. Returns `Constant[Regexp]`.
23
+ # * `linear_time?(pattern)` — one `Constant[String]` or `Constant[Regexp]` argument. Returns
24
+ # `Constant[bool]`.
17
25
  #
18
26
  # === Non-constant / unsupported cases
19
27
  #
20
28
  # Returns `nil` (deferring to the next dispatcher tier) when:
21
29
  # - the receiver is not `Singleton[Regexp]`,
22
- # - the required pattern argument is not a `Constant[String]`,
30
+ # - the required pattern argument is not a `Constant[String]` (or, for `union`/`linear_time?`, not a
31
+ # `Constant[String]`/`Constant[Regexp]`),
32
+ # - `union` is given more elements than `UNION_LIMIT` or any element fails the constant check,
33
+ # - `linear_time?` is given anything other than exactly one constant argument (a `timeout:` keyword
34
+ # argument is rejected this way — it would land as a second positional slot),
23
35
  # - the method is not in the supported set.
24
36
  module RegexpFolding
25
37
  REGEXP_ESCAPE_METHODS = Set[:escape, :quote].freeze
26
38
  private_constant :REGEXP_ESCAPE_METHODS
27
39
 
40
+ # `.new` and `.compile` are the same C function under two names — both fold identically.
41
+ REGEXP_NEW_METHODS = Set[:new, :compile].freeze
42
+ private_constant :REGEXP_NEW_METHODS
43
+
44
+ # `Regexp.union` element cap — same rationale as `SHELLWORDS_SPLIT_LIMIT` / `STRING_ARRAY_LIFT_LIMIT`:
45
+ # keep the fold's work bounded even though the source is a static literal.
46
+ UNION_LIMIT = 64
47
+ private_constant :UNION_LIMIT
48
+
28
49
  module_function
29
50
 
30
51
  # @return [Rigor::Type, nil] folded result, or nil to defer.
@@ -34,8 +55,10 @@ module Rigor
34
55
  args = context.args
35
56
  return nil unless SingletonFolding.receiver?(receiver, "Regexp")
36
57
  return fold_escape(args) if REGEXP_ESCAPE_METHODS.include?(method_name)
37
- return fold_new(args) if method_name == :new
58
+ return fold_new(args) if REGEXP_NEW_METHODS.include?(method_name)
38
59
  return fold_last_match(context) if method_name == :last_match
60
+ return fold_union(args) if method_name == :union
61
+ return fold_linear_time(args) if method_name == :linear_time?
39
62
 
40
63
  nil
41
64
  end
@@ -111,9 +134,9 @@ module Rigor
111
134
  end
112
135
 
113
136
  # `Regexp.new(pattern)` / `Regexp.new(pattern, opts)` — constructs the pattern at inference time.
114
- # Delegates to Ruby's real `Regexp.new` so all option forms (Integer flags, `true`/`false`, option
115
- # strings) are handled without case-analysis; non-constant or invalid arguments decline through to
116
- # the RBS tier.
137
+ # Also serves `Regexp.compile`, its documented alias (`REGEXP_NEW_METHODS`). Delegates to Ruby's
138
+ # real `Regexp.new` so all option forms (Integer flags, `true`/`false`, option strings) are handled
139
+ # without case-analysis; non-constant or invalid arguments decline through to the RBS tier.
117
140
  def fold_new(args)
118
141
  return nil if args.empty? || args.size > 2
119
142
 
@@ -132,6 +155,42 @@ module Rigor
132
155
  def constant_value_or_nil(type)
133
156
  type.is_a?(Type::Constant) ? type.value : nil
134
157
  end
158
+
159
+ # `Regexp.union(*patterns)` / `Regexp.union(array)` — folds when every pattern element is a
160
+ # `Constant[String]` or `Constant[Regexp]`. A single `Tuple` argument is unpacked exactly like
161
+ # Ruby's own single-array-argument form (`Regexp.union(["a", "b"])`); anything else is treated as
162
+ # the splatted-arguments form, including zero arguments (`Regexp.union()` → `/(?!)/`). Delegates to
163
+ # the real `Regexp.union` so encoding handling, escaping of String elements, and pass-through of
164
+ # already-`Regexp` elements all match Ruby exactly; declines (rescues to `nil`) on an encoding
165
+ # conflict between elements rather than propagating the exception.
166
+ def fold_union(args)
167
+ pattern_args = args.size == 1 && args.first.is_a?(Type::Tuple) ? args.first.elements : args
168
+ return nil if pattern_args.size > UNION_LIMIT
169
+ return nil unless pattern_args.all? { |arg| union_element?(arg) }
170
+
171
+ Type::Combinator.constant_of(Regexp.union(*pattern_args.map(&:value)))
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
176
+ def union_element?(arg)
177
+ arg.is_a?(Type::Constant) && (arg.value.is_a?(String) || arg.value.is_a?(Regexp))
178
+ end
179
+
180
+ # `Regexp.linear_time?(pattern)` — one `Constant[String]` or `Constant[Regexp]` argument. A second
181
+ # argument (the `timeout:` keyword, which lands as an extra positional slot) declines rather than
182
+ # being silently ignored. An invalid String pattern raises `RegexpError` when Ruby compiles it
183
+ # internally; rescued to `nil` like every other fold in this file.
184
+ def fold_linear_time(args)
185
+ return nil unless args.size == 1
186
+
187
+ arg = args.first
188
+ return nil unless union_element?(arg)
189
+
190
+ Type::Combinator.constant_of(Regexp.linear_time?(arg.value))
191
+ rescue StandardError
192
+ nil
193
+ end
135
194
  end
136
195
  end
137
196
  end