rigortype 0.2.6 → 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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/manual/02-cli-reference.md +2 -1
  4. data/docs/manual/08-skills.md +21 -0
  5. data/lib/rigor/cli/coverage_command.rb +42 -10
  6. data/lib/rigor/cli/skill_command.rb +52 -1
  7. data/lib/rigor/environment/rbs_loader.rb +28 -0
  8. data/lib/rigor/inference/statement_evaluator.rb +0 -4
  9. data/lib/rigor/sig_gen/generator.rb +25 -0
  10. data/lib/rigor/sig_gen/method_candidate.rb +7 -2
  11. data/lib/rigor/sig_gen/writer.rb +60 -13
  12. data/lib/rigor/version.rb +1 -1
  13. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +63 -2
  14. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +2 -3
  15. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +14 -24
  16. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +10 -3
  17. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +33 -76
  18. data/skills/rigor-ask/SKILL.md +21 -1
  19. data/skills/rigor-baseline-reduce/SKILL.md +16 -0
  20. data/skills/rigor-ci-setup/SKILL.md +96 -249
  21. data/skills/rigor-doctor/SKILL.md +39 -49
  22. data/skills/rigor-doctor/references/01-checks.md +52 -0
  23. data/skills/rigor-editor-setup/SKILL.md +14 -0
  24. data/skills/rigor-mcp-setup/SKILL.md +14 -0
  25. data/skills/rigor-monkeypatch-resolve/SKILL.md +15 -0
  26. data/skills/rigor-plugin-author/SKILL.md +16 -0
  27. data/skills/rigor-plugin-review/SKILL.md +174 -0
  28. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +214 -0
  29. data/skills/rigor-plugin-tune/SKILL.md +21 -2
  30. data/skills/rigor-project-init/SKILL.md +16 -0
  31. data/skills/rigor-protection-uplift/SKILL.md +15 -0
  32. data/skills/rigor-rbs-setup/SKILL.md +15 -0
  33. data/skills/rigor-upgrade/SKILL.md +16 -0
  34. metadata +7 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97d286eb15708fc212260925d407b48926a331798f0d2f210321b1d3c66cda55
4
- data.tar.gz: 9fadaf2c31394c142dd15b335dbf740774ceabff8ce85b8a79fcd7faa26ea4fa
3
+ metadata.gz: c16266bcbae2ff766dd6579df68ca08d31563d7f9a813ef49b88bd8f72734b7a
4
+ data.tar.gz: c80a5f751c3f757c30acfa0fd1631918a5b3fdc388752c03f27ad56c11a723c2
5
5
  SHA512:
6
- metadata.gz: 1b800eb64d10fdef9039ed429f5740f672cdf0811de86c1cac1434b6bb5a0c820df68d8d8b01aa03ed0cab518d18a6e51af12d608cf6ad50556e4b653a4d3ed6
7
- data.tar.gz: 5bee1c072412f7eac14d0c96314f8efe2473cd46efe9763e238bd3c345605deb29055db3601c52909c430d419d8821fc7befb51fe0f5211b8fbb94e379a5d635
6
+ metadata.gz: deda828a4f7171b30c1e0b0531fbb52cb815905e05ebb05186a87b49107d7ce9e80c4d5d9dc81fd35ed26a1872d2d6e9bbeefe98e3b146d1acd1e4d360a81a26
7
+ data.tar.gz: fbe3ba7eed33351ea7ec8863f09085ed1b55f99e17ac3a0bba51234723da1a35f1e90fa9dd694aa0a680c5dbb8725855deb789728c6886c16ccc229a6d4b1be1
data/README.md CHANGED
@@ -231,9 +231,10 @@ rigor docs --list # list every bundled page
231
231
 
232
232
  ## Status
233
233
 
234
- Current release: **`v0.2.0`** (2026-06-17) — the first
235
- publicly-announced (general / evaluation) release. It publishes an
236
- enumerated [compatibility surface](docs/compatibility.md) as a
234
+ Current release: **`v0.2.7`** (2026-07-05) — the latest cut on the
235
+ `0.2.x` evaluation line opened by `v0.2.0`, the first
236
+ publicly-announced (general / evaluation) release. The line publishes
237
+ an enumerated [compatibility surface](docs/compatibility.md) as a
237
238
  minor-non-break trial, rehearsing the contract that hard-freezes at
238
239
  `v1.0.0`. Rigor analyses real Ruby today: it has been hardened against
239
240
  Mastodon, Redmine, and GitLab FOSS, and the deliberately conservative
@@ -452,13 +452,14 @@ The positional slot is a skill *name*; alternative outputs are flags,
452
452
  so a skill can never be shadowed by a verb.
453
453
 
454
454
  ```sh
455
- rigor skill [<name>] [--path <name>] [--list] [--describe]
455
+ rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
456
456
  ```
457
457
 
458
458
  | Form | Purpose |
459
459
  | --- | --- |
460
460
  | (none) / `--list` | Table of every bundled skill (name + absolute path). |
461
461
  | `<name>` | Print the `SKILL.md` body to stdout, with a header pointing at the skill's `references/` directory. |
462
+ | `--full <name>` | Print the `SKILL.md` body **followed by every `references/*.md` inline** — the complete, version-current procedure in one call. This is what a skill's "First: load the version-current copy" directive points at, so a copy vendored into a project (e.g. via `npx skills add`) re-fetches its current steps from the installed gem instead of following a frozen copy. |
462
463
  | `--path <name>` | Print the single-line absolute `SKILL.md` path, suitable as input to a file-reading tool. |
463
464
  | `--describe` | Probe the project's state (config / baseline / `sig/` / CI — presence only, never runs `rigor check`) and recommend the next skill to run. Also spelled `describe`; surfaced top-level as [`rigor describe`](#rigor-describe) below. |
464
465
 
@@ -108,6 +108,7 @@ source checkout. The `rigor skill` command surfaces them:
108
108
  rigor skill describe # probe the project + recommend the next skill (alias: rigor describe)
109
109
  rigor skill --list # name + absolute path for each bundled skill
110
110
  rigor skill <name> # print the SKILL.md body (with a references/ header)
111
+ rigor skill --full <name> # the body + every references/*.md inline (complete procedure)
111
112
  rigor skill --path <name> # one-line absolute SKILL.md path, for a file-reading tool
112
113
  ```
113
114
 
@@ -133,6 +134,26 @@ npx skills add rigortype/rigor
133
134
  (The contributor-only skills under `.claude/skills/` are marked internal
134
135
  and are not installed by a bulk `npx skills add`.)
135
136
 
137
+ ### Keeping installed skills current
138
+
139
+ A skill copied into your project this way is **frozen at install time**,
140
+ while Rigor keeps evolving — flags, config keys, and rule ids move release
141
+ to release. To keep that from going stale (陳腐化), every skill opens with
142
+ a **"First: load the version-current copy"** directive: before following
143
+ its steps, an agent re-fetches the authoritative body from the *installed*
144
+ Rigor with
145
+
146
+ ```sh
147
+ rigor skill --full <name> # the current body + all its references/, in one call
148
+ ```
149
+
150
+ Because the vendored copy is fixed at install time but `rigor skill` always
151
+ reads the gem, the two diverge as you upgrade, and the directive makes the
152
+ agent prefer the gem's current version. You therefore do **not** need to
153
+ re-install the skills after upgrading Rigor — only the thin entry point
154
+ (`rigor-next-steps`) needs to work before Rigor exists; everything
155
+ version-specific is served live by the installed binary.
156
+
136
157
  ## Running a skill
137
158
 
138
159
  In an agent that supports Agent Skills, invoke the skill by name (in
@@ -144,7 +144,7 @@ module Rigor
144
144
 
145
145
  def scan_protection(paths, options)
146
146
  configuration = Configuration.load(options.fetch(:config))
147
- environment = project_environment(configuration)
147
+ environment = plugin_aware_environment(configuration)
148
148
  scope = scope_with_inferred_params(paths, configuration, environment)
149
149
  scanner = Inference::ProtectionScanner.new(scope: scope)
150
150
  accumulator = ProtectionAccumulator.new
@@ -153,21 +153,38 @@ module Rigor
153
153
  accumulator.to_report
154
154
  end
155
155
 
156
- # ADR-67 WD3 seed the call-site parameter-inference table so the
157
- # protection scan counts an inferred-parameter receiver (e.g. `node.loc`
158
- # where `node` is a `def compile(node)` parameter) as protected when its
159
- # call sites resolve to concrete argument types. ONLY the parameter table
160
- # is seeded — no cross-file discoveryso every site that does not gain
161
- # an inferred parameter type is classified byte-identically to the
162
- # un-inferred baseline. Collection spans the scanned `paths`.
156
+ # Seed the protection scan's scope with the same cross-file facts
157
+ # `rigor check` resolves against, so a receiver reads the type it
158
+ # actually has rather than a stripped-scope `Dynamic`:
159
+ #
160
+ # - `discovered_classes`a project constant referring to a class
161
+ # defined in a *sibling* file (`Account`, `User`) types as
162
+ # `singleton(Account)` instead of `Dynamic`. Without this, a
163
+ # single-file scan cannot see a class it does not itself declare,
164
+ # so every cross-file class-constant dispatch was miscounted as
165
+ # unprotected (the model-constant undercount found 2026-07-04).
166
+ # - `param_inferred_types` (ADR-67 WD3) — an inferred-parameter
167
+ # receiver (`node.loc` where `node` is a `def compile(node)`
168
+ # parameter) counts as protected when its call sites resolve to
169
+ # concrete argument types.
170
+ #
171
+ # Both span the scanned `paths` only (no whole-project pre-pass) —
172
+ # a site that gains neither is classified exactly as before.
163
173
  def scope_with_inferred_params(paths, configuration, environment)
164
174
  base = Scope.empty(environment: environment)
175
+ seed = {}
176
+
177
+ discovered = Inference::ScopeIndexer.discovered_classes_for_paths(paths)
178
+ seed[:discovered_classes] = discovered unless discovered.empty?
179
+
165
180
  table = Inference::ParameterInferenceCollector.collect(
166
181
  files: paths, environment: environment, target_ruby: configuration.target_ruby
167
182
  )
168
- return base if table.empty?
183
+ seed[:param_inferred_types] = table unless table.empty?
184
+
185
+ return base if seed.empty?
169
186
 
170
- base.with_discovery(base.discovery.with(param_inferred_types: table))
187
+ base.with_discovery(base.discovery.with(**seed))
171
188
  end
172
189
 
173
190
  def determine_protection_exit(report, options)
@@ -196,6 +213,21 @@ module Rigor
196
213
  CoverageScan.project_environment(configuration)
197
214
  end
198
215
 
216
+ # The protection scan must see the same receiver types `rigor check`
217
+ # does — including plugin-contributed `dynamic_return` types (a
218
+ # controller's `params` → `ActionController::Parameters`, a
219
+ # `Model.where` → `ActiveRecord::Relation[Model]`). The bare
220
+ # `project_environment` carries only the RBS environment (no plugin
221
+ # registry), so every plugin-typed receiver reads `Dynamic` and its
222
+ # dispatch site is miscounted as *unprotected* — a systematic
223
+ # undercount of what Rigor actually types on a plugin-using project.
224
+ # `ProjectContext` builds the plugin-aware environment (registry
225
+ # materialised + the per-run prepare pass that primes producers like
226
+ # the controller / model index) exactly as the LSP and the runner do.
227
+ def plugin_aware_environment(configuration)
228
+ LanguageServer::ProjectContext.new(configuration: configuration).environment
229
+ end
230
+
199
231
  def scan_one(path, scanner, accumulator, configuration)
200
232
  CoverageScan.scan_into(path, scanner, accumulator, configuration)
201
233
  end
@@ -25,6 +25,12 @@ module Rigor
25
25
  # inline body plus the header's absolute
26
26
  # paths let the agent act with or without
27
27
  # a file-reading tool.
28
+ # - `rigor skill --full <name>`— the body AND every `references/*.md`
29
+ # inline: the complete, version-current
30
+ # procedure in one call. The thinned SKILL
31
+ # bodies point a *frozen* (vendored) copy
32
+ # at this so the reader follows the gem's
33
+ # current steps, not a stale local copy.
28
34
  # - `rigor skill --path <name>`— one-line absolute path, for a Read tool.
29
35
  # - `rigor skill --list` — table of name + absolute path.
30
36
  # - `rigor skill --describe` — ADR-73's live entry point: a cheap
@@ -43,12 +49,14 @@ module Rigor
43
49
  # verb, so it stays first-class alongside `--describe`.
44
50
  class SkillCommand < Command
45
51
  USAGE = <<~USAGE
46
- Usage: rigor skill [<name>] [--path <name>] [--list] [--describe]
52
+ Usage: rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
47
53
 
48
54
  With no argument, lists the bundled skills.
49
55
 
50
56
  rigor skill List bundled skills
51
57
  rigor skill <name> Print the SKILL.md body for <name> (with a header)
58
+ rigor skill --full <name> Print the SKILL.md body AND its references/ inline
59
+ (the complete, version-current procedure in one call)
52
60
  rigor skill --path <name> Print the absolute path of the SKILL.md file for <name>
53
61
  rigor skill --list List bundled skills (name + absolute path)
54
62
  rigor skill --describe Report project state + recommend the next skill to run
@@ -56,6 +64,7 @@ module Rigor
56
64
  Examples:
57
65
  rigor skill
58
66
  rigor skill rigor-project-init
67
+ rigor skill --full rigor-baseline-reduce
59
68
  rigor skill --path rigor-baseline-reduce
60
69
  rigor skill --describe (also: rigor describe)
61
70
 
@@ -95,6 +104,9 @@ module Rigor
95
104
  when "--list"
96
105
  @argv.shift
97
106
  run_list
107
+ when "--full"
108
+ @argv.shift
109
+ run_full(@argv.shift)
98
110
  when "--path"
99
111
  @argv.shift
100
112
  run_path(@argv.shift)
@@ -147,6 +159,34 @@ module Rigor
147
159
  0
148
160
  end
149
161
 
162
+ # `rigor skill --full <name>` — the whole current procedure in one
163
+ # call: the SKILL.md body followed by each `references/*.md` inline.
164
+ # This is what the thinned SKILL bodies point a *frozen* copy at — a
165
+ # vendored copy of a skill (installed via `npx skills`) may lag the
166
+ # gem, so re-fetching the complete body here guarantees the reader
167
+ # follows the version that shipped with the installed Rigor, without
168
+ # needing a file-reading tool or reading a possibly-stale co-located
169
+ # `references/`.
170
+ def run_full(name)
171
+ return usage_error("`--full` requires a skill name") if name.nil?
172
+
173
+ skill = find_skill(name)
174
+ return name_error(name) if skill.nil?
175
+
176
+ @out.puts(render_print_header(skill))
177
+ @out.puts
178
+ @out.write(File.read(skill.fetch(:path)))
179
+
180
+ reference_files(skill).each do |ref|
181
+ @out.puts
182
+ @out.puts
183
+ @out.puts("<!-- ===== references/#{File.basename(ref)} (bundled with rigortype #{Rigor::VERSION}) ===== -->")
184
+ @out.puts
185
+ @out.write(File.read(ref))
186
+ end
187
+ 0
188
+ end
189
+
150
190
  def run_path(name)
151
191
  return usage_error("`--path` requires a skill name") if name.nil?
152
192
 
@@ -207,6 +247,17 @@ module Rigor
207
247
  discover_skills.find { |s| s.fetch(:name) == name }
208
248
  end
209
249
 
250
+ # The `references/*.md` files bundled alongside a skill, sorted so the
251
+ # `NN-` prefixes drive read order.
252
+ def reference_files(skill)
253
+ dir = File.join(File.dirname(skill.fetch(:path)), "references")
254
+ return [] unless File.directory?(dir)
255
+
256
+ # `Dir.glob` returns lexicographically sorted paths (Ruby 3.0+),
257
+ # so the `NN-` prefixes already drive read order.
258
+ Dir.glob(File.join(dir, "*.md"))
259
+ end
260
+
210
261
  def name_error(name)
211
262
  @err.puts("Unknown skill: #{name}")
212
263
  @err.puts("Available skills (try `rigor skill --list`):")
@@ -291,12 +291,29 @@ module Rigor
291
291
  # prefix of another name is declared `module` (it is a
292
292
  # namespace); a leaf is declared `class` (referenced types
293
293
  # appear in instance position far more often than as mixins).
294
+ #
295
+ # Names already declared in `base_env` are skipped — exactly the
296
+ # `declared.include?` guard {.collect_missing_namespaces} applies.
297
+ # Without it, stubbing a nested reference (`Foo::Bar::Baz`) re-emits
298
+ # its enclosing prefix (`Foo::Bar`) as a `module`, and when that
299
+ # prefix is already a `class` in the project's own `sig/` the kind
300
+ # mismatch makes `resolve_type_names` raise
301
+ # `RBS::DuplicatedDeclarationError`, collapsing the WHOLE env to nil
302
+ # (every type-of query then degrades to `Dynamic[Top]`). One
303
+ # malformed `.rbs` must not disproportionately blind the analysis:
304
+ # a subclass sig that references an inherited nested type
305
+ # (`class GitAdapter; def x: () -> GitAdapter::Revision`) was the
306
+ # real-world trigger — see the 2026-07-04 redmine onboarding note.
294
307
  def append_stub_declarations(base_env, missing)
308
+ declared = declared_type_names(base_env)
295
309
  names = missing.to_set
296
310
  missing.each do |name|
297
311
  parts = name.split("::")
298
312
  (1...parts.length).each { |i| names << parts[0, i].join("::") }
299
313
  end
314
+ names = names.reject { |name| declared.include?(name) }.to_set
315
+ return if names.empty?
316
+
300
317
  source = names.sort_by { |n| n.count(":") }.map do |name|
301
318
  keyword = names.any? { |other| other != name && other.start_with?("#{name}::") } ? "module" : "class"
302
319
  "#{keyword} #{name}\nend\n"
@@ -308,6 +325,17 @@ module Rigor
308
325
  nil
309
326
  end
310
327
 
328
+ # The `::`-stripped names of every class / module / class-alias
329
+ # declaration already present in `env`, so the synthesis paths
330
+ # never re-declare (and thereby duplicate) a real declaration.
331
+ def declared_type_names(env)
332
+ names = env.class_decls.keys.map { |n| n.to_s.sub(/\A::/, "") }
333
+ if env.respond_to?(:class_alias_decls)
334
+ names.concat(env.class_alias_decls.keys.map { |n| n.to_s.sub(/\A::/, "") })
335
+ end
336
+ names.to_set
337
+ end
338
+
311
339
  # ADR-32 WD4 — merge synthesised-from-source RBS strings
312
340
  # into the freshly-built environment. Each entry is a
313
341
  # `[virtual_filename, rbs_source]` pair. `virtual_filename`
@@ -782,10 +782,6 @@ module Rigor
782
782
  # (the else runs only when no exception was raised), but the
783
783
  # body's scope effects still apply because the body did run
784
784
  # before the else.
785
- def eval_begin_primary(node)
786
- eval_begin_primary_under(node, scope)
787
- end
788
-
789
785
  def eval_begin_primary_under(node, entry_scope)
790
786
  body_type, body_scope =
791
787
  if node.statements
@@ -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.6"
4
+ VERSION = "0.2.7"
5
5
  end
@@ -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