rigortype 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/docs/handbook/09-plugins.md +5 -2
- data/docs/handbook/appendix-liskov.md +5 -3
- data/docs/handbook/appendix-phpstan.md +2 -2
- data/docs/install.md +1 -1
- data/docs/manual/02-cli-reference.md +58 -1
- data/docs/manual/06-baseline.md +12 -0
- data/docs/manual/11-ci.md +6 -6
- data/docs/manual/15-type-protection-coverage.md +29 -0
- data/docs/manual/plugins/rigor-minitest.md +1 -1
- data/lib/rigor/cli/check_command.rb +4 -33
- data/lib/rigor/cli/check_runner_factory.rb +63 -0
- data/lib/rigor/cli/doctor_command.rb +295 -0
- data/lib/rigor/cli/plugins_command.rb +2 -2
- data/lib/rigor/cli/plugins_renderer.rb +1 -1
- data/lib/rigor/cli/protection_renderer.rb +32 -2
- data/lib/rigor/cli/protection_report.rb +32 -6
- data/lib/rigor/cli/upgrade_command.rb +25 -0
- data/lib/rigor/cli.rb +17 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/inference/dynamic_origin.rb +67 -0
- data/lib/rigor/inference/expression_typer.rb +22 -10
- data/lib/rigor/inference/fallback.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
- data/lib/rigor/inference/method_dispatcher.rb +19 -4
- data/lib/rigor/inference/mutation_widening.rb +18 -0
- data/lib/rigor/inference/protection_scanner.rb +6 -3
- data/lib/rigor/inference/statement_evaluator.rb +5 -4
- data/lib/rigor/plugin/base.rb +34 -7
- data/lib/rigor/plugin/registry.rb +1 -1
- data/lib/rigor/scope.rb +16 -5
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
- data/sig/rigor/plugin/base.rbs +2 -0
- data/sig/rigor/scope.rbs +3 -1
- data/skills/rigor-plugin-author/SKILL.md +8 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97d286eb15708fc212260925d407b48926a331798f0d2f210321b1d3c66cda55
|
|
4
|
+
data.tar.gz: 9fadaf2c31394c142dd15b335dbf740774ceabff8ce85b8a79fcd7faa26ea4fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b800eb64d10fdef9039ed429f5740f672cdf0811de86c1cac1434b6bb5a0c820df68d8d8b01aa03ed0cab518d18a6e51af12d608cf6ad50556e4b653a4d3ed6
|
|
7
|
+
data.tar.gz: 5bee1c072412f7eac14d0c96314f8efe2473cd46efe9763e238bd3c345605deb29055db3601c52909c430d419d8821fc7befb51fe0f5211b8fbb94e379a5d635
|
data/docs/handbook/09-plugins.md
CHANGED
|
@@ -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
|
-
`
|
|
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
|
-
`
|
|
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` (
|
|
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("
|
|
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 `
|
|
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 `
|
|
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.
|
|
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
|
-
`
|
|
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
|
|
@@ -539,6 +539,63 @@ with its stable id, the severity it would impose, and whether your config
|
|
|
539
539
|
adopts it. See [`docs/compatibility.md`](../compatibility.md) for how
|
|
540
540
|
bleeding-edge fits the stability model.
|
|
541
541
|
|
|
542
|
+
## `rigor doctor`
|
|
543
|
+
|
|
544
|
+
Classify setup problems vs a clean run with routed next actions
|
|
545
|
+
([ADR-77](../adr/77-doctor-and-upgrade-commands.md) WD1).
|
|
546
|
+
|
|
547
|
+
```sh
|
|
548
|
+
rigor doctor [--config PATH] [--format text|json]
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
| Flag | Purpose |
|
|
552
|
+
| --- | --- |
|
|
553
|
+
| `--config PATH` | Use this `.rigor.yml` instead of auto-discovery. |
|
|
554
|
+
| `--format text\|json` | Output format. Default `text`. |
|
|
555
|
+
|
|
556
|
+
Runs a scoped analysis and audits:
|
|
557
|
+
|
|
558
|
+
- **Configuration audit** — unresolved `signature_paths:`, unknown
|
|
559
|
+
`libraries:`, inert `disable:` / `severity_overrides:` tokens
|
|
560
|
+
({ConfigAudit}).
|
|
561
|
+
- **RBS environment health** — whether the RBS class universe built
|
|
562
|
+
successfully (`0` classes means a broken setup).
|
|
563
|
+
- **Plugin load errors** — whether every configured plugin loaded.
|
|
564
|
+
- **Baseline drift** — whether the current diagnostics have drifted
|
|
565
|
+
from the saved baseline.
|
|
566
|
+
- **Rails plugin gap** — whether `Gemfile.lock` contains Rails gems
|
|
567
|
+
but no Rails plugin is enabled.
|
|
568
|
+
|
|
569
|
+
Text output prints `[PASS]`, `[FAIL]`, or `[WARN]` per check plus a
|
|
570
|
+
routed hint (e.g. "Run `rigor baseline regenerate`"). JSON output
|
|
571
|
+
is a stable contract:
|
|
572
|
+
|
|
573
|
+
```json
|
|
574
|
+
{
|
|
575
|
+
"status": "issues_found",
|
|
576
|
+
"checks": [
|
|
577
|
+
{ "id": "config_audit", "status": "fail", "message": "...", "hint": "..." }
|
|
578
|
+
]
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Exits `1` when any check fails, `0` when all pass.
|
|
583
|
+
|
|
584
|
+
## `rigor upgrade`
|
|
585
|
+
|
|
586
|
+
Migration command skeleton ([ADR-50](../adr/50-release-engineering-and-stability-strategy.md)
|
|
587
|
+
WD7). The real body lands when a concrete backwards-compatibility
|
|
588
|
+
break gives it a target (e.g. re-running `baseline regenerate`
|
|
589
|
+
against a strengthened default profile, surfacing renamed
|
|
590
|
+
suppression ids, reporting `bleeding_edge:` graduations).
|
|
591
|
+
|
|
592
|
+
```sh
|
|
593
|
+
rigor upgrade
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Until then it prints the current version and notes that upgrade is
|
|
597
|
+
queued. Exits `0`.
|
|
598
|
+
|
|
542
599
|
## Environment variables
|
|
543
600
|
|
|
544
601
|
Most behaviour is driven by flags and `.rigor.yml`; a few
|
data/docs/manual/06-baseline.md
CHANGED
|
@@ -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
|
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.
|
|
196
|
-
still settling, so pinning is recommended; what Rigor
|
|
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.
|
|
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.
|
|
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.
|
|
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 `
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
require_relative "../config_audit"
|
|
8
|
+
require_relative "../analysis/baseline"
|
|
9
|
+
require_relative "../analysis/result"
|
|
10
|
+
require_relative "../plugin"
|
|
11
|
+
require_relative "../plugin/loader"
|
|
12
|
+
require_relative "../plugin/services"
|
|
13
|
+
require_relative "../reflection"
|
|
14
|
+
require_relative "../type/combinator"
|
|
15
|
+
require_relative "check_runner_factory"
|
|
16
|
+
require_relative "command"
|
|
17
|
+
require_relative "options"
|
|
18
|
+
|
|
19
|
+
module Rigor
|
|
20
|
+
class CLI
|
|
21
|
+
# `rigor doctor` — classify existing project findings into setup-problem
|
|
22
|
+
# vs clean-run with a routed next action (ADR-77 WD1).
|
|
23
|
+
#
|
|
24
|
+
# It is a presentation/aggregation layer over data `rigor check` already
|
|
25
|
+
# produces — no new analysis pass and no new diagnostic rule. The
|
|
26
|
+
# command runs a scoped analysis to gather stats, audits the
|
|
27
|
+
# configuration, validates plugins, and checks baseline drift, then
|
|
28
|
+
# reports a small fixed set of checks with a hint for each failure.
|
|
29
|
+
class DoctorCommand < Command # rubocop:disable Metrics/ClassLength
|
|
30
|
+
USAGE = "Usage: rigor doctor [options]"
|
|
31
|
+
|
|
32
|
+
# Check identifiers — the stable JSON contract (`checks[].id`).
|
|
33
|
+
CHECK_CONFIG = "config_audit"
|
|
34
|
+
CHECK_RBS = "rbs_environment"
|
|
35
|
+
CHECK_PLUGINS = "plugins"
|
|
36
|
+
CHECK_BASELINE = "baseline"
|
|
37
|
+
CHECK_RAILS = "rails_plugins"
|
|
38
|
+
|
|
39
|
+
RAILS_LOCK_MARKERS = %w[railties actionpack activerecord actioncable].freeze
|
|
40
|
+
RAILS_PLUGIN_MARKERS = %w[
|
|
41
|
+
rigor-activerecord rigor-actionpack rigor-actionmailer rigor-activejob
|
|
42
|
+
rigor-rails-routes rigor-rails-i18n rigor-actioncable rigor-activestorage rigor-rails
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# @return [Integer] CLI exit status.
|
|
46
|
+
def run
|
|
47
|
+
options = parse_options
|
|
48
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
49
|
+
findings = []
|
|
50
|
+
|
|
51
|
+
# 1. Config audit (cheap — no analysis needed).
|
|
52
|
+
findings.concat(audit_config(configuration))
|
|
53
|
+
|
|
54
|
+
# 2. Run a scoped analysis to gather stats + diagnostics for the
|
|
55
|
+
# deeper checks. Use no-cache so the probe doesn't churn disk.
|
|
56
|
+
runner = CheckRunnerFactory.build(
|
|
57
|
+
configuration: configuration,
|
|
58
|
+
options: { no_cache: true, explain: false, stats: true, workers: 0 },
|
|
59
|
+
buffer: nil,
|
|
60
|
+
cache_root: configuration.cache_path
|
|
61
|
+
)
|
|
62
|
+
result = runner.run(configuration.paths)
|
|
63
|
+
|
|
64
|
+
# 3. RBS environment check.
|
|
65
|
+
findings.concat(check_rbs_environment(result))
|
|
66
|
+
|
|
67
|
+
# 4. Plugin load check.
|
|
68
|
+
findings.concat(check_plugins(configuration))
|
|
69
|
+
|
|
70
|
+
# 5. Baseline drift check.
|
|
71
|
+
findings.concat(check_baseline(configuration, result))
|
|
72
|
+
|
|
73
|
+
# 6. Rails locked but no Rails plugins.
|
|
74
|
+
findings.concat(check_rails_plugins(configuration))
|
|
75
|
+
|
|
76
|
+
report(findings, options.fetch(:format))
|
|
77
|
+
findings.any? { |f| f[:status] == :fail } ? 1 : 0
|
|
78
|
+
rescue OptionParser::InvalidArgument => e
|
|
79
|
+
@err.puts(e.message)
|
|
80
|
+
CLI::EXIT_USAGE
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def parse_options
|
|
86
|
+
options = { config: nil, format: "text" }
|
|
87
|
+
OptionParser.new do |opts|
|
|
88
|
+
opts.banner = USAGE
|
|
89
|
+
Options.add_config(opts, options)
|
|
90
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
91
|
+
end.parse!(@argv)
|
|
92
|
+
validate!(options)
|
|
93
|
+
options
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate!(options)
|
|
97
|
+
return if %w[text json].include?(options.fetch(:format))
|
|
98
|
+
|
|
99
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Individual checks — each returns an Array of finding Hashes.
|
|
104
|
+
# A finding has: :check, :status (:pass/:fail/:warn), :message, :hint
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def audit_config(configuration)
|
|
108
|
+
warnings = ConfigAudit.warnings(configuration, project_root: Dir.pwd)
|
|
109
|
+
return [] if warnings.empty?
|
|
110
|
+
|
|
111
|
+
messages = warnings.map(&:message)
|
|
112
|
+
[
|
|
113
|
+
{
|
|
114
|
+
check: CHECK_CONFIG,
|
|
115
|
+
status: :fail,
|
|
116
|
+
message: "Configuration warnings: #{messages.size}",
|
|
117
|
+
hint: "Review and fix the flagged entries in your config file."
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def check_rbs_environment(result)
|
|
123
|
+
stats = result.stats
|
|
124
|
+
return [] unless stats
|
|
125
|
+
|
|
126
|
+
if stats.rbs_classes_total.zero?
|
|
127
|
+
[
|
|
128
|
+
{
|
|
129
|
+
check: CHECK_RBS,
|
|
130
|
+
status: :fail,
|
|
131
|
+
message: "RBS environment is empty (#{stats.rbs_classes_total} classes available)",
|
|
132
|
+
hint: "The RBS environment failed to build or loaded no signatures. " \
|
|
133
|
+
"Check `signature_paths:` for duplicate declarations, or run " \
|
|
134
|
+
"`rbs collection install` for gem signatures."
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
else
|
|
138
|
+
[
|
|
139
|
+
{
|
|
140
|
+
check: CHECK_RBS,
|
|
141
|
+
status: :pass,
|
|
142
|
+
message: "RBS environment healthy (#{stats.rbs_classes_total} classes available)",
|
|
143
|
+
hint: nil
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def check_plugins(configuration)
|
|
150
|
+
services = Plugin::Services.new(
|
|
151
|
+
reflection: Reflection,
|
|
152
|
+
type: Type::Combinator,
|
|
153
|
+
configuration: configuration,
|
|
154
|
+
cache_store: nil
|
|
155
|
+
)
|
|
156
|
+
registry = Plugin::Loader.load(configuration: configuration, services: services)
|
|
157
|
+
errors = registry.load_errors
|
|
158
|
+
return [] if errors.empty?
|
|
159
|
+
|
|
160
|
+
[
|
|
161
|
+
{
|
|
162
|
+
check: CHECK_PLUGINS,
|
|
163
|
+
status: :fail,
|
|
164
|
+
message: "Plugin load errors: #{errors.size}",
|
|
165
|
+
hint: "Run `rigor plugins --strict` for the full per-plugin report."
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def check_baseline(configuration, result)
|
|
171
|
+
path = configuration.baseline_path
|
|
172
|
+
return [] if path.nil? || !File.exist?(path)
|
|
173
|
+
|
|
174
|
+
baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
|
|
175
|
+
return [] if baseline.nil? || baseline.empty?
|
|
176
|
+
|
|
177
|
+
drifted = baseline.audit(result.diagnostics).reject { |row| row.status == :within }
|
|
178
|
+
return [] if drifted.empty?
|
|
179
|
+
|
|
180
|
+
[
|
|
181
|
+
{
|
|
182
|
+
check: CHECK_BASELINE,
|
|
183
|
+
status: :fail,
|
|
184
|
+
message: "Baseline drift detected (#{baseline_drift_summary(drifted)})",
|
|
185
|
+
hint: "Run `rigor baseline regenerate` to refresh the baseline."
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
rescue Analysis::Baseline::LoadError, Psych::SyntaxError => e
|
|
189
|
+
[
|
|
190
|
+
{
|
|
191
|
+
check: CHECK_BASELINE,
|
|
192
|
+
status: :warn,
|
|
193
|
+
message: "Baseline load failed: #{e.message}",
|
|
194
|
+
hint: "Check the baseline file path in your config."
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def baseline_drift_summary(drifted)
|
|
200
|
+
counts = drifted.group_by(&:status).transform_values(&:size)
|
|
201
|
+
parts = []
|
|
202
|
+
parts << "#{counts.fetch(:over, 0)} over threshold" if counts.key?(:over)
|
|
203
|
+
parts << "#{counts.fetch(:cleared, 0)} cleared" if counts.key?(:cleared)
|
|
204
|
+
parts << "#{counts.fetch(:reducible, 0)} reducible" if counts.key?(:reducible)
|
|
205
|
+
parts.join(", ")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def check_rails_plugins(configuration)
|
|
209
|
+
return [] unless rails_unconfigured?(configuration)
|
|
210
|
+
|
|
211
|
+
[
|
|
212
|
+
{
|
|
213
|
+
check: CHECK_RAILS,
|
|
214
|
+
status: :fail,
|
|
215
|
+
message: "Rails detected but no Rails plugins enabled",
|
|
216
|
+
hint: "Add Rails plugins (e.g. rigor-activerecord, rigor-actionpack) to " \
|
|
217
|
+
"`.rigor.yml` `plugins:` so framework calls resolve."
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# True when Rails is in Gemfile.lock but the config enables no
|
|
223
|
+
# Rails plugin — the same probe {ProjectStateProbe} uses.
|
|
224
|
+
def rails_unconfigured?(_configuration)
|
|
225
|
+
config_path = Configuration.discover
|
|
226
|
+
return false if config_path.nil?
|
|
227
|
+
return false unless File.file?(config_path)
|
|
228
|
+
|
|
229
|
+
lock = File.join(Dir.pwd, "Gemfile.lock")
|
|
230
|
+
return false unless File.file?(lock) && file_mentions_any?(lock, RAILS_LOCK_MARKERS)
|
|
231
|
+
|
|
232
|
+
!file_mentions_any?(config_path, RAILS_PLUGIN_MARKERS)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def file_mentions_any?(path, markers)
|
|
236
|
+
content = File.read(path)
|
|
237
|
+
markers.any? { |marker| content.include?(marker) }
|
|
238
|
+
rescue StandardError
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Reporting
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def report(findings, format)
|
|
247
|
+
case format
|
|
248
|
+
when "json" then report_json(findings)
|
|
249
|
+
else report_text(findings)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def report_json(findings)
|
|
254
|
+
payload = {
|
|
255
|
+
"status" => findings.any? { |f| f[:status] == :fail } ? "issues_found" : "clean",
|
|
256
|
+
"checks" => findings.map do |f|
|
|
257
|
+
{
|
|
258
|
+
"id" => f[:check].to_s,
|
|
259
|
+
"status" => f[:status].to_s,
|
|
260
|
+
"message" => f[:message],
|
|
261
|
+
"hint" => f[:hint]
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
}
|
|
265
|
+
@out.puts(JSON.pretty_generate(payload))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def report_text(findings)
|
|
269
|
+
failures = findings.select { |f| f[:status] == :fail }
|
|
270
|
+
warnings = findings.select { |f| f[:status] == :warn }
|
|
271
|
+
passes = findings.select { |f| f[:status] == :pass }
|
|
272
|
+
|
|
273
|
+
if failures.empty? && warnings.empty?
|
|
274
|
+
@out.puts("rigor doctor: all checks passed — no setup problems detected.")
|
|
275
|
+
else
|
|
276
|
+
@out.puts("rigor doctor: #{failures.size} issue(s) found")
|
|
277
|
+
failures.each { |f| print_finding(f) }
|
|
278
|
+
warnings.each { |f| print_finding(f) } unless warnings.empty?
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
passes.each { |f| print_finding(f) } unless passes.empty?
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def print_finding(finding)
|
|
285
|
+
label = case finding[:status]
|
|
286
|
+
when :fail then "[FAIL]"
|
|
287
|
+
when :warn then "[WARN]"
|
|
288
|
+
else "[PASS]"
|
|
289
|
+
end
|
|
290
|
+
@out.puts("#{label} #{finding[:check]}: #{finding[:message]}")
|
|
291
|
+
@out.puts(" → #{finding[:hint]}") if finding[:hint]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|