rigortype 0.0.1

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Slice 6 phase C sub-phase 3a — closure-escape classification.
8
+ #
9
+ # Given a `(receiver_type, method_name)` pair representing a
10
+ # block-accepting call, this analyzer answers one question:
11
+ # does the receiver's method invoke its block **immediately and
12
+ # synchronously**, without retaining the block past the call?
13
+ #
14
+ # The answer is one of three outcomes:
15
+ #
16
+ # - `:non_escaping` — the block is proven to be invoked
17
+ # immediately, zero or more times, and is NOT retained past
18
+ # the call. The receiver does not store the block in an
19
+ # instance variable, return it as a value, or schedule it for
20
+ # later invocation. Outer-local narrowing facts that survive
21
+ # the block body MAY safely survive the call.
22
+ # - `:escaping` — the block is proven to be retained past the
23
+ # call (stored, returned, or invoked asynchronously). Outer
24
+ # narrowing facts on locals the block can rebind MUST be
25
+ # dropped at the call boundary.
26
+ # - `:unknown` — the analyzer cannot prove either edge. Callers
27
+ # MUST treat `:unknown` as conservatively as `:escaping` for
28
+ # the purposes of fact retention; the distinction exists so
29
+ # diagnostics and later RBS-Extended effect plumbing can
30
+ # tell "deliberately conservative" apart from "declared
31
+ # escape".
32
+ #
33
+ # ## Catalogue
34
+ #
35
+ # Sub-phase 3a is RBS-blind: it ships a hardcoded catalogue
36
+ # keyed by Ruby class name. A future sub-phase will replace
37
+ # this with an `RBS::Extended` call-timing effect read from
38
+ # method signatures. The catalogue therefore covers ONLY the
39
+ # core-and-stdlib surface where immediate invocation is part of
40
+ # the documented contract:
41
+ #
42
+ # - `Array`, `Hash`, `Range`, `Integer`, `Enumerator::Lazy`
43
+ # iteration methods (`each`, `map`, `select`, `reject`,
44
+ # `flat_map`, `find`/`detect`, `any?`, `all?`, `none?`,
45
+ # `one?`, `count`, `inject`/`reduce`, `each_with_index`,
46
+ # `each_with_object`, `min_by`, `max_by`, `sort_by`,
47
+ # `partition`, `group_by`, `tally`, `sum`, `take_while`,
48
+ # `drop_while`, `chunk_while`, `slice_when`, `zip`,
49
+ # `collect`, `collect_concat`, `filter`, `filter_map`).
50
+ # - `Hash`-only iteration: `each_pair`, `each_key`, `each_value`,
51
+ # `transform_keys`, `transform_values`.
52
+ # - `Integer#times`, `Integer#upto`, `Integer#downto`,
53
+ # `Range#each`, `Range#step`.
54
+ # - `Object#tap`, `Object#then`, `Object#yield_self`.
55
+ # - Tuple/HashShape carriers map to Array/Hash for catalogue
56
+ # lookup so a literal `[1, 2, 3].each { ... }` is recognised.
57
+ #
58
+ # Anything outside the catalogue resolves to `:unknown`. The
59
+ # catalogue is intentionally narrow: adding entries requires
60
+ # confirming, by reading the method's stdlib documentation,
61
+ # that the block is not retained. False positives in this
62
+ # catalogue would silently weaken the soundness of fact
63
+ # retention in later sub-phases.
64
+ #
65
+ # The analyzer is a pure query. It MUST NOT mutate the
66
+ # receiver type or scope, MUST NOT raise on unrecognised
67
+ # inputs, and MUST be deterministic for a given input.
68
+ module ClosureEscapeAnalyzer
69
+ module_function
70
+
71
+ # @param receiver_type [Rigor::Type, nil]
72
+ # @param method_name [Symbol]
73
+ # @param environment [Rigor::Environment, nil] reserved for the
74
+ # future sub-phase that consults `RBS::Extended` call-timing
75
+ # effects; sub-phase 3a ignores it.
76
+ # @return [Symbol] one of `:non_escaping`, `:escaping`, `:unknown`.
77
+ def classify(receiver_type:, method_name:, environment: nil) # rubocop:disable Lint/UnusedMethodArgument
78
+ return :unknown if receiver_type.nil?
79
+
80
+ class_name = receiver_class_name(receiver_type)
81
+ return :unknown if class_name.nil?
82
+
83
+ method_sym = method_name.to_sym
84
+ return :non_escaping if non_escaping?(class_name, method_sym)
85
+ return :escaping if escaping?(class_name, method_sym)
86
+
87
+ :unknown
88
+ end
89
+
90
+ class << self
91
+ private
92
+
93
+ # Resolve a single concrete class name for catalogue lookup.
94
+ # Returns `nil` when the receiver carrier does not name a
95
+ # single class (e.g. `Top`, `Dynamic[Top]`, `Union[...]`,
96
+ # `Bot`). `Tuple` projects to `Array`; `HashShape` to `Hash`;
97
+ # `Singleton[C]` to `C` (so `Integer.times` would resolve as
98
+ # a singleton call, but the catalogue today only lists
99
+ # instance-side methods on `Integer`, so a hit there would
100
+ # be unsurprising — kept for forward consistency).
101
+ def receiver_class_name(receiver_type)
102
+ case receiver_type
103
+ when Type::Nominal, Type::Singleton then receiver_type.class_name
104
+ when Type::Tuple then "Array"
105
+ when Type::HashShape then "Hash"
106
+ when Type::Constant then constant_class_name(receiver_type.value)
107
+ end
108
+ end
109
+
110
+ # `Rigor::Type::Constant` only carries scalar literals
111
+ # (`Integer`, `Float`, `String`, `Symbol`, `Range`, booleans,
112
+ # `nil`); the carrier explicitly rejects mutable container
113
+ # values, so we only project from those scalar shapes here.
114
+ CONSTANT_CLASS_NAMES = {
115
+ Integer => "Integer",
116
+ String => "String",
117
+ Symbol => "Symbol",
118
+ Range => "Range",
119
+ TrueClass => "TrueClass",
120
+ FalseClass => "FalseClass",
121
+ NilClass => "NilClass"
122
+ }.freeze
123
+ private_constant :CONSTANT_CLASS_NAMES
124
+
125
+ def constant_class_name(value)
126
+ CONSTANT_CLASS_NAMES.each { |klass, name| return name if value.is_a?(klass) }
127
+ nil
128
+ end
129
+
130
+ def non_escaping?(class_name, method_sym)
131
+ methods = NON_ESCAPING[class_name]
132
+ return true if methods&.include?(method_sym)
133
+
134
+ # Object#tap/then/yield_self are inherited by every class.
135
+ OBJECT_NON_ESCAPING.include?(method_sym)
136
+ end
137
+
138
+ def escaping?(class_name, method_sym)
139
+ methods = ESCAPING[class_name]
140
+ methods ? methods.include?(method_sym) : false
141
+ end
142
+ end
143
+
144
+ OBJECT_NON_ESCAPING = %i[tap then yield_self].freeze
145
+
146
+ ENUMERABLE_NON_ESCAPING = %i[
147
+ each map collect flat_map collect_concat
148
+ select filter reject filter_map
149
+ find detect find_index find_all
150
+ any? all? none? one? count tally sum
151
+ inject reduce
152
+ each_with_index each_with_object
153
+ min_by max_by sort_by minmax_by
154
+ partition group_by chunk chunk_while slice_when slice_before slice_after
155
+ take_while drop_while
156
+ zip
157
+ ].freeze
158
+
159
+ ARRAY_EXTRA = %i[each_index].freeze
160
+ HASH_EXTRA = %i[
161
+ each_pair each_key each_value
162
+ transform_keys transform_values
163
+ delete_if keep_if
164
+ any? all? none? one?
165
+ ].freeze
166
+ RANGE_EXTRA = %i[step].freeze
167
+ INTEGER_EXTRA = %i[times upto downto].freeze
168
+
169
+ NON_ESCAPING = {
170
+ "Array" => (ENUMERABLE_NON_ESCAPING + ARRAY_EXTRA).freeze,
171
+ "Hash" => (ENUMERABLE_NON_ESCAPING + HASH_EXTRA).freeze,
172
+ "Range" => (ENUMERABLE_NON_ESCAPING + RANGE_EXTRA).freeze,
173
+ "Set" => ENUMERABLE_NON_ESCAPING,
174
+ "Integer" => INTEGER_EXTRA,
175
+ "Enumerator" => ENUMERABLE_NON_ESCAPING,
176
+ "Enumerator::Lazy" => ENUMERABLE_NON_ESCAPING
177
+ }.freeze
178
+
179
+ # Methods that are documented to **retain** the block past the
180
+ # call. The block is stored or scheduled, so outer narrowing
181
+ # facts on writeable captured locals cannot survive.
182
+ ESCAPING = {
183
+ "Module" => %i[define_method].freeze,
184
+ "Class" => %i[define_method].freeze,
185
+ "Thread" => %i[new start fork].freeze,
186
+ "Fiber" => %i[new].freeze,
187
+ "Proc" => %i[new].freeze
188
+ }.freeze
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source/node_walker"
4
+ require_relative "../scope"
5
+ require_relative "fallback_tracer"
6
+ require_relative "scope_indexer"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # Walks an AST and reports per-node-class coverage of `Rigor::Scope#type_of`.
11
+ #
12
+ # For every visited node the scanner runs `type_of` with a fresh
13
+ # `FallbackTracer` and inspects the first recorded event:
14
+ #
15
+ # * If the first event's `node_class` matches the visited node's class,
16
+ # the engine entered the fallback (else) branch *for this very node* —
17
+ # the node is counted as **directly unrecognized**.
18
+ # * Otherwise the typer either succeeded outright or recursed into a child
19
+ # that itself was unrecognized; the visited node is counted as
20
+ # recognized so pass-through wrappers (`ProgramNode`, `StatementsNode`,
21
+ # `ParenthesesNode`, ...) are not double-counted along with their leaves.
22
+ #
23
+ # This class is intended for tooling probes and CI gates rather than the
24
+ # hot inference path: it allocates a tracer per visited node and discards
25
+ # the inferred type values.
26
+ class CoverageScanner
27
+ Result = Data.define(:visits, :unrecognized, :events) do
28
+ # @return [Integer] sum of all visits across node classes.
29
+ def visited_count
30
+ visits.values.sum
31
+ end
32
+
33
+ # @return [Integer] sum of directly-unrecognized counts across classes.
34
+ def unrecognized_count
35
+ unrecognized.values.sum
36
+ end
37
+
38
+ # @return [Float] unrecognized_count / visited_count, or 0.0 when empty.
39
+ def unrecognized_ratio
40
+ total = visited_count
41
+ return 0.0 if total.zero?
42
+
43
+ unrecognized_count.fdiv(total)
44
+ end
45
+ end
46
+
47
+ # @param scope [Rigor::Scope] base scope used for every type_of call. Defaults to `Scope.empty`.
48
+ def initialize(scope: nil)
49
+ @scope = scope || Scope.empty
50
+ end
51
+
52
+ # @param root [Prism::Node]
53
+ # @return [Result]
54
+ def scan(root)
55
+ visits = Hash.new(0)
56
+ unrecognized = Hash.new(0)
57
+ events = []
58
+
59
+ # Build the per-node scope index once per scan so locals bound
60
+ # earlier in the program flow into the scope used to type every
61
+ # later node. The indexer walks the program with a tracer-less
62
+ # StatementEvaluator and propagates the recorded scope down to
63
+ # expression-interior nodes the evaluator does not visit. The
64
+ # second pass below re-types each node with its own tracer so
65
+ # the per-class fallback statistics stay attributable.
66
+ scope_index = ScopeIndexer.index(root, default_scope: @scope)
67
+
68
+ Source::NodeWalker.each(root) do |node|
69
+ visits[node.class] += 1
70
+
71
+ tracer = FallbackTracer.new
72
+ scope_index[node].type_of(node, tracer: tracer)
73
+
74
+ first_event = tracer.events.first
75
+ next unless first_event && first_event.node_class == node.class
76
+
77
+ unrecognized[node.class] += 1
78
+ events << first_event
79
+ end
80
+
81
+ Result.new(visits: visits, unrecognized: unrecognized, events: events)
82
+ end
83
+ end
84
+ end
85
+ end