rigortype 0.2.5 → 0.2.7

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/09-plugins.md +5 -2
  4. data/docs/handbook/appendix-liskov.md +5 -3
  5. data/docs/handbook/appendix-phpstan.md +2 -2
  6. data/docs/install.md +1 -1
  7. data/docs/manual/02-cli-reference.md +60 -2
  8. data/docs/manual/06-baseline.md +12 -0
  9. data/docs/manual/08-skills.md +21 -0
  10. data/docs/manual/11-ci.md +6 -6
  11. data/docs/manual/15-type-protection-coverage.md +29 -0
  12. data/docs/manual/plugins/rigor-minitest.md +1 -1
  13. data/lib/rigor/cli/check_command.rb +4 -33
  14. data/lib/rigor/cli/check_runner_factory.rb +63 -0
  15. data/lib/rigor/cli/coverage_command.rb +42 -10
  16. data/lib/rigor/cli/doctor_command.rb +295 -0
  17. data/lib/rigor/cli/plugins_command.rb +2 -2
  18. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  19. data/lib/rigor/cli/protection_renderer.rb +32 -2
  20. data/lib/rigor/cli/protection_report.rb +32 -6
  21. data/lib/rigor/cli/skill_command.rb +52 -1
  22. data/lib/rigor/cli/upgrade_command.rb +25 -0
  23. data/lib/rigor/cli.rb +17 -1
  24. data/lib/rigor/environment/rbs_loader.rb +28 -0
  25. data/lib/rigor/flow_contribution/fact.rb +1 -1
  26. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  27. data/lib/rigor/inference/expression_typer.rb +22 -10
  28. data/lib/rigor/inference/fallback.rb +2 -2
  29. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  30. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  31. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  32. data/lib/rigor/inference/mutation_widening.rb +18 -0
  33. data/lib/rigor/inference/protection_scanner.rb +6 -3
  34. data/lib/rigor/inference/statement_evaluator.rb +5 -8
  35. data/lib/rigor/plugin/base.rb +34 -7
  36. data/lib/rigor/plugin/registry.rb +1 -1
  37. data/lib/rigor/scope.rb +16 -5
  38. data/lib/rigor/sig_gen/generator.rb +25 -0
  39. data/lib/rigor/sig_gen/method_candidate.rb +7 -2
  40. data/lib/rigor/sig_gen/writer.rb +60 -13
  41. data/lib/rigor/version.rb +1 -1
  42. data/lib/rigor.rb +1 -0
  43. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +63 -2
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +2 -3
  45. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +14 -24
  46. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +10 -3
  47. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  48. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  49. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  50. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +36 -79
  51. data/sig/rigor/plugin/base.rbs +2 -0
  52. data/sig/rigor/scope.rbs +3 -1
  53. data/skills/rigor-ask/SKILL.md +21 -1
  54. data/skills/rigor-baseline-reduce/SKILL.md +16 -0
  55. data/skills/rigor-ci-setup/SKILL.md +96 -249
  56. data/skills/rigor-doctor/SKILL.md +39 -49
  57. data/skills/rigor-doctor/references/01-checks.md +52 -0
  58. data/skills/rigor-editor-setup/SKILL.md +14 -0
  59. data/skills/rigor-mcp-setup/SKILL.md +14 -0
  60. data/skills/rigor-monkeypatch-resolve/SKILL.md +15 -0
  61. data/skills/rigor-plugin-author/SKILL.md +24 -5
  62. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  63. data/skills/rigor-plugin-review/SKILL.md +174 -0
  64. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +214 -0
  65. data/skills/rigor-plugin-tune/SKILL.md +21 -2
  66. data/skills/rigor-project-init/SKILL.md +16 -0
  67. data/skills/rigor-protection-uplift/SKILL.md +15 -0
  68. data/skills/rigor-rbs-setup/SKILL.md +15 -0
  69. data/skills/rigor-upgrade/SKILL.md +16 -0
  70. metadata +11 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce4071258582638c452079484534e58c6d6b290a88ea51fdce715024d7c09297
4
- data.tar.gz: 90197cb4711899857c8d242470b063e7da64947e46e240253206179a0e8f6baf
3
+ metadata.gz: c16266bcbae2ff766dd6579df68ca08d31563d7f9a813ef49b88bd8f72734b7a
4
+ data.tar.gz: c80a5f751c3f757c30acfa0fd1631918a5b3fdc388752c03f27ad56c11a723c2
5
5
  SHA512:
6
- metadata.gz: 4e967fdc0ca679a712860a20da09842c2668727c0068a4ed9657ae4111d0d94a5ab8efa9684c1a75b1ad25dbeb03efd9a4580f85e771aa5a61a05f56f38b6203
7
- data.tar.gz: 8773e6364390cb36296345c4ec8974cee5bec585f7f9da292950859e4cb754081a68130db00dbc14f62d77c7f396477adace7480500d789bb838777ea268c6a2
6
+ metadata.gz: deda828a4f7171b30c1e0b0531fbb52cb815905e05ebb05186a87b49107d7ce9e80c4d5d9dc81fd35ed26a1872d2d6e9bbeefe98e3b146d1acd1e4d360a81a26
7
+ data.tar.gz: fbe3ba7eed33351ea7ec8863f09085ed1b55f99e17ac3a0bba51234723da1a35f1e90fa9dd694aa0a680c5dbb8725855deb789728c6886c16ccc229a6d4b1be1
data/README.md CHANGED
@@ -231,9 +231,10 @@ rigor docs --list # list every bundled page
231
231
 
232
232
  ## Status
233
233
 
234
- Current release: **`v0.2.0`** (2026-06-17) — the first
235
- publicly-announced (general / evaluation) release. It publishes an
236
- enumerated [compatibility surface](docs/compatibility.md) as a
234
+ Current release: **`v0.2.7`** (2026-07-05) — the latest cut on the
235
+ `0.2.x` evaluation line opened by `v0.2.0`, the first
236
+ publicly-announced (general / evaluation) release. The line publishes
237
+ an enumerated [compatibility surface](docs/compatibility.md) as a
237
238
  minor-non-break trial, rehearsing the contract that hard-freezes at
238
239
  `v1.0.0`. Rigor analyses real Ruby today: it has been hardened against
239
240
  Mastodon, Redmine, and GitLab FOSS, and the deliberately conservative
@@ -68,12 +68,15 @@ directory — gives a plugin five primary surfaces:
68
68
  array of `Rigor::Analysis::Diagnostic` rows. The runner
69
69
  stamps each with `source_family: "plugin.<your-id>"`.
70
70
  2. **`dynamic_return(receivers:, methods:, file_methods:)` /
71
- `type_specifier(methods:)`** — the per-call-site return-type
71
+ `narrowing_facts(methods:)`** — the per-call-site return-type
72
72
  and flow-narrowing contribution surface (ADR-37 Slice 2).
73
73
  A `dynamic_return` block names the inferred return type at a
74
74
  matching call site; the analyzer's dispatcher merges the
75
75
  contribution and uses it as if it were RBS-declared. A
76
- `type_specifier` block contributes branch-narrowing facts.
76
+ `narrowing_facts` block contributes branch-narrowing facts.
77
+ (`narrowing_facts` was renamed from `type_specifier` in ADR-80;
78
+ `type_specifier` remains as a deprecating alias removed in 0.3.0,
79
+ so use `narrowing_facts` in new plugins.)
77
80
  (These replaced the removed `flow_contribution_for` hook —
78
81
  ADR-52 WD3; a plugin that still defines it raises at load.)
79
82
  3. **`Plugin::IoBoundary#read_file`** / **`#open_url`** —
@@ -210,10 +210,12 @@ def shout(thing)
210
210
  end
211
211
 
212
212
  # Clause 1 / covariant returns: the body provably returns the
213
- # receiver, so #dup returns `self` (Array, not the widened Object).
214
- # Returning Object would *weaken* the postcondition.
213
+ # receiver, so #dup returns `self` (the receiver's own type, not the
214
+ # widened Object). Returning Object would *weaken* the postcondition.
215
+ # Shape preservation through `dup` (ADR-76 WD2) keeps the precise
216
+ # tuple here rather than degrading to the nominal `Array`.
215
217
  copy = [1, 2, 3].dup
216
- assert_type("Array", copy)
218
+ assert_type("[1, 2, 3]", copy)
217
219
  ```
218
220
 
219
221
  The connection to the type-theory appendix's **gradual guarantee**
@@ -96,13 +96,13 @@ When the assertion is recognised by **call shape** rather than
96
96
  by signature — PHPStan's `TypeSpecifyingExtension` interface,
97
97
  where you write a class that the framework instantiates and
98
98
  asks "given this call, what narrowings does it produce?" —
99
- Rigor's analogue is a plugin's `type_specifier` / `dynamic_return` and
99
+ Rigor's analogue is a plugin's `narrowing_facts` / `dynamic_return` and
100
100
  `#diagnostics_for_file` hooks plus the engine's
101
101
  `post_return_facts` substrate.
102
102
 
103
103
  | PHPStan extension type | Rigor analogue |
104
104
  | --- | --- |
105
- | `MethodTypeSpecifyingExtension` | Plugin's `Fact(target_kind: :parameter)` returned from `type_specifier` |
105
+ | `MethodTypeSpecifyingExtension` | Plugin's `Fact(target_kind: :parameter)` returned from `narrowing_facts` |
106
106
  | `StaticMethodTypeSpecifyingExtension` | Same, with `Fact(target_kind: :receiver-class)` |
107
107
  | `FunctionTypeSpecifyingExtension` | Same, with `Fact(target_kind: :argument)` |
108
108
  | `DynamicMethodReturnTypeExtension` | Plugin's `dynamic_return(methods:) { |call_node, scope, ...| ... }` |
data/docs/install.md CHANGED
@@ -152,7 +152,7 @@ without WSL). For all other environments, prefer Case A–D above.
152
152
  rigor --version
153
153
  ```
154
154
 
155
- A version string like `rigor 0.1.x` confirms a successful install.
155
+ A version string like `rigor 0.2.x` confirms a successful install.
156
156
  If the command is not found, revisit Step 2 for your case.
157
157
 
158
158
  ---
@@ -403,7 +403,7 @@ catalogue** ([ADR-37](../adr/37-plugin-interface-segregation.md)):
403
403
  a focused, machine-readable map of what each loaded plugin
404
404
  contributes — the AST node types its `node_rule`s match, the
405
405
  receiver classes its `dynamic_return`s gate on, the methods its
406
- `type_specifier`s narrow, and the facts it `produces` /
406
+ `narrowing_facts`s narrow, and the facts it `produces` /
407
407
  `consumes`. Combine with `--format=json` for tooling (an AI
408
408
  agent can enumerate every plugin's behaviour without reading a
409
409
  line of plugin source). The same narrow surfaces also appear in
@@ -452,13 +452,14 @@ The positional slot is a skill *name*; alternative outputs are flags,
452
452
  so a skill can never be shadowed by a verb.
453
453
 
454
454
  ```sh
455
- rigor skill [<name>] [--path <name>] [--list] [--describe]
455
+ rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
456
456
  ```
457
457
 
458
458
  | Form | Purpose |
459
459
  | --- | --- |
460
460
  | (none) / `--list` | Table of every bundled skill (name + absolute path). |
461
461
  | `<name>` | Print the `SKILL.md` body to stdout, with a header pointing at the skill's `references/` directory. |
462
+ | `--full <name>` | Print the `SKILL.md` body **followed by every `references/*.md` inline** — the complete, version-current procedure in one call. This is what a skill's "First: load the version-current copy" directive points at, so a copy vendored into a project (e.g. via `npx skills add`) re-fetches its current steps from the installed gem instead of following a frozen copy. |
462
463
  | `--path <name>` | Print the single-line absolute `SKILL.md` path, suitable as input to a file-reading tool. |
463
464
  | `--describe` | Probe the project's state (config / baseline / `sig/` / CI — presence only, never runs `rigor check`) and recommend the next skill to run. Also spelled `describe`; surfaced top-level as [`rigor describe`](#rigor-describe) below. |
464
465
 
@@ -539,6 +540,63 @@ with its stable id, the severity it would impose, and whether your config
539
540
  adopts it. See [`docs/compatibility.md`](../compatibility.md) for how
540
541
  bleeding-edge fits the stability model.
541
542
 
543
+ ## `rigor doctor`
544
+
545
+ Classify setup problems vs a clean run with routed next actions
546
+ ([ADR-77](../adr/77-doctor-and-upgrade-commands.md) WD1).
547
+
548
+ ```sh
549
+ rigor doctor [--config PATH] [--format text|json]
550
+ ```
551
+
552
+ | Flag | Purpose |
553
+ | --- | --- |
554
+ | `--config PATH` | Use this `.rigor.yml` instead of auto-discovery. |
555
+ | `--format text\|json` | Output format. Default `text`. |
556
+
557
+ Runs a scoped analysis and audits:
558
+
559
+ - **Configuration audit** — unresolved `signature_paths:`, unknown
560
+ `libraries:`, inert `disable:` / `severity_overrides:` tokens
561
+ ({ConfigAudit}).
562
+ - **RBS environment health** — whether the RBS class universe built
563
+ successfully (`0` classes means a broken setup).
564
+ - **Plugin load errors** — whether every configured plugin loaded.
565
+ - **Baseline drift** — whether the current diagnostics have drifted
566
+ from the saved baseline.
567
+ - **Rails plugin gap** — whether `Gemfile.lock` contains Rails gems
568
+ but no Rails plugin is enabled.
569
+
570
+ Text output prints `[PASS]`, `[FAIL]`, or `[WARN]` per check plus a
571
+ routed hint (e.g. "Run `rigor baseline regenerate`"). JSON output
572
+ is a stable contract:
573
+
574
+ ```json
575
+ {
576
+ "status": "issues_found",
577
+ "checks": [
578
+ { "id": "config_audit", "status": "fail", "message": "...", "hint": "..." }
579
+ ]
580
+ }
581
+ ```
582
+
583
+ Exits `1` when any check fails, `0` when all pass.
584
+
585
+ ## `rigor upgrade`
586
+
587
+ Migration command skeleton ([ADR-50](../adr/50-release-engineering-and-stability-strategy.md)
588
+ WD7). The real body lands when a concrete backwards-compatibility
589
+ break gives it a target (e.g. re-running `baseline regenerate`
590
+ against a strengthened default profile, surfacing renamed
591
+ suppression ids, reporting `bleeding_edge:` graduations).
592
+
593
+ ```sh
594
+ rigor upgrade
595
+ ```
596
+
597
+ Until then it prints the current version and notes that upgrade is
598
+ queued. Exits `0`.
599
+
542
600
  ## Environment variables
543
601
 
544
602
  Most behaviour is driven by flags and `.rigor.yml`; a few
@@ -77,6 +77,18 @@ a diagnostic another layer has already suppressed.
77
77
  default — one bucket per file × rule) or `--match-mode=message`
78
78
  (a bucket per distinct message: more precise, more churn).
79
79
 
80
+ `--match-mode=message` keys each bucket on the **rendered
81
+ message text**, which includes details such as the displayed
82
+ receiver type. That makes it sharper at telling two same-rule
83
+ diagnostics on one line apart, but also **brittle**: when a Rigor
84
+ upgrade rewords a message or changes how a type is displayed, the
85
+ key no longer matches and the previously-baselined diagnostic
86
+ resurfaces as if new. `--match-mode=rule` keys only on
87
+ `(file, rule)` and is immune to message rewording — prefer it
88
+ unless you specifically need per-message discrimination, and
89
+ expect to `regenerate` a `message`-mode baseline after upgrading
90
+ Rigor.
91
+
80
92
  ## Working a baseline down
81
93
 
82
94
  `rigor triage` summarises a diagnostic stream — rule
@@ -108,6 +108,7 @@ source checkout. The `rigor skill` command surfaces them:
108
108
  rigor skill describe # probe the project + recommend the next skill (alias: rigor describe)
109
109
  rigor skill --list # name + absolute path for each bundled skill
110
110
  rigor skill <name> # print the SKILL.md body (with a references/ header)
111
+ rigor skill --full <name> # the body + every references/*.md inline (complete procedure)
111
112
  rigor skill --path <name> # one-line absolute SKILL.md path, for a file-reading tool
112
113
  ```
113
114
 
@@ -133,6 +134,26 @@ npx skills add rigortype/rigor
133
134
  (The contributor-only skills under `.claude/skills/` are marked internal
134
135
  and are not installed by a bulk `npx skills add`.)
135
136
 
137
+ ### Keeping installed skills current
138
+
139
+ A skill copied into your project this way is **frozen at install time**,
140
+ while Rigor keeps evolving — flags, config keys, and rule ids move release
141
+ to release. To keep that from going stale (陳腐化), every skill opens with
142
+ a **"First: load the version-current copy"** directive: before following
143
+ its steps, an agent re-fetches the authoritative body from the *installed*
144
+ Rigor with
145
+
146
+ ```sh
147
+ rigor skill --full <name> # the current body + all its references/, in one call
148
+ ```
149
+
150
+ Because the vendored copy is fixed at install time but `rigor skill` always
151
+ reads the gem, the two diverge as you upgrade, and the directive makes the
152
+ agent prefer the gem's current version. You therefore do **not** need to
153
+ re-install the skills after upgrading Rigor — only the thin entry point
154
+ (`rigor-next-steps`) needs to work before Rigor exists; everything
155
+ version-specific is served live by the installed binary.
156
+
136
157
  ## Running a skill
137
158
 
138
159
  In an agent that supports Agent Skills, invoke the skill by name (in
data/docs/manual/11-ci.md CHANGED
@@ -192,9 +192,9 @@ remains available for any other tool that wants the raw diagnostic stream.
192
192
 
193
193
  The workflow above installs whatever `rigortype` is current at run
194
194
  time. To pin a version — and keep CI reproducible — choose one of the
195
- options below. While the `0.1.x` line is in preview the surface is
196
- still settling, so pinning is recommended; what Rigor commits to
197
- keeping stable (and from which release) is enumerated in
195
+ options below. While the `0.2.x` line is an evaluation trial the
196
+ surface is still settling, so pinning is recommended; what Rigor
197
+ commits to keeping stable (and from which release) is enumerated in
198
198
  [`docs/compatibility.md`](../compatibility.md).
199
199
 
200
200
  ### A CI-only `Gemfile` (recommended)
@@ -203,7 +203,7 @@ Commit a two-line `.github/rigor/Gemfile`:
203
203
 
204
204
  ```ruby
205
205
  source "https://rubygems.org"
206
- gem "rigortype", "~> 0.1"
206
+ gem "rigortype", "~> 0.2"
207
207
  ```
208
208
 
209
209
  plus its `Gemfile.lock`, and point the Rigor job at it through
@@ -242,7 +242,7 @@ updates:
242
242
 
243
243
  ### A pinned `gem install`
244
244
 
245
- `gem install rigortype -v "0.1.15"` in the workflow. Simpler, with no
245
+ `gem install rigortype -v "0.2.6"` in the workflow. Simpler, with no
246
246
  extra files — but Dependabot does not see a version inside a `run:`
247
247
  step, so updates to the pin are manual.
248
248
 
@@ -258,7 +258,7 @@ docker run --rm -v "$PWD:/src" ghcr.io/rigortype/rigor check
258
258
 
259
259
  `rigor` is the image entrypoint, so subcommands and flags follow the
260
260
  image name. Pin a version with an explicit tag —
261
- `ghcr.io/rigortype/rigor:0.1.15`.
261
+ `ghcr.io/rigortype/rigor:0.2.6`.
262
262
 
263
263
  ## Nix
264
264
 
@@ -172,6 +172,35 @@ breakage a *different* test would catch shows as a gap). For an
172
172
  accurate map, run the command over all tests that exercise the
173
173
  files you are measuring — trading time for completeness.
174
174
 
175
+ ### Why a hole is untyped — provenance and tractability
176
+
177
+ For a `Dynamic`-receiver hole, `rigor coverage --protection` also
178
+ records *why* the receiver is untyped and *whether a type can close
179
+ it*, so you spend effort where it pays off. Each `add_a_type_here`
180
+ entry carries two fields:
181
+
182
+ - **`dynamic_origin`** — the cause the value became dynamic:
183
+ `external_gem_without_rbs`, `framework_dsl_boundary`,
184
+ `analyzer_budget_cutoff`, `explicit_untyped`, or
185
+ `unsupported_syntax`.
186
+ - **`tractability`** — the action axis derived from that cause:
187
+ - **`add_rbs`** — you can close it with a type: install RBS
188
+ (`rbs collection install`), enable `dependencies.source_inference:`,
189
+ or tighten an authored `untyped` signature.
190
+ - **`enable_plugin`** — a framework / DSL boundary; reach for a
191
+ plugin or [`pre_eval:`](03-configuration.md), not a hand-written
192
+ type.
193
+ - **`engine_gap`** — not closable by a user type (a budget cutoff
194
+ or a construct Rigor does not yet model); report it.
195
+
196
+ The text report prints a one-line `by tractability:` breakdown under
197
+ the "Add a type here" header, and the JSON carries the same totals as
198
+ `tractability_summary`. Start with the `add_rbs` holes — they are the
199
+ ones a type actually catches.
200
+
201
+ Provenance is precision-additive only: it never changes a type, fires
202
+ no diagnostic, and never affects severity or the protection ratio.
203
+
175
204
  ## Cost and scope
176
205
 
177
206
  The fused tiers run real analyses and real test suites, so treat
@@ -79,7 +79,7 @@ matches.
79
79
  ## Plugin internals
80
80
 
81
81
  The assertion recogniser and the contract surfaces this plugin
82
- exercises — the ADR-37 `type_specifier` narrowing gate and the ADR-38
82
+ exercises — the ADR-37 `narrowing_facts` narrowing gate and the ADR-38
83
83
  `additional_initializers` for `setup` — are in the
84
84
  [plugin's README](../../../plugins/rigor-minitest/README.md). To write a
85
85
  plugin, see [`examples/`](../../../examples/README.md) and the
@@ -13,6 +13,7 @@ require_relative "command"
13
13
  require_relative "options"
14
14
  require_relative "diagnostic_formats"
15
15
  require_relative "ci_detector"
16
+ require_relative "check_runner_factory"
16
17
 
17
18
  module Rigor
18
19
  class CLI
@@ -244,42 +245,12 @@ module Rigor
244
245
  end
245
246
 
246
247
  def build_check_runner(configuration:, options:, buffer:, cache_root:)
247
- cache_store = if options.fetch(:no_cache)
248
- nil
249
- else
250
- Cache::Store.new(
251
- root: cache_root,
252
- max_bytes: configuration.cache_max_bytes
253
- )
254
- end
255
- Analysis::Runner.new(
256
- configuration: configuration,
257
- explain: options.fetch(:explain),
258
- cache_store: cache_store,
259
- collect_stats: options.fetch(:stats),
260
- workers: resolve_workers(options, configuration),
261
- buffer: buffer
248
+ CheckRunnerFactory.build(
249
+ configuration: configuration, options: options,
250
+ buffer: buffer, cache_root: cache_root
262
251
  )
263
252
  end
264
253
 
265
- # ADR-15 Phase 4c — resolves the worker count by
266
- # precedence: CLI `--workers=N` (most explicit) > env
267
- # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
268
- # `parallel.workers:` > 0 (sequential default). Returns
269
- # an Integer; non-numeric values raise so typos fail
270
- # loudly. CLI / env may pass a negative value — clamped
271
- # to 0 (sequential) so a stray `-1` doesn't crash the
272
- # pool spawn loop.
273
- def resolve_workers(options, configuration)
274
- cli_value = options[:workers]
275
- return [Integer(cli_value), 0].max if cli_value
276
-
277
- env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
278
- return [Integer(env_value), 0].max if env_value && !env_value.empty?
279
-
280
- configuration.parallel_workers
281
- end
282
-
283
254
  def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
284
255
  options = {
285
256
  config: nil,
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analysis/runner"
4
+ require_relative "../cache/store"
5
+
6
+ module Rigor
7
+ class CLI
8
+ # Shared factory for building an {Analysis::Runner} from CLI option
9
+ # hashes + a loaded {Configuration}.
10
+ #
11
+ # Extracted from {CheckCommand} so that `rigor doctor` and future
12
+ # `describe --deep` can reuse the exact same runner-setup logic
13
+ # (cache, workers, buffer, explain) without diverging from the
14
+ # primary check path (ADR-77 WD3).
15
+ module CheckRunnerFactory
16
+ module_function
17
+
18
+ # Builds a runner matching the configuration/plugin/cache resolution
19
+ # that `rigor check` itself uses.
20
+ #
21
+ # @param configuration [Rigor::Configuration]
22
+ # @param options [Hash] parsed CLI options (must contain at least
23
+ # `:no_cache`, `:explain`, `:stats`, `:workers`)
24
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
25
+ # @param cache_root [String]
26
+ # @return [Rigor::Analysis::Runner]
27
+ def build(configuration:, options:, buffer:, cache_root:)
28
+ cache_store = if options.fetch(:no_cache)
29
+ nil
30
+ else
31
+ Cache::Store.new(
32
+ root: cache_root,
33
+ max_bytes: configuration.cache_max_bytes
34
+ )
35
+ end
36
+ Analysis::Runner.new(
37
+ configuration: configuration,
38
+ explain: options.fetch(:explain),
39
+ cache_store: cache_store,
40
+ collect_stats: options.fetch(:stats),
41
+ workers: resolve_workers(options, configuration),
42
+ buffer: buffer
43
+ )
44
+ end
45
+
46
+ # Resolves the worker count by precedence: CLI `--workers=N`
47
+ # (most explicit) > env `RIGOR_RACTOR_WORKERS` > config
48
+ # `.rigor.yml` `parallel.workers:` > 0 (sequential default).
49
+ # Returns an Integer; non-numeric values raise so typos fail
50
+ # loudly. CLI / env may pass a negative value — clamped to 0
51
+ # (sequential) so a stray `-1` doesn't crash the pool spawn loop.
52
+ def resolve_workers(options, configuration)
53
+ cli_value = options[:workers]
54
+ return [Integer(cli_value), 0].max if cli_value
55
+
56
+ env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
57
+ return [Integer(env_value), 0].max if env_value && !env_value.empty?
58
+
59
+ configuration.parallel_workers
60
+ end
61
+ end
62
+ end
63
+ end
@@ -144,7 +144,7 @@ module Rigor
144
144
 
145
145
  def scan_protection(paths, options)
146
146
  configuration = Configuration.load(options.fetch(:config))
147
- environment = project_environment(configuration)
147
+ environment = plugin_aware_environment(configuration)
148
148
  scope = scope_with_inferred_params(paths, configuration, environment)
149
149
  scanner = Inference::ProtectionScanner.new(scope: scope)
150
150
  accumulator = ProtectionAccumulator.new
@@ -153,21 +153,38 @@ module Rigor
153
153
  accumulator.to_report
154
154
  end
155
155
 
156
- # ADR-67 WD3 seed the call-site parameter-inference table so the
157
- # protection scan counts an inferred-parameter receiver (e.g. `node.loc`
158
- # where `node` is a `def compile(node)` parameter) as protected when its
159
- # call sites resolve to concrete argument types. ONLY the parameter table
160
- # is seeded — no cross-file discoveryso every site that does not gain
161
- # an inferred parameter type is classified byte-identically to the
162
- # un-inferred baseline. Collection spans the scanned `paths`.
156
+ # Seed the protection scan's scope with the same cross-file facts
157
+ # `rigor check` resolves against, so a receiver reads the type it
158
+ # actually has rather than a stripped-scope `Dynamic`:
159
+ #
160
+ # - `discovered_classes`a project constant referring to a class
161
+ # defined in a *sibling* file (`Account`, `User`) types as
162
+ # `singleton(Account)` instead of `Dynamic`. Without this, a
163
+ # single-file scan cannot see a class it does not itself declare,
164
+ # so every cross-file class-constant dispatch was miscounted as
165
+ # unprotected (the model-constant undercount found 2026-07-04).
166
+ # - `param_inferred_types` (ADR-67 WD3) — an inferred-parameter
167
+ # receiver (`node.loc` where `node` is a `def compile(node)`
168
+ # parameter) counts as protected when its call sites resolve to
169
+ # concrete argument types.
170
+ #
171
+ # Both span the scanned `paths` only (no whole-project pre-pass) —
172
+ # a site that gains neither is classified exactly as before.
163
173
  def scope_with_inferred_params(paths, configuration, environment)
164
174
  base = Scope.empty(environment: environment)
175
+ seed = {}
176
+
177
+ discovered = Inference::ScopeIndexer.discovered_classes_for_paths(paths)
178
+ seed[:discovered_classes] = discovered unless discovered.empty?
179
+
165
180
  table = Inference::ParameterInferenceCollector.collect(
166
181
  files: paths, environment: environment, target_ruby: configuration.target_ruby
167
182
  )
168
- return base if table.empty?
183
+ seed[:param_inferred_types] = table unless table.empty?
184
+
185
+ return base if seed.empty?
169
186
 
170
- base.with_discovery(base.discovery.with(param_inferred_types: table))
187
+ base.with_discovery(base.discovery.with(**seed))
171
188
  end
172
189
 
173
190
  def determine_protection_exit(report, options)
@@ -196,6 +213,21 @@ module Rigor
196
213
  CoverageScan.project_environment(configuration)
197
214
  end
198
215
 
216
+ # The protection scan must see the same receiver types `rigor check`
217
+ # does — including plugin-contributed `dynamic_return` types (a
218
+ # controller's `params` → `ActionController::Parameters`, a
219
+ # `Model.where` → `ActiveRecord::Relation[Model]`). The bare
220
+ # `project_environment` carries only the RBS environment (no plugin
221
+ # registry), so every plugin-typed receiver reads `Dynamic` and its
222
+ # dispatch site is miscounted as *unprotected* — a systematic
223
+ # undercount of what Rigor actually types on a plugin-using project.
224
+ # `ProjectContext` builds the plugin-aware environment (registry
225
+ # materialised + the per-run prepare pass that primes producers like
226
+ # the controller / model index) exactly as the LSP and the runner do.
227
+ def plugin_aware_environment(configuration)
228
+ LanguageServer::ProjectContext.new(configuration: configuration).environment
229
+ end
230
+
199
231
  def scan_one(path, scanner, accumulator, configuration)
200
232
  CoverageScan.scan_into(path, scanner, accumulator, configuration)
201
233
  end