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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/docs/handbook/09-plugins.md +5 -2
  3. data/docs/handbook/appendix-liskov.md +5 -3
  4. data/docs/handbook/appendix-phpstan.md +2 -2
  5. data/docs/install.md +1 -1
  6. data/docs/manual/02-cli-reference.md +58 -1
  7. data/docs/manual/06-baseline.md +12 -0
  8. data/docs/manual/11-ci.md +6 -6
  9. data/docs/manual/15-type-protection-coverage.md +29 -0
  10. data/docs/manual/plugins/rigor-minitest.md +1 -1
  11. data/lib/rigor/cli/check_command.rb +4 -33
  12. data/lib/rigor/cli/check_runner_factory.rb +63 -0
  13. data/lib/rigor/cli/doctor_command.rb +295 -0
  14. data/lib/rigor/cli/plugins_command.rb +2 -2
  15. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  16. data/lib/rigor/cli/protection_renderer.rb +32 -2
  17. data/lib/rigor/cli/protection_report.rb +32 -6
  18. data/lib/rigor/cli/upgrade_command.rb +25 -0
  19. data/lib/rigor/cli.rb +17 -1
  20. data/lib/rigor/flow_contribution/fact.rb +1 -1
  21. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  22. data/lib/rigor/inference/expression_typer.rb +22 -10
  23. data/lib/rigor/inference/fallback.rb +2 -2
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  26. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  27. data/lib/rigor/inference/mutation_widening.rb +18 -0
  28. data/lib/rigor/inference/protection_scanner.rb +6 -3
  29. data/lib/rigor/inference/statement_evaluator.rb +5 -4
  30. data/lib/rigor/plugin/base.rb +34 -7
  31. data/lib/rigor/plugin/registry.rb +1 -1
  32. data/lib/rigor/scope.rb +16 -5
  33. data/lib/rigor/version.rb +1 -1
  34. data/lib/rigor.rb +1 -0
  35. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  36. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  37. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  38. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
  39. data/sig/rigor/plugin/base.rbs +2 -0
  40. data/sig/rigor/scope.rbs +3 -1
  41. data/skills/rigor-plugin-author/SKILL.md +8 -5
  42. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  43. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce4071258582638c452079484534e58c6d6b290a88ea51fdce715024d7c09297
4
- data.tar.gz: 90197cb4711899857c8d242470b063e7da64947e46e240253206179a0e8f6baf
3
+ metadata.gz: 97d286eb15708fc212260925d407b48926a331798f0d2f210321b1d3c66cda55
4
+ data.tar.gz: 9fadaf2c31394c142dd15b335dbf740774ceabff8ce85b8a79fcd7faa26ea4fa
5
5
  SHA512:
6
- metadata.gz: 4e967fdc0ca679a712860a20da09842c2668727c0068a4ed9657ae4111d0d94a5ab8efa9684c1a75b1ad25dbeb03efd9a4580f85e771aa5a61a05f56f38b6203
7
- data.tar.gz: 8773e6364390cb36296345c4ec8974cee5bec585f7f9da292950859e4cb754081a68130db00dbc14f62d77c7f396477adace7480500d789bb838777ea268c6a2
6
+ metadata.gz: 1b800eb64d10fdef9039ed429f5740f672cdf0811de86c1cac1434b6bb5a0c820df68d8d8b01aa03ed0cab518d18a6e51af12d608cf6ad50556e4b653a4d3ed6
7
+ data.tar.gz: 5bee1c072412f7eac14d0c96314f8efe2473cd46efe9763e238bd3c345605deb29055db3601c52909c430d419d8821fc7befb51fe0f5211b8fbb94e379a5d635
@@ -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
@@ -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
@@ -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.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
@@ -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