rigortype 0.4.3 → 0.4.4

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +16 -0
  5. data/docs/manual/02-cli-reference.md +4 -3
  6. data/docs/manual/03-configuration.md +16 -5
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/07-plugins.md +1 -1
  9. data/docs/manual/10-mcp-server.md +32 -3
  10. data/docs/manual/18-removing-dead-code.md +11 -1
  11. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  12. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  13. data/exe/rigor +12 -17
  14. data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
  15. data/lib/rigor/analysis/check_rules.rb +46 -36
  16. data/lib/rigor/analysis/incremental.rb +12 -0
  17. data/lib/rigor/analysis/incremental_session.rb +121 -21
  18. data/lib/rigor/analysis/reachability/graph.rb +23 -3
  19. data/lib/rigor/analysis/reachability/scan.rb +51 -9
  20. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  21. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  22. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  26. data/lib/rigor/analysis/runner.rb +35 -9
  27. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  28. data/lib/rigor/analysis/worker_session.rb +57 -1
  29. data/lib/rigor/cache/descriptor.rb +40 -6
  30. data/lib/rigor/cache/incremental_snapshot.rb +20 -4
  31. data/lib/rigor/cli/mcp_command.rb +1 -1
  32. data/lib/rigor/config_audit.rb +111 -15
  33. data/lib/rigor/configuration/dependencies.rb +19 -11
  34. data/lib/rigor/environment/rbs_loader.rb +46 -10
  35. data/lib/rigor/inference/block_call_timing.rb +5 -0
  36. data/lib/rigor/inference/captured_locals.rb +26 -3
  37. data/lib/rigor/inference/definer_resolution.rb +16 -7
  38. data/lib/rigor/inference/expression_typer.rb +59 -4
  39. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  40. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  41. data/lib/rigor/inference/module_function_state.rb +80 -13
  42. data/lib/rigor/inference/narrowing.rb +184 -68
  43. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  44. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  45. data/lib/rigor/inference/scope_indexer.rb +439 -70
  46. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  47. data/lib/rigor/inference/statement_evaluator.rb +84 -29
  48. data/lib/rigor/lens/anchor.rb +3 -3
  49. data/lib/rigor/lens/contract_reader.rb +15 -0
  50. data/lib/rigor/lens/document.rb +2 -2
  51. data/lib/rigor/lens/file_reader.rb +442 -73
  52. data/lib/rigor/lens/members.rb +65 -15
  53. data/lib/rigor/lens/run.rb +44 -2
  54. data/lib/rigor/lens.rb +2 -1
  55. data/lib/rigor/mcp/server.rb +61 -20
  56. data/lib/rigor/plugin/box_probe.rb +154 -7
  57. data/lib/rigor/project_environment.rb +6 -3
  58. data/lib/rigor/scope.rb +11 -6
  59. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  60. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  61. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  62. data/lib/rigor/sig_gen/writer.rb +47 -34
  63. data/lib/rigor/version.rb +1 -1
  64. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  65. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  66. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  67. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  68. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  69. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  70. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  71. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b20cef50f4d076f1afefb8c20563cd21a95a56b01bf4db3257371adfe7c64d5d
4
- data.tar.gz: 9c4f5f8825106ece96fb2ac00dc1ad4ef43f302368f1a12f87521c725530b924
3
+ metadata.gz: a09068aa2810a8d01758e3ce78f52dc6020f0d197bb83a561684b96969e5eddf
4
+ data.tar.gz: 0105753f063477bea3a85ba3dea3fe660fbe58d846d9388867eafd3b6fed1055
5
5
  SHA512:
6
- metadata.gz: 4418a4a75a22d27038d0607caa5a79723470e68f9662da1aec0b79e8056a1b157f78cf85f83cb51f646aa22d2634b0d851bb9f0132a548f5104acdee2372d1d2
7
- data.tar.gz: 026710fb13eebddf98776d6a9a3fb8e1738a7cc5fdd77c326afa0adb680c0760462d9e41b4aff57d1c41d1878fc05c41a8658be7b69ed4f265709aa27f6fc825
6
+ metadata.gz: fb111fc503dbb60810cbcdf4bfb0a717f694f771fe16bf41007ef1b9eb2728014aa19d112183e79652e4a1ae03c09d6e227ad13ebc063a9804b7a6ba916ae71b
7
+ data.tar.gz: 4c3d925ef588248fbd84529262632bcfa5e081eedd340c164f0de89b3a0f83abd5cdaad37be922a90571068966ec204f77e075993994db8b106abbaa8d135cd8
data/README.md CHANGED
@@ -232,7 +232,7 @@ rigor docs --list # list every bundled page
232
232
 
233
233
  ## Status
234
234
 
235
- Current release: **`v0.4.3`** (2026-10-15) — on the
235
+ Current release: **`v0.4.4`** (2026-10-16) — on the
236
236
  `0.4.x` line, continuing the evaluation era opened by `v0.2.0`, the first
237
237
  publicly-announced (general / evaluation) release. The line publishes
238
238
  an enumerated [compatibility surface](docs/compatibility.md) as a
@@ -0,0 +1,40 @@
1
+ # Rigor core overlay — supplemental core signatures.
2
+ #
3
+ # --- OpenStruct#method_missing (issue #1456) ---
4
+ #
5
+ # rbs 4.2 ships no `ostruct` signatures (since Ruby 4.0 `ostruct` is a bundled
6
+ # gem, not a default one), and the gem ships no `sig/`. The only `OpenStruct`
7
+ # declaration in the environment is json's add-on reopening in
8
+ # `stdlib/json/0/json.rbs` (`json_create` / `as_json` / `to_json`), so the
9
+ # class read as fully declared and `OpenStruct.new(name: "x").name` reported
10
+ # `call.undefined-method`.
11
+ #
12
+ # Upstream: ostruct 0.6.3 `lib/ostruct.rb` defines each field as a
13
+ # per-instance singleton method (`new_ostruct_member!` through
14
+ # `define_singleton_method!`) and answers the rest through
15
+ # `private def method_missing(mid, *args)`, which this file declares. It
16
+ # defines no `respond_to_missing?` of its own (Kernel's answers, and the
17
+ # fields are real singleton methods), so none is declared here.
18
+ #
19
+ # The per-field accessors cannot be declared on the class at all, so
20
+ # `Rigor::Analysis::CheckRules::CORE_OVERLAY_OPEN_RECEIVERS` names
21
+ # `OpenStruct` as an open receiver: an RBS `method_missing` alone does not
22
+ # stop the undefined-method rule. That list is what quiets `rigor check`;
23
+ # this file supplies the declaration the maintainer decision asked for.
24
+ #
25
+ # The declaration sits on a module the class includes, as `process.rbs`
26
+ # does, so a project `sig/` that declares `OpenStruct#method_missing` itself
27
+ # overrides it instead of raising `DuplicatedMethodDefinitionError`.
28
+ #
29
+ # Maintainer decision on #1456 (2026-10-16, option c): this narrow overlay.
30
+ # The general rule for a class declared only by another library's partial
31
+ # reopening is #1978.
32
+ module RigorCoreOverlay
33
+ module OpenStructMissing
34
+ private def method_missing: (Symbol mid, *untyped args) -> untyped
35
+ end
36
+ end
37
+
38
+ class OpenStruct
39
+ include RigorCoreOverlay::OpenStructMissing
40
+ end
@@ -751,6 +751,22 @@ by side without coordination.
751
751
  every other byte of the file verbatim.
752
752
  - **Will** append a new `class Foo … end` block when the
753
753
  target file does not declare the class yet.
754
+ - **Will** treat a method as already declared when a file
755
+ under the signature tree declares it on the same class
756
+ with a `def` or an `attr_*` of the same kind (instance or
757
+ singleton), in any opening of the class in that file. A
758
+ `def self?.` declares both kinds, and a `def self?.`
759
+ proposal counts as declared when either kind is. The
760
+ method is updated (`--overwrite`) or skipped in the file
761
+ that declares it instead of being added to the class's
762
+ first file. A replacement lands only on a `def` of the
763
+ proposal's own kind in the first opening of the class:
764
+ a `def self?.` proposal over a lone `def self.f` or
765
+ `def f`, a plain proposal over a `def self?.f`, or a
766
+ method declared in a later opening is skipped. A
767
+ `def f: ... | ...` overload is never replaced and never
768
+ counts as the file that declares `f`. An `alias` in
769
+ `sig/` is not yet recognised as declaring its name.
754
770
  - **Will not** touch files outside the configured signature
755
771
  tree.
756
772
  - **Will not** replace an existing method declaration
@@ -913,10 +913,11 @@ rigor mcp [--transport=stdio] [--config=PATH]
913
913
  ```
914
914
 
915
915
  `stdio` is the only transport. The server is a pure-Ruby
916
- JSON-RPC 2.0 implementation exposing seven read-only tools:
917
- `rigor_check`, `rigor_type_of`, `rigor_triage`,
916
+ JSON-RPC 2.0 implementation exposing eight read-only tools:
917
+ `rigor_check`, `rigor_type_of`, `rigor_triage`, `rigor_lens`,
918
918
  `rigor_annotate`, `rigor_sig_gen`, `rigor_explain`,
919
- `rigor_coverage`.
919
+ `rigor_coverage`. `rigor_lens` writes only the incremental
920
+ cache `rigor check --incremental` writes.
920
921
 
921
922
  ## `rigor lsp` vs `rigor mcp`
922
923
 
@@ -78,7 +78,7 @@ cache:
78
78
  | --- | --- | --- | --- |
79
79
  | `libraries` | Array | `[]` | Standard-library / gem names whose bundled RBS to load. |
80
80
  | `signature_paths` | Array | `nil` | Extra directories of `.rbs` files. Relative entries resolve against the config file's directory. |
81
- | `pre_eval` | Array | `[]` | Files (or globs) walked before per-file analysis, to register project monkey-patches and publish their top-level constants project-wide. The walker does not follow `require` / `require_relative`, so list the files in load order: a method declared in two files takes the last-listed one, and a file that requires a later-listed one is still read in list order. |
81
+ | `pre_eval` | Array | `[]` | Files (or globs) walked before per-file analysis, to register project monkey-patches and publish their top-level constants project-wide. The walker does not follow `require` / `require_relative`, so list the files in load order: a method declared in two files takes the last-listed one, and a file that requires a later-listed one is still read in list order. `type-of`, `type-scan`, `trace`, `annotate` and `sig-gen` read the patches too, so they type a patched method as `rigor check` does. |
82
82
  | `plugins` | Array | `[]` | Plugins to activate — see [Using plugins](07-plugins.md). |
83
83
 
84
84
  ### Signature generation
@@ -103,6 +103,8 @@ rigor: signature_paths: "/path/to/sig" does not exist (no signatures loaded from
103
103
  rigor: signature_paths: "/path/to/sig" matched 0 signature files
104
104
  rigor: libraries: "csb" is not an available RBS library (no signatures loaded from it)
105
105
  rigor: disable: "call.undefined-methdo" is not a recognized rule id; the suppression has no effect
106
+ rigor: disable: "plugin_loader.load-error" names a run-level row, which `disable:` does not reach; use `severity_overrides:` or a baseline
107
+ rigor: disable: "plugin.shoulda-matchers.unknown-column" names no plugin `plugins:` configures; the suppression has no effect
106
108
  rigor: severity_overrides: "flow.bogus" is not a recognized rule id; the override has no effect
107
109
  rigor: bundler.lockfile: "./missing/Gemfile.lock" does not exist
108
110
  ```
@@ -125,10 +127,19 @@ the JSON schema as you type rather than at check time.
125
127
  These are warnings, not errors — partial or optional bundles and
126
128
  forward-looking config are valid setups. The audit only fires on explicit,
127
129
  working-setup-safe signals: an unset default (auto-detected `<root>/sig`,
128
- auto-detected bundle) is never warned about, and a `disable:` /
129
- `severity_overrides:` token under a *plugin* family (`rspec.…`,
130
+ auto-detected bundle) is never warned about, and a
131
+ `severity_overrides:` key under a non-built-in family (`rspec.…`,
130
132
  `rbs_extended.…`) is left alone, since its rule id cannot be enumerated
131
- statically and may resolve at run time. The same findings appear in the
133
+ statically and may resolve at run time. `disable:` is checked more
134
+ closely, because what it can reach is known: a plugin's rule counts
135
+ only in its qualified `plugin.<id>.<rule>` form, so a bare plugin rule
136
+ (`rspec.let-binding`) is warned about. A qualified token is checked
137
+ against `plugins:` only when every entry's plugin ID is known without
138
+ loading it — an explicit `id:`, or a bundled plugin's gem name minus
139
+ `rigor-`; one third-party gem listed without `id:` turns that check
140
+ off. A token under `rbs_extended.…`, `rbs.…` or `dynamic.…` is left
141
+ alone, other than the reserved run-level rows (`rbs.coverage.…`,
142
+ `dynamic.dependency-source.…`), which are flagged. The same findings appear in the
132
143
  `--format=json` payload under `config_warnings` (each tagged with a
133
144
  `kind`), so CI can assert on them.
134
145
 
@@ -136,7 +147,7 @@ statically and may resolve at run time. The same findings appear in the
136
147
 
137
148
  | Key | Type | Default | Meaning |
138
149
  | --- | --- | --- | --- |
139
- | `disable` | Array | `[]` | Rule IDs or families to suppress project-wide. |
150
+ | `disable` | Array | `[]` | Rule IDs or families to suppress project-wide; `all` suppresses every built-in check rule. A plugin's diagnostics are named by qualified ID (`plugin.<id>.<rule>`, or `plugin.<id>` for the whole plugin); a bare ID or `all` does not reach them. The run-level `.rigor.yml` rows (`plugin_loader.*`, `dynamic.dependency-source.*`, `rbs.coverage.*`, `plugin_trust.*`, `pool-degraded`) are not reached by any spelling; use `severity_overrides:` for those. See [Diagnostics](04-diagnostics.md#suppressing-a-diagnostic). |
140
151
  | `severity_profile` | String | `"balanced"` | `lenient`, `balanced`, or `strict` — see [Diagnostics](04-diagnostics.md). |
141
152
  | `severity_overrides` | Hash | `{}` | Per-rule / per-family severity, e.g. `{ call: warning, flow.always-truthy-condition: off }`. |
142
153
  | `baseline` | String / `false` | `nil` | Path to a `.rigor-baseline.yml`, or `false` to disable an inherited one. See [Baselines](06-baseline.md). |
@@ -253,7 +253,12 @@ config.merge(extra) # rigor:disable call.undefined-method
253
253
  ```
254
254
 
255
255
  It accepts qualified IDs, family wildcards (`call`), a
256
- comma- or space-separated list, or `all`. The comment must sit
256
+ comma- or space-separated list, or `all`. A plugin's diagnostic
257
+ is reached only by its qualified ID as printed in brackets
258
+ (`plugin.shoulda-matchers.unknown-column`) or by the plugin's
259
+ whole family (`plugin.shoulda-matchers`); its bare rule
260
+ (`unknown-column`) and `all` leave it alone, because several
261
+ plugins share bare rules. The comment must sit
257
262
  on the line the diagnostic points at; there is no
258
263
  `disable-block` form, so an expression spread over several
259
264
  lines needs the comment on each line that fires.
@@ -283,7 +288,8 @@ suppressible like any other rule.
283
288
 
284
289
  **In-source, whole file.** `# rigor:disable-file <rules>`
285
290
  anywhere in a file suppresses those rules for every line;
286
- `# rigor:disable-file all` silences the file. Convention is to
291
+ `# rigor:disable-file all` silences every built-in rule in the file
292
+ (a plugin's diagnostics need their qualified ID, as above). Convention is to
287
293
  put it near the top — typically on a generated file, a fixture,
288
294
  or a vendored snippet — but every comment in the file is
289
295
  scanned, so any placement works.
@@ -300,6 +306,42 @@ disable:
300
306
  - flow.dead-assignment
301
307
  ```
302
308
 
309
+ `disable: [all]` turns off every built-in check rule, including
310
+ `effect.annotations-unchecked`, which is reported at a signature
311
+ file.
312
+
313
+ A plugin's own diagnostics are turned off by their qualified ID —
314
+ `plugin.<id>.<rule>` for one rule, `plugin.<id>` for every rule
315
+ of that plugin:
316
+
317
+ ```yaml
318
+ disable:
319
+ - plugin.shoulda-matchers.unknown-column
320
+ - plugin.rails-i18n
321
+ ```
322
+
323
+ A bare rule (`unknown-column`, `load-error`) and `all` do not
324
+ reach them, so one plugin's suppression never hides another
325
+ plugin's finding of the same name.
326
+
327
+ The list does not reach the run-level rows reported at
328
+ `.rigor.yml` — a plugin that failed to load or raised in
329
+ `prepare` (`plugin_loader.*`), a `dependencies.source_inference:`
330
+ gem that did not resolve (`dynamic.dependency-source.*`), the
331
+ `rbs.coverage.*` notices, `plugin_trust.*`, `pool-degraded` —
332
+ under any spelling, `plugin_loader.load-error` included. Turn
333
+ those off with [`severity_overrides:`](#severity-profiles) set
334
+ to `off`, or record them in a [baseline](06-baseline.md).
335
+ `severity_overrides:` matches the row's rule ID without the
336
+ source prefix, so the plugin loader's rows are `load-error` and
337
+ `runtime-error` there — and that key also re-stamps any plugin
338
+ diagnostic whose bare rule is the same. `rigor check` warns when a
339
+ `disable:` entry names one of these rows, names no rule at all,
340
+ or names a plugin `plugins:` does not configure (see
341
+ [Config validation warnings](03-configuration.md#config-validation-warnings)).
342
+ A path or parse error, which has no rule ID, is always
343
+ reported.
344
+
303
345
  For a *known backlog* you want to keep visible but not fail
304
346
  on, prefer a [baseline](06-baseline.md) over a blanket
305
347
  `disable:` — `disable:` also hides any new occurrences.
@@ -95,7 +95,7 @@ with the `plugins_isolation:` configuration key or the
95
95
  | --- | --- |
96
96
  | `process` (default) | Run the call in a forked, crash-contained worker, so the target library's monkey-patches and any crash never contaminate Rigor. Falls back to `none` where `fork` is unavailable (Windows / JRuby). |
97
97
  | `none` | Load the library into Rigor's own process and call it directly. |
98
- | `ruby_box` | Run inside an experimental `Ruby::Box` sandbox. This needs the `RUBY_BOX=1` start flag, so the `rigor` launcher re-execs itself with it set when you select this strategy. It also needs a Ruby that fixes [Ruby Bug #22260](https://bugs.ruby-lang.org/issues/22260), which no release up to 4.0.7 does. The launcher checks for the fix first, and without it prints a warning and uses the configured strategy instead. **Environment variable only** — the configuration file is read long after Ruby has booted, so `plugins_isolation: ruby_box` is reported as a configuration error instead. |
98
+ | `ruby_box` | Run inside an experimental `Ruby::Box` sandbox. This needs the `RUBY_BOX=1` start flag, so the `rigor` launcher re-execs itself with it set when you select this strategy. It also needs a Ruby that fixes [Ruby Bug #22260](https://bugs.ruby-lang.org/issues/22260), which no release up to 4.0.7 does. The launcher checks for the fix first, and without it prints a warning and uses the configured strategy instead. The same check runs when `RUBY_BOX=1` is already in your environment: without the fix, the launcher prints a notice and restarts itself without `RUBY_BOX`, so the run completes but plugins are not boxed. The check result is cached per Ruby build under `$XDG_CACHE_HOME/rigor` (or `~/.cache/rigor`) and never expires, so if you rebuild a Ruby in place with the fix, delete `$XDG_CACHE_HOME/rigor/ruby-box-probe` (or `~/.cache/rigor/ruby-box-probe`). A check that times out is not cached, so it costs up to ten seconds on every run. **Environment variable only** — the configuration file is read long after Ruby has booted, so `plugins_isolation: ruby_box` is reported as a configuration error instead. |
99
99
 
100
100
  The environment variable wins over `plugins_isolation:`, so you can
101
101
  override a project's committed choice for one invocation. The legacy
@@ -33,6 +33,7 @@ There is no conflict in running both simultaneously.
33
33
  | `rigor_check` | `rigor check --format json` | JSON diagnostic report |
34
34
  | `rigor_type_of` | `rigor type-of --format json` | JSON type at `FILE:LINE:COL` |
35
35
  | `rigor_triage` | `rigor triage --format json` | JSON distribution + class/method selectors + hotspots + hints |
36
+ | `rigor_lens` | `rigor lens --format json` | JSON `lens/v0` declaration map (experimental) |
36
37
  | `rigor_annotate` | `rigor annotate --no-color` | Annotated Ruby source |
37
38
  | `rigor_sig_gen` | `rigor sig-gen --print --format json` | JSON RBS skeleton candidates |
38
39
  | `rigor_explain` | `rigor explain --format json` | JSON rule catalog entries |
@@ -320,6 +321,34 @@ by `count` for the highest-leverage class/method, or filter by
320
321
 
321
322
  ---
322
323
 
324
+ ### rigor_lens
325
+
326
+ Map the classes, modules, methods and attributes the given files declare,
327
+ with the type `rigor check` gives each one and where it came from. The
328
+ document is the experimental `lens/v0` format
329
+ ([specification](../internal-spec/lens.md)); see
330
+ [Inspecting inferred types](05-inspecting-types.md) for the command.
331
+
332
+ **Input:**
333
+
334
+ | Argument | Type | Required |
335
+ |---|---|---|
336
+ | `files` | `string[]` | yes |
337
+ | `config` | `string` | no |
338
+
339
+ **Returns:** JSON. Each file carries its `status`, `sites`, `omissions`
340
+ and `diagnostics`; `members` holds the signatures.
341
+
342
+ **Typical agent use:** learn what a file or class offers, and the
343
+ contracts its methods already have, before editing it or writing a type.
344
+ Pass every file in one call: each call runs one analysis, and the first
345
+ call on a project costs a full `rigor check`. Paths, site keys and the cache are
346
+ relative to the directory the server was started in, so start it from the
347
+ project root; `--config` fixes which files belong to the project, not how
348
+ paths are written.
349
+
350
+ ---
351
+
323
352
  ### rigor_annotate
324
353
 
325
354
  Return the given Ruby source file with each line's last-expression
@@ -482,7 +511,7 @@ You should get a JSON response. If you get nothing or a shell error,
482
511
  `rigor` is not on your `PATH` — fix the install path first (see
483
512
  [Installing Rigor](01-installation.md)).
484
513
 
485
- **`rigor_check` / `rigor_triage` return an empty diagnostics array on a project that has errors.**
514
+ **`rigor_check` / `rigor_triage` return an empty diagnostics array on a project that has errors, or `rigor_lens` reports every file `outside_project`.**
486
515
 
487
516
  The tool uses `Configuration.discover` from the working directory the
488
517
  MCP server was started in. If the client launches `rigor mcp` from a
@@ -517,8 +546,8 @@ warmed by a prior `rigor check` run reduces it significantly.
517
546
 
518
547
  ## Status and roadmap
519
548
 
520
- `rigor mcp` shipped in v0.1.10 with all seven tools and stdio
521
- transport. Queued follow-ups are demand-driven:
549
+ `rigor mcp` shipped in v0.1.10 with seven tools and stdio
550
+ transport; `rigor_lens` joined them in v0.4.4. Queued follow-ups are demand-driven:
522
551
 
523
552
  - **HTTP transport** (slice 2) — `--transport=http` for CI / remote
524
553
  agent use; a minimal Rack endpoint.
@@ -265,7 +265,17 @@ under them are. `const_get` looks the name up from its receiver, so
265
265
  and mixins and at the top level as well, unless the call passes `false`
266
266
  as its second argument. A literal `const_get("Handler")` is a reference
267
267
  looked up in the same order, so it names `Migration::Handler` when
268
- that exists rather than a top-level `Handler`. A name built into a
268
+ that exists rather than a top-level `Handler`. When the receiver is
269
+ implicit, `self` or `self.class` inside a method (or a block in a class
270
+ body, such as a lambda or `define_method`), the call may run on a
271
+ subclass or on an includer, so a `Handler` declared in a descendant is
272
+ kept reachable too. Descendants are the classes written `class D < Base`
273
+ and the declarations whose body has a plain `include`, `prepend` or
274
+ `extend` of the module; `K.include(Mix)`, `K.send(:include, Mix)`,
275
+ `K.extend(Ext)` and `K = Class.new(Base)` are not followed yet. A call
276
+ written directly in a class body is not widened. Inside
277
+ `Other.instance_eval { }` (or `class_eval`, `module_eval` and their
278
+ `_exec` forms) the receiver is `Other`. A name built into a
269
279
  local variable first (`name = "V#{version}"; const_get(name)`) is read
270
280
  the same way. A
271
281
  class name appearing as a string in a `.yml`, `.json` or template file
@@ -127,8 +127,8 @@ routed. Helper-name recognition for all three is unaffected.
127
127
  metaprogramming the parser can't unfold (routes built in a loop
128
128
  over runtime data, helpers injected by an engine the parser
129
129
  doesn't model) may not register, which can surface a false
130
- `unknown-helper`. Record those in a baseline, or
131
- `# rigor:disable` the line.
130
+ `unknown-helper`. Record those in a baseline, or add
131
+ `# rigor:disable plugin.rails-routes.unknown-helper` to the line.
132
132
  - **The "routes file did not load" warning is run-scoped.** It is a
133
133
  fact about your configuration, not about any one source file, so
134
134
  it is reported once per run on `.rigor.yml` rather than on the
@@ -48,7 +48,10 @@ expected kind: `belong_to` / `have_one` (singular), `have_many` /
48
48
 
49
49
  Suppress with the qualified rule, e.g.
50
50
  `# rigor:disable plugin.shoulda-matchers.unknown-column`, or silence
51
- the whole family with `# rigor:disable plugin.shoulda-matchers`.
51
+ the whole family with `# rigor:disable plugin.shoulda-matchers`. The
52
+ same spellings work project-wide in `.rigor.yml`'s `disable:`. The
53
+ bare rule (`# rigor:disable unknown-column`) does not reach these
54
+ diagnostics.
52
55
 
53
56
  ## No configuration
54
57
 
data/exe/rigor CHANGED
@@ -6,8 +6,7 @@
6
6
  # (`RUBY_BOX=1`, not toggleable at runtime). When a project selects that
7
7
  # strategy (`RIGOR_PLUGIN_ISOLATION=ruby_box`, or the legacy `RIGOR_BOX`
8
8
  # alias) we re-exec the same command with the flag set before anything
9
- # else loads. The `RUBY_BOX` guard prevents an infinite re-exec, and the
10
- # inherited environment (RUBYOPT / BUNDLE_*) preserves the Bundler
9
+ # else loads. The inherited environment (RUBYOPT / BUNDLE_*) preserves the Bundler
11
10
  # context across the exec. The `none` and `process` (default) strategies
12
11
  # need no flag, so this is a no-op for them — behaviour is unchanged
13
12
  # unless a project opts into `ruby_box`.
@@ -17,22 +16,18 @@
17
16
  # Rubies through 4.0.7 segfault on Ruby Bug #22260). When it cannot,
18
17
  # the request is dropped with a warning and the run proceeds under the
19
18
  # configured strategy (`process` by default) instead of crashing.
20
- rigor_isolation = ENV["RIGOR_PLUGIN_ISOLATION"].to_s
21
- rigor_wants_box = rigor_isolation == "ruby_box" || !ENV["RIGOR_BOX"].to_s.empty?
22
- if rigor_wants_box && ENV["RUBY_BOX"].to_s.empty?
19
+ #
20
+ # When `RUBY_BOX` is already set (by hand or by a wrapper), this process
21
+ # booted inside the box before any Rigor code ran, so the same probe
22
+ # runs here too, and a refused Ruby is re-exec'ed with `RUBY_BOX`
23
+ # removed. The probe's verdict is cached per Ruby build, and the
24
+ # re-exec is marked so the child neither probes nor re-execs again.
25
+ # Nothing above `require "rigor/cli"` may define a Ractor-shareable
26
+ # lambda: that is what crashes inside the box.
27
+ if !ENV["RUBY_BOX"].to_s.empty? || ENV["RIGOR_PLUGIN_ISOLATION"].to_s == "ruby_box" ||
28
+ !ENV["RIGOR_BOX"].to_s.empty? || ENV.key?("RIGOR_BOX_PROBED")
23
29
  require_relative "../lib/rigor/plugin/box_probe"
24
- if (rigor_box_unsupported = Rigor::Plugin::BoxProbe.unsupported_reason)
25
- rigor_box_warning = "rigor: ruby_box plugin isolation is unavailable: #{rigor_box_unsupported}. " \
26
- "Falling back to the configured plugins_isolation strategy (default: process)."
27
- # `$stderr.puts`, not `warn`: `-W0` silences `warn`, and this fallback must never be silent.
28
- $stderr.puts rigor_box_warning # rubocop:disable Style/StderrPuts
29
- ENV.delete("RIGOR_PLUGIN_ISOLATION") if rigor_isolation == "ruby_box"
30
- ENV.delete("RIGOR_BOX")
31
- else
32
- ENV["RUBY_BOX"] = "1"
33
- ENV["RIGOR_PLUGIN_ISOLATION"] = "ruby_box" if rigor_isolation.empty?
34
- exec(RbConfig.ruby, __FILE__, *ARGV)
35
- end
30
+ exec(RbConfig.ruby, __FILE__, *ARGV) if Rigor::Plugin::BoxProbe.needs_reexec?(ENV)
36
31
  end
37
32
 
38
33
  lib = File.expand_path("../lib", __dir__)
@@ -173,6 +173,63 @@ module Rigor
173
173
  configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
174
174
  source-rbs-annotation-not-honoured
175
175
  ].freeze
176
+
177
+ # Resolves a user-supplied rule token (`undefined-method`, `call.undefined-method`, or the family wildcard
178
+ # `call`) to the set of canonical rule identifiers it disables. Returns `nil` for `"all"` (the existing
179
+ # wildcard meaning "every rule"), or for unknown tokens.
180
+ def self.resolve_rule_token(token)
181
+ return nil if token == "all"
182
+ return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
183
+ return PRE_EVAL_RULES if token == "pre-eval"
184
+ return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)
185
+
186
+ ALL_RULES.include?(token) ? [token] : []
187
+ end
188
+
189
+ # Expands a list of user-supplied rule tokens into the canonical-id set per ADR-8 § "Backward
190
+ # compatibility". `disabled_rules` accepts unprefixed legacy names (`undefined-method`), canonical names
191
+ # (`call.undefined-method`), family wildcards (`call`) and the `all` sentinel.
192
+ def self.expand_rule_tokens(tokens)
193
+ Array(tokens).each_with_object(Set.new) do |token, set|
194
+ set.merge(expand_token(token.to_s))
195
+ end
196
+ end
197
+
198
+ def self.expand_token(token)
199
+ return ["all"] if token == "all"
200
+
201
+ resolved = resolve_rule_token(token)
202
+ resolved.nil? || resolved.empty? ? [token] : resolved
203
+ end
204
+
205
+ # Whether the `.rigor.yml` `disable:` list, expanded by {.expand_rule_tokens}, drops a diagnostic whose rule
206
+ # id is `rule`: the `all` sentinel drops every rule-bearing row, any other entry its exact id. A nil rule (a
207
+ # parse, path or internal error) is never dropped.
208
+ def self.config_disabled?(rule, disabled)
209
+ return false if rule.nil?
210
+
211
+ disabled.include?("all") || disabled.include?(rule)
212
+ end
213
+
214
+ # The source-family prefix the runner stamps on every plugin-emitted row (`plugin.<id>`).
215
+ PLUGIN_FAMILY_PREFIX = "plugin."
216
+
217
+ # Issue #1953 — whether a suppression token set drops a plugin's own row. Such a row answers only to its
218
+ # qualified id: the exact `plugin.<id>.<rule>` (its `qualified_rule`) or the whole plugin's `plugin.<id>`
219
+ # (its `source_family`). Both compare whole strings against the row's own fields, so `plugin.<id>` never
220
+ # reaches a plugin whose id merely starts with `<id>.`. Ids and rules may both hold dots, so plugin `a` with
221
+ # rule `b.c` and plugin `a.b` with rule `c` share `plugin.a.b.c`, and that exact token drops both. A bare rule
222
+ # and `all` do not reach these rows: many plugins share a bare rule (`load-error`, `unknown-column`), and a
223
+ # bare match would hide another plugin's findings. Every other row (built-in, `plugin_loader`, `plugin_trust`)
224
+ # answers false.
225
+ def self.plugin_row_disabled?(diagnostic, tokens)
226
+ return false if diagnostic.rule.nil?
227
+
228
+ family = diagnostic.source_family.to_s
229
+ return false unless family.start_with?(PLUGIN_FAMILY_PREFIX)
230
+
231
+ tokens.include?(family) || tokens.include?(diagnostic.qualified_rule)
232
+ end
176
233
  end
177
234
  end
178
235
  end
@@ -79,19 +79,8 @@ module Rigor
79
79
  # runtime one and `def.override-visibility-reduced` cannot prove a reduction.
80
80
  IMPLICITLY_PRIVATE_METHODS = (LIFECYCLE_HOOKS + %i[respond_to_missing?]).freeze
81
81
 
82
- # Resolves a user-supplied rule token (`undefined-method`,
83
- # `call.undefined-method`, or the family wildcard `call`)
84
- # to the set of canonical rule identifiers it disables.
85
- # Returns `nil` for `"all"` (the existing wildcard meaning
86
- # "every rule"), or for unknown tokens.
87
- def self.resolve_rule_token(token)
88
- return nil if token == "all"
89
- return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
90
- return PRE_EVAL_RULES if token == "pre-eval"
91
- return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)
92
-
93
- ALL_RULES.include?(token) ? [token] : []
94
- end
82
+ # The token expansion (`resolve_rule_token` / `expand_rule_tokens` / `expand_token`) lives in the light
83
+ # `check_rules/rule_ids.rb`, so {SeverityStamp} can apply the `disable:` list on the engine-free hit path.
95
84
 
96
85
  module_function
97
86
 
@@ -480,7 +469,7 @@ module Rigor
480
469
  # - **File-level** (v0.1.2): `# rigor:disable-file <rule1>,
481
470
  # <rule2>` anywhere in the file suppresses the matching
482
471
  # diagnostic for every line. `# rigor:disable-file all`
483
- # suppresses every rule across the file. Convention is
472
+ # suppresses every built-in rule across the file. Convention is
484
473
  # to put the comment near the top, but Rigor accepts it
485
474
  # anywhere — the comment scope is "this file" regardless
486
475
  # of position.
@@ -488,7 +477,8 @@ module Rigor
488
477
  # on the same line as the offending expression
489
478
  # suppresses the matching diagnostic for that line
490
479
  # only. `# rigor:disable all` on a line suppresses
491
- # every rule on that line.
480
+ # every built-in rule on that line. A plugin's rows are
481
+ # not passed here; see {.filter_plugin_suppressed}.
492
482
  #
493
483
  # Both in-source forms are recognised only when the
494
484
  # marker is the FIRST thing in the comment, so a
@@ -507,7 +497,7 @@ module Rigor
507
497
  diagnostics.reject do |diagnostic|
508
498
  rule = diagnostic.rule
509
499
  next false if rule.nil?
510
- next true if disabled.include?(rule)
500
+ next true if config_disabled?(rule, disabled)
511
501
  next true if file_suppressions.include?("all") || file_suppressions.include?(rule)
512
502
 
513
503
  line_rules = line_suppressions[diagnostic.line]
@@ -515,6 +505,26 @@ module Rigor
515
505
  end
516
506
  end
517
507
 
508
+ # Issue #1953 — the in-source markers over the rows a plugin emitted while `path` was analysed
509
+ # (`#diagnostics_for_file` and node rules). A row answers to a `plugin.<id>.<rule>` or `plugin.<id>` token
510
+ # only ({.plugin_row_disabled?}); a bare id and `all` keep reaching built-in rows alone. `comments` are
511
+ # `path`'s, so a row the plugin positioned in another file is left to the `disable:` list and the baseline.
512
+ # The `disable:` list itself is applied to these rows by {SeverityStamp}, on every run shape.
513
+ def filter_plugin_suppressed(diagnostics, path:, comments:)
514
+ return diagnostics if diagnostics.empty?
515
+
516
+ line_suppressions, file_suppressions = parse_suppression_comments(comments)
517
+ return diagnostics if line_suppressions.empty? && file_suppressions.empty?
518
+
519
+ diagnostics.reject do |diagnostic|
520
+ next false unless diagnostic.path == path
521
+ next true if plugin_row_disabled?(diagnostic, file_suppressions)
522
+
523
+ line_rules = line_suppressions.fetch(diagnostic.line, nil)
524
+ !line_rules.nil? && plugin_row_disabled?(diagnostic, line_rules)
525
+ end
526
+ end
527
+
518
528
  # Every suppression-recognition pattern below is anchored with `\A` against the COMMENT SLICE —
519
529
  # Prism hands us a slice that starts at the `#`, so `\A#` means "the marker is the first thing in
520
530
  # the comment". A real directive always has that shape, in both the whole-line form and the
@@ -704,24 +714,6 @@ module Rigor
704
714
  )
705
715
  end
706
716
 
707
- # Expands a list of user-supplied rule tokens into the
708
- # canonical-id set per ADR-8 § "Backward compatibility".
709
- # `disabled_rules` accepts unprefixed legacy names
710
- # (`undefined-method`), canonical names
711
- # (`call.undefined-method`), and family wildcards (`call`).
712
- def expand_rule_tokens(tokens)
713
- Array(tokens).each_with_object(Set.new) do |token, set|
714
- set.merge(expand_token(token.to_s))
715
- end
716
- end
717
-
718
- def expand_token(token)
719
- return ["all"] if token == "all"
720
-
721
- resolved = resolve_rule_token(token)
722
- resolved.nil? || resolved.empty? ? [token] : resolved
723
- end
724
-
725
717
  # rubocop:disable-next Metrics/ClassLength
726
718
  class << self
727
719
  private
@@ -1393,6 +1385,21 @@ module Rigor
1393
1385
  GEM_OVERLAY_OPEN_RECEIVERS = Set["ActiveSupport::Duration", "ActiveSupport::Concern"].freeze
1394
1386
  private_constant :GEM_OVERLAY_OPEN_RECEIVERS
1395
1387
 
1388
+ # Issue #1456 — the `data/core_overlay/` twin of {GEM_OVERLAY_OPEN_RECEIVERS}. `OpenStruct` defines each field
1389
+ # as a per-instance singleton method and answers the rest through a private `method_missing` (ostruct 0.6), so
1390
+ # no signature can enumerate its surface. The only other declaration in the environment is json's add-on
1391
+ # reopening (`json_create` / `as_json` / `to_json`), which made the class read as fully declared and every
1392
+ # field access a `call.undefined-method`. `data/core_overlay/ostruct.rbs` declares that `method_missing`, and
1393
+ # an RBS-declared `method_missing` does not by itself open a receiver here, so the class is named. No load
1394
+ # gate is needed: the core overlay loads into every environment that has RBS at all, so the list is active
1395
+ # whenever the declaration it protects is. Both sides of the class are open, as with
1396
+ # {GEM_OVERLAY_OPEN_RECEIVERS}, and an inherited Object / Kernel signature stops deciding `call.wrong-arity`
1397
+ # and `call.argument-type-mismatch` ({#unauthoritative_inherited_signature?}): lost true positives,
1398
+ # accepted. The general rule — a class declared only by another library's partial reopening — is #1978, not
1399
+ # this list.
1400
+ CORE_OVERLAY_OPEN_RECEIVERS = Set["OpenStruct"].freeze
1401
+ private_constant :CORE_OVERLAY_OPEN_RECEIVERS
1402
+
1396
1403
  # ADR-26 — whether `class_name` is declared "open" by a
1397
1404
  # loaded plugin (manifest `open_receivers:`). An open
1398
1405
  # class responds beyond its RBS surface, so the
@@ -1401,11 +1408,14 @@ module Rigor
1401
1408
  # RBS method table, so proving a call "undefined" against it is
1402
1409
  # unsound: a plugin-declared open receiver, a Rigor-bundled-
1403
1410
  # overlay open receiver ({GEM_OVERLAY_OPEN_RECEIVERS}) whose
1404
- # bundled directory this run actually loaded, or a
1411
+ # bundled directory this run actually loaded, a core-overlay
1412
+ # open receiver ({CORE_OVERLAY_OPEN_RECEIVERS}), or a
1405
1413
  # Rigor-synthesized stub type (see `RbsLoader#synthesized_type_names`).
1406
1414
  def unbounded_receiver_surface?(class_name, scope)
1415
+ bare_name = class_name.to_s.sub(/\A::/, "")
1407
1416
  open_receiver?(class_name, scope) || synthesized_stub_receiver?(class_name, scope) ||
1408
- (GEM_OVERLAY_OPEN_RECEIVERS.include?(class_name.to_s.sub(/\A::/, "")) && gem_overlay_loaded?(scope))
1417
+ CORE_OVERLAY_OPEN_RECEIVERS.include?(bare_name) ||
1418
+ (GEM_OVERLAY_OPEN_RECEIVERS.include?(bare_name) && gem_overlay_loaded?(scope))
1409
1419
  end
1410
1420
 
1411
1421
  def open_receiver?(class_name, scope)
@@ -120,6 +120,18 @@ module Rigor
120
120
  appeared.freeze
121
121
  end
122
122
 
123
+ # Issue #1459 — the method names some file in `paths` started or stopped defining in this edit, on any class or
124
+ # side. A consumer whose answer asked whether the program defines a name anywhere
125
+ # (`Inference::BlockCallTiming.project_defines_anywhere?`) recorded a `defines:<name>` edge whichever way it
126
+ # answered. `paths` MUST include removed files, whose every name vanished. `before` / `after` map a path to the
127
+ # Set of names it defines (`Inference::ScopeIndexer.defined_method_names`); a path absent from either side
128
+ # defines nothing there.
129
+ def changed_defined_names(paths, before, after)
130
+ moved = Set.new
131
+ paths.each { |path| moved.merge((before[path] || Set.new) ^ (after[path] || Set.new)) }
132
+ moved.freeze
133
+ end
134
+
123
135
  # Issue #1120 — the method names whose refinements moved in this edit. A consumer whose answer consulted the
124
136
  # refinement table recorded a `refinement:<method>` edge whichever way it answered, because the answer is a
125
137
  # function of the whole project's refinements of that name. `paths` MUST include removed files, whose whole