rigortype 0.4.2 → 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 (128) 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 +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a812e377e347e70448e42c33e6f4fe79152d40851b999609e9e5fdd98dc5a5a0
4
- data.tar.gz: 285b195c8058fa30063c5ad40ac6718cd59a3f1d79bb79ccac32184093dba5b0
3
+ metadata.gz: a09068aa2810a8d01758e3ce78f52dc6020f0d197bb83a561684b96969e5eddf
4
+ data.tar.gz: 0105753f063477bea3a85ba3dea3fe660fbe58d846d9388867eafd3b6fed1055
5
5
  SHA512:
6
- metadata.gz: 148890acd58affcbc6eb81afe948d882cf254930ff7ad83635592a065bf038de149865891767318721bdc103e7def6e4c71686b8d40a8ff9b55034c446cf8d32
7
- data.tar.gz: e6d458ec387e004c83b8751e5381eda53ec5979433580263ea1586cbb574245875f50a94fce86540badae485d08d20ff73c7e3aea4d80d33c4226af3a4b7f584
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.2`** (2026-10-11) — 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
@@ -188,11 +188,11 @@ changes which calls the signature accepts:
188
188
  $ rigor sig-gen --diff lib/c.rb
189
189
  --- lib/c.rb: C#m
190
190
  - def m: (Integer x) -> untyped
191
- + def m: (Integer x) -> nil
191
+ + def m: (Integer x) -> Float
192
192
 
193
193
  --- lib/c.rb: C#n
194
194
  - def n: (?) -> untyped
195
- + def n: (?) -> nil
195
+ + def n: (?) -> Float
196
196
  ```
197
197
 
198
198
  The `-` line is the declaration the proposal replaces. A method
@@ -523,9 +523,16 @@ Neither does a declaration the body proves as a literal.
523
523
  -> String` whose body is the string `"bot"`: the declared
524
524
  type is your abstraction over that body, and pinning the
525
525
  literal would rewrite a contract every sibling class shares.
526
- A method no `.rbs` declares is unaffected — the literal is
527
- still the strictest thing the body proves, and that is what
528
- gets written.
526
+ A declared `untyped` that you wrote in `sig/` is treated
527
+ the same way for a value literal: an Integer, String or
528
+ Symbol the body proves is widened to its class (`-> 1`
529
+ becomes `-> Integer`, `-> ("S" | nil)` becomes
530
+ `-> (String | nil)`), while `nil`, `true`, `false` and a
531
+ computed `Float` are proposed as they are. An `untyped` that
532
+ only the inline reader synthesizes for a `def` you did not
533
+ annotate keeps the literal, as does a method no `.rbs`
534
+ declares — the literal is still the strictest thing the body
535
+ proves, and that is what gets written.
529
536
 
530
537
  A comment rather than a `%a{…}` annotation, for three
531
538
  reasons: it stays out of the `rigor:v1:` directive namespace
@@ -744,6 +751,22 @@ by side without coordination.
744
751
  every other byte of the file verbatim.
745
752
  - **Will** append a new `class Foo … end` block when the
746
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.
747
770
  - **Will not** touch files outside the configured signature
748
771
  tree.
749
772
  - **Will not** replace an existing method declaration
@@ -36,7 +36,7 @@ the `paths:` list from the configuration file.
36
36
  | `--explain` | Surface fail-soft fallback events as `info` diagnostics. |
37
37
  | `--no-cache` | Skip the persistent cache for this run. With `--incremental` it is a cold full analysis: the incremental snapshot and the run-result cache are neither read nor written, so the next plain `--incremental` run still uses the snapshot it had before. |
38
38
  | `--incremental` | Re-analyse only the files changed since the last run plus the files that depend on them, serving the rest from a cross-process disk snapshot (ADR-46). Diagnostics are identical to a full run; a config / gem / version change (or a file added or removed) transparently forces a full re-analysis. A run with nothing changed is answered from the cache without starting the analyzer. See [Caching](12-caching.md). |
39
- | `--verify-incremental` | Acceptance gate: run the incremental analyzer against a full `--no-cache` run and assert the diagnostics are byte-identical, then exit (0 on match, 1 with the differing diagnostics on mismatch). Used in CI to guarantee `--incremental` never serves a stale result. |
39
+ | `--verify-incremental` | Acceptance gate: run the incremental analyzer against a full `--no-cache` run and assert the diagnostics agree as a set and in the same order, then exit (0 on match, 1 on mismatch: the differing diagnostics for a set mismatch, or the first index where the order diverges for a same-set, different-order mismatch). Used in CI to guarantee `--incremental` never serves a stale result. |
40
40
  | `--clear-cache` | Delete the cache directory before running. |
41
41
  | `--cache-stats` | Print the on-disk cache inventory when finished — on stdout under `--format text`, on stderr under every other format, so machine-readable output stays a parseable document. `--clear-cache`'s and `--verify-incremental`'s notes follow the same rule. |
42
42
  | `--[no-]stats` | Print a run summary (files, classes, memory, wall time) to stderr. Default on. |
@@ -171,6 +171,24 @@ the two builds against each other — but if you are chasing a
171
171
  disagreement between a probe and a `check` run, comparing
172
172
  against `rigor check --no-cache` removes that variable.
173
173
 
174
+ ## `rigor lens`
175
+
176
+ Map the declarations of one or more files with their types and
177
+ type provenance (experimental). See
178
+ [Inspecting inferred types](05-inspecting-types.md).
179
+
180
+ ```sh
181
+ rigor lens [--format=text|json] [--config=PATH] [--workers=N] FILE...
182
+ ```
183
+
184
+ A lens runs the same incremental analysis as
185
+ `rigor check --incremental` and reads and writes its cache.
186
+ `--workers` applies to a cold run. `--format=json` writes one
187
+ `lens/v0` document ([specification](../internal-spec/lens.md)).
188
+ Exit `0` when at least one file has a result, `1` when none
189
+ does, `64` on a usage error; diagnostics in the code never
190
+ change the exit status.
191
+
174
192
  ## `rigor trace`
175
193
 
176
194
  Replay HOW the engine typed a file, step by step, as a
@@ -895,10 +913,11 @@ rigor mcp [--transport=stdio] [--config=PATH]
895
913
  ```
896
914
 
897
915
  `stdio` is the only transport. The server is a pure-Ruby
898
- JSON-RPC 2.0 implementation exposing seven read-only tools:
899
- `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`,
900
918
  `rigor_annotate`, `rigor_sig_gen`, `rigor_explain`,
901
- `rigor_coverage`.
919
+ `rigor_coverage`. `rigor_lens` writes only the incremental
920
+ cache `rigor check --incremental` writes.
902
921
 
903
922
  ## `rigor lsp` vs `rigor mcp`
904
923
 
@@ -19,8 +19,10 @@ To *layer* configs rather than replace, a config file can name
19
19
  a base with `includes:` (recursive). `--config=PATH` bypasses
20
20
  discovery entirely.
21
21
 
22
- All relative paths in a config file resolve against that
23
- file's own directory.
22
+ Relative paths in a config file resolve against that file's own directory, with three exceptions:
23
+ `bundler.bundle_path:`, `bundler.lockfile:` and `rbs_collection.lockfile:` resolve against the working directory
24
+ `rigor` runs from. They match only when you run `rigor` from the directory that holds the config file, which is
25
+ the usual case; with `--config path/to/.rigor.yml` from elsewhere, give these three as absolute paths.
24
26
  A leading `~/` (or a bare `~`) in a path setting expands to your home directory; `~name` is an ordinary
25
27
  directory called `~name`, not another user's home. This covers `paths:`, `signature_paths:`, `test_paths:`,
26
28
  `pre_eval:`, `plugins_io.allowed_paths:`, `includes:`, `bundler.bundle_path:`, `bundler.lockfile:` and
@@ -64,7 +66,7 @@ cache:
64
66
  | --- | --- | --- | --- |
65
67
  | `target_ruby` | String | `"4.0"` | The lowest Ruby version *your* project runs on — `"X.Y"`, `"X.Y.Z"`, or `"latest"`. Independent of the Ruby Rigor itself runs on. Every value selects the syntax Rigor parses. A version you set explicitly also turns on the Ruby-deprecation rules for that version ([`call.deprecated-ruby2-keywords`](04-diagnostics.md#rule-call-deprecated-ruby2-keywords) from `"4.1"`); the default and `"latest"` never do, which is why `rigor init` writes the key commented out. |
66
68
  | `paths` | Array | `["lib"]` | Directories or files to analyse. |
67
- | `exclude` | Array | `[]` | Glob patterns to skip. `vendor/bundle`, `.bundle`, and `node_modules` are always excluded. |
69
+ | `exclude` | Array | `[]` | Glob patterns to skip. A relative pattern is matched against the file's path relative to the directory of the config file in use (the working directory when no config file is used), so it excludes the same files whether the path came from `paths:` or from a command-line argument, and it never matches on a directory above that base (a file outside it is seen as `../…`, so `../shared/lib/gen/**` reaches a sibling directory that `paths:` names). A leading `./` is ignored, and a pattern starting with `.` (such as `.*`) also matches files outside the base, which appear as `../…`. A symlink to a file is matched only under its own name: the realpath pass resolves directories, not files. `*` and `**` also cross `/`, so `lib/*.rb` matches nested files too. An absolute pattern is matched against the absolute path. Patterns from an `includes:` file resolve against the top-level config's directory, not the included file's. `vendor/bundle`, `.bundle`, and `node_modules` are always excluded. |
68
70
  | `test_paths` | Array | `nil` | The project's test roots: the directories (or files) holding its tests. Relative entries resolve against the config file's directory. Unset auto-detects whichever of `spec/` and `test/` exist; `[]` declares none. `rigor sig-gen --params=observed` reads call sites there to type parameters, and names on stderr a declared root that does not exist. Test roots are not analysed unless `paths:` also lists them, and changing them invalidates no cache. |
69
71
  | `includes` | Array | `[]` | Other config files to layer underneath this one. |
70
72
  | `fold_platform_specific_paths` | Boolean | `false` | Resolve Ruby-version-conditional load paths when discovering sources. |
@@ -76,7 +78,7 @@ cache:
76
78
  | --- | --- | --- | --- |
77
79
  | `libraries` | Array | `[]` | Standard-library / gem names whose bundled RBS to load. |
78
80
  | `signature_paths` | Array | `nil` | Extra directories of `.rbs` files. Relative entries resolve against the config file's directory. |
79
- | `pre_eval` | Array | `[]` | Files (or globs) walked before per-file analysis, to register project monkey-patches and publish their top-level constants project-wide. |
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. |
80
82
  | `plugins` | Array | `[]` | Plugins to activate — see [Using plugins](07-plugins.md). |
81
83
 
82
84
  ### Signature generation
@@ -101,6 +103,8 @@ rigor: signature_paths: "/path/to/sig" does not exist (no signatures loaded from
101
103
  rigor: signature_paths: "/path/to/sig" matched 0 signature files
102
104
  rigor: libraries: "csb" is not an available RBS library (no signatures loaded from it)
103
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
104
108
  rigor: severity_overrides: "flow.bogus" is not a recognized rule id; the override has no effect
105
109
  rigor: bundler.lockfile: "./missing/Gemfile.lock" does not exist
106
110
  ```
@@ -123,10 +127,19 @@ the JSON schema as you type rather than at check time.
123
127
  These are warnings, not errors — partial or optional bundles and
124
128
  forward-looking config are valid setups. The audit only fires on explicit,
125
129
  working-setup-safe signals: an unset default (auto-detected `<root>/sig`,
126
- auto-detected bundle) is never warned about, and a `disable:` /
127
- `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.…`,
128
132
  `rbs_extended.…`) is left alone, since its rule id cannot be enumerated
129
- 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
130
143
  `--format=json` payload under `config_warnings` (each tagged with a
131
144
  `kind`), so CI can assert on them.
132
145
 
@@ -134,7 +147,7 @@ statically and may resolve at run time. The same findings appear in the
134
147
 
135
148
  | Key | Type | Default | Meaning |
136
149
  | --- | --- | --- | --- |
137
- | `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). |
138
151
  | `severity_profile` | String | `"balanced"` | `lenient`, `balanced`, or `strict` — see [Diagnostics](04-diagnostics.md). |
139
152
  | `severity_overrides` | Hash | `{}` | Per-rule / per-family severity, e.g. `{ call: warning, flow.always-truthy-condition: off }`. |
140
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.
@@ -89,6 +89,14 @@ array. Line queries add `line_enumerations` metadata with the
89
89
  shown and total expression counts. An exact position is the same
90
90
  query the editor integration answers on hover.
91
91
 
92
+ Inside a `rescue` or `ensure` clause, `type-of` and hover can
93
+ disagree with `rigor check`. `check` reads a variable that the
94
+ `begin` body assigns as untyped there, because the clause may run
95
+ after the body raised partway through. `type-of` shows the value
96
+ the variable had before the `begin`. In
97
+ `conn = nil; begin; conn = open_conn; work; rescue; conn.close; end`,
98
+ `type-of` reports `conn` as `nil` and `check` reports nothing.
99
+
92
100
  ## `rigor trace` — watch the inference happen
93
101
 
94
102
  Where `annotate` and `type-of` show the *answer*, `rigor trace
@@ -110,11 +118,53 @@ typer visits; the default keeps only the three teachable event
110
118
  kinds. The JSON stream is stable enough to build course
111
119
  material or figures from.
112
120
 
121
+ ## `rigor lens` — a file's declarations and where their types come from
122
+
123
+ `rigor lens` lists every class, module, method and attribute a
124
+ file declares, with the type each one has and where that type
125
+ came from: `sig/`, an inline `#:` / `@rbs` comment, a plugin, a
126
+ library, or Rigor's inference from the body. It is experimental:
127
+ the command and its `lens/v0` JSON may still change.
128
+
129
+ ```sh
130
+ rigor lens lib/shop.rb # text, for reading
131
+ rigor lens --format=json lib/*.rb # one JSON document
132
+ ```
133
+
134
+ ```text
135
+ lib/shop.rb xxh3-64:845f7426b6acfe12 (2 omitted)
136
+ 4:7c90 class Shop
137
+ 8:e37e Shop#label: (::Integer n) -> ::String [contract inline]
138
+ 14:ce93 Shop#total: () -> Integer [single_site inferred]
139
+ 16:182b Shop#fetch [contested; also lib/legacy_shop.rb:2]
140
+ omitted constant Shop::LIMIT (line 5)
141
+ ```
142
+
143
+ The answer is the one `rigor check` reaches. A lens runs the
144
+ same incremental analysis as `rigor check --incremental` and
145
+ shares its cache, so the first run costs a full check and later
146
+ runs reuse it. The diagnostics it reports for a file are the
147
+ ones `check` reports.
148
+
149
+ - A method Ruby could take from more than one definition (the
150
+ same method defined in two files) is `contested`. Rigor does
151
+ not guess which one runs, so it prints no signature for it.
152
+ - A declaration this version does not model yet (a constant, an
153
+ `alias`, a `define_method`) is listed as omitted, never
154
+ dropped.
155
+ - `LINE:HASH` is an anchor: it changes when the declaration or
156
+ the type comments above it change, and not when prose
157
+ comments do.
158
+
159
+ The JSON document is specified in
160
+ [the lens output contract](../internal-spec/lens.md).
161
+
113
162
  ## Which to reach for
114
163
 
115
164
  | You want… | Use |
116
165
  | --- | --- |
117
166
  | One expression's type, from the shell | `rigor type-of` |
167
+ | A file's declarations, their types and where each came from | `rigor lens` |
118
168
  | Every line of a file surveyed | `rigor annotate` |
119
169
  | The derivation replayed step by step | `rigor trace` |
120
170
  | A type printed mid-analysis, in context | `dump_type` |
@@ -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.
@@ -184,7 +184,8 @@ file, which other files its analysis read from, so it knows
184
184
  exactly which files an edit can affect. A continuous-integration
185
185
  gate, `rigor check --verify-incremental`, asserts this on every
186
186
  build: it runs the incremental analyzer and a full analysis and
187
- fails if they disagree on a single diagnostic.
187
+ fails if they disagree on a single diagnostic, or list the same
188
+ diagnostics in a different order.
188
189
 
189
190
  Rigor is also precise about *what* an edit changed. Re-checking a
190
191
  file that many others depend on — a base class, a widely-used
@@ -259,6 +260,22 @@ version change does, or `--clear-cache`. So the shortcut can stay
259
260
  off indefinitely; run `rigor check --incremental --clear-cache`
260
261
  once to turn it back on.
261
262
 
263
+
264
+ A warm `--incremental` run says on stderr how far the edit spread,
265
+ for example `rigor: --incremental warm — re-analysed 3 of 1295 files
266
+ (1 changed), reused the rest`. The first count is the files the
267
+ recheck re-analysed, out of M, the analysed set: every project file
268
+ the run covers, or only the subset under a path argument such as
269
+ `rigor check --incremental lib`. The re-analysed files are M minus
270
+ those served from the snapshot. They include files that a change
271
+ reached without editing them, such as a file whose inferred
272
+ parameter types moved (ADR-67), not only the edited files and their
273
+ dependents. The parenthesis lists `changed`, and `added` or
274
+ `removed` when there are some. View templates from a plugin are not
275
+ in M. The recheck re-runs every template on every warm run, so they
276
+ are counted after the file clause, as `plus 2 view templates`. An
277
+ unchanged run reads `re-analysed 0 of 1295 files, reused all`.
278
+
262
279
  `--incremental` is most useful for fast local re-checks and CI
263
280
  on a changed branch.
264
281
 
@@ -185,8 +185,8 @@ paths:
185
185
  - lib
186
186
 
187
187
  exclude:
188
- - vendor
189
- - tmp
188
+ - "vendor/**"
189
+ - "tmp/**"
190
190
 
191
191
  plugins:
192
192
  # Rails core
@@ -116,8 +116,9 @@ gap in coverage rather than a finding about your code.
116
116
  rigor unused --entry-point='lib/cli.rb' --entry-point='lib/workers/**/*.rb'
117
117
  ```
118
118
 
119
- Repeatable. Each glob is matched against every declaration's path, and
120
- roots every declaration in a matching file.
119
+ Repeatable. Each glob is matched against every declaration's path as
120
+ written, relative to the project root, and relative to the directory
121
+ you run in, and roots every declaration in a matching file.
121
122
 
122
123
  **File-level references**, free. Code that runs on load — an
123
124
  initializer naming a class, a `.rake` task — roots what it names. This
@@ -162,9 +163,12 @@ section held 22 policy classes whose only remaining caller was
162
163
  `spec/policies/`. Those 22 were not adjudicated, so treat the section
163
164
  as high-signal rather than high-yield; the yield is unmeasured.
164
165
 
165
- "Test code" is decided by path, relative to the directory you run `rigor unused` in (run it from the
166
- project root): `spec/` and `test/` anywhere below it, `*_spec.rb` and `*_test.rb`, and the end-to-end
167
- trees `qa/`, `e2e/` and `features/` at that root. Tooling directories such as
166
+ "Test code" is decided by path, relative to the project root: the directory of the `.rigor.yml` in
167
+ use, or the directory you run `rigor unused` in when there is none or when the config sits below it
168
+ (`--config=ci/rigor.yml` from the project root). Running it from a subdirectory with `--config` reads
169
+ the same project, and references are read from the directory you run it in as well. A file counts
170
+ as test code when its path as written below either directory says so. The test paths are `spec/` and `test/` anywhere below the root,
171
+ `*_spec.rb` and `*_test.rb`, and the end-to-end trees `qa/`, `e2e/` and `features/` at the root. Tooling directories such as
168
172
  `rubocop/`, `keeps/`, `tooling/` and `scripts/` count as production, so a class used only by them is
169
173
  rooted rather than listed: deleting it would break the tooling.
170
174
 
@@ -257,10 +261,23 @@ can only bound the namespace, so everything under `Foo` is demoted;
257
261
  `"V#{version}"` is the start of a name, so `V1`, `V2_0` and everything
258
262
  under them are. `const_get` looks the name up from its receiver, so
259
263
  `const_get("V#{version}")` inside `class Migration` demotes
260
- `Migration::V1` — and the top-level `V*` names as well, unless the call
261
- passes `false` as its second argument. A name built into a local
262
- variable first (`name = "V#{version}"; const_get(name)`) is read the
263
- same way. A
264
+ `Migration::V1` — and the `V*` names under `Migration`'s superclasses
265
+ and mixins and at the top level as well, unless the call passes `false`
266
+ as its second argument. A literal `const_get("Handler")` is a reference
267
+ looked up in the same order, so it names `Migration::Handler` when
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
279
+ local variable first (`name = "V#{version}"; const_get(name)`) is read
280
+ the same way. A
264
281
  class name appearing as a string in a `.yml`, `.json` or template file
265
282
  demotes the same way — weaker evidence than a constant reference, and
266
283
  neither proof of use nor grounds to call it dead.
@@ -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__)