rigortype 0.1.0 → 0.1.2

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/range.yml +6 -4
  10. data/data/builtins/ruby_core/string.yml +15 -10
  11. data/data/builtins/ruby_core/time.yml +3 -3
  12. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  13. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  14. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  15. data/lib/rigor/analysis/check_rules.rb +346 -18
  16. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  17. data/lib/rigor/analysis/runner.rb +90 -6
  18. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  19. data/lib/rigor/cli/diff_command.rb +169 -0
  20. data/lib/rigor/cli/explain_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +3 -3
  22. data/lib/rigor/cli/type_scan_command.rb +4 -4
  23. data/lib/rigor/cli.rb +29 -5
  24. data/lib/rigor/configuration/severity_profile.rb +18 -3
  25. data/lib/rigor/configuration.rb +186 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/inference/expression_typer.rb +3 -1
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  29. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  32. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  33. data/lib/rigor/inference/narrowing.rb +150 -6
  34. data/lib/rigor/inference/scope_indexer.rb +220 -17
  35. data/lib/rigor/inference/statement_evaluator.rb +29 -0
  36. data/lib/rigor/plugin/base.rb +43 -0
  37. data/lib/rigor/plugin/fact_store.rb +92 -0
  38. data/lib/rigor/plugin/io_boundary.rb +92 -19
  39. data/lib/rigor/plugin/load_error.rb +14 -2
  40. data/lib/rigor/plugin/loader.rb +116 -0
  41. data/lib/rigor/plugin/manifest.rb +75 -6
  42. data/lib/rigor/plugin/services.rb +14 -2
  43. data/lib/rigor/plugin/trust_policy.rb +30 -7
  44. data/lib/rigor/plugin.rb +1 -0
  45. data/lib/rigor/scope.rb +30 -5
  46. data/lib/rigor/trinary.rb +1 -1
  47. data/lib/rigor/type/integer_range.rb +6 -2
  48. data/lib/rigor/version.rb +1 -1
  49. data/sig/rigor/environment.rbs +3 -2
  50. data/sig/rigor/scope.rbs +3 -0
  51. data/sig/rigor.rbs +8 -2
  52. metadata +9 -1
@@ -122,10 +122,25 @@ module Rigor
122
122
  Type::Nominal => :dispatch_nominal_size,
123
123
  Type::Difference => :dispatch_difference,
124
124
  Type::Refined => :dispatch_refined,
125
- Type::Intersection => :dispatch_intersection
125
+ Type::Intersection => :dispatch_intersection,
126
+ Type::IntegerRange => :dispatch_integer_range
126
127
  }.freeze
127
128
  private_constant :RECEIVER_HANDLERS
128
129
 
130
+ # v0.1.1 Track 1 slice 5b — `Integer#to_s(base)` on a
131
+ # non-negative `IntegerRange` receiver. The output of
132
+ # `n.to_s(b)` for `n >= 0` is digit-string-only (no
133
+ # leading sign), so when the base is in this table the
134
+ # result lifts to the matching imported refinement.
135
+ # Bases not listed (2, 36, ...) keep the v0.1.0 baseline
136
+ # since Rigor has no carrier for the resulting alphabet.
137
+ TO_S_BASE_REFINEMENTS = {
138
+ 10 => :decimal_int_string,
139
+ 8 => :octal_int_string,
140
+ 16 => :hex_int_string
141
+ }.freeze
142
+ private_constant :TO_S_BASE_REFINEMENTS
143
+
129
144
  def try_dispatch(receiver:, method_name:, args:)
130
145
  args ||= []
131
146
  handler = RECEIVER_HANDLERS[receiver.class]
@@ -184,6 +199,42 @@ module Rigor
184
199
  Type::Combinator.non_negative_int
185
200
  end
186
201
 
202
+ # `IntegerRange#to_s` precision (v0.1.1 Track 1 slice 5b).
203
+ # When the range's lower bound is `>= 0`, every member is
204
+ # a non-negative integer and `to_s(base)` returns a
205
+ # digit-string with no leading sign. The result lifts to
206
+ # the matching imported refinement (`decimal-int-string`
207
+ # for base 10, `octal-int-string` for 8, `hex-int-string`
208
+ # for 16). Signed ranges fall through (the result could
209
+ # carry a `-` sign that no Rigor refinement currently
210
+ # captures), as do bases without a digit-only refinement.
211
+ def dispatch_integer_range(range, method_name, args)
212
+ return nil unless method_name == :to_s
213
+ return nil unless range.lower >= 0
214
+
215
+ base = base_argument(args)
216
+ return nil if base.nil?
217
+
218
+ refinement = TO_S_BASE_REFINEMENTS[base]
219
+ return nil if refinement.nil?
220
+
221
+ Type::Combinator.public_send(refinement)
222
+ end
223
+
224
+ # `to_s` with no argument defaults to base 10. With one
225
+ # argument, the value MUST be a `Constant<Integer>` to
226
+ # be statically known. Anything else (Nominal[Integer]
227
+ # arg, multi-arg, etc.) declines.
228
+ def base_argument(args)
229
+ return 10 if args.empty?
230
+ return nil unless args.size == 1
231
+
232
+ arg = args.first
233
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Integer)
234
+
235
+ arg.value
236
+ end
237
+
187
238
  # Refinement-aware projections over a `Difference[base,
188
239
  # removed]` receiver. When the removed value is the
189
240
  # empty witness of the base (`Constant[""]` for
@@ -289,7 +340,21 @@ module Rigor
289
340
  %i[octal_int downcase] => :refined_self,
290
341
  %i[octal_int upcase] => :refined_self,
291
342
  %i[hex_int downcase] => :refined_self,
292
- %i[hex_int upcase] => :refined_self
343
+ %i[hex_int upcase] => :refined_self,
344
+ # v0.1.1 Track 1 slice 2 — `to_i` / `to_int` on a
345
+ # known digit-only string. `decimal-int-string`
346
+ # (`/\A\d+\z/`) and `numeric-string` (Rigor's
347
+ # numeric-string predicate, ASCII digits) are
348
+ # predicates over digit-only strings, so the parse
349
+ # is total over the carrier domain and the result
350
+ # is always `>= 0`. `non-negative-int` is the
351
+ # tightest carrier that captures both the lower
352
+ # bound and the integer-ness without inventing a
353
+ # narrower carrier.
354
+ %i[decimal_int to_i] => :non_negative_int,
355
+ %i[decimal_int to_int] => :non_negative_int,
356
+ %i[numeric to_i] => :non_negative_int,
357
+ %i[numeric to_int] => :non_negative_int
293
358
  }.freeze
294
359
  private_constant :REFINED_STRING_PROJECTIONS
295
360
 
@@ -313,6 +378,7 @@ module Rigor
313
378
  when :refined_self then refined
314
379
  when :uppercase_string then Type::Combinator.uppercase_string
315
380
  when :lowercase_string then Type::Combinator.lowercase_string
381
+ when :non_negative_int then Type::Combinator.non_negative_int
316
382
  end
317
383
  end
318
384
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "../reflection"
4
4
  require_relative "../type"
5
+ require_relative "../flow_contribution"
6
+ require_relative "../flow_contribution/merger"
5
7
  require_relative "method_dispatcher/constant_folding"
6
8
  require_relative "method_dispatcher/literal_string_folding"
7
9
  require_relative "method_dispatcher/shape_dispatch"
@@ -59,12 +61,25 @@ module Rigor
59
61
  # @param environment [Rigor::Environment, nil] required for
60
62
  # RBS-backed dispatch; when nil only constant folding can fire.
61
63
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
62
- def dispatch(receiver_type:, method_name:, arg_types:, block_type: nil, environment: nil)
64
+ def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/ParameterLists
65
+ block_type: nil, environment: nil,
66
+ call_node: nil, scope: nil)
63
67
  return nil if receiver_type.nil?
64
68
 
65
69
  precise = dispatch_precise_tiers(receiver_type, method_name, arg_types, block_type)
66
70
  return precise if precise
67
71
 
72
+ # v0.1.1 Track 2 slice 7 — plugin return-type contribution
73
+ # tier. Sits ahead of `RbsDispatch` so a plugin that
74
+ # understands a domain-specific dispatch (e.g. an
75
+ # `ActiveRecord::Base.find` returning `Nominal[<resolved
76
+ # model>]`) wins over the RBS-projected envelope. Only
77
+ # consults the registry when both `call_node` and `scope`
78
+ # are supplied — the dispatcher's own internal callers
79
+ # (per-element block fold, etc.) skip this tier.
80
+ plugin_result = try_plugin_contribution(call_node, scope)
81
+ return plugin_result if plugin_result
82
+
68
83
  rbs_result = RbsDispatch.try_dispatch(
69
84
  receiver: receiver_type, method_name: method_name, args: arg_types,
70
85
  environment: environment, block_type: block_type
@@ -85,6 +100,40 @@ module Rigor
85
100
  try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
86
101
  end
87
102
 
103
+ # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
104
+ # slice 7. Walks every loaded plugin's
105
+ # `#flow_contribution_for(call_node:, scope:)` hook,
106
+ # collects the non-nil `FlowContribution` bundles, merges
107
+ # them through `FlowContribution::Merger`, and returns
108
+ # the merged `return_type` slot (or nil when no plugin
109
+ # contributed a return type).
110
+ #
111
+ # Plugins whose hook raises have their contribution
112
+ # silently dropped for this call so the dispatch chain
113
+ # keeps moving — the run-level diagnostic envelope (per
114
+ # ADR-2 § "Plugin Trust and I/O Policy") is owned by
115
+ # `Analysis::Runner#plugin_emitted_diagnostics`.
116
+ def try_plugin_contribution(call_node, scope)
117
+ return nil if call_node.nil? || scope.nil?
118
+
119
+ registry = scope.environment&.plugin_registry
120
+ return nil if registry.nil? || registry.empty?
121
+
122
+ contributions = collect_plugin_contributions(registry, call_node, scope)
123
+ return nil if contributions.empty?
124
+
125
+ FlowContribution::Merger.merge(contributions).return_type
126
+ end
127
+
128
+ def collect_plugin_contributions(registry, call_node, scope)
129
+ registry.plugins.filter_map do |plugin|
130
+ contribution = plugin.flow_contribution_for(call_node: call_node, scope: scope)
131
+ contribution.is_a?(FlowContribution) ? contribution : nil
132
+ rescue StandardError
133
+ nil
134
+ end
135
+ end
136
+
88
137
  # Runs the precision tiers (constant fold, shape dispatch,
89
138
  # file-path fold, block fold) in order and returns the first
90
139
  # non-nil answer. Each tier owns its own receiver/argument
@@ -7,6 +7,7 @@ require_relative "../type"
7
7
  require_relative "../environment"
8
8
  require_relative "../rbs_extended"
9
9
  require_relative "../analysis/fact_store"
10
+ require_relative "../builtins/regex_refinement"
10
11
 
11
12
  module Rigor
12
13
  module Inference
@@ -746,20 +747,51 @@ module Rigor
746
747
  # `nil`. Subtract the dead half on each edge so callers
747
748
  # like `year.upcase` inside the truthy branch no longer
748
749
  # fire `possible-nil-receiver`.
750
+ #
751
+ # v0.1.1 Track 1 slice 1 — when the regex source is a
752
+ # statically known `RegularExpressionNode` and a named
753
+ # capture's body matches one of the curated shapes in
754
+ # {Rigor::Builtins::RegexRefinement::RULES}, the truthy
755
+ # branch narrows further than `String` to the matching
756
+ # imported refinement (e.g. `decimal-int-string` for
757
+ # `\d+`). Bodies outside the table fall back to the
758
+ # v0.1.0 baseline (plain `String`).
749
759
  def analyse_match_write(node, scope)
750
760
  string_t = Type::Combinator.nominal_of("String")
751
761
  nil_t = Type::Combinator.constant_of(nil)
762
+ refinements = match_write_capture_refinements(node)
752
763
  truthy = scope
753
764
  falsey = scope
754
765
  node.targets.each do |target|
755
766
  next unless target.is_a?(Prism::LocalVariableTargetNode)
756
767
 
757
- truthy = truthy.with_local(target.name, string_t)
768
+ truthy = truthy.with_local(target.name, refinements[target.name] || string_t)
758
769
  falsey = falsey.with_local(target.name, nil_t)
759
770
  end
760
771
  [truthy, falsey]
761
772
  end
762
773
 
774
+ # Extracts `{ capture_name => Refinement }` for every named
775
+ # capture group in the `MatchWriteNode`'s wrapped `=~` call
776
+ # whose body the recogniser table accepts. Bodies that
777
+ # contain nested groups, anchors, alternation, or anything
778
+ # else outside the curated forms drop out and the caller
779
+ # falls back to plain `String`.
780
+ NAMED_CAPTURE_BODY_RE = /\(\?<([A-Za-z_][A-Za-z0-9_]*)>([^()|]*)\)/
781
+ private_constant :NAMED_CAPTURE_BODY_RE
782
+
783
+ def match_write_capture_refinements(node)
784
+ regex = node.call.is_a?(Prism::CallNode) ? node.call.receiver : nil
785
+ return {} unless regex.is_a?(Prism::RegularExpressionNode)
786
+
787
+ refinements = {}
788
+ regex.unescaped.scan(NAMED_CAPTURE_BODY_RE) do |name, body|
789
+ type = ::Rigor::Builtins::RegexRefinement.for_capture_body(body)
790
+ refinements[name.to_sym] = type if type
791
+ end
792
+ refinements
793
+ end
794
+
763
795
  # Recognised CallNode predicates:
764
796
  # - `recv.nil?` (Slice 6 phase 1, no args, no block)
765
797
  # - unary `!recv` (`name == :!`, no args, no block)
@@ -772,19 +804,82 @@ module Rigor
772
804
  # through to the no-narrowing fallback.
773
805
  def analyse_call(node, scope)
774
806
  return nil if node.block
775
- return nil if node.receiver.nil?
776
807
 
777
- shape_result = dispatch_call(node, scope, node.name)
778
- return shape_result if shape_result
808
+ unless node.receiver.nil?
809
+ shape_result = dispatch_call(node, scope, node.name)
810
+ return shape_result if shape_result
811
+
812
+ # v0.1.1 Track 1 slice 4 — String predicate flow facts.
813
+ string_predicate_result = analyse_string_predicate(node, scope)
814
+ return string_predicate_result if string_predicate_result
815
+ end
779
816
 
780
817
  # Slice 7 phase 15 — RBS::Extended predicate
781
818
  # effects. When the method's RBS signature carries
782
819
  # `rigor:v1:predicate-if-true` / `predicate-if-false`
783
820
  # annotations, apply them to narrow the corresponding
784
- # local-variable arguments on each edge.
821
+ # local-variable arguments on each edge. v0.1.1 Track
822
+ # 1 slice 3 — implicit-self calls (`recv == nil`,
823
+ # e.g. `admin?` inside an instance method body) flow
824
+ # through here too so `self`-targeted facts can edit
825
+ # `scope.self_type`.
785
826
  analyse_rbs_extended_contribution(node, scope)
786
827
  end
787
828
 
829
+ # v0.1.1 Track 1 slice 4 — `String#start_with?` /
830
+ # `#end_with?` / `#include?` against a `Constant<String>`
831
+ # needle attaches a relational `FactStore::Fact` to the
832
+ # local on each edge. The receiver's type does NOT
833
+ # change — Rigor has no "starts-with-X" carrier today —
834
+ # so the fact carries the predicate semantics for any
835
+ # downstream consumer that wants to read it (e.g. a
836
+ # plugin's `prepare(services)` hook in v0.1.x).
837
+ # Truthy edge: positive polarity. Falsey edge: negative
838
+ # polarity. Mirrors the equality-predicate fact pattern
839
+ # already used by `analyse_equality_predicate`.
840
+ STRING_PREDICATE_NAMES = %i[start_with? end_with? include?].freeze
841
+ private_constant :STRING_PREDICATE_NAMES
842
+
843
+ def analyse_string_predicate(node, scope)
844
+ return nil unless string_predicate_call?(node)
845
+
846
+ needle = string_predicate_needle(node, scope)
847
+ return nil if needle.nil?
848
+
849
+ local_name = node.receiver.name
850
+ return nil if scope.local(local_name).nil?
851
+
852
+ [
853
+ scope.with_fact(string_predicate_fact(local_name, node.name, needle, polarity: :positive)),
854
+ scope.with_fact(string_predicate_fact(local_name, node.name, needle, polarity: :negative))
855
+ ]
856
+ end
857
+
858
+ def string_predicate_call?(node)
859
+ STRING_PREDICATE_NAMES.include?(node.name) &&
860
+ node.receiver.is_a?(Prism::LocalVariableReadNode) &&
861
+ !node.arguments.nil? &&
862
+ node.arguments.arguments.size == 1
863
+ end
864
+
865
+ def string_predicate_needle(node, scope)
866
+ needle_type = static_literal_type(node.arguments.arguments.first, scope)
867
+ return nil unless needle_type.is_a?(Type::Constant) && needle_type.value.is_a?(String)
868
+
869
+ needle_type.value
870
+ end
871
+
872
+ def string_predicate_fact(name, predicate, needle, polarity:)
873
+ Analysis::FactStore::Fact.new(
874
+ bucket: :relational,
875
+ target: Analysis::FactStore::Target.local(name),
876
+ predicate: predicate,
877
+ payload: needle,
878
+ polarity: polarity,
879
+ stability: :local_binding
880
+ )
881
+ end
882
+
788
883
  ZERO_CLASS_PREDICATES = %i[positive? negative? zero? nonzero?].freeze
789
884
  COMPARISON_OPERATORS = %i[< <= > >=].freeze
790
885
  private_constant :ZERO_CLASS_PREDICATES, :COMPARISON_OPERATORS
@@ -1267,6 +1362,7 @@ module Rigor
1267
1362
 
1268
1363
  def apply_fact_to_scope(fact, call_node, entry_scope, target_scope, method_def)
1269
1364
  target_node = fact_target_node(fact, call_node, method_def)
1365
+ return apply_self_fact(fact, target_node, entry_scope, target_scope) if fact.target_kind == :self
1270
1366
  return target_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
1271
1367
 
1272
1368
  local_name = target_node.name
@@ -1277,6 +1373,45 @@ module Rigor
1277
1373
  target_scope.with_local(local_name, narrowed)
1278
1374
  end
1279
1375
 
1376
+ # v0.1.1 Track 1 slice 3 — `target_kind == :self` facts
1377
+ # (`predicate-if-true self is T`, `predicate-if-false
1378
+ # self is T`) narrow whichever scope binding is bound to
1379
+ # the call's receiver. Four receiver shapes participate:
1380
+ #
1381
+ # `recv.method?` where recv is...
1382
+ # - `LocalVariableReadNode` -> narrow that local
1383
+ # - `InstanceVariableReadNode` -> narrow that ivar
1384
+ # - `Prism::SelfNode` (explicit self) -> narrow `self_type`
1385
+ # - nil (implicit self call inside a method body)
1386
+ # -> narrow `self_type`
1387
+ #
1388
+ # Other receiver shapes (method chains, expressions) have
1389
+ # no scope binding to narrow against and fall through.
1390
+ def apply_self_fact(fact, receiver_node, entry_scope, target_scope)
1391
+ case receiver_node
1392
+ when nil, Prism::SelfNode
1393
+ current = entry_scope.self_type
1394
+ return target_scope if current.nil?
1395
+
1396
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1397
+ target_scope.with_self_type(narrowed)
1398
+ when Prism::LocalVariableReadNode
1399
+ current = entry_scope.local(receiver_node.name)
1400
+ return target_scope if current.nil?
1401
+
1402
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1403
+ target_scope.with_local(receiver_node.name, narrowed)
1404
+ when Prism::InstanceVariableReadNode
1405
+ current = entry_scope.ivar(receiver_node.name)
1406
+ return target_scope if current.nil?
1407
+
1408
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1409
+ target_scope.with_ivar(receiver_node.name, narrowed)
1410
+ else
1411
+ target_scope
1412
+ end
1413
+ end
1414
+
1280
1415
  # Resolves a Fact's target node. Mirrors the earlier
1281
1416
  # `effect_target_node` helper but reads from the
1282
1417
  # canonical Fact carrier; `:self` routes to the call
@@ -1291,7 +1426,9 @@ module Rigor
1291
1426
  end
1292
1427
 
1293
1428
  def resolve_rbs_extended_method(node, scope)
1294
- receiver_type = scope.type_of(node.receiver)
1429
+ receiver_type = receiver_type_for_resolve(node, scope)
1430
+ return nil if receiver_type.nil?
1431
+
1295
1432
  class_name = rbs_extended_class_name(receiver_type)
1296
1433
  return nil if class_name.nil?
1297
1434
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
@@ -1305,6 +1442,13 @@ module Rigor
1305
1442
  nil
1306
1443
  end
1307
1444
 
1445
+ # Implicit self call (`admin?` with no receiver inside an
1446
+ # instance method body) — read the method definition from
1447
+ # `scope.self_type` per v0.1.1 Track 1 slice 3.
1448
+ def receiver_type_for_resolve(node, scope)
1449
+ node.receiver.nil? ? scope.self_type : scope.type_of(node.receiver)
1450
+ end
1451
+
1308
1452
  def rbs_extended_class_name(receiver_type)
1309
1453
  case receiver_type
1310
1454
  when Type::Nominal, Type::Singleton then receiver_type.class_name