rigortype 0.2.5 → 0.2.7
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/README.md +4 -3
- 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 +60 -2
- data/docs/manual/06-baseline.md +12 -0
- data/docs/manual/08-skills.md +21 -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/lib/rigor/cli/check_command.rb +4 -33
- data/lib/rigor/cli/check_runner_factory.rb +63 -0
- data/lib/rigor/cli/coverage_command.rb +42 -10
- 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/skill_command.rb +52 -1
- data/lib/rigor/cli/upgrade_command.rb +25 -0
- data/lib/rigor/cli.rb +17 -1
- data/lib/rigor/environment/rbs_loader.rb +28 -0
- 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 -8
- 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/sig_gen/generator.rb +25 -0
- data/lib/rigor/sig_gen/method_candidate.rb +7 -2
- data/lib/rigor/sig_gen/writer.rb +60 -13
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +63 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +2 -3
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +14 -24
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +10 -3
- 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-rspec/lib/rigor/plugin/rspec.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +36 -79
- data/sig/rigor/plugin/base.rbs +2 -0
- data/sig/rigor/scope.rbs +3 -1
- data/skills/rigor-ask/SKILL.md +21 -1
- data/skills/rigor-baseline-reduce/SKILL.md +16 -0
- data/skills/rigor-ci-setup/SKILL.md +96 -249
- data/skills/rigor-doctor/SKILL.md +39 -49
- data/skills/rigor-doctor/references/01-checks.md +52 -0
- data/skills/rigor-editor-setup/SKILL.md +14 -0
- data/skills/rigor-mcp-setup/SKILL.md +14 -0
- data/skills/rigor-monkeypatch-resolve/SKILL.md +15 -0
- data/skills/rigor-plugin-author/SKILL.md +24 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
- data/skills/rigor-plugin-review/SKILL.md +174 -0
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +214 -0
- data/skills/rigor-plugin-tune/SKILL.md +21 -2
- data/skills/rigor-project-init/SKILL.md +16 -0
- data/skills/rigor-protection-uplift/SKILL.md +15 -0
- data/skills/rigor-rbs-setup/SKILL.md +15 -0
- data/skills/rigor-upgrade/SKILL.md +16 -0
- metadata +11 -4
|
@@ -63,6 +63,7 @@ module Rigor
|
|
|
63
63
|
@namespace_kinds = {}
|
|
64
64
|
@module_function_methods = Set.new
|
|
65
65
|
@class_shells = Set.new
|
|
66
|
+
@class_superclasses = {}
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
# Lifts legacy plain-`Array[Type]` observation entries
|
|
@@ -116,6 +117,7 @@ module Rigor
|
|
|
116
117
|
@namespace_kinds = {}
|
|
117
118
|
@module_function_methods = Set.new
|
|
118
119
|
@class_shells = Set.new
|
|
120
|
+
@class_superclasses = {}
|
|
119
121
|
defs = collect_method_definitions(parse_result.value)
|
|
120
122
|
candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
|
|
121
123
|
# An analyzer bug typing one def's body must cost only that
|
|
@@ -195,10 +197,32 @@ module Rigor
|
|
|
195
197
|
|
|
196
198
|
full = (prefix + [name]).join("::")
|
|
197
199
|
@namespace_kinds[full] = node.is_a?(Prism::ClassNode) ? :class : :module
|
|
200
|
+
record_superclass(node, full)
|
|
198
201
|
walk_namespace_body(node, prefix + [name], out)
|
|
199
202
|
true
|
|
200
203
|
end
|
|
201
204
|
|
|
205
|
+
# ADR-14: a generated subclass declaration MUST carry its
|
|
206
|
+
# superclass, or the sidecar `sig/` misrepresents the class
|
|
207
|
+
# (inherited members vanish → receiver dispatch degrades to
|
|
208
|
+
# `Dynamic`) and, worse, a nested reference to an inherited
|
|
209
|
+
# type re-declares the class as a bare namespace on the RBS
|
|
210
|
+
# side and can collapse the whole env (the 2026-07-04 redmine
|
|
211
|
+
# `GitAdapter < AbstractAdapter` crash). Only a plain constant
|
|
212
|
+
# superclass is emittable: `class X < Foo` / `class X <
|
|
213
|
+
# Foo::Bar` yields the source token verbatim (RBS resolves it
|
|
214
|
+
# relative to the emitted namespace, matching Ruby's lexical
|
|
215
|
+
# scope). A computed superclass (`Struct.new`, `Data.define`,
|
|
216
|
+
# `Class.new`, any `CallNode`) is left unrecorded — those flow
|
|
217
|
+
# through the {#register_data_struct_shell} shell path or are
|
|
218
|
+
# simply un-representable, and guessing would misfold.
|
|
219
|
+
def record_superclass(node, full)
|
|
220
|
+
return unless node.is_a?(Prism::ClassNode)
|
|
221
|
+
|
|
222
|
+
superclass = qualified_constant_path(node.superclass)
|
|
223
|
+
@class_superclasses[full] = superclass if superclass
|
|
224
|
+
end
|
|
225
|
+
|
|
202
226
|
# ADR-14 gap-#3 (e): recognises
|
|
203
227
|
# `Const = Data.define(...)` and
|
|
204
228
|
# `Const = Struct.new(...)` as class declarations.
|
|
@@ -292,6 +316,7 @@ module Rigor
|
|
|
292
316
|
MethodCandidate.new(
|
|
293
317
|
namespace_kinds: @namespace_kinds,
|
|
294
318
|
class_shells: @class_shells.to_a,
|
|
319
|
+
class_superclasses: @class_superclasses,
|
|
295
320
|
**
|
|
296
321
|
)
|
|
297
322
|
end
|
|
@@ -25,11 +25,11 @@ module Rigor
|
|
|
25
25
|
class MethodCandidate
|
|
26
26
|
attr_reader :path, :class_name, :method_name, :kind, :classification,
|
|
27
27
|
:inferred_return, :declared_return_rbs, :rbs, :skip_reason,
|
|
28
|
-
:namespace_kinds, :class_shells
|
|
28
|
+
:namespace_kinds, :class_shells, :class_superclasses
|
|
29
29
|
|
|
30
30
|
def initialize(path:, class_name:, method_name:, kind:, classification:, # rubocop:disable Metrics/ParameterLists
|
|
31
31
|
inferred_return: nil, declared_return_rbs: nil, rbs: nil, skip_reason: nil,
|
|
32
|
-
namespace_kinds: {}, class_shells: [])
|
|
32
|
+
namespace_kinds: {}, class_shells: [], class_superclasses: {})
|
|
33
33
|
@path = path
|
|
34
34
|
@class_name = class_name
|
|
35
35
|
@method_name = method_name
|
|
@@ -41,6 +41,11 @@ module Rigor
|
|
|
41
41
|
@skip_reason = skip_reason
|
|
42
42
|
@namespace_kinds = namespace_kinds.freeze
|
|
43
43
|
@class_shells = class_shells.freeze
|
|
44
|
+
# Qualified-class-name => superclass source token (e.g.
|
|
45
|
+
# `{ "Foo::Bar" => "Base" }`). Only plain-constant
|
|
46
|
+
# superclasses appear; computed ones are absent. The Writer
|
|
47
|
+
# emits `class Bar < Base` for the leaf when present.
|
|
48
|
+
@class_superclasses = class_superclasses.freeze
|
|
44
49
|
freeze
|
|
45
50
|
end
|
|
46
51
|
|
data/lib/rigor/sig_gen/writer.rb
CHANGED
|
@@ -51,6 +51,9 @@ module Rigor
|
|
|
51
51
|
# Empty until then so the single-target `#write` path
|
|
52
52
|
# falls back to per-candidate kinds only.
|
|
53
53
|
@global_namespace_kinds = {}
|
|
54
|
+
# Run-level qualified-class-name => superclass-token view,
|
|
55
|
+
# same lifecycle as `@global_namespace_kinds`.
|
|
56
|
+
@global_superclasses = {}
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
# Process the full candidate list by resolving each
|
|
@@ -71,6 +74,7 @@ module Rigor
|
|
|
71
74
|
return [] if emittable.empty?
|
|
72
75
|
|
|
73
76
|
@global_namespace_kinds = build_namespace_kinds(candidates)
|
|
77
|
+
@global_superclasses = build_superclasses(candidates)
|
|
74
78
|
emittable.group_by { |c| @path_mapper.target_for(c.path, class_name: c.class_name) }
|
|
75
79
|
.map { |target, group| write_target(target, group) }
|
|
76
80
|
end
|
|
@@ -154,7 +158,8 @@ module Rigor
|
|
|
154
158
|
shells = collect_class_shells(candidates)
|
|
155
159
|
tree = build_namespace_tree(candidates, shells)
|
|
156
160
|
kinds = merged_namespace_kinds(candidates)
|
|
157
|
-
|
|
161
|
+
supers = merged_superclasses(candidates)
|
|
162
|
+
render_tree_nodes(tree, kinds, supers, 0)
|
|
158
163
|
end
|
|
159
164
|
|
|
160
165
|
# Drains `class_shells` from every candidate; the
|
|
@@ -191,6 +196,30 @@ module Rigor
|
|
|
191
196
|
end
|
|
192
197
|
end
|
|
193
198
|
|
|
199
|
+
# Superclass twins of {#merged_namespace_kinds} /
|
|
200
|
+
# {#build_namespace_kinds}: fold every candidate's
|
|
201
|
+
# per-file `class_superclasses` map into one view keyed by
|
|
202
|
+
# qualified class name. Only plain-constant superclasses are
|
|
203
|
+
# ever recorded (the generator skips computed ones), so a
|
|
204
|
+
# missing key means "emit no superclass", never "unknown".
|
|
205
|
+
def merged_superclasses(candidates)
|
|
206
|
+
merged = @global_superclasses.dup
|
|
207
|
+
candidates.each do |c|
|
|
208
|
+
next unless c.respond_to?(:class_superclasses)
|
|
209
|
+
|
|
210
|
+
(c.class_superclasses || {}).each { |name, sup| merged[name] = sup }
|
|
211
|
+
end
|
|
212
|
+
merged
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def build_superclasses(candidates)
|
|
216
|
+
candidates.each_with_object({}) do |candidate, acc|
|
|
217
|
+
next unless candidate.respond_to?(:class_superclasses)
|
|
218
|
+
|
|
219
|
+
(candidate.class_superclasses || {}).each { |name, sup| acc[name] = sup }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
194
223
|
# A `class` declaration is authoritative and MUST win
|
|
195
224
|
# over the `:module` wrapper default: a compact
|
|
196
225
|
# `class Foo::Bar` never names `Foo`, so the only signal
|
|
@@ -234,27 +263,43 @@ module Rigor
|
|
|
234
263
|
end
|
|
235
264
|
end
|
|
236
265
|
|
|
237
|
-
def render_tree_nodes(node, kinds, depth)
|
|
238
|
-
node[:children].values.map
|
|
266
|
+
def render_tree_nodes(node, kinds, supers, depth)
|
|
267
|
+
node[:children].values.map do |child|
|
|
268
|
+
render_tree_node(child, kinds, supers, depth, [node[:name]].compact)
|
|
269
|
+
end.join("\n")
|
|
239
270
|
end
|
|
240
271
|
|
|
241
|
-
def render_tree_node(node, kinds, depth, prefix)
|
|
272
|
+
def render_tree_node(node, kinds, supers, depth, prefix)
|
|
242
273
|
indent = INDENT * depth
|
|
243
274
|
qualified = (prefix + [node[:name]]).join("::")
|
|
244
275
|
keyword = node_keyword(node, kinds, qualified)
|
|
245
|
-
|
|
246
|
-
|
|
276
|
+
header = "#{keyword} #{node[:name]}#{superclass_suffix(keyword, supers, qualified)}"
|
|
277
|
+
body = render_tree_node_body(node, kinds, supers, depth, prefix)
|
|
278
|
+
"#{indent}#{header}\n#{body}#{indent}end\n"
|
|
247
279
|
end
|
|
248
280
|
|
|
249
|
-
def render_tree_node_body(node, kinds, depth, prefix)
|
|
281
|
+
def render_tree_node_body(node, kinds, supers, depth, prefix)
|
|
250
282
|
inner_indent = INDENT * (depth + 1)
|
|
251
283
|
method_lines = node[:methods].map { |c| "#{inner_indent}#{c.rbs}\n" }.join
|
|
252
284
|
child_blocks = node[:children].values.map do |child|
|
|
253
|
-
render_tree_node(child, kinds, depth + 1, prefix + [node[:name]])
|
|
285
|
+
render_tree_node(child, kinds, supers, depth + 1, prefix + [node[:name]])
|
|
254
286
|
end.join
|
|
255
287
|
method_lines + child_blocks
|
|
256
288
|
end
|
|
257
289
|
|
|
290
|
+
# ` < Super` for a `class` node whose qualified name has a
|
|
291
|
+
# recorded superclass, else the empty string. A `module`
|
|
292
|
+
# never takes a superclass (RBS forbids it), so the keyword
|
|
293
|
+
# gates emission — a name coincidentally recorded as both
|
|
294
|
+
# (impossible from one source class, but cheap to guard)
|
|
295
|
+
# stays a bare module.
|
|
296
|
+
def superclass_suffix(keyword, supers, qualified)
|
|
297
|
+
return "" unless keyword == :class
|
|
298
|
+
|
|
299
|
+
superclass = supers[qualified]
|
|
300
|
+
superclass ? " < #{superclass}" : ""
|
|
301
|
+
end
|
|
302
|
+
|
|
258
303
|
# Per ADR-14 gap-#3 (a) the keyword for a segment comes
|
|
259
304
|
# from `namespace_kinds` when known. The default for an
|
|
260
305
|
# explicit class shell (gap-#3 (e), `Const =
|
|
@@ -277,7 +322,8 @@ module Rigor
|
|
|
277
322
|
return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
|
|
278
323
|
|
|
279
324
|
state = MergeState.new(source: source, decls: decls, applied: [], skipped: [])
|
|
280
|
-
|
|
325
|
+
supers = merged_superclasses(candidates)
|
|
326
|
+
candidates.group_by(&:class_name).each { |class_name, methods| merge_class(state, class_name, methods, supers) }
|
|
281
327
|
merge_class_shells(state, collect_class_shells(candidates), merged_namespace_kinds(candidates))
|
|
282
328
|
|
|
283
329
|
action = state.applied.empty? ? :noop : :updated
|
|
@@ -367,10 +413,10 @@ module Rigor
|
|
|
367
413
|
nil
|
|
368
414
|
end
|
|
369
415
|
|
|
370
|
-
def merge_class(state, class_name, methods)
|
|
416
|
+
def merge_class(state, class_name, methods, supers = {})
|
|
371
417
|
decl = find_class_decl(state.decls, class_name)
|
|
372
418
|
state.source = if decl.nil?
|
|
373
|
-
append_new_class(state.source, class_name, methods, state.applied)
|
|
419
|
+
append_new_class(state.source, class_name, methods, state.applied, supers[class_name])
|
|
374
420
|
else
|
|
375
421
|
merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
|
|
376
422
|
end
|
|
@@ -405,9 +451,10 @@ module Rigor
|
|
|
405
451
|
# Appends an entirely new `class Foo … end` block at the
|
|
406
452
|
# end of the file (with a leading blank line as
|
|
407
453
|
# separator).
|
|
408
|
-
def append_new_class(source, class_name, methods, applied)
|
|
454
|
+
def append_new_class(source, class_name, methods, applied, superclass = nil)
|
|
409
455
|
body = methods.map { |c| "#{INDENT}#{c.rbs}" }.join("\n")
|
|
410
|
-
|
|
456
|
+
header = superclass ? "class #{class_name} < #{superclass}" : "class #{class_name}"
|
|
457
|
+
snippet = "\n#{header}\n#{body}\nend\n"
|
|
411
458
|
applied.concat(methods)
|
|
412
459
|
ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
|
|
413
460
|
end
|
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"
|
|
@@ -76,8 +76,8 @@ module Rigor
|
|
|
76
76
|
# resolves as `Admin::DomainBlocksController` (matching the
|
|
77
77
|
# `ControllerDiscoverer`), so render paths and filter-chain
|
|
78
78
|
# validation on nested controllers are correct.
|
|
79
|
-
version: "0.
|
|
80
|
-
description: "Validates Action Pack route-helper calls and filter chains inside controllers.",
|
|
79
|
+
version: "0.9.0",
|
|
80
|
+
description: "Validates Action Pack route-helper calls and filter chains inside controllers, and types the request-context readers (`params` / `session` / `request`).",
|
|
81
81
|
config_schema: {
|
|
82
82
|
"controller_search_paths" => { kind: :array, default: ["app/controllers"] },
|
|
83
83
|
"view_search_paths" => { kind: :array, default: ["app/views"] }
|
|
@@ -173,8 +173,69 @@ module Rigor
|
|
|
173
173
|
diagnostics_for(Analyzer.permit_violations_for(call_node: node, model_index: index), path: path, node: node)
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
# Phase 5 (2026-07-04) — type the implicit-self request-context
|
|
177
|
+
# readers (`params`, `session`, `request`, `flash`, `cookies`)
|
|
178
|
+
# inside controllers. The
|
|
179
|
+
# typing-obstacle probe
|
|
180
|
+
# (docs/notes/20260704-rails-coverage-onboarding-carrier-trap.md,
|
|
181
|
+
# obstacle O3) found `params` typing to `Dynamic[top]` the single
|
|
182
|
+
# largest protection-coverage hole on real Rails apps: `params[:x]`
|
|
183
|
+
# is the #1 dispatch cluster (redmine app+lib: `[]` 2378 sites) and
|
|
184
|
+
# `session[:x] =` a large share of the `[]=` cluster, all
|
|
185
|
+
# unprotected because the receiver is Dynamic.
|
|
186
|
+
#
|
|
187
|
+
# Each returns a bare nominal with NO bundled RBS on purpose. That
|
|
188
|
+
# makes the reader a *concrete* receiver (so `coverage --protection`
|
|
189
|
+
# counts the site as protected and the dispatch resolves against a
|
|
190
|
+
# named class) while its method surface stays engine-lenient — Rigor
|
|
191
|
+
# does not fire `undefined-method` on a class it has no RBS for, so
|
|
192
|
+
# `params.require(...).permit(...)`, `session.delete(:x)`,
|
|
193
|
+
# `request.xhr?`, and every other method on these stay FP-safe.
|
|
194
|
+
# Shipping a partial RBS would re-introduce the carrier-additivity
|
|
195
|
+
# trap (a declared class drops every member the RBS omits → false
|
|
196
|
+
# `undefined-method`). ADR-5: this types the container, never the
|
|
197
|
+
# caller's argument, so the values stay lenient.
|
|
198
|
+
REQUEST_CONTEXT_READER_TYPES = {
|
|
199
|
+
params: "ActionController::Parameters",
|
|
200
|
+
session: "ActionDispatch::Request::Session",
|
|
201
|
+
request: "ActionDispatch::Request",
|
|
202
|
+
flash: "ActionDispatch::Flash::FlashHash",
|
|
203
|
+
cookies: "ActionDispatch::Cookies::CookieJar"
|
|
204
|
+
}.freeze
|
|
205
|
+
|
|
206
|
+
dynamic_return methods: REQUEST_CONTEXT_READER_TYPES.keys do |call_node, scope|
|
|
207
|
+
next nil unless call_node.is_a?(Prism::CallNode)
|
|
208
|
+
next nil unless call_node.receiver.nil? # the implicit-self reader
|
|
209
|
+
next nil unless call_node.arguments.nil? # `params`, not `params(x)`
|
|
210
|
+
next nil unless controller_scope?(scope)
|
|
211
|
+
|
|
212
|
+
class_name = REQUEST_CONTEXT_READER_TYPES[call_node.name]
|
|
213
|
+
next nil if class_name.nil?
|
|
214
|
+
|
|
215
|
+
Rigor::Type::Combinator.nominal_of(class_name)
|
|
216
|
+
end
|
|
217
|
+
|
|
176
218
|
private
|
|
177
219
|
|
|
220
|
+
# True when the current `self` is a controller — the enclosing
|
|
221
|
+
# class is one the discoverer indexed, or its name follows the
|
|
222
|
+
# Rails `*Controller` convention (covering controllers outside
|
|
223
|
+
# `controller_search_paths`, e.g. one shipped by an engine).
|
|
224
|
+
# Typing `params` is precision-additive, so the name-convention
|
|
225
|
+
# fallback is FP-safe.
|
|
226
|
+
def controller_scope?(scope)
|
|
227
|
+
self_type = scope&.self_type
|
|
228
|
+
return false unless self_type.respond_to?(:class_name)
|
|
229
|
+
|
|
230
|
+
name = self_type.class_name
|
|
231
|
+
return false if name.nil?
|
|
232
|
+
|
|
233
|
+
index = producer_value(:controller_index)
|
|
234
|
+
return true if index && (index.find(name) || index.find("::#{name}"))
|
|
235
|
+
|
|
236
|
+
name.end_with?("Controller")
|
|
237
|
+
end
|
|
238
|
+
|
|
178
239
|
def controller_file?(path)
|
|
179
240
|
@controller_search_paths.any? do |root|
|
|
180
241
|
# The runner may pass `path` as either an absolute
|
|
@@ -208,9 +208,8 @@ module Rigor
|
|
|
208
208
|
FINDER_METHOD_NAMES = %i[find find_by! find_by where all order limit none select].freeze
|
|
209
209
|
private_constant :FINDER_METHOD_NAMES
|
|
210
210
|
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
# `methods:` name gate. `Model.find(id)` narrows the call
|
|
211
|
+
# Return-type contribution via the run-time `methods:` name
|
|
212
|
+
# gate (ADR-52 slice 5b). `Model.find(id)` narrows the call
|
|
214
213
|
# site's return type to `Nominal[Model]`, so chained calls
|
|
215
214
|
# (`User.find(1).name`) resolve through the analyzer's
|
|
216
215
|
# normal dispatch instead of the RBS-level untyped
|
|
@@ -22,17 +22,20 @@ module Rigor
|
|
|
22
22
|
@contracts = contracts
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
# Per-`ClassNode` check over the engine-owned walk (ADR-37):
|
|
26
|
+
# called once per class node the `node_rule` in `hanami.rb`
|
|
27
|
+
# dispatches, against every contract whose `path_glob` matches
|
|
28
|
+
# the file. No cross-class-node state is needed, so the checker
|
|
29
|
+
# ships no `class_nodes` traversal of its own.
|
|
30
|
+
def check_class(class_node, path:)
|
|
31
|
+
@contracts.filter_map do |contract|
|
|
32
|
+
next unless path_matches?(contract.path_glob, path)
|
|
33
|
+
|
|
34
|
+
handle_def = find_handle(class_node, contract)
|
|
35
|
+
if handle_def.nil?
|
|
36
|
+
missing_handle_diagnostic(contract, path, class_node)
|
|
37
|
+
else
|
|
38
|
+
handle_arity_mismatch_diagnostic(contract, path, class_node, handle_def)
|
|
36
39
|
end
|
|
37
40
|
end
|
|
38
41
|
end
|
|
@@ -49,12 +52,6 @@ module Rigor
|
|
|
49
52
|
File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
def class_nodes(root)
|
|
53
|
-
found = []
|
|
54
|
-
walk(root) { |node| found << node if node.is_a?(Prism::ClassNode) }
|
|
55
|
-
found
|
|
56
|
-
end
|
|
57
|
-
|
|
58
55
|
def find_handle(class_node, contract)
|
|
59
56
|
direct_defs(class_node).find do |def_node|
|
|
60
57
|
def_node.name == contract.method_name &&
|
|
@@ -107,13 +104,6 @@ module Rigor
|
|
|
107
104
|
path = class_node.constant_path
|
|
108
105
|
path.respond_to?(:slice) ? path.slice : class_node.name.to_s
|
|
109
106
|
end
|
|
110
|
-
|
|
111
|
-
def walk(node, &)
|
|
112
|
-
return if node.nil?
|
|
113
|
-
|
|
114
|
-
yield node
|
|
115
|
-
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
116
|
-
end
|
|
117
107
|
end
|
|
118
108
|
end
|
|
119
109
|
end
|
|
@@ -98,11 +98,18 @@ module Rigor
|
|
|
98
98
|
@protocol_contracts || manifest.protocol_contracts
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
# ADR-37 — per-class-node validation over the engine-owned walk.
|
|
102
|
+
# Each `Prism::ClassNode` is checked against every contract
|
|
103
|
+
# independently, so the plugin no longer ships its own `class_nodes`
|
|
104
|
+
# traversal; `ActionChecker#check_class` keeps the per-class
|
|
105
|
+
# contract logic. (A per-class contract check is exactly what
|
|
106
|
+
# `node_rule` is for — the return type is void, so no `scope`
|
|
107
|
+
# query is needed.)
|
|
108
|
+
node_rule Prism::ClassNode do |node, _scope, path|
|
|
102
109
|
contracts = protocol_contracts
|
|
103
|
-
|
|
110
|
+
next [] if contracts.empty?
|
|
104
111
|
|
|
105
|
-
ActionChecker.new(contracts: contracts).
|
|
112
|
+
ActionChecker.new(contracts: contracts).check_class(node, path: path)
|
|
106
113
|
end
|
|
107
114
|
end
|
|
108
115
|
|
|
@@ -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
|
|
@@ -110,7 +110,7 @@ module Rigor
|
|
|
110
110
|
# ADR-37 slice 2 — matcher narrowing
|
|
111
111
|
# (`expect(x).to be_a(T)` → `post_return_facts` on `x`),
|
|
112
112
|
# method-gated by the engine on the expectation verbs.
|
|
113
|
-
|
|
113
|
+
narrowing_facts methods: %i[to not_to to_not] do |call_node, scope|
|
|
114
114
|
MatcherAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
|
|
115
115
|
end
|
|
116
116
|
|
|
@@ -147,11 +147,7 @@ module Rigor
|
|
|
147
147
|
# symlink-bearing form here. Look up under both so the
|
|
148
148
|
# match is symlink-agnostic.
|
|
149
149
|
errors = @parse_errors_by_path[path] || @parse_errors_by_path[canonicalize(path)] || []
|
|
150
|
-
|
|
151
|
-
diagnostics.concat(absurd_reachable_diagnostics(path, root))
|
|
152
|
-
diagnostics.concat(reveal_type_diagnostics(path, root))
|
|
153
|
-
diagnostics.concat(assert_type_mismatch_diagnostics(path, root))
|
|
154
|
-
diagnostics
|
|
150
|
+
errors.map { |error| parse_error_diagnostic(path, error) }
|
|
155
151
|
end
|
|
156
152
|
|
|
157
153
|
# ADR-52 slice 4 — per-call return-type path via the
|
|
@@ -169,16 +165,45 @@ module Rigor
|
|
|
169
165
|
end
|
|
170
166
|
|
|
171
167
|
# ADR-52 slice 4 — `T.bind(self, T)`'s self-narrowing fact,
|
|
172
|
-
# contributed via the method-gated `
|
|
168
|
+
# contributed via the method-gated `narrowing_facts` DSL. The
|
|
173
169
|
# statement evaluator consults this path for narrowing facts.
|
|
174
170
|
# The return-type half (`Constant[nil]`) flows through the
|
|
175
171
|
# `dynamic_return` rule above; the block re-checks the `T.`
|
|
176
172
|
# receiver via the recogniser, so an unrelated `bind` call
|
|
177
173
|
# contributes nothing.
|
|
178
|
-
|
|
174
|
+
narrowing_facts methods: [:bind] do |call_node, scope|
|
|
179
175
|
bind_post_return_facts(call_node, scope)
|
|
180
176
|
end
|
|
181
177
|
|
|
178
|
+
# ADR-37 — the three per-call diagnostics ride the engine-owned
|
|
179
|
+
# walk instead of three hand-rolled `walk_for_*` recursions. Each
|
|
180
|
+
# candidate `T.` call is *recorded by object identity* during the
|
|
181
|
+
# inference pass (the `dynamic_return` / `narrowing_facts` rules
|
|
182
|
+
# above call `record_*`), so by the time these node rules fire in
|
|
183
|
+
# the diagnostics phase the sets are populated; the membership
|
|
184
|
+
# `delete` both gates the emission and pops the entry so a re-run
|
|
185
|
+
# cannot double-fire. The recorded set is the gate — no per-node
|
|
186
|
+
# `AbsurdRecognizer` / name check is needed here.
|
|
187
|
+
node_rule Prism::CallNode do |node, _scope, path|
|
|
188
|
+
next [] unless @reachable_absurd_nodes.delete(node)
|
|
189
|
+
|
|
190
|
+
[absurd_diagnostic(path, node)]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
node_rule Prism::CallNode do |node, _scope, path|
|
|
194
|
+
display = @reveal_type_calls.delete(node)
|
|
195
|
+
next [] if display.nil?
|
|
196
|
+
|
|
197
|
+
[reveal_type_diagnostic(path, node, display)]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
node_rule Prism::CallNode do |node, _scope, path|
|
|
201
|
+
recorded = @assert_type_mismatches.delete(node)
|
|
202
|
+
next [] if recorded.nil?
|
|
203
|
+
|
|
204
|
+
[assert_type_mismatch_diagnostic(path, node, *recorded)]
|
|
205
|
+
end
|
|
206
|
+
|
|
182
207
|
private
|
|
183
208
|
|
|
184
209
|
# Run-time method-name gate for the `dynamic_return` rule
|
|
@@ -258,7 +283,7 @@ module Rigor
|
|
|
258
283
|
lookup_signature(call_node, scope)&.return_type
|
|
259
284
|
end
|
|
260
285
|
|
|
261
|
-
# The `
|
|
286
|
+
# The `narrowing_facts` body for `T.bind` — same sigil gate as
|
|
262
287
|
# the return-type path, then the recogniser's
|
|
263
288
|
# `post_return_facts` (the `Fact(target_kind: :self)` that
|
|
264
289
|
# narrows `scope.self_type` for the rest of the block).
|
|
@@ -536,31 +561,9 @@ module Rigor
|
|
|
536
561
|
nil
|
|
537
562
|
end
|
|
538
563
|
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
542
|
-
# `@reachable_absurd_nodes` (populated during the engine's
|
|
543
|
-
# earlier pass through the `dynamic_return` rule). Pops
|
|
544
|
-
# matched entries so a duplicate run doesn't double-emit.
|
|
545
|
-
def absurd_reachable_diagnostics(path, root)
|
|
546
|
-
return [] if @reachable_absurd_nodes.empty?
|
|
547
|
-
|
|
548
|
-
diagnostics = []
|
|
549
|
-
walk_for_absurd(root) do |call_node|
|
|
550
|
-
next unless @reachable_absurd_nodes.delete(call_node)
|
|
551
|
-
|
|
552
|
-
diagnostics << absurd_diagnostic(path, call_node)
|
|
553
|
-
end
|
|
554
|
-
diagnostics
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
def walk_for_absurd(node, &)
|
|
558
|
-
return unless node.is_a?(Prism::Node)
|
|
559
|
-
|
|
560
|
-
yield node if node.is_a?(Prism::CallNode) && AbsurdRecognizer.absurd_call?(node)
|
|
561
|
-
node.compact_child_nodes.each { |child| walk_for_absurd(child, &) }
|
|
562
|
-
end
|
|
563
|
-
|
|
564
|
+
# Emits a `plugin.sorbet.absurd-reachable` warning for the
|
|
565
|
+
# `T.absurd(x)` call recorded in `@reachable_absurd_nodes` during
|
|
566
|
+
# inference; the node rule above does the identity match and pop.
|
|
564
567
|
def absurd_diagnostic(path, call_node)
|
|
565
568
|
Rigor::Analysis::Diagnostic.from_node(
|
|
566
569
|
call_node,
|
|
@@ -591,29 +594,6 @@ module Rigor
|
|
|
591
594
|
type.respond_to?(:describe) ? type.describe : type.inspect
|
|
592
595
|
end
|
|
593
596
|
|
|
594
|
-
def reveal_type_diagnostics(path, root)
|
|
595
|
-
return [] if @reveal_type_calls.empty?
|
|
596
|
-
|
|
597
|
-
diagnostics = []
|
|
598
|
-
walk_for_reveal_type(root) do |call_node|
|
|
599
|
-
display = @reveal_type_calls.delete(call_node)
|
|
600
|
-
next if display.nil?
|
|
601
|
-
|
|
602
|
-
diagnostics << reveal_type_diagnostic(path, call_node, display)
|
|
603
|
-
end
|
|
604
|
-
diagnostics
|
|
605
|
-
end
|
|
606
|
-
|
|
607
|
-
def walk_for_reveal_type(node, &)
|
|
608
|
-
return unless node.is_a?(Prism::Node)
|
|
609
|
-
|
|
610
|
-
if node.is_a?(Prism::CallNode) && node.name == :reveal_type &&
|
|
611
|
-
TypeTranslator.sorbet_t_namespaced?(node.receiver)
|
|
612
|
-
yield node
|
|
613
|
-
end
|
|
614
|
-
node.compact_child_nodes.each { |child| walk_for_reveal_type(child, &) }
|
|
615
|
-
end
|
|
616
|
-
|
|
617
597
|
def reveal_type_diagnostic(path, call_node, display)
|
|
618
598
|
Rigor::Analysis::Diagnostic.from_node(
|
|
619
599
|
call_node,
|
|
@@ -647,29 +627,6 @@ module Rigor
|
|
|
647
627
|
@assert_type_mismatches[call_node] = [display_for_type(inferred), display_for_type(asserted)]
|
|
648
628
|
end
|
|
649
629
|
|
|
650
|
-
def assert_type_mismatch_diagnostics(path, root)
|
|
651
|
-
return [] if @assert_type_mismatches.empty?
|
|
652
|
-
|
|
653
|
-
diagnostics = []
|
|
654
|
-
walk_for_assert_type(root) do |call_node|
|
|
655
|
-
recorded = @assert_type_mismatches.delete(call_node)
|
|
656
|
-
next if recorded.nil?
|
|
657
|
-
|
|
658
|
-
diagnostics << assert_type_mismatch_diagnostic(path, call_node, *recorded)
|
|
659
|
-
end
|
|
660
|
-
diagnostics
|
|
661
|
-
end
|
|
662
|
-
|
|
663
|
-
def walk_for_assert_type(node, &)
|
|
664
|
-
return unless node.is_a?(Prism::Node)
|
|
665
|
-
|
|
666
|
-
if node.is_a?(Prism::CallNode) && node.name == :assert_type! &&
|
|
667
|
-
TypeTranslator.sorbet_t_namespaced?(node.receiver)
|
|
668
|
-
yield node
|
|
669
|
-
end
|
|
670
|
-
node.compact_child_nodes.each { |child| walk_for_assert_type(child, &) }
|
|
671
|
-
end
|
|
672
|
-
|
|
673
630
|
def assert_type_mismatch_diagnostic(path, call_node, inferred_display, asserted_display)
|
|
674
631
|
Rigor::Analysis::Diagnostic.from_node(
|
|
675
632
|
call_node,
|
data/sig/rigor/plugin/base.rbs
CHANGED
|
@@ -26,6 +26,8 @@ class Rigor::Plugin::Base
|
|
|
26
26
|
def self.dynamic_return: (?receivers: (Array[String] | ^() -> Array[String])?, ?methods: (Array[untyped] | ^() -> Array[untyped])?) { (untyped call_node, untyped scope) -> untyped } -> nil
|
|
27
27
|
def self.dynamic_returns: () -> Array[untyped]
|
|
28
28
|
|
|
29
|
+
def self.narrowing_facts: (methods: Array[untyped]) { (untyped call_node, untyped scope) -> untyped } -> nil
|
|
30
|
+
# Deprecating alias for `narrowing_facts` (ADR-80); removed in 0.3.0.
|
|
29
31
|
def self.type_specifier: (methods: Array[untyped]) { (untyped call_node, untyped scope) -> untyped } -> nil
|
|
30
32
|
def self.type_specifiers: () -> Array[untyped]
|
|
31
33
|
|
data/sig/rigor/scope.rbs
CHANGED
|
@@ -8,6 +8,7 @@ module Rigor
|
|
|
8
8
|
attr_reader cvars: Hash[Symbol, Type::t]
|
|
9
9
|
attr_reader globals: Hash[Symbol, Type::t]
|
|
10
10
|
attr_reader discovery: DiscoveryIndex
|
|
11
|
+
attr_reader dynamic_origins: Hash[untyped, Symbol]
|
|
11
12
|
attr_reader indexed_narrowings: Hash[IndexedKey, Type::t]
|
|
12
13
|
attr_reader method_chain_narrowings: Hash[ChainKey, Type::t]
|
|
13
14
|
attr_reader source_path: String?
|
|
@@ -32,6 +33,7 @@ module Rigor
|
|
|
32
33
|
def data_member_layouts: () -> Hash[String, Array[Symbol]]
|
|
33
34
|
def struct_member_layouts: () -> Hash[String, { members: Array[Symbol], keyword_init: bool }]
|
|
34
35
|
def param_inferred_types: () -> Hash[[String, Symbol, Symbol], Hash[Symbol, Type::t]]
|
|
36
|
+
def record_dynamic_origin: (untyped node, Symbol cause) -> Scope
|
|
35
37
|
|
|
36
38
|
class DiscoveryIndex
|
|
37
39
|
attr_reader declared_types: Hash[untyped, Type::t]
|
|
@@ -71,7 +73,7 @@ module Rigor
|
|
|
71
73
|
|
|
72
74
|
def self.empty: (?environment: Environment, ?source_path: String?) -> Scope
|
|
73
75
|
|
|
74
|
-
def initialize: (environment: Environment, locals: Hash[Symbol, Type::t], ?fact_store: Analysis::FactStore, ?self_type: Type::t?, ?ivars: Hash[Symbol, Type::t], ?cvars: Hash[Symbol, Type::t], ?globals: Hash[Symbol, Type::t], ?discovery: DiscoveryIndex, ?source_path: String?) -> void
|
|
76
|
+
def initialize: (environment: Environment, locals: Hash[Symbol, Type::t], ?fact_store: Analysis::FactStore, ?self_type: Type::t?, ?ivars: Hash[Symbol, Type::t], ?cvars: Hash[Symbol, Type::t], ?globals: Hash[Symbol, Type::t], ?discovery: DiscoveryIndex, ?source_path: String?, ?dynamic_origins: Hash[untyped, Symbol]) -> void
|
|
75
77
|
def with_source_path: (String? path) -> Scope
|
|
76
78
|
def with_struct_fold_safe: (Set[Symbol] locals) -> Scope
|
|
77
79
|
def struct_fold_safe?: (String | Symbol name) -> bool
|