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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/string_scanner.rbs +6 -5
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/06-classes.md +1 -1
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -247
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/manual/02-cli-reference.md +32 -0
- data/docs/manual/04-diagnostics.md +36 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +3 -2
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +34 -13
- data/lib/rigor/analysis/run_cache_key.rb +10 -0
- data/lib/rigor/analysis/runner.rb +2 -1
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +99 -24
- data/lib/rigor/cli/check_command.rb +12 -6
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/doctor_command.rb +6 -8
- data/lib/rigor/cli/skill_command.rb +21 -1
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment.rb +10 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +46 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/server.rb +19 -9
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/sig_gen/writer.rb +183 -47
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +18 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- 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
|
|
575
|
-
|
|
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.
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
table
|
|
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
|
-
|
|
329
|
-
|
|
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
|
|
325
|
-
|
|
324
|
+
for how to read a diagnostic, and
|
|
325
|
+
[manual — Diagnostics](../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
|
|
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
|
|
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:
|
data/docs/manual/06-baseline.md
CHANGED
|
@@ -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
|
data/docs/manual/08-skills.md
CHANGED
|
@@ -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
|
|
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`,
|
|
238
|
-
|
|
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
|
-
[
|
|
68
|
-
|
|
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
|
-
#
|
|
47
|
-
#
|
|
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 →
|
|
113
|
-
# duplicate-hash-key → return-in-ensure). Keys match {.build_node_collectors}'
|
|
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`.
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
|
|
361
|
-
|
|
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(
|
|
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(
|
|
2156
|
-
params =
|
|
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
|
|
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
|