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 +4 -4
- data/README.md +151 -18
- data/lib/rail_verdict/analyzers/bundler_audit.rb +2 -1
- data/lib/rail_verdict/analyzers/minitest.rb +47 -34
- data/lib/rail_verdict/analyzers/rspec.rb +120 -42
- data/lib/rail_verdict/analyzers/rubocop.rb +2 -1
- data/lib/rail_verdict/analyzers/simplecov.rb +71 -75
- data/lib/rail_verdict/check.rb +1 -1
- data/lib/rail_verdict/cli.rb +365 -17
- data/lib/rail_verdict/doctor.rb +2 -1
- data/lib/rail_verdict/handoff.rb +122 -0
- data/lib/rail_verdict/mcp/cache.rb +50 -6
- data/lib/rail_verdict/mcp/server.rb +7 -1
- data/lib/rail_verdict/mcp/tools/create_handoff.rb +115 -0
- data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +16 -3
- data/lib/rail_verdict/mcp/tools/get_verification_receipt.rb +17 -3
- data/lib/rail_verdict/mcp/tools/inspect_handoff.rb +72 -0
- data/lib/rail_verdict/mcp/tools/verify_handoff.rb +117 -0
- data/lib/rail_verdict/receipt.rb +163 -22
- data/lib/rail_verdict/reuse.rb +168 -0
- data/lib/rail_verdict/schema_validator.rb +5 -0
- data/lib/rail_verdict/verification_environment.rb +180 -0
- data/lib/rail_verdict/verification_identity.rb +95 -0
- data/lib/rail_verdict/version.rb +1 -1
- data/lib/rail_verdict.rb +4 -0
- data/schemas/receipt-validation-v1.schema.json +6 -0
- data/schemas/verification-handoff-v1.schema.json +69 -0
- data/schemas/verification-receipt-v1.schema.json +1 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 220003ceaeba89b1fb2f8f7082a8a2e6f6dcdcc070005221000602524e3b7576
|
|
4
|
+
data.tar.gz: 13fb2353b109fed755c762be216060174b3a89a59bbc6e443f06d133edbf0537
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
###
|
|
81
|
+
### External Validation Campaign
|
|
82
82
|
|
|
83
|
-
The 1.0
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
clean_command.fetch(:executable),
|
|
26
31
|
invocation.fetch("argv"),
|
|
27
32
|
chdir: repository_root,
|
|
28
|
-
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(
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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"
|
|
162
|
+
{ executable: "bundle", args_prefix: ["exec", "rspec"] }
|
|
102
163
|
else
|
|
103
|
-
{ executable: "rspec", args_prefix: [
|
|
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[
|
|
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:
|
|
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
|