rail_verdict 1.2.0 → 1.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd73e695fc399f10d5e9dcde4f8f04294948160821c9f256234e79d6345bb8e0
4
- data.tar.gz: dfb8a4b7ae5613ab75ef864dfbca00d3fa7c6241ad47937241e524aa5724b7ff
3
+ metadata.gz: 220003ceaeba89b1fb2f8f7082a8a2e6f6dcdcc070005221000602524e3b7576
4
+ data.tar.gz: 13fb2353b109fed755c762be216060174b3a89a59bbc6e443f06d133edbf0537
5
5
  SHA512:
6
- metadata.gz: 9faf8ca3b218e240f87800727704caa02c588d5e88fa8a614a15f0862928ad4ccd36725bbd66300d89330c9994a4f16c2f7d63cd14df2c79899b8d4380bb95de
7
- data.tar.gz: 05daa4f456785c207522f9c8be75066ee0a7c112ee5681b7ea311151b3e0b2180fee7008ff97e5b79f12f439751c115a43cefc7550a1a6e16c1637296d9415c5
6
+ metadata.gz: 7a58b08566a039a6c32fae4a4e4154be529a2c4e0567afc2ee7ac8b24c6e944e0b0555359eb8ea532c02f6143aa4e7594cce208c41bc284243d519d704f07d62
7
+ data.tar.gz: a79f0ce2a5eb249cd902f60f74b41318dba4f52112004109b42d394540f9ab8aac846a0e5e8d22c1cee009d649016fbf75dc8d715a2440fb498e61b3600247ee
data/README.md CHANGED
@@ -78,18 +78,11 @@ The Lab exercises RailVerdict across realistic operational scenarios:
78
78
  - Exercising fail-closed behavior on missing or malformed analyzer output;
79
79
  - Testing release artifact installation from clean environments.
80
80
 
81
- ### 21 / 21 External Validation Campaign
81
+ ### External Validation Campaign
82
82
 
83
- The 1.0 release closeout validated **21 out of 21** external scenarios:
83
+ The Lab independently validates the published gem as an external consumer. 1.0 closeout validated **21 / 21** scenarios; **1.2.0** certification validates **81 scenarios** (14 categories, see Lab README) including Verification Receipts and PR Intelligence. See [railverdict-lab](https://github.com/pedro-dalben/railverdict-lab) for the canonical campaign and results.
84
84
 
85
- | Category | Scenarios | Result |
86
- |---|---:|:---:|
87
- | **Core Verification** | 12 / 12 | PASS |
88
- | **Operational & CI** | 6 / 6 | PASS |
89
- | **Release Closeout** | 3 / 3 | PASS |
90
- | **Total** | **21 / 21** | **PASS** |
91
-
92
- These controlled validation scenarios represent rigorous external regression verification rather than a claim of mathematically bug-free software. During development, the Lab uncovered real defects — such as test failure scoping edge cases — which were fixed before the 1.0 release.
85
+ These controlled validation scenarios represent rigorous external regression verification rather than a claim of mathematically bug-free software. During development, the Lab uncovered real defects — such as test failure scoping edge cases and the 1.2 dogfooding hardening — which were fixed before release.
93
86
 
94
87
  ---
95
88
 
@@ -236,11 +229,73 @@ an explicit value, including every analyzer in older configuration versions,
236
229
  continues to use 30 seconds. A timeout is incomplete evidence, never a normal
237
230
  finding: a required timeout produces `INCOMPLETE` and exit code `2`.
238
231
 
239
- RailVerdict 1.0.1 has no CLI timeout override; the versioned configuration is
232
+ There is no CLI timeout override; the versioned configuration (`version: 1.5`) is
240
233
  the supported public surface. SimpleCov accepts the same setting for a uniform
241
234
  configuration contract, but reads a local coverage artifact rather than
242
235
  starting an analyzer process.
243
236
 
237
+ ### 30-Second Demonstration (real output)
238
+
239
+ ```console
240
+ $ railverdict check
241
+
242
+ RailVerdict Verification: PASS
243
+ Policy: no_new_debt (complete)
244
+ Analyzers: 5 run (5 complete)
245
+ Findings: 0 introduced, 0 existing, 0 blocking
246
+ Exit: 0
247
+ ```
248
+
249
+ Introduce a controlled defect:
250
+
251
+ ```ruby
252
+ # app/models/user.rb:42
253
+ unused = "oops" # Lint/UselessAssignment
254
+ ```
255
+
256
+ ```console
257
+ $ railverdict check
258
+
259
+ RailVerdict Verification: FAIL
260
+ Policy: no_new_debt (failed)
261
+ Findings: 1 introduced (blocking), 0 existing
262
+ - [rubocop] Lint/UselessAssignment in app/models/user.rb:42 (introduced)
263
+ Exit: 1
264
+ ```
265
+
266
+ Fix it, rerun → `PASS`. See `docs/release/1.2-clean-room-rehearsal.md` for automated reproduction.
267
+
268
+ ### Why not just CI?
269
+
270
+ CI answers **“did these jobs execute successfully?”** across fragmented outputs. RailVerdict answers **“given required evidence, repository state, baseline and policy, what is the deterministic verification decision for this change?”** — normalized findings, baseline-aware policy (`no_new_debt`), changed-scope (`--changed --base`), fail-closed `INCOMPLETE` on missing evidence, and machine contracts (JSON/SARIF/exits) plus Repository State Identity and Verification Receipts. It complements CI; the example in [GitHub Actions Integration](#github-actions-integration) runs RailVerdict inside CI.
271
+
272
+ ### Why not just RSpec + RuboCop?
273
+
274
+ Those are the evidence. RailVerdict is the verifier:
275
+
276
+ ```
277
+ RSpec says: 42 tests passed.
278
+ RuboCop says: 3 offenses.
279
+ SimpleCov says: 91.3% coverage.
280
+ Git says: these 7 lines changed.
281
+ RailVerdict says: FAIL — this change introduced a blocking finding.
282
+ ```
283
+
284
+ They produce facts; policy owns the gate. `rubocop` + `rspec` + `minitest` + `simplecov` + `bundler-audit` + `Git` are inputs; `PASS/WARN/FAIL/INCOMPLETE` is the `GateResult` (see `docs/contracts.md`).
285
+
286
+ ### Why this matters for AI agents
287
+
288
+ An AI agent can write code, run tests, and declare itself finished — but it should not be the authority deciding whether its own work is acceptable.
289
+
290
+ ```
291
+ Agent --modifies--> Repository --verify--> RailVerdict
292
+ |--> deterministic evidence
293
+ |--> GateResult
294
+ `--> Verification Receipt
295
+ ```
296
+
297
+ The agent proposes; the verification system decides. Receipts let agents (and humans) prove `fresh` vs `stale` after any edit — see below. AI inside RailVerdict (`explain`/`investigate`) is advisory only and never changes `GateResult`.
298
+
244
299
  ---
245
300
 
246
301
  ## The Default Policy Model: No New Debt
@@ -339,12 +394,28 @@ exit code `2`.
339
394
  Verification is only meaningful for the exact state that was verified. RailVerdict 1.2 binds every guarded verification to a deterministic **Repository State Identity** (HEAD + Git index snapshot + worktree delta with content hashes + configuration/baseline/waiver digests) and issues a machine-readable **Verification Receipt**:
340
395
 
341
396
  ```console
342
- $ railverdict receipt create --format json > receipt.json
397
+ $ railverdict check
398
+ # => RailVerdict Verification: PASS (exit 0)
399
+ $ railverdict receipt create > receipt.json
400
+ # receipt_id = sha256:<64 hex> (no created_at, no timestamps)
401
+
343
402
  $ railverdict receipt verify receipt.json --format json
344
- {"schema_version":"1.0","status":"fresh","reasons":[],"gate":"PASS", ...}
403
+ {"schema_version":"1.0","status":"fresh","reasons":[],"gate":"PASS"}
404
+ ```
405
+
406
+ Edit anything afterwards and the same receipt reports `stale`:
407
+
408
+ ```console
409
+ $ echo "# changed after verification" >> app/models/user.rb
410
+ $ railverdict receipt verify receipt.json --format json
411
+ {"schema_version":"1.0","status":"stale","reasons":["worktree_changed"],"gate":"PASS"}
412
+ # exit 2 — current state is not what was verified
413
+
414
+ # Same for staged, config, baseline, waiver edits:
415
+ # head_changed | index_changed | worktree_changed | configuration_changed | baseline_changed | waivers_changed
345
416
  ```
346
417
 
347
- Edit anything afterwards — source, staged index, untracked files, config, baseline, waivers — and the same receipt reports `stale` with a deterministic reason (`head_changed`, `index_changed`, `worktree_changed`, `configuration_changed`, `baseline_changed`, `waivers_changed`). If the repository mutates while analyzers run, receipt issuance fails closed with `repository_changed_during_verification`. Receipts exist for PASS, FAIL, and INCOMPLETE alike; they are deterministic integrity records, not signed attestations — a trusted CI remains the trust anchor when forgery is in scope. Coding agents follow the completion protocol in [docs/agent-verification.md](docs/agent-verification.md).
418
+ If the repository mutates while analyzers run, receipt issuance fails closed with `repository_changed_during_verification`. Receipts exist for `PASS`, `FAIL`, and `INCOMPLETE`; they are deterministic integrity records — **not signed attestations** — a trusted CI remains the trust anchor when forgery is in scope. Full protocol in [docs/agent-verification.md](docs/agent-verification.md).
348
419
 
349
420
  ```
350
421
  Deterministic Verification → PR Intelligence → Verification Receipt → Agent Verification Protocol
@@ -364,7 +435,7 @@ All analyzers in RailVerdict are **external and owned by the target project**. R
364
435
  | **SimpleCov** | Code and changed-line coverage | `>= 1, < 2` | Ingests versioned public `coverage/coverage.json` v1 (never parses internal `.resultset.json`). |
365
436
  | **bundler-audit** | Gem dependency vulnerabilities | `>= 0.9.3, < 1` | Runs `bundle exec bundler-audit check --format json` (never runs automatic updates). Robustly extracts JSON when advisory-DB download notices precede the payload. |
366
437
 
367
- > **Brakeman Status:** Brakeman support is **not included** in 1.0 (on HOLD pending legal and licensing review). Third-party analyzers retain their respective upstream licenses.
438
+ > **Brakeman Status:** Brakeman support is **not included** in 1.2.0 (on HOLD pending legal and licensing review). Third-party analyzers retain their respective upstream licenses.
368
439
 
369
440
  ---
370
441
 
@@ -450,7 +521,44 @@ See [`docs/ai.md`](docs/ai.md) and [`docs/privacy.md`](docs/privacy.md) for deta
450
521
 
451
522
  ## Coding Agents & The Repair Loop
452
523
 
453
- RailVerdict provides a structured verification loop for AI coding agents (such as Claude, Codex, or custom agents):
524
+ ### Agent Completion Protocol (copyable for AGENTS.md / CLAUDE.md / Codex)
525
+
526
+ Before declaring any code-changing task complete:
527
+
528
+ 1. Run the required project tests.
529
+ 2. Run `bundle exec railverdict check` (or `check --changed --base <main>` in PRs).
530
+ 3. If `FAIL`: remediate findings and rerun until `PASS` or explicitly report the blocker.
531
+ 4. If `INCOMPLETE` (exit 2): do not claim verification succeeded — fix the evidence gap (missing analyzer, bad base, shallow history).
532
+ 5. After final `PASS`, create `bundle exec railverdict receipt create > receipt.json`.
533
+ 6. Do not modify repository state after receipt creation without reverifying — `receipt verify` must stay `fresh`.
534
+
535
+ `FAIL` means remediation required. `INCOMPLETE` must never be represented as `PASS`. See `docs/agent-verification.md` for the formal contract.
536
+
537
+ <details><summary>AGENTS.md snippet (paste into your repo)</summary>
538
+
539
+ ```markdown
540
+ ## Verification
541
+
542
+ Before completing any code-changing task:
543
+
544
+ bundle exec railverdict check
545
+
546
+ A task may only be reported as verified when RailVerdict returns PASS (exit 0).
547
+
548
+ FAIL (exit 1) means remediation is required.
549
+
550
+ INCOMPLETE (exit 2) means required evidence could not be obtained and must never be represented as PASS.
551
+
552
+ After the final PASS, create a verification receipt: bundle exec railverdict receipt create > receipt.json
553
+
554
+ Do not modify repository state after receipt creation without reverifying (receipt verify must be fresh).
555
+ ```
556
+
557
+ Validated against 1.2.0: `init`, `doctor`, `check`, `pr`, `baseline create`, `findings`, `repair`, `receipt create|verify`, `mcp serve`.
558
+
559
+ </details>
560
+
561
+ RailVerdict also provides a structured verification loop for AI coding agents (such as Claude, Codex, or custom agents):
454
562
 
455
563
  ```
456
564
  Agent modifies code
@@ -503,6 +611,8 @@ railverdict mcp serve
503
611
  | `verify_repair` | Reruns verification and classifies whether a repair succeeded or regressed. |
504
612
  | `explain` | Generates an advisory AI explanation for a finding. |
505
613
  | `investigate` | Investigates top blocking findings across the codebase. |
614
+ | `get_verification_receipt` | Returns the Verification Receipt for the last `verify` without rerunning analyzers (refuses stale cache). |
615
+ | `get_pr_intelligence` | Returns PR Intelligence derived from the last `verify`'s GateResult (no rerun). |
506
616
 
507
617
  ### MCP Security Properties
508
618
 
@@ -575,7 +685,7 @@ To maintain clear technical boundaries, RailVerdict is explicitly **NOT**:
575
685
 
576
686
  ## Project Status
577
687
 
578
- - **Release Version:** `1.0.1`
688
+ - **Release Version:** `1.2.0`
579
689
  - **License:** [MIT](LICENSE) (see [NOTICE](NOTICE))
580
690
  - **Trademarks:** [TRADEMARKS.md](TRADEMARKS.md)
581
691
  - **Foundation & Legal:** [docs/foundation.md](docs/foundation.md) — preliminary screen found no obvious software/tool conflict; NOT LEGAL CLEARANCE; qualified trademark review NOT PERFORMED — NON-BLOCKING BY MAINTAINER DECISION 2026-08-19 (Pedro Dalben).
@@ -605,9 +715,32 @@ To maintain clear technical boundaries, RailVerdict is explicitly **NOT**:
605
715
 
606
716
  ---
607
717
 
718
+ ## Try RailVerdict Challenge (5 minutes)
719
+
720
+ 1. `bundle add rail_verdict --group development,test --require false && bundle install`
721
+ 2. `bundle exec railverdict init && bundle exec railverdict doctor`
722
+ 3. `bundle exec railverdict baseline create` (or skip for greenfield) → `bundle exec railverdict check` → first gate
723
+ 4. Introduce a controlled RuboCop offense, rerun → `FAIL`, fix → `PASS`, then `railverdict receipt create && railverdict receipt verify`
724
+
725
+ Tell us where it confused (see `docs/troubleshooting.md` and `docs/launch/try-challenge.md`).
726
+
727
+ ## FAQ
728
+
729
+ **Is RailVerdict a test framework? Does it replace RSpec/RuboCop/CI?** No — it consumes them; they are evidence, it is the verifier. Run it inside CI.
730
+
731
+ **Does it upload source? Require AI? Does AI decide PASS?** No/no/no — offline by default, AI advisory only off by default (`trust: redacted`).
732
+
733
+ **Legacy apps?** Yes — `no_new_debt` baseline.
734
+
735
+ **INCOMPLETE vs FAIL?** `FAIL` = complete verification rejected new debt; `INCOMPLETE` = required evidence missing → no gate (exit 2), never PASS.
736
+
737
+ **Baseline / waiver / receipt?** Baseline snapshots current debt; waiver is exact-fingerprint, UTC-expired exemption; receipt is an integrity record (`receipt_id = sha256:`) — **not signed**, forgeable via whole-file rewrite — trusted CI is the trust anchor. Mutating after `PASS` makes receipt `stale`.
738
+
739
+ **Brakeman / Ruby / Rails?** Brakeman not in 1.2.0 (HOLD); Ruby `>=3.3`, Rails `>=8.0` bounded.
740
+
608
741
  ## Contributing & Issues
609
742
 
610
- Contributions and issue reports are welcome. Please open an issue on GitHub for:
743
+ Contributions and issue reports are welcome. See `CONTRIBUTING.md` for “how to run tests / add regression”. Issue templates: Bug, Feature request, Compatibility report. Please open an issue on GitHub for:
611
744
 
612
745
  - Analyzer compatibility and version range feedback;
613
746
  - False positives or false negatives in evidence normalization;
@@ -19,13 +19,14 @@ module RailVerdict
19
19
  end
20
20
 
21
21
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
22
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
22
23
  command = @command_resolver.call(repository_root)
23
24
  invocation = Shared.invocation_for(command, ["version"])
24
25
  result = runner.run(
25
26
  command.fetch(:executable),
26
27
  invocation.fetch("argv"),
27
28
  chdir: repository_root,
28
- timeout_seconds: timeout_seconds
29
+ timeout_seconds: effective_timeout
29
30
  )
30
31
 
31
32
  return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
@@ -21,13 +21,14 @@ module RailVerdict
21
21
  end
22
22
 
23
23
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
24
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
24
25
  command = @command_resolver.call(repository_root)
25
26
  probe_argv = probe_argv_for(command, repository_root)
26
27
  result = runner.run(
27
28
  command.fetch(:executable),
28
29
  probe_argv,
29
30
  chdir: repository_root,
30
- timeout_seconds: timeout_seconds
31
+ timeout_seconds: effective_timeout
31
32
  )
32
33
 
33
34
  return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
@@ -63,7 +64,7 @@ module RailVerdict
63
64
  end
64
65
 
65
66
  invocation = Shared.invocation_for(command, ["run"])
66
- output_path = File.join(repository_root, ".railverdict-minitest-#{SecureRandom.hex(6)}.json")
67
+ output_path = File.join(Dir.tmpdir, "railverdict-minitest-#{SecureRandom.hex(8)}.json")
67
68
  env_reset_required = false
68
69
  previous_env = ENV["RAILVERDICT_MINITEST_OUTPUT"]
69
70
  begin
@@ -93,7 +94,25 @@ module RailVerdict
93
94
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
94
95
  end
95
96
 
96
- analyzer_result = AnalyzerResult.new(
97
+ # Process exit reconciliation (RH-03):
98
+ failures_and_errors = (summary["failures"] || 0) + (summary["errors"] || 0)
99
+ if result.exit_code == 0
100
+ if failures_and_errors > 0 || !findings.empty?
101
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: "Minitest exited with status 0 but reported #{failures_and_errors} failures/errors", tool_version: tool_version), []]
102
+ end
103
+ elsif result.exit_code == 1
104
+ if failures_and_errors == 0 && findings.empty?
105
+ detail = Shared.detail_for(result)
106
+ msg = detail.strip.empty? ? "Minitest exited with status 1 but reported 0 failed tests" : detail
107
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "failed", message: msg, tool_version: tool_version), []]
108
+ end
109
+ else
110
+ detail = Shared.detail_for(result)
111
+ msg = detail.strip.empty? ? "Minitest exited with unexpected status #{result.exit_code}" : detail
112
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "failed", message: msg, tool_version: tool_version), []]
113
+ end
114
+
115
+ analyzer_result = AnalyzerResult.new(
97
116
  analyzer: ANALYZER_ID,
98
117
  tool_version: tool_version,
99
118
  invocation: invocation,
@@ -141,16 +160,11 @@ module RailVerdict
141
160
 
142
161
  def resolve_reporter_path
143
162
  candidates = []
144
- begin
145
- specs = Gem::Specification.find_all_by_name("rail_verdict")
146
- if specs.any?
147
- best = specs.max_by(&:version)
148
- candidates << File.join(best.full_gem_path, "exe", "railverdict-minitest-reporter.rb")
149
- end
150
- rescue StandardError
151
- nil
152
- end
153
163
  candidates << File.expand_path("../../../exe/railverdict-minitest-reporter.rb", __dir__)
164
+ if defined?(Gem) && Gem.respond_to?(:loaded_specs) && Gem.loaded_specs["rail_verdict"]
165
+ spec = Gem.loaded_specs["rail_verdict"]
166
+ candidates << File.join(spec.full_gem_path, "exe", "railverdict-minitest-reporter.rb")
167
+ end
154
168
  candidates.find { |path| File.file?(path) && File.readable?(path) }
155
169
  end
156
170
 
@@ -177,31 +191,30 @@ module RailVerdict
177
191
  end
178
192
 
179
193
  def load_reporter_document(output_path, run_result, invocation, tool_version)
180
- if File.file?(output_path)
181
- begin
182
- bytes = File.binread(output_path)
183
- rescue SystemCallError => error
184
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
185
- end
186
- if bytes.bytesize > 4 * 1024 * 1024
187
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: "Minitest reporter output exceeds 4 MiB", tool_version: tool_version), []]
188
- end
189
- text = bytes.dup.force_encoding(Encoding::UTF_8)
190
- unless text.valid_encoding?
191
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: "Minitest reporter output is not valid UTF-8", tool_version: tool_version), []]
192
- end
193
- begin
194
- return JSON.parse(text)
195
- rescue JSON::ParserError => error
196
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
197
- end
194
+ unless File.file?(output_path)
195
+ detail = Shared.detail_for(run_result)
196
+ msg = detail.strip.empty? ? "Minitest reporter did not produce output" : detail
197
+ status = run_result.exit_code && run_result.exit_code != 0 ? "failed" : "malformed"
198
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: status, message: msg, tool_version: tool_version), []]
198
199
  end
199
- stdout = run_result.stdout.to_s
200
- if stdout.strip.empty?
201
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: "Minitest reporter did not produce output", tool_version: tool_version), []]
200
+
201
+ begin
202
+ bytes = File.binread(output_path)
203
+ rescue SystemCallError => error
204
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
202
205
  end
206
+
207
+ if bytes.bytesize > 4 * 1024 * 1024
208
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: "Minitest reporter output exceeds 4 MiB", tool_version: tool_version), []]
209
+ end
210
+
211
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
212
+ unless text.valid_encoding?
213
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: "Minitest reporter output is not valid UTF-8", tool_version: tool_version), []]
214
+ end
215
+
203
216
  begin
204
- JSON.parse(stdout)
217
+ JSON.parse(text)
205
218
  rescue JSON::ParserError => error
206
219
  [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
207
220
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "tmpdir"
5
+ require "securerandom"
4
6
 
5
7
  require_relative "_shared"
6
8
 
@@ -19,13 +21,16 @@ module RailVerdict
19
21
  end
20
22
 
21
23
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
24
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
22
25
  command = @command_resolver.call(repository_root)
23
- invocation = Shared.invocation_for(command, ["--version"])
26
+ clean_prefix = clean_args_prefix(command.fetch(:args_prefix))
27
+ clean_command = command.merge(args_prefix: clean_prefix)
28
+ invocation = Shared.invocation_for(clean_command, ["--version"])
24
29
  result = runner.run(
25
- command.fetch(:executable),
30
+ clean_command.fetch(:executable),
26
31
  invocation.fetch("argv"),
27
32
  chdir: repository_root,
28
- timeout_seconds: timeout_seconds
33
+ timeout_seconds: effective_timeout
29
34
  )
30
35
 
31
36
  return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
@@ -47,61 +52,136 @@ module RailVerdict
47
52
 
48
53
  def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
49
54
  command = @command_resolver.call(repository_root)
55
+ clean_prefix = clean_args_prefix(command.fetch(:args_prefix))
56
+ clean_command = command.merge(args_prefix: clean_prefix)
50
57
  probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
51
- version_invocation = Shared.invocation_for(command, ["--version"])
58
+ version_invocation = Shared.invocation_for(clean_command, ["--version"])
52
59
 
53
60
  unless probe_result.status == "succeeded"
54
61
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: version_invocation, status: probe_result.status, message: probe_result.message, tool_version: probe_result.version), []]
55
62
  end
56
63
 
57
- invocation = Shared.invocation_for(command, ["--format", "json"])
58
- # Real-world large RSpec suites can exceed 4 MiB; use bounded but larger capture.
64
+ output_path = File.join(Dir.tmpdir, "railverdict-rspec-#{SecureRandom.hex(8)}.json")
65
+ public_invocation = Shared.invocation_for(clean_command, ["--format", "json"])
66
+ run_argv = clean_prefix.dup.concat(["--format", "json", "--out", output_path])
67
+
59
68
  max_stdout = resolve_stdout_limit(configuration, repository_root, 16 * 1024 * 1024)
60
- result = runner.run(
61
- command.fetch(:executable),
62
- invocation.fetch("argv"),
63
- chdir: repository_root,
64
- timeout_seconds: timeout_seconds,
65
- max_stdout_bytes: max_stdout
66
- )
67
69
  tool_version = probe_result.version
68
70
 
69
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :spawn_failed
70
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: Shared.detail_for(result), tool_version: tool_version), []] if Shared.truncated?(result)
71
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "timed_out", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :timed_out
72
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "signaled", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :signaled
73
-
74
71
  begin
75
- document = JSON.parse(result.stdout)
76
- rescue JSON::ParserError => error
77
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
72
+ result = runner.run(
73
+ clean_command.fetch(:executable),
74
+ run_argv,
75
+ chdir: repository_root,
76
+ timeout_seconds: timeout_seconds,
77
+ max_stdout_bytes: max_stdout
78
+ )
79
+
80
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "unavailable", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :spawn_failed
81
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "truncated", message: Shared.detail_for(result), tool_version: tool_version), []] if Shared.truncated?(result)
82
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "timed_out", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :timed_out
83
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "signaled", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :signaled
84
+
85
+ unless File.file?(output_path)
86
+ detail = Shared.detail_for(result)
87
+ msg = detail.strip.empty? ? "RSpec did not produce structured output" : detail
88
+ status = result.exit_code && result.exit_code != 0 ? "failed" : "malformed"
89
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: status, message: msg, tool_version: tool_version), []]
90
+ end
91
+
92
+ begin
93
+ bytes = File.binread(output_path)
94
+ rescue SystemCallError => error
95
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
96
+ end
97
+
98
+ if bytes.bytesize > max_stdout
99
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "truncated", message: "RSpec output exceeds #{max_stdout} bytes", tool_version: tool_version), []]
100
+ end
101
+
102
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
103
+ unless text.valid_encoding?
104
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "parse_failed", message: "RSpec output is not valid UTF-8", tool_version: tool_version), []]
105
+ end
106
+
107
+ begin
108
+ document = JSON.parse(text)
109
+ rescue JSON::ParserError => error
110
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
111
+ end
112
+
113
+ begin
114
+ summary, findings = normalize_document(document)
115
+ rescue MalformedOutput => error
116
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
117
+ end
118
+
119
+ # Process exit reconciliation (RH-02):
120
+ # 0: all examples passed, 0 failures/errors
121
+ # 1: failed examples present (failures > 0 or findings non-empty)
122
+ # Any other exit code or contradiction: fail closed
123
+ failures_count = summary["failures"] || 0
124
+ if result.exit_code == 0
125
+ if failures_count > 0 || !findings.empty?
126
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: "RSpec exited with status 0 but reported #{failures_count} failures", tool_version: tool_version), []]
127
+ end
128
+ elsif result.exit_code == 1
129
+ if failures_count == 0 && findings.empty?
130
+ detail = Shared.detail_for(result)
131
+ msg = detail.strip.empty? ? "RSpec exited with status 1 but reported 0 failed examples" : detail
132
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
133
+ end
134
+ else
135
+ detail = Shared.detail_for(result)
136
+ msg = detail.strip.empty? ? "RSpec exited with unexpected status #{result.exit_code}" : detail
137
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
138
+ end
139
+
140
+ analyzer_result = AnalyzerResult.new(
141
+ analyzer: ANALYZER_ID,
142
+ tool_version: tool_version,
143
+ invocation: public_invocation,
144
+ execution_status: "succeeded",
145
+ finding_ids: findings.map(&:id),
146
+ evidence_summary: summary
147
+ )
148
+ [analyzer_result, findings]
149
+ ensure
150
+ begin
151
+ File.unlink(output_path) if output_path && File.exist?(output_path)
152
+ rescue StandardError
153
+ nil
154
+ end
78
155
  end
79
-
80
- begin
81
- summary, findings = normalize_document(document)
82
- rescue MalformedOutput => error
83
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
84
- end
85
-
86
- analyzer_result = AnalyzerResult.new(
87
- analyzer: ANALYZER_ID,
88
- tool_version: tool_version,
89
- invocation: invocation,
90
- execution_status: "succeeded",
91
- finding_ids: findings.map(&:id),
92
- evidence_summary: summary
93
- )
94
- [analyzer_result, findings]
95
156
  end
96
157
 
97
158
  private
98
159
 
99
160
  def default_command(repository_root)
100
161
  if File.file?(File.join(repository_root, "Gemfile"))
101
- { executable: "bundle", args_prefix: ["exec", "rspec", "--format", "json"] }
162
+ { executable: "bundle", args_prefix: ["exec", "rspec"] }
102
163
  else
103
- { executable: "rspec", args_prefix: ["--format", "json"] }
164
+ { executable: "rspec", args_prefix: [] }
165
+ end
166
+ end
167
+
168
+ def clean_args_prefix(prefix)
169
+ cleaned = []
170
+ skip_next = false
171
+ Array(prefix).each_with_index do |arg, i|
172
+ if skip_next
173
+ skip_next = false
174
+ next
175
+ end
176
+ if arg == "--format" && Array(prefix)[i + 1] == "json"
177
+ skip_next = true
178
+ next
179
+ elsif arg == "--format=json"
180
+ next
181
+ end
182
+ cleaned << arg
104
183
  end
184
+ cleaned
105
185
  end
106
186
 
107
187
  def normalize_document(document)
@@ -142,7 +222,7 @@ module RailVerdict
142
222
  raw_msg = (example["exception"] && example["exception"]["message"]) || example["full_description"] || example["description"] || nil
143
223
  message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg.nil? || raw_msg.to_s.strip.empty? ? "rspec example failed" : raw_msg)
144
224
 
145
- rule_id = "rspec/example:#{example['id'] || id_for(example, index)}"
225
+ rule_id = "rspec/example:#{example[id] || id_for(example, index)}"
146
226
  path = normalize_path(example["file_path"] || example["file"] || "spec/unknown_spec.rb")
147
227
  failure_line, failure_path = failure_location(example, path)
148
228
  start_line = failure_line || example["line_number"] || extract_line(example["id"])
@@ -273,14 +353,12 @@ module RailVerdict
273
353
  end
274
354
 
275
355
  def resolve_stdout_limit(configuration, repository_root, default_bytes)
276
- # Prefer explicit per-analyzer config if present (future-compatible), else default.
277
356
  raw_limit = nil
278
357
  if configuration
279
358
  sel = configuration.analyzers[ANALYZER_ID] rescue nil
280
359
  raw_limit = sel && sel["output_limit_bytes"]
281
360
  end
282
361
  raw_limit ||= default_bytes
283
- # Clamp to safe ceiling
284
362
  limit = Integer(raw_limit) rescue default_bytes
285
363
  limit = default_bytes if limit <= 0
286
364
  max = RailVerdict::ProcessRunner::MAX_SAFE_STDOUT_BYTES
@@ -27,13 +27,14 @@ module RailVerdict
27
27
  end
28
28
 
29
29
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
30
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
30
31
  command = @command_resolver.call(repository_root)
31
32
  invocation = invocation_for(command, ["--version"])
32
33
  result = runner.run(
33
34
  command.fetch(:executable),
34
35
  invocation.fetch("argv"),
35
36
  chdir: repository_root,
36
- timeout_seconds: timeout_seconds
37
+ timeout_seconds: effective_timeout
37
38
  )
38
39
 
39
40
  return Probe.new(status: "unavailable", message: detail_for(result)) if result.status == :spawn_failed