rigortype 0.3.0 → 0.3.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/06-classes.md +1 -1
  17. data/docs/handbook/07-rbs-and-extended.md +76 -101
  18. data/docs/handbook/08-understanding-errors.md +114 -247
  19. data/docs/handbook/09-plugins.md +54 -144
  20. data/docs/handbook/README.md +5 -3
  21. data/docs/handbook/appendix-liskov.md +4 -2
  22. data/docs/handbook/appendix-phpstan.md +14 -7
  23. data/docs/handbook/appendix-steep.md +4 -2
  24. data/docs/handbook/appendix-type-theory.md +3 -1
  25. data/docs/manual/02-cli-reference.md +32 -0
  26. data/docs/manual/04-diagnostics.md +36 -4
  27. data/docs/manual/06-baseline.md +35 -1
  28. data/docs/manual/08-skills.md +6 -1
  29. data/docs/manual/09-editor-integration.md +3 -2
  30. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  31. data/docs/manual/plugins/rigor-devise.md +4 -2
  32. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  33. data/lib/rigor/analysis/check_rules.rb +34 -13
  34. data/lib/rigor/analysis/run_cache_key.rb +10 -0
  35. data/lib/rigor/analysis/runner.rb +2 -1
  36. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  37. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  38. data/lib/rigor/cache/store.rb +99 -24
  39. data/lib/rigor/cli/check_command.rb +12 -6
  40. data/lib/rigor/cli/check_invocation.rb +84 -0
  41. data/lib/rigor/cli/doctor_command.rb +6 -8
  42. data/lib/rigor/cli/skill_command.rb +21 -1
  43. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  44. data/lib/rigor/cli/skill_describe.rb +75 -9
  45. data/lib/rigor/environment/default_libraries.rb +5 -4
  46. data/lib/rigor/environment.rb +10 -1
  47. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  48. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  49. data/lib/rigor/language_server/buffer_table.rb +46 -6
  50. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  51. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  52. data/lib/rigor/language_server/server.rb +19 -9
  53. data/lib/rigor/language_server.rb +1 -0
  54. data/lib/rigor/plugin/base.rb +29 -2
  55. data/lib/rigor/sig_gen/writer.rb +183 -47
  56. data/lib/rigor/version.rb +1 -1
  57. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  58. data/sig/rigor/cache.rbs +6 -0
  59. data/sig/rigor/inference/void_origin.rbs +18 -0
  60. data/sig/rigor/plugin/base.rbs +4 -3
  61. metadata +7 -3
@@ -571,8 +571,10 @@ model"](appendix-type-theory.md#what-rigor-does-not-model) records.
571
571
  authoring the declared contracts `def.return-type-mismatch` checks
572
572
  against.
573
573
  - [Chapter 8 — Understanding errors](08-understanding-errors.md) for
574
- the `def.return-type-mismatch` / `call.argument-type-mismatch`
575
- rules and severity profiles.
574
+ what the `def.*` family claims, and
575
+ [manual Diagnostics](../manual/04-diagnostics.md) for the
576
+ `def.return-type-mismatch` / `call.argument-type-mismatch`
577
+ entries and the severity profiles.
576
578
 
577
579
  If you want to compare against another *tool* rather than the
578
580
  *principle*, the sibling appendices cover
@@ -72,8 +72,10 @@ in another tab, almost every advanced refinement transfers.
72
72
  ## The `@phpstan-assert` family
73
73
 
74
74
  PHPStan's assertion-narrowing PHPDoc tags map directly onto
75
- Rigor's `RBS::Extended` directive grammar. Chapter 7 covers
76
- the table in depth; here it is again for reference:
75
+ Rigor's `RBS::Extended` directive grammar. This is the mapping;
76
+ [Chapter 7](07-rbs-and-extended.md) works the Rigor side
77
+ through examples, and the directive reference itself is
78
+ [manual — RBS::Extended annotations](../manual/16-rbs-extended-annotations.md):
77
79
 
78
80
  | PHPStan PHPDoc | Rigor RBS::Extended | Effect |
79
81
  | --- | --- | --- |
@@ -149,7 +151,8 @@ Rigor has two baseline mechanisms: a **managed** baseline
149
151
  the next `rigor check` via the `baseline:` config key — the
150
152
  closest match to PHPStan's `--baseline`), and a **lightweight**
151
153
  ad-hoc snapshot (`rigor diff` over a `--format=json` dump).
152
- Chapter 8 has the walkthrough.
154
+ [Manual Baselines](../manual/06-baseline.md) walks through
155
+ both.
153
156
 
154
157
  The `includes:` semantics also match PHPStan's: declaration
155
158
  order, later overrides earlier, the current file's keys win
@@ -322,11 +325,15 @@ You probably do not need to read the rest of this appendix
322
325
  section sequentially. Three useful pointers:
323
326
 
324
327
  - [Chapter 7 — RBS and `RBS::Extended`](07-rbs-and-extended.md)
325
- has the full directive grammar, including the PHPStan-mapping
326
- table that this page summarises.
328
+ works the directive grammar through examples; the reference
329
+ table is
330
+ [manual — RBS::Extended annotations](../manual/16-rbs-extended-annotations.md).
327
331
  - [Chapter 8 — Understanding errors](08-understanding-errors.md)
328
- covers the rule catalogue, severity profiles, baseline
329
- diffing every PHPStan onboarding analogue.
332
+ explains how to read a diagnostic and which knob to reach
333
+ for; the catalogue, severity profiles and baselines — every
334
+ PHPStan onboarding analogue — are
335
+ [manual — Diagnostics](../manual/04-diagnostics.md) and
336
+ [manual — Baselines](../manual/06-baseline.md).
330
337
  - [Chapter 9 — Plugins](09-plugins.md) for the
331
338
  Type-Specifying / Dynamic-Return analogues.
332
339
 
@@ -321,8 +321,10 @@ section sequentially. Three useful pointers:
321
321
  if you want to see how the directive grammar layers on top
322
322
  of the RBS you already write.
323
323
  - [Chapter 8 — Understanding errors](08-understanding-errors.md)
324
- for the rule catalogue, severity profiles, and baseline
325
- diffingthe analogue to Steep's diagnostic config.
324
+ for how to read a diagnostic, and
325
+ [manualDiagnostics](../manual/04-diagnostics.md) for the
326
+ rule catalogue and severity profiles — the analogue to
327
+ Steep's diagnostic config.
326
328
  - [`docs/notes/20260503-steep-cross-check-triage.md`](../notes/20260503-steep-cross-check-triage.md)
327
329
  for a worked side-by-side run of Steep and Rigor on the
328
330
  same project (the project itself).
@@ -1651,7 +1651,9 @@ practical companion:
1651
1651
  for the directive grammar that lets you teach Rigor about a
1652
1652
  custom predicate.
1653
1653
  - [Chapter 8 — Understanding errors](08-understanding-errors.md)
1654
- for the rule catalogue (the user-visible end of the trinary
1654
+ for how a diagnostic reads, and
1655
+ [manual — Diagnostics](../manual/04-diagnostics.md) for the
1656
+ rule catalogue (the user-visible end of the trinary
1655
1657
  certainty).
1656
1658
 
1657
1659
  If you want to compare against another *tool* rather than the
@@ -479,6 +479,7 @@ so a skill can never be shadowed by a verb.
479
479
 
480
480
  ```sh
481
481
  rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
482
+ rigor skill describe [--deep]
482
483
  ```
483
484
 
484
485
  | Form | Purpose |
@@ -488,6 +489,33 @@ rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
488
489
  | `--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. |
489
490
  | `--path <name>` | Print the single-line absolute `SKILL.md` path, suitable as input to a file-reading tool. |
490
491
  | `--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. |
492
+ | `describe --deep` | The same report, except it **runs `rigor check` first** and routes the headline recommendation on the result. Opt-in, because it costs a full analysis and writes `.rigor/cache` — the un-flagged form stays presence-only and side-effect-free. |
493
+
494
+ ### `describe --deep`
495
+
496
+ By default the recommendation comes from a presence-only probe, so it can
497
+ tell that a project *has* a config but not whether the analysis is
498
+ healthy. `--deep` runs the check for you and lets the result pick the
499
+ headline, using the same routing the `## For the agent` section already
500
+ teaches:
501
+
502
+ | Deep check result | Headline becomes |
503
+ | --- | --- |
504
+ | RBS environment built to 0 classes, or a `configuration-error` diagnostic | `rigor-doctor` — the analysis is hollow until the setup is fixed. |
505
+ | Call sites resolving to the project's own definitions (reopened core / gem classes), as at least a third of the errors | `rigor-monkeypatch-resolve` — list them in `pre_eval:` and they clear wholesale. |
506
+ | Any remaining error diagnostics | `rigor-baseline-reduce`. Proven monkey-patch sites below that share are still reported here, so the finding survives even when the headline stays on the larger problem. |
507
+ | Clean, or the project has no config yet | Unchanged — the presence-only recommendation stands. |
508
+
509
+ If the check **cannot run at all** (no config, an unloadable plugin, a
510
+ malformed config) `--deep` does not fail and does not pretend the project
511
+ is clean: it reports what went wrong, falls back to the presence-only
512
+ recommendation, and points you at `rigor doctor`. It deliberately does
513
+ *not* route on weaker signals — "framework calls typing as `Dynamic`" is
514
+ still a judgement call left to you and your agent.
515
+
516
+ ```sh
517
+ rigor skill describe --deep # also: rigor describe --deep
518
+ ```
491
519
 
492
520
  The verb spellings `rigor skill list` / `print <name>` / `path <name>`
493
521
  were **removed in v0.3.0** — the positional slot is a skill name, so
@@ -511,6 +539,10 @@ It reports a presence-only project-state probe (does a `.rigor.yml`,
511
539
  recommended next skill. It is read-only and side-effect-free — it never
512
540
  runs `rigor check`. Identical output to `rigor skill describe`.
513
541
 
542
+ `rigor describe --deep` forwards to
543
+ [`rigor skill describe --deep`](#rigor-skill), which opts into running the
544
+ check first — slow, and it writes the cache.
545
+
514
546
  ## `rigor docs`
515
547
 
516
548
  Print the documentation bundled inside the `rigortype` gem
@@ -97,7 +97,21 @@ re-stamps it for the run. Three profiles, set with the
97
97
  | --- | --- |
98
98
  | `lenient` | Only proven diagnostics are errors; uncertain ones drop to `warning` / `info`. For incremental adoption on legacy code. |
99
99
  | `balanced` *(default)* | Most rules `error`; `dump.type` `info`; uncertain rules `warning`. |
100
- | `strict` | Nearly every rule is an `error` the exceptions are `call.self-undefined-method` (stays `off`, opt-in only) and `flow.unreachable-clause` (`warning`, pending its false-positive gate). CI-friendly. |
100
+ | `strict` | Nearly every rule is an `error`. The exceptions: `call.self-undefined-method` and `static.value-use.void` stay `off` (both opt-in only), `flow.unreachable-clause` stays `warning` pending its false-positive gate, and the three `suppression.*` rules stay `warning` — a stale suppression comment is worth telling you about, but it is not a reason to fail a build. CI-friendly. |
101
+
102
+ Under `balanced`, the rules that do **not** emit as `error`
103
+ are:
104
+
105
+ | Severity | Rules |
106
+ | --- | --- |
107
+ | `warning` | `call.unresolved-toplevel`, `def.ivar-write-mismatch`, `def.return-type-mismatch`, `def.override-visibility-reduced`, `def.override-return-widened`, `def.override-param-narrowed`, `flow.unreachable-branch`, `flow.always-truthy-condition`, `flow.dead-assignment`, `flow.duplicate-hash-key`, `flow.return-in-ensure`, `flow.shadowed-rescue-clause`, `suppression.unknown-rule`, `suppression.empty`, `suppression.unknown-marker` |
108
+ | `info` | `flow.unreachable-clause`, `dump.type` |
109
+ | `off` | `call.self-undefined-method`, `static.value-use.void` |
110
+
111
+ Everything else emits as `error`. For one rule under all three
112
+ profiles, `rigor explain <rule>` prints `Authored severity:` and
113
+ `Severity by profile:` — that output is generated from the rule
114
+ catalogue itself, so it is the per-rule source of truth.
101
115
 
102
116
  For finer control, `severity_overrides:` maps a rule ID or a
103
117
  family to one of `error`, `warning`, `info`, or `off`:
@@ -109,7 +123,15 @@ severity_overrides:
109
123
  call: warning
110
124
  ```
111
125
 
112
- A rule-specific override beats a family override.
126
+ A rule-specific override beats a family override. `off` drops
127
+ the diagnostic from the result entirely, which makes
128
+ `severity_overrides:` the lighter-touch sibling of `disable:`
129
+ below — both silence a rule; the override reads as "this one
130
+ rule, at this severity" alongside the rest of the profile.
131
+
132
+ YAML reserves the bareword `off` as a boolean. If an override
133
+ that names it seems not to apply, quote it — `"off"` — and the
134
+ same for `on`.
113
135
 
114
136
  ## Machine-readable output (`--format json`)
115
137
 
@@ -198,7 +220,10 @@ config.merge(extra) # rigor:disable call.undefined-method
198
220
  ```
199
221
 
200
222
  It accepts qualified IDs, family wildcards (`call`), a
201
- comma- or space-separated list, or `all`.
223
+ comma- or space-separated list, or `all`. The comment must sit
224
+ on the line the diagnostic points at; there is no
225
+ `disable-block` form, so an expression spread over several
226
+ lines needs the comment on each line that fires.
202
227
 
203
228
  A marker that cannot work is flagged rather than silently
204
229
  ignored: a token that names no known rule (a typo like
@@ -217,7 +242,14 @@ suppressible like any other rule.
217
242
 
218
243
  **In-source, whole file.** `# rigor:disable-file <rules>`
219
244
  anywhere in a file suppresses those rules for every line;
220
- `# rigor:disable-file all` silences the file.
245
+ `# rigor:disable-file all` silences the file. Convention is to
246
+ put it near the top — typically on a generated file, a fixture,
247
+ or a vendored snippet — but every comment in the file is
248
+ scanned, so any placement works.
249
+
250
+ The three layers **compose**: a file-scope marker does not
251
+ cancel a line-scope one, and a project-wide `disable:` still
252
+ applies to a file that carries neither.
221
253
 
222
254
  **Project-wide.** The `disable:` config key turns rules off
223
255
  across the whole run:
@@ -4,7 +4,11 @@ A **baseline** records the diagnostics a project already has,
4
4
  so `rigor check` can stay silent about them and surface only
5
5
  what is *new*. It is the pragmatic on-ramp for adopting Rigor
6
6
  on an existing codebase: you do not have to reach zero
7
- diagnostics before the check becomes useful in CI.
7
+ diagnostics before the check becomes useful in CI. It is also
8
+ what the [`rigor-project-init` skill](08-skills.md) snapshots
9
+ for you at onboarding
10
+ ([ADR-22](../adr/22-baseline-and-project-onboarding.md) is the
11
+ design).
8
12
 
9
13
  ## The baseline file
10
14
 
@@ -89,6 +93,36 @@ unless you specifically need per-message discrimination, and
89
93
  expect to `regenerate` a `message`-mode baseline after upgrading
90
94
  Rigor.
91
95
 
96
+ ## The ad-hoc form — `rigor diff`
97
+
98
+ A managed baseline is not the only way to fail CI on *new*
99
+ diagnostics only. The lightweight alternative keeps a plain JSON
100
+ snapshot in the repository and compares against it explicitly:
101
+
102
+ ```sh
103
+ # Once: capture the current diagnostic surface.
104
+ rigor check --format=json > rigor.baseline.json
105
+ git add rigor.baseline.json
106
+
107
+ # Per PR: compare against the committed snapshot.
108
+ rigor diff rigor.baseline.json
109
+ ```
110
+
111
+ [`rigor diff`](02-cli-reference.md#rigor-diff) prints a `+ NEW`
112
+ row for every diagnostic absent from the snapshot and a
113
+ `- FIXED` row for every one resolved since, and exits `1` when
114
+ anything is new — so a PR that adds a violation fails while the
115
+ recorded legacy ones stay quiet. `--format=json` is available
116
+ for editor and dashboard integrations. Regenerate the snapshot
117
+ with the same `rigor check --format=json` redirection whenever
118
+ you fix a row, and the project tightens monotonically.
119
+
120
+ The difference from a managed baseline is where the knowledge
121
+ lives: `rigor diff` is a separate step your CI script has to
122
+ run, while a `baseline:` file makes `rigor check` itself exit
123
+ clean on recorded diagnostics. Prefer the managed form unless
124
+ you specifically want the raw JSON snapshot.
125
+
92
126
  ## Working a baseline down
93
127
 
94
128
  `rigor triage` summarises a diagnostic stream — rule
@@ -106,6 +106,7 @@ source checkout. The `rigor skill` command surfaces them:
106
106
 
107
107
  ```sh
108
108
  rigor skill describe # probe the project + recommend the next skill (alias: rigor describe)
109
+ rigor skill describe --deep # same, but run `rigor check` first and route on its result
109
110
  rigor skill --list # name + absolute path for each bundled skill
110
111
  rigor skill <name> # print the SKILL.md body (with a references/ header)
111
112
  rigor skill --full <name> # the body + every references/*.md inline (complete procedure)
@@ -113,7 +114,11 @@ rigor skill --path <name> # one-line absolute SKILL.md path, for a file-readin
113
114
  ```
114
115
 
115
116
  `rigor skill describe` is the recommendation engine driven by
116
- `rigor-next-steps`; `rigor skill rigor-project-init` is the
117
+ `rigor-next-steps`. It is a presence-only probe by default — cheap and
118
+ side-effect-free; add `--deep` when you want the recommendation to come
119
+ from a real analysis instead
120
+ ([CLI reference](02-cli-reference.md#describe---deep)).
121
+ `rigor skill rigor-project-init` is the
117
122
  canonical way to hand an agent the onboarding workflow without pointing
118
123
  it at the repository. The `list` / `print` / `path` verb spellings are
119
124
  deprecated (removed in v0.3.0). See [CLI reference](02-cli-reference.md#rigor-skill).
@@ -18,6 +18,7 @@ design + capability matrix lives in
18
18
 
19
19
  | LSP method | Behaviour |
20
20
  |---|---|
21
+ | `textDocument/didOpen` / `didChange` / `didClose` | Incremental sync (`TextDocumentSyncKind::Incremental`): a `didChange` carries range edits, which are spliced into the buffer the server already holds instead of re-sending and re-parsing the whole document on every keystroke. Positions are read as UTF-16 code-unit offsets (`positionEncoding: "utf-16"`), so an emoji or other non-BMP character counts as two. The full-text change form stays supported. |
21
22
  | `textDocument/publishDiagnostics` | Pushed on every `didChange`, 200ms debounced. Severity / rule / source map directly to Rigor's diagnostic taxonomy. |
22
23
  | `textDocument/hover` | Type-aware markdown. Per-node-class dispatch surfaces receiver type + RBS signature for method calls, FQN + singleton type + defined-in path for constants, narrowed type + bound-at for locals, canonical refinement names (`non-empty-string`, …) for `Refined` / `Difference` carriers. |
23
24
  | `textDocument/completion` | Method completion after `.` (driven by inferred receiver type), constant-path completion after `::`. Composite receivers (Union → intersection of methods, Tuple / HashShape → ancestor nominal, Refined → underlying nominal) handled. Parse-recovery sentinel makes mid-edit `obj.` / `Foo::` buffers work. |
@@ -234,8 +235,8 @@ Cold start is dominated by RBS environment build; warm starts
234
235
 
235
236
  LSP v1 + v2 landed in v0.1.6 and ship in the `0.1.x` line. Queued
236
237
  follow-ups (`textDocument/signatureHelp`, hash-key completion,
237
- `textDocument/definition`, incremental `didChange` sync, Ractor
238
- pool dispatch, codeAction / rename / semanticTokens / inlayHint)
238
+ `textDocument/definition`, Ractor pool dispatch,
239
+ codeAction / rename / semanticTokens / inlayHint)
239
240
  are demand-driven; the current queue is the open
240
241
  [`area:editor` issues](https://github.com/rigortype/rigor/issues?q=is%3Aissue+is%3Aopen+label%3Aarea%3Aeditor).
241
242
 
@@ -44,6 +44,30 @@ or `stream_for record`) — the absence of a literal match doesn't prove
44
44
  the stream is invalid. Non-`Channel` receivers and non-literal stream
45
45
  arguments pass through silently.
46
46
 
47
+ ## `#receive(data)` parameter typing
48
+
49
+ The plugin also carries an [ADR-28](../../adr/28-path-scoped-protocol-contracts.md)
50
+ path-scoped protocol contract: inside any `#receive(data)` defined
51
+ under `channel_search_paths`, `data` types as `Hash` instead of
52
+ `Dynamic[Top]`. `#receive` is ActionCable's framework-dispatched
53
+ catch-all action — invoked with the decoded JSON payload when an
54
+ incoming message carries no `"action"` key — so the parameter shape is
55
+ uniform across every channel.
56
+
57
+ ```ruby
58
+ # app/channels/chat_channel.rb
59
+ class ChatChannel < ApplicationCable::Channel
60
+ def receive(data)
61
+ data["body"] # data: Hash
62
+ data.no_such_method # error: call.undefined-method
63
+ end
64
+ end
65
+ ```
66
+
67
+ Custom action methods (`def speak(data)`) are not covered — their
68
+ names are project-chosen, and a protocol contract names a single fixed
69
+ method. Only `#receive` is a reserved, uniformly-shaped hook.
70
+
47
71
  ## Configuration
48
72
 
49
73
  ```yaml
@@ -54,6 +78,11 @@ plugins:
54
78
  channel_base_classes: ["ApplicationCable::Channel", "ActionCable::Channel::Base"] # default
55
79
  ```
56
80
 
81
+ `channel_search_paths` also retargets the `#receive` protocol
82
+ contract's glob — including for multiple configured roots — so a
83
+ custom channel directory gets the same `data: Hash` typing as the
84
+ default.
85
+
57
86
  ## Limitations
58
87
 
59
88
  - **Direct-superclass match only.** An indirect chain (`AdminChannel <
@@ -68,6 +97,9 @@ plugins:
68
97
  than directly in the channel body) is out of scope.
69
98
  - **Bare `broadcast(...)`** without an explicit `ActionCable.server`
70
99
  receiver is skipped to avoid false positives on unrelated methods.
100
+ - **The `#receive` contract is path-scoped, not class-scoped** (ADR-28):
101
+ any `def receive(data)` defined anywhere under `channel_search_paths`
102
+ is typed, even on a class that isn't an ActionCable channel.
71
103
 
72
104
  ## Plugin internals
73
105
 
@@ -64,7 +64,9 @@ The macro manifest (the trait registry mapping each strategy to its
64
64
  module), the concern re-targeting walk, the demo, and the
65
65
  contract surfaces this plugin exercises are in the
66
66
  [plugin's README](../../../plugins/rigor-devise/README.md);
67
- [handbook chapter 9](../../handbook/09-plugins.md) covers the Tier B
68
- macro substrate generally. To write a plugin, see
67
+ [`docs/internal-spec/macro-substrate.md`](../../internal-spec/macro-substrate.md)
68
+ specifies the Tier B trait registry generally, and
69
+ [handbook chapter 9](../../handbook/09-plugins.md) is the
70
+ orientation. To write a plugin, see
69
71
  [`examples/`](../../../examples/README.md) and the
70
72
  [`rigor-plugin-author`](../08-skills.md) skill.
@@ -36,6 +36,12 @@ module Rigor
36
36
  Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
37
37
  ].freeze
38
38
 
39
+ # ADR-53 Track B — the node classes the shared {RuleWalk} dispatches to this collector: the consumer
40
+ # nodes whose slots are value positions. Prism node classes are leaves, so class-equality dispatch
41
+ # matches the legacy walk's `case`. No context gate — a value-context void use is a use wherever the
42
+ # DFS reaches it, loops and blocks included.
43
+ NODE_CLASSES = [Prism::CallNode, *WRITE_NODE_CLASSES].freeze
44
+
39
45
  Result = Data.define(:void_node, :origin)
40
46
 
41
47
  def initialize(scope_index)
@@ -43,14 +49,27 @@ module Rigor
43
49
  @results = []
44
50
  end
45
51
 
46
- # Walk the whole subtree once, checking each consumer node's value slots. Returns one {Result} per
47
- # value-context use of a recovered-`void` call.
52
+ # Legacy single-collector walk kept as the oracle the ADR-53 Track B equivalence harness compares
53
+ # {RuleWalk} against. Walks the whole subtree once, checking each consumer node's value slots.
54
+ # Returns one {Result} per value-context use of a recovered-`void` call.
48
55
  # @return [Array<Result>]
49
56
  def collect(root)
50
57
  walk(root)
51
58
  @results.freeze
52
59
  end
53
60
 
61
+ # {RuleWalk} entry point: the legacy walk's per-node slot inspection, invoked at every consumer node
62
+ # under the shared traversal contract. The `context` is unused — every consumer node is inspected.
63
+ def visit(node, _context = nil)
64
+ inspect_consumer(node)
65
+ end
66
+
67
+ # The accumulated result, frozen the same way `#collect` returns it — used by {RuleWalk}-driven
68
+ # callers after the walk completes.
69
+ def results
70
+ @results.freeze
71
+ end
72
+
54
73
  private
55
74
 
56
75
  def walk(node)
@@ -97,7 +97,6 @@ module Rigor
97
97
  collectors = node_collectors || run_node_collectors(path, root, scope_index)
98
98
  diagnostics = collectors[:main_pass].results.dup
99
99
  diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
100
- diagnostics.concat(void_value_use_diagnostics(path, root, scope_index))
101
100
  COLLECTOR_DIAGNOSTIC_BUILDERS.each do |role, builder|
102
101
  diagnostics.concat(send(builder, path, collectors[role].results))
103
102
  end
@@ -109,10 +108,13 @@ module Rigor
109
108
  end
110
109
 
111
110
  # The per-collector diagnostic builders {.diagnose} folds over, in the historical emission order
112
- # (always-truthy → unreachable-clause → shadowed-rescue → ivar-write → dead-assignment →
113
- # duplicate-hash-key → return-in-ensure). Keys match {.build_node_collectors}' roles; each value is a
114
- # `(path, results)` module_function on this module.
111
+ # (value-use-void → always-truthy → unreachable-clause → shadowed-rescue → ivar-write →
112
+ # dead-assignment → duplicate-hash-key → return-in-ensure). Keys match {.build_node_collectors}'
113
+ # roles; each value is a `(path, results)` module_function on this module. `void_value_use` leads
114
+ # because its standalone walk used to run before this fold, and the emission order is byte-identical
115
+ # output, not an implementation detail.
115
116
  COLLECTOR_DIAGNOSTIC_BUILDERS = {
117
+ void_value_use: :void_value_use_diagnostics,
116
118
  always_truthy: :always_truthy_condition_diagnostics,
117
119
  unreachable_clauses: :unreachable_clause_diagnostics,
118
120
  shadowed_rescues: :shadowed_rescue_diagnostics,
@@ -155,6 +157,7 @@ module Rigor
155
157
  def build_node_collectors(path, scope_index)
156
158
  {
157
159
  main_pass: MainPassCollector.new(->(node) { main_pass_node_diagnostics(path, node, scope_index) }),
160
+ void_value_use: VoidValueUseCollector.new(scope_index),
158
161
  always_truthy: AlwaysTruthyConditionCollector.new(scope_index),
159
162
  unreachable_clauses: UnreachableClauseCollector.new(scope_index),
160
163
  shadowed_rescues: ShadowedRescueCollector.new(scope_index),
@@ -354,12 +357,11 @@ module Rigor
354
357
  end
355
358
  end
356
359
 
357
- # ADR-100 WD2 — `static.value-use.void`. Runs a standalone walk (like `self_undefined_method_diagnostics`)
358
- # over `root`, so its value-context slot inspection stays independent of the shared per-node
359
- # {RuleWalk}. Each result is a value-context use of a call whose author-declared `-> void` return the
360
- # engine recovered to `top`.
361
- def void_value_use_diagnostics(path, root, scope_index)
362
- VoidValueUseCollector.new(scope_index).collect(root).map do |result|
360
+ # ADR-100 WD2 — `static.value-use.void`. Its value-context slot inspection rides the shared per-node
361
+ # {RuleWalk} like the other collectors, so the file is not re-traversed for it. Each result is a
362
+ # value-context use of a call whose author-declared `-> void` return the engine recovered to `top`.
363
+ def void_value_use_diagnostics(path, results)
364
+ results.map do |result|
363
365
  build_void_value_use_diagnostic(path, result)
364
366
  end
365
367
  end
@@ -2117,7 +2119,7 @@ module Rigor
2117
2119
  arguments = call_node.arguments&.arguments || []
2118
2120
  arguments.each_with_index do |arg, index|
2119
2121
  arg_type = scope.type_of(arg)
2120
- params = overload_positional_params(functions, index)
2122
+ params = overload_positional_params(method_types, index)
2121
2123
  next if params.nil? # arity divergence — some overload lacks a param here
2122
2124
 
2123
2125
  mismatch =
@@ -2152,11 +2154,30 @@ module Rigor
2152
2154
  # The matching positional RBS param across every overload, or nil when
2153
2155
  # any overload has no param at `index` (arity divergence — the
2154
2156
  # wrong-arity rule's concern, not this one's).
2155
- def overload_positional_params(functions, index)
2156
- params = functions.map { |function| (function.required_positionals + function.optional_positionals)[index] }
2157
+ def overload_positional_params(method_types, index)
2158
+ params = method_types.map do |method_type|
2159
+ function = method_type.type
2160
+ param = (function.required_positionals + function.optional_positionals)[index]
2161
+ param && resolve_param_bounds(param, method_type)
2162
+ end
2157
2163
  params.any?(&:nil?) ? nil : params
2158
2164
  end
2159
2165
 
2166
+ # Substitutes each bounded method-level type parameter for its bound, so
2167
+ # `[I < _ToInt] (I index) -> …` is walked as `(_ToInt index) -> …`. A bare
2168
+ # `Variable` is undecidable to the acceptance walk and admits everything,
2169
+ # which silently disables both channels for the whole overload; the bound
2170
+ # constrains the argument exactly as an ordinary param of that type would.
2171
+ # Load-bearing since rbs 4.1 rewrote core signatures into this form
2172
+ # (`Array#fetch`'s block overload is `[I < _ToInt, T] (I index) { … }`).
2173
+ def resolve_param_bounds(param, method_type)
2174
+ bounded = method_type.type_params.select(&:upper_bound)
2175
+ return param if bounded.empty?
2176
+
2177
+ substitution = RBS::Substitution.build(bounded.map(&:name), bounded.map(&:upper_bound))
2178
+ param.map_type { |type| type.sub(substitution) }
2179
+ end
2180
+
2160
2181
  # The class names whose instances `nil` IS — `NilClass` and every
2161
2182
  # ancestor. A parameter typed as any other class instance rejects nil.
2162
2183
  NIL_COMPATIBLE_CLASS_NAMES = %w[NilClass Object BasicObject Kernel].to_set.freeze
@@ -36,6 +36,16 @@ module Rigor
36
36
 
37
37
  RUN_DIAGNOSTICS_PRODUCER_ID = "analysis.run-diagnostics"
38
38
 
39
+ # The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
40
+ # but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
41
+ # one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
42
+ # single file are three separate live generations. 16 is a judgement call sized for that churn; it is
43
+ # a cap on GENERATIONS, not on correctness — over-evicting here costs a recompute, never a wrong
44
+ # answer. Measured 2026-07-25 against a real project's `.rigor/cache`: the cap does bind (60 distinct
45
+ # path-set generations observed, differing in the `paths` slot alone), with no evidence yet on how
46
+ # often an evicted generation is asked for again — see issue #151.
47
+ GENERATION_CAP = 16
48
+
39
49
  # @param rbs_config_entries [Array<Cache::Descriptor::ConfigEntry>] the RBS-derived config slots
40
50
  # (`rbs.libraries` [+ `rbs.virtual_rbs`]). nil on any failure so a malformed key disables the cache.
41
51
  def descriptor(configuration:, files:, explain:, rbs_config_entries:)
@@ -454,7 +454,8 @@ module Rigor
454
454
 
455
455
  computed = false
456
456
  diagnostics = @cache_store.fetch_or_validate(
457
- producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key_descriptor
457
+ producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key_descriptor,
458
+ generation_cap: RunCacheKey::GENERATION_CAP
458
459
  ) do
459
460
  computed = true
460
461
  diags = assemble_run_diagnostics(expansion, environment: environment)
@@ -15,12 +15,22 @@ module Rigor
15
15
  # `self::PRODUCER_ID` resolves the constant on the concrete subclass, and `compute(loader)` dispatches to
16
16
  # its private class method. See the `_CacheProducer` RBS interface for the structural contract.
17
17
  class RbsCacheProducer
18
+ # Every RBS producer is whole-project and content-keyed: one entry is live and any older generation is
19
+ # unreachable, so `Cache::Store#evict!` keeps a small number of them (one spare for the still-warm
20
+ # previous signature state — the RBS environment blob alone runs to ~1.8 MB, ADR-54 WD3). Declared on
21
+ # the base, so a producer added by subclassing inherits a cap instead of being silently uncapped; a
22
+ # subclass with different economics overrides this method.
23
+ def self.generation_cap
24
+ 2
25
+ end
26
+
18
27
  def self.fetch(loader:, store:)
19
28
  # ADR-54 WD4 — the descriptor is identical for every producer consulting the same loader (same sig
20
29
  # files, same libraries), so the loader memoises one build per process instead of re-digesting every
21
30
  # .rbs file once per producer.
22
31
  descriptor = loader.rbs_cache_descriptor
23
- store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
32
+ store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor,
33
+ generation_cap: generation_cap) do
24
34
  compute(loader)
25
35
  end
26
36
  end
@@ -17,6 +17,20 @@ require "rbs"
17
17
  #
18
18
  # Idempotent: the guard checks `method_defined?(:_dump)` so requiring this file twice (or against an upstream
19
19
  # rbs that adds Marshal hooks itself) is a no-op.
20
+ #
21
+ # `RBS::TypeName` / `RBS::Namespace` carry a second, subtler Marshal hazard, introduced by rbs 4.1's
22
+ # flyweight interning (ruby/rbs#2957): both memoise their `#hash` into an `@hash` ivar, and that value is
23
+ # derived from `Array#hash` / `Symbol#hash`, which Ruby seeds PER PROCESS. Marshal round-trips the ivar
24
+ # verbatim, so a cached `TypeName` loaded in a later process answers `hash` with the *writing* process's
25
+ # value while a freshly parsed `TypeName.parse("::String")` answers with this process's. The two are still
26
+ # `eql?`, so nothing raises — every Hash keyed by a TypeName simply misses. `RBS::Environment#class_decls`
27
+ # is exactly such a Hash, so a warm cache silently reported every core class as unknown.
28
+ #
29
+ # `_dump` / `_load` on both classes fixes it at the representation: the name is dumped as its source string
30
+ # and reconstructed through `.parse`, which routes back through the flyweight interner. That drops the stale
31
+ # `@hash` (it is re-memoised on demand, under this process's seed), and restores the flyweight identity the
32
+ # interner exists to provide — a cached env now shares one object per distinct name rather than one per
33
+ # reference. Both classes are value objects fully described by `to_s`, so the round-trip is lossless.
20
34
  module RBS
21
35
  class Location
22
36
  unless method_defined?(:_dump)
@@ -29,4 +43,28 @@ module RBS
29
43
  end
30
44
  end
31
45
  end
46
+
47
+ class Namespace
48
+ unless method_defined?(:_dump)
49
+ def _dump(_)
50
+ to_s
51
+ end
52
+
53
+ def self._load(string)
54
+ parse(string)
55
+ end
56
+ end
57
+ end
58
+
59
+ class TypeName
60
+ unless method_defined?(:_dump)
61
+ def _dump(_)
62
+ to_s
63
+ end
64
+
65
+ def self._load(string)
66
+ parse(string)
67
+ end
68
+ end
69
+ end
32
70
  end