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.
- package/PRIVACY.md +2 -0
- package/README.md +39 -8
- package/RELEASE_CHECKLIST.md +1 -1
- package/RELEASE_NOTES.md +64 -0
- package/agents/ruby-upgrade.md +4 -2
- package/bin/opencode-ruby-upgrader.mjs +18 -2
- package/docs/bundler-bridge.md +107 -0
- package/package.json +35 -7
- package/src/advisory.js +140 -0
- package/src/bundler-compat.js +68 -0
- package/src/commit-hop.js +24 -13
- package/src/controller.js +81 -7
- package/src/dashboard-page.js +1 -1
- package/src/dashboard.js +9 -1
- package/src/databases.js +35 -7
- package/src/inventory.js +5 -1
- package/src/run-state.js +91 -2
- package/src/target-runtime.js +24 -8
|
@@ -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,
|
|
37
|
+
function assertValidatedIteration(report, bridge = null) {
|
|
38
38
|
const iteration = report.iterations?.at(-1);
|
|
39
|
-
|
|
40
|
-
|
|
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,
|
|
169
|
+
function messageFor(iteration, reportPath, report, bridge = null) {
|
|
165
170
|
const receiptTrailer = iteration.validationReceipts?.length ? [`Validation-Receipt-Digest: sha256:${receiptDigest(iteration.validationReceipts)}`] : [];
|
|
166
|
-
if (
|
|
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,
|
|
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,
|
|
195
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
package/src/dashboard-page.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
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
|
};
|