rigortype 0.2.4 → 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 (47) 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/docs/manual/plugins/rigor-rails-i18n.md +22 -3
  12. data/lib/rigor/analysis/incremental_session.rb +7 -2
  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/doctor_command.rb +295 -0
  16. data/lib/rigor/cli/plugins_command.rb +2 -2
  17. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  18. data/lib/rigor/cli/protection_renderer.rb +32 -2
  19. data/lib/rigor/cli/protection_report.rb +32 -6
  20. data/lib/rigor/cli/upgrade_command.rb +25 -0
  21. data/lib/rigor/cli.rb +17 -1
  22. data/lib/rigor/flow_contribution/fact.rb +1 -1
  23. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  24. data/lib/rigor/inference/expression_typer.rb +22 -10
  25. data/lib/rigor/inference/fallback.rb +2 -2
  26. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  27. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  28. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  29. data/lib/rigor/inference/mutation_widening.rb +18 -0
  30. data/lib/rigor/inference/protection_scanner.rb +6 -3
  31. data/lib/rigor/inference/statement_evaluator.rb +5 -4
  32. data/lib/rigor/plugin/base.rb +34 -7
  33. data/lib/rigor/plugin/registry.rb +1 -1
  34. data/lib/rigor/scope.rb +16 -5
  35. data/lib/rigor/version.rb +1 -1
  36. data/lib/rigor.rb +1 -0
  37. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  38. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  39. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +52 -0
  40. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +123 -8
  41. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  42. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
  43. data/sig/rigor/plugin/base.rbs +2 -0
  44. data/sig/rigor/scope.rbs +3 -1
  45. data/skills/rigor-plugin-author/SKILL.md +8 -5
  46. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  47. metadata +27 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 749e0e15c9b85dcec97c9afc1b107d5ebc1a1a685cc19aa39d316a9826992d4a
4
- data.tar.gz: 45bcee26284594e35b51a050ce8ac234935bee954063b50fdfe1b0c6a9bcddea
3
+ metadata.gz: 97d286eb15708fc212260925d407b48926a331798f0d2f210321b1d3c66cda55
4
+ data.tar.gz: 9fadaf2c31394c142dd15b335dbf740774ceabff8ce85b8a79fcd7faa26ea4fa
5
5
  SHA512:
6
- metadata.gz: fbf022b6e92a96e1c095cb3d887936161e836201662f051c60de5d0d78494f263228a28df509c3b937768735b5ce18162ee28657b2aaf1cd7a8b2d4851338a53
7
- data.tar.gz: a49e89a07950f54fcf8f38e328b8fdb658aaf0802154df457c7b9b1370f35279c1d6d5fe3acf6a45c9e49ff2f7c09ff9f209b1f7dab68d20b283c9baa4b2760b
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
@@ -43,9 +43,14 @@ errors_demo.rb:25:1: warning: `t('errors.messages.blank')` is missing from local
43
43
  with a literal first argument. **Lazy keys** — `t('.title')` in a
44
44
  controller — are expanded to `<controller_scope>.<action>.<key>`
45
45
  from the file path and the innermost enclosing `def`, matching
46
- Rails' convention; lazy keys in non-controller files are skipped
47
- (the scope can't be determined statically). Calls with a
48
- non-literal key (`t(some_variable)`) pass through unchecked.
46
+ Rails' convention; lazy keys in non-controller Ruby files (models,
47
+ helpers, mailers) are skipped (the scope can't be determined
48
+ statically at that point). **View template lazy keys** —
49
+ `t('.title')` inside `app/views/setting/index.html.erb` expands
50
+ to `setting.index.title` and is validated for key existence and
51
+ per-locale coverage; ERB, Haml, and Slim templates under
52
+ `view_search_paths` (default `app/views`) are scanned. Calls with
53
+ a non-literal key (`t(some_variable)`) pass through unchecked.
49
54
 
50
55
  Keys under the prefixes Rails and the `rails-i18n` gem ship
51
56
  themselves (`date.` / `time.` / `datetime.` / `number.` /
@@ -62,11 +67,14 @@ plugins:
62
67
  config:
63
68
  locale_search_paths: ["config/locales"] # default
64
69
  configured_locales: ["en"] # default
70
+ view_search_paths: ["app/views"] # default
65
71
  ```
66
72
 
67
73
  `configured_locales` is the set of locales the project ships;
68
74
  setting it to `["en", "ja"]` turns on `missing-locale` warnings
69
75
  whenever a key resolves in one but not the other.
76
+ `view_search_paths` controls which directories are scanned for
77
+ view templates containing lazy `t('.key')` calls.
70
78
 
71
79
  ## Limitations
72
80
 
@@ -74,6 +82,17 @@ whenever a key resolves in one but not the other.
74
82
  - **Lazy keys outside controllers are skipped** — the
75
83
  controller/action scope `t('.x')` depends on isn't derivable in
76
84
  a model / helper / mailer.
85
+ - **View template lazy keys** (`t('.key')` inside ERB / Haml /
86
+ Slim) are validated for key existence and per-locale coverage.
87
+ Interpolation validation is skipped for templates — the hash
88
+ may come from controller instance variables not visible in the
89
+ template source. Configure `view_search_paths:` to override the
90
+ default `["app/views"]`.
91
+ - **View diagnostics duplicate under `--workers`** — the view
92
+ scan is a project-wide pass surfaced through the per-file
93
+ diagnostic hook, so each fork-pool worker re-emits the full set
94
+ (the same once-per-run limitation the `load-error` diagnostics
95
+ carry). Default `rigor check` (sequential) is unaffected.
77
96
  - **Pluralization is recognised but not validated** — `count:` is
78
97
  treated as a reserved option; whether the locale defines
79
98
  `:zero` / `:one` / `:other` is not checked.
@@ -35,9 +35,14 @@ module Rigor
35
35
 
36
36
  # @param paths [Array<String>, nil] explicit analysis roots; nil
37
37
  # (the default) uses the configuration's `paths:`.
38
- def initialize(configuration:, paths: nil)
38
+ # @param environment [Rigor::Environment, nil] optional shared
39
+ # environment to thread into each internal Runner. Long-lived
40
+ # callers and specs can use this to avoid rebuilding the same
41
+ # RBS universe for every baseline / recheck / oracle run.
42
+ def initialize(configuration:, paths: nil, environment: nil)
39
43
  @configuration = configuration
40
44
  @paths = paths
45
+ @environment = environment
41
46
  @cache = {} # analyzed path => [Diagnostic]
42
47
  @sources = {} # analyzed path => Set<source path it read from>
43
48
  @digests = {} # analyzed path => content digest at last analysis
@@ -312,7 +317,7 @@ module Rigor
312
317
  end
313
318
 
314
319
  def build_runner(**)
315
- Runner.new(configuration: @configuration, cache_store: nil, **)
320
+ Runner.new(configuration: @configuration, cache_store: nil, environment: @environment, **)
316
321
  end
317
322
 
318
323
  # Run the runner over the session's explicit paths (or, when none were
@@ -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