opencode-ruby-upgrader 0.1.7 → 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.
@@ -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/databases.js CHANGED
@@ -40,7 +40,11 @@ export const databases = Object.freeze({
40
40
  startArgs: () => ["--env", "POSTGRES_HOST_AUTH_METHOD=trust", "--env", `POSTGRES_DB=${DB_NAME}`, "postgres:16-alpine"],
41
41
  readyArgs: (container) => ["exec", container, "pg_isready", "-U", "postgres", "-d", DB_NAME],
42
42
  // Use the app's own Rails task so framework-specific database setup remains
43
- // consistent with the project's PostgreSQL adapter.
43
+ // consistent with the project's PostgreSQL adapter. DATABASE_URL is already
44
+ // set on the app container and Rails prefers it over database.yml, so a
45
+ // project pinning `host: localhost` or a socket-only DSN still resolves to
46
+ // the isolated container -- no rewriting of the config under test, which
47
+ // would be a source change to the worktree.
44
48
  createArgs: ({ appContainer }) => ["exec", appContainer, "bundle", "exec", "rake", "db:create"],
45
49
  databaseUrl: (container) => `postgresql://postgres@${container}:5432/${DB_NAME}`,
46
50
  ensureReady: ({ probe }) => readyLoop({ probe, attempts: 30, label: "PostgreSQL" })
@@ -75,18 +79,42 @@ export function resolveDatabase(adapter) {
75
79
  return databases[key];
76
80
  }
77
81
 
82
+ // Declarative forms only. A bare word is not a declaration: real
83
+ // `config/database.yml` files carry YAML keys that merely *name* a profile
84
+ // (rails/rails ships `connections:` with `mysql2:` and `postgresql:` keys and
85
+ // only `adapter: sqlite3` in play), and a Gemfile may carry a driver for an
86
+ // unrelated tool. Matching bare tokens made detection asymmetric -- mysql2
87
+ // matched anywhere while postgres required `adapter:` -- so the bias could only
88
+ // ever produce a false MySQL answer, silently.
89
+ const MYSQL = [/\badapter\s*:\s*mysql2\b/i, /\bgem\s*\(?\s*["']mysql2["']/i, /\bmysql2:\/\//i];
90
+ const POSTGRES = [/\badapter\s*:\s*(?:postgresql|postgres)\b/i, /\bgem\s*\(?\s*["']pg["']/i, /\b(?:postgresql|postgres):\/\//i];
91
+ // Gemfile.lock records resolved specs as ` mysql2 (0.5.6)` / ` pg (1.6.2)`.
92
+ const MYSQL_LOCK = /^[ \t]*mysql2 \(/m;
93
+ const POSTGRES_LOCK = /^[ \t]*pg \(/m;
94
+
78
95
  // Best-effort adapter detection from the project's own declarations. An
79
96
  // explicit `--database` always wins; this only fills the gap so the common case
80
97
  // needs no flag.
81
98
  export function detectDatabase(root = process.cwd()) {
82
99
  const read = (file) => { try { return fs.readFileSync(path.join(root, file), "utf8"); } catch { return ""; } };
83
100
  const uncommented = (contents) => contents.replace(/#.*$/gm, "");
84
- const corpus = [read("Gemfile"), read("Gemfile.lock"), read("config/database.yml")].map(uncommented).join("\n");
85
- const mysql = /\bmysql2\b/i.test(corpus) || /adapter:\s*mysql2\b/i.test(corpus);
86
- const postgres = /\bpg\b/i.test(corpus) || /adapter:\s*(?:postgresql|postgres)\b/i.test(corpus) || /postgresql:\/\//i.test(corpus);
87
- if (mysql && postgres) throw new Error("Both MySQL and PostgreSQL were detected. Specify --database mysql or --database postgres.");
88
- if (mysql && !postgres) return "mysql";
89
- return "postgres";
101
+ const declares = (contents, lock) => ({
102
+ mysql: MYSQL.some((re) => re.test(contents)) || (lock && MYSQL_LOCK.test(contents)),
103
+ postgres: POSTGRES.some((re) => re.test(contents)) || (lock && POSTGRES_LOCK.test(contents)),
104
+ });
105
+ const resolve = ({ mysql, postgres }) => {
106
+ if (mysql && postgres) throw new Error("Both MySQL and PostgreSQL were detected. Specify --database mysql or --database postgres.");
107
+ return mysql ? "mysql" : postgres ? "postgres" : null;
108
+ };
109
+ // `config/database.yml` is what the app actually connects to; a `gem` line
110
+ // only says a driver is *available*. Discourse, for instance, is PostgreSQL
111
+ // (`adapter: postgresql` throughout) but carries `gem "mysql2"` behind an
112
+ // import-mode conditional. OR-ing both files reported that as ambiguous and
113
+ // needlessly forced an explicit flag on an unambiguous project.
114
+ const configured = resolve(declares(uncommented(read("config/database.yml")), false));
115
+ if (configured) return configured;
116
+ const gems = resolve(declares(`${uncommented(read("Gemfile"))}\n${uncommented(read("Gemfile.lock"))}`, true));
117
+ return gems ?? "postgres";
90
118
  }
91
119
 
92
120
  export { DB_NAME, NAME };
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
  };