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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/NOTICE +14 -0
  4. data/README.md +192 -0
  5. data/exe/railverdict +6 -0
  6. data/exe/railverdict-minitest-reporter.rb +90 -0
  7. data/lib/rail_verdict/analyzers/_shared.rb +54 -0
  8. data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
  9. data/lib/rail_verdict/analyzers/minitest.rb +312 -0
  10. data/lib/rail_verdict/analyzers/rspec.rb +274 -0
  11. data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
  12. data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
  13. data/lib/rail_verdict/baseline.rb +151 -0
  14. data/lib/rail_verdict/check.rb +415 -0
  15. data/lib/rail_verdict/cli.rb +471 -0
  16. data/lib/rail_verdict/comparison.rb +216 -0
  17. data/lib/rail_verdict/configuration.rb +165 -0
  18. data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
  19. data/lib/rail_verdict/contracts/finding.rb +154 -0
  20. data/lib/rail_verdict/contracts/gate_result.rb +171 -0
  21. data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
  22. data/lib/rail_verdict/doctor.rb +132 -0
  23. data/lib/rail_verdict/errors.rb +17 -0
  24. data/lib/rail_verdict/findings_command.rb +24 -0
  25. data/lib/rail_verdict/fingerprint.rb +72 -0
  26. data/lib/rail_verdict/git/command.rb +48 -0
  27. data/lib/rail_verdict/git/context.rb +361 -0
  28. data/lib/rail_verdict/git/diff_parser.rb +200 -0
  29. data/lib/rail_verdict/git/errors.rb +16 -0
  30. data/lib/rail_verdict/init.rb +38 -0
  31. data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
  32. data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
  33. data/lib/rail_verdict/intelligence/budget.rb +36 -0
  34. data/lib/rail_verdict/intelligence/cache.rb +74 -0
  35. data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
  36. data/lib/rail_verdict/intelligence/manifest.rb +70 -0
  37. data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
  38. data/lib/rail_verdict/intelligence/prompt.rb +28 -0
  39. data/lib/rail_verdict/intelligence/provider.rb +14 -0
  40. data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
  41. data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
  42. data/lib/rail_verdict/intelligence/redactor.rb +51 -0
  43. data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
  44. data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
  45. data/lib/rail_verdict/intelligence.rb +11 -0
  46. data/lib/rail_verdict/mcp/cache.rb +233 -0
  47. data/lib/rail_verdict/mcp/repository_root.rb +31 -0
  48. data/lib/rail_verdict/mcp/serializers.rb +98 -0
  49. data/lib/rail_verdict/mcp/server.rb +110 -0
  50. data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
  51. data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
  52. data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
  53. data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
  54. data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
  55. data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
  56. data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
  57. data/lib/rail_verdict/mcp/validators.rb +81 -0
  58. data/lib/rail_verdict/mcp.rb +7 -0
  59. data/lib/rail_verdict/process_runner.rb +292 -0
  60. data/lib/rail_verdict/rails_context/classifier.rb +57 -0
  61. data/lib/rail_verdict/rails_context/confidence.rb +13 -0
  62. data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
  63. data/lib/rail_verdict/rails_context/context.rb +115 -0
  64. data/lib/rail_verdict/rails_context/detector.rb +141 -0
  65. data/lib/rail_verdict/rails_context/limits.rb +17 -0
  66. data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
  67. data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
  68. data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
  69. data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
  70. data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
  71. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
  72. data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
  73. data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
  74. data/lib/rail_verdict/rails_context.rb +21 -0
  75. data/lib/rail_verdict/repair/boundary.rb +36 -0
  76. data/lib/rail_verdict/repair/command.rb +89 -0
  77. data/lib/rail_verdict/repair/constraints.rb +31 -0
  78. data/lib/rail_verdict/repair/context_assembler.rb +301 -0
  79. data/lib/rail_verdict/repair/diff_context.rb +51 -0
  80. data/lib/rail_verdict/repair/packet.rb +158 -0
  81. data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
  82. data/lib/rail_verdict/repair/verification_plan.rb +45 -0
  83. data/lib/rail_verdict/repair/verifier.rb +84 -0
  84. data/lib/rail_verdict/reporters/console.rb +107 -0
  85. data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
  86. data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
  87. data/lib/rail_verdict/reporters/sarif.rb +138 -0
  88. data/lib/rail_verdict/run_context.rb +99 -0
  89. data/lib/rail_verdict/schema_validator.rb +85 -0
  90. data/lib/rail_verdict/strict_yaml.rb +134 -0
  91. data/lib/rail_verdict/verification/policy.rb +359 -0
  92. data/lib/rail_verdict/version.rb +5 -0
  93. data/lib/rail_verdict/waiver.rb +67 -0
  94. data/lib/rail_verdict/waiver_store.rb +59 -0
  95. data/lib/rail_verdict.rb +64 -0
  96. data/schemas/ai-analysis-v1.schema.json +54 -0
  97. data/schemas/baseline-v1.schema.json +108 -0
  98. data/schemas/configuration-v1.1.schema.json +150 -0
  99. data/schemas/configuration-v1.2.schema.json +182 -0
  100. data/schemas/configuration-v1.3.schema.json +198 -0
  101. data/schemas/configuration-v1.4.schema.json +146 -0
  102. data/schemas/configuration-v1.schema.json +72 -0
  103. data/schemas/coverage-v1.schema.json +51 -0
  104. data/schemas/finding-v1.schema.json +131 -0
  105. data/schemas/minitest-reporter-v1.schema.json +113 -0
  106. data/schemas/repair-packet-v1.schema.json +364 -0
  107. data/schemas/result-v1.schema.json +378 -0
  108. data/schemas/waiver-v1.schema.json +45 -0
  109. data/schemas/waivers-v1.schema.json +67 -0
  110. 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
+ [![Gem Version](https://badge.fury.io/rb/rail_verdict.svg)](https://rubygems.org/gems/rail_verdict)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-CC342D.svg)](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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rail_verdict"
5
+
6
+ exit RailVerdict::CLI.new.run(ARGV)
@@ -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