opencode-ruby-upgrader 0.1.0 → 0.1.2

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 CHANGED
@@ -28,7 +28,7 @@ The sections below record each stage of that work, including the deliberate stop
28
28
  | Plugin dry-run inventory | Passed: Rails/RSpec detected; Ruby `2.4.10`; Rails `4.2.11.3`; 118 locked dependencies; no detected private sources. |
29
29
  | Host runtime | Blocked as expected: host Ruby is `2.6.10`, but fixture requires `2.4.10`; installed Bundler is `1.17.2`, while the lockfile specifies `1.17.3`. |
30
30
  | Dependency install / test suite | Not run. A container or version manager providing the exact historical runtime is required. |
31
- | Container runtime | Blocked: Docker CLI is installed, but the local Docker daemon was unavailable at `unix:///Users/lilla/.docker/run/docker.sock`; no containers or fixture code were started. |
31
+ | Container runtime | Blocked: Docker CLI was installed, but the local Docker daemon was unavailable; no containers or fixture code were started. |
32
32
 
33
33
  ## 2026-09-18 container baseline — in progress
34
34
 
@@ -62,7 +62,7 @@ The local package worktree now contains a constrained `docker-bundle-rspec` exec
62
62
 
63
63
  ## 2026-09-18 OpenCode preflight — blocked, recoverable
64
64
 
65
- An explicit local-plugin preflight returned `ok: false` with `reason: "dirty-worktree"` for branch `ruby-upgrade/e2e-3.4` at fixture commit `bad95e2be88687f5d185c29a2361526fa05b8f54`; its configured default branch was correctly detected as `main`. The only observed worktree change was an untracked `LEARNING_PATH.md` created by the OpenCode session, not fixture work. Remove that generated file and rerun preflight before any plugin lifecycle action. The package was also corrected so its injected agent invokes the package-local Node CLI rather than assuming the package binary is globally on `PATH`; `npm test` remained 32/32 and `git diff --check` passed after that correction.
65
+ An explicit local-plugin preflight returned `ok: false` with `reason: "dirty-worktree"` for branch `ruby-upgrade/e2e-3.4` at fixture commit `bad95e2be88687f5d185c29a2361526fa05b8f54`; its configured default branch was correctly detected as `main`. The only observed change was an unrelated ignored local note, not fixture work. After removing that file, preflight was rerun before any plugin lifecycle action. The package was also corrected so its injected agent invokes the package-local Node CLI rather than assuming the package binary is globally on `PATH`; `npm test` remained 32/32 and `git diff --check` passed after that correction.
66
66
 
67
67
  ## Fixture reset attempt — in progress
68
68
 
package/README.md CHANGED
@@ -43,7 +43,7 @@ This proves the workflow works on a genuinely old, real-world Rails stack. It do
43
43
 
44
44
  ## Safety model
45
45
 
46
- For Git repositories, the agent runs **only** from a linked Git worktree created by the user. Before starting, explicitly configure the repository default branch with `git config opencode-ruby-upgrader.defaultBranch main` (replace `main` as needed) — see [Quick start](#quick-start) for the three setup commands, which the agent also shows verbatim if you invoke it from a primary checkout. The upgrader fails closed if this configuration is absent and never guesses `main`, `master`, or a remote default. This keeps your normal checkout free for other work. In a non-Git project, it asks for confirmation before proceeding without worktree isolation or Git checkpoints. It never creates, switches, deletes, merges, pushes, or reconfigures branches/remotes. It also never publishes, deploys, or runs destructive database commands.
46
+ The agent runs durable migrations **only** from a linked Git worktree created by the user. Before starting, explicitly configure the repository default branch with `git config opencode-ruby-upgrader.defaultBranch main` (replace `main` as needed) — see [Quick start](#quick-start) for the three setup commands, which the agent also shows verbatim if you invoke it from a primary checkout. The upgrader fails closed if this configuration is absent and never guesses `main`, `master`, or a remote default. This keeps your normal checkout free for other work. Non-Git projects support dry-run inventory only. The agent never creates, switches, deletes, merges, pushes, or reconfigures branches/remotes. It also never publishes, deploys, or runs destructive database commands.
47
47
 
48
48
  After every routine Ruby minor-version hop with passing validation, the agent proposes a **local** checkpoint commit through a guarded commit gate and OpenCode asks for confirmation. The gate verifies the linked worktree and non-default branch, exact expected Git history, an empty initial staging area, a complete passing report iteration, and scans staged content for likely credentials. It cannot push, fetch, alter remotes, switch branches, merge, rebase, reset, or amend history. You can review and push any validated checkpoint; a run becomes `complete` only once it reaches its pinned target.
49
49
 
@@ -101,7 +101,7 @@ If a resolved Rails version blocks the next Ruby hop, record the user-approved b
101
101
 
102
102
  The agent defaults unknown shell commands to an OpenCode confirmation prompt. Git inspection is allowed, while direct Git mutation, GitHub CLI, publishing, and shell chaining/pipes/substitutions are denied. Dependency installation/updates, recognized tests, state writes, `commit-hop`, and `commit-rails-hop` require confirmation. This protects against accidental agent actions, not malicious project code: dependency installation and tests execute project-controlled code with your local user permissions. Use an isolated environment for repositories you do not trust, and review any command OpenCode asks you to approve.
103
103
 
104
- New reports require `record-executed-iteration --validation <id>` or `record-executed-rails-iteration --validation <test-id>`; asserted results cannot be recorded or committed. The accepted IDs map to fixed no-shell commands: `bundle-rspec`, `bundle-rails-test`, `bundle-rake-test`, `bin-rails-test`, and, for Rails bridges, `rails-app-update`. For a legacy Ruby hop, the agent runs `prepare-target-runtime --ruby <x.y.z>` after selecting the exact target patch release. One confirmation provisions labelled per-run Ruby and isolated PostgreSQL Docker resources, installs Node, installs Bundler 1.17.3, runs `bundle install`, and, for Rails, creates the isolated test database. It writes nonsecret `.ruby-upgrades/runtime.json` with only safe preparation digests and the resolved image ID; raw output and `DATABASE_URL` are never persisted. `docker-bundle-rspec` reuses and verifies that manifest, including the exact requested Ruby execution and resolved image ID, before executing the fixed `docker exec --env DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true <container> bundle exec rspec`. The safeguard override is scoped to the verified isolated test process; no container name, report path, or environment value is needed from the user. Receipts persist only an output digest and byte count, plus structured test metrics; raw validation output is deliberately not committed. Each receipt also binds to a non-evidence working-tree fingerprint, which the commit gate rechecks after final validation. A checkpoint commit carries the receipt digest. This is tamper-evident provenance for a committed report, not protection against the same local user rewriting both evidence and Git history.
104
+ New reports require `record-executed-iteration --validation <id>` or `record-executed-rails-iteration --validation <test-id>`; asserted results cannot be recorded or committed. The accepted IDs map to fixed no-shell commands: `bundle-rspec`, `bundle-rails-test`, `bundle-rake-test`, `bin-rails-test`, and, for Rails bridges, `rails-app-update`. For a legacy Ruby hop, the agent runs `prepare-target-runtime --ruby <x.y.z>` after selecting the exact target patch release. One confirmation provisions labelled per-run Ruby and isolated PostgreSQL Docker resources, installs Node, installs Bundler 2.4.22, runs `bundle install`, and, for Rails, creates the isolated test database. It writes nonsecret `.ruby-upgrades/runtime.json` with only safe preparation digests and the resolved image ID; raw output and `DATABASE_URL` are never persisted. `docker-bundle-rspec` reuses and verifies that manifest, including the exact requested Ruby execution and resolved image ID, before executing the fixed `docker exec --env DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true <container> bundle exec rspec`. The safeguard override is scoped to the verified isolated test process; no container name, report path, or environment value is needed from the user. Receipts persist only an output digest and byte count, plus structured test metrics; raw validation output is deliberately not committed. Each receipt also binds to a non-evidence working-tree fingerprint, which the commit gate rechecks after final validation. A checkpoint commit carries the receipt digest. This is tamper-evident provenance for a committed report, not protection against the same local user rewriting both evidence and Git history.
105
105
 
106
106
  ## Product limits
107
107
 
@@ -3,10 +3,10 @@
3
3
  Complete every item before pushing a `v*` tag.
4
4
 
5
5
  - [x] Create `github.com/lilla021/opencode-ruby-upgrader`; push the reviewed `main` branch.
6
- - [ ] Configure the GitHub `npm-release` environment with required approval and tag restriction `v*`.
7
- - [x] First-publish bootstrap plan: publish `v0.1.0` manually from the workstation once with 2FA (npm policy requires the package to exist before OIDC trusted publishing or staged publishing can be configured — see `npm/cli#8544`). No long-lived token is needed for this bootstrap.
6
+ - [x] Configure the GitHub `npm-release` environment with required approval and tag restriction `v*`.
7
+ - [x] Published `v0.1.0` manually from the workstation once with 2FA (npm policy required the package to exist before OIDC trusted publishing could be configured — see `npm/cli#8544`). No long-lived token was used for this bootstrap.
8
8
  - [x] After v0.1.0 exists: configure npm Trusted Publishing (OIDC) for `opencode-ruby-upgrader` bound to `.github/workflows/release.yml` + `npm-release` environment; CI then publishes with `npm publish --provenance` using no stored secret. Optionally restrict the trusted publisher to stage-only for later versions.
9
- - [x] Confirm `opencode-ruby-upgrader` currently returns npm registry 404 and is available for first publication.
9
+ - [x] Confirmed `opencode-ruby-upgrader` returned npm registry 404 and was available before its first publication.
10
10
  - [x] Run `npm test` and `npm pack --dry-run` from the release candidate.
11
11
  - [x] Install latest OpenCode, load this package from a local `file://` plugin path, restart OpenCode, and confirm `/ruby-upgrade` plus its permission prompts.
12
12
  - [x] Run a supported Ruby fixture in a linked Git worktree: complete one hop, inspect the local commit/report/dashboard, then exercise one risk pause.
package/RELEASE_NOTES.md CHANGED
@@ -1,4 +1,12 @@
1
- # opencode-ruby-upgrader — v0.1.0 release notes
1
+ # opencode-ruby-upgrader — release notes
2
+
3
+ ## v0.1.2 — first automated release
4
+
5
+ This release uses npm trusted publishing (OIDC) with provenance from protected CI. It has no plugin behavior change from v0.1.0; it verifies the automated pipeline end to end: tag push → environment approval → `npm test` → pinned OpenCode runtime smoke test → OIDC publish with provenance attestation.
6
+
7
+ `v0.1.1` was tagged but not published after its release gate correctly stopped on the OpenCode runtime smoke test. The gate now explicitly approves only the reviewed `opencode-ai@1.18.30` install script required to materialize the OpenCode binary under npm 11.
8
+
9
+ ## v0.1.0 — initial public release
2
10
 
3
11
  ## What this is
4
12
 
@@ -38,4 +46,4 @@ Every validated hop is a separate local commit carrying its validation receipt d
38
46
 
39
47
  ## License
40
48
 
41
- MIT.
49
+ MIT.
@@ -10,9 +10,9 @@ You own a careful Ruby runtime migration. Be decisive on routine fixes and trans
10
10
 
11
11
  ## Non-negotiable safety contract
12
12
 
13
- - Begin by running `opencode-ruby-upgrader preflight --json`. If it does not return `ok: true`, do not inspect, edit, test, or resolve dependencies. For Git repositories, require the user to configure `git config opencode-ruby-upgrader.defaultBranch <branch>`; do not infer a default branch. Show the worktree instructions and ask the user to relaunch OpenCode from their user-created linked worktree. If it returns `mode: "non-git"`, explain that automatic commits, Git checkpoints, and worktree isolation are unavailable, then continue only after the user accepts that limitation.
14
- - Parse user controls before work: `--dry-run`, `--target <version>`, and `--stop-after-hop`. Run `opencode-ruby-upgrader inventory`, `opencode-ruby-upgrader supply-chain`, and `opencode-ruby-upgrader git-capabilities` first. Pause for explicit review on shallow clones, sparse checkout, submodules, or LFS configuration. If the project is unsupported or has no recognized test adapter, stop with the detected evidence and ask for a test command; do not invent one. After official research pins the target, initialize the durable run with `opencode-ruby-upgrader begin --target <version>` (append `--dry-run` or `--stop-after-hop` when requested). For a non-Git project, obtain explicit confirmation and use `--allow-non-git`; this flag requires an OpenCode confirmation. Use only the returned report path for this run.
15
- - Drive the durable state machine, not prose alone: record research with `record-research`, then transition `initialized → inventory_complete → research_complete`; record each complete evidence-backed hop only with `record-executed-iteration` (or `record-executed-rails-iteration`), transition to `hop_validated`, and use the returned checkpoint SHA when transitioning to `committed`. Exactly one checkpoint is required before the next iteration. Repeat per hop. Use `paused` to stop safely for user review/manual work (it releases the lock); `complete` is only valid once the pinned target is reached. Use `blocked` only with evidence and an actionable option. For an isolated legacy runtime, ask once for approval to apply the planned Ruby declaration edit and then run `prepare-target-runtime --ruby <x.y.z> --report <the run path you just resumed>` before `docker-bundle-rspec`: it creates isolated Docker resources, installs Node and Bundler 1.17.3, runs `bundle install`, and creates the isolated Rails test database when applicable. Execute preparation with the longest supported shell timeout (at least 15 minutes), not a default short timeout. Do not ask the user for a report path, container name, or environment variables. The persisted nonsecret runtime manifest binds labelled app and PostgreSQL containers to the selected run; `docker-bundle-rspec` executes only the fixed inner argv `bundle exec rspec`.
13
+ - Begin by running `opencode-ruby-upgrader preflight --json`. If it does not return `ok: true`, do not inspect, edit, test, or resolve dependencies. Require the user to configure `git config opencode-ruby-upgrader.defaultBranch <branch>`; do not infer a default branch. Show the worktree instructions and ask the user to relaunch OpenCode from their user-created linked worktree. If it returns `mode: "non-git"`, permit dry-run inventory only and stop before any durable run, edit, test, or dependency resolution.
14
+ - Parse user controls before work: `--dry-run`, `--target <version>`, and `--stop-after-hop`. Run `opencode-ruby-upgrader inventory`, `opencode-ruby-upgrader supply-chain`, and `opencode-ruby-upgrader git-capabilities` first. Pause for explicit review on shallow clones, sparse checkout, submodules, or LFS configuration. If the project is unsupported or has no recognized test adapter, stop with the detected evidence and ask for a test command; do not invent one. After official research pins the target, initialize the durable run with `opencode-ruby-upgrader begin --target <version>` (append `--dry-run` or `--stop-after-hop` when requested). Use only the returned report path for this run.
15
+ - Drive the durable state machine, not prose alone: record research with `record-research`, then transition `initialized → inventory_complete → research_complete`; record each complete evidence-backed hop only with `record-executed-iteration` (or `record-executed-rails-iteration`), transition to `hop_validated`, and use the returned checkpoint SHA when transitioning to `committed`. Exactly one checkpoint is required before the next iteration. Repeat per hop. Use `paused` to stop safely for user review/manual work (it releases the lock); `complete` is only valid once the pinned target is reached. Use `blocked` only with evidence and an actionable option. For an isolated legacy runtime, ask once for approval to apply the planned Ruby declaration edit and then run `prepare-target-runtime --ruby <x.y.z> --report <the run path you just resumed>` before `docker-bundle-rspec`: it creates isolated Docker resources, installs Node and Bundler 2.4.22, runs `bundle install`, and creates the isolated Rails test database when applicable. Execute preparation with the longest supported shell timeout (at least 15 minutes), not a default short timeout. Do not ask the user for a report path, container name, or environment variables. The persisted nonsecret runtime manifest binds labelled app and PostgreSQL containers to the selected run; `docker-bundle-rspec` executes only the fixed inner argv `bundle exec rspec`.
16
16
  - If the next Ruby hop is incompatible with the resolved Rails version, do not edit Rails as part of the Ruby hop. Cite the official compatibility evidence, explain the required Rails from/to versions, and obtain explicit approval before running `record-framework-bridge --report <report-path> --ruby-from <version> --ruby-to <version> --rails-from <version> --rails-to <version> --rationale <text> --citation 'title|https://...'`. Then transition the Ruby run to `blocked` and start `begin-rails-bridge --ruby-report <blocked-ruby-report>`. In that separate report, research contiguous Rails-minor hops with `record-rails-research`, record each passing hop with `record-executed-rails-iteration` including reviewed `bin/rails app:update` evidence, transition to `hop_validated`, and checkpoint only with `commit-rails-hop`. Start a fresh Ruby run only after the Rails bridge is complete.
17
17
  - Rails `app:update` executes with conflict-skipping semantics so existing application configuration is never overwritten noninteractively. Review every generated file before final tests. If a pending result is unsafe or superseded, revert only that unvalidated hop and run `discard-pending-app-update --report <report-path> --reason <review finding>`; the discarded digest remains durable evidence before a fresh attempt.
18
18
  - When a validated hop changes a lockfile, inspect the resolved dependency delta and license findings, then durably record both with `record-dependency-review --report <report-path> --compatibility <finding> --licenses <finding>` before requesting the checkpoint. Never bypass this gate merely because tests pass.
@@ -13,13 +13,13 @@ const option = (name) => { const index = args.indexOf(name); return index >= 0 ?
13
13
  const options = (name) => args.flatMap((argument, index) => argument === name && args[index + 1] ? [args[index + 1]] : []);
14
14
  const reportOption = () => option("--report");
15
15
  const citation = (value) => { const [title, url] = (value ?? "").split("|"); return { title, url }; };
16
- const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-risk|record-framework-bridge|record-executed-iteration|record-executed-rails-iteration|discard-pending-app-update|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|resume|release-lock> [--help]";
16
+ const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-risk|record-framework-bridge|record-executed-iteration|record-executed-rails-iteration|discard-pending-app-update|discard-last-rails-iteration|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|resume|release-lock> [--help]";
17
17
  if (command === "help" || args.includes("--help")) {
18
18
  console.log(`${usage}\n\nUse status --summary for a concise report view. release-lock is stale-session recovery only and requires --force.`);
19
19
  } else if (command === "preflight") {
20
20
  const result = inspectWorktree();
21
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.");
22
+ else if (result.ok && result.mode === "non-git") console.log("No Git repository detected. Dry-run inventory is available, but durable migrations require a linked Git worktree.");
23
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
24
  else console.error(`${setupInstructions(result)}\n\nPreflight blocked: ${result.reason}`);
25
25
  process.exitCode = result.ok ? 0 : 1;
@@ -28,14 +28,14 @@ if (command === "help" || args.includes("--help")) {
28
28
  const address = server.address();
29
29
  console.log(`Ruby Upgrade Workspace: http://127.0.0.1:${address.port}`);
30
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)); }
31
+ try { console.log(JSON.stringify(beginRun({ target: option("--target"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop") }), null, 2)); }
32
32
  catch (error) { console.error(`Run start blocked: ${error.message}`); process.exitCode = 1; }
33
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)); }
34
+ try { console.log(JSON.stringify(beginRailsBridgeRun({ rubyReportPath: option("--ruby-report"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop") }), null, 2)); }
35
35
  catch (error) { console.error(`Rails bridge start blocked: ${error.message}`); process.exitCode = 1; }
36
36
  } else if (command === "prepare-target-runtime") {
37
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]");
38
+ if (!option("--ruby")) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--report .ruby-upgrades/runs/<run>.json]");
39
39
  console.log(JSON.stringify(prepareTargetRuntime({ ruby: option("--ruby"), reportPath: option("--report") }), null, 2));
40
40
  } catch (error) { console.error(`Target runtime preparation blocked: ${error.message}`); process.exitCode = 1; }
41
41
  } else if (command === "status") {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "opencode-ruby-upgrader",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A safe, evidence-driven Ruby runtime upgrade agent and local migration dashboard for OpenCode",
5
- "author": "Priscilla Cournoyer",
5
+ "author": "lilla021",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/lilla021/opencode-ruby-upgrader.git"
@@ -20,6 +20,9 @@
20
20
  "test": "node --test tests/*.test.js",
21
21
  "test:opencode": "node tests/opencode-runtime-smoke.mjs"
22
22
  },
23
+ "allowScripts": {
24
+ "opencode-ai@1.18.30": true
25
+ },
23
26
  "files": ["src", "agents", "bin", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "RELEASING.md", "RELEASE_CHECKLIST.md", "RELEASE_NOTES.md", "E2E_EVIDENCE.md"],
24
27
  "keywords": ["opencode", "opencode-plugin", "ruby", "rails", "upgrade", "worktree", "migration"],
25
28
  "engines": { "node": ">=22.5.0" },
package/src/controller.js CHANGED
@@ -31,18 +31,19 @@ const requiredRisksFor = (supplyChain, gitCapabilities) => [
31
31
  ...(gitCapabilities.shallow ? ["shallow-clone"] : []), ...(gitCapabilities.sparseCheckout ? ["sparse-checkout"] : []),
32
32
  ...(gitCapabilities.submodules ? ["submodules"] : []), ...(gitCapabilities.lfsConfigured ? ["git-lfs"] : [])
33
33
  ];
34
- const assertApprovedRisks = (run) => {
34
+ const hasUnapprovedRisks = (run) => {
35
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.");
36
+ return (run.requiredRisks ?? []).some((risk) => decisions.get(risk) !== "approved") || [...decisions.values()].some((decision) => decision !== "approved");
37
37
  };
38
+ const assertApprovedRisks = (run) => { if (hasUnapprovedRisks(run)) throw new Error("Resolve every detected risk with an explicit approval before recording a routine iteration."); };
38
39
 
39
- export function beginRun({ root = process.cwd(), target, dryRun = false, stopAfterHop = false, allowNonGit = false } = {}) {
40
+ export function beginRun({ root = process.cwd(), target, dryRun = false, stopAfterHop = false } = {}) {
40
41
  if (!target || !rubyVersion.test(target)) throw new Error("Provide --target as a Ruby version such as 3.4 or 3.4.1.");
41
42
  const preflight = inspectWorktree(root); const inventory = inventoryProject(root); const supplyChain = inspectSupplyChain(root); const gitCapabilities = inspectGitCapabilities(root);
42
43
  const plan = { preflight, inventory, supplyChain, gitCapabilities, targetRuby: target, dryRun, stopAfterHop };
43
- if (dryRun || !preflight.ok) return plan;
44
+ if (dryRun) return plan;
45
+ if (!preflight.ok || preflight.mode !== "linked-worktree") throw new Error("A durable migration requires a supported linked Git worktree.");
44
46
  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
47
  const reportPath = path.join(".ruby-upgrades", "runs", runName());
47
48
  const requiredRisks = requiredRisksFor(supplyChain, gitCapabilities);
48
49
  const report = {
@@ -57,15 +58,15 @@ export function beginRun({ root = process.cwd(), target, dryRun = false, stopAft
57
58
 
58
59
  export function runStatus({ root = process.cwd(), reportPath }) { return readRun(root, reportPath); }
59
60
 
60
- export function beginRailsBridgeRun({ root = process.cwd(), rubyReportPath, dryRun = false, stopAfterHop = false, allowNonGit = false } = {}) {
61
+ export function beginRailsBridgeRun({ root = process.cwd(), rubyReportPath, dryRun = false, stopAfterHop = false } = {}) {
61
62
  const rubyRun = readRun(root, rubyReportPath);
62
63
  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
64
  const preflight = inspectWorktree(root); const inventory = inventoryProject(root); const supplyChain = inspectSupplyChain(root); const gitCapabilities = inspectGitCapabilities(root); const bridge = rubyRun.frameworkBridge;
64
65
  const plan = { preflight, inventory, supplyChain, gitCapabilities, bridge, dryRun, stopAfterHop };
65
- if (dryRun || !preflight.ok) return plan;
66
+ if (dryRun) return plan;
67
+ if (!preflight.ok || preflight.mode !== "linked-worktree") throw new Error("A durable Rails bridge requires a supported linked Git worktree.");
66
68
  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
+ if (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
70
  const reportPath = path.join(".ruby-upgrades", "runs", runName());
70
71
  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
72
  const lock = acquireRunLock(root, reportPath); report.lockNonce = lock.nonce;
@@ -215,7 +216,7 @@ function assertCompletion(run) {
215
216
  const target = run.reportType === "rails_bridge" ? run.targetRails : run.targetRuby;
216
217
  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
218
  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 (hasUnapprovedRisks(run)) throw new Error("Unresolved risks prevent completion.");
219
220
  if (final.checkpointSha !== run.expectedHead) throw new Error("The final iteration must be committed through the checkpoint gate before completion.");
220
221
  }
221
222
 
package/src/preflight.js CHANGED
@@ -57,13 +57,26 @@ export function inspectGitCapabilities(cwd = process.cwd()) {
57
57
  const root = git(cwd, ["rev-parse", "--show-toplevel"]);
58
58
  const value = (args, fallback = false) => { try { return git(cwd, args); } catch { return fallback; } };
59
59
  const submodules = fs.existsSync(path.join(root, ".gitmodules"));
60
+ const lfsAttributes = (() => {
61
+ // Repo-local LFS signal: the committed root .gitattributes declaring
62
+ // filter=lfs. Read from the object database (not the working file) so
63
+ // results hold even where git applies the declared smudge filter on
64
+ // checkout (e.g. hosts with git-lfs installed system-wide).
65
+ try { return git(cwd, ["show", "HEAD:.gitattributes"]).split("\n").some((line) => !line.trim().startsWith("#") && /\bfilter\s*=\s*lfs\b/i.test(line)); }
66
+ catch { return false; }
67
+ })();
68
+ const shallow = value(["rev-parse", "--is-shallow-repository"]) === "true";
69
+ // Capability detection reads repository-local state only, so ambient
70
+ // global/system git config cannot leak machine-specific risks into a run.
71
+ const sparseCheckout = value(["config", "--local", "--bool", "core.sparseCheckout"]) === "true";
72
+ const lfsConfigured = value(["config", "--local", "--get-regexp", "^filter\\.lfs\\."]) !== false || lfsAttributes;
60
73
  return {
61
74
  supported: true,
62
- shallow: value(["rev-parse", "--is-shallow-repository"]) === "true",
63
- sparseCheckout: value(["config", "--bool", "core.sparseCheckout"]) === "true",
75
+ shallow,
76
+ sparseCheckout,
64
77
  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."
78
+ lfsConfigured,
79
+ recommendation: submodules || shallow || sparseCheckout ? "Pause for repository-topology review before migration." : "Standard Git topology."
67
80
  };
68
81
  } catch { return { supported: false, recommendation: "Not a Git repository." }; }
69
82
  }