opencode-ruby-upgrader 0.1.8 → 0.1.9

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.
package/PRIVACY.md CHANGED
@@ -8,4 +8,6 @@ Reports may be staged into local checkpoint commits. Review them before committi
8
8
 
9
9
  Dependency installation, tests, and Rails tooling execute project-controlled code with your local user permissions after confirmation. Use an isolated environment for repositories you do not trust.
10
10
 
11
+ The advisory infrastructure review reads version pins and workflow files inside the current worktree (`.ruby-version`, `.tool-versions`, `.mise.toml`, `Gemfile`, `Gemfile.lock`, `Dockerfile`, `.github/workflows/`), and the dashboard computes it locally on each request. It performs no network requests and stores nothing beyond the file and version values already present in those files. Because the findings are recomputed rather than recorded, they never enter a report or commit as durable evidence.
12
+
11
13
  Preparing an isolated runtime creates local Docker containers, a dedicated network, and pulled images that persist after the run finishes; nothing is uploaded and no port is published to your host. Those resources are labelled with the run ID and a SHA-256 hash of the worktree path, never the path itself. That hash exists only on the Docker resources — it is deliberately kept out of `.ruby-upgrades/` so that committing your upgrade evidence does not publish a guessable fingerprint of your filesystem path. Validation recomputes the hash from the current worktree and compares it against the live labels. Validation receipts written under `.ruby-upgrades/runs/` also record ephemeral Docker identifiers (container IDs, network ID, and image IDs) for traceability of the isolated run; these are local environment metadata, not personal data. Remove containers/networks with `docker rm`/`docker network rm` using the names recorded in `.ruby-upgrades/runtime.json`, and remove pulled images separately if you want the disk space back.
package/README.md CHANGED
@@ -91,7 +91,7 @@ Use `/ruby-upgrade --dry-run` for a no-write inventory and proposed migration as
91
91
  The agent has completed a real end-to-end migration against a public fixture: [`ruby2-rails4-bootstrap-heroku`](https://github.com/lilla021/ruby2-rails4-bootstrap-heroku) (BSD-2-Clause) moved from **Ruby 2.4.10 / Rails 4.2.11.3** to **Ruby 3.4.10 / Rails 7.1.6** across 15 receipt-backed hops. Every hop was validated by `bundle exec rspec` in an isolated Docker container, then committed as a local checkpoint before the next hop began.
92
92
 
93
93
  - [Upgrade pull request](https://github.com/lilla021/ruby2-rails4-bootstrap-heroku/pull/1) — the full migration: 19 commits, one per reviewed step, with lint and spec checks currently passing on GitHub Actions.
94
- - [Evidence ledger](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/E2E_EVIDENCE.md) — every hop's validation receipt, commit SHA, and the fixes the migration required.
94
+ - [Evidence ledger](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/E2E_EVIDENCE.md) — every hop's validation receipt, commit SHA, and the fixes the migration required.
95
95
 
96
96
  This proves the workflow works on a genuinely old, real-world Rails stack. It does not claim every upgrade is safe — see [Safety model](#safety-model) for what the agent refuses to do without you, and [Product limits](#product-limits) for what it cannot prove.
97
97
 
@@ -139,11 +139,19 @@ Every run has a generated JSON record and Markdown companion under `.ruby-upgrad
139
139
 
140
140
  As an example, here is one such run rendered in the dashboard — per-hop summaries, test metrics, and citation links. The full process and per-hop detail live in the run reports themselves:
141
141
 
142
- ![Example of the local evidence dashboard](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/v0.1.6/docs/dashboard.png)
142
+ ![Example of the local evidence dashboard](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/v0.1.9/docs/dashboard.png)
143
143
 
144
144
  The same reports open as a vault — each run is a Markdown note paired with its JSON record:
145
145
 
146
- ![Example vault view: run reports as paired Markdown and JSON notes](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/v0.1.6/docs/vault.png)
146
+ ![Example vault view: run reports as paired Markdown and JSON notes](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/v0.1.9/docs/vault.png)
147
+
148
+ ### Infrastructure review (advisory)
149
+
150
+ Each report and the dashboard also carry a read-only review of the infrastructure the upgrade may have made inconsistent: Ruby version pins in `.ruby-version`, `.tool-versions`, `.mise.toml`, `Gemfile`, and `Dockerfile`, CI workflows that do not yet test the new target, and the Bundler version recorded in `Gemfile.lock`.
151
+
152
+ These are advisory and never block a commit. The agent validates your app inside an isolated container; it has no access to your CI config, deploy host, or platform dashboard, so it cannot know what your production runtime actually uses. A version pin that deliberately lags the app is legitimate in many repositories, so every finding states the evidence it was read from and asks you to confirm it. Where a value cannot be read with confidence — an unparseable Dockerfile base image, an unfamiliar CI matrix shape — the tool stays silent rather than guessing.
153
+
154
+ The dashboard recomputes these on every request from the current worktree instead of storing them in the report. A report describes what happened during that run; these findings describe the repo as it stands now, and a frozen inference would misrepresent the second as the first.
147
155
 
148
156
  Launch the local-only dashboard from the repository worktree:
149
157
 
@@ -151,10 +159,35 @@ Launch the local-only dashboard from the repository worktree:
151
159
  npx opencode-ruby-upgrader dashboard
152
160
  ```
153
161
 
154
- It binds exclusively to `127.0.0.1` on an ephemeral port and remains in the foreground until you stop it with Ctrl-C. The read-only dashboard displays valid Ruby and Rails-bridge reports plus locally discoverable checkpoint commits; it never changes reports, Git state, or uploads code.
162
+ It binds exclusively to `127.0.0.1` on an ephemeral port and remains in the foreground until you stop it with Ctrl-C. The read-only dashboard displays valid Ruby, Rails-bridge, and Bundler-bridge reports plus locally discoverable checkpoint commits; it never changes reports, Git state, or uploads code.
155
163
 
156
164
  The dashboard identifies local checkpoint commits from trailers embedded in those commits. Before the final push, inspect them locally with `git log`, `git show`, and the dashboard; after you push, the same individual commits are available for GitHub review.
157
165
 
166
+ ## When a prerequisite blocks the hop
167
+
168
+ A Ruby upgrade occasionally cannot cross a version boundary on its own. Two cases are handled, and both work the same way: the Ruby run becomes terminal, and a separately scoped bridge run takes over the prerequisite change.
169
+
170
+ ### Rails bridge
171
+
172
+ If a resolved Rails version blocks the next Ruby hop, that Ruby run becomes terminal and a separate Rails-bridge run takes over. See the [Rails bridge lifecycle](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/docs/rails-bridge.md) reference.
173
+
174
+ ### Bundler bridge
175
+
176
+ Your `Gemfile.lock` records the Bundler that wrote it under `BUNDLED WITH`, and Bundler switches to that version automatically. So when an upgrade moves Ruby forward, an older pinned Bundler is what breaks — not the other way round. The official compatibility guide is a table of *minimum floors* (Bundler 2.5 requires Ruby >= 3.0, 2.6 requires >= 3.1, 2.7 and 4.0 require >= 3.2), not fixed pairings: Ruby simply ships a matching Bundler as its default.
177
+
178
+ If your pin sits below the researched floor for the target Ruby, the Ruby run becomes terminal and a separately scoped Bundler bridge takes over, exactly like the Rails bridge. Approving it records the floor, the rationale, and the official citation in the run evidence:
179
+
180
+ ```bash
181
+ npx opencode-ruby-upgrader record-bundler-bridge --report <run>.json \
182
+ --ruby-from 3.3 --ruby-to 3.4 --bundler-from 2.4.17 --bundler-to 2.5.22 \
183
+ --minimum-bundler 2.5 --rationale "Bundler 2.4 predates Ruby 3.4 support." \
184
+ --citation "Bundler compatibility with Ruby|https://guides.rubygems.org/bundler-compatibility/"
185
+ ```
186
+
187
+ A bridge cannot be approved for a pin that already clears the floor, so this cannot block a hop for no reason. The bridge run then researches a contiguous Bundler ladder (`begin-bundler-bridge`, `record-bundler-research`), and a hop is recorded only when the rewritten `BUNDLED WITH` pin **and** the Bundler that actually executed the tests both match the target. Because Bundler 4.0 follows 2.7 directly, the ladder may cross that boundary in a single hop.
188
+
189
+ Full lifecycle detail: [docs/bundler-bridge.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/docs/bundler-bridge.md).
190
+
158
191
  ## Recovery
159
192
 
160
193
  Each active run holds a local lock. To stop for review or manual work, transition it to `paused`; that releases the lock without marking the migration complete. Resume the existing report rather than starting a second migration:
@@ -165,8 +198,6 @@ opencode-ruby-upgrader resume --report .ruby-upgrades/runs/<run>.json
165
198
 
166
199
  `complete`, `blocked`, and `paused` runs release their lock. For other blockers, inspect the report and use the documented transition/resume path.
167
200
 
168
- If a resolved Rails version blocks the next Ruby hop, that Ruby run becomes terminal and a separate Rails-bridge run takes over. See the [Rails bridge lifecycle](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/docs/rails-bridge.md) reference.
169
-
170
201
  To undo a completed hop, use the reviewable local history: `git revert <hop-sha>`. Do not use reset, rebase, or force-push as routine migration recovery.
171
202
 
172
203
  ## Troubleshooting
@@ -202,7 +233,7 @@ The credential scanner is heuristic: it recognizes common token formats and quot
202
233
 
203
234
  No telemetry, no analytics, and no report uploads. Migration evidence is written only under `.ruby-upgrades/` in the current worktree — run reports under `.ruby-upgrades/runs/` and nonsecret runtime metadata in `.ruby-upgrades/runtime.json` — and the dashboard binds to `127.0.0.1` only. Reports may contain target versions, branch names, commit SHAs, changed-file names, dependency source origins, citations, and bounded validation metadata — absolute local paths and recognized credentials are redacted, but redaction is best-effort. Prepared Docker containers, networks, and images stay on your machine and are labelled with the run ID and a one-way hash of the worktree path, never the path itself.
204
235
 
205
- Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/PRIVACY.md). To report a vulnerability, see [SECURITY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/SECURITY.md).
236
+ Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/PRIVACY.md). To report a vulnerability, see [SECURITY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/SECURITY.md).
206
237
 
207
238
  ## Contributing
208
239
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # opencode-ruby-upgrader — release notes
2
2
 
3
+ ## v0.1.9 — advisory infrastructure review and the Bundler bridge
4
+
5
+ Every run already reported that a container-validated upgrade is not a deployed upgrade. v0.1.9 makes that concrete, and then lets you act on one of the findings: when a Ruby upgrade is blocked by a Bundler that cannot run the target, the Ruby run becomes terminal and a separately scoped Bundler bridge takes over, mirroring the existing Rails bridge.
6
+
7
+ ### Infrastructure review (advisory)
8
+
9
+ - New advisory findings for stale Ruby version pins (`.ruby-version`, `.tool-versions`, `.mise.toml`, `Gemfile`, `Dockerfile`), CI workflows that do not yet test the upgraded target, and the Bundler version recorded in `Gemfile.lock` under `BUNDLED WITH`. Each finding carries the file and value it was read from, a stated basis (`observed` vs `review`), and a remediation that asks rather than instructs.
10
+ - These never gate anything. The agent validates the app inside an isolated container and has no access to your CI config, deploy host, or platform dashboard. A version pin that deliberately lags the app is legitimate in many repositories, so a finding that blocked a commit would be wrong as often as it was right.
11
+ - Silence on ambiguity. An unparseable Dockerfile base image is reported as unread rather than as a version mismatch, and CI inspection only reports Ruby versions visible as plain scalars, because workflow matrix shapes vary too much to parse reliably. Reporting nothing beats guessing.
12
+ - The dashboard recomputes findings per request from the live worktree and stores nothing in the report. A report describes what happened during that run; these describe the repo as it stands now, and persisting an inference as durable evidence would misrepresent the second as the first.
13
+
14
+ ### The Bundler bridge
15
+
16
+ - `record-bundler-bridge` requires a researched minimum floor, a rationale, and the official citation, and records all three in run evidence. The bridge can only be approved when the recorded pin is genuinely below that floor.
17
+ - `begin-bundler-bridge` and `record-bundler-research` produce a separate report linked back to the blocked Ruby run, with a contiguous Bundler ladder. Bundler 4.0 follows 2.7 directly (there was no 3.x series), so that boundary is a valid single hop; without it a project on 2.4 could never reach 4.0 through a reviewed ladder.
18
+ - `record-executed-bundler-iteration` records a hop only when the rewritten `BUNDLED WITH` pin **and** the Bundler version that actually executed the tests both match the target. This is the honest evidence gate for a bridge whose output is a file change rather than a reviewable generator receipt, and it reuses the runtime Bundler attestation rather than trusting the lockfile alone.
19
+ - `commit-bundler-hop` commits only when the recorded pin agrees with the hop target, and requires `Bundler-Bridge-Ruby-Report` so the linkage survives in Git history.
20
+
21
+ ### Behaviour correction
22
+
23
+ The premise this was built on was wrong in an instructive way. "Ruby 4 requires Bundler 4" is not a constraint: the official table gives *minimum floors*, and each Ruby release merely ships a matching pair as its default. The compatibility risk runs the other way — an old Bundler pin breaks when Ruby moves forward — so the bridge is the mirror image of the Rails bridge, where the framework lags the Ruby. Both exist because something in the project cannot cross the hop alone.
24
+
25
+ ### No hardcoded compatibility table
26
+
27
+ `src/bundler-compat.js` deliberately stores no versions. The tool performs no network requests, so it cannot look up which Bundler supports which Ruby, and a baked-in table would silently rot at the next series release. The floor arrives the same way Rails and Ruby facts already do — supplied by the agent as cited research at the moment the bridge is approved — and only the comparison arithmetic lives locally, because that has to be deterministic for an approval to be defensible.
28
+
29
+ ### Scope of change
30
+
31
+ The Bundler bridge is implemented as a data variation of the existing bridge machinery rather than a third parallel code path. Bridge-specific behaviour is selected by a `bridge` parameter in the commit gate and a bridge-kind prefix in checkpoint trailers, which keeps the `reportType` branches from doubling for every future bridge type.
32
+
33
+ ### Evidence
34
+
35
+ Unit suite 80/80, covering the floor comparison, the 2.7→4.0 boundary, ladder contiguity, bridge scoping, terminality, and the pin/runtime agreement gate, plus advisory findings against real application shapes: a project pinning 3.3.4 across four files and a workflow on 3.3.4 yields all four findings; after the pins are updated, the stale findings disappear while the Bundler note remains, since that one is a confirmation rather than a detected defect. A repository with no version files produces no findings, and a `FROM my-registry.internal/ruby:latest` base produces no version mismatch. Both real-container smokes pass against real `mysql:8.4` and `postgres:16-alpine`, each asserting a passing RSpec receipt, a secret-free persisted manifest, and clean teardown.
36
+
37
+ ### Known limitations
38
+
39
+ - A Bundler bridge is never raised automatically. The advisory review points at the pin, but deciding whether it needs a bridge requires the researched floor, which only the agent can supply. That judgment stays explicit by design.
40
+ - CI workflow inspection reads only Ruby versions visible as plain scalars. Unusual matrix shapes are left unreported rather than guessed at.
41
+
3
42
  ## v0.1.8 — real-adapter detection and PostgreSQL container evidence
4
43
 
5
44
  v0.1.7 shipped database detection that had only ever been tested against hand-written fixtures. Running it against real Rails applications found three wrong answers, one of them silent. This release fixes detection and closes the container-evidence gap for the default adapter.
@@ -14,12 +14,14 @@ You own a careful Ruby runtime migration. Be decisive on routine fixes and trans
14
14
  - Parse user controls before work: `--dry-run`, `--target <version>`, and `--stop-after-hop`. Run `opencode-ruby-upgrader inventory`, `opencode-ruby-upgrader supply-chain`, and `opencode-ruby-upgrader git-capabilities` first. Pause for explicit review on shallow clones, sparse checkout, submodules, or LFS configuration. If the project is unsupported or has no recognized test adapter, stop with the detected evidence and ask for a test command; do not invent one. After official research pins the target, initialize the durable run with `opencode-ruby-upgrader begin --target <version>` (append `--dry-run` or `--stop-after-hop` when requested). Use only the returned report path for this run.
15
15
  - Drive the durable state machine, not prose alone: record research with `record-research`, then transition `initialized → inventory_complete → research_complete`; record each complete evidence-backed hop only with `record-executed-iteration` (or `record-executed-rails-iteration`), transition to `hop_validated`, and use the returned checkpoint SHA when transitioning to `committed`. Exactly one checkpoint is required before the next iteration. Repeat per hop. Use `paused` to stop safely for user review/manual work (it releases the lock); `complete` is only valid once the pinned target is reached. Use `blocked` only with evidence and an actionable option. For an isolated legacy runtime, ask once for approval to apply the planned Ruby declaration edit and then run `prepare-target-runtime --ruby <x.y.z> --report <the run path you just resumed>` before `docker-bundle-rspec`: it creates isolated Docker resources, installs Node and Bundler 2.4.22, runs `bundle install`, and creates the isolated Rails test database when applicable. The isolated test database is PostgreSQL unless the project declares MySQL through `mysql2`, in which case the runtime prepares an isolated MySQL server; the engine is detected from the project's own declarations, so do not guess or pass `--database` on your own. If preparation reports that both MySQL and PostgreSQL were detected, stop and present that conflict with your evidence and ask the user to choose the engine once, then rerun with their answer. Execute preparation with the longest supported shell timeout (at least 15 minutes), not a default short timeout. Do not ask the user for a report path, container name, or environment variables. The persisted nonsecret runtime manifest binds labeled app and database containers to the selected run and worktree; `docker-bundle-rspec` executes only the fixed inner argv `bundle exec rspec`. Prepared Docker resources persist after the run ends; report the container and network names from the manifest so the user can remove them, and never remove containers or networks that the tool did not create for this run.
16
16
  - If the next Ruby hop is incompatible with the resolved Rails version, do not edit Rails as part of the Ruby hop. Cite the official compatibility evidence, explain the required Rails from/to versions, and obtain explicit approval before running `record-framework-bridge --report <report-path> --ruby-from <version> --ruby-to <version> --rails-from <version> --rails-to <version> --rationale <text> --citation 'title|https://...'`. Then transition the Ruby run to `blocked` and start `begin-rails-bridge --ruby-report <blocked-ruby-report>`. In that separate report, research contiguous Rails-minor hops with `record-rails-research`, record each passing hop with `record-executed-rails-iteration` including reviewed `bin/rails app:update` evidence, transition to `hop_validated`, and checkpoint only with `commit-rails-hop`. Start a fresh Ruby run only after the Rails bridge is complete.
17
+ - If the project's `Gemfile.lock` records a `BUNDLED WITH` version below the researched minimum Bundler for the target Ruby, treat the pinned Bundler -- not Ruby itself -- as the blocker. Do not edit the lockfile's pin by hand. Consult the official compatibility guide at `https://guides.rubygems.org/bundler-compatibility/` and cite it: it lists minimum Ruby floors per Bundler series, so a higher Bundler supports a wider range of Rubies and an *older* pin is what breaks when Ruby moves forward. Obtain explicit approval before running `record-bundler-bridge --report <report-path> --ruby-from <version> --ruby-to <version> --bundler-from <version> --bundler-to <version> --minimum-bundler <version> --rationale <text> --citation 'title|https://...'`. Then transition the Ruby run to `blocked` and start `begin-bundler-bridge --ruby-report <blocked-ruby-report>`. In that separate report, research contiguous Bundler-series hops with `record-bundler-research --ladder <v1,v2,...>` (a direct 2.7 to 4.0 hop is valid because no Bundler 3.x series exists), rewrite the pin with `bundle lock --bundler <version>` inside the isolated runtime, and record each passing hop with `record-executed-bundler-iteration`; that hop is rejected unless both the rewritten `BUNDLED WITH` pin and the Bundler that executed the tests match the target. Transition to `hop_validated` and checkpoint only with `commit-bundler-hop`. Start a fresh Ruby run only after the Bundler bridge is complete. Never approve a bridge for a pin that already clears the researched floor.
17
18
  - Rails `app:update` executes with conflict-skipping semantics so existing application configuration is never overwritten noninteractively. Review every generated file before final tests. If a pending result is unsafe or superseded, revert only that unvalidated hop and run `discard-pending-app-update --report <report-path> --reason <review finding>`; the discarded digest remains durable evidence before a fresh attempt.
18
19
  - When a validated hop changes a lockfile, inspect the resolved dependency delta and license findings, then durably record both with `record-dependency-review --report <report-path> --compatibility <finding> --licenses <finding>` before requesting the checkpoint. Never bypass this gate merely because tests pass.
19
- - Never create, switch, delete, merge, push, fetch, reconfigure, or directly commit Git branches/remotes. Never invoke GitHub CLI, publishing, release, deployment, credential, or destructive database commands. The only permitted commit paths are Ruby-only `opencode-ruby-upgrader commit-hop --report <report-path>` and Rails-bridge-only `opencode-ruby-upgrader commit-rails-hop --report <report-path>` after their respective validation gates succeed.
20
+ - Never create, switch, delete, merge, push, fetch, reconfigure, or directly commit Git branches/remotes. Never invoke GitHub CLI, publishing, release, deployment, credential, or destructive database commands. The only permitted commit paths are Ruby-only `opencode-ruby-upgrader commit-hop --report <report-path>`, Rails-bridge-only `opencode-ruby-upgrader commit-rails-hop --report <report-path>`, and Bundler-bridge-only `opencode-ruby-upgrader commit-bundler-hop --report <report-path>` after their respective validation gates succeed.
20
21
  - Before every edit, acknowledge: this is automated migration assistance; the user must review diffs, tests, and any deployment. Never claim production safety.
21
- - Require a clean working tree before the first iteration, excluding only the plugin-owned `.ruby-upgrades` evidence directory. Do not ask the user to review or clean evidence files that this run just created; inspect and stop for every other tracked, staged, or untracked project change. Record branch, starting SHA, runtime/OS, Bundler, test commands, the pinned target Ruby version, source URLs, and source access date in `.ruby-upgrades/runs/<timestamp>.json` and an adjacent Markdown report. These files are versioned migration evidence. `begin` already acquires the single-worktree lock. Use `resume` only after a paused or interrupted run; `complete`, `blocked`, and `paused` runs release their lock. A Ruby run blocked by an approved Rails bridge must not resume.
22
+ - Require a clean working tree before the first iteration, excluding only the plugin-owned `.ruby-upgrades` evidence directory. Do not ask the user to review or clean evidence files that this run just created; inspect and stop for every other tracked, staged, or untracked project change. Record branch, starting SHA, runtime/OS, Bundler, test commands, the pinned target Ruby version, source URLs, and source access date in `.ruby-upgrades/runs/<timestamp>.json` and an adjacent Markdown report. These files are versioned migration evidence. `begin` already acquires the single-worktree lock. Use `resume` only after a paused or interrupted run; `complete`, `blocked`, and `paused` runs release their lock. A Ruby run blocked by an approved Rails or Bundler bridge must not resume.
22
23
  - Never ask a user to provide or restore an internal report path, run ID, container name, or runtime manifest. If no resumable durable run exists, explain that the prior local run evidence is unavailable and direct the user to restart with `/ruby-upgrade --target <version>`; retain the same safety gates and plainly summarize the new plan before any side effect. When stale paused reports exist, retain them as evidence but select the most recently started paused report matching the current branch and requested target; use that report path internally for resume and target-runtime preparation.
24
+ - Each generated report and dashboard card includes an advisory infrastructure review of the reader's own files: stale Ruby pins, CI workflows that do not yet test the target, and the recorded `BUNDLED WITH` Bundler pin. It is read-only and never blocks or gates a hop. Present these findings when the run completes, do not act on them automatically, and do not treat an intentional version pin that lags the app as a defect.
23
25
  - A successful routine iteration is proposed locally through the commit gate and requires an OpenCode confirmation. The gate requires the original linked worktree/branch, an unchanged expected HEAD, a completed report iteration with passing tests, an empty initial staging area, and no detected credential material. The user alone reviews, pushes, and deploys; they may push any validated local checkpoint.
24
26
  - Stop rather than guess on data migrations, authentication/authorization, payments, serialization, background jobs, native extensions, secrets, production configuration, required framework-major upgrades, private dependency sources, or failed validation. Explain the evidence and offer safe continuation options.
25
27
 
@@ -3,7 +3,7 @@ import { inspectGitCapabilities, inspectWorktree, setupInstructions } from "../s
3
3
  import { startDashboard } from "../src/dashboard.js";
4
4
  import { commitValidatedHop, commitValidatedRailsHop, CommitGateError } from "../src/commit-hop.js";
5
5
  import { acquireRunLock, readRun, releaseRunLock, RunStateError } from "../src/run-state.js";
6
- import { beginRailsBridgeRun, beginRun, discardLastRailsIteration, discardPendingRailsAppUpdate, recordDependencyReview, recordExecutedIteration, recordExecutedRailsIteration, recordFrameworkBridge, recordRailsResearch, recordResearch, recordRiskDecision, resumeRun, runStatus, transitionRun } from "../src/controller.js";
6
+ import { beginBundlerBridgeRun, beginRailsBridgeRun, beginRun, discardLastRailsIteration, discardPendingRailsAppUpdate, recordBundlerBridge, recordBundlerResearch, recordDependencyReview, recordExecutedBundlerIteration, recordExecutedIteration, recordExecutedRailsIteration, recordFrameworkBridge, recordRailsResearch, recordResearch, recordRiskDecision, resumeRun, runStatus, transitionRun } from "../src/controller.js";
7
7
  import { inventoryProject } from "../src/inventory.js";
8
8
  import { inspectSupplyChain } from "../src/supply-chain.js";
9
9
  import { prepareTargetRuntime } from "../src/target-runtime.js";
@@ -13,7 +13,7 @@ const option = (name) => { const index = args.indexOf(name); return index >= 0 ?
13
13
  const options = (name) => args.flatMap((argument, index) => argument === name && args[index + 1] ? [args[index + 1]] : []);
14
14
  const reportOption = () => option("--report");
15
15
  const citation = (value) => { const [title, url] = (value ?? "").split("|"); return { title, url }; };
16
- const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-risk|record-framework-bridge|record-executed-iteration|record-executed-rails-iteration|discard-pending-app-update|discard-last-rails-iteration|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|resume|release-lock> [--help]";
16
+ const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|begin-bundler-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-bundler-research|record-risk|record-framework-bridge|record-bundler-bridge|record-executed-iteration|record-executed-rails-iteration|record-executed-bundler-iteration|discard-pending-app-update|discard-last-rails-iteration|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|commit-bundler-hop|resume|release-lock> [--help]";
17
17
  const prepareRuntimeHelp = `Usage: opencode-ruby-upgrader prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]
18
18
 
19
19
  Prepares an isolated, run-bound Docker runtime. The database is detected from mysql2/PostgreSQL project declarations; absent evidence defaults to PostgreSQL, while conflicting evidence requires --database. Use --database only to override detection deliberately.`;
@@ -38,6 +38,9 @@ if (command === "prepare-target-runtime" && args.includes("--help")) {
38
38
  } else if (command === "begin-rails-bridge") {
39
39
  try { console.log(JSON.stringify(beginRailsBridgeRun({ rubyReportPath: option("--ruby-report"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop") }), null, 2)); }
40
40
  catch (error) { console.error(`Rails bridge start blocked: ${error.message}`); process.exitCode = 1; }
41
+ } else if (command === "begin-bundler-bridge") {
42
+ try { console.log(JSON.stringify(beginBundlerBridgeRun({ rubyReportPath: option("--ruby-report"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop") }), null, 2)); }
43
+ catch (error) { console.error(`Bundler bridge start blocked: ${error.message}`); process.exitCode = 1; }
41
44
  } else if (command === "prepare-target-runtime") {
42
45
  try {
43
46
  if (!option("--ruby")) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]");
@@ -62,18 +65,27 @@ if (command === "prepare-target-runtime" && args.includes("--help")) {
62
65
  } else if (command === "record-rails-research") {
63
66
  try { console.log(JSON.stringify(recordRailsResearch({ reportPath: reportOption(), ladder: (option("--ladder") ?? "").split(",").filter(Boolean), citations: options("--citation").map(citation) }), null, 2)); }
64
67
  catch (error) { console.error(`Rails research recording blocked: ${error.message}`); process.exitCode = 1; }
68
+ } else if (command === "record-bundler-research") {
69
+ try { console.log(JSON.stringify(recordBundlerResearch({ reportPath: reportOption(), ladder: option("--ladder")?.split(","), citations: options("--citation").map(citation) }), null, 2)); }
70
+ catch (error) { console.error(`Bundler research blocked: ${error.message}`); process.exitCode = 1; }
65
71
  } else if (command === "record-risk") {
66
72
  try { console.log(JSON.stringify(recordRiskDecision({ reportPath: reportOption(), risk: option("--risk"), decision: option("--decision"), evidence: option("--evidence") }), null, 2)); }
67
73
  catch (error) { console.error(`Risk recording blocked: ${error.message}`); process.exitCode = 1; }
68
74
  } else if (command === "record-framework-bridge") {
69
75
  try { console.log(JSON.stringify(recordFrameworkBridge({ reportPath: reportOption(), rubyFrom: option("--ruby-from"), rubyTo: option("--ruby-to"), railsFrom: option("--rails-from"), railsTo: option("--rails-to"), rationale: option("--rationale"), citations: options("--citation").map(citation) }), null, 2)); }
70
76
  catch (error) { console.error(`Rails compatibility bridge blocked: ${error.message}`); process.exitCode = 1; }
77
+ } else if (command === "record-bundler-bridge") {
78
+ try { console.log(JSON.stringify(recordBundlerBridge({ reportPath: reportOption(), rubyFrom: option("--ruby-from"), rubyTo: option("--ruby-to"), bundlerFrom: option("--bundler-from"), bundlerTo: option("--bundler-to"), minimumBundler: option("--minimum-bundler"), rationale: option("--rationale"), citations: options("--citation").map(citation) }), null, 2)); }
79
+ catch (error) { console.error(`Bundler compatibility bridge blocked: ${error.message}`); process.exitCode = 1; }
71
80
  } else if (command === "record-executed-iteration") {
72
81
  try { console.log(JSON.stringify(recordExecutedIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--json") ?? ""), validationCommandId: option("--validation") }), null, 2)); }
73
82
  catch (error) { console.error(`Executed validation blocked: ${error.message}`); process.exitCode = 1; }
74
83
  } else if (command === "record-executed-rails-iteration") {
75
84
  try { console.log(JSON.stringify(recordExecutedRailsIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--json") ?? ""), testValidationCommandId: option("--validation") }), null, 2)); }
76
85
  catch (error) { console.error(`Executed Rails validation blocked: ${error.message}`); process.exitCode = 1; }
86
+ } else if (command === "record-executed-bundler-iteration") {
87
+ try { console.log(JSON.stringify(recordExecutedBundlerIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--iteration") ?? "{}"), validationCommandId: option("--validation-command-id") }), null, 2)); }
88
+ catch (error) { console.error(`Bundler hop blocked: ${error.message}`); process.exitCode = 1; }
77
89
  } else if (command === "discard-pending-app-update") {
78
90
  try { console.log(JSON.stringify(discardPendingRailsAppUpdate({ reportPath: reportOption(), reason: option("--reason") }), null, 2)); }
79
91
  catch (error) { console.error(`Rails app:update discard blocked: ${error.message}`); process.exitCode = 1; }
@@ -103,6 +115,10 @@ if (command === "prepare-target-runtime" && args.includes("--help")) {
103
115
  const reportIndex = args.indexOf("--report");
104
116
  try { console.log(JSON.stringify(commitValidatedRailsHop({ reportPath: reportIndex >= 0 ? args[reportIndex + 1] : undefined, allowHooks: args.includes("--allow-hooks"), allowBroadLockfile: args.includes("--allow-broad-lockfile"), allowPrivateSources: args.includes("--allow-private-sources") }), null, 2)); }
105
117
  catch (error) { console.error(`${error instanceof CommitGateError ? `Rails commit blocked (${error.code})` : "Rails commit failed"}: ${error.message}`); process.exitCode = 1; }
118
+ } else if (command === "commit-bundler-hop") {
119
+ const reportIndex = args.indexOf("--report");
120
+ try { console.log(JSON.stringify(commitValidatedBundlerHop({ reportPath: reportIndex >= 0 ? args[reportIndex + 1] : undefined, allowHooks: args.includes("--allow-hooks"), allowBroadLockfile: args.includes("--allow-broad-lockfile"), allowPrivateSources: args.includes("--allow-private-sources") }), null, 2)); }
121
+ catch (error) { console.error(`${error instanceof CommitGateError ? `Bundler commit blocked (${error.code})` : "Bundler commit failed"}: ${error.message}`); process.exitCode = 1; }
106
122
  } else if (["resume", "release-lock"].includes(command)) {
107
123
  const reportIndex = args.indexOf("--report");
108
124
  const reportPath = reportIndex >= 0 ? args[reportIndex + 1] : undefined;
@@ -0,0 +1,107 @@
1
+ # Bundler bridge lifecycle
2
+
3
+ A Bundler bridge is the mirror image of a [Rails bridge](rails-bridge.md). Where a Rails hop is blocked because the framework lags the Ruby, a Bundler hop is blocked because the project's own pinned Bundler lags the Ruby it must now run on.
4
+
5
+ ## Why this exists
6
+
7
+ `Gemfile.lock` records the Bundler that wrote it under `BUNDLED WITH`, and Bundler switches to that version automatically. It is therefore the most reliable available signal for which Bundler will actually run in your project — more reliable than scanning for an installed gem.
8
+
9
+ The official compatibility guide at <https://guides.rubygems.org/bundler-compatibility/> is a table of **minimum floors**, not fixed pairings:
10
+
11
+ | Bundler | Requires Ruby | Requires RubyGems |
12
+ | --- | --- | --- |
13
+ | 4.0 | >= 3.2.0 | >= 3.4.1 |
14
+ | 2.7 | >= 3.2.0 | >= 3.4.1 |
15
+ | 2.6 | >= 3.1.0 | >= 3.3.3 |
16
+ | 2.5 | >= 3.0.0 | >= 3.2.3 |
17
+
18
+ A higher Bundler supports a *wider* range of Rubies, so moving Ruby forward essentially never forces a Bundler upgrade. Each Ruby release simply ships a matching pair as its default. The compatibility risk runs the other way: an **existing Bundler pin breaks when the Ruby moves forward**.
19
+
20
+ Two corrections worth stating plainly, because the intuitive version of this is wrong:
21
+
22
+ - "Ruby 4 requires Bundler 4" is not a constraint. Bundler 2.7 and 4.0 both support Ruby >= 3.2.
23
+ - Ruby 4.0 ships Bundler 4.0 as its bundled default. That is a convenience pairing, not a compatibility requirement.
24
+
25
+ ## No hardcoded table
26
+
27
+ `src/bundler-compat.js` deliberately stores no versions. This tool performs no network requests, so it cannot look up which Bundler supports which Ruby, and a baked-in table would silently rot at the next series release.
28
+
29
+ The minimum floor arrives the same way Rails and Ruby compatibility facts already enter this system: supplied as cited research at the moment the bridge is approved, and recorded in the run report so it can be re-verified later. Only the comparison arithmetic — version ordering, contiguity, floor checks — lives locally, because that has to be deterministic for an approval to be defensible.
30
+
31
+ ## Lifecycle
32
+
33
+ ### 1. Approve the bridge on the blocked Ruby run
34
+
35
+ ```bash
36
+ opencode-ruby-upgrader record-bundler-bridge --report <run>.json \
37
+ --ruby-from 3.3 --ruby-to 3.4 \
38
+ --bundler-from 2.4.17 --bundler-to 2.5.22 \
39
+ --minimum-bundler 2.5 \
40
+ --rationale "Bundler 2.4 predates Ruby 3.4 support." \
41
+ --citation "Bundler compatibility with Ruby|https://guides.rubygems.org/bundler-compatibility/"
42
+
43
+ opencode-ruby-upgrader transition --report <run>.json --phase blocked
44
+ ```
45
+
46
+ The bridge records the floor, the rationale, the citation, and the compatibility source URL. The Ruby run becomes **terminal**: it cannot be resumed, because resuming would skip the prerequisite change and leave the project on a Bundler that cannot run the target Ruby.
47
+
48
+ Approval is refused when the recorded pin already clears the floor. Blocking a hop that the pin already satisfies would be wrong as often as it is right.
49
+
50
+ ### 2. Begin the separate bridge run
51
+
52
+ ```bash
53
+ opencode-ruby-upgrader begin-bundler-bridge --ruby-report <blocked-ruby-report>.json
54
+ ```
55
+
56
+ This creates a `reportType: "bundler_bridge"` report linked back to the blocked Ruby run through `bridge.rubyReportPath` and `bridge.rubyRunId`.
57
+
58
+ ### 3. Research a contiguous ladder
59
+
60
+ ```bash
61
+ opencode-ruby-upgrader record-bundler-research --report <bridge>.json \
62
+ --ladder 2.4.17,2.5.22,2.6.9,2.7.2,4.0.11 \
63
+ --citation "Bundler compatibility with Ruby|https://guides.rubygems.org/bundler-compatibility/"
64
+ ```
65
+
66
+ The ladder must begin at the recorded `BUNDLED WITH` pin and advance one series at a time. A direct **2.7 → 4.0** hop is valid because Bundler never had a 3.x series; without that boundary rule a project on 2.4 could never reach 4.0 through a reviewed ladder.
67
+
68
+ ### 4. Raise the pin and validate
69
+
70
+ Rewrite the pin inside the isolated runtime, then record the hop:
71
+
72
+ ```bash
73
+ opencode-ruby-upgrader record-executed-bundler-iteration --report <bridge>.json \
74
+ --iteration '<json>' --validation-command-id docker-bundle-rspec
75
+ ```
76
+
77
+ This is where a Bundler bridge differs from a Rails bridge. A Rails hop produces a reviewable `app:update` receipt; a Bundler hop produces a **file change**, and a lockfile edit is weak evidence — anyone can hand-write `BUNDLED WITH 2.5.22`.
78
+
79
+ So the hop is accepted only when **two independent facts agree**:
80
+
81
+ 1. The rewritten `BUNDLED WITH` pin in `Gemfile.lock` matches the hop target.
82
+ 2. The Bundler version that actually executed the tests matches the hop target, attested by the isolated runtime.
83
+
84
+ A mismatch is refused explicitly, for example: `Validation executed Bundler 2.4.22, not 2.5.22. Re-prepare the target runtime on the new Bundler before recording this hop.`
85
+
86
+ The recorded pin is stored on the iteration as `lockfilePin`, and report validation rejects any hop whose pin disagrees with its target.
87
+
88
+ ### 5. Checkpoint
89
+
90
+ ```bash
91
+ opencode-ruby-upgrader transition --report <bridge>.json --phase hop_validated
92
+ opencode-ruby-upgrader commit-bundler-hop --report <bridge>.json
93
+ ```
94
+
95
+ The checkpoint message carries `Bundler-Upgrade-Report`, `Bundler-Upgrade-Hop`, and `Bundler-Bridge-Ruby-Report` trailers, so the linkage back to the blocked Ruby run survives in Git history and can be verified later.
96
+
97
+ ### 6. Restart the Ruby upgrade
98
+
99
+ Only after the bridge is complete should a fresh Ruby run begin.
100
+
101
+ ## Rollback
102
+
103
+ Use the reviewable local history: `git revert <hop-sha>`. Do not use reset, rebase, or force-push as routine migration recovery.
104
+
105
+ ## Reporting to the user
106
+
107
+ Advisory findings about the `BUNDLED WITH` pin appear in every report's **Infrastructure review (advisory)** section and on the dashboard. They are read-only and never gate a hop: an intentional version pin that lags the app is legitimate in many repositories. Where a value cannot be read with confidence, the tool stays silent rather than guessing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ruby-upgrader",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "A safe, evidence-driven Ruby runtime upgrade agent and local migration dashboard for OpenCode",
5
5
  "author": "Priscilla Cournoyer (https://github.com/lilla021)",
6
6
  "repository": {
@@ -26,9 +26,35 @@
26
26
  "allowScripts": {
27
27
  "opencode-ai@1.18.30": true
28
28
  },
29
- "files": ["src", "agents", "bin", "docs/*.md", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "RELEASING.md", "RELEASE_CHECKLIST.md", "RELEASE_NOTES.md", "E2E_EVIDENCE.md"],
30
- "keywords": ["opencode", "opencode-plugin", "ruby", "rails", "upgrade", "worktree", "migration"],
31
- "engines": { "node": ">=22.5.0" },
29
+ "files": [
30
+ "src",
31
+ "agents",
32
+ "bin",
33
+ "README.md",
34
+ "LICENSE",
35
+ "SECURITY.md",
36
+ "PRIVACY.md",
37
+ "RELEASING.md",
38
+ "RELEASE_CHECKLIST.md",
39
+ "RELEASE_NOTES.md",
40
+ "E2E_EVIDENCE.md",
41
+ "docs/bundler-bridge.md",
42
+ "docs/rails-bridge.md"
43
+ ],
44
+ "keywords": [
45
+ "opencode",
46
+ "opencode-plugin",
47
+ "ruby",
48
+ "rails",
49
+ "upgrade",
50
+ "worktree",
51
+ "migration"
52
+ ],
53
+ "engines": {
54
+ "node": ">=22.5.0"
55
+ },
32
56
  "license": "MIT",
33
- "publishConfig": { "access": "public" }
34
- }
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }
@@ -0,0 +1,140 @@
1
+ // Read-only advisory findings about infrastructure the upgrade may have made
2
+ // inconsistent.
3
+ //
4
+ // Everything here is advisory by design and never blocks a commit. The agent
5
+ // runs the app in an isolated container; it does not have your CI config, your
6
+ // deploy host, or your platform's build process. A finding says "this file
7
+ // disagrees with the version you just moved to, confirm it" -- never "you must
8
+ // change this". A deliberate version pin that lags the app is legitimate in many
9
+ // repos, and blocking on it would be wrong as often as right.
10
+ //
11
+ // Each finding carries the evidence it was derived from so the reader can judge
12
+ // it. `confidence` distinguishes a value read out of a file ("observed") from a
13
+ // judgment about what that value implies ("review").
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ const exists = (root, file) => fs.existsSync(path.join(root, file));
19
+ const read = (root, file) => (exists(root, file) ? fs.readFileSync(path.join(root, file), "utf8") : "");
20
+
21
+ function finding(severity, area, title, detail, evidence, confidence = "observed") {
22
+ return { severity, area, title, detail, evidence, confidence };
23
+ }
24
+
25
+ // `BUNDLED WITH` is the project's own record of which Bundler wrote its
26
+ // lockfile, and Bundler auto-switches to it. That makes it the most reliable
27
+ // available signal for "which Bundler will actually run here" -- more reliable
28
+ // than scanning for an installed gem, and the value a Ruby hop can invalidate.
29
+ export function bundledWith(root = process.cwd()) {
30
+ const lockfile = read(root, "Gemfile.lock");
31
+ const match = /^BUNDLED WITH\s*\r?\n\s+(\d+(?:\.\d+)+)/m.exec(lockfile);
32
+ return match?.[1] ?? null;
33
+ }
34
+
35
+ // Ruby version declarations, read from the files that actually pin one. Values
36
+ // are reported verbatim: an odd but intentional pin is still worth showing.
37
+ export function rubyPins(root = process.cwd()) {
38
+ const pins = [];
39
+ const versionFiles = [".ruby-version", ".tool-versions", ".mise.toml"];
40
+ for (const file of versionFiles) {
41
+ if (!exists(root, file)) continue;
42
+ const contents = read(root, file);
43
+ const value = contents.match(/^\s*(\d+\.\d+(?:\.\d+)?)\s*$/m)?.[1]
44
+ ?? contents.match(/^\s*ruby\s+(\d+\.\d+(?:\.\d+)?)\s*$/m)?.[1]
45
+ ?? contents.match(/ruby\s*=\s*["'](\d+\.\d+(?:\.\d+)?)/)?.[1]
46
+ ?? null;
47
+ if (value) pins.push({ file, value });
48
+ }
49
+ if (exists(root, "Dockerfile")) {
50
+ const match = read(root, "Dockerfile").match(/^\s*FROM\s+ruby:(\d+\.\d+(?:\.\d+)?)/im);
51
+ if (match) pins.push({ file: "Dockerfile", value: match[1] });
52
+ }
53
+ if (exists(root, "Gemfile")) {
54
+ const match = read(root, "Gemfile").match(/^\s*ruby\s+["']([\d.]+)["']/m);
55
+ if (match) pins.push({ file: "Gemfile", value: match[1] });
56
+ }
57
+ return pins;
58
+ }
59
+
60
+ // CI workflows are inspected conservatively. YAML matrix shapes vary far too
61
+ // much to parse reliably, so this only reports Ruby versions it can see as plain
62
+ // scalars and stays silent otherwise. Reporting nothing is better than guessing
63
+ // at a matrix it only partly understood.
64
+ export function ciRubyVersions(root = process.cwd()) {
65
+ const found = [];
66
+ const workflows = exists(root, ".github/workflows")
67
+ ? fs.readdirSync(path.join(root, ".github/workflows")).filter((f) => /\.ya?ml$/.test(f))
68
+ : [];
69
+ for (const file of workflows) {
70
+ const contents = read(root, path.join(".github/workflows", file));
71
+ const seen = new Set();
72
+ for (const match of contents.matchAll(/(?:ruby[-_]?version|ruby|RUBY_VERSION)["'\s:=]+(\d+\.\d+(?:\.\d+)?)/gi)) {
73
+ seen.add(match[1]);
74
+ }
75
+ if (seen.size) found.push({ file: path.join(".github/workflows", file), versions: [...seen].sort() });
76
+ }
77
+ return found;
78
+ }
79
+
80
+ const series = (version) => (version ?? "").split(".").slice(0, 2).join(".");
81
+ const sameSeries = (a, b) => series(a) === series(b) && series(a) !== "";
82
+
83
+ // `advisoryFindings` is intentionally read-only and dependency-free so it can be
84
+ // called from report writing without pulling in the Docker runtime machinery.
85
+ export function advisoryFindings({ root = process.cwd(), run = {}, inventory = {} } = {}) {
86
+ const findings = [];
87
+ const target = run.targetRuby;
88
+ const targetSeries = series(target);
89
+ const pins = rubyPins(root);
90
+ const ciVersions = ciRubyVersions(root);
91
+ const bundler = bundledWith(root);
92
+
93
+ for (const pin of pins) {
94
+ if (!target || sameSeries(pin.value, target)) continue;
95
+ findings.push(finding(
96
+ "review",
97
+ "version-pin",
98
+ `${pin.file} pins Ruby ${pin.value}, but this run targeted ${target}`,
99
+ `Update this if the deploy platform reads it, or leave it if it intentionally lags the app. Confirm which one your production runtime actually honours.`,
100
+ `${pin.file}: ${pin.value}`
101
+ ));
102
+ }
103
+
104
+ const pinFiles = new Set(pins.map((p) => p.file));
105
+ if (!pinFiles.has("Dockerfile") && exists(root, "Dockerfile")) {
106
+ findings.push(finding("review", "version-pin", "Dockerfile has no parseable Ruby base image", "The base image tag could not be read, so its Ruby version was not compared against this run.", "Dockerfile: unparsed", "review"));
107
+ }
108
+
109
+ for (const workflow of ciVersions) {
110
+ const behind = workflow.versions.filter((version) => targetSeries && series(version) < targetSeries);
111
+ if (!behind.length) continue;
112
+ findings.push(finding(
113
+ "review",
114
+ "ci",
115
+ `${workflow.file} does not test Ruby ${target}`,
116
+ `It references ${behind.join(", ")}. If CI keeps testing the older version, a regression on the new one would not be caught before merge.`,
117
+ `${workflow.file}: ${workflow.versions.join(", ")}`
118
+ ));
119
+ }
120
+
121
+ if (bundler && target) {
122
+ findings.push(finding(
123
+ "review",
124
+ "bundler",
125
+ `Gemfile.lock pins Bundler ${bundler}`,
126
+ `This run validated the app inside a container. Bundler switches to the version in \`BUNDLED WITH\`, so confirm your deploy host and CI resolve ${bundler} and that it supports Ruby ${target}. If it does not, the hop needs a Bundler upgrade scoped as its own run.`,
127
+ `Gemfile.lock BUNDLED WITH: ${bundler}`
128
+ ));
129
+ }
130
+
131
+ if (inventory.deploy?.length && !exists(root, ".ruby-version") && !exists(root, "Dockerfile")) {
132
+ findings.push(finding("review", "platform", "No shared Ruby version file", `Deploy files exist (${inventory.deploy.join(", ")}) but there is no \`.ruby-version\`, \`.tool-versions\`, \`.mise.toml\`, or \`Dockerfile\`. The Ruby version may then be defined only inside a platform dashboard or base image you cannot see from here.`, `deploy: ${inventory.deploy.join(", ")}`));
133
+ }
134
+
135
+ if (exists(root, "Gemfile.lock") && !fs.statSync(path.join(root, "Gemfile.lock")).isFile()) {
136
+ findings.push(finding("review", "lockfile", "Gemfile.lock is not a regular file", "Its BUNDLED WITH value was not read, so no Bundler finding is reported.", "Gemfile.lock", "review"));
137
+ }
138
+
139
+ return findings;
140
+ }
@@ -0,0 +1,68 @@
1
+ // Bundler/Ruby compatibility facts for the Bundler bridge.
2
+ //
3
+ // This module deliberately holds no version table. The tool performs no network
4
+ // requests, so it cannot look up which Bundler supports which Ruby, and a
5
+ // hardcoded table would silently rot the first time RubyGems cut a series.
6
+ // Instead the *floor* is supplied by the agent as cited research at the moment a
7
+ // bridge is approved -- the same way Rails and Ruby compatibility facts already
8
+ // enter this system -- and recorded in run evidence so it can be re-verified.
9
+ //
10
+ // What lives here is the arithmetic: comparing versions, and deciding whether a
11
+ // declared floor is genuinely above the project's pin. That logic must be local
12
+ // and deterministic, because it is what makes an approval defensible.
13
+
14
+ export const BUNDLER_COMPATIBILITY_SOURCE = "https://guides.rubygems.org/bundler-compatibility/";
15
+
16
+ // The official guide is a table of *minimum floors*, not fixed pairings: Bundler
17
+ // 2.5 requires Ruby >= 3.0, 2.6 requires >= 3.1, and 2.7 and 4.0 both require
18
+ // >= 3.2. A higher Bundler supports a wider range of Rubies, so a Ruby hop
19
+ // forward essentially never forces a Bundler upgrade -- an *existing* Bundler
20
+ // pin is what breaks when the Ruby moves. The bridge therefore exists because
21
+ // this project's pin lags its new Ruby, which is the mirror image of the Rails
22
+ // bridge (where Rails lagging Ruby blocks the hop).
23
+ export const bundlerVersion = /^\d+\.\d+(?:\.\d+)?$/;
24
+
25
+ export const bundlerSeries = (value) => String(value ?? "").split(".").slice(0, 2).join(".");
26
+ const parts = (value) => bundlerSeries(value).split(".").map(Number);
27
+ export const compareBundler = (left, right) => {
28
+ const [lMajor, lMinor] = parts(left); const [rMajor, rMinor] = parts(right);
29
+ return lMajor !== rMajor ? lMajor - rMajor : lMinor - rMinor;
30
+ };
31
+
32
+ // Bundler series move +1 per minor, with one major boundary: the 2.x line ends
33
+ // at 2.7 and the next series is 4.0. That jump is major 2 to 4 -- major 3 was
34
+ // never a Bundler series -- so this is an explicit successor rather than the
35
+ // +1 pattern Ruby uses. Treating 2.7 -> 4.0 as a valid single hop is required
36
+ // because that is how the official series run; without it a project on 2.4 could
37
+ // never reach 4.0 through a reviewed ladder.
38
+ const bundlerSeriesSuccessors = new Map([["2.7", "4.0"]]);
39
+ export const contiguousBundlerHop = (from, to) => {
40
+ const [fromMajor, fromMinor] = parts(from); const [toMajor, toMinor] = parts(to);
41
+ if (![fromMajor, fromMinor, toMajor, toMinor].every((value) => Number.isInteger(value))) return false;
42
+ return toMajor === fromMajor && toMinor === fromMinor + 1
43
+ || bundlerSeriesSuccessors.get(bundlerSeries(from)) === bundlerSeries(to);
44
+ };
45
+
46
+ // True when the project's recorded `BUNDLED WITH` pin sits below a researched
47
+ // floor. Bundler auto-switches to that pin, so this is the version that will
48
+ // actually run -- and the reason a hop is blocked.
49
+ export function bundlerPinBelowFloor({ pinned, minimum }) {
50
+ if (!bundlerVersion.test(String(pinned ?? "")) || !bundlerVersion.test(String(minimum ?? ""))) return false;
51
+ return compareBundler(pinned, minimum) < 0;
52
+ }
53
+
54
+ // A ladder must start at the pin and reach the researched floor one contiguous
55
+ // minor series at a time, so no hop can skip an intermediate Bundler whose
56
+ // behavior changes are themselves unreviewed.
57
+ export function validateBundlerLadder({ ladder, pinned, target }) {
58
+ if (!Array.isArray(ladder) || ladder.length < 2) return "A Bundler research ladder must contain at least two versions.";
59
+ if (ladder.some((version) => !bundlerVersion.test(version))) return "A Bundler research ladder must contain Bundler versions.";
60
+ if (bundlerSeries(ladder[0]) !== bundlerSeries(pinned)) return "A Bundler research ladder must begin at the version recorded in BUNDLED WITH.";
61
+ // Contiguity is checked before the endpoint so a ladder that both skips a
62
+ // series and overshoots reports the more specific problem.
63
+ for (let index = 1; index < ladder.length; index += 1) {
64
+ if (!contiguousBundlerHop(ladder[index - 1], ladder[index])) return "A Bundler research ladder must advance one minor series per hop, or across the 2.7 to 4.0 series boundary.";
65
+ }
66
+ if (bundlerSeries(ladder.at(-1)) !== bundlerSeries(target)) return "A Bundler research ladder must end at the researched target Bundler.";
67
+ return null;
68
+ }
package/src/commit-hop.js CHANGED
@@ -34,11 +34,12 @@ function readReport(root, reportPath) {
34
34
  catch { throw new CommitGateError("The upgrade report is missing or fails the run schema.", "invalid-report"); }
35
35
  }
36
36
 
37
- function assertValidatedIteration(report, rails = false) {
37
+ function assertValidatedIteration(report, bridge = null) {
38
38
  const iteration = report.iterations?.at(-1);
39
- if (rails ? (!report.targetRails || !report.targetRailsPinnedAt || !report.bridge) : (!report.targetRuby || !report.targetPinnedAt)) {
40
- throw new CommitGateError("The report must pin its target and bridge evidence before an automatic commit.", "target-not-pinned");
41
- }
39
+ const pinned = bridge === "rails" ? (report.targetRails && report.targetRailsPinnedAt && report.bridge)
40
+ : bridge === "bundler" ? (report.targetBundler && report.targetBundlerPinnedAt && report.bridge)
41
+ : (report.targetRuby && report.targetPinnedAt);
42
+ if (!pinned) throw new CommitGateError("The report must pin its target and bridge evidence before an automatic commit.", "target-not-pinned");
42
43
  if (!iteration || iteration.status !== "complete" || iteration.tests?.passed !== true) {
43
44
  throw new CommitGateError("The latest hop is not recorded as complete with passing tests.", "validation-missing");
44
45
  }
@@ -52,7 +53,11 @@ function assertValidatedIteration(report, rails = false) {
52
53
  if (!Array.isArray(iteration.fixes) || !iteration.fixes.length || !Array.isArray(iteration.citations) || !iteration.citations.length || !iteration.tests?.smoke) {
53
54
  throw new CommitGateError("The latest hop requires explained fixes, citations, and smoke-test evidence.", "evidence-missing");
54
55
  }
55
- if (rails && !iteration.appUpdateReview) throw new CommitGateError("The latest Rails hop requires reviewed bin/rails app:update evidence.", "app-update-review-missing");
56
+ if (bridge === "rails" && !iteration.appUpdateReview) throw new CommitGateError("The latest Rails hop requires reviewed bin/rails app:update evidence.", "app-update-review-missing");
57
+ // A Bundler hop changes the lockfile, so the commit must carry the pin it
58
+ // produced. Committing a hop whose recorded pin disagrees with its target
59
+ // would publish evidence that does not match the repository state.
60
+ if (bridge === "bundler" && (!iteration.lockfilePin || iteration.lockfilePin.split(".").slice(0, 2).join(".") !== iteration.to.split(".").slice(0, 2).join("."))) throw new CommitGateError("The latest Bundler hop must record the BUNDLED WITH pin it produced.", "bundler-pin-missing");
56
61
  return iteration;
57
62
  }
58
63
 
@@ -161,9 +166,13 @@ function assertValidationFingerprint(cwd, iteration) {
161
166
  }
162
167
  }
163
168
 
164
- function messageFor(iteration, reportPath, report, rails = false) {
169
+ function messageFor(iteration, reportPath, report, bridge = null) {
165
170
  const receiptTrailer = iteration.validationReceipts?.length ? [`Validation-Receipt-Digest: sha256:${receiptDigest(iteration.validationReceipts)}`] : [];
166
- if (rails) return [
171
+ if (bridge === "bundler") return [
172
+ `chore(bundler): upgrade ${iteration.from} to ${iteration.to}`, "", "Raise the BUNDLED WITH floor recorded in Gemfile.lock so this project can run on the target Ruby.", `Validation: ${iteration.tests.command || "project test suite"} executed on Bundler ${iteration.lockfilePin}.`, `Evidence: ${reportPath}`,
173
+ `Bundler-Upgrade-Report: ${reportPath}`, `Bundler-Upgrade-Hop: ${iteration.from}->${iteration.to}`, `Bundler-Bridge-Ruby-Report: ${report.bridge.rubyReportPath}`, ...receiptTrailer
174
+ ].join("\n");
175
+ if (bridge === "rails") return [
167
176
  `chore(rails): upgrade ${iteration.from} to ${iteration.to}`, "", "Apply the reviewed Rails framework compatibility changes.", `Validation: ${iteration.tests.command || "project test suite"}.`, `Evidence: ${reportPath}`,
168
177
  `Rails-Upgrade-Report: ${reportPath}`, `Rails-Upgrade-Hop: ${iteration.from}->${iteration.to}`, `Rails-App-Update-Reviewed: ${iteration.from}->${iteration.to}`, `Rails-Bridge-Ruby-Report: ${report.bridge.rubyReportPath}`, ...receiptTrailer
169
178
  ].join("\n");
@@ -178,7 +187,7 @@ function messageFor(iteration, reportPath, report, rails = false) {
178
187
  ].join("\n");
179
188
  }
180
189
 
181
- function commitValidated({ cwd = process.cwd(), reportPath, allowHooks = false, allowBroadLockfile = false, allowPrivateSources = false, rails = false }) {
190
+ function commitValidated({ cwd = process.cwd(), reportPath, allowHooks = false, allowBroadLockfile = false, allowPrivateSources = false, bridge = null }) {
182
191
  if (!reportPath) throw new CommitGateError("Provide --report .ruby-upgrades/runs/<run>.json.", "missing-report-path");
183
192
  const state = gitState(cwd);
184
193
  if (!state.linked || !state.branch || !state.defaultBranch || state.branch === state.defaultBranch) {
@@ -191,8 +200,9 @@ function commitValidated({ cwd = process.cwd(), reportPath, allowHooks = false,
191
200
  if (!report.lockNonce) throw new CommitGateError("The report is missing its active lock capability.", "lock-capability-missing");
192
201
  try { assertRunLock(state.root, reportPath, report.lockNonce); }
193
202
  catch (error) { throw new CommitGateError(error.message, error.code); }
194
- const iteration = assertValidatedIteration(report, rails);
195
- if (report.schemaVersion !== 2 || report.phase !== "hop_validated" || Boolean(report.reportType === "rails_bridge") !== rails) throw new CommitGateError("The run must use the current schema and transition to hop_validated before an automatic commit.", "phase-not-validated");
203
+ const iteration = assertValidatedIteration(report, bridge);
204
+ const expectedType = bridge === "rails" ? "rails_bridge" : bridge === "bundler" ? "bundler_bridge" : "ruby";
205
+ if (report.schemaVersion !== 2 || report.phase !== "hop_validated" || (report.reportType ?? "ruby") !== expectedType) throw new CommitGateError("The run must use the current schema and transition to hop_validated before an automatic commit.", "phase-not-validated");
196
206
  if (report.branch !== state.branch) throw new CommitGateError("The worktree branch no longer matches the recorded run branch.", "unexpected-branch");
197
207
  assertExpectedHead(state, report);
198
208
  const indexPath = path.resolve(cwd, text(cwd, ["rev-parse", "--git-path", "index"]));
@@ -216,7 +226,7 @@ function commitValidated({ cwd = process.cwd(), reportPath, allowHooks = false,
216
226
  throw new CommitGateError(`Potential credential material detected in: ${secretPaths.join(", ")}. Review it manually; it was not committed.`, "secret-detected");
217
227
  }
218
228
 
219
- const message = messageFor(iteration, relativeReport, report, rails);
229
+ const message = messageFor(iteration, relativeReport, report, bridge);
220
230
  assertValidationFingerprint(cwd, iteration);
221
231
  if (text(cwd, ["rev-parse", "HEAD"]) !== report.expectedHead) throw new CommitGateError("HEAD changed while the commit gate was validating; refusing to commit atop unexpected history.", "unexpected-head");
222
232
  commit(cwd, message, env);
@@ -230,5 +240,6 @@ function commitValidated({ cwd = process.cwd(), reportPath, allowHooks = false,
230
240
  }
231
241
  }
232
242
 
233
- export function commitValidatedHop(options = {}) { return commitValidated(options); }
234
- export function commitValidatedRailsHop(options = {}) { return commitValidated({ ...options, rails: true }); }
243
+ export function commitValidatedHop(options = {}) { return commitValidated({ ...options, bridge: null }); }
244
+ export function commitValidatedRailsHop(options = {}) { return commitValidated({ ...options, bridge: "rails" }); }
245
+ export function commitValidatedBundlerHop(options = {}) { return commitValidated({ ...options, bridge: "bundler" }); }
package/src/controller.js CHANGED
@@ -4,9 +4,11 @@ import { execFileSync } from "node:child_process";
4
4
  import { inspectGitCapabilities, inspectWorktree } from "./preflight.js";
5
5
  import { inventoryProject } from "./inventory.js";
6
6
  import { inspectSupplyChain } from "./supply-chain.js";
7
+ import { bundledWith as inventoryBundledWith } from "./advisory.js";
7
8
  import { acquireRunLock, assertRunLock, readRun, releaseRunLock, writeRun } from "./run-state.js";
8
9
  import { executeValidation } from "./validation-executor.js";
9
10
  import { receiptDigest } from "./provenance.js";
11
+ import { BUNDLER_COMPATIBILITY_SOURCE, bundlerVersion, bundlerSeries, compareBundler, bundlerPinBelowFloor, contiguousBundlerHop, validateBundlerLadder } from "./bundler-compat.js";
10
12
 
11
13
  const rubyVersion = /^\d+\.\d+(?:\.\d+)?$/;
12
14
  const railsVersion = /^\d+(?:\.\d+)+$/;
@@ -74,6 +76,31 @@ export function beginRailsBridgeRun({ root = process.cwd(), rubyReportPath, dryR
74
76
  return { ...plan, reportPath, report, lock };
75
77
  }
76
78
 
79
+ export function beginBundlerBridgeRun({ root = process.cwd(), rubyReportPath, dryRun = false, stopAfterHop = false } = {}) {
80
+ const rubyRun = readRun(root, rubyReportPath);
81
+ if (rubyRun.reportType?.endsWith("_bridge") || rubyRun.phase !== "blocked" || rubyRun.status !== "blocked" || !rubyRun.bundlerBridge) throw new Error("A Bundler bridge can start only from a blocked Ruby run with an approved Bundler compatibility bridge.");
82
+ const preflight = inspectWorktree(root); const inventory = inventoryProject(root); const supplyChain = inspectSupplyChain(root); const gitCapabilities = inspectGitCapabilities(root); const bridge = rubyRun.bundlerBridge;
83
+ const plan = { preflight, inventory, supplyChain, gitCapabilities, bridge, dryRun, stopAfterHop };
84
+ if (dryRun) return plan;
85
+ if (!preflight.ok || preflight.mode !== "linked-worktree") throw new Error("A durable Bundler bridge requires a supported linked Git worktree.");
86
+ if (bundlerSeries(inventory.bundler?.bundledWith ?? "") !== bundlerSeries(bridge.bundlerFrom)) throw new Error("The current Gemfile.lock must still record the Bundler version recorded by the blocked Ruby run.");
87
+ if (preflight.branch !== rubyRun.branch || preflight.sha !== rubyRun.expectedHead) throw new Error("Bundler bridge must start on the blocked Ruby run's recorded branch and checkpoint SHA.");
88
+ const reportPath = path.join(".ruby-upgrades", "runs", runName());
89
+ const report = { schemaVersion: 2, validationReceiptsRequired: true, reportType: "bundler_bridge", runId: crypto.randomUUID(), title: `Bundler bridge ${bridge.bundlerFrom} to ${bridge.bundlerTo}`, status: "in_progress", phase: "initialized", startedAt: new Date().toISOString(), targetBundler: bridge.bundlerTo, targetBundlerPinnedAt: new Date().toISOString(), targetRuby: bridge.rubyTo, branch: preflight.branch ?? null, worktreeRoot: preflight.root ?? root, startingSha: preflight.sha ?? null, expectedHead: preflight.sha ?? null, control: { stopAfterHop }, inventory, supplyChain, gitCapabilities, bridge: { rubyReportPath, rubyRunId: rubyRun.runId, rubyFrom: bridge.rubyFrom, rubyTo: bridge.rubyTo, bundlerFrom: bridge.bundlerFrom, bundlerTo: bridge.bundlerTo, minimumBundler: bridge.minimumBundler, compatibilitySource: bridge.compatibilitySource, approvedAt: bridge.recordedAt }, research: { ladder: [], citations: [] }, riskDecisions: [], requiredRisks: requiredRisksFor(supplyChain, gitCapabilities), summary: [`Bundler bridge initialized from a blocked Ruby hop; researched floor ${bridge.minimumBundler}.`], iterations: [], sessionSummary: "" };
90
+ const lock = acquireRunLock(root, reportPath); report.lockNonce = lock.nonce;
91
+ try { writeRun(root, reportPath, report); } catch (error) { releaseRunLock(root, reportPath, lock.nonce); throw error; }
92
+ return { ...plan, reportPath, report, lock };
93
+ }
94
+
95
+ export function recordBundlerResearch({ root = process.cwd(), reportPath, ladder, citations }) {
96
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
97
+ if (run.reportType !== "bundler_bridge") throw new Error("Bundler research belongs only to a Bundler bridge report.");
98
+ const problem = validateBundlerLadder({ ladder, pinned: run.bridge.bundlerFrom, target: run.targetBundler });
99
+ if (problem) throw new Error(problem);
100
+ if (!Array.isArray(citations) || !citations.length || citations.some((citation) => !citation?.title || !/^https:\/\//.test(citation.url ?? ""))) throw new Error("Bundler research requires at least one HTTPS official-source citation.");
101
+ run.research = { ladder, citations }; writeRun(root, reportPath, run); return run;
102
+ }
103
+
77
104
  export function recordResearch({ root = process.cwd(), reportPath, ladder, citations }) {
78
105
  const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
79
106
  if (!Array.isArray(ladder) || !ladder.length || ladder.some((version) => !rubyVersion.test(version))) throw new Error("Research requires a Ruby-version ladder.");
@@ -98,6 +125,31 @@ export function recordRiskDecision({ root = process.cwd(), reportPath, risk, dec
98
125
  const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce); run.riskDecisions.push({ risk, decision, evidence, recordedAt: new Date().toISOString() }); writeRun(root, reportPath, run); return run;
99
126
  }
100
127
 
128
+ // A Bundler bridge records the same kind of decision the Rails bridge does: the
129
+ // project's own pin cannot cross the researched Ruby hop, so the change happens
130
+ // in its own separately scoped run. What differs is the evidence -- a Rails hop
131
+ // produces a reviewable `app:update` receipt, while a Bundler hop produces a
132
+ // lockfile change that must be confirmed against the version actually executed.
133
+ export function recordBundlerBridge({ root = process.cwd(), reportPath, rubyFrom, rubyTo, bundlerFrom, bundlerTo, minimumBundler, rationale, citations }) {
134
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
135
+ if (!["research_complete", "committed"].includes(run.phase)) throw new Error("Record a Bundler compatibility bridge only after research or a committed Ruby checkpoint and before a blocked Ruby hop.");
136
+ if (run.frameworkBridge || run.bundlerBridge) throw new Error("This run already has an approved compatibility bridge; complete it in a separately scoped run.");
137
+ if (![rubyFrom, rubyTo].every((version) => rubyVersion.test(version)) || ![bundlerFrom, bundlerTo, minimumBundler].every((version) => bundlerVersion.test(version))) throw new Error("A Bundler compatibility bridge requires Ruby from/to and Bundler from/to/floor versions.");
138
+ if (series(rubyFrom) !== series(run.iterations.at(-1)?.to ?? run.research.ladder[0]) || series(rubyTo) !== series(run.research.ladder[run.research.ladder.findIndex((version) => series(version) === series(rubyFrom)) + 1])) throw new Error("Bundler compatibility bridge must describe the next researched Ruby hop.");
139
+ if (series(bundlerFrom) !== series(run.inventory.bundler?.bundledWith ?? "")) throw new Error("A Bundler compatibility bridge must begin at the version recorded in the project's BUNDLED WITH.");
140
+ // Without this the bridge could be approved for a pin that already satisfies
141
+ // the floor, which would block a hop for no reason. It is deliberately the
142
+ // last check before approval, so the message names the actual pin and floor
143
+ // rather than a generic validation failure.
144
+ const pin = run.inventory.bundler?.bundledWith ?? bundlerFrom;
145
+ if (!bundlerPinBelowFloor({ pinned: pin, minimum: minimumBundler })) throw new Error(`Bundler ${pin} is not below the researched floor ${minimumBundler}; approving a bridge would block the hop without cause.`);
146
+ if (compareBundler(bundlerTo, minimumBundler) < 0) throw new Error("The bridge target Bundler must be at or above the researched minimum floor.");
147
+ if (!rationale || !Array.isArray(citations) || !citations.length || citations.some((citation) => !citation?.title || !/^https:\/\//.test(citation.url ?? ""))) throw new Error("A Bundler compatibility bridge requires a rationale and HTTPS citations.");
148
+ run.bundlerBridge = { status: "approved", rubyFrom, rubyTo, bundlerFrom, bundlerTo, minimumBundler, compatibilitySource: BUNDLER_COMPATIBILITY_SOURCE, rationale, citations, recordedAt: new Date().toISOString() };
149
+ run.summary = [...run.summary, `User approved a separately scoped Bundler ${bundlerFrom} → ${bundlerTo} bridge before Ruby ${rubyFrom} → ${rubyTo}, on a researched floor of ${minimumBundler}.`];
150
+ writeRun(root, reportPath, run); return run;
151
+ }
152
+
101
153
  export function recordFrameworkBridge({ root = process.cwd(), reportPath, rubyFrom, rubyTo, railsFrom, railsTo, rationale, citations }) {
102
154
  const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
103
155
  if (!["research_complete", "committed"].includes(run.phase)) throw new Error("Record a Rails compatibility bridge only after research or a committed Ruby checkpoint and before a blocked Ruby hop.");
@@ -114,7 +166,7 @@ export function recordFrameworkBridge({ root = process.cwd(), reportPath, rubyFr
114
166
 
115
167
  function recordIterationInternal({ root = process.cwd(), reportPath, iteration, executed = false }) {
116
168
  const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
117
- if (run.reportType === "rails_bridge") throw new Error("Use record-rails-iteration for a Rails bridge report.");
169
+ if (run.reportType?.endsWith("_bridge")) throw new Error(`Use the ${run.reportType === "rails_bridge" ? "Rails" : "Bundler"} bridge iteration recorder for a bridge report.`);
118
170
  if (run.validationReceiptsRequired && !executed) throw new Error("New reports require record-executed-iteration so validation receipts are created by the executor.");
119
171
  if (run.phase !== "research_complete" && run.phase !== "committed") throw new Error("Record iterations only after research or a prior checkpoint.");
120
172
  assertApprovedRisks(run);
@@ -190,6 +242,27 @@ export function discardLastRailsIteration({ root = process.cwd(), reportPath, re
190
242
  writeRun(root, reportPath, run); return run;
191
243
  }
192
244
 
245
+ // A Bundler hop is evidenced by what it changed and what actually ran. `bundle
246
+ // lock --bundler` rewrites the `BUNDLED WITH` pin, and the runtime attests the
247
+ // Bundler that executed the tests; requiring both to agree is what stops a hop
248
+ // being recorded on the strength of a lockfile edit alone.
249
+ export function recordExecutedBundlerIteration({ root = process.cwd(), reportPath, iteration, validationCommandId, executed = false }) {
250
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
251
+ if (run.reportType !== "bundler_bridge" || !["research_complete", "committed"].includes(run.phase)) throw new Error("Record Bundler iterations only after Bundler research or a prior Bundler checkpoint.");
252
+ assertApprovedRisks(run);
253
+ if (run.iterations.length && (run.phase !== "committed" || !run.iterations.at(-1).checkpointSha)) throw new Error("Each iteration requires exactly one checkpoint before the next iteration.");
254
+ const expectedFrom = run.iterations.at(-1)?.to ?? run.research.ladder[0];
255
+ const index = run.research.ladder.findIndex((version) => bundlerSeries(version) === bundlerSeries(expectedFrom));
256
+ const expectedTo = run.research.ladder[index + 1];
257
+ if (!iteration || index < 0 || !expectedTo || bundlerSeries(iteration.from) !== bundlerSeries(expectedFrom) || bundlerSeries(iteration.to) !== bundlerSeries(expectedTo)) throw new Error("Iteration must advance exactly one researched Bundler series from the prior ladder point.");
258
+ const receipt = executeValidation({ root, inventory: run.inventory, commandId: validationCommandId, expectedRuntime: { runId: run.runId, reportPath } });
259
+ if (receipt.kind !== "test" || receipt.exitCode !== 0 || receipt.testEvidence?.passed !== true) throw new Error(`Validation failed; receipt ${receipt.id} was not recorded as a passing hop.`);
260
+ const pinned = inventoryBundledWith(root);
261
+ if (bundlerSeries(pinned ?? "") !== bundlerSeries(iteration.to)) throw new Error(`Gemfile.lock still records Bundler ${pinned ?? "none"}; a hop to ${iteration.to} requires the BUNDLED WITH pin to be rewritten first.`);
262
+ if (bundlerSeries(receipt.environment?.bundlerVersion ?? "") !== bundlerSeries(iteration.to)) throw new Error(`Validation executed Bundler ${receipt.environment?.bundlerVersion ?? "an unrecorded version"}, not ${iteration.to}. Re-prepare the target runtime on the new Bundler before recording this hop.`);
263
+ run.iterations.push({ ...iteration, status: "complete", lockfilePin: pinned }); writeRun(root, reportPath, run); return run;
264
+ }
265
+
193
266
  export function recordDependencyReview({ root = process.cwd(), reportPath, compatibility, licenses }) {
194
267
  if (!compatibility?.trim() || !licenses?.trim()) throw new Error("Dependency review requires compatibility and license findings.");
195
268
  const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
@@ -205,7 +278,8 @@ function validRailsReviewForExecution(review, receipt) { return review?.command
205
278
  function validationPreflight(root, reportPath, rails) {
206
279
  const run = readRun(root, reportPath);
207
280
  assertRunLock(root, reportPath, run.lockNonce);
208
- if (Boolean(run.reportType === "rails_bridge") !== rails || !["research_complete", "committed"].includes(run.phase)) throw new Error("Validation can run only for the active report type after research or a prior checkpoint.");
281
+ const activeType = run.reportType === "rails_bridge" ? "rails_bridge" : run.reportType === "bundler_bridge" ? "bundler_bridge" : "ruby";
282
+ if ((rails ? "rails_bridge" : "ruby") !== activeType || !["research_complete", "committed"].includes(run.phase)) throw new Error("Validation can run only for the active report type after research or a prior checkpoint.");
209
283
  assertApprovedRisks(run);
210
284
  if (run.iterations.length && (run.phase !== "committed" || !run.iterations.at(-1).checkpointSha)) throw new Error("Each iteration requires exactly one checkpoint before validation can run again.");
211
285
  return run;
@@ -213,7 +287,7 @@ function validationPreflight(root, reportPath, rails) {
213
287
 
214
288
  function assertCompletion(run) {
215
289
  const final = run.iterations.at(-1);
216
- const target = run.reportType === "rails_bridge" ? run.targetRails : run.targetRuby;
290
+ const target = run.reportType === "rails_bridge" ? run.targetRails : run.reportType === "bundler_bridge" ? run.targetBundler : run.targetRuby;
217
291
  if (!final || series(final.to) !== series(target)) throw new Error("A run can complete only after the final validated iteration reaches its pinned target.");
218
292
  if (series(run.research.ladder.at(-1)) !== series(target)) throw new Error("Research ladder does not reach its pinned target.");
219
293
  if (hasUnapprovedRisks(run)) throw new Error("Unresolved risks prevent completion.");
@@ -228,14 +302,14 @@ function verifyCheckpoint(root, reportPath, run, commitSha) {
228
302
  if (git(root, ["rev-parse", `${head}^`]) !== run.expectedHead) throw new Error("Checkpoint must be a direct child of the prior recorded checkpoint.");
229
303
  const iteration = run.iterations.at(-1);
230
304
  const message = git(root, ["log", "-1", "--format=%B"]);
231
- const prefix = run.reportType === "rails_bridge" ? "Rails" : "Ruby";
232
- if (!message.includes(`${prefix}-Upgrade-Report: ${reportPath}`) || !message.includes(`${prefix}-Upgrade-Hop: ${iteration.from}->${iteration.to}`) || (iteration.validationReceipts?.length && !message.includes(`Validation-Receipt-Digest: sha256:${receiptDigest(iteration.validationReceipts)}`)) || (prefix === "Rails" && (!message.includes(`Rails-App-Update-Reviewed: ${iteration.from}->${iteration.to}`) || !message.includes(`Rails-Bridge-Ruby-Report: ${run.bridge.rubyReportPath}`)))) throw new Error("Checkpoint commit does not carry the required run and hop trailers.");
305
+ const prefix = run.reportType === "rails_bridge" ? "Rails" : run.reportType === "bundler_bridge" ? "Bundler" : "Ruby";
306
+ if (!message.includes(`${prefix}-Upgrade-Report: ${reportPath}`) || !message.includes(`${prefix}-Upgrade-Hop: ${iteration.from}->${iteration.to}`) || (iteration.validationReceipts?.length && !message.includes(`Validation-Receipt-Digest: sha256:${receiptDigest(iteration.validationReceipts)}`)) || (prefix === "Rails" && (!message.includes(`Rails-App-Update-Reviewed: ${iteration.from}->${iteration.to}`) || !message.includes(`Rails-Bridge-Ruby-Report: ${run.bridge.rubyReportPath}`))) || (prefix === "Bundler" && !message.includes(`Bundler-Bridge-Ruby-Report: ${run.bridge.rubyReportPath}`))) throw new Error("Checkpoint commit does not carry the required run and hop trailers.");
233
307
  }
234
308
 
235
309
  export function transitionRun({ root = process.cwd(), reportPath, phase, note = "", commitSha = "" }) {
236
310
  if (!Object.hasOwn(transitions, phase)) throw new Error(`Unknown run phase: ${phase}.`);
237
311
  const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce); const current = run.phase;
238
- if (current === "blocked" && run.frameworkBridge) throw new Error("A Ruby run blocked by a Rails bridge is terminal; complete the bridge and start a fresh Ruby run.");
312
+ if (current === "blocked" && (run.frameworkBridge || run.bundlerBridge)) throw new Error("A Ruby run blocked by an approved compatibility bridge is terminal; complete that bridge and start a fresh Ruby run.");
239
313
  if (!transitions[current]?.includes(phase)) throw new Error(`Cannot transition from ${current} to ${phase}.`);
240
314
  if (phase === "research_complete" && (!run.research.ladder.length || !run.research.citations.length)) throw new Error("Record research and citations before completing research.");
241
315
  if (phase === "hop_validated" && (run.iterations.at(-1)?.tests?.passed !== true || !run.iterations.at(-1)?.validationReceipts?.length)) throw new Error("A hop requires passing tests and executed validation receipts.");
@@ -258,7 +332,7 @@ export function transitionRun({ root = process.cwd(), reportPath, phase, note =
258
332
  export function resumeRun({ root = process.cwd(), reportPath, continueAfterHop = false }) {
259
333
  const run = readRun(root, reportPath);
260
334
  if (run.status === "complete") throw new Error("This run is complete; start a new run instead.");
261
- if (run.frameworkBridge && run.phase === "blocked") throw new Error("This Ruby run is blocked by an approved Rails bridge and cannot resume. Complete the linked Rails bridge, then begin a fresh Ruby run.");
335
+ if ((run.frameworkBridge || run.bundlerBridge) && run.phase === "blocked") throw new Error("This Ruby run is blocked by an approved compatibility bridge and cannot resume. Complete the linked bridge, then begin a fresh Ruby run.");
262
336
  if (run.phase === "paused" && !transitions.paused.includes(run.resumePhase)) throw new Error("Paused run has no safe resume phase.");
263
337
  if (run.control.stopAfterHop && run.iterations.length >= 1 && run.resumePhase === "committed" && !continueAfterHop) throw new Error("This run stopped after its requested hop. Resume with explicit --continue-after-hop only after user review.");
264
338
  if (continueAfterHop) run.control.stopAfterHop = false;
@@ -11,6 +11,6 @@ const testValue=(tests,key,fallback='—')=>tests&&tests[key]!==undefined&&tests
11
11
  fetch('/api/runs').then(response=>response.json()).then(runs=>{
12
12
  const app=document.querySelector('#app');
13
13
  if(!runs.length){app.innerHTML='<article class="card"><b>No upgrade runs yet.</b><p>Run <code>/ruby-upgrade</code> from a linked Git worktree. Evidence will appear under <code>.ruby-upgrades/runs/</code>.</p></article>';return;}
14
- app.innerHTML=runs.map(run=>{const iterations=run.iterations||[];const latest=iterations.at(-1)||{};const tests=latest.tests||{};const commits=(run.localCommits||[]).map(sha=>'<code>'+esc(sha)+'</code>').join(' ')||'No local commit found yet.';const target=run.reportType==='rails_bridge'?'Rails '+(run.targetRails||'target unknown'):'Ruby '+(run.targetRuby||'target unknown');const bridge=run.reportType==='rails_bridge'?'<p><b>Blocked Ruby hop:</b> '+esc(run.bridge?.rubyFrom)+' → '+esc(run.bridge?.rubyTo)+' · <code>'+esc(run.bridge?.rubyReportPath)+'</code></p>':'';const details=iterations.map(iteration=>{const itTests=iteration.tests||{};const fixes=(iteration.fixes||[]).map(fix=>'<li><b>'+esc((fix.files||[]).join(', ')||'Migration fix')+'</b><br>'+esc(fix.explanation)+'</li>').join('')||'<li>No code or dependency fixes recorded.</li>';const citations=(iteration.citations||[]).map(citation=>'<a href="'+safeUrl(citation.url)+'" target="_blank" rel="noreferrer">'+esc(citation.title||citation.url)+'</a>').join(' · ')||'No citations recorded.';const appUpdate=iteration.appUpdateReview?'<p><small>app:update: '+esc(iteration.appUpdateReview.outcome)+' — '+esc(iteration.appUpdateReview.summary)+'</small></p>':'';const receipts=(iteration.validationReceipts||[]).map(receipt=>{const digest=receipt.output&&receipt.output.redactedSha256?receipt.output.redactedSha256.slice(0,16):'';const meta=['kind:'+esc(receipt.kind||'—'),'exit:'+esc(receipt.exitCode??'—'),(receipt.durationMs?'ms:'+esc(receipt.durationMs):'')].filter(Boolean).join(' · ');return '<li><code>'+meta+'</code>'+((receipt.testEvidence&&receipt.testEvidence.passed===true)?' <b>[PASS]</b>':'')+'<br><small>digest <code>'+esc(digest||'—')+'</code> · '+(receipt.environment?esc(receipt.environment.imageRef||receipt.environment.ruby||'')+' · <code>'+esc(receipt.environment.imageId||receipt.environment.id||'')+'</code>':'')+'</small></li>';}).join('');const review=iteration.dependencyReview&&iteration.dependencyReview.completed?'<p><small>Dependency review: '+esc(iteration.dependencyReview.compatibility||'completed')+(iteration.dependencyReview.licenses?' · '+esc(iteration.dependencyReview.licenses):'')+'</small></p>':'';return '<section class="iteration"><b>'+esc(iteration.from)+' → '+esc(iteration.to)+' · '+esc(iteration.status)+'</b><div class="grid">'+metric('Test result',itTests.passed===true?'Passed':itTests.passed===false?'Failed':'—')+metric('Smoke check',testValue(itTests,'smoke'))+metric('Coverage',testValue(itTests,'coveragePercent',null)===null?'—':testValue(itTests,'coveragePercent')+'%')+'</div><ul>'+fixes+'</ul>'+review+((iteration.validationReceipts&&iteration.validationReceipts.length)?'<p><b>Receipts</b></p><ul>'+receipts+'</ul>':'')+appUpdate+'<br><small>Sources: '+citations+'</small></section>';}).join('');return '<article class="card"><span class="tag">'+esc(run.status||'unknown')+'</span><span class="tag">'+esc(target)+'</span><h2>'+esc(run.title||run.file)+'</h2><p class="sub">'+esc(run.startedAt||'Unknown start')+' · '+esc(run.branch||'Unknown branch')+'</p><div class="grid">'+metric('Tests',testValue(tests,'count'))+metric('Coverage',testValue(tests,'coveragePercent',null)===null?'—':testValue(tests,'coveragePercent')+'%')+metric('Duration',testValue(tests,'durationSeconds',null)===null?'—':testValue(tests,'durationSeconds')+'s')+metric('Smoke check',testValue(tests,'smoke'))+'</div><pre>'+esc((run.summary||[]).join('\n'))+'</pre>'+bridge+details+'<p><b>Local commits:</b> '+commits+'</p><p><b>Session:</b> '+esc(run.sessionSummary||'—')+'</p></article>';}).join('');
14
+ app.innerHTML=runs.map(run=>{const iterations=run.iterations||[];const latest=iterations.at(-1)||{};const tests=latest.tests||{};const commits=(run.localCommits||[]).map(sha=>'<code>'+esc(sha)+'</code>').join(' ')||'No local commit found yet.';const target=run.reportType==='rails_bridge'?'Rails '+(run.targetRails||'target unknown'):run.reportType==='bundler_bridge'?'Bundler '+(run.targetBundler||'target unknown'):'Ruby '+(run.targetRuby||'target unknown');const advisories=(run.advisories||[]).map(item=>'<li><span class="tag">'+esc(item.area)+'</span> <b>'+esc(item.title)+'</b><br><small>'+esc(item.detail)+'</small><br><small>Evidence: <code>'+esc(item.evidence)+'</code> · '+esc(item.confidence)+'</small></li>').join('')||'<li><small>No infrastructure inconsistencies detected against the current worktree.</small></li>';const advisoryBlock='<p><b>Infrastructure review (advisory)</b> <small>— about files this run did not change; nothing here blocks the upgrade</small></p><ul>'+advisories+'</ul>';const bridge=run.bridge?'<p><b>Blocked Ruby hop:</b> '+esc(run.bridge.rubyFrom)+' → '+esc(run.bridge.rubyTo)+(run.bridge.minimumBundler?' · researched Bundler floor <code>'+esc(run.bridge.minimumBundler)+'</code>':'')+' · <code>'+esc(run.bridge.rubyReportPath)+'</code></p>':'';const details=iterations.map(iteration=>{const itTests=iteration.tests||{};const fixes=(iteration.fixes||[]).map(fix=>'<li><b>'+esc((fix.files||[]).join(', ')||'Migration fix')+'</b><br>'+esc(fix.explanation)+'</li>').join('')||'<li>No code or dependency fixes recorded.</li>';const citations=(iteration.citations||[]).map(citation=>'<a href="'+safeUrl(citation.url)+'" target="_blank" rel="noreferrer">'+esc(citation.title||citation.url)+'</a>').join(' · ')||'No citations recorded.';const appUpdate=iteration.appUpdateReview?'<p><small>app:update: '+esc(iteration.appUpdateReview.outcome)+' — '+esc(iteration.appUpdateReview.summary)+'</small></p>':'';const receipts=(iteration.validationReceipts||[]).map(receipt=>{const digest=receipt.output&&receipt.output.redactedSha256?receipt.output.redactedSha256.slice(0,16):'';const meta=['kind:'+esc(receipt.kind||'—'),'exit:'+esc(receipt.exitCode??'—'),(receipt.durationMs?'ms:'+esc(receipt.durationMs):'')].filter(Boolean).join(' · ');return '<li><code>'+meta+'</code>'+((receipt.testEvidence&&receipt.testEvidence.passed===true)?' <b>[PASS]</b>':'')+'<br><small>digest <code>'+esc(digest||'—')+'</code> · '+(receipt.environment?esc(receipt.environment.imageRef||receipt.environment.ruby||'')+' · <code>'+esc(receipt.environment.imageId||receipt.environment.id||'')+'</code>':'')+'</small></li>';}).join('');const pin=iteration.lockfilePin?'<p><small>BUNDLED WITH: <code>'+esc(iteration.lockfilePin)+'</code></small></p>':'';const review=iteration.dependencyReview&&iteration.dependencyReview.completed?'<p><small>Dependency review: '+esc(iteration.dependencyReview.compatibility||'completed')+(iteration.dependencyReview.licenses?' · '+esc(iteration.dependencyReview.licenses):'')+'</small></p>':'';return '<section class="iteration"><b>'+esc(iteration.from)+' → '+esc(iteration.to)+' · '+esc(iteration.status)+'</b><div class="grid">'+metric('Test result',itTests.passed===true?'Passed':itTests.passed===false?'Failed':'—')+metric('Smoke check',testValue(itTests,'smoke'))+metric('Coverage',testValue(itTests,'coveragePercent',null)===null?'—':testValue(itTests,'coveragePercent')+'%')+'</div><ul>'+fixes+'</ul>'+pin+review+((iteration.validationReceipts&&iteration.validationReceipts.length)?'<p><b>Receipts</b></p><ul>'+receipts+'</ul>':'')+appUpdate+'<br><small>Sources: '+citations+'</small></section>';}).join('');return '<article class="card"><span class="tag">'+esc(run.status||'unknown')+'</span><span class="tag">'+esc(target)+'</span><h2>'+esc(run.title||run.file)+'</h2><p class="sub">'+esc(run.startedAt||'Unknown start')+' · '+esc(run.branch||'Unknown branch')+'</p><div class="grid">'+metric('Tests',testValue(tests,'count'))+metric('Coverage',testValue(tests,'coveragePercent',null)===null?'—':testValue(tests,'coveragePercent')+'%')+metric('Duration',testValue(tests,'durationSeconds',null)===null?'—':testValue(tests,'durationSeconds')+'s')+metric('Smoke check',testValue(tests,'smoke'))+'</div><pre>'+esc((run.summary||[]).join('\n'))+'</pre>'+bridge+advisoryBlock+details+'<p><b>Local commits:</b> '+commits+'</p><p><b>Session:</b> '+esc(run.sessionSummary||'—')+'</p></article>';}).join('');
15
15
  }).catch(error=>{document.querySelector('#app').innerHTML='<article class="card">Could not load upgrade reports: '+esc(error.message)+'</article>';});
16
16
  </script></body></html>`;
package/src/dashboard.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { dashboardPage } from "./dashboard-page.js";
6
6
  import { readRun } from "./run-state.js";
7
+ import { advisoryFindings } from "./advisory.js";
7
8
 
8
9
  const MAX_RUN_FILES = 250;
9
10
  const MAX_RUN_BYTES = 1024 * 1024;
@@ -53,8 +54,15 @@ export function startDashboard({ root = process.cwd(), port = 0 } = {}) {
53
54
  return response.end("Method not allowed");
54
55
  }
55
56
  if (request.url === "/api/runs") {
57
+ // Advisory findings are recomputed per request rather than stored in the
58
+ // report. They describe the worktree as it stands now, so a stale value in
59
+ // a historical run would be worse than no value at all.
60
+ const runs = readUpgradeRuns(root).map((run) => {
61
+ try { return { ...run, advisories: advisoryFindings({ root, run, inventory: run.inventory ?? {} }) }; }
62
+ catch { return { ...run, advisories: [] }; }
63
+ });
56
64
  response.writeHead(200, { ...securityHeaders, "content-type": "application/json; charset=utf-8" });
57
- return response.end(JSON.stringify(readUpgradeRuns(root)));
65
+ return response.end(JSON.stringify(runs));
58
66
  }
59
67
  if (request.url === "/" || request.url === "/index.html") {
60
68
  response.writeHead(200, { ...securityHeaders, "content-type": "text/html; charset=utf-8" });
package/src/inventory.js CHANGED
@@ -23,7 +23,11 @@ export function inventoryProject(root = process.cwd()) {
23
23
  const deploy = ["Dockerfile", "docker-compose.yml", "Procfile", "app.json", "render.yaml", "fly.toml", "config/deploy.yml"].filter((file) => exists(root, file));
24
24
  return {
25
25
  root, supported: exists(root, "Gemfile"), framework: rails ? "rails" : "ruby", testFramework: rspec ? "rspec" : minitest ? "minitest" : "unknown",
26
- rubyDeclarations, rails: rails ? { declaredVersion: declaredRails, resolvedVersion: resolvedRails } : null, recommendedCommands: commands, ci, deploy,
26
+ rubyDeclarations, rails: rails ? { declaredVersion: declaredRails, resolvedVersion: resolvedRails } : null,
27
+ // Bundler switches to the version under `BUNDLED WITH`, so this is the
28
+ // version that will actually run -- not merely the newest one installed.
29
+ bundler: { bundledWith: lockfile.match(/^BUNDLED WITH\s*\r?\n\s+(\d+(?:\.\d+)+)/m)?.[1] ?? null },
30
+ recommendedCommands: commands, ci, deploy,
27
31
  requiresDecision: !exists(root, "Gemfile") || commands.length === 0,
28
32
  reason: !exists(root, "Gemfile") ? "No Gemfile found." : commands.length === 0 ? "No recognized test adapter found." : undefined
29
33
  };
package/src/run-state.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
+ import { advisoryFindings } from "./advisory.js";
5
6
 
6
7
  const SECRET_VALUE = /(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:xox[baprs]-|npm_|glpat-)[A-Za-z0-9_-]{16,})/g;
7
8
  const SECRET_KEY = /(?:password|secret|token|api[_-]?key|credential|authorization)/i;
@@ -30,6 +31,8 @@ export function redactSourceUrl(value) {
30
31
 
31
32
  function validVersion(value) { return typeof value === "string" && /^\d+\.\d+(?:\.(?:\d+|x))?$/.test(value); }
32
33
  function validRailsVersion(value) { return typeof value === "string" && /^\d+(?:\.\d+)+$/.test(value); }
34
+ function validBundlerVersion(value) { return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value); }
35
+ const bundlerSeriesOf = (value) => String(value ?? "").split(".").slice(0, 2).join(".");
33
36
  function validCitation(value) { return value && typeof value.title === "string" && /^https:\/\//.test(value.url ?? ""); }
34
37
  function safeFiles(files) { return Array.isArray(files) && files.every((file) => typeof file === "string" && !path.isAbsolute(file) && !file.includes("..")); }
35
38
  function validAppUpdateReview(review) { return review && review.command === "bin/rails app:update" && typeof review.receiptId === "string" && !Number.isNaN(Date.parse(review.executedAt ?? "")) && !Number.isNaN(Date.parse(review.reviewedAt ?? "")) && validFingerprint(review.worktree) && ["no_changes", "changes_applied", "changes_deferred"].includes(review.outcome) && safeFiles(review.files) && typeof review.summary === "string" && Boolean(review.summary); }
@@ -59,6 +62,31 @@ function receiptErrors(iteration, index, rails, required, errors) {
59
62
  }
60
63
  }
61
64
 
65
+ function validateBundlerBridge(run, errors) {
66
+ for (const key of ["runId", "title", "startedAt", "targetBundler", "targetBundlerPinnedAt", "phase", "status", "bridge"]) if (!run[key]) errors.push(`Bundler bridge report requires ${key}.`);
67
+ if (!validBundlerVersion(run.targetBundler)) errors.push("targetBundler must be a Bundler version.");
68
+ if (typeof run.runId !== "string" || !/^[a-f0-9-]{36}$/i.test(run.runId ?? "")) errors.push("runId must be a UUID.");
69
+ if (run.startedAt && Number.isNaN(Date.parse(run.startedAt))) errors.push("startedAt must be ISO-8601.");
70
+ if (!statuses.has(run.status) || !phases.has(run.phase)) errors.push("Bundler bridge has invalid status or phase.");
71
+ if (run.lockNonce !== undefined && (typeof run.lockNonce !== "string" || !/^[a-f0-9-]{36}$/i.test(run.lockNonce))) errors.push("lockNonce must be a UUID.");
72
+ if (run.targetBundlerPinnedAt && Number.isNaN(Date.parse(run.targetBundlerPinnedAt))) errors.push("targetBundlerPinnedAt must be ISO-8601.");
73
+ // The link back to the blocked Ruby run is what makes the bridge separately
74
+ // scoped rather than a second phase of the same run, so it must be complete.
75
+ if (!run.bridge || typeof run.bridge !== "object" || typeof run.bridge.rubyReportPath !== "string" || !/^[a-f0-9-]{36}$/i.test(run.bridge.rubyRunId ?? "") || !validVersion(run.bridge.rubyFrom) || !validVersion(run.bridge.rubyTo) || !validBundlerVersion(run.bridge.bundlerFrom) || !validBundlerVersion(run.bridge.bundlerTo) || !validBundlerVersion(run.bridge.minimumBundler)) errors.push("Bundler bridge report has invalid Ruby-run linkage.");
76
+ if (!Array.isArray(run.research?.ladder) || run.research.ladder.some((version) => !validBundlerVersion(version))) errors.push("Bundler bridge research.ladder must contain Bundler versions.");
77
+ if (!Array.isArray(run.research?.citations) || run.research.citations.some((citation) => !validCitation(citation))) errors.push("research.citations must contain HTTPS citations.");
78
+ if (!Array.isArray(run.iterations)) errors.push("iterations must be an array.");
79
+ for (const [index, iteration] of (run.iterations ?? []).entries()) {
80
+ if (!validBundlerVersion(iteration?.from) || !validBundlerVersion(iteration?.to) || iteration?.status !== "complete") errors.push(`Bundler iteration ${index + 1} is invalid.`);
81
+ if (!safeFiles(iteration?.files) || iteration?.tests?.passed !== true || !iteration.tests?.command || !iteration.tests?.smoke || !Array.isArray(iteration?.citations) || !iteration.citations.length || !Array.isArray(iteration?.fixes) || iteration.fixes.some((fix) => !safeFiles(fix.files) || !fix.explanation)) errors.push(`Bundler iteration ${index + 1} lacks required evidence.`);
82
+ // The pin the hop actually produced, so a later reader can see the lockfile
83
+ // agreed with the validated version instead of taking it on trust.
84
+ if (!validBundlerVersion(iteration?.lockfilePin) || bundlerSeriesOf(iteration.lockfilePin) !== bundlerSeriesOf(iteration.to)) errors.push(`Bundler iteration ${index + 1} must record the BUNDLED WITH pin it produced.`);
85
+ if (iteration?.checkpointSha !== undefined && !/^[a-f0-9]{40}$/i.test(iteration.checkpointSha)) errors.push(`Bundler iteration ${index + 1} checkpointSha must be a Git SHA.`);
86
+ receiptErrors(iteration, index, false, run.validationReceiptsRequired === true, errors);
87
+ }
88
+ }
89
+
62
90
  function validateRailsBridge(run, errors) {
63
91
  for (const key of ["runId", "title", "startedAt", "targetRails", "targetRailsPinnedAt", "phase", "status", "bridge"]) if (!run[key]) errors.push(`Rails bridge report requires ${key}.`);
64
92
  if (!validRailsVersion(run.targetRails)) errors.push("targetRails must be a Rails version.");
@@ -88,6 +116,7 @@ export function validateRun(run) {
88
116
  if (run.schemaVersion !== 2) errors.push("Report schemaVersion must be 2.");
89
117
  if (run.validationReceiptsRequired !== undefined && run.validationReceiptsRequired !== true) errors.push("validationReceiptsRequired must be true when present.");
90
118
  if (run.reportType === "rails_bridge") { validateRailsBridge(run, errors); return { valid: errors.length === 0, errors }; }
119
+ if (run.reportType === "bundler_bridge") { validateBundlerBridge(run, errors); return { valid: errors.length === 0, errors }; }
91
120
  if (run.reportType !== undefined && run.reportType !== "ruby") errors.push("Unknown reportType.");
92
121
  for (const key of ["runId", "title", "startedAt", "targetRuby", "targetPinnedAt", "phase", "status"]) if (!run[key]) errors.push(`Report requires ${key}.`);
93
122
  if (run.lockNonce !== undefined && (typeof run.lockNonce !== "string" || !/^[a-f0-9-]{36}$/i.test(run.lockNonce))) errors.push("lockNonce must be a UUID.");
@@ -109,6 +138,16 @@ export function validateRun(run) {
109
138
  if (typeof bridge?.rationale !== "string" || !bridge.rationale) errors.push("frameworkBridge requires a rationale.");
110
139
  if (!Array.isArray(bridge?.citations) || !bridge.citations.length || bridge.citations.some((citation) => !validCitation(citation))) errors.push("frameworkBridge requires HTTPS citations.");
111
140
  }
141
+ if (run.bundlerBridge !== undefined && run.bundlerBridge !== null) {
142
+ const bridge = run.bundlerBridge;
143
+ if (!bridge || typeof bridge !== "object" || bridge.status !== "approved") errors.push("bundlerBridge must be an approved compatibility decision.");
144
+ if (!validVersion(bridge?.rubyFrom) || !validVersion(bridge?.rubyTo) || !validBundlerVersion(bridge?.bundlerFrom) || !validBundlerVersion(bridge?.bundlerTo) || !validBundlerVersion(bridge?.minimumBundler)) errors.push("bundlerBridge requires Ruby from/to and Bundler from/to/floor versions.");
145
+ // The researched floor is what justifies blocking the hop, so its provenance
146
+ // has to be recorded rather than asserted.
147
+ if (typeof bridge?.compatibilitySource !== "string" || !/^https:\/\//.test(bridge.compatibilitySource)) errors.push("bundlerBridge requires the official compatibility source.");
148
+ if (typeof bridge?.rationale !== "string" || !bridge.rationale) errors.push("bundlerBridge requires a rationale.");
149
+ if (!Array.isArray(bridge?.citations) || !bridge.citations.length || bridge.citations.some((citation) => !validCitation(citation))) errors.push("bundlerBridge requires HTTPS citations.");
150
+ }
112
151
  for (const [index, iteration] of (run.iterations ?? []).entries()) {
113
152
  if (!validVersion(iteration?.from) || !validVersion(iteration?.to)) errors.push(`Iteration ${index + 1} requires Ruby from/to versions.`);
114
153
  if (iteration?.status !== "complete") errors.push(`Iteration ${index + 1} must be complete.`);
@@ -173,13 +212,63 @@ export function writeRun(root, relativePath, run) {
173
212
  const markdown = file.replace(/\.json$/, ".md");
174
213
  if (fs.existsSync(markdown) && (!fs.lstatSync(markdown).isFile() || fs.lstatSync(markdown).isSymbolicLink())) throw new RunStateError("Markdown evidence must be a regular file inside the worktree.", "unsafe-report-path");
175
214
  const safe = redact(run);
176
- const target = safe.reportType === "rails_bridge" ? `- **Target Rails:** ${safe.targetRails}` : `- **Target Ruby:** ${safe.targetRuby}`;
177
- const markdownBody = `# ${safe.title}\n\n- **Status:** ${safe.status}\n- **Phase:** ${safe.phase}\n${target}\n- **Started:** ${safe.startedAt}\n\n## Durable evidence\n\n\`\`\`json\n${JSON.stringify(safe, null, 2)}\n\`\`\`\n`;
215
+ const target = safe.reportType === "rails_bridge" ? `- **Target Rails:** ${safe.targetRails}` : safe.reportType === "bundler_bridge" ? `- **Target Bundler:** ${safe.targetBundler}` : `- **Target Ruby:** ${safe.targetRuby}`;
216
+ const followUps = followUpActions(safe);
217
+ const advisories = collectAdvisories(root, safe);
218
+ const followUpBlock = followUps.length
219
+ ? `\n## Follow-up actions\n\nThese are yours to take. Nothing below was verified by this run; the run cannot test your production topology.\n\n${followUps.map((item) => `- ${item}`).join("\n")}\n`
220
+ : "";
221
+ // Advisory only: never a gate. Rendered as its own section so it is obvious
222
+ // these are observations about the reader's infrastructure, not evidence the
223
+ // run collected about the app.
224
+ const advisoryBlock = advisories.length
225
+ ? `\n## Infrastructure review (advisory)\n\nRead-only observations about files this run did not change. Nothing here blocks the upgrade, and an intentional version pin that lags the app is legitimate. Confirm each against your actual deploy platform.\n\n| Area | Finding | Evidence | Basis |\n| --- | --- | --- | --- |\n${advisories.map((item) => `| ${item.area} | ${item.title}<br>${item.detail} | \`${item.evidence.replace(/\|/g, "\\|")}\` | ${item.confidence} |`).join("\n")}\n`
226
+ : "";
227
+ const markdownBody = `# ${safe.title}\n\n- **Status:** ${safe.status}\n- **Phase:** ${safe.phase}\n${target}\n- **Started:** ${safe.startedAt}\n${followUpBlock}${advisoryBlock}\n## Durable evidence\n\n\`\`\`json\n${JSON.stringify(safe, null, 2)}\n\`\`\`\n`;
178
228
  const markdownTemporary = path.join(path.dirname(markdown), `.${path.basename(markdown)}.${process.pid}.${crypto.randomUUID()}.tmp`);
179
229
  fs.writeFileSync(markdownTemporary, markdownBody, { mode: 0o600, flag: "wx" });
180
230
  fs.renameSync(markdownTemporary, markdown);
181
231
  }
182
232
 
233
+ // Deployment-visible items a local test run provably cannot check. The isolated
234
+ // runtime proves the code runs on the target Ruby in a container; it says nothing
235
+ // about the host that will actually serve it. Each item is phrased as something
236
+ // the reader must verify, never as something this run established, because a run
237
+ // that "passed" here has not touched the real deploy target at all.
238
+ function followUpActions(safe) {
239
+ const actions = [];
240
+ const last = safe.iterations?.at(-1);
241
+ if (last?.checkpointSha) actions.push(`Checkpoint \`${last.checkpointSha.slice(0, 7)}\` is validated locally but not deployed: push it and run your own staging check before it reaches production.`);
242
+ if (safe.phase === "blocked" || safe.status === "blocked") actions.push("This run is blocked. Resolve or explicitly approve the outstanding risks before treating the upgrade as complete.");
243
+ if (safe.frameworkBridge?.status === "approved") actions.push(`A Rails ${safe.frameworkBridge.railsFrom} → ${safe.frameworkBridge.railsTo} bridge was approved here but not performed. It needs its own separate run.`);
244
+ if (safe.bundlerBridge?.status === "approved") actions.push(`A Bundler ${safe.bundlerBridge.bundlerFrom} → ${safe.bundlerBridge.bundlerTo} bridge was approved here but not performed, on a researched floor of ${safe.bundlerBridge.minimumBundler}. Run \`begin-bundler-bridge\`, then restart the Ruby upgrade.`);
245
+ const environment = safe.environment;
246
+ if (environment?.type === "docker") {
247
+ // The single most common surprise: the container pins Bundler 2.4.22 and the
248
+ // app's own CI or deploy host may resolve a different one, changing which
249
+ // lockfile semantics apply.
250
+ actions.push(`The isolated runtime used Bundler ${environment.bundlerVersion ?? "a pinned version (unrecorded)"}${environment.bundlerVersion ? ` on Ruby ${environment.ruby}` : ""}. Confirm your deploy host and CI resolve the same Bundler version, or re-run \`bundle lock\` there.`);
251
+ actions.push(`Native gems were compiled for this container (${environment.database ?? "database"} on Ruby ${environment.ruby}). Rebuild native extensions on your deploy platform rather than copying \`node_modules\`-style build output.`);
252
+ actions.push(`Your deploy host must reach the real database directly; this run used an isolated ${environment.database} reachable only over a private Docker network and deliberately published no ports.`);
253
+ actions.push("Prepared containers and networks persist after the run by design. Remove them when you are done: `docker rm -f` and `docker network rm` on the names in `.ruby-upgrades/runtime.json`.");
254
+ }
255
+ actions.push("Review the validation receipts in `.ruby-upgrades/runs/` for the exact commands, exit codes, and test evidence before merging.");
256
+ return actions;
257
+ }
258
+
259
+ // Read the worktree's own infrastructure and compare it against what this run
260
+ // changed. Wrapped because the report must still render when a worktree cannot
261
+ // be inspected (an explicit `--report` from elsewhere, or a half-removed tree);
262
+ // a missing advisory section must not fail a run whose evidence is already
263
+ // recorded.
264
+ export function collectAdvisories(root, safe) {
265
+ try {
266
+ return advisoryFindings({ root, run: safe, inventory: safe.inventory ?? {} });
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
183
272
  function lockPath(root) {
184
273
  try { return execFileSync("git", ["rev-parse", "--git-path", "opencode-ruby-upgrade.lock"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
185
274
  catch { return path.join(runsDirectory(root, true), ".run.lock"); }
@@ -46,27 +46,36 @@ function preparationResult(result) {
46
46
  return { sha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) };
47
47
  }
48
48
  function bootstrap(spawn, runtime, db, rails) {
49
- const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? [`${db.label} test database initialization`] : []), "Ruby version attestation"];
49
+ const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? [`${db.label} test database initialization`] : []), "Ruby version attestation", "Bundler version attestation"];
50
50
  const commands = [
51
51
  ["exec", runtime.appContainer, "sh", "-c", NODE_INSTALL],
52
- ["exec", runtime.appContainer, "gem", "install", "bundler", "-v", "2.4.22", "--no-document"],
53
- ["exec", runtime.appContainer, "bundle", "_2.4.22_", "install"],
52
+ ["exec", runtime.appContainer, "gem", "install", "bundler", "-v", runtime.bundlerToInstall ?? "2.4.22", "--no-document"],
53
+ ["exec", runtime.appContainer, "bundle", `_${runtime.bundlerToInstall ?? "2.4.22"}_`, "install"],
54
54
  ...(rails ? [db.createArgs({ appContainer: runtime.appContainer, databaseContainer: runtime.databaseContainer })] : []),
55
- ["exec", runtime.appContainer, "ruby", "--version"]
55
+ ["exec", runtime.appContainer, "ruby", "--version"],
56
+ // Attest the Bundler version actually installed, so the report can warn when
57
+ // CI/host bundler differs. `gem install` output is advisory; `--version` is
58
+ // authoritative for what `bundle exec` will actually resolve.
59
+ ["exec", runtime.appContainer, "bundle", `_${runtime.bundlerToInstall ?? "2.4.22"}_`, "--version"]
56
60
  ];
57
61
  const results = commands.map((args, index) => {
58
62
  const result = docker(spawn, args);
59
63
  if (result.status !== 0) throw new Error(`Target runtime bootstrap failed during ${labels[index]}. Rerun prepare-target-runtime --ruby <x.y.z>.`);
60
64
  return result;
61
65
  });
62
- const rubyOutput = `${results.at(-1).stdout ?? ""}${results.at(-1).stderr ?? ""}`;
66
+ // Index the attestations explicitly rather than from the end of the array:
67
+ // `ruby --version` is no longer the final command, and an off-by-one here would
68
+ // validate the Bundler banner against the Ruby regex.
69
+ const rubyIndex = 3 + (rails ? 1 : 0);
70
+ const rubyOutput = `${results[rubyIndex].stdout ?? ""}${results[rubyIndex].stderr ?? ""}`;
63
71
  if (!new RegExp(`^ruby ${runtime.ruby.replaceAll(".", "\\.")}(?:p\\d+|\\s|$)`).test(rubyOutput.trim())) throw new Error("Target runtime did not execute the requested Ruby version.");
64
72
  return {
65
73
  node: preparationResult(results[0]),
66
74
  bundler: preparationResult(results[1]),
67
75
  bundleInstall: preparationResult(results[2]),
68
76
  ...(rails ? { databaseCreate: preparationResult(results[3]) } : {}),
69
- rubyVersion: preparationResult(results.at(-1))
77
+ rubyVersion: preparationResult(results[rubyIndex]),
78
+ bundlerVersion: `${results.at(-1).stdout ?? ""}${results.at(-1).stderr ?? ""}`.trim()
70
79
  };
71
80
  }
72
81
  function railsProject(root) {
@@ -164,7 +173,9 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, d
164
173
  const canonical = canonicalRoot(root);
165
174
  const db = resolveDatabase(database ?? detectDatabase(canonical));
166
175
  const selected = selectedRun(canonical, reportPath);
167
- const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, database: db.adapter, ...names(selected.run.runId, db) };
176
+ const run = readRun(canonical, selected.reportPath);
177
+ const bundlerToInstall = run.reportType === "bundler_bridge" ? run.targetBundler : (run.bundlerBridge ? run.bundlerBridge.bundlerTo : undefined) || "2.4.22";
178
+ const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, database: db.adapter, bundlerToInstall, ...names(selected.run.runId, db) };
168
179
  const owner = identity(runtime.runId, canonical);
169
180
  const existing = readTargetRuntime(canonical);
170
181
  if (existing && existing.runId !== runtime.runId) {
@@ -250,5 +261,10 @@ export function validateTargetRuntime({ root = process.cwd(), runtime = readTarg
250
261
  const expectedIds = [app?.Id, database?.Id].sort();
251
262
  const valid = appMatches(app, canonical, runtime, db) && databaseMatches(database, runtime, db) && hasOwnership(network, owner) && hasOwnership(app, owner) && hasOwnership(database, owner) && connectedIds.length === 2 && connectedIds.every((id, index) => id === expectedIds[index]) && app?.Image === runtime.resolvedImageId && (!runtime.databaseImageId || database?.Image === runtime.databaseImageId) && env.RAILS_ENV === "test" && env.DATABASE_URL === db.databaseUrl(runtime.databaseContainer);
252
263
  if (!valid) throw new Error("Target runtime no longer matches its prepared run. Rerun prepare-target-runtime --ruby <x.y.z>.");
253
- return { runId: runtime.runId, reportPath: runtime.reportPath, name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby, database: runtime.database, databaseContainer: runtime.databaseContainer, databaseContainerId: database.Id, databaseImageId: database.Image, databaseImageRef: database.Config.Image, network: runtime.network, networkId: network.Id };
264
+ // bundlerVersion is surfaced so the generated report can warn that the deploy
265
+ // host and CI may resolve a different Bundler than this container pinned. It
266
+ // comes from `bundle --version` in the prepared container, not from the
267
+ // bootstrap log, so it reflects what `bundle exec` actually resolved.
268
+ const bundlerVersion = /Bundler version (\d+(?:\.\d+)+)/.exec(runtime.preparation?.bundlerVersion ?? "")?.[1] ?? undefined;
269
+ return { runId: runtime.runId, reportPath: runtime.reportPath, name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby, ...(bundlerVersion ? { bundlerVersion } : {}), database: runtime.database, databaseContainer: runtime.databaseContainer, databaseContainerId: database.Id, databaseImageId: database.Image, databaseImageRef: database.Config.Image, network: runtime.network, networkId: network.Id };
254
270
  }