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.
@@ -0,0 +1,273 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { inspectGitCapabilities, inspectWorktree } from "./preflight.js";
5
+ import { inventoryProject } from "./inventory.js";
6
+ import { inspectSupplyChain } from "./supply-chain.js";
7
+ import { acquireRunLock, assertRunLock, readRun, releaseRunLock, writeRun } from "./run-state.js";
8
+ import { executeValidation } from "./validation-executor.js";
9
+ import { receiptDigest } from "./provenance.js";
10
+
11
+ const rubyVersion = /^\d+\.\d+(?:\.\d+)?$/;
12
+ const railsVersion = /^\d+(?:\.\d+)+$/;
13
+ const runName = () => `${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID()}.json`;
14
+ const transitions = {
15
+ initialized: ["inventory_complete", "blocked", "paused"], inventory_complete: ["research_complete", "blocked", "paused"],
16
+ research_complete: ["hop_validated", "blocked", "paused"], hop_validated: ["committed", "blocked", "paused"],
17
+ committed: ["hop_validated", "complete", "blocked", "paused"], paused: ["inventory_complete", "research_complete", "hop_validated", "committed", "blocked"], blocked: ["inventory_complete", "research_complete", "hop_validated", "paused"], complete: []
18
+ };
19
+ const series = (value) => value.split(".").slice(0, 2).join(".");
20
+ const git = (root, args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
21
+ const minorNumber = (value) => { const [major, minor] = series(value).split(".").map(Number); return major * 1000 + minor; };
22
+ const railsMinorNumber = (value) => { const [major, minor] = series(value).split(".").map(Number); return major * 1000 + minor; };
23
+ const contiguousRailsHop = (from, to) => railsMinorNumber(to) === railsMinorNumber(from) + 1 || (Number(to.split(".")[0]) === Number(from.split(".")[0]) + 1 && Number(to.split(".")[1]) === 0);
24
+ const rubyMajorBoundaries = new Map([[2, 7], [3, 4]]);
25
+ const contiguousRubyHop = (from, to) => {
26
+ const [fromMajor, fromMinor] = series(from).split(".").map(Number); const [toMajor, toMinor] = series(to).split(".").map(Number);
27
+ return minorNumber(to) === minorNumber(from) + 1 || (toMajor === fromMajor + 1 && toMinor === 0 && rubyMajorBoundaries.get(fromMajor) === fromMinor);
28
+ };
29
+ const requiredRisksFor = (supplyChain, gitCapabilities) => [
30
+ ...(supplyChain.privateSources.length ? ["private-dependency-sources"] : []),
31
+ ...(gitCapabilities.shallow ? ["shallow-clone"] : []), ...(gitCapabilities.sparseCheckout ? ["sparse-checkout"] : []),
32
+ ...(gitCapabilities.submodules ? ["submodules"] : []), ...(gitCapabilities.lfsConfigured ? ["git-lfs"] : [])
33
+ ];
34
+ const assertApprovedRisks = (run) => {
35
+ const decisions = new Map(run.riskDecisions.map((risk) => [risk.risk, risk.decision]));
36
+ if ((run.requiredRisks ?? []).some((risk) => decisions.get(risk) !== "approved") || [...decisions.values()].some((decision) => decision !== "approved")) throw new Error("Resolve every detected risk with an explicit approval before recording a routine iteration.");
37
+ };
38
+
39
+ export function beginRun({ root = process.cwd(), target, dryRun = false, stopAfterHop = false, allowNonGit = false } = {}) {
40
+ if (!target || !rubyVersion.test(target)) throw new Error("Provide --target as a Ruby version such as 3.4 or 3.4.1.");
41
+ const preflight = inspectWorktree(root); const inventory = inventoryProject(root); const supplyChain = inspectSupplyChain(root); const gitCapabilities = inspectGitCapabilities(root);
42
+ const plan = { preflight, inventory, supplyChain, gitCapabilities, targetRuby: target, dryRun, stopAfterHop };
43
+ if (dryRun || !preflight.ok) return plan;
44
+ if (!inventory.supported || inventory.requiresDecision) throw new Error("A durable migration requires a Gemfile and a recognized executable test adapter. Supply an explicit validation command through a future reviewed adapter instead of guessing.");
45
+ if (preflight.mode === "non-git" && !allowNonGit) throw new Error("Non-Git runs need explicit consent: rerun begin with --allow-non-git after reviewing the loss of worktree isolation and checkpoint commits.");
46
+ const reportPath = path.join(".ruby-upgrades", "runs", runName());
47
+ const requiredRisks = requiredRisksFor(supplyChain, gitCapabilities);
48
+ const report = {
49
+ schemaVersion: 2, validationReceiptsRequired: true, runId: crypto.randomUUID(), title: `Ruby migration to ${target}`, status: "in_progress", phase: "initialized", startedAt: new Date().toISOString(), targetRuby: target, targetPinnedAt: new Date().toISOString(),
50
+ branch: preflight.branch ?? null, worktreeRoot: preflight.root ?? root, startingSha: preflight.sha ?? null, expectedHead: preflight.sha ?? null, control: { stopAfterHop }, inventory, supplyChain, gitCapabilities,
51
+ research: { ladder: [], citations: [] }, requiredRisks, riskDecisions: [], frameworkBridge: null, summary: ["Run initialized; official-source research and compatibility ladder pending."], iterations: [], sessionSummary: ""
52
+ };
53
+ const lock = acquireRunLock(root, reportPath); report.lockNonce = lock.nonce;
54
+ try { writeRun(root, reportPath, report); } catch (error) { releaseRunLock(root, reportPath, lock.nonce); throw error; }
55
+ return { ...plan, reportPath, report, lock };
56
+ }
57
+
58
+ export function runStatus({ root = process.cwd(), reportPath }) { return readRun(root, reportPath); }
59
+
60
+ export function beginRailsBridgeRun({ root = process.cwd(), rubyReportPath, dryRun = false, stopAfterHop = false, allowNonGit = false } = {}) {
61
+ const rubyRun = readRun(root, rubyReportPath);
62
+ if (rubyRun.reportType === "rails_bridge" || rubyRun.phase !== "blocked" || rubyRun.status !== "blocked" || !rubyRun.frameworkBridge) throw new Error("A Rails bridge can start only from a blocked Ruby run with an approved compatibility bridge.");
63
+ const preflight = inspectWorktree(root); const inventory = inventoryProject(root); const supplyChain = inspectSupplyChain(root); const gitCapabilities = inspectGitCapabilities(root); const bridge = rubyRun.frameworkBridge;
64
+ const plan = { preflight, inventory, supplyChain, gitCapabilities, bridge, dryRun, stopAfterHop };
65
+ if (dryRun || !preflight.ok) return plan;
66
+ if (!inventory.rails?.resolvedVersion || series(inventory.rails.resolvedVersion) !== series(bridge.railsFrom)) throw new Error("The current Gemfile.lock must still resolve the Rails version recorded by the blocked Ruby run.");
67
+ if (preflight.mode === "non-git" && !allowNonGit) throw new Error("Non-Git Rails bridges need explicit consent: rerun with --allow-non-git.");
68
+ if (preflight.mode !== "non-git" && (preflight.branch !== rubyRun.branch || preflight.sha !== rubyRun.expectedHead)) throw new Error("Rails bridge must start on the blocked Ruby run's recorded branch and checkpoint SHA.");
69
+ const reportPath = path.join(".ruby-upgrades", "runs", runName());
70
+ const report = { schemaVersion: 2, validationReceiptsRequired: true, reportType: "rails_bridge", runId: crypto.randomUUID(), title: `Rails bridge ${bridge.railsFrom} to ${bridge.railsTo}`, status: "in_progress", phase: "initialized", startedAt: new Date().toISOString(), targetRails: bridge.railsTo, targetRailsPinnedAt: new Date().toISOString(), 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, railsFrom: bridge.railsFrom, railsTo: bridge.railsTo, approvedAt: bridge.recordedAt }, research: { ladder: [], citations: [] }, riskDecisions: [], requiredRisks: requiredRisksFor(supplyChain, gitCapabilities), summary: ["Rails bridge initialized from blocked Ruby compatibility decision."], iterations: [], sessionSummary: "" };
71
+ const lock = acquireRunLock(root, reportPath); report.lockNonce = lock.nonce;
72
+ try { writeRun(root, reportPath, report); } catch (error) { releaseRunLock(root, reportPath, lock.nonce); throw error; }
73
+ return { ...plan, reportPath, report, lock };
74
+ }
75
+
76
+ export function recordResearch({ root = process.cwd(), reportPath, ladder, citations }) {
77
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
78
+ if (!Array.isArray(ladder) || !ladder.length || ladder.some((version) => !rubyVersion.test(version))) throw new Error("Research requires a Ruby-version ladder.");
79
+ if (series(ladder.at(-1)) !== series(run.targetRuby)) throw new Error("The research ladder must end at the pinned target Ruby series.");
80
+ if (ladder.some((version, index) => index > 0 && !contiguousRubyHop(ladder[index - 1], version))) throw new Error("The research ladder must advance exactly one Ruby minor series per hop.");
81
+ const declared = run.inventory.rubyDeclarations.map((declaration) => declaration.value).find(Boolean);
82
+ if (declared && series(ladder[0]) !== series(declared)) throw new Error("The research ladder must begin at the project’s detected Ruby version.");
83
+ if (!Array.isArray(citations) || !citations.length || citations.some((citation) => !citation?.title || !/^https:\/\//.test(citation.url ?? ""))) throw new Error("Research requires at least one HTTPS official-source citation.");
84
+ run.research = { ladder, citations }; writeRun(root, reportPath, run); return run;
85
+ }
86
+
87
+ export function recordRailsResearch({ root = process.cwd(), reportPath, ladder, citations }) {
88
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
89
+ if (run.reportType !== "rails_bridge") throw new Error("Rails research belongs only to a Rails bridge report.");
90
+ if (!Array.isArray(ladder) || ladder.length < 2 || ladder.some((version) => !railsVersion.test(version)) || series(ladder[0]) !== series(run.bridge.railsFrom) || series(ladder.at(-1)) !== series(run.targetRails) || ladder.some((version, index) => index > 0 && !contiguousRailsHop(ladder[index - 1], version))) throw new Error("Rails research must provide contiguous minor-version hops from the recorded Rails version to targetRails.");
91
+ if (!Array.isArray(citations) || !citations.length || citations.some((citation) => !citation?.title || !/^https:\/\//.test(citation.url ?? ""))) throw new Error("Rails research requires HTTPS citations.");
92
+ run.research = { ladder, citations }; writeRun(root, reportPath, run); return run;
93
+ }
94
+
95
+ export function recordRiskDecision({ root = process.cwd(), reportPath, risk, decision, evidence = "" }) {
96
+ if (!risk || !["approved", "paused", "blocked"].includes(decision)) throw new Error("Risk decisions require a risk and approved, paused, or blocked decision.");
97
+ 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;
98
+ }
99
+
100
+ export function recordFrameworkBridge({ root = process.cwd(), reportPath, rubyFrom, rubyTo, railsFrom, railsTo, rationale, citations }) {
101
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
102
+ 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.");
103
+ if (run.frameworkBridge) throw new Error("This run already has an approved Rails compatibility bridge; complete it in a separately scoped Rails migration.");
104
+ if (![rubyFrom, rubyTo].every((version) => rubyVersion.test(version)) || ![railsFrom, railsTo].every((version) => railsVersion.test(version))) throw new Error("A Rails compatibility bridge requires Ruby and Rails from/to versions.");
105
+ 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("Rails compatibility bridge must describe the next researched Ruby hop.");
106
+ if (!run.inventory.rails?.resolvedVersion) throw new Error("Rails compatibility bridges require a resolved Rails version from Gemfile.lock.");
107
+ if (series(railsFrom) !== series(run.inventory.rails.resolvedVersion)) throw new Error("Rails compatibility bridge must begin at the detected Rails version.");
108
+ if (!rationale || !Array.isArray(citations) || !citations.length || citations.some((citation) => !citation?.title || !/^https:\/\//.test(citation.url ?? ""))) throw new Error("Rails compatibility bridge requires a rationale and HTTPS citations.");
109
+ run.frameworkBridge = { status: "approved", rubyFrom, rubyTo, railsFrom, railsTo, rationale, citations, recordedAt: new Date().toISOString() };
110
+ run.summary = [...run.summary, `User approved a separately scoped Rails ${railsFrom} → ${railsTo} bridge before Ruby ${rubyFrom} → ${rubyTo}.`];
111
+ writeRun(root, reportPath, run); return run;
112
+ }
113
+
114
+ function recordIterationInternal({ root = process.cwd(), reportPath, iteration, executed = false }) {
115
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
116
+ if (run.reportType === "rails_bridge") throw new Error("Use record-rails-iteration for a Rails bridge report.");
117
+ if (run.validationReceiptsRequired && !executed) throw new Error("New reports require record-executed-iteration so validation receipts are created by the executor.");
118
+ if (run.phase !== "research_complete" && run.phase !== "committed") throw new Error("Record iterations only after research or a prior checkpoint.");
119
+ assertApprovedRisks(run);
120
+ 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.");
121
+ const expectedFrom = run.iterations.at(-1)?.to ?? run.research.ladder[0];
122
+ const fromIndex = run.research.ladder.findIndex((version) => series(version) === series(expectedFrom));
123
+ const expectedTo = run.research.ladder[fromIndex + 1];
124
+ if (!iteration || fromIndex < 0 || !expectedTo || series(iteration.from) !== series(expectedFrom) || series(iteration.to) !== series(expectedTo)) throw new Error("Iteration must advance exactly one researched Ruby minor series from the prior ladder point.");
125
+ run.iterations.push({ ...iteration, status: "complete" }); writeRun(root, reportPath, run); return run;
126
+ }
127
+
128
+ export function recordIteration(options = {}) { return recordIterationInternal(options); }
129
+
130
+ function recordRailsIterationInternal({ root = process.cwd(), reportPath, iteration, executed = false }) {
131
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
132
+ if (run.reportType !== "rails_bridge" || !["research_complete", "committed"].includes(run.phase)) throw new Error("Record Rails iterations only after Rails research or a prior Rails checkpoint.");
133
+ if (run.validationReceiptsRequired && !executed) throw new Error("New reports require record-executed-rails-iteration so validation receipts are created by the executor.");
134
+ assertApprovedRisks(run);
135
+ 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.");
136
+ const expectedFrom = run.iterations.at(-1)?.to ?? run.research.ladder[0]; const index = run.research.ladder.findIndex((version) => series(version) === series(expectedFrom)); const expectedTo = run.research.ladder[index + 1];
137
+ if (!iteration || !expectedTo || series(iteration.from) !== series(expectedFrom) || series(iteration.to) !== series(expectedTo) || !iteration.appUpdateReview) throw new Error("Rails iteration must advance exactly one researched Rails minor and include app:update review evidence.");
138
+ run.iterations.push({ ...iteration, status: "complete" }); writeRun(root, reportPath, run); return run;
139
+ }
140
+
141
+ export function recordRailsIteration(options = {}) { return recordRailsIterationInternal(options); }
142
+
143
+ export function recordExecutedIteration({ root = process.cwd(), reportPath, iteration, validationCommandId }) {
144
+ const run = validationPreflight(root, reportPath, false); const receipt = executeValidation({ root, inventory: run.inventory, commandId: validationCommandId });
145
+ 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.`);
146
+ return recordIterationInternal({ root, reportPath, executed: true, iteration: { ...iteration, tests: { ...receipt.testEvidence, smoke: iteration?.tests?.smoke }, validationReceipts: [receipt] } });
147
+ }
148
+
149
+ export function recordExecutedRailsIteration({ root = process.cwd(), reportPath, iteration, testValidationCommandId }) {
150
+ const run = validationPreflight(root, reportPath, true);
151
+ if (!run.pendingAppUpdate) {
152
+ const updateReceipt = executeValidation({ root, inventory: run.inventory, commandId: "rails-app-update" });
153
+ if (updateReceipt.exitCode !== 0) throw new Error("app:update failed; Rails hop was not recorded.");
154
+ run.pendingAppUpdate = updateReceipt;
155
+ writeRun(root, reportPath, run);
156
+ return { pendingReview: true, receipt: updateReceipt };
157
+ }
158
+ const updateReceipt = run.pendingAppUpdate;
159
+ const review = { ...iteration?.appUpdateReview, receiptId: updateReceipt.id, executedAt: updateReceipt.startedAt, worktree: updateReceipt.worktree };
160
+ if (!validRailsReviewForExecution(review, updateReceipt)) throw new Error("Review app:update after it executes and bind the review to its receipt and working-tree fingerprint.");
161
+ const testReceipt = executeValidation({ root, inventory: run.inventory, commandId: testValidationCommandId });
162
+ if (testReceipt.kind !== "test" || testReceipt.exitCode !== 0 || testReceipt.testEvidence?.passed !== true) throw new Error("Final validation failed; Rails hop was not recorded.");
163
+ const recorded = recordRailsIterationInternal({ root, reportPath, executed: true, iteration: { ...iteration, tests: { ...testReceipt.testEvidence, smoke: iteration?.tests?.smoke }, validationReceipts: [updateReceipt, testReceipt], appUpdateReview: review } });
164
+ delete recorded.pendingAppUpdate;
165
+ writeRun(root, reportPath, recorded);
166
+ return recorded;
167
+ }
168
+
169
+ export function discardPendingRailsAppUpdate({ root = process.cwd(), reportPath, reason }) {
170
+ if (!reason?.trim()) throw new Error("Discarding app:update evidence requires a review reason.");
171
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
172
+ if (run.reportType !== "rails_bridge" || !run.pendingAppUpdate) throw new Error("No pending Rails app:update receipt exists for this bridge.");
173
+ run.discardedAppUpdates = [...(run.discardedAppUpdates ?? []), { receipt: run.pendingAppUpdate, reason: reason.trim(), discardedAt: new Date().toISOString() }];
174
+ delete run.pendingAppUpdate;
175
+ run.summary = [...run.summary, `Discarded a reviewed app:update result: ${reason.trim()}`];
176
+ writeRun(root, reportPath, run); return run;
177
+ }
178
+
179
+ export function discardLastRailsIteration({ root = process.cwd(), reportPath, reason }) {
180
+ if (!reason?.trim()) throw new Error("Discarding a recorded Rails iteration requires a review reason.");
181
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
182
+ if (run.reportType !== "rails_bridge" || run.phase !== "hop_validated" || run.pendingAppUpdate) throw new Error("Discard the latest validated, uncommitted Rails iteration only while the bridge hop is validated with no pending app:update.");
183
+ const iteration = run.iterations.at(-1);
184
+ if (!iteration || iteration.checkpointSha) throw new Error("There is no validated, uncommitted Rails iteration to discard.");
185
+ run.discardedIterations = [...(run.discardedIterations ?? []), { iteration, reason: reason.trim(), discardedAt: new Date().toISOString() }];
186
+ run.iterations = run.iterations.slice(0, -1);
187
+ run.phase = run.iterations.length ? "committed" : "research_complete";
188
+ run.summary = [...run.summary, `Discarded the latest validated Rails iteration (${iteration.from} -> ${iteration.to}) to re-validate the hop: ${reason.trim()}`];
189
+ writeRun(root, reportPath, run); return run;
190
+ }
191
+
192
+ export function recordDependencyReview({ root = process.cwd(), reportPath, compatibility, licenses }) {
193
+ if (!compatibility?.trim() || !licenses?.trim()) throw new Error("Dependency review requires compatibility and license findings.");
194
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce);
195
+ const iteration = run.iterations?.at(-1);
196
+ if (run.phase !== "hop_validated" || !iteration || iteration.checkpointSha) throw new Error("Record dependency review only for the latest validated, uncommitted hop.");
197
+ iteration.dependencyReview = { completed: true, compatibility: compatibility.trim(), licenses: licenses.trim(), reviewedAt: new Date().toISOString() };
198
+ run.summary = [...run.summary, "Recorded dependency compatibility and license review for the validated hop."];
199
+ writeRun(root, reportPath, run); return run;
200
+ }
201
+
202
+ function validRailsReviewForExecution(review, receipt) { return review?.command === "bin/rails app:update" && review.receiptId === receipt.id && review.executedAt === receipt.startedAt && review.worktree?.diffSha256 === receipt.worktree.diffSha256 && !Number.isNaN(Date.parse(review.reviewedAt ?? "")) && Date.parse(review.reviewedAt) >= Date.parse(receipt.finishedAt); }
203
+
204
+ function validationPreflight(root, reportPath, rails) {
205
+ const run = readRun(root, reportPath);
206
+ assertRunLock(root, reportPath, run.lockNonce);
207
+ 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.");
208
+ assertApprovedRisks(run);
209
+ 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.");
210
+ return run;
211
+ }
212
+
213
+ function assertCompletion(run) {
214
+ const final = run.iterations.at(-1);
215
+ const target = run.reportType === "rails_bridge" ? run.targetRails : run.targetRuby;
216
+ if (!final || series(final.to) !== series(target)) throw new Error("A run can complete only after the final validated iteration reaches its pinned target.");
217
+ if (series(run.research.ladder.at(-1)) !== series(target)) throw new Error("Research ladder does not reach its pinned target.");
218
+ if (run.riskDecisions.some((risk) => risk.decision !== "approved")) throw new Error("Unresolved risks prevent completion.");
219
+ if (final.checkpointSha !== run.expectedHead) throw new Error("The final iteration must be committed through the checkpoint gate before completion.");
220
+ }
221
+
222
+ function verifyCheckpoint(root, reportPath, run, commitSha) {
223
+ if (run.gitCapabilities?.supported !== true || !run.branch || !run.expectedHead) throw new Error("Non-Git runs cannot claim Git checkpoints or complete through the checkpoint lifecycle.");
224
+ const head = git(root, ["rev-parse", "HEAD"]);
225
+ if (head !== commitSha) throw new Error("Checkpoint SHA must be the current Git HEAD created by commit-hop.");
226
+ if (git(root, ["branch", "--show-current"]) !== run.branch) throw new Error("Checkpoint branch no longer matches the recorded run branch.");
227
+ if (git(root, ["rev-parse", `${head}^`]) !== run.expectedHead) throw new Error("Checkpoint must be a direct child of the prior recorded checkpoint.");
228
+ const iteration = run.iterations.at(-1);
229
+ const message = git(root, ["log", "-1", "--format=%B"]);
230
+ const prefix = run.reportType === "rails_bridge" ? "Rails" : "Ruby";
231
+ 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.");
232
+ }
233
+
234
+ export function transitionRun({ root = process.cwd(), reportPath, phase, note = "", commitSha = "" }) {
235
+ if (!Object.hasOwn(transitions, phase)) throw new Error(`Unknown run phase: ${phase}.`);
236
+ const run = readRun(root, reportPath); assertRunLock(root, reportPath, run.lockNonce); const current = run.phase;
237
+ 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.");
238
+ if (!transitions[current]?.includes(phase)) throw new Error(`Cannot transition from ${current} to ${phase}.`);
239
+ if (phase === "research_complete" && (!run.research.ladder.length || !run.research.citations.length)) throw new Error("Record research and citations before completing research.");
240
+ 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.");
241
+ if (phase === "hop_validated" && run.iterations.at(-1)?.checkpointSha) throw new Error("A checkpointed iteration cannot be validated again; record the next hop first.");
242
+ if (phase === "committed") {
243
+ if (!/^[a-f0-9]{40}$/i.test(commitSha)) throw new Error("Record the checkpoint SHA after commit-hop.");
244
+ const iteration = run.iterations.at(-1); if (!iteration || iteration.checkpointSha) throw new Error("A committed transition requires one uncheckpointed iteration.");
245
+ verifyCheckpoint(root, reportPath, run, commitSha);
246
+ iteration.checkpointSha = commitSha; run.expectedHead = commitSha;
247
+ }
248
+ if (phase === "complete") assertCompletion(run);
249
+ if (phase === "paused") run.resumePhase = current;
250
+ if (phase === "committed" && run.control.stopAfterHop && run.iterations.length === 1) { run.resumePhase = "committed"; phase = "paused"; }
251
+ run.phase = phase; run.status = phase === "complete" ? "complete" : phase === "blocked" ? "blocked" : phase === "paused" ? "paused" : "in_progress";
252
+ if (note) run.summary = [...run.summary, note]; writeRun(root, reportPath, run);
253
+ if (["complete", "blocked", "paused"].includes(phase)) releaseRunLock(root, reportPath, run.lockNonce);
254
+ return run;
255
+ }
256
+
257
+ export function resumeRun({ root = process.cwd(), reportPath, continueAfterHop = false }) {
258
+ const run = readRun(root, reportPath);
259
+ if (run.status === "complete") throw new Error("This run is complete; start a new run instead.");
260
+ 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.");
261
+ if (run.phase === "paused" && !transitions.paused.includes(run.resumePhase)) throw new Error("Paused run has no safe resume phase.");
262
+ 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.");
263
+ if (continueAfterHop) run.control.stopAfterHop = false;
264
+ const lock = acquireRunLock(root, reportPath); run.lockNonce = lock.nonce;
265
+ try {
266
+ if (run.phase === "paused") {
267
+ run.phase = run.resumePhase; run.status = "in_progress"; delete run.resumePhase; writeRun(root, reportPath, run);
268
+ }
269
+ } catch (error) { releaseRunLock(root, reportPath, lock.nonce); throw error; }
270
+ return { lock, run: readRun(root, reportPath) };
271
+ }
272
+
273
+ export const runPhases = Object.freeze(Object.keys(transitions));
@@ -0,0 +1,16 @@
1
+ export const dashboardPage = String.raw`<!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>Ruby Upgrade Workspace</title>
4
+ <style>
5
+ :root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:#101114;color:#e9e9ed;font:15px ui-sans-serif,system-ui,-apple-system,sans-serif;line-height:1.45}main{max-width:1080px;margin:auto;padding:56px 28px 90px}h1{font-size:34px;letter-spacing:-.7px;margin:0 0 7px}h2{font-size:20px;margin:12px 0 4px}.sub,small{color:#a8acb7}.card{background:#191b20;border:1px solid #30333b;border-radius:12px;padding:20px;margin:15px 0;box-shadow:0 8px 30px #0002}.tag{display:inline-block;background:#302028;color:#ffb7ba;border-radius:99px;padding:4px 9px;font-size:12px;margin-right:6px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:10px;margin:16px 0}.metric{background:#121419;border-radius:9px;padding:10px}.metric b{display:block;color:#fff;font-size:17px;overflow-wrap:anywhere}.iteration{border-top:1px solid #30333b;margin-top:16px;padding-top:14px}ul{padding-left:21px;margin:9px 0}pre{white-space:pre-wrap;color:#c9ccd4;font:13px ui-monospace,SFMono-Regular,monospace}a{color:#ff9da1}code{background:#252831;padding:2px 4px;border-radius:4px}
6
+ </style></head><body><main><h1>Ruby Upgrade Workspace</h1><p class="sub">Local-only migration evidence, test health, and source citations.</p><div id="app"></div></main><script>
7
+ const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
8
+ const safeUrl=value=>/^https?:\/\//.test(String(value))?esc(value):'#';
9
+ const metric=(label,value)=>'<div class="metric"><small>'+esc(label)+'</small><b>'+esc(value??'—')+'</b></div>';
10
+ const testValue=(tests,key,fallback='—')=>tests&&tests[key]!==undefined&&tests[key]!==null?tests[key]:fallback;
11
+ fetch('/api/runs').then(response=>response.json()).then(runs=>{
12
+ const app=document.querySelector('#app');
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('');
15
+ }).catch(error=>{document.querySelector('#app').innerHTML='<article class="card">Could not load upgrade reports: '+esc(error.message)+'</article>';});
16
+ </script></body></html>`;
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { dashboardPage } from "./dashboard-page.js";
6
+ import { readRun } from "./run-state.js";
7
+
8
+ const MAX_RUN_FILES = 250;
9
+ const MAX_RUN_BYTES = 1024 * 1024;
10
+
11
+ function localCommitLinks(root) {
12
+ try {
13
+ const output = execFileSync("git", ["log", "--max-count=250", "--format=%H%x1f%B%x1e"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 2 * 1024 * 1024 });
14
+ return output.split("\x1e").reduce((links, entry) => {
15
+ const [sha, message] = entry.split("\x1f");
16
+ const match = message?.match(/^(?:Ruby|Rails)-Upgrade-Report:\s+(.+)$/m);
17
+ if (sha && match) (links[match[1].trim()] ??= []).push(sha);
18
+ return links;
19
+ }, {});
20
+ } catch { return {}; }
21
+ }
22
+
23
+ export function readUpgradeRuns(root = process.cwd()) {
24
+ const directory = path.join(root, ".ruby-upgrades", "runs");
25
+ if (!fs.existsSync(directory)) return [];
26
+ const commits = localCommitLinks(root);
27
+ return fs.readdirSync(directory, { withFileTypes: true })
28
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
29
+ .flatMap((entry) => {
30
+ const filePath = path.join(directory, entry.name);
31
+ try {
32
+ if (fs.statSync(filePath).size > MAX_RUN_BYTES) return [];
33
+ const relativePath = path.join(".ruby-upgrades", "runs", entry.name);
34
+ const parsed = readRun(root, relativePath);
35
+ const { lockNonce, ...report } = parsed;
36
+ return [{ ...report, file: entry.name, localCommits: commits[relativePath] ?? [] }];
37
+ }
38
+ catch { return []; }
39
+ })
40
+ .sort((a, b) => String(b.startedAt ?? "").localeCompare(String(a.startedAt ?? "")))
41
+ .slice(0, MAX_RUN_FILES);
42
+ }
43
+
44
+ export function startDashboard({ root = process.cwd(), port = 0 } = {}) {
45
+ const server = http.createServer((request, response) => {
46
+ const securityHeaders = {
47
+ "cache-control": "no-store",
48
+ "x-content-type-options": "nosniff",
49
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
50
+ };
51
+ if (request.method !== "GET") {
52
+ response.writeHead(405, { ...securityHeaders, allow: "GET", "content-type": "text/plain; charset=utf-8" });
53
+ return response.end("Method not allowed");
54
+ }
55
+ if (request.url === "/api/runs") {
56
+ response.writeHead(200, { ...securityHeaders, "content-type": "application/json; charset=utf-8" });
57
+ return response.end(JSON.stringify(readUpgradeRuns(root)));
58
+ }
59
+ if (request.url === "/" || request.url === "/index.html") {
60
+ response.writeHead(200, { ...securityHeaders, "content-type": "text/html; charset=utf-8" });
61
+ return response.end(dashboardPage);
62
+ }
63
+ response.writeHead(404, { ...securityHeaders, "content-type": "text/plain; charset=utf-8" });
64
+ response.end("Not found");
65
+ });
66
+ return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
67
+ }
package/src/index.js ADDED
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
6
+
7
+ function readAgentPrompt() {
8
+ const cli = `node ${path.join(packageRoot, "bin", "opencode-ruby-upgrader.mjs")}`;
9
+ return fs.readFileSync(path.join(packageRoot, "agents", "ruby-upgrade.md"), "utf8")
10
+ .replace(/^---[\s\S]*?---\s*/m, "")
11
+ .replaceAll("opencode-ruby-upgrader ", `${cli} `)
12
+ .trim();
13
+ }
14
+
15
+ export default async function RubyUpgradePlugin() {
16
+ return {
17
+ config: async (cfg) => {
18
+ cfg.agent ??= {};
19
+ if (!cfg.agent["ruby-upgrade"]) {
20
+ cfg.agent["ruby-upgrade"] = {
21
+ mode: "primary",
22
+ color: "#cc342d",
23
+ description: "Principal-level, evidence-driven Ruby and Rails runtime migration specialist.",
24
+ prompt: readAgentPrompt(),
25
+ permission: {
26
+ read: "allow", edit: "allow", glob: "allow", grep: "allow",
27
+ webfetch: "allow", question: "allow", todowrite: "allow",
28
+ bash: {
29
+ "*": "ask",
30
+ "git *": "deny",
31
+ "git status*": "allow",
32
+ "git diff*": "allow",
33
+ "git log*": "allow",
34
+ "git show*": "allow",
35
+ "git rev-parse*": "allow",
36
+ "git branch --show-current": "allow",
37
+ "git worktree list*": "allow",
38
+ "git push*": "deny",
39
+ "git -C * push*": "deny",
40
+ "git fetch*": "deny",
41
+ "git remote*": "deny",
42
+ "git checkout*": "deny",
43
+ "git switch*": "deny",
44
+ "git reset*": "deny",
45
+ "git clean*": "deny",
46
+ "git rebase*": "deny",
47
+ "git merge*": "deny",
48
+ "bundle --version*": "allow",
49
+ "bundle check*": "allow",
50
+ "bundle install*": "ask",
51
+ "bundle update *": "ask",
52
+ "bundle exec rspec*": "ask",
53
+ "bundle exec rails test*": "ask",
54
+ "bundle exec rake test*": "ask",
55
+ "bundle exec rake db:*": "deny",
56
+ "bin/rails test*": "ask",
57
+ "./bin/rails test*": "ask",
58
+ "bin/rails app:update": "ask",
59
+ "ruby --version*": "allow",
60
+ [`node ${path.join(packageRoot, "bin", "opencode-ruby-upgrader.mjs")} preflight*`]: "allow",
61
+ [`node ${path.join(packageRoot, "bin", "opencode-ruby-upgrader.mjs")} prepare-target-runtime --ruby *`]: "ask",
62
+ "opencode-ruby-upgrader preflight*": "allow",
63
+ "opencode-ruby-upgrader begin --target *": "ask",
64
+ "opencode-ruby-upgrader begin-rails-bridge --ruby-report *": "ask",
65
+ "opencode-ruby-upgrader prepare-target-runtime --ruby *": "ask",
66
+ "opencode-ruby-upgrader status --report *": "allow",
67
+ "opencode-ruby-upgrader transition --report *": "ask",
68
+ "opencode-ruby-upgrader inventory": "allow",
69
+ "opencode-ruby-upgrader supply-chain": "allow",
70
+ "opencode-ruby-upgrader git-capabilities": "allow",
71
+ "opencode-ruby-upgrader record-research --report *": "ask",
72
+ "opencode-ruby-upgrader record-rails-research --report *": "ask",
73
+ "opencode-ruby-upgrader record-risk --report *": "ask",
74
+ "opencode-ruby-upgrader record-framework-bridge --report *": "ask",
75
+ "opencode-ruby-upgrader record-iteration --report *": "ask",
76
+ "opencode-ruby-upgrader record-rails-iteration --report *": "ask",
77
+ "opencode-ruby-upgrader record-executed-iteration --report *": "ask",
78
+ "opencode-ruby-upgrader record-executed-rails-iteration --report *": "ask",
79
+ "opencode-ruby-upgrader discard-pending-app-update --report *": "ask",
80
+ "opencode-ruby-upgrader discard-last-rails-iteration --report *": "ask",
81
+ "opencode-ruby-upgrader record-dependency-review --report *": "ask",
82
+ "opencode-ruby-upgrader commit-hop --report *": "ask",
83
+ "opencode-ruby-upgrader commit-rails-hop --report *": "ask",
84
+ "opencode-ruby-upgrader resume --report *": "ask",
85
+ "opencode-ruby-upgrader release-lock --report *": "ask",
86
+ "*;*": "deny",
87
+ "*&&*": "deny",
88
+ "*||*": "deny",
89
+ "*|*": "deny",
90
+ "*$(*": "deny",
91
+ "*`*": "deny",
92
+ "git push*": "deny",
93
+ "gh *": "deny",
94
+ "npm publish*": "deny",
95
+ "gem push*": "deny",
96
+ "bundle exec rake release*": "deny"
97
+ }
98
+ }
99
+ };
100
+ }
101
+
102
+ cfg.command ??= {};
103
+ if (!cfg.command["ruby-upgrade"]) {
104
+ cfg.command["ruby-upgrade"] = {
105
+ description: "Safely migrate this Ruby project to the latest stable Ruby release.",
106
+ agent: "ruby-upgrade",
107
+ template: "Begin the Ruby migration preflight. User context: $ARGUMENTS"
108
+ };
109
+ }
110
+ }
111
+ };
112
+ }
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const exists = (root, file) => fs.existsSync(path.join(root, file));
5
+ const read = (root, file) => exists(root, file) ? fs.readFileSync(path.join(root, file), "utf8") : "";
6
+
7
+ export function inventoryProject(root = process.cwd()) {
8
+ const gemfile = read(root, "Gemfile");
9
+ const lockfile = read(root, "Gemfile.lock");
10
+ const rubyFiles = [".ruby-version", ".tool-versions", ".mise.toml", "Gemfile", "Dockerfile"].filter((file) => exists(root, file));
11
+ const rubyDeclarations = rubyFiles.map((file) => {
12
+ const contents = read(root, file);
13
+ const match = contents.match(/(?:^|\s)(?:ruby\s*[= ]\s*["']?|ruby\s+|FROM\s+ruby:)(\d+\.\d+(?:\.\d+)?)/mi) ?? (file === ".ruby-version" ? contents.match(/^\s*(\d+\.\d+(?:\.\d+)?)\s*$/m) : null);
14
+ return { file, value: match?.[1] ?? null };
15
+ });
16
+ const rspec = exists(root, "spec") || /\brspec\b/i.test(gemfile);
17
+ const declaredRails = gemfile.match(/\bgem\s+["']rails["']\s*,?\s*["']?([~<>= ]*\d+\.\d+(?:\.\d+)?)/i)?.[1]?.trim() ?? null;
18
+ const resolvedRails = lockfile.match(/^ {4}rails \((\d+(?:\.\d+)+)\)$/m)?.[1] ?? null;
19
+ const rails = /\bgem\s+["']rails["']/i.test(gemfile) || Boolean(resolvedRails);
20
+ const minitest = exists(root, "test") || /\bminitest\b/i.test(gemfile);
21
+ const commands = rspec ? ["bundle exec rspec"] : rails ? [exists(root, "bin/rails") ? "bin/rails test" : "bundle exec rails test"] : minitest ? ["bundle exec rake test"] : [];
22
+ const ci = [".github/workflows", ".gitlab-ci.yml", ".circleci", "Jenkinsfile", "azure-pipelines.yml"].filter((file) => exists(root, file));
23
+ const deploy = ["Dockerfile", "docker-compose.yml", "Procfile", "app.json", "render.yaml", "fly.toml", "config/deploy.yml"].filter((file) => exists(root, file));
24
+ return {
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,
27
+ requiresDecision: !exists(root, "Gemfile") || commands.length === 0,
28
+ reason: !exists(root, "Gemfile") ? "No Gemfile found." : commands.length === 0 ? "No recognized test adapter found." : undefined
29
+ };
30
+ }
@@ -0,0 +1,69 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function git(cwd, args) {
6
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
7
+ }
8
+
9
+ function configuredDefaultBranch(cwd) {
10
+ try { return git(cwd, ["config", "--get", "opencode-ruby-upgrader.defaultBranch"]); }
11
+ catch { return undefined; }
12
+ }
13
+
14
+ export function inspectWorktree(cwd = process.cwd()) {
15
+ try {
16
+ try { git(cwd, ["--version"]); }
17
+ catch (error) { return { ok: false, reason: "git-unavailable", detail: error.message }; }
18
+ if (git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
19
+ return { ok: true, mode: "non-git", root: cwd };
20
+ }
21
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
22
+ const gitDir = git(cwd, ["rev-parse", "--git-dir"]);
23
+ const commonDir = git(cwd, ["rev-parse", "--git-common-dir"]);
24
+ const linkedWorktree = path.resolve(root, gitDir) !== path.resolve(root, commonDir);
25
+ const branch = git(cwd, ["branch", "--show-current"]);
26
+ const sha = git(cwd, ["rev-parse", "HEAD"]);
27
+ const dirty = Boolean(git(cwd, ["status", "--porcelain", "--", ".", ":(exclude).ruby-upgrades"]));
28
+ const defaultBranch = configuredDefaultBranch(cwd);
29
+ if (!linkedWorktree) return { ok: false, reason: "primary-checkout", root, branch, sha, defaultBranch };
30
+ if (!branch) return { ok: false, reason: "detached-head", root, sha, defaultBranch };
31
+ if (!defaultBranch) return { ok: false, reason: "default-branch-unconfigured", root, branch, sha };
32
+ if (branch === defaultBranch) return { ok: false, reason: "default-branch", root, branch, sha, defaultBranch };
33
+ if (dirty) return { ok: false, reason: "dirty-worktree", root, branch, sha, defaultBranch };
34
+ return { ok: true, mode: "linked-worktree", root, branch, sha, gitDir, commonDir };
35
+ } catch (error) {
36
+ if (/not a git repository/i.test(error.message)) return { ok: true, mode: "non-git", root: cwd };
37
+ return { ok: false, reason: "git-preflight-failed", detail: error.message };
38
+ }
39
+ }
40
+
41
+ export function setupInstructions(result) {
42
+ const repo = result.root ? path.basename(result.root) : "your-repository";
43
+ return [
44
+ "Ruby upgrades run only in a user-created linked Git worktree.",
45
+ "Configure the repository default branch before starting:",
46
+ " git config opencode-ruby-upgrader.defaultBranch <default-branch>",
47
+ "From your primary checkout, create a branch and linked worktree yourself:",
48
+ ` git branch ruby-upgrade/ruby-<target>`,
49
+ ` git worktree add ../${repo}-ruby-<target> ruby-upgrade/ruby-<target>`,
50
+ ` cd ../${repo}-ruby-<target> && opencode`,
51
+ "The agent will not create, switch, merge, push, or otherwise manage branches/remotes."
52
+ ].join("\n");
53
+ }
54
+
55
+ export function inspectGitCapabilities(cwd = process.cwd()) {
56
+ try {
57
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
58
+ const value = (args, fallback = false) => { try { return git(cwd, args); } catch { return fallback; } };
59
+ const submodules = fs.existsSync(path.join(root, ".gitmodules"));
60
+ return {
61
+ supported: true,
62
+ shallow: value(["rev-parse", "--is-shallow-repository"]) === "true",
63
+ sparseCheckout: value(["config", "--bool", "core.sparseCheckout"]) === "true",
64
+ submodules,
65
+ lfsConfigured: value(["config", "--get-regexp", "^filter\\.lfs\\."]) !== false,
66
+ recommendation: submodules || value(["rev-parse", "--is-shallow-repository"]) === "true" || value(["config", "--bool", "core.sparseCheckout"]) === "true" ? "Pause for repository-topology review before migration." : "Standard Git topology."
67
+ };
68
+ } catch { return { supported: false, recommendation: "Not a Git repository." }; }
69
+ }
@@ -0,0 +1,21 @@
1
+ import crypto from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+
4
+ function canonical(value) {
5
+ if (Array.isArray(value)) return value.map(canonical);
6
+ if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
7
+ return value;
8
+ }
9
+
10
+ export function receiptDigest(receipts = []) {
11
+ return crypto.createHash("sha256").update(JSON.stringify(canonical(receipts))).digest("hex");
12
+ }
13
+
14
+ export function worktreeFingerprint(root = process.cwd()) {
15
+ const git = (args) => execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
16
+ const headSha = git(["rev-parse", "HEAD"]).trim();
17
+ const diff = git(["diff", "--binary", "HEAD", "--", ":(exclude).ruby-upgrades"]);
18
+ const untracked = git(["ls-files", "--others", "--exclude-standard", "-z", "--", ":(exclude).ruby-upgrades"])
19
+ .split("\0").filter(Boolean).map((file) => `${file}\0${git(["hash-object", "--", file]).trim()}`).join("\0");
20
+ return { algorithm: "sha256", headSha, diffSha256: crypto.createHash("sha256").update(`${diff}\0${untracked}`).digest("hex") };
21
+ }