rigortype 0.0.7 → 0.0.9

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +195 -21
  3. data/data/builtins/ruby_core/encoding.yml +210 -0
  4. data/data/builtins/ruby_core/exception.yml +641 -0
  5. data/data/builtins/ruby_core/numeric.yml +3 -2
  6. data/data/builtins/ruby_core/proc.yml +731 -0
  7. data/data/builtins/ruby_core/random.yml +166 -0
  8. data/data/builtins/ruby_core/re.yml +689 -0
  9. data/data/builtins/ruby_core/struct.yml +449 -0
  10. data/lib/rigor/analysis/diagnostic.rb +28 -2
  11. data/lib/rigor/analysis/runner.rb +19 -3
  12. data/lib/rigor/builtins/imported_refinements.rb +6 -1
  13. data/lib/rigor/cache/descriptor.rb +278 -0
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +63 -0
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +60 -0
  16. data/lib/rigor/cache/rbs_constant_table.rb +47 -0
  17. data/lib/rigor/cache/rbs_descriptor.rb +53 -0
  18. data/lib/rigor/cache/rbs_environment.rb +52 -0
  19. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +40 -0
  20. data/lib/rigor/cache/rbs_known_class_names.rb +43 -0
  21. data/lib/rigor/cache/store.rb +325 -0
  22. data/lib/rigor/cli.rb +88 -7
  23. data/lib/rigor/environment/rbs_hierarchy.rb +18 -5
  24. data/lib/rigor/environment/rbs_loader.rb +148 -25
  25. data/lib/rigor/environment.rb +11 -2
  26. data/lib/rigor/flow_contribution.rb +128 -0
  27. data/lib/rigor/inference/builtins/encoding_catalog.rb +67 -0
  28. data/lib/rigor/inference/builtins/exception_catalog.rb +92 -0
  29. data/lib/rigor/inference/builtins/proc_catalog.rb +122 -0
  30. data/lib/rigor/inference/builtins/random_catalog.rb +58 -0
  31. data/lib/rigor/inference/builtins/re_catalog.rb +81 -0
  32. data/lib/rigor/inference/builtins/struct_catalog.rb +55 -0
  33. data/lib/rigor/inference/expression_typer.rb +26 -1
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -1
  35. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +87 -0
  36. data/lib/rigor/inference/method_dispatcher.rb +2 -0
  37. data/lib/rigor/inference/narrowing.rb +29 -14
  38. data/lib/rigor/rbs_extended.rb +55 -0
  39. data/lib/rigor/type/combinator.rb +72 -0
  40. data/lib/rigor/type/refined.rb +50 -2
  41. data/lib/rigor/version.rb +1 -1
  42. data/lib/rigor.rb +9 -0
  43. data/sig/rigor.rbs +3 -1
  44. metadata +24 -1
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Random` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # The static classifier marks most Random methods `:leaf`
12
+ # because their C bodies do not call `rb_funcall*` /
13
+ # `rb_yield` / `rb_check_frozen` directly. Random is the
14
+ # canonical case where that heuristic under-counts: every
15
+ # call to `#rand` / `#bytes` / `Random.rand` / `Random.bytes`
16
+ # advances the receiver's Mersenne-Twister state through a
17
+ # helper (`rand_random` -> `random_real` / `random_ulong_limited`),
18
+ # so folding any of them statically is unsound.
19
+ # `Random.new_seed` and `Random.urandom` are non-deterministic
20
+ # (different output every call); even though they are
21
+ # functionally pure they would produce a misleading constant
22
+ # at fold time. The whole class is conservative-by-default
23
+ # at the catalog tier; precision flows through the RBS layer.
24
+ RANDOM_CATALOG = MethodCatalog.new(
25
+ path: File.expand_path(
26
+ "../../../../data/builtins/ruby_core/random.yml",
27
+ __dir__
28
+ ),
29
+ mutating_selectors: {
30
+ "Random" => Set[
31
+ # `rand_random` -> `random_real` / `random_ulong_limited`
32
+ # advance the MT state on the receiver (instance #rand)
33
+ # and on `Random::DEFAULT` (singleton .rand). The
34
+ # classifier misses the indirect mutator.
35
+ :rand,
36
+ # `random_bytes` / `random_s_bytes` consume MT output
37
+ # the same way #rand does — every call mutates the
38
+ # underlying generator.
39
+ :bytes,
40
+ # Non-deterministic: each call produces a fresh seed
41
+ # via `with_random_seed` reading platform entropy. Folding
42
+ # to a constant would freeze a value that the runtime
43
+ # never actually returns twice.
44
+ :new_seed,
45
+ # Non-deterministic: reads from platform CSPRNG (e.g.
46
+ # /dev/urandom). Folding is unsound for the same reason
47
+ # as `new_seed`.
48
+ :urandom,
49
+ # `initialize_copy` is blocklisted by convention so a
50
+ # hypothetical future `Constant<Random>` carrier
51
+ # cannot fold an aliasing copy through the catalog.
52
+ :initialize_copy
53
+ ]
54
+ }
55
+ )
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Regexp` / `MatchData` catalog. Singleton — load once,
9
+ # consult during dispatch.
10
+ #
11
+ # `Init_Regexp` in `references/ruby/re.c` registers BOTH
12
+ # classes in a single C init block, so the catalog carries
13
+ # both — `Regexp` (the pattern carrier) plus `MatchData`
14
+ # (the result-of-match carrier produced by `Regexp#match` /
15
+ # `String#match` and consulted via `$~`). The catalog wiring
16
+ # therefore mostly governs:
17
+ #
18
+ # 1. The reader surface on each class (`Regexp#source`,
19
+ # `Regexp#options`, `Regexp#casefold?`, `MatchData#size`,
20
+ # `MatchData#captures`, etc.) — RBS-declared returns are
21
+ # preserved through dispatch.
22
+ # 2. The blocklist below, which keeps methods that touch
23
+ # process-global state (the `$~` backref) from being
24
+ # folded. Regexp matching is observably stateful:
25
+ # `Regexp#=~`, `#===` and `#~` all call `rb_backref_set`
26
+ # (writing `$~` and the `$1..$N` / `$&` / `` $` `` / `$'`
27
+ # aliases). A constant-fold that dropped those calls
28
+ # would silently change the visible state of the program,
29
+ # so they MUST decline through to the RBS tier.
30
+ #
31
+ # `Regexp.last_match` and `Regexp.timeout` / `Regexp.timeout=`
32
+ # are class-level (singleton) methods that also touch
33
+ # process-global state, but the dispatcher's catalog lookup
34
+ # only consults `:instance` entries today — class-method calls
35
+ # on a `Singleton` receiver type take the `meta_*` path in
36
+ # `MethodDispatcher` rather than walking `CATALOG_BY_CLASS` —
37
+ # so listing them here would be dead code. Their RBS-tier
38
+ # signatures already widen the answer enough to keep the
39
+ # behaviour sound; revisit if the dispatcher ever grows a
40
+ # singleton-aware catalog path.
41
+ REGEXP_CATALOG = MethodCatalog.new(
42
+ path: File.expand_path(
43
+ "../../../../data/builtins/ruby_core/re.yml",
44
+ __dir__
45
+ ),
46
+ mutating_selectors: {
47
+ "Regexp" => Set[
48
+ # Defensive: aliasing-copy semantics already covered
49
+ # by the `:mutates_self` classifier, listed here for
50
+ # symmetry with String / Array / Hash / Range / Set.
51
+ :initialize_copy,
52
+ # `=~`, `===`, `~` all run `rb_reg_search` (or call
53
+ # `rb_backref_set(Qnil)` directly) — every successful
54
+ # OR failing match writes `$~` and the
55
+ # `$1..$N` / `$&` / `` $` `` / `$'` aliases. Folding
56
+ # would discard the visible side effect.
57
+ :=~,
58
+ :"===",
59
+ :~,
60
+ # `match` is already `:block_dependent` (the C body
61
+ # yields), but it ALSO writes `$~` regardless of the
62
+ # block. Listed here so a future extractor that
63
+ # reclassifies it as `:leaf` (because the yield is
64
+ # behind a helper) does not silently fold it.
65
+ :match
66
+ ],
67
+ "MatchData" => Set[
68
+ # Defensive entry mirroring the other catalogs.
69
+ # `match_init_copy` is already `:leaf` per the
70
+ # extractor (it copies the regs slot in place but
71
+ # uses no helper the C-body regex flags as a
72
+ # mutator); blocked so a future
73
+ # `Constant<MatchData>` carrier never folds an
74
+ # aliasing copy through the catalog.
75
+ :initialize_copy
76
+ ]
77
+ }
78
+ )
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Struct` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # `Struct` is a meta-class: `Struct.new(*members)` returns a
12
+ # fresh anonymous subclass — never a `Struct` value. Today
13
+ # Rigor never produces a `Constant<Struct>` carrier (a literal
14
+ # struct instance), so the catalog is defensive: it documents
15
+ # the shape and forbids unsafe folds in case a future tier
16
+ # learns to lift literal struct instances into the value
17
+ # lattice.
18
+ #
19
+ # Subclasses define their own writers (`name=`) at class-build
20
+ # time, so per-instance member accessors do not appear in this
21
+ # YAML — only the generic `[]` / `[]=` pair on the base class.
22
+ # `[]=` is already classified `:mutates_self`; `[]` reads a
23
+ # member but the answer depends on the subclass's member
24
+ # definition, which the catalog does not see, so we blocklist
25
+ # it defensively.
26
+ STRUCT_CATALOG = MethodCatalog.new(
27
+ path: File.expand_path(
28
+ "../../../../data/builtins/ruby_core/struct.yml",
29
+ __dir__
30
+ ),
31
+ mutating_selectors: {
32
+ "Struct" => Set[
33
+ # Defensive: aliasing-copy semantics on a hypothetical
34
+ # `Constant<Struct>` carrier. Convention across the
35
+ # other catalogs (Range, Random, Pathname).
36
+ :initialize_copy,
37
+ # `rb_struct_hash` mixes member values via
38
+ # `rb_hash` -> `rb_funcall(:hash, ...)`. The classifier
39
+ # sees no direct dispatch because the recursion goes
40
+ # through `rb_hash` (a helper), but the answer depends
41
+ # on the member values' `#hash` — user-redefinable.
42
+ # Block to avoid folding a hash that would diverge
43
+ # from the runtime once a member overrides `#hash`.
44
+ :hash,
45
+ # `rb_struct_aref` reads a member by name or index; the
46
+ # answer depends on the subclass's member layout, which
47
+ # the catalog does not carry. Folding without knowing
48
+ # the layout would be unsound.
49
+ :[]
50
+ ]
51
+ }
52
+ )
53
+ end
54
+ end
55
+ end
@@ -472,10 +472,35 @@ module Rigor
472
472
  [keys, values]
473
473
  end
474
474
 
475
- def type_of_interpolated_string(_node)
475
+ # An interpolated string `"#{a}b#{c}"` is `literal-string`
476
+ # when every part contributes literal-bearing material:
477
+ # plain text segments are literal by construction, embedded
478
+ # expressions count when their type is itself literal-string-
479
+ # compatible (a `Constant<String>`, the `literal-string`
480
+ # carrier, an `Intersection` containing it, or a `Union`
481
+ # whose members all qualify). Otherwise the result widens to
482
+ # plain `Nominal[String]` as before.
483
+ def type_of_interpolated_string(node)
484
+ return Type::Combinator.literal_string if interpolation_parts_literal?(node.parts)
485
+
476
486
  Type::Combinator.nominal_of(String)
477
487
  end
478
488
 
489
+ def interpolation_parts_literal?(parts)
490
+ parts.all? { |part| interpolation_part_literal?(part) }
491
+ end
492
+
493
+ def interpolation_part_literal?(part)
494
+ case part
495
+ when Prism::StringNode
496
+ true
497
+ when Prism::EmbeddedStatementsNode, Prism::EmbeddedVariableNode
498
+ Type::Combinator.literal_string_compatible?(type_of(part))
499
+ else
500
+ false
501
+ end
502
+ end
503
+
479
504
  def type_of_interpolated_symbol(_node)
480
505
  Type::Combinator.nominal_of(Symbol)
481
506
  end
@@ -14,6 +14,12 @@ require_relative "../builtins/enumerable_catalog"
14
14
  require_relative "../builtins/rational_catalog"
15
15
  require_relative "../builtins/complex_catalog"
16
16
  require_relative "../builtins/pathname_catalog"
17
+ require_relative "../builtins/random_catalog"
18
+ require_relative "../builtins/struct_catalog"
19
+ require_relative "../builtins/encoding_catalog"
20
+ require_relative "../builtins/re_catalog"
21
+ require_relative "../builtins/proc_catalog"
22
+ require_relative "../builtins/exception_catalog"
17
23
 
18
24
  module Rigor
19
25
  module Inference
@@ -1077,7 +1083,16 @@ module Rigor
1077
1083
  [Date, [Builtins::DATE_CATALOG, "Date"]],
1078
1084
  [Rational, [Builtins::RATIONAL_CATALOG, "Rational"]],
1079
1085
  [Complex, [Builtins::COMPLEX_CATALOG, "Complex"]],
1080
- [Pathname, [Builtins::PATHNAME_CATALOG, "Pathname"]]
1086
+ [Pathname, [Builtins::PATHNAME_CATALOG, "Pathname"]],
1087
+ [Random, [Builtins::RANDOM_CATALOG, "Random"]],
1088
+ [Struct, [Builtins::STRUCT_CATALOG, "Struct"]],
1089
+ [Encoding, [Builtins::ENCODING_CATALOG, "Encoding"]],
1090
+ [Regexp, [Builtins::REGEXP_CATALOG, "Regexp"]],
1091
+ [MatchData, [Builtins::REGEXP_CATALOG, "MatchData"]],
1092
+ [Proc, [Builtins::PROC_CATALOG, "Proc"]],
1093
+ [Method, [Builtins::PROC_CATALOG, "Method"]],
1094
+ [UnboundMethod, [Builtins::PROC_CATALOG, "UnboundMethod"]],
1095
+ [Exception, [Builtins::EXCEPTION_CATALOG, "Exception"]]
1081
1096
  ].freeze
1082
1097
  private_constant :CATALOG_BY_CLASS
1083
1098
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Dispatcher tier that lifts string-composition results into
9
+ # the `literal-string` carrier when every operand is itself
10
+ # literal-bearing. Sits between {ConstantFolding} (which
11
+ # handles all-Constant cases) and {ShapeDispatch}; runs only
12
+ # for `String#+` / `String#*` / `String#<<` / `String#concat`
13
+ # calls whose inputs the ConstantFolding tier could not fold
14
+ # to a precise `Constant<String>` (e.g. one operand is
15
+ # `literal-string` rather than `Constant<String>`, or the
16
+ # multiplication exceeds the constant-fold size cap).
17
+ #
18
+ # Result rule:
19
+ #
20
+ # - `+`, `<<`, `concat`: receiver and argument MUST both be
21
+ # `Type::Combinator.literal_string_compatible?`. The result
22
+ # is `literal-string`. `<<` and `concat` mutate the
23
+ # receiver at runtime; the analyzer does not track that
24
+ # mutation against the local's binding, but the call's
25
+ # *return value* is the receiver itself, and the receiver
26
+ # stays literal-bearing because every appended slice was
27
+ # literal-bearing too.
28
+ # - `*`: receiver MUST be literal-bearing; argument MUST be
29
+ # integer-typed. The result is `literal-string`.
30
+ #
31
+ # Other receiver / argument shapes decline so the next tier
32
+ # (ShapeDispatch / FileFolding / RbsDispatch) takes over and
33
+ # the call site widens to the RBS-declared `Nominal[String]`
34
+ # as before.
35
+ module LiteralStringFolding
36
+ module_function
37
+
38
+ CONCAT_METHODS = %i[+ << concat].freeze
39
+ private_constant :CONCAT_METHODS
40
+
41
+ def try_dispatch(receiver:, method_name:, args:, **)
42
+ return nil unless Type::Combinator.literal_string_compatible?(receiver)
43
+ return nil unless args.size == 1
44
+
45
+ if CONCAT_METHODS.include?(method_name)
46
+ fold_concat(args.first)
47
+ elsif method_name == :*
48
+ fold_repeat(args.first)
49
+ end
50
+ end
51
+
52
+ def fold_concat(arg_type)
53
+ return nil unless Type::Combinator.literal_string_compatible?(arg_type)
54
+
55
+ Type::Combinator.literal_string
56
+ end
57
+
58
+ def fold_repeat(arg_type)
59
+ return nil unless integer_typed?(arg_type)
60
+ return nil if known_negative_integer?(arg_type)
61
+
62
+ Type::Combinator.literal_string
63
+ end
64
+
65
+ def integer_typed?(type)
66
+ case type
67
+ when Type::Constant then type.value.is_a?(Integer)
68
+ when Type::Nominal then type.class_name == "Integer"
69
+ when Type::IntegerRange then true
70
+ else false
71
+ end
72
+ end
73
+
74
+ # `String#*` raises ArgumentError on a negative multiplier, so a
75
+ # `Constant<-1>` argument is not a valid lift target. Decline so
76
+ # the call site keeps the existing nil-result behaviour rather
77
+ # than promising a `literal-string` value that could never
78
+ # exist at runtime.
79
+ def known_negative_integer?(type)
80
+ type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.negative?
81
+ end
82
+
83
+ private_class_method :fold_concat, :fold_repeat, :integer_typed?
84
+ end
85
+ end
86
+ end
87
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../reflection"
4
4
  require_relative "../type"
5
5
  require_relative "method_dispatcher/constant_folding"
6
+ require_relative "method_dispatcher/literal_string_folding"
6
7
  require_relative "method_dispatcher/shape_dispatch"
7
8
  require_relative "method_dispatcher/rbs_dispatch"
8
9
  require_relative "method_dispatcher/iterator_dispatch"
@@ -101,6 +102,7 @@ module Rigor
101
102
  return meta_result if meta_result
102
103
 
103
104
  ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
105
+ LiteralStringFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
104
106
  ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
105
107
  FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
106
108
  KernelDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
@@ -539,26 +539,41 @@ module Rigor
539
539
  # downstream narrowing knows the refinement subset is
540
540
  # excluded.
541
541
  #
542
- # The result is sound but imprecise: without a
543
- # complementary carrier (e.g. `mixed-case-string` for
544
- # `~lowercase-string`) we cannot enumerate the surviving
545
- # values. Difference is the carrier-of-last-resort, and
546
- # the existing `Type::Difference` consumers already
547
- # treat it correctly.
542
+ # v0.0.9 when the predicate has a registered
543
+ # complement (see {Type::Refined::COMPLEMENT_PAIRS}) and
544
+ # the part is exactly the refinement's base, the
545
+ # narrowing returns `Refined[base, complement_predicate]`
546
+ # instead of `Difference[base, refined]`. This is the
547
+ # `~T` symmetry the spec promises: `~lowercase-string`
548
+ # narrows `String` to `non-lowercase-string` rather than
549
+ # `Difference[String, lowercase-string]`.
550
+ #
551
+ # Predicates without a registered complement still fall
552
+ # back to the imprecise but sound `Difference[part,
553
+ # refined]` carrier so behaviour is unchanged for
554
+ # untouched call sites.
548
555
  def complement_refined(current_type, refined)
549
- base = refined.base
556
+ complement = registered_complement_for(refined)
550
557
  parts = current_type.is_a?(Type::Union) ? current_type.members : [current_type]
558
+ survivors = parts.filter_map { |part| complement_refined_part(part, refined, complement) }
559
+ return current_type if survivors.empty?
560
+
561
+ Type::Combinator.union(*survivors)
562
+ end
551
563
 
552
- survivors = parts.map do |part|
553
- next nil if part == refined
554
- next part if base_disjoint?(base, part)
564
+ def complement_refined_part(part, refined, complement)
565
+ return nil if part == refined
566
+ return part if base_disjoint?(refined.base, part)
567
+ return complement if complement && part == refined.base
555
568
 
556
- Type::Combinator.difference(part, refined)
557
- end.compact
569
+ Type::Combinator.difference(part, refined)
570
+ end
558
571
 
559
- return current_type if survivors.empty?
572
+ def registered_complement_for(refined)
573
+ complement_id = refined.complement_predicate_id
574
+ return nil if complement_id.nil?
560
575
 
561
- Type::Combinator.union(*survivors)
576
+ Type::Combinator.refined(refined.base, complement_id)
562
577
  end
563
578
 
564
579
  def falsey_value?(value)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "type"
4
4
  require_relative "builtins/imported_refinements"
5
+ require_relative "flow_contribution"
5
6
 
6
7
  module Rigor
7
8
  # Slice 7 phase 15 — first-preview reader for the
@@ -410,5 +411,59 @@ module Rigor
410
411
 
411
412
  ParamOverride.new(param_name: match[:param].to_sym, type: type)
412
413
  end
414
+
415
+ # The shared {Rigor::FlowContribution::Provenance} for every
416
+ # bundle this module produces. `source_family: :rbs_extended`
417
+ # so consumers (today the documentation surface; v0.1.0 the
418
+ # plugin contribution merger) can attribute facts back to the
419
+ # RBS::Extended layer.
420
+ RBS_EXTENDED_PROVENANCE = FlowContribution::Provenance.new(
421
+ source_family: :rbs_extended,
422
+ plugin_id: nil,
423
+ node: nil,
424
+ descriptor: nil
425
+ ).freeze
426
+
427
+ # Rolls up every recognised RBS::Extended directive on
428
+ # `method_def` into a single {Rigor::FlowContribution}:
429
+ #
430
+ # - `predicate-if-true` → `truthy_facts` (`PredicateEffect`s)
431
+ # - `predicate-if-false` → `falsey_facts` (`PredicateEffect`s)
432
+ # - `assert*` → `post_return_facts` (`AssertEffect`s)
433
+ # - `return:` override → `return_type` (`Rigor::Type`)
434
+ #
435
+ # Param overrides are intentionally NOT included — they refine
436
+ # the call's signature contract rather than its flow facts and
437
+ # do not fit ADR-2 § "Flow Contribution Bundle" slot semantics.
438
+ # Callers that care about parameter contracts keep using
439
+ # {.read_param_type_overrides} / {.param_type_override_map}.
440
+ #
441
+ # Returns `nil` when the method carries no recognised
442
+ # contribution directives (callers can skip the merge step
443
+ # without iterating an empty bundle).
444
+ def read_flow_contribution(method_def)
445
+ return nil if method_def.nil?
446
+
447
+ predicate_effects = read_predicate_effects(method_def)
448
+ assert_effects = read_assert_effects(method_def)
449
+ return_override = read_return_type_override(method_def)
450
+ return nil if predicate_effects.empty? && assert_effects.empty? && return_override.nil?
451
+
452
+ build_flow_contribution(predicate_effects, assert_effects, return_override)
453
+ end
454
+
455
+ def build_flow_contribution(predicate_effects, assert_effects, return_override)
456
+ FlowContribution.new(
457
+ return_type: return_override,
458
+ truthy_facts: nilable_slot(predicate_effects.select(&:truthy_only?)),
459
+ falsey_facts: nilable_slot(predicate_effects.select(&:falsey_only?)),
460
+ post_return_facts: nilable_slot(assert_effects),
461
+ provenance: RBS_EXTENDED_PROVENANCE
462
+ )
463
+ end
464
+
465
+ def nilable_slot(facts)
466
+ facts.empty? ? nil : facts
467
+ end
413
468
  end
414
469
  end
@@ -149,14 +149,40 @@ module Rigor
149
149
  Refined.new(nominal_of("String"), :lowercase)
150
150
  end
151
151
 
152
+ # Complement of `lowercase-string`: a `String` with at least
153
+ # one non-lowercase character (i.e. `v != v.downcase`).
154
+ # Registered as the paired complement of
155
+ # `:lowercase` in {Refined::COMPLEMENT_PAIRS} so
156
+ # `~lowercase-string` narrows to this carrier instead of
157
+ # falling back to `Difference[String, lowercase-string]`.
158
+ def non_lowercase_string
159
+ Refined.new(nominal_of("String"), :not_lowercase)
160
+ end
161
+
152
162
  def uppercase_string
153
163
  Refined.new(nominal_of("String"), :uppercase)
154
164
  end
155
165
 
166
+ # Complement of `uppercase-string`: a `String` with at least
167
+ # one non-uppercase character. Paired with `:uppercase` in
168
+ # {Refined::COMPLEMENT_PAIRS}.
169
+ def non_uppercase_string
170
+ Refined.new(nominal_of("String"), :not_uppercase)
171
+ end
172
+
156
173
  def numeric_string
157
174
  Refined.new(nominal_of("String"), :numeric)
158
175
  end
159
176
 
177
+ # Complement of `numeric-string`: a `String` that is not
178
+ # accepted by Rigor's Ruby numeric-string predicate
179
+ # (contains at least one non-digit, has a malformed numeric
180
+ # form, etc.). Paired with `:numeric` in
181
+ # {Refined::COMPLEMENT_PAIRS}.
182
+ def non_numeric_string
183
+ Refined.new(nominal_of("String"), :not_numeric)
184
+ end
185
+
160
186
  def decimal_int_string
161
187
  Refined.new(nominal_of("String"), :decimal_int)
162
188
  end
@@ -169,6 +195,52 @@ module Rigor
169
195
  Refined.new(nominal_of("String"), :hex_int)
170
196
  end
171
197
 
198
+ # `literal-string` — a `String` that is statically known to
199
+ # come from a source-code literal (or a composition of
200
+ # literals). v0.0.9 tracks this flow through interpolation
201
+ # `"#{...}"`, leaving propagation through `+` / `<<` to a
202
+ # later slice. Every `Constant<String>` is implicitly
203
+ # literal-string-compatible; the carrier exists for cases
204
+ # where the concrete value is unknown but literal-ness has
205
+ # been established (an RBS::Extended `return: literal-string`
206
+ # annotation, or interpolation over literal-bearing parts).
207
+ def literal_string
208
+ Refined.new(nominal_of("String"), :literal_string)
209
+ end
210
+
211
+ # `non-empty-literal-string` = `non-empty-string ∩ literal-string`.
212
+ # Composes the point-removal half (`Difference[String, ""]`)
213
+ # with the predicate-subset half. Both members erase to
214
+ # `String`.
215
+ def non_empty_literal_string
216
+ intersection(non_empty_string, literal_string)
217
+ end
218
+
219
+ # Recognises the carriers that participate in literal-string
220
+ # flow tracking: any `Constant<String>` (constants are literal
221
+ # by construction), the `literal-string` Refined carrier, an
222
+ # `Intersection` containing `literal-string`, or a `Union`
223
+ # whose every member qualifies. Used by
224
+ # `ExpressionTyper#type_of_interpolated_string` and the
225
+ # `LiteralStringFolding` dispatcher tier so propagation
226
+ # through interpolation and `+`/`*` composition stays
227
+ # consistent.
228
+ def literal_string_compatible?(type)
229
+ case type
230
+ when Constant then type.value.is_a?(String)
231
+ when Refined then literal_string_carrier?(type)
232
+ when Intersection then type.members.any? { |m| literal_string_compatible?(m) }
233
+ when Union then type.members.all? { |m| literal_string_compatible?(m) }
234
+ else false
235
+ end
236
+ end
237
+
238
+ def literal_string_carrier?(refined)
239
+ refined.predicate_id == :literal_string &&
240
+ refined.base.is_a?(Nominal) &&
241
+ refined.base.class_name == "String"
242
+ end
243
+
172
244
  # Normalised intersection. Flattens nested Intersections,
173
245
  # drops `Top` members, collapses to `Bot` if any member is
174
246
  # `Bot`, deduplicates structurally-equal members, sorts the
@@ -141,11 +141,24 @@ module Rigor
141
141
 
142
142
  PREDICATES = {
143
143
  lowercase: ->(v) { v.is_a?(String) && v == v.downcase },
144
+ not_lowercase: ->(v) { v.is_a?(String) && v != v.downcase },
144
145
  uppercase: ->(v) { v.is_a?(String) && v == v.upcase },
146
+ not_uppercase: ->(v) { v.is_a?(String) && v != v.upcase },
145
147
  numeric: ->(v) { v.is_a?(String) && NUMERIC_STRING_PATTERN.match?(v) },
148
+ not_numeric: ->(v) { v.is_a?(String) && !NUMERIC_STRING_PATTERN.match?(v) },
146
149
  decimal_int: ->(v) { v.is_a?(String) && DECIMAL_INT_STRING_PATTERN.match?(v) },
147
150
  octal_int: ->(v) { v.is_a?(String) && OCTAL_INT_STRING_PATTERN.match?(v) },
148
- hex_int: ->(v) { v.is_a?(String) && HEX_INT_STRING_PATTERN.match?(v) }
151
+ hex_int: ->(v) { v.is_a?(String) && HEX_INT_STRING_PATTERN.match?(v) },
152
+ # `literal-string` is a flow-tracked predicate, not a value-
153
+ # level predicate: a String is literal-string when it is
154
+ # known to come from a source-code literal (or composition
155
+ # of literals). Every concrete `Constant<String>` is
156
+ # already literal by construction, so the inspection
157
+ # recogniser returns true for any String — the property is
158
+ # really tracked in the flow analysis (interpolation,
159
+ # concatenation, RBS::Extended `return: literal-string`)
160
+ # rather than recovered by inspecting an arbitrary string.
161
+ literal_string: ->(v) { v.is_a?(String) }
149
162
  }.freeze
150
163
 
151
164
  # Maps `[base_class_name, predicate_id]` pairs to their
@@ -154,14 +167,49 @@ module Rigor
154
167
  # to the operator form.
155
168
  CANONICAL_NAMES = {
156
169
  ["String", :lowercase] => "lowercase-string",
170
+ ["String", :not_lowercase] => "non-lowercase-string",
157
171
  ["String", :uppercase] => "uppercase-string",
172
+ ["String", :not_uppercase] => "non-uppercase-string",
158
173
  ["String", :numeric] => "numeric-string",
174
+ ["String", :not_numeric] => "non-numeric-string",
159
175
  ["String", :decimal_int] => "decimal-int-string",
160
176
  ["String", :octal_int] => "octal-int-string",
161
- ["String", :hex_int] => "hex-int-string"
177
+ ["String", :hex_int] => "hex-int-string",
178
+ ["String", :literal_string] => "literal-string"
162
179
  }.freeze
163
180
  private_constant :CANONICAL_NAMES
164
181
 
182
+ # Bidirectional `predicate_id ↔ complement_predicate_id`
183
+ # registry. `~Refined[base, p]` narrows to
184
+ # `Refined[base, COMPLEMENT_PAIRS[p]]` when the part is the
185
+ # refinement's base — the precise carrier the spec promises
186
+ # under the `~T` operator. Predicates without a registered
187
+ # complement fall back to the imprecise but sound
188
+ # `Difference[part, refined]` carrier from the existing
189
+ # narrowing rule.
190
+ #
191
+ # Adding a new pair here is an additive change: register the
192
+ # complement predicate in {PREDICATES}, give it a kebab-case
193
+ # canonical name in {CANONICAL_NAMES}, and add the bidirectional
194
+ # entry below. No call site needs to know about the new pair —
195
+ # `complement_refined` consults this map and routes through
196
+ # the registered complement automatically.
197
+ COMPLEMENT_PAIRS = {
198
+ lowercase: :not_lowercase,
199
+ not_lowercase: :lowercase,
200
+ uppercase: :not_uppercase,
201
+ not_uppercase: :uppercase,
202
+ numeric: :not_numeric,
203
+ not_numeric: :numeric
204
+ }.freeze
205
+ private_constant :COMPLEMENT_PAIRS
206
+
207
+ # @return [Symbol, nil] the registered complement predicate
208
+ # id, or nil when no pair is registered for this predicate.
209
+ def complement_predicate_id
210
+ COMPLEMENT_PAIRS[predicate_id]
211
+ end
212
+
165
213
  private
166
214
 
167
215
  def canonical_name
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.0.7"
4
+ VERSION = "0.0.9"
5
5
  end