rigortype 0.2.4 → 0.2.6
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 +4 -4
- data/docs/handbook/09-plugins.md +5 -2
- data/docs/handbook/appendix-liskov.md +5 -3
- data/docs/handbook/appendix-phpstan.md +2 -2
- data/docs/install.md +1 -1
- data/docs/manual/02-cli-reference.md +58 -1
- data/docs/manual/06-baseline.md +12 -0
- data/docs/manual/11-ci.md +6 -6
- data/docs/manual/15-type-protection-coverage.md +29 -0
- data/docs/manual/plugins/rigor-minitest.md +1 -1
- data/docs/manual/plugins/rigor-rails-i18n.md +22 -3
- data/lib/rigor/analysis/incremental_session.rb +7 -2
- data/lib/rigor/cli/check_command.rb +4 -33
- data/lib/rigor/cli/check_runner_factory.rb +63 -0
- data/lib/rigor/cli/doctor_command.rb +295 -0
- data/lib/rigor/cli/plugins_command.rb +2 -2
- data/lib/rigor/cli/plugins_renderer.rb +1 -1
- data/lib/rigor/cli/protection_renderer.rb +32 -2
- data/lib/rigor/cli/protection_report.rb +32 -6
- data/lib/rigor/cli/upgrade_command.rb +25 -0
- data/lib/rigor/cli.rb +17 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/inference/dynamic_origin.rb +67 -0
- data/lib/rigor/inference/expression_typer.rb +22 -10
- data/lib/rigor/inference/fallback.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
- data/lib/rigor/inference/method_dispatcher.rb +19 -4
- data/lib/rigor/inference/mutation_widening.rb +18 -0
- data/lib/rigor/inference/protection_scanner.rb +6 -3
- data/lib/rigor/inference/statement_evaluator.rb +5 -4
- data/lib/rigor/plugin/base.rb +34 -7
- data/lib/rigor/plugin/registry.rb +1 -1
- data/lib/rigor/scope.rb +16 -5
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +52 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +123 -8
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
- data/sig/rigor/plugin/base.rbs +2 -0
- data/sig/rigor/scope.rbs +3 -1
- data/skills/rigor-plugin-author/SKILL.md +8 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
- metadata +27 -3
|
@@ -9,6 +9,7 @@ require_relative "../analysis/self_call_resolution_recorder"
|
|
|
9
9
|
require_relative "block_parameter_binder"
|
|
10
10
|
require_relative "body_fixpoint"
|
|
11
11
|
require_relative "budget_trace"
|
|
12
|
+
require_relative "dynamic_origin"
|
|
12
13
|
require_relative "fallback"
|
|
13
14
|
require_relative "flow_tracer"
|
|
14
15
|
require_relative "indexed_narrowing"
|
|
@@ -224,15 +225,20 @@ module Rigor
|
|
|
224
225
|
def initialize(scope:, tracer: nil)
|
|
225
226
|
@scope = scope
|
|
226
227
|
@tracer = tracer
|
|
228
|
+
@typing_node = nil
|
|
227
229
|
end
|
|
228
230
|
|
|
229
231
|
def type_of(node)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
previous = @typing_node
|
|
233
|
+
@typing_node = node
|
|
234
|
+
result = if FlowTracer.active?
|
|
235
|
+
FlowTracer.trace_node(node) { untraced_type_of(node) }
|
|
236
|
+
else
|
|
237
|
+
untraced_type_of(node)
|
|
238
|
+
end
|
|
239
|
+
result
|
|
240
|
+
ensure
|
|
241
|
+
@typing_node = previous
|
|
236
242
|
end
|
|
237
243
|
|
|
238
244
|
def untraced_type_of(node)
|
|
@@ -1011,11 +1017,12 @@ module Rigor
|
|
|
1011
1017
|
|
|
1012
1018
|
def fallback_for(node, family:)
|
|
1013
1019
|
inner = dynamic_top
|
|
1014
|
-
record_fallback(node, family: family, inner_type: inner)
|
|
1020
|
+
record_fallback(node, family: family, inner_type: inner, origin: DynamicOrigin::UNSUPPORTED_SYNTAX)
|
|
1021
|
+
scope.record_dynamic_origin(node, DynamicOrigin::UNSUPPORTED_SYNTAX)
|
|
1015
1022
|
inner
|
|
1016
1023
|
end
|
|
1017
1024
|
|
|
1018
|
-
def record_fallback(node, family:, inner_type:)
|
|
1025
|
+
def record_fallback(node, family:, inner_type:, origin: nil)
|
|
1019
1026
|
return unless tracer
|
|
1020
1027
|
|
|
1021
1028
|
location = node.respond_to?(:location) ? node.location : nil
|
|
@@ -1023,7 +1030,8 @@ module Rigor
|
|
|
1023
1030
|
node_class: node.class,
|
|
1024
1031
|
location: location,
|
|
1025
1032
|
family: family,
|
|
1026
|
-
inner_type: inner_type
|
|
1033
|
+
inner_type: inner_type,
|
|
1034
|
+
origin: origin
|
|
1027
1035
|
)
|
|
1028
1036
|
tracer.record_fallback(event)
|
|
1029
1037
|
end
|
|
@@ -2128,6 +2136,7 @@ module Rigor
|
|
|
2128
2136
|
# this signature's summary sees the floor, not the stale `bot` seed.
|
|
2129
2137
|
def degrade_entangled_fixpoint(summaries, plain_signature)
|
|
2130
2138
|
BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
|
|
2139
|
+
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
2131
2140
|
summaries[plain_signature][:assumption] = Type::Combinator.untyped
|
|
2132
2141
|
Type::Combinator.untyped
|
|
2133
2142
|
end
|
|
@@ -2152,6 +2161,7 @@ module Rigor
|
|
|
2152
2161
|
# Out of iterations and still unstable — collapse to today's
|
|
2153
2162
|
# widening behaviour.
|
|
2154
2163
|
BudgetTrace.hit(BudgetTrace::RECURSION_FIXPOINT_CAP)
|
|
2164
|
+
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
2155
2165
|
summaries[plain_signature][:assumption] = Type::Combinator.untyped
|
|
2156
2166
|
return Type::Combinator.untyped
|
|
2157
2167
|
end
|
|
@@ -2244,6 +2254,7 @@ module Rigor
|
|
|
2244
2254
|
return type unless would_have_been_guarded && !fully_value_pinned?(type)
|
|
2245
2255
|
|
|
2246
2256
|
BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
|
|
2257
|
+
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
2247
2258
|
# ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned
|
|
2248
2259
|
# must be byte-identical to the plain guard's `untyped`. This path
|
|
2249
2260
|
# deliberately does NOT route to the in-progress fixpoint summary:
|
|
@@ -2363,7 +2374,8 @@ module Rigor
|
|
|
2363
2374
|
locals: locals.freeze,
|
|
2364
2375
|
self_type: receiver,
|
|
2365
2376
|
discovery: scope.discovery,
|
|
2366
|
-
struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body)
|
|
2377
|
+
struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body),
|
|
2378
|
+
dynamic_origins: scope.dynamic_origins
|
|
2367
2379
|
)
|
|
2368
2380
|
end
|
|
2369
2381
|
|
|
@@ -20,8 +20,8 @@ module Rigor
|
|
|
20
20
|
# - inner_type: the Rigor::Type returned to the caller (currently
|
|
21
21
|
# always Dynamic[Top]; later slices may carry richer fallback
|
|
22
22
|
# types).
|
|
23
|
-
class Fallback < Data.define(:node_class, :location, :family, :inner_type)
|
|
24
|
-
def initialize(node_class:, location:, family:, inner_type:)
|
|
23
|
+
class Fallback < Data.define(:node_class, :location, :family, :inner_type, :origin)
|
|
24
|
+
def initialize(node_class:, location:, family:, inner_type:, origin: nil)
|
|
25
25
|
raise ArgumentError, "node_class must be a Class, got #{node_class.class}" unless node_class.is_a?(Class)
|
|
26
26
|
|
|
27
27
|
unless FALLBACK_FAMILIES.include?(family)
|
|
@@ -218,11 +218,27 @@ module Rigor
|
|
|
218
218
|
# is what ultimately limits how wide an inferred type gets.
|
|
219
219
|
UNION_FOLD_OUTPUT_LIMIT = 8
|
|
220
220
|
|
|
221
|
+
# ADR-78 — reflexive over-fold guard.
|
|
222
|
+
# Reflective dispatch (`public_send` / `send` / `__send__`) must
|
|
223
|
+
# NOT constant-fold unless the method-name argument is itself a
|
|
224
|
+
# value-pinned literal `Constant[Symbol]`. With a runtime-variable
|
|
225
|
+
# method name the dispatched method is not statically determined,
|
|
226
|
+
# so the call degrades to the RBS result (`untyped`) — exactly as
|
|
227
|
+
# it does without the guard, but explicit so a later shape-carrier
|
|
228
|
+
# preservation tier (ADR-76 WD2) cannot surface an over-fold as a
|
|
229
|
+
# spurious `flow.always-truthy-condition`.
|
|
230
|
+
REFLECTIVE_SEND_METHODS = %i[public_send send __send__].to_set.freeze
|
|
231
|
+
|
|
221
232
|
# @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
|
|
222
233
|
def try_dispatch(context)
|
|
223
234
|
receiver = context.receiver
|
|
224
235
|
method_name = context.method_name
|
|
225
236
|
args = context.args
|
|
237
|
+
|
|
238
|
+
if REFLECTIVE_SEND_METHODS.include?(method_name)
|
|
239
|
+
first_arg = args.first
|
|
240
|
+
return nil unless first_arg.is_a?(Type::Constant) && first_arg.value.is_a?(Symbol)
|
|
241
|
+
end
|
|
226
242
|
# v0.0.7 — `String#%` against a `Tuple` / `HashShape`
|
|
227
243
|
# argument runs Ruby's format-string engine when both
|
|
228
244
|
# sides are statically constant. The standard
|
|
@@ -102,7 +102,11 @@ module Rigor
|
|
|
102
102
|
find_index: :tuple_find_index,
|
|
103
103
|
rindex: :tuple_rindex,
|
|
104
104
|
flatten: :tuple_flatten,
|
|
105
|
-
join: :tuple_join
|
|
105
|
+
join: :tuple_join,
|
|
106
|
+
freeze: :shape_self,
|
|
107
|
+
dup: :shape_self,
|
|
108
|
+
clone: :shape_self,
|
|
109
|
+
itself: :shape_self
|
|
106
110
|
}.freeze
|
|
107
111
|
|
|
108
112
|
# Byte cap on a folded `tuple.join` result — a huge tuple times a
|
|
@@ -151,7 +155,23 @@ module Rigor
|
|
|
151
155
|
:< => :hash_compare,
|
|
152
156
|
:<= => :hash_compare,
|
|
153
157
|
:> => :hash_compare,
|
|
154
|
-
:>= => :hash_compare
|
|
158
|
+
:>= => :hash_compare,
|
|
159
|
+
# ADR-76 WD2 / ADR-78 WD3 — pure self-returners preserve the
|
|
160
|
+
# `HashShape` carrier instead of degrading to the nominal `Hash`
|
|
161
|
+
# via the RBS `() -> self` signature, so
|
|
162
|
+
# `MESSAGES = {…}.freeze; MESSAGES[reason]` folds the value union
|
|
163
|
+
# rather than reading `Dynamic`. `dup` / `clone` produce a fresh
|
|
164
|
+
# object, but Rigor's shape carriers are immutable values, so
|
|
165
|
+
# preserving the carrier is sound for reads; a later in-place
|
|
166
|
+
# mutation routes through `MutationWidening`. (The `Tuple` table
|
|
167
|
+
# carries the same four entries; both became safe once the
|
|
168
|
+
# block-form over-fold guard landed in `try_dispatch` — ADR-78
|
|
169
|
+
# WD1 — so `CONST = [...].freeze; CONST.any? { … }` no longer
|
|
170
|
+
# folds the no-block result and fires a reflexive always-truthy.)
|
|
171
|
+
freeze: :shape_self,
|
|
172
|
+
dup: :shape_self,
|
|
173
|
+
clone: :shape_self,
|
|
174
|
+
itself: :shape_self
|
|
155
175
|
}.freeze
|
|
156
176
|
|
|
157
177
|
# @return [Rigor::Type, nil] the precise element/value type, or
|
|
@@ -189,6 +209,17 @@ module Rigor
|
|
|
189
209
|
method_name = context.method_name
|
|
190
210
|
args = context.args
|
|
191
211
|
args ||= []
|
|
212
|
+
# ADR-78 WD1 — every shape handler folds *no-block* semantics; none
|
|
213
|
+
# evaluates a passed block. So a block-form call (`tuple.any? { … }`,
|
|
214
|
+
# `tuple.sum { … }`, `tuple.count { … }`) must NOT fold the no-block
|
|
215
|
+
# result — doing so ignores the block (an over-fold: `any? { false }`
|
|
216
|
+
# would still fold `Constant[true]`). Declining defers to BlockFolding
|
|
217
|
+
# / RBS. This is the over-fold class the Tuple shape-carrier
|
|
218
|
+
# preservation (ADR-76 WD2) surfaced as reflexive `always-truthy` on
|
|
219
|
+
# `CONST = [...].freeze; CONST.any? { … }`; fixing it at the fold (not
|
|
220
|
+
# the rule) unblocks that preservation.
|
|
221
|
+
return nil unless context.block_type.nil?
|
|
222
|
+
|
|
192
223
|
handler = RECEIVER_HANDLERS[receiver.class]
|
|
193
224
|
return nil unless handler
|
|
194
225
|
|
|
@@ -236,6 +267,14 @@ module Rigor
|
|
|
236
267
|
send(handler, shape, method_name, args)
|
|
237
268
|
end
|
|
238
269
|
|
|
270
|
+
# ADR-76 WD2 / ADR-78 WD3 — a pure self-returner
|
|
271
|
+
# (`freeze` / `dup` / `clone` / `itself`) returns the receiver
|
|
272
|
+
# carrier unchanged, preserving the shape that the nominal
|
|
273
|
+
# `() -> self` RBS signature would otherwise drop.
|
|
274
|
+
def shape_self(carrier, _method_name, _args)
|
|
275
|
+
carrier
|
|
276
|
+
end
|
|
277
|
+
|
|
239
278
|
def dispatch_nominal_size(nominal, method_name, args)
|
|
240
279
|
projection = nominal_projection(nominal, method_name, args)
|
|
241
280
|
return projection if projection
|
|
@@ -6,6 +6,7 @@ require_relative "../flow_contribution"
|
|
|
6
6
|
require_relative "../flow_contribution/merger"
|
|
7
7
|
require_relative "../builtins/hkt_builtins"
|
|
8
8
|
require_relative "../builtins/static_return_refinements"
|
|
9
|
+
require_relative "dynamic_origin"
|
|
9
10
|
require_relative "flow_tracer"
|
|
10
11
|
require_relative "method_dispatcher/call_context"
|
|
11
12
|
require_relative "method_dispatcher/constant_folding"
|
|
@@ -83,7 +84,7 @@ module Rigor
|
|
|
83
84
|
result
|
|
84
85
|
end
|
|
85
86
|
|
|
86
|
-
def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
87
|
+
def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
87
88
|
block_type: nil, environment: nil,
|
|
88
89
|
call_node: nil, scope: nil)
|
|
89
90
|
return nil if receiver_type.nil?
|
|
@@ -114,7 +115,12 @@ module Rigor
|
|
|
114
115
|
# are supplied — the dispatcher's own internal callers
|
|
115
116
|
# (per-element block fold, etc.) skip this tier.
|
|
116
117
|
plugin_result = try_plugin_contribution(call_node, scope, receiver_type)
|
|
117
|
-
|
|
118
|
+
if plugin_result
|
|
119
|
+
if plugin_result.is_a?(Type::Dynamic)
|
|
120
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::FRAMEWORK_DSL_BOUNDARY)
|
|
121
|
+
end
|
|
122
|
+
return plugin_result
|
|
123
|
+
end
|
|
118
124
|
|
|
119
125
|
# ADR-20 slice 3 — Rigor-bundled HKT-builtin return-
|
|
120
126
|
# type tier. Sits ABOVE `RbsDispatch.try_dispatch` so
|
|
@@ -145,6 +151,9 @@ module Rigor
|
|
|
145
151
|
rbs_result = RbsDispatch.try_dispatch(context)
|
|
146
152
|
if rbs_result
|
|
147
153
|
record_boundary_cross_if_applicable(receiver_type, method_name, rbs_result, environment)
|
|
154
|
+
if rbs_result.is_a?(Type::Dynamic) && rbs_result.static_facet.is_a?(Type::Top)
|
|
155
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::EXPLICIT_UNTYPED)
|
|
156
|
+
end
|
|
148
157
|
return rbs_result
|
|
149
158
|
end
|
|
150
159
|
|
|
@@ -174,7 +183,10 @@ module Rigor
|
|
|
174
183
|
# `Dynamic[top]` so the patched call resolves
|
|
175
184
|
# cross-file without `call.undefined-method`.
|
|
176
185
|
patched_result = try_project_patched_method(receiver_type, method_name, environment)
|
|
177
|
-
|
|
186
|
+
if patched_result
|
|
187
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
|
|
188
|
+
return patched_result
|
|
189
|
+
end
|
|
178
190
|
|
|
179
191
|
# ADR-10 slice 2b-ii — dependency-source inference tier.
|
|
180
192
|
# Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
|
|
@@ -186,7 +198,10 @@ module Rigor
|
|
|
186
198
|
# dynamic-origin envelope; per-method return-type
|
|
187
199
|
# precision is queued for a later slice.
|
|
188
200
|
dep_source_result = try_dependency_source(receiver_type, method_name, environment)
|
|
189
|
-
|
|
201
|
+
if dep_source_result
|
|
202
|
+
scope&.record_dynamic_origin(call_node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
|
|
203
|
+
return dep_source_result
|
|
204
|
+
end
|
|
190
205
|
|
|
191
206
|
# v0.1.3 — discovered-method dispatch tier. When the
|
|
192
207
|
# receiver class has no RBS BUT scope_indexer recorded
|
|
@@ -102,8 +102,22 @@ module Rigor
|
|
|
102
102
|
replace
|
|
103
103
|
].to_set.freeze
|
|
104
104
|
|
|
105
|
+
# Methods that return the receiver (or a shallow copy) and
|
|
106
|
+
# cannot mutate it. They must not trigger widening or any
|
|
107
|
+
# other receiver-fact invalidation. The list is intentionally
|
|
108
|
+
# narrow — only methods whose purity is unconditional and
|
|
109
|
+
# whose return value is the receiver itself (or a copy that
|
|
110
|
+
# leaves the original untouched).
|
|
111
|
+
PURE_SELF_RETURNERS = %i[freeze dup clone itself].freeze
|
|
112
|
+
|
|
105
113
|
module_function
|
|
106
114
|
|
|
115
|
+
# True when `method_name` is a pure self-returner that must
|
|
116
|
+
# not invalidate the receiver's facts.
|
|
117
|
+
def pure_self_returner?(method_name)
|
|
118
|
+
PURE_SELF_RETURNERS.include?(method_name)
|
|
119
|
+
end
|
|
120
|
+
|
|
107
121
|
# Returns a scope with the call's receiver widened, when the
|
|
108
122
|
# receiver is a local-/instance-variable read whose current
|
|
109
123
|
# binding is a literal-shape carrier (`Tuple` / `HashShape`)
|
|
@@ -114,6 +128,8 @@ module Rigor
|
|
|
114
128
|
# @param current_scope [Rigor::Scope]
|
|
115
129
|
# @return [Rigor::Scope]
|
|
116
130
|
def widen_after_call(call_node:, current_scope:)
|
|
131
|
+
return current_scope if pure_self_returner?(call_node.name)
|
|
132
|
+
|
|
117
133
|
receiver = call_node.receiver
|
|
118
134
|
return current_scope if receiver.nil?
|
|
119
135
|
|
|
@@ -181,6 +197,8 @@ module Rigor
|
|
|
181
197
|
end
|
|
182
198
|
|
|
183
199
|
def widen_for_outer_receiver(call_node, scope)
|
|
200
|
+
return scope if pure_self_returner?(call_node.name)
|
|
201
|
+
|
|
184
202
|
receiver = call_node.receiver
|
|
185
203
|
return scope if receiver.nil?
|
|
186
204
|
|
|
@@ -19,7 +19,7 @@ module Rigor
|
|
|
19
19
|
# (does a diagnostic fire) is the phased mutation tier.
|
|
20
20
|
class ProtectionScanner
|
|
21
21
|
# A single unprotected call site.
|
|
22
|
-
Site = Data.define(:line, :receiver, :method_name)
|
|
22
|
+
Site = Data.define(:line, :receiver, :method_name, :dynamic_origin)
|
|
23
23
|
|
|
24
24
|
FileResult = Data.define(:protected_count, :unprotected_count, :sites) do
|
|
25
25
|
def total = protected_count + unprotected_count
|
|
@@ -43,14 +43,17 @@ module Rigor
|
|
|
43
43
|
Source::NodeWalker.each(root) do |node|
|
|
44
44
|
next unless dispatch_site?(node)
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
scope = index[node.receiver]
|
|
47
|
+
receiver_type = scope.type_of(node.receiver)
|
|
47
48
|
if concrete_receiver?(receiver_type)
|
|
48
49
|
protected_count += 1
|
|
49
50
|
else
|
|
51
|
+
origin = scope.dynamic_origins[node.receiver]
|
|
50
52
|
sites << Site.new(
|
|
51
53
|
line: node.location.start_line,
|
|
52
54
|
receiver: safe_describe(receiver_type),
|
|
53
|
-
method_name: node.name.to_s
|
|
55
|
+
method_name: node.name.to_s,
|
|
56
|
+
dynamic_origin: origin
|
|
54
57
|
)
|
|
55
58
|
end
|
|
56
59
|
end
|
|
@@ -1861,13 +1861,13 @@ module Rigor
|
|
|
1861
1861
|
end
|
|
1862
1862
|
|
|
1863
1863
|
# ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return
|
|
1864
|
-
# narrowing from the method-gated `
|
|
1864
|
+
# narrowing from the method-gated `narrowing_facts` DSL, wrapped as
|
|
1865
1865
|
# a facts-only `FlowContribution`, swallowing per-plugin
|
|
1866
1866
|
# exceptions so a buggy plugin can't abort the assertion path.
|
|
1867
1867
|
EMPTY_CONTRIBUTIONS = [].freeze
|
|
1868
1868
|
private_constant :EMPTY_CONTRIBUTIONS
|
|
1869
1869
|
|
|
1870
|
-
# Fast-exit guard: skip if no plugin declares a `
|
|
1870
|
+
# Fast-exit guard: skip if no plugin declares a `narrowing_facts` rule, or if
|
|
1871
1871
|
# no registered method-name gate matches the call. See
|
|
1872
1872
|
# `collect_gated_statement_contributions` for the full consultation.
|
|
1873
1873
|
def collect_plugin_contributions(registry, call_node, current_scope)
|
|
@@ -1882,7 +1882,7 @@ module Rigor
|
|
|
1882
1882
|
end
|
|
1883
1883
|
|
|
1884
1884
|
# ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order.
|
|
1885
|
-
# Visits only plugins in `for_statement` (declare a `
|
|
1885
|
+
# Visits only plugins in `for_statement` (declare a `narrowing_facts` rule),
|
|
1886
1886
|
# further gated by the method-name Set probe so the common no-candidate
|
|
1887
1887
|
# case is a single lookup. Accumulates lazily; caller is read-only.
|
|
1888
1888
|
def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
|
|
@@ -2918,7 +2918,8 @@ module Rigor
|
|
|
2918
2918
|
environment: scope.environment,
|
|
2919
2919
|
locals: {}.freeze,
|
|
2920
2920
|
source_path: scope.source_path,
|
|
2921
|
-
discovery: scope.discovery
|
|
2921
|
+
discovery: scope.discovery,
|
|
2922
|
+
dynamic_origins: scope.dynamic_origins
|
|
2922
2923
|
)
|
|
2923
2924
|
end
|
|
2924
2925
|
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Rigor
|
|
|
20
20
|
#
|
|
21
21
|
# This class implements all plugin protocol hooks: per-call
|
|
22
22
|
# return-type contributions (`dynamic_return`), narrowing-fact
|
|
23
|
-
# contributions (`
|
|
23
|
+
# contributions (`narrowing_facts`), AST node rules (`node_rule`),
|
|
24
24
|
# and producer/cache hooks. Cumulative implementation per the
|
|
25
25
|
# ADR-37 / ADR-52 slice chain.
|
|
26
26
|
#
|
|
@@ -440,19 +440,32 @@ module Rigor
|
|
|
440
440
|
# `post_return_facts` slot of the deleted `flow_contribution_for`
|
|
441
441
|
# hook (ADR-52 WD3):
|
|
442
442
|
#
|
|
443
|
-
#
|
|
443
|
+
# narrowing_facts methods: [:assert_kind_of] do |call_node, scope|
|
|
444
444
|
# # return an Array of post-return facts, or nil
|
|
445
445
|
# end
|
|
446
446
|
#
|
|
447
447
|
# `methods:` is a non-empty Array of method names; the engine
|
|
448
448
|
# calls the block only when `call_node.name` is one of them. The
|
|
449
449
|
# block returns the same `post_return_facts` the merger applies.
|
|
450
|
-
|
|
451
|
-
|
|
450
|
+
#
|
|
451
|
+
# This hook supplies post-return *narrowing facts* — the
|
|
452
|
+
# predicate/assertion edges a call establishes about its
|
|
453
|
+
# arguments or receiver, e.g. `assert_kind_of(String, x)` ⇒ `x`
|
|
454
|
+
# is narrowed to `String` on the continuation. It does NOT give a
|
|
455
|
+
# call a *type*; for that use {dynamic_return} (per-call-site) or
|
|
456
|
+
# contribute RBS via the manifest `signature_paths:`.
|
|
457
|
+
# `dynamic_return` is the type slot, this is the fact slot.
|
|
458
|
+
#
|
|
459
|
+
# Renamed from `type_specifier` (ADR-80): the old name read as a
|
|
460
|
+
# parallel to `dynamic_return` (a type) when it actually returns
|
|
461
|
+
# facts. {.type_specifier} survives as a deprecating alias through
|
|
462
|
+
# 0.2.x and is removed in 0.3.0.
|
|
463
|
+
def narrowing_facts(methods:, &block)
|
|
464
|
+
raise ArgumentError, "Plugin::Base.narrowing_facts requires a block body" if block.nil?
|
|
452
465
|
unless methods.is_a?(Array) && !methods.empty? &&
|
|
453
466
|
methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
|
|
454
467
|
raise ArgumentError,
|
|
455
|
-
"Plugin::Base.
|
|
468
|
+
"Plugin::Base.narrowing_facts methods: must be a non-empty Array of Symbol/String, " \
|
|
456
469
|
"got #{methods.inspect}"
|
|
457
470
|
end
|
|
458
471
|
|
|
@@ -461,6 +474,20 @@ module Rigor
|
|
|
461
474
|
nil
|
|
462
475
|
end
|
|
463
476
|
|
|
477
|
+
# DEPRECATED (ADR-80) — renamed to {.narrowing_facts}. This hook
|
|
478
|
+
# supplies post-return narrowing *facts*, not a type; the old
|
|
479
|
+
# name misleads by parallel with {.dynamic_return}. Retained as a
|
|
480
|
+
# warning-emitting alias through 0.2.x; REMOVED in 0.3.0. Migrate
|
|
481
|
+
# `type_specifier methods: …` → `narrowing_facts methods: …`.
|
|
482
|
+
def type_specifier(methods:, &)
|
|
483
|
+
unless @type_specifier_deprecation_warned
|
|
484
|
+
@type_specifier_deprecation_warned = true
|
|
485
|
+
warn("[rigor] Plugin::Base.type_specifier is deprecated (ADR-80) and will be " \
|
|
486
|
+
"removed in Rigor 0.3.0; rename it to `narrowing_facts`. (#{name})")
|
|
487
|
+
end
|
|
488
|
+
narrowing_facts(methods:, &)
|
|
489
|
+
end
|
|
490
|
+
|
|
464
491
|
# Frozen snapshot of the declared type-specifier rules. Memoised
|
|
465
492
|
# for the same reason as {dynamic_returns} — consulted per plugin
|
|
466
493
|
# per dispatch, over an array fixed at class-definition time.
|
|
@@ -504,7 +531,7 @@ module Rigor
|
|
|
504
531
|
# production users migrated. Per-call return types are declared via
|
|
505
532
|
# the gated {.dynamic_return} DSL (static / run-time / per-file
|
|
506
533
|
# name sets, static / run-time receiver sets); post-return
|
|
507
|
-
# narrowing facts via {.
|
|
534
|
+
# narrowing facts via {.narrowing_facts}. See the CHANGELOG
|
|
508
535
|
# migration note for the idiom-by-idiom mapping.
|
|
509
536
|
|
|
510
537
|
# ADR-9 slice 3 — per-run preparation hook. The runner
|
|
@@ -625,7 +652,7 @@ module Rigor
|
|
|
625
652
|
end
|
|
626
653
|
|
|
627
654
|
# ADR-37 slice 2 — the post-return narrowing facts contributed by
|
|
628
|
-
# this plugin's {.
|
|
655
|
+
# this plugin's {.narrowing_facts} rules for a call. The engine
|
|
629
656
|
# calls this from `StatementEvaluator`; a rule fires only when
|
|
630
657
|
# `call_node.name`
|
|
631
658
|
# is one of its declared `methods:`. Failures isolate to [].
|
|
@@ -148,7 +148,7 @@ module Rigor
|
|
|
148
148
|
"plugin #{(plugin.class.name || plugin.class).inspect} defines `flow_contribution_for`, " \
|
|
149
149
|
"which was removed (ADR-52). Declare the per-call return type via `dynamic_return` " \
|
|
150
150
|
"(receivers:/methods:/file_methods: gates, static or callable) and post-return narrowing " \
|
|
151
|
-
"facts via `
|
|
151
|
+
"facts via `narrowing_facts` — see the CHANGELOG migration note."
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
# Same `Method#owner` trick for the per-file diagnostics hook —
|
data/lib/rigor/scope.rb
CHANGED
|
@@ -23,7 +23,8 @@ module Rigor
|
|
|
23
23
|
:ivars, :cvars, :globals,
|
|
24
24
|
:indexed_narrowings, :method_chain_narrowings,
|
|
25
25
|
:declaration_sourced,
|
|
26
|
-
:source_path, :discovery, :struct_fold_safe_locals
|
|
26
|
+
:source_path, :discovery, :struct_fold_safe_locals,
|
|
27
|
+
:dynamic_origins
|
|
27
28
|
|
|
28
29
|
# ADR-53 Track A — the seed-time discovery tables live on the
|
|
29
30
|
# {DiscoveryIndex} the scope carries by a single reference; the
|
|
@@ -122,6 +123,11 @@ module Rigor
|
|
|
122
123
|
end
|
|
123
124
|
end
|
|
124
125
|
|
|
126
|
+
def record_dynamic_origin(node, cause)
|
|
127
|
+
@dynamic_origins[node] = cause
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
125
131
|
def initialize(
|
|
126
132
|
environment:, locals:,
|
|
127
133
|
fact_store: Analysis::FactStore.empty,
|
|
@@ -134,7 +140,8 @@ module Rigor
|
|
|
134
140
|
method_chain_narrowings: EMPTY_CHAIN_NARROWINGS,
|
|
135
141
|
declaration_sourced: EMPTY_DECLARATION_SOURCED,
|
|
136
142
|
source_path: nil,
|
|
137
|
-
struct_fold_safe_locals: EMPTY_FOLD_SAFE
|
|
143
|
+
struct_fold_safe_locals: EMPTY_FOLD_SAFE,
|
|
144
|
+
dynamic_origins: {}.compare_by_identity
|
|
138
145
|
)
|
|
139
146
|
@environment = environment
|
|
140
147
|
@locals = locals
|
|
@@ -149,6 +156,7 @@ module Rigor
|
|
|
149
156
|
@declaration_sourced = declaration_sourced
|
|
150
157
|
@source_path = source_path
|
|
151
158
|
@struct_fold_safe_locals = struct_fold_safe_locals
|
|
159
|
+
@dynamic_origins = dynamic_origins
|
|
152
160
|
freeze
|
|
153
161
|
end
|
|
154
162
|
|
|
@@ -716,7 +724,8 @@ module Rigor
|
|
|
716
724
|
method_chain_narrowings: @method_chain_narrowings,
|
|
717
725
|
declaration_sourced: @declaration_sourced,
|
|
718
726
|
source_path: @source_path,
|
|
719
|
-
struct_fold_safe_locals: @struct_fold_safe_locals
|
|
727
|
+
struct_fold_safe_locals: @struct_fold_safe_locals,
|
|
728
|
+
dynamic_origins: @dynamic_origins
|
|
720
729
|
)
|
|
721
730
|
self.class.new(
|
|
722
731
|
environment: environment, locals: locals,
|
|
@@ -727,7 +736,8 @@ module Rigor
|
|
|
727
736
|
method_chain_narrowings: method_chain_narrowings,
|
|
728
737
|
declaration_sourced: declaration_sourced,
|
|
729
738
|
source_path: source_path,
|
|
730
|
-
struct_fold_safe_locals: struct_fold_safe_locals
|
|
739
|
+
struct_fold_safe_locals: struct_fold_safe_locals,
|
|
740
|
+
dynamic_origins: dynamic_origins
|
|
731
741
|
)
|
|
732
742
|
end
|
|
733
743
|
|
|
@@ -764,7 +774,8 @@ module Rigor
|
|
|
764
774
|
# flow-live (a method-local nil write / failed-guard narrowing), the
|
|
765
775
|
# merge is flow-live and `possible-nil-receiver` fires as before.
|
|
766
776
|
declaration_sourced: join_declaration_sourced(other),
|
|
767
|
-
source_path: source_path
|
|
777
|
+
source_path: source_path,
|
|
778
|
+
dynamic_origins: @dynamic_origins
|
|
768
779
|
)
|
|
769
780
|
end
|
|
770
781
|
|
data/lib/rigor/version.rb
CHANGED
data/lib/rigor.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "rigor/environment"
|
|
|
10
10
|
require_relative "rigor/rbs_extended"
|
|
11
11
|
require_relative "rigor/testing"
|
|
12
12
|
require_relative "rigor/inference/budget_trace"
|
|
13
|
+
require_relative "rigor/inference/dynamic_origin"
|
|
13
14
|
require_relative "rigor/inference/fallback"
|
|
14
15
|
require_relative "rigor/inference/fallback_tracer"
|
|
15
16
|
require_relative "rigor/inference/acceptance"
|
|
@@ -145,7 +145,7 @@ module Rigor
|
|
|
145
145
|
private_constant :SPEC_MATCHER_FORM
|
|
146
146
|
|
|
147
147
|
# ADR-37 slice 2 — the method names this analyzer narrows on,
|
|
148
|
-
# for the plugin's `
|
|
148
|
+
# for the plugin's `narrowing_facts methods:` gate.
|
|
149
149
|
SUPPORTED_METHODS = (ASSERT_FORM.keys + SPEC_MATCHER_FORM.keys).freeze
|
|
150
150
|
|
|
151
151
|
def spec_form_fact(call_node, environment:)
|
|
@@ -78,7 +78,7 @@ module Rigor
|
|
|
78
78
|
# assertion, method-gated by the engine. The engine routes
|
|
79
79
|
# `:local`-kind facts through
|
|
80
80
|
# `StatementEvaluator#apply_local_post_return_fact`.
|
|
81
|
-
|
|
81
|
+
narrowing_facts methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
|
|
82
82
|
AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
|
|
83
83
|
end
|
|
84
84
|
end
|
|
@@ -42,6 +42,32 @@ module Rigor
|
|
|
42
42
|
# `_controller.rb` (e.g. `users`, `admin/users`).
|
|
43
43
|
CONTROLLER_PATH_RE = %r{(?:^|/)controllers/(.+)_controller\.rb$}
|
|
44
44
|
|
|
45
|
+
# Matches Rails view-template file paths to derive the
|
|
46
|
+
# I18n "virtual path" — the scope that Rails uses for
|
|
47
|
+
# lazy `t('.key')` lookups inside a template.
|
|
48
|
+
#
|
|
49
|
+
# Captures the path segment between `views/` and the
|
|
50
|
+
# format+variant+handler suffix, then strips a leading
|
|
51
|
+
# underscore from each segment — Rails templates resolve
|
|
52
|
+
# `_form.html.erb` as "form", not "_form".
|
|
53
|
+
#
|
|
54
|
+
# app/views/setting/index.html.erb → setting.index
|
|
55
|
+
# app/views/admin/users/new.html.erb → admin.users.new
|
|
56
|
+
# app/views/home/index.html+mobile.erb → home.index
|
|
57
|
+
# app/views/users/_form.html.erb → users.form
|
|
58
|
+
#
|
|
59
|
+
# The view-scope lazy expansion replaces the action
|
|
60
|
+
# part with the view's virtual path:
|
|
61
|
+
# `<%= t('.title') %>` → `setting.index.title`
|
|
62
|
+
VIEW_SCOPE_RE = %r{views/(.+?)\.(?:\w+)(?:\+\w+)?\.\w+\z}
|
|
63
|
+
|
|
64
|
+
# Regex to extract lazy-key arguments from ERB / HTML
|
|
65
|
+
# template content. Matches `t('.key')`, `t(".key")`,
|
|
66
|
+
# `I18n.t('.key')`, and `I18n.translate('.key')` with a
|
|
67
|
+
# leading dot on the key string. Captures only the key
|
|
68
|
+
# part after the dot.
|
|
69
|
+
LAZY_T_KEY_RE = /\b(?:I18n\.)?(?:t|translate)\s*\(\s*(?:"\.([^"\\]*)"|'\.([^'\\]*)')/
|
|
70
|
+
|
|
45
71
|
# Reserved option keys — these are recognised by I18n
|
|
46
72
|
# itself and not treated as interpolation variables.
|
|
47
73
|
RESERVED_OPTION_KEYS = %i[
|
|
@@ -146,6 +172,32 @@ module Rigor
|
|
|
146
172
|
m[1].tr("/", ".")
|
|
147
173
|
end
|
|
148
174
|
|
|
175
|
+
def view_scope_from_path(path)
|
|
176
|
+
m = VIEW_SCOPE_RE.match(path.to_s)
|
|
177
|
+
return nil unless m
|
|
178
|
+
|
|
179
|
+
m[1].split("/").map { |seg| seg.sub(/\A_/, "") }.join(".")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def extract_lazy_keys_from_erb(content)
|
|
183
|
+
content.scan(LAZY_T_KEY_RE).map { |dq, sq| dq || sq }.uniq
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_view_key(key, locale_index:, configured_locales:)
|
|
187
|
+
entry = locale_index.find(key)
|
|
188
|
+
if entry.nil?
|
|
189
|
+
return [] if locale_index.pluralization_namespace?(key)
|
|
190
|
+
return [] if rails_shipped_key?(key)
|
|
191
|
+
|
|
192
|
+
return [unknown_key_violation(key, locale_index)]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
violations = [translation_call_info(key, entry)]
|
|
196
|
+
missing = locale_index.missing_locales_for(key, configured_locales: configured_locales)
|
|
197
|
+
violations << missing_locale_violation(key, missing) unless missing.empty?
|
|
198
|
+
violations
|
|
199
|
+
end
|
|
200
|
+
|
|
149
201
|
# Expands a lazy key (starting with `.`) to its full
|
|
150
202
|
# dotted path using the controller scope and action name.
|
|
151
203
|
# Returns the raw key unchanged for absolute keys.
|