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.
- checksums.yaml +7 -0
- data/LICENSE +373 -0
- data/README.md +152 -0
- data/exe/rigor +9 -0
- data/lib/rigor/analysis/check_rules.rb +503 -0
- data/lib/rigor/analysis/diagnostic.rb +35 -0
- data/lib/rigor/analysis/fact_store.rb +133 -0
- data/lib/rigor/analysis/result.rb +29 -0
- data/lib/rigor/analysis/runner.rb +119 -0
- data/lib/rigor/ast/type_node.rb +41 -0
- data/lib/rigor/ast.rb +22 -0
- data/lib/rigor/cli/type_of_command.rb +160 -0
- data/lib/rigor/cli/type_of_renderer.rb +88 -0
- data/lib/rigor/cli/type_scan_command.rb +160 -0
- data/lib/rigor/cli/type_scan_renderer.rb +165 -0
- data/lib/rigor/cli/type_scan_report.rb +32 -0
- data/lib/rigor/cli.rb +195 -0
- data/lib/rigor/configuration.rb +49 -0
- data/lib/rigor/environment/class_registry.rb +141 -0
- data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
- data/lib/rigor/environment/rbs_loader.rb +244 -0
- data/lib/rigor/environment.rb +177 -0
- data/lib/rigor/inference/acceptance.rb +444 -0
- data/lib/rigor/inference/block_parameter_binder.rb +198 -0
- data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
- data/lib/rigor/inference/coverage_scanner.rb +85 -0
- data/lib/rigor/inference/expression_typer.rb +831 -0
- data/lib/rigor/inference/fallback.rb +35 -0
- data/lib/rigor/inference/fallback_tracer.rb +64 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
- data/lib/rigor/inference/method_dispatcher.rb +213 -0
- data/lib/rigor/inference/method_parameter_binder.rb +257 -0
- data/lib/rigor/inference/multi_target_binder.rb +143 -0
- data/lib/rigor/inference/narrowing.rb +1008 -0
- data/lib/rigor/inference/rbs_type_translator.rb +219 -0
- data/lib/rigor/inference/scope_indexer.rb +468 -0
- data/lib/rigor/inference/statement_evaluator.rb +1017 -0
- data/lib/rigor/rbs_extended.rb +98 -0
- data/lib/rigor/scope.rb +340 -0
- data/lib/rigor/source/node_locator.rb +104 -0
- data/lib/rigor/source/node_walker.rb +37 -0
- data/lib/rigor/source.rb +15 -0
- data/lib/rigor/testing.rb +65 -0
- data/lib/rigor/trinary.rb +108 -0
- data/lib/rigor/type/accepts_result.rb +109 -0
- data/lib/rigor/type/bot.rb +57 -0
- data/lib/rigor/type/combinator.rb +148 -0
- data/lib/rigor/type/constant.rb +90 -0
- data/lib/rigor/type/dynamic.rb +60 -0
- data/lib/rigor/type/hash_shape.rb +246 -0
- data/lib/rigor/type/nominal.rb +83 -0
- data/lib/rigor/type/singleton.rb +65 -0
- data/lib/rigor/type/top.rb +56 -0
- data/lib/rigor/type/tuple.rb +84 -0
- data/lib/rigor/type/union.rb +65 -0
- data/lib/rigor/type.rb +23 -0
- data/lib/rigor/version.rb +5 -0
- data/lib/rigor.rb +29 -0
- data/sig/rigor/analysis/fact_store.rbs +51 -0
- data/sig/rigor/ast.rbs +11 -0
- data/sig/rigor/environment.rbs +59 -0
- data/sig/rigor/inference.rbs +151 -0
- data/sig/rigor/rbs_extended.rbs +22 -0
- data/sig/rigor/scope.rbs +49 -0
- data/sig/rigor/source.rbs +20 -0
- data/sig/rigor/testing.rbs +9 -0
- data/sig/rigor/trinary.rbs +29 -0
- data/sig/rigor/type.rbs +171 -0
- data/sig/rigor.rbs +70 -0
- 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
|