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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/09-plugins.md +5 -2
  4. data/docs/handbook/appendix-liskov.md +5 -3
  5. data/docs/handbook/appendix-phpstan.md +2 -2
  6. data/docs/install.md +1 -1
  7. data/docs/manual/02-cli-reference.md +60 -2
  8. data/docs/manual/06-baseline.md +12 -0
  9. data/docs/manual/08-skills.md +21 -0
  10. data/docs/manual/11-ci.md +6 -6
  11. data/docs/manual/15-type-protection-coverage.md +29 -0
  12. data/docs/manual/plugins/rigor-minitest.md +1 -1
  13. data/lib/rigor/cli/check_command.rb +4 -33
  14. data/lib/rigor/cli/check_runner_factory.rb +63 -0
  15. data/lib/rigor/cli/coverage_command.rb +42 -10
  16. data/lib/rigor/cli/doctor_command.rb +295 -0
  17. data/lib/rigor/cli/plugins_command.rb +2 -2
  18. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  19. data/lib/rigor/cli/protection_renderer.rb +32 -2
  20. data/lib/rigor/cli/protection_report.rb +32 -6
  21. data/lib/rigor/cli/skill_command.rb +52 -1
  22. data/lib/rigor/cli/upgrade_command.rb +25 -0
  23. data/lib/rigor/cli.rb +17 -1
  24. data/lib/rigor/environment/rbs_loader.rb +28 -0
  25. data/lib/rigor/flow_contribution/fact.rb +1 -1
  26. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  27. data/lib/rigor/inference/expression_typer.rb +22 -10
  28. data/lib/rigor/inference/fallback.rb +2 -2
  29. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  30. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  31. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  32. data/lib/rigor/inference/mutation_widening.rb +18 -0
  33. data/lib/rigor/inference/protection_scanner.rb +6 -3
  34. data/lib/rigor/inference/statement_evaluator.rb +5 -8
  35. data/lib/rigor/plugin/base.rb +34 -7
  36. data/lib/rigor/plugin/registry.rb +1 -1
  37. data/lib/rigor/scope.rb +16 -5
  38. data/lib/rigor/sig_gen/generator.rb +25 -0
  39. data/lib/rigor/sig_gen/method_candidate.rb +7 -2
  40. data/lib/rigor/sig_gen/writer.rb +60 -13
  41. data/lib/rigor/version.rb +1 -1
  42. data/lib/rigor.rb +1 -0
  43. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +63 -2
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +2 -3
  45. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +14 -24
  46. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +10 -3
  47. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  48. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  49. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  50. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +36 -79
  51. data/sig/rigor/plugin/base.rbs +2 -0
  52. data/sig/rigor/scope.rbs +3 -1
  53. data/skills/rigor-ask/SKILL.md +21 -1
  54. data/skills/rigor-baseline-reduce/SKILL.md +16 -0
  55. data/skills/rigor-ci-setup/SKILL.md +96 -249
  56. data/skills/rigor-doctor/SKILL.md +39 -49
  57. data/skills/rigor-doctor/references/01-checks.md +52 -0
  58. data/skills/rigor-editor-setup/SKILL.md +14 -0
  59. data/skills/rigor-mcp-setup/SKILL.md +14 -0
  60. data/skills/rigor-monkeypatch-resolve/SKILL.md +15 -0
  61. data/skills/rigor-plugin-author/SKILL.md +24 -5
  62. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  63. data/skills/rigor-plugin-review/SKILL.md +174 -0
  64. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +214 -0
  65. data/skills/rigor-plugin-tune/SKILL.md +21 -2
  66. data/skills/rigor-project-init/SKILL.md +16 -0
  67. data/skills/rigor-protection-uplift/SKILL.md +15 -0
  68. data/skills/rigor-rbs-setup/SKILL.md +15 -0
  69. data/skills/rigor-upgrade/SKILL.md +16 -0
  70. 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
 
@@ -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
- render_tree_nodes(tree, kinds, 0)
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 { |child| render_tree_node(child, kinds, depth, [node[:name]].compact) }.join("\n")
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
- body = render_tree_node_body(node, kinds, depth, prefix)
246
- "#{indent}#{keyword} #{node[:name]}\n#{body}#{indent}end\n"
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
- candidates.group_by(&:class_name).each { |class_name, methods| merge_class(state, class_name, methods) }
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
- snippet = "\nclass #{class_name}\n#{body}\nend\n"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.2.5"
4
+ VERSION = "0.2.7"
5
5
  end
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.8.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
- # v0.1.2 — return-type contribution; ADR-52 slice 5b —
212
- # migrated off `flow_contribution_for` onto the run-time
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
- def check(path:, root:)
26
- @contracts.flat_map do |contract|
27
- next [] unless path_matches?(contract.path_glob, path)
28
-
29
- class_nodes(root).filter_map do |class_node|
30
- handle_def = find_handle(class_node, contract)
31
- if handle_def.nil?
32
- missing_handle_diagnostic(contract, path, class_node)
33
- else
34
- handle_arity_mismatch_diagnostic(contract, path, class_node, handle_def)
35
- end
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
- def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
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
- return [] if contracts.empty?
110
+ next [] if contracts.empty?
104
111
 
105
- ActionChecker.new(contracts: contracts).check(path: path, root: root)
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 `type_specifier methods:` gate.
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
- type_specifier methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
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
- type_specifier methods: %i[to not_to to_not] do |call_node, scope|
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
- diagnostics = errors.map { |error| parse_error_diagnostic(path, error) }
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 `type_specifier` DSL. The
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
- type_specifier methods: [:bind] do |call_node, scope|
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 `type_specifier` body for `T.bind` — same sigil gate as
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
- # Walks the per-file AST looking for `T.absurd(x)` call
540
- # nodes and emits a `plugin.sorbet.absurd-reachable`
541
- # warning for any whose object identity matches
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,
@@ -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