opencode-ruby-upgrader 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/E2E_EVIDENCE.md +186 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +9 -0
- package/README.md +118 -0
- package/RELEASE_CHECKLIST.md +17 -0
- package/RELEASE_NOTES.md +41 -0
- package/RELEASING.md +9 -0
- package/SECURITY.md +11 -0
- package/agents/ruby-upgrade.md +91 -0
- package/bin/opencode-ruby-upgrader.mjs +121 -0
- package/package.json +28 -0
- package/src/commit-hop.js +234 -0
- package/src/controller.js +273 -0
- package/src/dashboard-page.js +16 -0
- package/src/dashboard.js +67 -0
- package/src/index.js +112 -0
- package/src/inventory.js +30 -0
- package/src/preflight.js +69 -0
- package/src/provenance.js +21 -0
- package/src/run-state.js +200 -0
- package/src/supply-chain.js +16 -0
- package/src/target-runtime.js +165 -0
- package/src/test-evidence.js +9 -0
- package/src/validation-executor.js +45 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { inspectGitCapabilities, inspectWorktree, setupInstructions } from "../src/preflight.js";
|
|
3
|
+
import { startDashboard } from "../src/dashboard.js";
|
|
4
|
+
import { commitValidatedHop, commitValidatedRailsHop, CommitGateError } from "../src/commit-hop.js";
|
|
5
|
+
import { acquireRunLock, readRun, releaseRunLock, RunStateError } from "../src/run-state.js";
|
|
6
|
+
import { beginRailsBridgeRun, beginRun, discardLastRailsIteration, discardPendingRailsAppUpdate, recordDependencyReview, recordExecutedIteration, recordExecutedRailsIteration, recordFrameworkBridge, recordRailsResearch, recordResearch, recordRiskDecision, resumeRun, runStatus, transitionRun } from "../src/controller.js";
|
|
7
|
+
import { inventoryProject } from "../src/inventory.js";
|
|
8
|
+
import { inspectSupplyChain } from "../src/supply-chain.js";
|
|
9
|
+
import { prepareTargetRuntime } from "../src/target-runtime.js";
|
|
10
|
+
|
|
11
|
+
const [command, ...args] = process.argv.slice(2);
|
|
12
|
+
const option = (name) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; };
|
|
13
|
+
const options = (name) => args.flatMap((argument, index) => argument === name && args[index + 1] ? [args[index + 1]] : []);
|
|
14
|
+
const reportOption = () => option("--report");
|
|
15
|
+
const citation = (value) => { const [title, url] = (value ?? "").split("|"); return { title, url }; };
|
|
16
|
+
const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-risk|record-framework-bridge|record-executed-iteration|record-executed-rails-iteration|discard-pending-app-update|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|resume|release-lock> [--help]";
|
|
17
|
+
if (command === "help" || args.includes("--help")) {
|
|
18
|
+
console.log(`${usage}\n\nUse status --summary for a concise report view. release-lock is stale-session recovery only and requires --force.`);
|
|
19
|
+
} else if (command === "preflight") {
|
|
20
|
+
const result = inspectWorktree();
|
|
21
|
+
if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
22
|
+
else if (result.ok && result.mode === "non-git") console.log("✓ No Git repository detected; proceeding without Git checkpoints or worktree isolation.");
|
|
23
|
+
else if (result.ok) console.log(`✓ Linked worktree: ${result.root}\n✓ Branch: ${result.branch}\n✓ Starting commit: ${result.sha}\n✓ Remote writes: disabled`);
|
|
24
|
+
else console.error(`${setupInstructions(result)}\n\nPreflight blocked: ${result.reason}`);
|
|
25
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
26
|
+
} else if (command === "dashboard") {
|
|
27
|
+
const server = await startDashboard();
|
|
28
|
+
const address = server.address();
|
|
29
|
+
console.log(`Ruby Upgrade Workspace: http://127.0.0.1:${address.port}`);
|
|
30
|
+
} else if (command === "begin") {
|
|
31
|
+
try { console.log(JSON.stringify(beginRun({ target: option("--target"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop"), allowNonGit: args.includes("--allow-non-git") }), null, 2)); }
|
|
32
|
+
catch (error) { console.error(`Run start blocked: ${error.message}`); process.exitCode = 1; }
|
|
33
|
+
} else if (command === "begin-rails-bridge") {
|
|
34
|
+
try { console.log(JSON.stringify(beginRailsBridgeRun({ rubyReportPath: option("--ruby-report"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop"), allowNonGit: args.includes("--allow-non-git") }), null, 2)); }
|
|
35
|
+
catch (error) { console.error(`Rails bridge start blocked: ${error.message}`); process.exitCode = 1; }
|
|
36
|
+
} else if (command === "prepare-target-runtime") {
|
|
37
|
+
try {
|
|
38
|
+
if (!option("--ruby") || args.some((argument) => !["--ruby", "--report", option("--ruby"), option("--report")].includes(argument))) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--report .ruby-upgrades/runs/<run>.json]");
|
|
39
|
+
console.log(JSON.stringify(prepareTargetRuntime({ ruby: option("--ruby"), reportPath: option("--report") }), null, 2));
|
|
40
|
+
} catch (error) { console.error(`Target runtime preparation blocked: ${error.message}`); process.exitCode = 1; }
|
|
41
|
+
} else if (command === "status") {
|
|
42
|
+
try {
|
|
43
|
+
const run = runStatus({ reportPath: option("--report") });
|
|
44
|
+
if (args.includes("--summary")) {
|
|
45
|
+
const target = run.reportType === "rails_bridge" ? `Rails ${run.targetRails}` : `Ruby ${run.targetRuby}`;
|
|
46
|
+
const latest = run.iterations.at(-1); const checkpoint = latest?.checkpointSha ? `checkpoint ${latest.checkpointSha.slice(0, 12)}` : "no checkpoint yet";
|
|
47
|
+
console.log(`${run.title}\n${target} · ${run.status}/${run.phase} · ${checkpoint}\nReport: ${option("--report")}`);
|
|
48
|
+
} else console.log(JSON.stringify(run, null, 2));
|
|
49
|
+
}
|
|
50
|
+
catch (error) { console.error(`Run status failed: ${error.message}`); process.exitCode = 1; }
|
|
51
|
+
} else if (command === "transition") {
|
|
52
|
+
try { console.log(JSON.stringify(transitionRun({ reportPath: reportOption(), phase: option("--phase"), note: option("--note"), commitSha: option("--commit-sha") }), null, 2)); }
|
|
53
|
+
catch (error) { console.error(`State transition blocked: ${error.message}`); process.exitCode = 1; }
|
|
54
|
+
} else if (command === "record-research") {
|
|
55
|
+
try { console.log(JSON.stringify(recordResearch({ reportPath: reportOption(), ladder: (option("--ladder") ?? "").split(",").filter(Boolean), citations: options("--citation").map(citation) }), null, 2)); }
|
|
56
|
+
catch (error) { console.error(`Research recording blocked: ${error.message}`); process.exitCode = 1; }
|
|
57
|
+
} else if (command === "record-rails-research") {
|
|
58
|
+
try { console.log(JSON.stringify(recordRailsResearch({ reportPath: reportOption(), ladder: (option("--ladder") ?? "").split(",").filter(Boolean), citations: options("--citation").map(citation) }), null, 2)); }
|
|
59
|
+
catch (error) { console.error(`Rails research recording blocked: ${error.message}`); process.exitCode = 1; }
|
|
60
|
+
} else if (command === "record-risk") {
|
|
61
|
+
try { console.log(JSON.stringify(recordRiskDecision({ reportPath: reportOption(), risk: option("--risk"), decision: option("--decision"), evidence: option("--evidence") }), null, 2)); }
|
|
62
|
+
catch (error) { console.error(`Risk recording blocked: ${error.message}`); process.exitCode = 1; }
|
|
63
|
+
} else if (command === "record-framework-bridge") {
|
|
64
|
+
try { console.log(JSON.stringify(recordFrameworkBridge({ reportPath: reportOption(), rubyFrom: option("--ruby-from"), rubyTo: option("--ruby-to"), railsFrom: option("--rails-from"), railsTo: option("--rails-to"), rationale: option("--rationale"), citations: options("--citation").map(citation) }), null, 2)); }
|
|
65
|
+
catch (error) { console.error(`Rails compatibility bridge blocked: ${error.message}`); process.exitCode = 1; }
|
|
66
|
+
} else if (command === "record-executed-iteration") {
|
|
67
|
+
try { console.log(JSON.stringify(recordExecutedIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--json") ?? ""), validationCommandId: option("--validation") }), null, 2)); }
|
|
68
|
+
catch (error) { console.error(`Executed validation blocked: ${error.message}`); process.exitCode = 1; }
|
|
69
|
+
} else if (command === "record-executed-rails-iteration") {
|
|
70
|
+
try { console.log(JSON.stringify(recordExecutedRailsIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--json") ?? ""), testValidationCommandId: option("--validation") }), null, 2)); }
|
|
71
|
+
catch (error) { console.error(`Executed Rails validation blocked: ${error.message}`); process.exitCode = 1; }
|
|
72
|
+
} else if (command === "discard-pending-app-update") {
|
|
73
|
+
try { console.log(JSON.stringify(discardPendingRailsAppUpdate({ reportPath: reportOption(), reason: option("--reason") }), null, 2)); }
|
|
74
|
+
catch (error) { console.error(`Rails app:update discard blocked: ${error.message}`); process.exitCode = 1; }
|
|
75
|
+
} else if (command === "discard-last-rails-iteration") {
|
|
76
|
+
try { console.log(JSON.stringify(discardLastRailsIteration({ reportPath: reportOption(), reason: option("--reason") }), null, 2)); }
|
|
77
|
+
catch (error) { console.error(`Rails iteration discard blocked: ${error.message}`); process.exitCode = 1; }
|
|
78
|
+
} else if (command === "record-dependency-review") {
|
|
79
|
+
try { console.log(JSON.stringify(recordDependencyReview({ reportPath: reportOption(), compatibility: option("--compatibility"), licenses: option("--licenses") }), null, 2)); }
|
|
80
|
+
catch (error) { console.error(`Dependency review blocked: ${error.message}`); process.exitCode = 1; }
|
|
81
|
+
} else if (command === "inventory") {
|
|
82
|
+
console.log(JSON.stringify(inventoryProject(), null, 2));
|
|
83
|
+
} else if (command === "git-capabilities") {
|
|
84
|
+
console.log(JSON.stringify(inspectGitCapabilities(), null, 2));
|
|
85
|
+
} else if (command === "supply-chain") {
|
|
86
|
+
console.log(JSON.stringify(inspectSupplyChain(), null, 2));
|
|
87
|
+
} else if (command === "commit-hop") {
|
|
88
|
+
const reportIndex = args.indexOf("--report");
|
|
89
|
+
try {
|
|
90
|
+
const result = commitValidatedHop({ reportPath: reportIndex >= 0 ? args[reportIndex + 1] : undefined, allowHooks: args.includes("--allow-hooks"), allowBroadLockfile: args.includes("--allow-broad-lockfile"), allowPrivateSources: args.includes("--allow-private-sources") });
|
|
91
|
+
console.log(JSON.stringify(result, null, 2));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const prefix = error instanceof CommitGateError ? `Commit blocked (${error.code})` : "Commit failed";
|
|
94
|
+
console.error(`${prefix}: ${error.message}`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
}
|
|
97
|
+
} else if (command === "commit-rails-hop") {
|
|
98
|
+
const reportIndex = args.indexOf("--report");
|
|
99
|
+
try { console.log(JSON.stringify(commitValidatedRailsHop({ reportPath: reportIndex >= 0 ? args[reportIndex + 1] : undefined, allowHooks: args.includes("--allow-hooks"), allowBroadLockfile: args.includes("--allow-broad-lockfile"), allowPrivateSources: args.includes("--allow-private-sources") }), null, 2)); }
|
|
100
|
+
catch (error) { console.error(`${error instanceof CommitGateError ? `Rails commit blocked (${error.code})` : "Rails commit failed"}: ${error.message}`); process.exitCode = 1; }
|
|
101
|
+
} else if (["resume", "release-lock"].includes(command)) {
|
|
102
|
+
const reportIndex = args.indexOf("--report");
|
|
103
|
+
const reportPath = reportIndex >= 0 ? args[reportIndex + 1] : undefined;
|
|
104
|
+
try {
|
|
105
|
+
if (!reportPath) throw new RunStateError("Provide --report .ruby-upgrades/runs/<run>.json.", "missing-report-path");
|
|
106
|
+
if (command === "resume") {
|
|
107
|
+
console.log(JSON.stringify(resumeRun({ reportPath, continueAfterHop: args.includes("--continue-after-hop") }), null, 2));
|
|
108
|
+
} else {
|
|
109
|
+
if (!args.includes("--force")) throw new RunStateError("release-lock is stale-session recovery only. Verify the owner is gone, then rerun with --force.", "force-required");
|
|
110
|
+
releaseRunLock(process.cwd(), reportPath, readRun(process.cwd(), reportPath).lockNonce);
|
|
111
|
+
console.log("Upgrade run lock released.");
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const prefix = error instanceof RunStateError ? `Run control blocked (${error.code})` : "Run control failed";
|
|
115
|
+
console.error(`${prefix}: ${error.message}`);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
console.error(usage);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-ruby-upgrader",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A safe, evidence-driven Ruby runtime upgrade agent and local migration dashboard for OpenCode",
|
|
5
|
+
"author": "Priscilla Cournoyer",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/lilla021/opencode-ruby-upgrader.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/lilla021/opencode-ruby-upgrader/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/lilla021/opencode-ruby-upgrader#readme",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.js",
|
|
16
|
+
"bin": {
|
|
17
|
+
"opencode-ruby-upgrader": "./bin/opencode-ruby-upgrader.mjs"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test tests/*.test.js",
|
|
21
|
+
"test:opencode": "node tests/opencode-runtime-smoke.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": ["src", "agents", "bin", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "RELEASING.md", "RELEASE_CHECKLIST.md", "RELEASE_NOTES.md", "E2E_EVIDENCE.md"],
|
|
24
|
+
"keywords": ["opencode", "opencode-plugin", "ruby", "rails", "upgrade", "worktree", "migration"],
|
|
25
|
+
"engines": { "node": ">=22.5.0" },
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"publishConfig": { "access": "public" }
|
|
28
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { assertRunLock, readRun } from "./run-state.js";
|
|
5
|
+
import { receiptDigest, worktreeFingerprint } from "./provenance.js";
|
|
6
|
+
|
|
7
|
+
const text = (cwd, args, env) => execFileSync("git", args, { cwd, env: { ...process.env, ...env }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
8
|
+
const run = (cwd, args, env) => execFileSync("git", args, { cwd, env: { ...process.env, ...env }, stdio: "ignore" });
|
|
9
|
+
const within = (parent, child) => child === parent || child.startsWith(`${parent}${path.sep}`);
|
|
10
|
+
|
|
11
|
+
export class CommitGateError extends Error {
|
|
12
|
+
constructor(message, code) { super(message); this.code = code; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function gitState(cwd) {
|
|
16
|
+
const root = text(cwd, ["rev-parse", "--show-toplevel"]);
|
|
17
|
+
const gitDir = text(cwd, ["rev-parse", "--git-dir"]);
|
|
18
|
+
const commonDir = text(cwd, ["rev-parse", "--git-common-dir"]);
|
|
19
|
+
const linked = path.resolve(root, gitDir) !== path.resolve(root, commonDir);
|
|
20
|
+
const branch = text(cwd, ["branch", "--show-current"]);
|
|
21
|
+
let defaultBranch;
|
|
22
|
+
try { defaultBranch = text(cwd, ["config", "--get", "opencode-ruby-upgrader.defaultBranch"]); }
|
|
23
|
+
catch { defaultBranch = undefined; }
|
|
24
|
+
return { root, linked, branch, defaultBranch, sha: text(cwd, ["rev-parse", "HEAD"]) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readReport(root, reportPath) {
|
|
28
|
+
const fullPath = path.resolve(root, reportPath);
|
|
29
|
+
const allowedDirectory = path.join(root, ".ruby-upgrades", "runs");
|
|
30
|
+
if (!within(allowedDirectory, fullPath) || path.extname(fullPath) !== ".json") {
|
|
31
|
+
throw new CommitGateError("Report must be a JSON file under .ruby-upgrades/runs/.", "invalid-report-path");
|
|
32
|
+
}
|
|
33
|
+
try { return { fullPath, report: readRun(root, reportPath) }; }
|
|
34
|
+
catch { throw new CommitGateError("The upgrade report is missing or fails the run schema.", "invalid-report"); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertValidatedIteration(report, rails = false) {
|
|
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
|
+
}
|
|
42
|
+
if (!iteration || iteration.status !== "complete" || iteration.tests?.passed !== true) {
|
|
43
|
+
throw new CommitGateError("The latest hop is not recorded as complete with passing tests.", "validation-missing");
|
|
44
|
+
}
|
|
45
|
+
if (iteration.checkpointSha) throw new CommitGateError("The latest iteration already has a checkpoint; record the next hop before committing again.", "iteration-already-checkpointed");
|
|
46
|
+
if (!Array.isArray(iteration.validationReceipts) || !iteration.validationReceipts.length) {
|
|
47
|
+
throw new CommitGateError("The latest hop requires an executed validation receipt.", "validation-receipt-missing");
|
|
48
|
+
}
|
|
49
|
+
if (!iteration.from || !iteration.to) {
|
|
50
|
+
throw new CommitGateError("The latest report iteration needs both from and to Ruby versions.", "invalid-iteration");
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(iteration.fixes) || !iteration.fixes.length || !Array.isArray(iteration.citations) || !iteration.citations.length || !iteration.tests?.smoke) {
|
|
53
|
+
throw new CommitGateError("The latest hop requires explained fixes, citations, and smoke-test evidence.", "evidence-missing");
|
|
54
|
+
}
|
|
55
|
+
if (rails && !iteration.appUpdateReview) throw new CommitGateError("The latest Rails hop requires reviewed bin/rails app:update evidence.", "app-update-review-missing");
|
|
56
|
+
return iteration;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertExpectedHead(state, report) {
|
|
60
|
+
if (!report.expectedHead || state.sha !== report.expectedHead) {
|
|
61
|
+
throw new CommitGateError("HEAD no longer matches the recorded upgrade baseline; refusing to commit mixed changes.", "unexpected-head");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stagedSecretPaths(cwd, env) {
|
|
66
|
+
const tokens = text(cwd, ["diff", "--cached", "--name-status", "-z"], env).split("\0").filter(Boolean);
|
|
67
|
+
const names = [];
|
|
68
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
69
|
+
const status = tokens[i];
|
|
70
|
+
if (/^[MACD]/.test(status)) {
|
|
71
|
+
if (status.charAt(0) !== "D") names.push(tokens[i + 1]);
|
|
72
|
+
i += 1;
|
|
73
|
+
} else if (/^[RC]\d{3}$/.test(status)) {
|
|
74
|
+
names.push(tokens[i + 2]);
|
|
75
|
+
i += 2;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const indicators = [
|
|
79
|
+
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/i,
|
|
80
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
81
|
+
/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/,
|
|
82
|
+
/\b(?:xox[baprs]-|npm_|glpat-)[A-Za-z0-9_-]{16,}/,
|
|
83
|
+
/(?:password|secret|token|api[_-]?key|authorization)\s*[:=]\s*["'][^"'\n]{8,}["']/i
|
|
84
|
+
];
|
|
85
|
+
const flagged = [];
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
try {
|
|
88
|
+
const size = Number(text(cwd, ["cat-file", "-s", `:${name}`], env));
|
|
89
|
+
if (!Number.isSafeInteger(size) || size > 2 * 1024 * 1024) { flagged.push(name); continue; }
|
|
90
|
+
const contents = execFileSync("git", ["show", `:${name}`], { cwd, env: { ...process.env, ...env }, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 });
|
|
91
|
+
if (contents.includes("\0") || indicators.some((pattern) => pattern.test(contents))) flagged.push(name);
|
|
92
|
+
} catch { flagged.push(name); }
|
|
93
|
+
}
|
|
94
|
+
return flagged;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function executableHooks(cwd) {
|
|
98
|
+
let hooksPath;
|
|
99
|
+
try { hooksPath = text(cwd, ["config", "--get", "core.hooksPath"]); }
|
|
100
|
+
catch { hooksPath = text(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]); }
|
|
101
|
+
const directory = path.isAbsolute(hooksPath) ? hooksPath : path.resolve(text(cwd, ["rev-parse", "--show-toplevel"]), hooksPath);
|
|
102
|
+
try { return fs.readdirSync(directory).filter((name) => !name.endsWith(".sample") && (fs.statSync(path.join(directory, name)).mode & 0o111)); }
|
|
103
|
+
catch { return []; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertScopedChanges(cwd, iteration, reportPath, env) {
|
|
107
|
+
const changed = text(cwd, ["diff", "--cached", "--name-only", "-z"], env).split("\0").filter(Boolean);
|
|
108
|
+
const declared = new Set([...(iteration.files ?? []), reportPath, reportPath.replace(/\.json$/, ".md")]);
|
|
109
|
+
const unexpected = changed.filter((file) => !declared.has(file));
|
|
110
|
+
if (unexpected.length) throw new CommitGateError(`Changed files were not declared by this hop: ${unexpected.join(", ")}. Add them with an explanation or stop for review.`, "unexpected-change-scope");
|
|
111
|
+
if (!changed.length) throw new CommitGateError("There are no migration changes to commit.", "nothing-to-commit");
|
|
112
|
+
return changed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function unstageUnrelatedEvidence(cwd, iteration, reportPath, env) {
|
|
116
|
+
const declared = new Set([...(iteration.files ?? []), reportPath, reportPath.replace(/\.json$/, ".md")]);
|
|
117
|
+
const staged = text(cwd, ["diff", "--cached", "--name-only", "-z"], env).split("\0").filter(Boolean);
|
|
118
|
+
const localEvidence = staged.filter((file) => file.startsWith(".ruby-upgrades/") && !declared.has(file));
|
|
119
|
+
if (localEvidence.length) run(cwd, ["reset", "-q", "HEAD", "--", ...localEvidence], env);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function dependencyGate(cwd, changed, iteration, { allowBroadLockfile, allowPrivateSources }, env) {
|
|
123
|
+
const lockfiles = changed.filter((file) => /(?:^|\/)(?:Gemfile\.lock|gems\.lock)$/i.test(file));
|
|
124
|
+
if (lockfiles.length && iteration.dependencyReview?.completed !== true) throw new CommitGateError("A changed lockfile requires dependencyReview.completed: true with compatibility and license findings recorded.", "dependency-review-missing");
|
|
125
|
+
for (const lockfile of lockfiles) {
|
|
126
|
+
const lines = text(cwd, ["diff", "--cached", "--numstat", "--", lockfile], env).split("\t");
|
|
127
|
+
if ((Number(lines[0]) + Number(lines[1])) > 500 && !allowBroadLockfile) throw new CommitGateError(`Lockfile churn in ${lockfile} exceeds 500 changed lines. Review it and explicitly continue with --allow-broad-lockfile.`, "broad-lockfile-churn");
|
|
128
|
+
const content = execFileSync("git", ["show", `:${lockfile}`], { cwd, env: { ...process.env, ...env }, encoding: "utf8" });
|
|
129
|
+
const remotes = [...content.matchAll(/^\s*remote:\s*(\S+)\s*$/gm)].map((match) => match[1]);
|
|
130
|
+
if (remotes.some((remote) => !/^https:\/\/rubygems\.org\/?$/i.test(remote)) && !allowPrivateSources) throw new CommitGateError(`A non-RubyGems source appears in ${lockfile}. Review its trust and explicitly continue with --allow-private-sources.`, "private-dependency-source");
|
|
131
|
+
}
|
|
132
|
+
const gemfiles = changed.filter((file) => /(?:^|\/)Gemfile$/i.test(file));
|
|
133
|
+
for (const gemfile of gemfiles) {
|
|
134
|
+
const content = execFileSync("git", ["show", `:${gemfile}`], { cwd, env: { ...process.env, ...env }, encoding: "utf8" });
|
|
135
|
+
if (/(?:git|github|path)\s*:\s*["']/i.test(content) || /\bsource\s*["'](?!https:\/\/rubygems\.org\/?["'])/i.test(content)) {
|
|
136
|
+
if (!allowPrivateSources) throw new CommitGateError(`A non-standard or non-RubyGems dependency source appears in ${gemfile}. Review its trust and explicitly continue with --allow-private-sources.`, "private-dependency-source");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function commit(cwd, message, env) {
|
|
142
|
+
try { execFileSync("git", ["commit", "-m", message], { cwd, env: { ...process.env, ...env }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
|
|
143
|
+
catch (error) {
|
|
144
|
+
const detail = String(error.stderr ?? error.message);
|
|
145
|
+
if (/cannot lock ref|is at .+ but expected|reference transaction/i.test(detail)) throw new CommitGateError("Git history changed while the checkpoint was being created. The migration changes were not committed; review the new commit and resume only after explicit review.", "unexpected-head");
|
|
146
|
+
if (/Author identity unknown|unable to auto-detect email/i.test(detail)) throw new CommitGateError("Git author identity is not configured. Configure user.name and user.email yourself, then resume the run; this tool will not change Git configuration.", "git-identity-missing");
|
|
147
|
+
if (/gpg failed|failed to sign/i.test(detail)) throw new CommitGateError("Git commit signing failed. Repair your local signing setup or disable it yourself, then resume the run.", "git-signing-failed");
|
|
148
|
+
throw new CommitGateError(`Git refused the local commit: ${detail.trim()}`, "git-commit-failed");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function finalTestReceipt(iteration) {
|
|
153
|
+
return iteration.validationReceipts.findLast((receipt) => receipt.kind === "test" && receipt.exitCode === 0 && receipt.testEvidence?.passed === true);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertValidationFingerprint(cwd, iteration) {
|
|
157
|
+
const receipt = finalTestReceipt(iteration);
|
|
158
|
+
const current = worktreeFingerprint(cwd);
|
|
159
|
+
if (!receipt || receipt.worktree.headSha !== current.headSha || receipt.worktree.diffSha256 !== current.diffSha256) {
|
|
160
|
+
throw new CommitGateError("The working tree changed after final validation; rerun validation before committing.", "validation-fingerprint-mismatch");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function messageFor(iteration, reportPath, report, rails = false) {
|
|
165
|
+
const receiptTrailer = iteration.validationReceipts?.length ? [`Validation-Receipt-Digest: sha256:${receiptDigest(iteration.validationReceipts)}`] : [];
|
|
166
|
+
if (rails) return [
|
|
167
|
+
`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
|
+
`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
|
+
].join("\n");
|
|
170
|
+
return [
|
|
171
|
+
`chore(ruby): upgrade ${iteration.from} to ${iteration.to}`,
|
|
172
|
+
"",
|
|
173
|
+
"Apply the smallest compatible runtime, dependency, and code changes for this Ruby minor-version hop.",
|
|
174
|
+
`Validation: ${iteration.tests.command || "project test suite"}.`,
|
|
175
|
+
`Evidence: ${reportPath}`,
|
|
176
|
+
`Ruby-Upgrade-Report: ${reportPath}`,
|
|
177
|
+
`Ruby-Upgrade-Hop: ${iteration.from}->${iteration.to}`, ...receiptTrailer
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function commitValidated({ cwd = process.cwd(), reportPath, allowHooks = false, allowBroadLockfile = false, allowPrivateSources = false, rails = false }) {
|
|
182
|
+
if (!reportPath) throw new CommitGateError("Provide --report .ruby-upgrades/runs/<run>.json.", "missing-report-path");
|
|
183
|
+
const state = gitState(cwd);
|
|
184
|
+
if (!state.linked || !state.branch || !state.defaultBranch || state.branch === state.defaultBranch) {
|
|
185
|
+
throw new CommitGateError("Automatic commits require a configured default branch and a different branch in a linked worktree.", "unsafe-worktree");
|
|
186
|
+
}
|
|
187
|
+
if (text(cwd, ["diff", "--cached", "--name-only"])) {
|
|
188
|
+
throw new CommitGateError("The staging area was not empty before the commit gate ran.", "staging-not-clean");
|
|
189
|
+
}
|
|
190
|
+
const { fullPath, report } = readReport(state.root, reportPath);
|
|
191
|
+
if (!report.lockNonce) throw new CommitGateError("The report is missing its active lock capability.", "lock-capability-missing");
|
|
192
|
+
try { assertRunLock(state.root, reportPath, report.lockNonce); }
|
|
193
|
+
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");
|
|
196
|
+
if (report.branch !== state.branch) throw new CommitGateError("The worktree branch no longer matches the recorded run branch.", "unexpected-branch");
|
|
197
|
+
assertExpectedHead(state, report);
|
|
198
|
+
const indexPath = path.resolve(cwd, text(cwd, ["rev-parse", "--git-path", "index"]));
|
|
199
|
+
const lockPath = `${indexPath}.lock`;
|
|
200
|
+
const temporaryIndex = path.join(path.dirname(indexPath), `.opencode-ruby-upgrader-${process.pid}-${Date.now()}.index`);
|
|
201
|
+
try { fs.writeFileSync(lockPath, "", { flag: "wx", mode: 0o600 }); }
|
|
202
|
+
catch { throw new CommitGateError("The Git index is busy; retry after the other Git operation finishes.", "index-busy"); }
|
|
203
|
+
const env = { GIT_INDEX_FILE: temporaryIndex };
|
|
204
|
+
let committed = false;
|
|
205
|
+
try {
|
|
206
|
+
run(cwd, ["read-tree", "HEAD"], env);
|
|
207
|
+
run(cwd, ["add", "--all"], env);
|
|
208
|
+
const relativeReport = path.relative(state.root, fullPath);
|
|
209
|
+
unstageUnrelatedEvidence(cwd, iteration, relativeReport, env);
|
|
210
|
+
const changed = assertScopedChanges(cwd, iteration, relativeReport, env);
|
|
211
|
+
dependencyGate(cwd, changed, iteration, { allowBroadLockfile, allowPrivateSources }, env);
|
|
212
|
+
const hooks = executableHooks(cwd);
|
|
213
|
+
if (hooks.length && !allowHooks) throw new CommitGateError(`Executable Git hooks are active: ${hooks.join(", ")}. Review them and explicitly continue with --allow-hooks.`, "active-git-hooks");
|
|
214
|
+
const secretPaths = stagedSecretPaths(cwd, env);
|
|
215
|
+
if (secretPaths.length) {
|
|
216
|
+
throw new CommitGateError(`Potential credential material detected in: ${secretPaths.join(", ")}. Review it manually; it was not committed.`, "secret-detected");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const message = messageFor(iteration, relativeReport, report, rails);
|
|
220
|
+
assertValidationFingerprint(cwd, iteration);
|
|
221
|
+
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
|
+
commit(cwd, message, env);
|
|
223
|
+
committed = true;
|
|
224
|
+
fs.renameSync(temporaryIndex, lockPath);
|
|
225
|
+
fs.renameSync(lockPath, indexPath);
|
|
226
|
+
return { sha: text(cwd, ["rev-parse", "HEAD"]), message, branch: state.branch, report: relativeReport };
|
|
227
|
+
} finally {
|
|
228
|
+
if (!committed) fs.rmSync(temporaryIndex, { force: true });
|
|
229
|
+
fs.rmSync(lockPath, { force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function commitValidatedHop(options = {}) { return commitValidated(options); }
|
|
234
|
+
export function commitValidatedRailsHop(options = {}) { return commitValidated({ ...options, rails: true }); }
|