rail_verdict 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/NOTICE +14 -0
- data/README.md +192 -0
- data/exe/railverdict +6 -0
- data/exe/railverdict-minitest-reporter.rb +90 -0
- data/lib/rail_verdict/analyzers/_shared.rb +54 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
- data/lib/rail_verdict/analyzers/minitest.rb +312 -0
- data/lib/rail_verdict/analyzers/rspec.rb +274 -0
- data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
- data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
- data/lib/rail_verdict/baseline.rb +151 -0
- data/lib/rail_verdict/check.rb +415 -0
- data/lib/rail_verdict/cli.rb +471 -0
- data/lib/rail_verdict/comparison.rb +216 -0
- data/lib/rail_verdict/configuration.rb +165 -0
- data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
- data/lib/rail_verdict/contracts/finding.rb +154 -0
- data/lib/rail_verdict/contracts/gate_result.rb +171 -0
- data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
- data/lib/rail_verdict/doctor.rb +132 -0
- data/lib/rail_verdict/errors.rb +17 -0
- data/lib/rail_verdict/findings_command.rb +24 -0
- data/lib/rail_verdict/fingerprint.rb +72 -0
- data/lib/rail_verdict/git/command.rb +48 -0
- data/lib/rail_verdict/git/context.rb +361 -0
- data/lib/rail_verdict/git/diff_parser.rb +200 -0
- data/lib/rail_verdict/git/errors.rb +16 -0
- data/lib/rail_verdict/init.rb +38 -0
- data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
- data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
- data/lib/rail_verdict/intelligence/budget.rb +36 -0
- data/lib/rail_verdict/intelligence/cache.rb +74 -0
- data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
- data/lib/rail_verdict/intelligence/manifest.rb +70 -0
- data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
- data/lib/rail_verdict/intelligence/prompt.rb +28 -0
- data/lib/rail_verdict/intelligence/provider.rb +14 -0
- data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
- data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
- data/lib/rail_verdict/intelligence/redactor.rb +51 -0
- data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
- data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
- data/lib/rail_verdict/intelligence.rb +11 -0
- data/lib/rail_verdict/mcp/cache.rb +233 -0
- data/lib/rail_verdict/mcp/repository_root.rb +31 -0
- data/lib/rail_verdict/mcp/serializers.rb +98 -0
- data/lib/rail_verdict/mcp/server.rb +110 -0
- data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
- data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
- data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
- data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
- data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
- data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
- data/lib/rail_verdict/mcp/validators.rb +81 -0
- data/lib/rail_verdict/mcp.rb +7 -0
- data/lib/rail_verdict/process_runner.rb +292 -0
- data/lib/rail_verdict/rails_context/classifier.rb +57 -0
- data/lib/rail_verdict/rails_context/confidence.rb +13 -0
- data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
- data/lib/rail_verdict/rails_context/context.rb +115 -0
- data/lib/rail_verdict/rails_context/detector.rb +141 -0
- data/lib/rail_verdict/rails_context/limits.rb +17 -0
- data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
- data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
- data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
- data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
- data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
- data/lib/rail_verdict/rails_context.rb +21 -0
- data/lib/rail_verdict/repair/boundary.rb +36 -0
- data/lib/rail_verdict/repair/command.rb +89 -0
- data/lib/rail_verdict/repair/constraints.rb +31 -0
- data/lib/rail_verdict/repair/context_assembler.rb +301 -0
- data/lib/rail_verdict/repair/diff_context.rb +51 -0
- data/lib/rail_verdict/repair/packet.rb +158 -0
- data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
- data/lib/rail_verdict/repair/verification_plan.rb +45 -0
- data/lib/rail_verdict/repair/verifier.rb +84 -0
- data/lib/rail_verdict/reporters/console.rb +107 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
- data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
- data/lib/rail_verdict/reporters/sarif.rb +138 -0
- data/lib/rail_verdict/run_context.rb +99 -0
- data/lib/rail_verdict/schema_validator.rb +85 -0
- data/lib/rail_verdict/strict_yaml.rb +134 -0
- data/lib/rail_verdict/verification/policy.rb +359 -0
- data/lib/rail_verdict/version.rb +5 -0
- data/lib/rail_verdict/waiver.rb +67 -0
- data/lib/rail_verdict/waiver_store.rb +59 -0
- data/lib/rail_verdict.rb +64 -0
- data/schemas/ai-analysis-v1.schema.json +54 -0
- data/schemas/baseline-v1.schema.json +108 -0
- data/schemas/configuration-v1.1.schema.json +150 -0
- data/schemas/configuration-v1.2.schema.json +182 -0
- data/schemas/configuration-v1.3.schema.json +198 -0
- data/schemas/configuration-v1.4.schema.json +146 -0
- data/schemas/configuration-v1.schema.json +72 -0
- data/schemas/coverage-v1.schema.json +51 -0
- data/schemas/finding-v1.schema.json +131 -0
- data/schemas/minitest-reporter-v1.schema.json +113 -0
- data/schemas/repair-packet-v1.schema.json +364 -0
- data/schemas/result-v1.schema.json +378 -0
- data/schemas/waiver-v1.schema.json +45 -0
- data/schemas/waivers-v1.schema.json +67 -0
- metadata +192 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1e670a01af3af01cfa3698b4842130b0a14368bfb08215536233bdd3423ef246
|
|
4
|
+
data.tar.gz: dae1e03f62fdc014fe37475a6299b1679113ee4ba0bebdadf7d46e883234bd66
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c512dc731422fff707233b0ebfeadd7dd85e6a846f19bfa6958be98905c8946d11ef1d85206e6ec7aa9952b12498ab39ca0c9b2f89d8c7c227ec10be53475fd1
|
|
7
|
+
data.tar.gz: c4b08aae8dd9e704df651737cb9a96fbe1adfab763c44fe56bb4cabd6fb71dd5fb2211e0cc786e8686dd3b379891f8ca8f0b28064235ae10bc8c118893c003cf
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RailVerdict Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/NOTICE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
RailVerdict
|
|
2
|
+
Copyright (c) 2026 Pedro Dalben
|
|
3
|
+
|
|
4
|
+
This product is licensed under the MIT License. See LICENSE for details.
|
|
5
|
+
Third-party dependencies and analyzer tools retain their own licenses;
|
|
6
|
+
see docs/analyzers.md and the NOTICE of any distributed artifact for details.
|
|
7
|
+
|
|
8
|
+
Copyright holder: Pedro Dalben
|
|
9
|
+
Trademark/project owner: Pedro Dalben (no registration claimed; qualified review not performed — non-blocking by maintainer decision 2026-08-19, Pedro Dalben — residual risk accepted)
|
|
10
|
+
Security contact: dev@pedrodalben.com.br
|
|
11
|
+
Legal/trademark contact: dev@pedrodalben.com.br
|
|
12
|
+
Repository: https://github.com/pedro-dalben/RailVerdict
|
|
13
|
+
|
|
14
|
+
Qualified trademark/name review remains NOT PERFORMED — NON-BLOCKING BY MAINTAINER DECISION for initial open-source publication; recommended but not mandatory. Preliminary screen 2026-08-19: no obvious conflict observed; NOT LEGAL CLEARANCE. See docs/foundation.md.
|
data/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# RailVerdict
|
|
2
|
+
|
|
3
|
+
**Evidence before merge. Deterministic, offline, fail-closed verification for Ruby on Rails.**
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/rail_verdict)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://www.ruby-lang.org)
|
|
8
|
+
|
|
9
|
+
RailVerdict collects evidence from established Ruby/Rails quality tools, normalizes it into versioned canonical findings, applies repository-owned policy and baselines, and returns a deterministic merge gate for **humans, CI, and coding agents**.
|
|
10
|
+
|
|
11
|
+
> **One command. One gate.** No SaaS, no accounts, no telemetry, no hosted service. Core verification is fully offline.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why RailVerdict?
|
|
16
|
+
|
|
17
|
+
Existing tools give fragmented signals — lint, tests, coverage, dependencies. RailVerdict's value is the **stable verification model** that makes those signals *comparable, policy-addressable, and machine-readable* with one deterministic `PASS / WARN / FAIL / INCOMPLETE`.
|
|
18
|
+
|
|
19
|
+
| Problem | How RailVerdict helps |
|
|
20
|
+
|---|---|
|
|
21
|
+
| "Passing" CI that hid incomplete evidence | Incomplete required analyzers can never become `PASS` (`INCOMPLETE` / exit 2) |
|
|
22
|
+
| Legacy debt blocks adoption | Versioned fingerprints + baselines + `no_new_debt` — existing debt retained, only regressions block |
|
|
23
|
+
| Untrusted PR code near secrets | `--changed` from trustworthy local Git facts; safe GitHub Actions example with minimum permissions |
|
|
24
|
+
| Agents scraping terminal output | Versioned JSON (`result-v1`) + stable exit codes + canonical ordering |
|
|
25
|
+
| "Where is this finding related?" | Bounded Rails-aware context (models, routes, views, policies, schema, associations) without booting Rails |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
gem install rail_verdict
|
|
33
|
+
|
|
34
|
+
# In a Rails (or synthetic) repository:
|
|
35
|
+
railverdict init # writes .railverdict.yml (mode: no_new_debt)
|
|
36
|
+
railverdict doctor # validates config, probes analyzers
|
|
37
|
+
# no_new_debt without a baseline → INCOMPLETE (baseline_required); create a baseline first:
|
|
38
|
+
railverdict check # full verification → console (or INCOMPLETE baseline_required)
|
|
39
|
+
railverdict baseline create # atomic baseline from a complete run (requires PASS/WARN/FAIL, not INCOMPLETE)
|
|
40
|
+
railverdict check --format json # versioned JSON on stdout, diagnostics on stderr
|
|
41
|
+
railverdict check --changed --base main # changed-scope gate from deterministic Git diff
|
|
42
|
+
railverdict findings --format json # projection of normalized findings
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Exit codes:** `0` PASS/WARN · `1` FAIL · `2` INCOMPLETE/config/tool error · `130` interrupted.
|
|
46
|
+
|
|
47
|
+
**Notes:** RailVerdict does NOT bundle or install analyzers, boot Rails, update bundler-audit DB, require AI or MCP. Configure/install desired analyzers in the target bundle, then `railverdict doctor` shows status. With `mode: no_new_debt`, `check` returns `INCOMPLETE baseline_required` until you run `baseline create`.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## What Runs
|
|
52
|
+
|
|
53
|
+
All analyzers are **external and target-project-owned** — RailVerdict never installs or bundles them.
|
|
54
|
+
|
|
55
|
+
| Adapter | What it consumes | Supported versions |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| **RuboCop** (+ `rubocop-rails` provenance) | `bundle exec rubocop --format json` | RuboCop `>= 1.72, < 2` · `rubocop-rails >= 2, < 3` |
|
|
58
|
+
| **Minitest** | RailVerdict-owned reporter → `minitest-reporter-v1` JSON | `>= 5, < 7` |
|
|
59
|
+
| **RSpec** | `--format json` | `>= 3.13, < 4` |
|
|
60
|
+
| **SimpleCov** | Public `coverage/coverage.json` v1 (never `.resultset.json`/HTML) | `>= 1, < 2` |
|
|
61
|
+
| **bundler-audit** | `check --format json` (never `update`) | `>= 0.9.3, < 1` |
|
|
62
|
+
|
|
63
|
+
Each adapter maps the full failure corpus (`unavailable`, `unsupported`, `timed_out`, `signaled`, `failed`, `parse_failed`, `truncated`, `malformed`) and records tool provenance.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Core Concepts
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
AnalyzerResult (what a tool did)
|
|
71
|
+
│
|
|
72
|
+
▼
|
|
73
|
+
Finding (analyzer-independent, fingerprinted, versioned)
|
|
74
|
+
│
|
|
75
|
+
▼
|
|
76
|
+
Comparison (introduced / existing / resolved / changed / moved · rename-aware)
|
|
77
|
+
│
|
|
78
|
+
▼
|
|
79
|
+
Policy (advisory · no_new_debt · strict) ──► GateResult (immutable)
|
|
80
|
+
│ │
|
|
81
|
+
▼ ▼
|
|
82
|
+
Console / JSON / SARIF / GitHub Annotations exit code
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- **Fingerprint v1** — canonical payload `sha256:<64hex>` over `{analyzer, rule_id, path, message}` (line/timestamp/path-order agnostic).
|
|
86
|
+
- **Baselines** — versioned, atomic (`fsync` + `rename`), read-only checks never mutate them.
|
|
87
|
+
- **Waivers** — exact-fingerprint, with `owner`, `reason`, `created_at`, UTC `expires_at`, optional `issue_ref`.
|
|
88
|
+
- **Comparison** — `moved` = same rule+message, different path (rename-aware); `changed` = same path+rule, different message; `introduced`/`resolved` are fallback.
|
|
89
|
+
- **Policy** — the *only* gate authority; adapters/reporters/AI can never change it. `required: true` incomplete → `INCOMPLETE`, not `PASS`.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Changed Scope & GitHub
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
railverdict check --changed --base origin/main # PR-like
|
|
97
|
+
railverdict check --changed --base HEAD~1 --format json # local diff
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- Resolves `HEAD`, `base` (`--base` > `git.base` in `.railverdict.yml`), `merge-base`, NUL-safe changed files/lines, renames, binaries, conflicts.
|
|
101
|
+
- Missing base or shallow/incomplete history → `INCOMPLETE` (`git_scope_failed`) — never guesses.
|
|
102
|
+
- Production changed-line coverage uses the same `changed_line_set` recorded in `RunContext`.
|
|
103
|
+
|
|
104
|
+
**GitHub Actions** — see [`docs/github-actions.md`](docs/github-actions.md) and [`examples/github/railverdict.yml`](examples/github/railverdict.yml): `pull_request` (never `pull_request_target`), `contents: read`, `fetch-depth: 0` at `head.sha`, same local gate invoked as `bundle exec railverdict check --changed --base ${{ github.event.pull_request.base.sha }}`. SARIF and annotation projections are pure `GateResult` projections.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Rails-Aware Context (Phase 05)
|
|
109
|
+
|
|
110
|
+
When `--changed` is used, RailVerdict enriches the result with **bounded, deterministic Rails relationships** — without booting the app, loading ActiveRecord, executing `routes.rb`/`schema.rb`, or building a code graph.
|
|
111
|
+
|
|
112
|
+
For each **changed** file, it classifies `kind` + `constant` and resolves:
|
|
113
|
+
|
|
114
|
+
- **Related tests** — `test/**/…_test.rb` / `spec/**/…_spec.rb` that physically exist (candidates, not "affected" guarantees).
|
|
115
|
+
- **Policies** — `app/policies/<model>_policy.rb`.
|
|
116
|
+
- **Views** — `app/views/<controller>/…` (bounded, deterministic).
|
|
117
|
+
- **Routes** — literal `resources` / `get … to: '…#…'` mappings from `config/routes.rb` (`draw`/`mount`/`concerns` → `unresolved`).
|
|
118
|
+
- **Schema** — `db/schema.rb` table fragments (`db/structure.sql` → `unresolved`).
|
|
119
|
+
- **Associations** — literal `belongs_to` / `has_one` / `has_many` / `has_and_belongs_to_many`.
|
|
120
|
+
|
|
121
|
+
Every related item carries `confidence` in `{exact, conventional, inferred, unresolved}` and a `provenance` string. Failures degrade to partial context, never to `INCOMPLETE`. Full details: [`docs/rails-context.md`](docs/rails-context.md).
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
"rails_context": {
|
|
125
|
+
"detected": { "rails_version": "8.0.1", "test_framework": "rspec", "database_adapter": "postgresql" },
|
|
126
|
+
"scope": "changed",
|
|
127
|
+
"entries": [{ "source_path": "app/models/user.rb", "kind": "model", "constant": "User", "related": […] }]
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Configuration
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
# .railverdict.yml (v1.3)
|
|
137
|
+
version: 1.3
|
|
138
|
+
mode: no_new_debt # advisory | no_new_debt | strict
|
|
139
|
+
analyzers:
|
|
140
|
+
rubocop: { enabled: true, required: true }
|
|
141
|
+
minitest: { enabled: true, required: true }
|
|
142
|
+
rspec: { enabled: true, required: false }
|
|
143
|
+
simplecov: { enabled: true, required: false, coverage_path: coverage/coverage.json, freshness_window_seconds: 86400 }
|
|
144
|
+
bundler_audit: { enabled: true, required: false }
|
|
145
|
+
git:
|
|
146
|
+
base: main # fallback for --changed when --base is not passed
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Strict, versioned schemas: `configuration-v1` … `v1.3`, `finding-v1`, `result-v1`, `baseline-v1`, `waivers-v1`, `coverage-v1`. Unknown fields fail with a property path. See [`docs/contracts.md`](docs/contracts.md).
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Documentation
|
|
154
|
+
|
|
155
|
+
| Topic | File |
|
|
156
|
+
|---|---|
|
|
157
|
+
| Product, philosophy, architecture | [`PROJECT.md`](PROJECT.md) · [`PHILOSOPHY.md`](PHILOSOPHY.md) · [`ARCHITECTURE.md`](ARCHITECTURE.md) |
|
|
158
|
+
| Public contracts & CLI surface | [`docs/contracts.md`](docs/contracts.md) |
|
|
159
|
+
| Analyzers & support proposal | [`docs/analyzers.md`](docs/analyzers.md) |
|
|
160
|
+
| Baselines, comparison, waivers | [`docs/baselines.md`](docs/baselines.md) |
|
|
161
|
+
| GitHub Actions & SARIF | [`docs/github-actions.md`](docs/github-actions.md) |
|
|
162
|
+
| Rails-aware context | [`docs/rails-context.md`](docs/rails-context.md) |
|
|
163
|
+
| MCP (agents) | [`docs/mcp.md`](docs/mcp.md) · [`examples/mcp/client_config.json`](examples/mcp/client_config.json) |
|
|
164
|
+
| AI & privacy | [`docs/ai.md`](docs/ai.md) · [`docs/privacy.md`](docs/privacy.md) |
|
|
165
|
+
| Repair workflow | [`docs/repair-workflow.md`](docs/repair-workflow.md) |
|
|
166
|
+
| Security & information firewall | [`SECURITY.md`](SECURITY.md) |
|
|
167
|
+
| Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
|
|
168
|
+
| Roadmap (Phase 0–9) | [`ROADMAP.md`](ROADMAP.md) |
|
|
169
|
+
| ADRs | [`docs/adr/`](docs/adr/) |
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
bundle install
|
|
177
|
+
bundle exec rake test # synthetic fixtures only, deterministic
|
|
178
|
+
bundle exec rubocop
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- Ruby `>= 3.3`, one gem, one process, `json_schemer` as sole runtime dependency.
|
|
182
|
+
- External execution via `executable + argv` (no shell interpolation), bounded I/O, monotonic timeout, process-group cleanup, minimal env, NFC-normalized paths.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Legal
|
|
187
|
+
|
|
188
|
+
- **License:** [MIT](LICENSE) — see [NOTICE](NOTICE)
|
|
189
|
+
- **Trademarks:** [TRADEMARKS.md](TRADEMARKS.md)
|
|
190
|
+
- **Foundation:** [docs/foundation.md](docs/foundation.md) — name evidence, identity mapping, preliminary screen (no obvious conflict; NOT LEGAL CLEARANCE), qualified trademark review NOT PERFORMED — NON-BLOCKING BY MAINTAINER DECISION 2026-08-19 (Pedro Dalben)
|
|
191
|
+
- **Schemas:** [finding v1](schemas/finding-v1.schema.json), [configuration v1](schemas/configuration-v1.schema.json), [result v1](schemas/result-v1.schema.json)
|
|
192
|
+
- **Examples:** [finding](examples/finding-v1.json), [configuration](examples/configuration-v1.yml), [result](examples/result-v1.json)
|
data/exe/railverdict
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "minitest"
|
|
6
|
+
|
|
7
|
+
class RailverdictMinitestReporter < Minitest::AbstractReporter
|
|
8
|
+
def initialize(output_path)
|
|
9
|
+
@output_path = output_path
|
|
10
|
+
@results = []
|
|
11
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record(result)
|
|
15
|
+
status =
|
|
16
|
+
if result.skipped?
|
|
17
|
+
"skipped"
|
|
18
|
+
elsif result.error?
|
|
19
|
+
"errored"
|
|
20
|
+
elsif result.failure
|
|
21
|
+
"failed"
|
|
22
|
+
else
|
|
23
|
+
"passed"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
failure = result.failure
|
|
27
|
+
entry = {
|
|
28
|
+
"class_name" => result.klass.to_s,
|
|
29
|
+
"method_name" => result.name.to_s,
|
|
30
|
+
"status" => status,
|
|
31
|
+
"time_seconds" => result.time.to_f.round(6)
|
|
32
|
+
}
|
|
33
|
+
if failure
|
|
34
|
+
entry["failure_message"] = failure.message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
|
|
35
|
+
entry["failure_class"] = failure.class.name.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 256]
|
|
36
|
+
loc = failure.location.to_s
|
|
37
|
+
captures = loc.match(/\A(.+):(\d+)(\z|:\d+:\z)/)&.captures
|
|
38
|
+
file = captures && captures[0]
|
|
39
|
+
line_str = captures && captures[1]
|
|
40
|
+
if file && line_str
|
|
41
|
+
line = Integer(line_str, 10) rescue nil
|
|
42
|
+
if line && line >= 1
|
|
43
|
+
entry["file"] = file.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 1024]
|
|
44
|
+
entry["line"] = line
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
@results << entry
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def report
|
|
52
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time).round(6)
|
|
53
|
+
runner = Minitest::VERSION ? "minitest #{Minitest::VERSION}" : "minitest unknown"
|
|
54
|
+
seed = Minitest.respond_to?(:seed) ? Minitest.seed : nil
|
|
55
|
+
failures = @results.count { |r| r["status"] == "failed" }
|
|
56
|
+
errors = @results.count { |r| r["status"] == "errored" }
|
|
57
|
+
skips = @results.count { |r| r["status"] == "skipped" }
|
|
58
|
+
assertions = @results.count { |r| %w[passed failed errored].include?(r["status"]) }
|
|
59
|
+
seed_val = seed && Integer(seed) rescue nil
|
|
60
|
+
@results.sort_by! { |t| [t["class_name"], t["method_name"]] }
|
|
61
|
+
document = {
|
|
62
|
+
"schema_version" => "1.0",
|
|
63
|
+
"runner" => runner,
|
|
64
|
+
"seed" => seed_val,
|
|
65
|
+
"tests_total" => @results.length,
|
|
66
|
+
"assertions" => assertions,
|
|
67
|
+
"failures" => failures,
|
|
68
|
+
"errors" => errors,
|
|
69
|
+
"skips" => skips,
|
|
70
|
+
"duration_seconds" => duration,
|
|
71
|
+
"tests" => @results
|
|
72
|
+
}
|
|
73
|
+
File.write(@output_path, JSON.generate(document))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
output_path = ENV["RAILVERDICT_MINITEST_OUTPUT"].to_s
|
|
78
|
+
unless output_path.empty?
|
|
79
|
+
Minitest.load_plugins
|
|
80
|
+
Minitest.extensions << :railverdict
|
|
81
|
+
module Minitest
|
|
82
|
+
class << self
|
|
83
|
+
def plugin_railverdict_init(options)
|
|
84
|
+
self.reporter << RailverdictMinitestReporter.new(ENV["RAILVERDICT_MINITEST_OUTPUT"].to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def plugin_railverdict_options(opts, options); end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Shared
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def bounded_message(message)
|
|
9
|
+
message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def detail_for(run_result)
|
|
13
|
+
detail = run_result.detail.to_s
|
|
14
|
+
stderr = run_result.stderr.to_s.lines.first.to_s.strip
|
|
15
|
+
bounded_message([detail, stderr].reject(&:empty?).join(": "))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def execution_message(run_result)
|
|
19
|
+
message = run_result.stderr.to_s.lines.first.to_s.strip
|
|
20
|
+
message = "#{run_result.detail}" if message.empty?
|
|
21
|
+
message = "execution failed with status #{run_result.exit_code}" if message.empty?
|
|
22
|
+
bounded_message(message)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def truncated?(run_result)
|
|
26
|
+
run_result.stdout_truncated || run_result.stderr_truncated
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse_semver(output)
|
|
30
|
+
match = output.to_s.match(/\b(\d+\.\d+\.\d+)\b/)
|
|
31
|
+
match && match[1]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def invocation_for(command, arguments)
|
|
35
|
+
{
|
|
36
|
+
"executable" => command.fetch(:executable),
|
|
37
|
+
"argv" => command.fetch(:args_prefix).dup.concat(arguments)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failure_result(analyzer_id:, invocation:, status:, message:, tool_version: nil)
|
|
42
|
+
status = status.to_s
|
|
43
|
+
AnalyzerResult.new(
|
|
44
|
+
analyzer: analyzer_id,
|
|
45
|
+
tool_version: tool_version,
|
|
46
|
+
invocation: invocation,
|
|
47
|
+
execution_status: status,
|
|
48
|
+
finding_ids: [],
|
|
49
|
+
failure: { "code" => status, "message" => bounded_message(message.to_s) }
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "_shared"
|
|
6
|
+
|
|
7
|
+
module RailVerdict
|
|
8
|
+
module Analyzers
|
|
9
|
+
class BundlerAudit
|
|
10
|
+
ANALYZER_ID = "bundler_audit"
|
|
11
|
+
SUPPORTED_VERSIONS = Gem::Requirement.new(">= 0.9.3", "< 1")
|
|
12
|
+
|
|
13
|
+
Probe = Struct.new(:status, :version, :message, :database_updated_at, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
class MalformedOutput < StandardError; end
|
|
16
|
+
|
|
17
|
+
def initialize(command_resolver: nil)
|
|
18
|
+
@command_resolver = command_resolver || method(:default_command)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
|
|
22
|
+
command = @command_resolver.call(repository_root)
|
|
23
|
+
invocation = Shared.invocation_for(command, ["version"])
|
|
24
|
+
result = runner.run(
|
|
25
|
+
command.fetch(:executable),
|
|
26
|
+
invocation.fetch("argv"),
|
|
27
|
+
chdir: repository_root,
|
|
28
|
+
timeout_seconds: timeout_seconds
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
|
|
32
|
+
return Probe.new(status: "timed_out", message: Shared.detail_for(result)) if result.status == :timed_out
|
|
33
|
+
return Probe.new(status: "signaled", message: Shared.detail_for(result)) if result.status == :signaled
|
|
34
|
+
return Probe.new(status: "truncated", message: Shared.detail_for(result)) if Shared.truncated?(result)
|
|
35
|
+
return Probe.new(status: "unavailable", message: Shared.detail_for(result)) unless result.exit_code == 0
|
|
36
|
+
|
|
37
|
+
version = Shared.parse_semver(result.stdout)
|
|
38
|
+
return Probe.new(status: "unsupported", message: "bundler-audit version could not be parsed") unless version
|
|
39
|
+
return Probe.new(status: "unsupported", version: version, message: "unsupported bundler-audit version #{version}") unless SUPPORTED_VERSIONS.satisfied_by?(Gem::Version.new(version))
|
|
40
|
+
|
|
41
|
+
db_updated = parse_database_updated(result.stdout)
|
|
42
|
+
|
|
43
|
+
Probe.new(status: "succeeded", version: version, database_updated_at: db_updated)
|
|
44
|
+
rescue ArgumentError
|
|
45
|
+
Probe.new(status: "unsupported", message: "bundler-audit reported an invalid version")
|
|
46
|
+
rescue KeyError, ArgumentError => error
|
|
47
|
+
Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
|
|
51
|
+
command = @command_resolver.call(repository_root)
|
|
52
|
+
probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
|
|
53
|
+
version_invocation = Shared.invocation_for(command, ["version"])
|
|
54
|
+
|
|
55
|
+
unless probe_result.status == "succeeded"
|
|
56
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: version_invocation, status: probe_result.status, message: probe_result.message, tool_version: probe_result.version), []]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
invocation = Shared.invocation_for(command, ["check", "--format", "json"])
|
|
60
|
+
result = runner.run(
|
|
61
|
+
command.fetch(:executable),
|
|
62
|
+
invocation.fetch("argv"),
|
|
63
|
+
chdir: repository_root,
|
|
64
|
+
timeout_seconds: timeout_seconds
|
|
65
|
+
)
|
|
66
|
+
tool_version = probe_result.version
|
|
67
|
+
|
|
68
|
+
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
|
|
69
|
+
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)
|
|
70
|
+
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
|
|
71
|
+
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
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
document = JSON.parse(result.stdout)
|
|
75
|
+
rescue JSON::ParserError => error
|
|
76
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
summary, findings = normalize_document(document, tool_version)
|
|
81
|
+
rescue MalformedOutput => error
|
|
82
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
analyzer_result = AnalyzerResult.new(
|
|
86
|
+
analyzer: ANALYZER_ID,
|
|
87
|
+
tool_version: tool_version,
|
|
88
|
+
invocation: invocation,
|
|
89
|
+
execution_status: "succeeded",
|
|
90
|
+
finding_ids: findings.map(&:id),
|
|
91
|
+
evidence_summary: summary
|
|
92
|
+
)
|
|
93
|
+
[analyzer_result, findings]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def default_command(repository_root)
|
|
99
|
+
if File.file?(File.join(repository_root, "Gemfile"))
|
|
100
|
+
{ executable: "bundle", args_prefix: ["exec", "bundler-audit"] }
|
|
101
|
+
else
|
|
102
|
+
{ executable: "bundler-audit", args_prefix: [] }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_database_updated(output)
|
|
107
|
+
match = output.to_s.match(/database.*?(\d{4}-\d{2}-\d{2})/i)
|
|
108
|
+
match && match[1]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def normalize_document(document, tool_version)
|
|
112
|
+
raise MalformedOutput, "bundler-audit JSON root must be an object" unless document.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
results = document["results"] || document["vulnerabilities"] || document["advisories"] || []
|
|
115
|
+
raise MalformedOutput, "bundler-audit results must be an array" unless results.is_a?(Array)
|
|
116
|
+
|
|
117
|
+
findings = []
|
|
118
|
+
results.each_with_index do |entry, index|
|
|
119
|
+
finding = normalize_entry(entry, index)
|
|
120
|
+
findings << finding if finding
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
findings = findings.uniq { |f| f.fingerprint }.sort_by(&:sort_key)
|
|
124
|
+
summary = {
|
|
125
|
+
"vulnerabilities" => findings.length,
|
|
126
|
+
"tool_version" => tool_version.to_s[0, 64],
|
|
127
|
+
"database_updated_at" => parse_database_updated(document.to_s)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
[summary, findings]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def normalize_entry(entry, index)
|
|
134
|
+
raise MalformedOutput, "bundler-audit entry #{index} must be an object" unless entry.is_a?(Hash)
|
|
135
|
+
|
|
136
|
+
advisory = entry["advisory"] || entry
|
|
137
|
+
id = (advisory["id"] || entry["id"] || "advisory-#{index}").to_s
|
|
138
|
+
id = "advisory-#{index}" if id.empty?
|
|
139
|
+
gem_name = (entry["gem"] && entry["gem"]["name"]) || entry["gem_name"] || advisory["gem"] || "unknown"
|
|
140
|
+
gem_name = "unknown" if gem_name.to_s.empty?
|
|
141
|
+
|
|
142
|
+
severity = map_severity(advisory["criticality"] || advisory["severity"] || entry["criticality"] || entry["severity"])
|
|
143
|
+
message = (advisory["title"] || advisory["description"] || entry["title"] || entry["description"] || "vulnerability in #{gem_name}").to_s
|
|
144
|
+
message = message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
|
|
145
|
+
message = "vulnerability in #{gem_name}" if message.empty?
|
|
146
|
+
|
|
147
|
+
rule_id = "bundler_audit/advisory:#{id}"
|
|
148
|
+
category = "dependency"
|
|
149
|
+
|
|
150
|
+
path = "Gemfile.lock"
|
|
151
|
+
fingerprint = Finding.fingerprint_for(
|
|
152
|
+
analyzer: ANALYZER_ID,
|
|
153
|
+
rule_id: rule_id,
|
|
154
|
+
path: path,
|
|
155
|
+
message: message
|
|
156
|
+
)
|
|
157
|
+
Finding.new(
|
|
158
|
+
fingerprint: fingerprint,
|
|
159
|
+
origin: "deterministic",
|
|
160
|
+
analyzer: ANALYZER_ID,
|
|
161
|
+
rule_id: rule_id,
|
|
162
|
+
category: category,
|
|
163
|
+
severity: severity,
|
|
164
|
+
confidence: "high",
|
|
165
|
+
state: "observed",
|
|
166
|
+
evidence_ref: "native:bundler_audit:#{fingerprint.delete_prefix("sha256:")[0, 12]}",
|
|
167
|
+
location: { "path" => path },
|
|
168
|
+
message: message
|
|
169
|
+
)
|
|
170
|
+
rescue ArgumentError => error
|
|
171
|
+
raise MalformedOutput, "bundler-audit entry #{index} is malformed: #{error.message}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def map_severity(raw)
|
|
175
|
+
case raw.to_s.downcase
|
|
176
|
+
when "critical" then "critical"
|
|
177
|
+
when "high" then "high"
|
|
178
|
+
when "medium" then "medium"
|
|
179
|
+
when "low" then "low"
|
|
180
|
+
when "info", "unknown", "" then "medium"
|
|
181
|
+
else "medium"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|