rigortype 0.0.3 → 0.0.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +215 -117
  3. data/data/builtins/ruby_core/comparable.yml +87 -0
  4. data/data/builtins/ruby_core/complex.yml +505 -0
  5. data/data/builtins/ruby_core/date.yml +1737 -0
  6. data/data/builtins/ruby_core/enumerable.yml +557 -0
  7. data/data/builtins/ruby_core/file.yml +9 -0
  8. data/data/builtins/ruby_core/hash.yml +936 -0
  9. data/data/builtins/ruby_core/range.yml +389 -0
  10. data/data/builtins/ruby_core/rational.yml +365 -0
  11. data/data/builtins/ruby_core/set.yml +594 -0
  12. data/data/builtins/ruby_core/string.yml +9 -0
  13. data/data/builtins/ruby_core/time.yml +752 -0
  14. data/lib/rigor/analysis/check_rules.rb +11 -3
  15. data/lib/rigor/builtins/imported_refinements.rb +192 -10
  16. data/lib/rigor/cli.rb +1 -1
  17. data/lib/rigor/inference/acceptance.rb +181 -12
  18. data/lib/rigor/inference/builtins/comparable_catalog.rb +27 -0
  19. data/lib/rigor/inference/builtins/complex_catalog.rb +41 -0
  20. data/lib/rigor/inference/builtins/date_catalog.rb +98 -0
  21. data/lib/rigor/inference/builtins/enumerable_catalog.rb +27 -0
  22. data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
  23. data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
  24. data/lib/rigor/inference/builtins/rational_catalog.rb +38 -0
  25. data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
  26. data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +145 -11
  28. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +202 -1
  29. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +95 -0
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +135 -6
  32. data/lib/rigor/inference/method_dispatcher.rb +3 -1
  33. data/lib/rigor/inference/method_parameter_binder.rb +29 -4
  34. data/lib/rigor/inference/narrowing.rb +211 -0
  35. data/lib/rigor/inference/scope_indexer.rb +87 -11
  36. data/lib/rigor/inference/statement_evaluator.rb +6 -0
  37. data/lib/rigor/rbs_extended.rb +170 -14
  38. data/lib/rigor/type/combinator.rb +90 -0
  39. data/lib/rigor/type/integer_range.rb +4 -2
  40. data/lib/rigor/type/intersection.rb +135 -0
  41. data/lib/rigor/type/refined.rb +174 -0
  42. data/lib/rigor/type.rb +2 -0
  43. data/lib/rigor/version.rb +1 -1
  44. data/sig/rigor/environment.rbs +4 -6
  45. data/sig/rigor/inference.rbs +2 -1
  46. data/sig/rigor/rbs_extended.rbs +11 -0
  47. data/sig/rigor/type.rbs +75 -35
  48. metadata +22 -1
@@ -241,6 +241,55 @@ module Rigor
241
241
  narrow_class_dispatch(type, class_name, context)
242
242
  end
243
243
 
244
+ # Negation pair for `assert_value is ~refinement` /
245
+ # `predicate-if-* … is ~refinement` directives. Computes
246
+ # the complement of `refinement` within the current
247
+ # local's domain `current_type`.
248
+ #
249
+ # Carrier-by-carrier rules:
250
+ #
251
+ # - `Difference[base, Constant[v]]`. Complement of
252
+ # `base \ {v}` within `current_type`. Walk the current
253
+ # type's union members, keep each part disjoint from
254
+ # `base`, and add the removed-value Constant once when
255
+ # any current member covers it. `assert s is
256
+ # ~non-empty-string` over `s: String | nil` narrows to
257
+ # `Constant[""] | NilClass`.
258
+ # - `IntegerRange[a, b]` (v0.0.5+ slice). Complement is
259
+ # the two open halves `int<min, a-1>` and
260
+ # `int<b+1, max>`, each intersected with the
261
+ # integer-domain parts of `current_type`. Non-integer
262
+ # parts (nil, String, …) of a Union receiver survive
263
+ # unchanged. `assert n is ~int<5, 10>` over `n:
264
+ # Integer | nil` narrows to `int<min, 4> | int<11,
265
+ # max> | NilClass`.
266
+ # - `Type::Intersection[M1, M2, …]` (v0.0.5+ slice). De
267
+ # Morgan: `D \ (M1 ∩ M2) = (D \ M1) ∪ (D \ M2)`. Each
268
+ # member's complement is computed independently within
269
+ # `current_type` and the results are unioned. Members
270
+ # the algebra cannot complement (Refined, non-Constant
271
+ # Difference, …) contribute `current_type` itself, so
272
+ # the union widens the answer to `current_type` —
273
+ # sound but imprecise.
274
+ # - `Refined[base, predicate]`. Predicate complements are
275
+ # not reducible to a finite carrier without a richer
276
+ # shape (e.g. `~lowercase-string` is "uppercase OR
277
+ # mixed-case"); `current_type` is returned unchanged.
278
+ def narrow_not_refinement(current_type, refinement_type)
279
+ case refinement_type
280
+ when Type::Difference
281
+ return current_type unless refinement_type.removed.is_a?(Type::Constant)
282
+
283
+ complement_difference(current_type, refinement_type)
284
+ when Type::IntegerRange
285
+ complement_integer_range(current_type, refinement_type)
286
+ when Type::Intersection
287
+ complement_intersection(current_type, refinement_type)
288
+ else
289
+ current_type
290
+ end
291
+ end
292
+
244
293
  # Public predicate analyser. Returns `[truthy_scope, falsey_scope]`,
245
294
  # always; when no narrowing rule matches the predicate node both
246
295
  # entries are the receiver scope unchanged.
@@ -321,6 +370,162 @@ module Rigor
321
370
  class << self
322
371
  private
323
372
 
373
+ # Complement of `Difference[base, Constant[v]]` within
374
+ # `current_type`. Walks the current type's union members,
375
+ # keeps each member disjoint from `base` (those values
376
+ # were never in the refinement to begin with), and adds
377
+ # the removed-value `Constant[v]` exactly once when any
378
+ # current member covers it. Members that are fully
379
+ # contained in the refinement (i.e. inside `base` and
380
+ # NOT equal to the removed value) are dropped — they are
381
+ # exactly the values the negation excludes.
382
+ def complement_difference(current_type, difference)
383
+ base = difference.base
384
+ removed = difference.removed
385
+ parts = current_type.is_a?(Type::Union) ? current_type.members : [current_type]
386
+
387
+ survivors = []
388
+ add_removed = false
389
+ parts.each do |part|
390
+ if base_disjoint?(base, part)
391
+ survivors << part
392
+ elsif part_covers_constant?(part, removed)
393
+ add_removed = true
394
+ end
395
+ end
396
+ survivors << removed if add_removed
397
+
398
+ return current_type if survivors.empty?
399
+
400
+ Type::Combinator.union(*survivors)
401
+ end
402
+
403
+ def base_disjoint?(base, part)
404
+ base.accepts(part, mode: :gradual).no?
405
+ end
406
+
407
+ def part_covers_constant?(part, constant)
408
+ result = part.accepts(constant, mode: :gradual)
409
+ result.yes? || result.maybe?
410
+ end
411
+
412
+ # Complement of an `IntegerRange[a, b]` within
413
+ # `current_type`. Splits the range complement into the
414
+ # two open halves `int<min, a-1>` and `int<b+1, max>`
415
+ # (skipping a half when its bound is infinity), then
416
+ # intersects each half with the integer-domain parts of
417
+ # `current_type`. Non-integer parts of a Union receiver
418
+ # (nil, String, …) survive unchanged.
419
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
420
+ def complement_integer_range(current_type, range)
421
+ halves = integer_range_complement_halves(range)
422
+ parts = current_type.is_a?(Type::Union) ? current_type.members : [current_type]
423
+
424
+ survivors = []
425
+ parts.each do |part|
426
+ if integer_member?(part)
427
+ halves.each do |half|
428
+ meet = intersect_integer_part(part, half)
429
+ survivors << meet unless meet.nil? || meet.is_a?(Type::Bot)
430
+ end
431
+ else
432
+ survivors << part
433
+ end
434
+ end
435
+
436
+ return current_type if survivors.empty?
437
+
438
+ Type::Combinator.union(*survivors)
439
+ end
440
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
441
+
442
+ # Returns the two open halves of an IntegerRange's
443
+ # complement: the left half `int<-∞, a-1>` (when `a` is
444
+ # finite) and the right half `int<b+1, ∞>` (when `b` is
445
+ # finite). Universal ranges (both bounds infinite) yield
446
+ # an empty array — the complement is empty.
447
+ def integer_range_complement_halves(range)
448
+ halves = []
449
+ left_max = range.min
450
+ right_min = range.max
451
+
452
+ if left_max.is_a?(Integer)
453
+ halves << Type::Combinator.integer_range(Type::IntegerRange::NEG_INFINITY, left_max - 1)
454
+ end
455
+ if right_min.is_a?(Integer)
456
+ halves << Type::Combinator.integer_range(right_min + 1, Type::IntegerRange::POS_INFINITY)
457
+ end
458
+ halves
459
+ end
460
+
461
+ def integer_member?(part)
462
+ case part
463
+ when Type::Constant then part.value.is_a?(Integer)
464
+ when Type::IntegerRange then true
465
+ when Type::Nominal then part.class_name == "Integer"
466
+ else false
467
+ end
468
+ end
469
+
470
+ # Intersect an integer-domain part with a complement
471
+ # half-range. For a Nominal[Integer] receiver the meet
472
+ # is the half itself; for an existing IntegerRange the
473
+ # meet narrows both bounds; for a Constant[Integer] the
474
+ # meet is the constant when the half covers it,
475
+ # otherwise nil.
476
+ def intersect_integer_part(part, half)
477
+ case part
478
+ when Type::Nominal
479
+ half
480
+ when Type::IntegerRange
481
+ integer_range_meet(part, half)
482
+ when Type::Constant
483
+ half.covers?(part.value) ? part : nil
484
+ end
485
+ end
486
+
487
+ def integer_range_meet(left, right)
488
+ low = numeric_to_bound([integer_bound_value(left.min), integer_bound_value(right.min)].max)
489
+ high = numeric_to_bound([integer_bound_value(left.max), integer_bound_value(right.max)].min)
490
+ return nil if integer_range_disjoint?(low, high)
491
+
492
+ Type::Combinator.integer_range(low, high)
493
+ end
494
+
495
+ def integer_bound_value(bound)
496
+ return -Float::INFINITY if bound == Type::IntegerRange::NEG_INFINITY
497
+ return Float::INFINITY if bound == Type::IntegerRange::POS_INFINITY
498
+
499
+ bound
500
+ end
501
+
502
+ def numeric_to_bound(value)
503
+ return Type::IntegerRange::NEG_INFINITY if value == -Float::INFINITY
504
+ return Type::IntegerRange::POS_INFINITY if value == Float::INFINITY
505
+
506
+ value.to_i
507
+ end
508
+
509
+ def integer_range_disjoint?(low, high)
510
+ return false if low == Type::IntegerRange::NEG_INFINITY
511
+ return false if high == Type::IntegerRange::POS_INFINITY
512
+
513
+ low > high
514
+ end
515
+
516
+ # De Morgan: `D \ (M1 ∩ M2 ∩ …) = (D \ M1) ∪ (D \ M2) ∪
517
+ # …`. Each member's complement is computed independently
518
+ # within `current_type` and the results are unioned.
519
+ # Members the algebra cannot complement contribute
520
+ # `current_type` itself, so the union widens to
521
+ # `current_type` overall — sound but imprecise.
522
+ def complement_intersection(current_type, intersection)
523
+ per_member = intersection.members.map do |member|
524
+ Narrowing.narrow_not_refinement(current_type, member)
525
+ end
526
+ Type::Combinator.union(*per_member)
527
+ end
528
+
324
529
  def falsey_value?(value)
325
530
  value.nil? || value == false
326
531
  end
@@ -1029,6 +1234,12 @@ module Rigor
1029
1234
  # the effect's `negative?` flag. Shared between
1030
1235
  # predicate-if-* and assert-if-* application paths.
1031
1236
  def narrow_for_effect(current, effect, environment)
1237
+ if effect.respond_to?(:refinement?) && effect.refinement?
1238
+ return narrow_not_refinement(current, effect.refinement_type) if effect.negative?
1239
+
1240
+ return effect.refinement_type
1241
+ end
1242
+
1032
1243
  if effect.negative?
1033
1244
  narrow_not_class(current, effect.class_name, exact: false, environment: environment)
1034
1245
  else
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../scope"
6
6
  require_relative "../type"
7
+ require_relative "narrowing"
7
8
  require_relative "statement_evaluator"
8
9
 
9
10
  module Rigor
@@ -475,16 +476,9 @@ module Rigor
475
476
 
476
477
  case node
477
478
  when Prism::ModuleNode, Prism::ClassNode
478
- name = qualified_name_for(node.constant_path)
479
- if name
480
- full = (qualified_prefix + [name]).join("::")
481
- singleton = Type::Combinator.singleton_of(full)
482
- identity_table[node.constant_path] = singleton
483
- discovered[full] = singleton
484
- child_prefix = qualified_prefix + [name]
485
- record_declarations(node.body, child_prefix, identity_table, discovered) if node.body
486
- return
487
- end
479
+ return if record_class_or_module?(node, qualified_prefix, identity_table, discovered)
480
+ when Prism::ConstantWriteNode
481
+ return if record_data_define_constant?(node, qualified_prefix, identity_table, discovered)
488
482
  end
489
483
 
490
484
  node.compact_child_nodes.each do |child|
@@ -492,6 +486,60 @@ module Rigor
492
486
  end
493
487
  end
494
488
 
489
+ def record_class_or_module?(node, qualified_prefix, identity_table, discovered)
490
+ name = qualified_name_for(node.constant_path)
491
+ return false unless name
492
+
493
+ full = (qualified_prefix + [name]).join("::")
494
+ singleton = Type::Combinator.singleton_of(full)
495
+ identity_table[node.constant_path] = singleton
496
+ discovered[full] = singleton
497
+ child_prefix = qualified_prefix + [name]
498
+ record_declarations(node.body, child_prefix, identity_table, discovered) if node.body
499
+ true
500
+ end
501
+
502
+ # Recognises `Const = Data.define(*Symbol) [do ... end]` and registers
503
+ # `Const` (qualified by the surrounding class/module path) as a
504
+ # discovered class. `Const.new(...)` then resolves to a fresh
505
+ # `Nominal[Const]` via `meta_new`, instead of the un-narrowed
506
+ # `Dynamic[top]` returned by the default `Class#new` envelope.
507
+ #
508
+ # The Data.define block body, if present, is recursed into so any
509
+ # nested class/module declarations in the override block (rare but
510
+ # legal) still feed the discovered table.
511
+ def record_data_define_constant?(node, qualified_prefix, identity_table, discovered)
512
+ return false unless data_define_call?(node.value)
513
+
514
+ full = (qualified_prefix + [node.name.to_s]).join("::")
515
+ discovered[full] = Type::Combinator.singleton_of(full)
516
+ record_declarations(node.value, qualified_prefix, identity_table, discovered)
517
+ true
518
+ end
519
+
520
+ # Recognises `Data.define(*Symbol)` and `Data.define(*Symbol) do ... end`
521
+ # at constant-write rvalue position. The receiver MUST be the bare
522
+ # `Data` constant (or `::Data`); other receivers (a local variable, a
523
+ # method call return) are rejected because their identity is not
524
+ # statically known.
525
+ def data_define_call?(node)
526
+ return false unless node.is_a?(Prism::CallNode)
527
+ return false unless node.name == :define
528
+ return false unless data_constant_receiver?(node.receiver)
529
+
530
+ args = node.arguments&.arguments || []
531
+ args.all?(Prism::SymbolNode)
532
+ end
533
+
534
+ def data_constant_receiver?(node)
535
+ case node
536
+ when Prism::ConstantReadNode
537
+ node.name == :Data
538
+ when Prism::ConstantPathNode
539
+ node.parent.nil? && node.name == :Data
540
+ end
541
+ end
542
+
495
543
  def qualified_name_for(constant_path_node)
496
544
  case constant_path_node
497
545
  when Prism::ConstantReadNode
@@ -515,6 +563,13 @@ module Rigor
515
563
  # Prism node the StatementEvaluator did not visit (i.e. expression-
516
564
  # interior nodes like the receiver/args of a CallNode). Those
517
565
  # nodes inherit their nearest recorded ancestor's scope.
566
+ #
567
+ # `IfNode` / `UnlessNode` are special-cased: the truthy and falsey
568
+ # branches each get their predicate's narrowed scope before
569
+ # recursing. This handles expression-position conditionals
570
+ # (e.g. `cache[k] = if cond; t; else; e; end` and conditionals
571
+ # nested as call arguments) which are typed by ExpressionTyper
572
+ # without going through `eval_if`'s narrowing path.
518
573
  def propagate(node, table, parent_scope)
519
574
  return unless node.is_a?(Prism::Node)
520
575
 
@@ -526,7 +581,28 @@ module Rigor
526
581
  parent_scope
527
582
  end
528
583
 
529
- node.compact_child_nodes.each { |child| propagate(child, table, current_scope) }
584
+ case node
585
+ when Prism::IfNode
586
+ propagate_if_branches(node, table, current_scope)
587
+ when Prism::UnlessNode
588
+ propagate_unless_branches(node, table, current_scope)
589
+ else
590
+ node.compact_child_nodes.each { |child| propagate(child, table, current_scope) }
591
+ end
592
+ end
593
+
594
+ def propagate_if_branches(node, table, current_scope)
595
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
596
+ propagate(node.predicate, table, current_scope) if node.predicate
597
+ propagate(node.statements, table, truthy_scope) if node.statements
598
+ propagate(node.subsequent, table, falsey_scope) if node.subsequent
599
+ end
600
+
601
+ def propagate_unless_branches(node, table, current_scope)
602
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
603
+ propagate(node.predicate, table, current_scope) if node.predicate
604
+ propagate(node.statements, table, falsey_scope) if node.statements
605
+ propagate(node.else_clause, table, truthy_scope) if node.else_clause
530
606
  end
531
607
  end
532
608
  # rubocop:enable Metrics/ModuleLength
@@ -894,6 +894,12 @@ module Rigor
894
894
  end
895
895
 
896
896
  def narrow_for_assert_effect(current_type, effect, environment)
897
+ if effect.refinement?
898
+ return Narrowing.narrow_not_refinement(current_type, effect.refinement_type) if effect.negative?
899
+
900
+ return effect.refinement_type
901
+ end
902
+
897
903
  if effect.negative?
898
904
  Narrowing.narrow_not_class(current_type, effect.class_name, exact: false, environment: environment)
899
905
  else
@@ -50,10 +50,19 @@ module Rigor
50
50
  # when the directive uses the `~ClassName` form, in
51
51
  # which case the engine narrows AWAY from `class_name`
52
52
  # (`Narrowing.narrow_not_class`) instead of toward it.
53
- PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative) do
53
+ #
54
+ # `refinement_type` is non-nil when the right-hand side is
55
+ # a kebab-case refinement name (`non-empty-string`,
56
+ # `lowercase-string`, …) instead of a Capitalised class
57
+ # name. The narrowing tier substitutes the carrier for the
58
+ # current local type; `class_name` is then nil and
59
+ # `negative` is false (refinement-form directives do not
60
+ # support `~T` negation in v0.0.4).
61
+ PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
54
62
  def truthy_only? = edge == :truthy_only
55
63
  def falsey_only? = edge == :falsey_only
56
64
  def negative? = negative == true
65
+ def refinement? = !refinement_type.nil?
57
66
  end
58
67
 
59
68
  # Returned for `assert` / `assert-if-true` /
@@ -71,11 +80,12 @@ module Rigor
71
80
  #
72
81
  # `negative` mirrors `PredicateEffect`: true when the
73
82
  # directive uses `~ClassName` syntax.
74
- AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative) do
83
+ AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
75
84
  def always? = condition == :always
76
85
  def if_truthy_return? = condition == :if_truthy_return
77
86
  def if_falsey_return? = condition == :if_falsey_return
78
87
  def negative? = negative == true
88
+ def refinement? = !refinement_type.nil?
79
89
  end
80
90
 
81
91
  module_function
@@ -100,6 +110,14 @@ module Rigor
100
110
  effects.uniq
101
111
  end
102
112
 
113
+ # The right-hand side accepts either a Capitalised class
114
+ # name (with optional `~` negation, optional `::` prefix,
115
+ # qualified names) OR a kebab-case refinement payload
116
+ # routed through `Builtins::ImportedRefinements::Parser`
117
+ # (bare names, `name[T]`, `name<min, max>`). The two arms
118
+ # share the same overall directive shape; the parser
119
+ # detects which form matched by looking at the `class_name`
120
+ # vs `refinement` capture groups.
103
121
  PREDICATE_DIRECTIVE_PATTERN = /
104
122
  \A
105
123
  rigor:v1:(?<directive>predicate-if-(?:true|false))
@@ -107,7 +125,11 @@ module Rigor
107
125
  (?<target>self|[a-z_][a-zA-Z0-9_]*)
108
126
  \s+is\s+
109
127
  (?<negation>~?)
110
- (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
128
+ (?:
129
+ (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
130
+ |
131
+ (?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
132
+ )
111
133
  \s*
112
134
  \z
113
135
  /x
@@ -119,16 +141,18 @@ module Rigor
119
141
 
120
142
  directive = match[:directive].to_s
121
143
  target = match[:target].to_s
122
- class_name = match[:class_name].to_s.sub(/\A::/, "")
123
144
  edge = directive == "predicate-if-true" ? :truthy_only : :falsey_only
124
- target_kind = target == "self" ? :self : :parameter
125
- target_name = target == "self" ? :self : target.to_sym
145
+ target_kind, target_name = target_fields(target)
146
+ class_name, refinement_type, negative = resolve_directive_rhs(match)
147
+ return nil if class_name.nil? && refinement_type.nil?
148
+
126
149
  PredicateEffect.new(
127
150
  edge: edge,
128
151
  target_kind: target_kind,
129
152
  target_name: target_name,
130
153
  class_name: class_name,
131
- negative: match[:negation].to_s == "~"
154
+ negative: negative,
155
+ refinement_type: refinement_type
132
156
  )
133
157
  end
134
158
 
@@ -158,7 +182,11 @@ module Rigor
158
182
  (?<target>self|[a-z_][a-zA-Z0-9_]*)
159
183
  \s+is\s+
160
184
  (?<negation>~?)
161
- (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
185
+ (?:
186
+ (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
187
+ |
188
+ (?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
189
+ )
162
190
  \s*
163
191
  \z
164
192
  /x
@@ -180,18 +208,60 @@ module Rigor
180
208
  return nil if condition.nil?
181
209
 
182
210
  target = match[:target].to_s
183
- class_name = match[:class_name].to_s.sub(/\A::/, "")
184
- target_kind = target == "self" ? :self : :parameter
185
- target_name = target == "self" ? :self : target.to_sym
211
+ target_kind, target_name = target_fields(target)
212
+ class_name, refinement_type, negative = resolve_directive_rhs(match)
213
+ return nil if class_name.nil? && refinement_type.nil?
214
+
186
215
  AssertEffect.new(
187
216
  condition: condition,
188
217
  target_kind: target_kind,
189
218
  target_name: target_name,
190
219
  class_name: class_name,
191
- negative: match[:negation].to_s == "~"
220
+ negative: negative,
221
+ refinement_type: refinement_type
192
222
  )
193
223
  end
194
224
 
225
+ # Resolves the `class_name` / `refinement` alternation in
226
+ # the assert / predicate directive patterns. Returns
227
+ # `[class_name, refinement_type, negative]`:
228
+ #
229
+ # - Class-name arm matched: `class_name` is the resolved
230
+ # string (leading `::` stripped), `refinement_type` is
231
+ # nil, `negative` reflects the optional `~` prefix.
232
+ # - Refinement arm matched: `class_name` is nil,
233
+ # `refinement_type` is the resolved `Rigor::Type`,
234
+ # `negative` reflects the `~` prefix. v0.0.5 supports
235
+ # refinement-form negation for the `Difference[base,
236
+ # Constant]` shape (the narrowing tier computes the
237
+ # complement decomposition); other refinement carriers
238
+ # under negation fall back to the conservative
239
+ # "current_type unchanged" answer.
240
+ # - Refinement payload unparseable: returns
241
+ # `[nil, nil, false]` so callers can drop the directive
242
+ # silently (fail-soft policy).
243
+ def resolve_directive_rhs(match)
244
+ negative = match[:negation].to_s == "~"
245
+ class_capture = match[:class_name]
246
+ return [class_capture.to_s.sub(/\A::/, ""), nil, negative] if class_capture
247
+
248
+ refinement_capture = match[:refinement]
249
+ return [nil, nil, false] if refinement_capture.nil?
250
+
251
+ type = Builtins::ImportedRefinements.parse(refinement_capture)
252
+ return [nil, nil, false] if type.nil?
253
+
254
+ [nil, type, negative]
255
+ end
256
+
257
+ def target_fields(target)
258
+ if target == "self"
259
+ %i[self self]
260
+ else
261
+ [:parameter, target.to_sym]
262
+ end
263
+ end
264
+
195
265
  # Reads the `rigor:v1:return: <kebab-name>` directive off
196
266
  # `RBS::Definition::Method#annotations`. The directive
197
267
  # overrides a method's RBS-declared return type with one of
@@ -238,11 +308,20 @@ module Rigor
238
308
  nil
239
309
  end
240
310
 
311
+ # The trailing payload supports the full refinement
312
+ # grammar in `Builtins::ImportedRefinements::Parser` —
313
+ # bare kebab-case names plus parameterised forms like
314
+ # `non-empty-array[Integer]`, `non-empty-hash[Symbol,
315
+ # Integer]`, and `int<5, 10>`. The directive head is
316
+ # consumed by the regex; the rest is forwarded to the
317
+ # refinement parser. Anything the parser cannot resolve
318
+ # falls back to nil so the call site keeps the
319
+ # RBS-declared return type.
241
320
  RETURN_DIRECTIVE_PATTERN = /
242
321
  \A
243
322
  rigor:v1:return:
244
323
  \s+
245
- (?<refinement>[a-z][a-z0-9-]*)
324
+ (?<payload>\S(?:.*\S)?)
246
325
  \s*
247
326
  \z
248
327
  /x
@@ -252,7 +331,84 @@ module Rigor
252
331
  match = RETURN_DIRECTIVE_PATTERN.match(string)
253
332
  return nil if match.nil?
254
333
 
255
- Builtins::ImportedRefinements.lookup(match[:refinement])
334
+ Builtins::ImportedRefinements.parse(match[:payload])
335
+ end
336
+
337
+ # Returned for `rigor:v1:param: <name> <refinement>`. The
338
+ # parameter name is a Ruby identifier (Symbol); the type
339
+ # is any `Rigor::Type` the refinement parser resolves
340
+ # (bare kebab-case name, parameterised form, or `int<...>`
341
+ # range — the same grammar the `return:` directive
342
+ # accepts).
343
+ ParamOverride = Data.define(:param_name, :type)
344
+
345
+ # Reads every `rigor:v1:param: <name> <refinement>`
346
+ # directive off `RBS::Definition::Method#annotations` and
347
+ # returns the resolved `ParamOverride` list. Annotations
348
+ # the parser cannot resolve (typo, unknown refinement, no
349
+ # `param:` directive at all) are silently dropped — the
350
+ # call site keeps the RBS-declared parameter type for
351
+ # those parameters. The reader accepts a nil method
352
+ # definition so call sites can pass through optional
353
+ # method lookups without a guard.
354
+ #
355
+ # Example annotation in an RBS file:
356
+ #
357
+ # class Slug
358
+ # %a{rigor:v1:param: id is non-empty-string}
359
+ # def normalise: (::String id) -> String
360
+ # end
361
+ #
362
+ # The RBS-declared type of `id` is `String`. The override
363
+ # tightens it to `non-empty-string` for argument-check
364
+ # purposes; passing a too-wide `Nominal[String]` argument
365
+ # is flagged as an argument-type mismatch at the call
366
+ # site.
367
+ def read_param_type_overrides(method_def)
368
+ return [] if method_def.nil?
369
+
370
+ annotations = method_def.annotations
371
+ return [] if annotations.nil? || annotations.empty?
372
+
373
+ annotations.filter_map { |annotation| parse_param_annotation(annotation.string) }
374
+ end
375
+
376
+ # Convenience reader for call sites that want to look up
377
+ # a single override by parameter name. Returns a frozen
378
+ # Hash<Symbol, Rigor::Type>; missing keys mean "use the
379
+ # RBS-declared type". Callers MUST treat the hash as
380
+ # read-only.
381
+ def param_type_override_map(method_def)
382
+ read_param_type_overrides(method_def).to_h { |o| [o.param_name, o.type] }.freeze
383
+ end
384
+
385
+ # The `is` glue word is optional so authors can write
386
+ # either `param: id is non-empty-string` (consistent with
387
+ # the existing `assert` / `predicate-if-*` directives) or
388
+ # the terser `param: id non-empty-string`. The trailing
389
+ # payload accepts the full refinement grammar in
390
+ # `Builtins::ImportedRefinements::Parser`.
391
+ PARAM_DIRECTIVE_PATTERN = /
392
+ \A
393
+ rigor:v1:param:
394
+ \s+
395
+ (?<param>[a-z_][a-zA-Z0-9_]*)
396
+ \s+
397
+ (?:is\s+)?
398
+ (?<payload>\S(?:.*\S)?)
399
+ \s*
400
+ \z
401
+ /x
402
+ private_constant :PARAM_DIRECTIVE_PATTERN
403
+
404
+ def parse_param_annotation(string)
405
+ match = PARAM_DIRECTIVE_PATTERN.match(string)
406
+ return nil if match.nil?
407
+
408
+ type = Builtins::ImportedRefinements.parse(match[:payload])
409
+ return nil if type.nil?
410
+
411
+ ParamOverride.new(param_name: match[:param].to_sym, type: type)
256
412
  end
257
413
  end
258
414
  end