rigortype 0.3.0 → 0.3.2

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  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/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -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
 
@@ -28,12 +28,23 @@ on:
28
28
  - the **content** of the source and `.rbs` files that fed it,
29
29
  - the **gems** in play, by name and locked version,
30
30
  - the active **plugins**, by ID and version,
31
- - the relevant **configuration**.
31
+ - the relevant **configuration**,
32
+ - **Rigor itself**, by version.
32
33
 
33
34
  Change any of those and the dependent entries are recomputed
34
35
  automatically. A corrupt or unreadable entry is treated as a
35
36
  miss and overwritten — bad cache state cannot wedge a run.
36
37
 
38
+ Rigor's version identifies its own code only for a released gem.
39
+ If you run Rigor from a git checkout — a working copy you are
40
+ patching, or `gem "rigor", github:` tracking a branch, where two
41
+ commits share one version number — the entries that hold
42
+ analysis results are keyed on the content of Rigor's own source
43
+ as well, so editing the analyzer and re-running recomputes
44
+ instead of replaying the previous answer. That costs one pass
45
+ over Rigor's source per run (about 20 ms); an installed gem
46
+ neither pays it nor needs it.
47
+
37
48
  The cache is also schema-versioned: after a Rigor upgrade that
38
49
  changes the cache format, the stale cache is purged on the
39
50
  first writable run.
@@ -157,10 +168,11 @@ affected files in parallel just as a full run does.
157
168
 
158
169
  The snapshot lives under the cache directory (`.rigor/cache`)
159
170
  and is keyed by a fingerprint of your configuration, your locked
160
- gems, your project's own `sig/` RBS, and the Rigor version.
161
- Change any of those and the snapshot is dropped and the next run
162
- is a full one, so an incremental run can never serve a stale
163
- result. (The fingerprint keys on the analysis *roots* e.g.
171
+ gems, your project's own `sig/` RBS, the Rigor version, and — if
172
+ you run Rigor from a checkout the content of Rigor's own
173
+ source. Change any of those and the snapshot is dropped and the
174
+ next run is a full one, so an incremental run can never serve a
175
+ stale result. (The fingerprint keys on the analysis *roots* — e.g.
164
176
  `["lib"]` — not the expanded file list, so adding or removing a
165
177
  file *under* those roots does **not** drop the snapshot: the
166
178
  incremental session re-analyzes the added files and the
@@ -48,6 +48,17 @@ power. `--threshold=RATIO` turns it into a CI gate (exit `1`
48
48
  below the ratio) and `--format=json` carries the structured
49
49
  fields.
50
50
 
51
+ Some `protected` sites are typed only through call-site
52
+ [parameter inference](03-configuration.md) (`parameter_inference:`
53
+ — an undeclared parameter seeded from what its callers pass)
54
+ rather than a declaration — a lower bound that can only widen, so
55
+ none of Rigor's negative rules fire against it yet
56
+ ([ADR-67](../adr/67-parameter-type-inference.md) WD6b). The text
57
+ report calls these out as "lower-bound-typed" when there are any,
58
+ and the JSON form always carries the count as `lower_bound_typed`.
59
+ It is a sub-count *within* `protected`, never subtracted from it —
60
+ the protected ratio itself is unaffected.
61
+
51
62
  On a large project, `--workers=N` fork-parallelizes the scan
52
63
  (both the parameter-inference pre-pass and the per-file scan),
53
64
  with output byte-identical to a sequential run. The worker count
@@ -74,11 +85,117 @@ real "add a type here" site, surfaced with no guesswork.
74
85
 
75
86
  It runs many analyses, so it defaults to the **git-changed** `.rb`
76
87
  files (pass explicit paths to widen — whole-project is minutes) and
77
- is an opt-in CI deep-dive, not an interactive check. The framing is
88
+ is an opt-in CI deep-dive, not an interactive check. `--workers=N`
89
+ applies here too, and it is the main lever on a wide run: the
90
+ whole-project pre-pass is paid once, then the per-file measurement —
91
+ which is nearly all of the wall time — is fork-mapped across
92
+ workers, byte-identically to a sequential run.
93
+
94
+ The framing is
78
95
  always *effectiveness / where to add a type*, never "your code is
79
96
  broken": a surviving breakage at a `Dynamic` site is a place
80
97
  the type net does not reach.
81
98
 
99
+ ### Re-running it is cheap
100
+
101
+ Each file's measurement is cached and served back while nothing
102
+ that could change it has moved — the file itself, any file it was
103
+ recorded as reading from, the resolved configuration, your `sig/`,
104
+ the gem set, the engine version, `--limit` / `--seed`, and which
105
+ bleeding-edge features you have adopted. Edit one file and that
106
+ file plus the files recorded as reading from it are re-measured;
107
+ the rest are served. A one-line stderr report says what happened,
108
+ so `--format=json` stdout stays clean:
109
+
110
+ ```
111
+ coverage: mutation cache — re-measured 2 file(s), 350 served from cache.
112
+ ```
113
+
114
+ The cache reads the cross-file dependency edges that a
115
+ `rigor check --incremental` run records, so warm one once per
116
+ project:
117
+
118
+ ```sh
119
+ rigor check --incremental # records the edges
120
+ rigor coverage --protection --mutation lib # measures, then caches
121
+ ```
122
+
123
+ Without that snapshot — or under `--no-cache`, or with
124
+ `dependent-closure-kill-oracle` adopted, whose verdicts depend on
125
+ *other* files' diagnostics and so cannot be validated per file —
126
+ every file is re-measured and the stderr line names the reason. A
127
+ file the snapshot does not know about is re-measured rather than
128
+ served: the cache never guesses.
129
+
130
+ ### Which sites Tier 2 measures
131
+
132
+ Tier 2 only mutates a site where Rigor holds a *concrete* receiver
133
+ type — at a `Dynamic` receiver the breakage would survive by
134
+ construction, which measures nothing. Today it decides that from
135
+ the file alone, so a call on a project class declared in a
136
+ **sibling** file (`Post.where`, `Account.find`) reads `Dynamic` and
137
+ the site is left out of the denominator entirely, even though Tier
138
+ 1 counts it.
139
+
140
+ The [`discovery-seeded-mutation-sites`](02-cli-reference.md#rigor-show-bleedingedge)
141
+ bleeding-edge feature closes that gap, giving Tier 2 the same
142
+ cross-file view Tier 1 has — on *both* halves of the measurement,
143
+ site selection and the re-analysis that decides whether a mutation
144
+ was caught, so an admitted site is one a breakage can really trip:
145
+
146
+ ```yaml
147
+ # .rigor.yml
148
+ bleeding_edge:
149
+ - discovery-seeded-mutation-sites
150
+ ```
151
+
152
+ It is **off by default and opt-in on purpose**. Those sites are
153
+ mostly ones the type net does not yet catch, so admitting them
154
+ *lowers* the reported effectiveness ratio on unchanged code — and
155
+ `--threshold=RATIO` exits 1 below a ratio you pin in CI. Adopt it
156
+ when you are ready to re-baseline that number; it is the intended
157
+ default at a future major.
158
+
159
+ ### Where a catch is allowed to land
160
+
161
+ Deciding whether a breakage was caught is a separate question from
162
+ which sites are measured, and it has the same blind spot in the
163
+ opposite direction. Tier 2 re-analyses the mutated file and looks
164
+ at *that file's* diagnostics — so the most valuable catch Rigor
165
+ delivers is scored as a miss. Change what a method returns and the
166
+ error appears in its **callers**, which is exactly the cross-file
167
+ reach the analyzer exists for.
168
+
169
+ The [`dependent-closure-kill-oracle`](02-cli-reference.md#rigor-show-bleedingedge)
170
+ bleeding-edge feature re-analyses the mutated file **and the files
171
+ that depend on it**, and counts the breakage as caught when a new
172
+ diagnostic appears anywhere in that set:
173
+
174
+ ```yaml
175
+ # .rigor.yml
176
+ bleeding_edge:
177
+ - dependent-closure-kill-oracle
178
+ ```
179
+
180
+ It composes with the site seed above — one decides what is
181
+ measured, the other where a catch may land — and either can be
182
+ adopted alone. The mutated file's verdict is unchanged, so this can
183
+ only **add** kills: the ratio moves up or not at all, and it cannot
184
+ turn a `--threshold` build red. A number recorded under it is still
185
+ not comparable with one recorded without it.
186
+
187
+ It costs more: each surviving breakage is re-analysed once per
188
+ dependent file (the caught ones stop at the mutated file), and the
189
+ dependency graph itself is one extra whole-project pass per run —
190
+ about a third more wall time on Rigor's own `lib`.
191
+
192
+ Worth knowing before you pay that: on the two codebases measured so
193
+ far (Rigor's own `lib`, redmine's `app/models`) it added **no**
194
+ kills. A breakage that survives there is one the analyzer reports
195
+ *nowhere* — not one it reports in a caller — which is a statement
196
+ about where the type net has teeth, and exactly what the
197
+ unprotected-site list is telling you.
198
+
82
199
  What *does* reach it is your tests.
83
200
 
84
201
  ## The fused view — types **and** tests (`--with-tests`)
@@ -236,6 +353,10 @@ them as a deep-dive, not a per-keystroke check:
236
353
  (suite runtime)`. A fast, well-scoped test command is the biggest
237
354
  lever.
238
355
  - **Cap with `--limit`** on `--include-dynamic` or large files.
356
+ - **Spread with `--workers=N`** on `--mutation` (and Tier 1). The
357
+ fused `--with-tests` tier stays sequential — the suite hook shells
358
+ out, and concurrent runs would race — so widen it with scope and
359
+ `--limit` instead.
239
360
 
240
361
  ## In CI
241
362
 
@@ -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.
@@ -45,6 +45,28 @@ plugin id/version + config), so an unchanged second run skips the parse.
45
45
  | Rule | Severity | Fires when |
46
46
  | --- | --- | --- |
47
47
  | `plugin.rbs-inline.source-rbs-synthesis-failed` | info | rbs-inline could not parse a file; analysis falls back to no inline-RBS contribution and the diagnostic carries the upstream error |
48
+ | `plugin.rbs-inline.source-rbs-annotation-not-honoured` | info | an annotation parsed successfully but contributed nothing — the file's other annotations still apply. Today this means the `# @rbs module-self: Foo` spelling; see below |
49
+
50
+ ## Which inline-RBS dialect Rigor reads
51
+
52
+ There are two implementations of inline RBS: the
53
+ [`rbs-inline` gem](https://github.com/soutaro/rbs-inline), which this plugin
54
+ runs, and the `RBS::InlineParser` built into `rbs` 4.x. **Rigor reads the
55
+ gem's dialect** ([ADR-32](../../adr/32-rbs-inline-comment-ingestion.md) WD11).
56
+ They overlap almost entirely — `#:`, `@rbs` method types, `def self.`,
57
+ instance-variable annotations, `@rbs skip` all behave identically — but they
58
+ are not the same grammar, and one difference bites in practice:
59
+
60
+ | you write | Rigor honours it |
61
+ | --- | --- |
62
+ | `# @rbs module-self Comparable` | yes |
63
+ | `# @rbs module-self: Comparable` | **no** — this is the spelling in rbs's own `docs/inline.md` |
64
+
65
+ Rigor reports the second form as
66
+ `plugin.rbs-inline.source-rbs-annotation-not-honoured` rather than dropping it
67
+ in silence. Constructs the gem supports and the built-in parser does not —
68
+ `@rbs generic T`, `@rbs!` embedded RBS blocks, `@rbs inherits`, method
69
+ visibility — all work here.
48
70
 
49
71
  ## Configuration
50
72
 
@@ -119,6 +119,7 @@ module Rigor
119
119
  # the runner grows a new bare id.
120
120
  NON_CHECK_DIAGNOSTIC_IDS = %w[
121
121
  configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
122
+ source-rbs-annotation-not-honoured
122
123
  ].freeze
123
124
  end
124
125
  end
@@ -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
@@ -1269,13 +1271,23 @@ module Rigor
1269
1271
  # presence checks below cannot rule out a sound call.
1270
1272
  return nil unless Rigor::Reflection.rbs_class_known?("NilClass", scope: scope)
1271
1273
 
1272
- return nil unless union_contains_nil?(receiver_type)
1273
- return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, scope)
1274
- return nil if nil_class_has_method?(call_node.name, scope)
1274
+ return nil unless nil_bearing_union_witnesses?(receiver_type, call_node.name, scope)
1275
1275
 
1276
1276
  build_nil_receiver_diagnostic(path, call_node)
1277
1277
  end
1278
1278
 
1279
+ # The receiver-type half of the rule, factored out of the node-shape
1280
+ # guards above: the union must carry nil, must carry a non-nil arm the
1281
+ # presence question can be asked of, must support the method on every
1282
+ # non-nil arm (so the call is only wrong on the nil path), and the
1283
+ # method must be absent from `NilClass` (so the nil path really raises).
1284
+ def nil_bearing_union_witnesses?(receiver_type, method_name, scope)
1285
+ union_contains_nil?(receiver_type) &&
1286
+ union_has_nameable_non_nil_arm?(receiver_type) &&
1287
+ union_method_present_on_non_nil?(receiver_type, method_name, scope) &&
1288
+ !nil_class_has_method?(method_name, scope)
1289
+ end
1290
+
1279
1291
  def union_contains_nil?(union)
1280
1292
  union.members.any? { |member| nil_member?(member) }
1281
1293
  end
@@ -1300,6 +1312,23 @@ module Rigor
1300
1312
  (member.is_a?(Type::Nominal) && member.class_name == "NilClass")
1301
1313
  end
1302
1314
 
1315
+ # Possible-nil may witness only where the presence question below is
1316
+ # ANSWERABLE. `method_present_anywhere?` reports "present" for a
1317
+ # nameless arm (Dynamic / Top / Bot) — the permissive polarity the
1318
+ # union-undefined-method rule's FP safety rests on — so a union whose
1319
+ # non-nil arms are ALL nameless satisfied that gate vacuously and fired
1320
+ # on every method name, including names defined on no class anywhere.
1321
+ # That inverted the intent: the gate suppressed exactly where knowledge
1322
+ # exists (`String | nil` calling a nonexistent method stays silent) and
1323
+ # permitted exactly where none does. Requiring one nameable concrete
1324
+ # arm restores the polarity without touching the shared helper:
1325
+ # `String | nil` keeps firing, and so does `Dynamic | String | nil` —
1326
+ # the nameless arm stays permissive inside the all-arms check, which is
1327
+ # only about the arms' method surface.
1328
+ def union_has_nameable_non_nil_arm?(union)
1329
+ union.members.any? { |m| !nil_member?(m) && !concrete_class_name(m).nil? }
1330
+ end
1331
+
1303
1332
  # The non-nil members must collectively support the
1304
1333
  # method (i.e. for every non-nil member, the method
1305
1334
  # exists on its class via RBS or in-source discovery).
@@ -2073,13 +2102,25 @@ module Rigor
2073
2102
  param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
2074
2103
  mismatch = argument_mismatch(method_def.method_types, call_node, scope, param_overrides)
2075
2104
  return nil if mismatch.nil?
2076
- # ADR-67 WD6b the mismatching argument is an inferred-parameter local, whose type is an
2077
- # open-call-site lower bound; firing argument-type-mismatch against it is an FP by construction.
2078
- return nil if inferred_param_argument?(mismatch[:node], scope)
2105
+ return nil if inferred_param_mismatch_verdict?(call_node, mismatch, scope)
2079
2106
 
2080
2107
  build_argument_type_diagnostic(path, call_node, class_name, mismatch)
2081
2108
  end
2082
2109
 
2110
+ # ADR-67 WD6b — an argument-type-mismatch verdict resting on an open-call-site lower bound, on
2111
+ # either side of the call. The ARGUMENT side: the mismatching argument is an inferred-parameter
2112
+ # local, so firing against it is an FP by construction. The RECEIVER side: when the receiver roots
2113
+ # at an inferred parameter, the method whose parameter contract the argument was checked against
2114
+ # was itself resolved through a lower-bound type, so the whole verdict is speculative. The 2026-07-30
2115
+ # self-check surfaced the receiver half as a guard hole: seeding `env : RBS::Environment` activated
2116
+ # this rule on `env.unload(culprits)` and flagged a correct Array argument against `unload`'s
2117
+ # declared `Set[Pathname]` — an upstream signature stricter than its implementation, exactly the FP
2118
+ # class WD6b exists to suppress. The other guarded rules already declined on a param-rooted
2119
+ # receiver; this brings argument-type-mismatch in line.
2120
+ def inferred_param_mismatch_verdict?(call_node, mismatch, scope)
2121
+ inferred_param_argument?(mismatch[:node], scope) || inferred_param_receiver?(call_node, scope)
2122
+ end
2123
+
2083
2124
  # Single overload → the exact per-argument acceptance (unchanged).
2084
2125
  # Multiple overloads → the nil channel (a pure-`nil` argument every
2085
2126
  # overload rejects) plus, on non-coerce methods, the non-nil channel
@@ -2117,7 +2158,7 @@ module Rigor
2117
2158
  arguments = call_node.arguments&.arguments || []
2118
2159
  arguments.each_with_index do |arg, index|
2119
2160
  arg_type = scope.type_of(arg)
2120
- params = overload_positional_params(functions, index)
2161
+ params = overload_positional_params(method_types, index)
2121
2162
  next if params.nil? # arity divergence — some overload lacks a param here
2122
2163
 
2123
2164
  mismatch =
@@ -2152,11 +2193,30 @@ module Rigor
2152
2193
  # The matching positional RBS param across every overload, or nil when
2153
2194
  # any overload has no param at `index` (arity divergence — the
2154
2195
  # 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] }
2196
+ def overload_positional_params(method_types, index)
2197
+ params = method_types.map do |method_type|
2198
+ function = method_type.type
2199
+ param = (function.required_positionals + function.optional_positionals)[index]
2200
+ param && resolve_param_bounds(param, method_type)
2201
+ end
2157
2202
  params.any?(&:nil?) ? nil : params
2158
2203
  end
2159
2204
 
2205
+ # Substitutes each bounded method-level type parameter for its bound, so
2206
+ # `[I < _ToInt] (I index) -> …` is walked as `(_ToInt index) -> …`. A bare
2207
+ # `Variable` is undecidable to the acceptance walk and admits everything,
2208
+ # which silently disables both channels for the whole overload; the bound
2209
+ # constrains the argument exactly as an ordinary param of that type would.
2210
+ # Load-bearing since rbs 4.1 rewrote core signatures into this form
2211
+ # (`Array#fetch`'s block overload is `[I < _ToInt, T] (I index) { … }`).
2212
+ def resolve_param_bounds(param, method_type)
2213
+ bounded = method_type.type_params.select(&:upper_bound)
2214
+ return param if bounded.empty?
2215
+
2216
+ substitution = RBS::Substitution.build(bounded.map(&:name), bounded.map(&:upper_bound))
2217
+ param.map_type { |type| type.sub(substitution) }
2218
+ end
2219
+
2160
2220
  # The class names whose instances `nil` IS — `NilClass` and every
2161
2221
  # ancestor. A parameter typed as any other class instance rejects nil.
2162
2222
  NIL_COMPATIBLE_CLASS_NAMES = %w[NilClass Object BasicObject Kernel].to_set.freeze