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.
- checksums.yaml +4 -4
- data/README.md +4 -3
- data/docs/manual/02-cli-reference.md +2 -1
- data/docs/manual/08-skills.md +21 -0
- data/lib/rigor/cli/coverage_command.rb +42 -10
- data/lib/rigor/cli/skill_command.rb +52 -1
- data/lib/rigor/environment/rbs_loader.rb +28 -0
- data/lib/rigor/inference/statement_evaluator.rb +0 -4
- 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/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-sorbet/lib/rigor/plugin/sorbet.rb +33 -76
- 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 +16 -0
- 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 +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c16266bcbae2ff766dd6579df68ca08d31563d7f9a813ef49b88bd8f72734b7a
|
|
4
|
+
data.tar.gz: c80a5f751c3f757c30acfa0fd1631918a5b3fdc388752c03f27ad56c11a723c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
data/docs/manual/08-skills.md
CHANGED
|
@@ -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 =
|
|
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
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
@@ -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
|