From fee5cf441a6a6087167e686e5e071e59b7033b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 11 Jun 2026 10:44:33 -0300 Subject: [PATCH] fix: prevent Bun downgrades during upstream merges --- .kilo/plans/1781184434428-neon-island.md | 24 ++++++++ package.json | 2 +- script/upstream/README.md | 6 +- script/upstream/merge.ts | 17 ++++++ .../transforms/transform-package-json.test.ts | 61 ++++++++++++++++++- .../transforms/transform-package-json.ts | 51 ++++++++++++++++ 6 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 .kilo/plans/1781184434428-neon-island.md diff --git a/.kilo/plans/1781184434428-neon-island.md b/.kilo/plans/1781184434428-neon-island.md new file mode 100644 index 0000000000..c175e33ae4 --- /dev/null +++ b/.kilo/plans/1781184434428-neon-island.md @@ -0,0 +1,24 @@ +# Prevent Bun downgrades during upstream merges + +## Goal + +Ensure `script/upstream/merge.ts` keeps the newer root `packageManager` Bun version across Kilo and upstream: accept an upstream upgrade, preserve Kilo when upstream is older, and fail before finalizing if the merged result is below either input. + +## Implementation + +1. Update `script/upstream/transforms/transform-package-json.ts` with a small, exported Bun package-manager reconciliation helper that: + - Applies only to the root `package.json`. + - Parses `bun@` values and compares them with the existing version comparison logic. + - Selects the newer value from Kilo/base and upstream, preserving the original full `packageManager` string. + - Treats malformed or missing values conservatively so an unparseable upstream value cannot replace a valid Kilo value. + - Records a transform change when it restores a newer Kilo version. +2. Invoke that helper in every root package reconciliation path: conflicted package transformation, pre-merge package transformation, and post-merge reconciliation. This makes an upstream `bun@1.3.15` upgrade flow through while preventing an upstream `bun@1.3.13` from replacing Kilo's `bun@1.3.14`. +3. Add a final guard in `script/upstream/merge.ts`, after successful package reconciliation and before finalization/push, that compares the working-tree Bun version with both the base commit and pristine upstream target. Abort with a clear error if the result is lower than the newest valid input, protecting against future transform/rerere regressions. +4. Extend `script/upstream/transforms/transform-package-json.test.ts` with focused cases for Kilo-newer, upstream-newer, equal, non-root, and malformed package-manager values. Cover the final comparison/guard through an exported pure assertion helper rather than mocking git. +5. Update `script/upstream/README.md` to document the newest-Bun-wins behavior and the no-downgrade final validation. + +## Validation + +- Run `bun test script/upstream/transforms/transform-package-json.test.ts`. +- Run the repository typecheck (`bun run typecheck`) to validate the updated transform and merge orchestration types. +- No changeset is needed because this only changes internal upstream-merge tooling. diff --git a/package.json b/package.json index 96965ca363..4a60f5b96d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.13", + "packageManager": "bun@1.3.14", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:storybook": "bun --cwd packages/storybook storybook", diff --git a/script/upstream/README.md b/script/upstream/README.md index 2024a11ad6..0423f0dd88 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -48,7 +48,7 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1. | `transforms/skip-files.ts` | Skip/remove files that shouldn't exist in Kilo | | `transforms/transform-i18n.ts` | Transform i18n files with Kilo branding | | `transforms/transform-take-theirs.ts` | Take upstream + apply Kilo branding for branding-only files | -| `transforms/transform-package-json.ts` | Enhanced package.json with Kilo dependency injection | +| `transforms/transform-package-json.ts` | Enhanced package.json with Kilo dependency injection and newest-Bun-wins reconciliation | | `transforms/transform-scripts.ts` | Transform script files with GitHub API references | | `transforms/transform-extensions.ts` | Transform extension files (Zed, etc.) | | `transforms/transform-web.ts` | Transform web/docs files (.mdx) | @@ -196,6 +196,10 @@ Now: The only remaining conflicts are files with **actual code differences** - files with `kilocode_change` markers that contain Kilo-specific logic. +### Bun Version Safety + +Root `package.json` reconciliation uses the newer valid `packageManager` Bun version from Kilo and upstream. An older upstream version cannot downgrade Kilo, while a newer upstream version is retained as an upgrade. Before the merge is finalized, `merge.ts` also validates the result against the pristine Kilo base and upstream commit and aborts if the merged Bun version is lower than either input. + ## CLI Options ### merge.ts diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 0e1d9a5f1e..ca30c69d87 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -37,6 +37,7 @@ import { transformConflictedPackageJson, transformAllPackageJson, reconcileAllPackageJson, + assertBunPackageManager, } from "./transforms/transform-package-json" import { transformConflictedScripts, transformAllScripts } from "./transforms/transform-scripts" import { transformConflictedExtensions, transformAllExtensions } from "./transforms/transform-extensions" @@ -206,6 +207,20 @@ async function getAuthor(): Promise { .replace(/\s+/g, "") } +function manager(content: string): string | undefined { + const pkg: unknown = JSON.parse(content) + if (!pkg || typeof pkg !== "object" || !("packageManager" in pkg)) return undefined + return typeof pkg.packageManager === "string" ? pkg.packageManager : undefined +} + +async function validateBun(base: string, upstream: string): Promise { + const current = manager(await Bun.file("package.json").text()) + const ours = manager(await $`git show ${base}:package.json`.text()) + const theirs = manager(await $`git show ${upstream}:package.json`.text()) + assertBunPackageManager(current, ours, theirs) + logger.success(`Validated Bun packageManager: ${current ?? "missing"}`) +} + async function createBackupBranch(baseBranch: string): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) const backupName = `backup/${baseBranch}-${timestamp}` @@ -807,6 +822,7 @@ async function main() { // Exit early - don't continue to finalization steps process.exit(1) } else { + await validateBun(baseSha, targetVersion.commit) await git.stageAll() await git.commit(`merge: upstream ${targetVersion.tag}`) logger.success("Merge completed - all conflicts auto-resolved!") @@ -824,6 +840,7 @@ async function main() { if (reconcileCount > 0) { logger.success(`Reconciled ${reconcileCount} package.json file(s) post-merge`) } + await validateBun(baseSha, targetVersion.commit) await git.stageAll() const hasChanges = await git.hasUncommittedChanges() if (hasChanges) { diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index 60e6d46e09..59020c4366 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -1,5 +1,13 @@ import { expect, test } from "bun:test" -import { fixCatalog, fixMetadata, fixScripts, mergeWithNewestVersions } from "./transform-package-json" +import { + assertBunPackageManager, + fixCatalog, + fixMetadata, + fixPackageManager, + fixScripts, + mergeWithNewestVersions, + selectBunPackageManager, +} from "./transform-package-json" test("fixScripts preserves Kilo-only root scripts from base", () => { const ours = { @@ -125,3 +133,54 @@ test("mergeWithNewestVersions appends theirs-only keys at the end", () => { const result = mergeWithNewestVersions(ours, theirs, changes, "dependencies") expect(Object.keys(result)).toEqual(["a", "b", "c"]) }) + +test("selectBunPackageManager keeps the newer Bun version", () => { + expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.13")).toBe("bun@1.3.14") + expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.15")).toBe("bun@1.3.15") + expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.14")).toBe("bun@1.3.14") +}) + +test("selectBunPackageManager preserves valid versions over malformed values", () => { + expect(selectBunPackageManager("bun@1.3.14", "bun@latest")).toBe("bun@1.3.14") + expect(selectBunPackageManager("bun@latest", "bun@1.3.15")).toBe("bun@1.3.15") + expect(selectBunPackageManager("bun@latest", "npm@11.0.0")).toBeUndefined() +}) + +test("fixPackageManager prevents root Bun downgrades", () => { + const pkg: Record = { packageManager: "bun@1.3.13" } + const ours = { packageManager: "bun@1.3.14" } + const changes: string[] = [] + fixPackageManager(pkg, "package.json", ours, changes) + expect(pkg.packageManager).toBe("bun@1.3.14") + expect(changes).toEqual(["packageManager: bun@1.3.13 -> bun@1.3.14 (Kilo newer)"]) +}) + +test("fixPackageManager accepts upstream Bun upgrades", () => { + const pkg: Record = { packageManager: "bun@1.3.15" } + const changes: string[] = [] + fixPackageManager(pkg, "package.json", { packageManager: "bun@1.3.14" }, changes) + expect(pkg.packageManager).toBe("bun@1.3.15") + expect(changes).toEqual([]) +}) + +test("fixPackageManager ignores nested package.json files", () => { + const pkg: Record = { packageManager: "bun@1.3.13" } + const changes: string[] = [] + fixPackageManager(pkg, "packages/opencode/package.json", { packageManager: "bun@1.3.14" }, changes) + expect(pkg.packageManager).toBe("bun@1.3.13") + expect(changes).toEqual([]) +}) + +test("assertBunPackageManager rejects merged downgrades and invalid values", () => { + expect(() => assertBunPackageManager("bun@1.3.13", "bun@1.3.14", "bun@1.3.12")).toThrow( + "Bun packageManager downgrade detected", + ) + expect(() => assertBunPackageManager("bun@latest", "bun@1.3.14", "bun@1.3.15")).toThrow( + "Bun packageManager validation failed", + ) +}) + +test("assertBunPackageManager accepts the newest input or a newer result", () => { + expect(() => assertBunPackageManager("bun@1.3.15", "bun@1.3.14", "bun@1.3.15")).not.toThrow() + expect(() => assertBunPackageManager("bun@1.3.16", "bun@1.3.14", "bun@1.3.15")).not.toThrow() +}) diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 20fa0b280a..7986a847db 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -105,6 +105,51 @@ function compareVersions(a: string, b: string): number | null { return 0 } +function bun(value: unknown): { value: string; version: string } | null { + if (typeof value !== "string") return null + const match = value.match(/^bun@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/) + if (!match) return null + if (compareVersions(match[1], match[1]) === null) return null + return { value, version: match[1] } +} + +export function selectBunPackageManager(ours: unknown, theirs: unknown): string | undefined { + const left = bun(ours) + const right = bun(theirs) + if (left && right) return compareVersions(left.version, right.version)! > 0 ? left.value : right.value + if (left) return left.value + if (right) return right.value + return undefined +} + +export function fixPackageManager( + pkg: Record, + path: string, + ours: Record | null, + changes: string[], +): void { + if (path !== "package.json") return + const next = selectBunPackageManager(ours?.packageManager, pkg.packageManager) + if (!next || pkg.packageManager === next) return + const prior = typeof pkg.packageManager === "string" ? pkg.packageManager : "missing or invalid" + changes.push(`packageManager: ${prior} -> ${next} (Kilo newer)`) + pkg.packageManager = next +} + +export function assertBunPackageManager(current: unknown, base: unknown, upstream: unknown): void { + const inputs = [bun(base), bun(upstream)].filter((item): item is NonNullable => item !== null) + if (inputs.length === 0) return + const required = inputs.reduce((max, item) => (compareVersions(item.version, max.version)! > 0 ? item : max)) + const actual = bun(current) + if (!actual) { + throw new Error( + `Bun packageManager validation failed: merged value is invalid; expected at least ${required.value}`, + ) + } + if (compareVersions(actual.version, required.version)! >= 0) return + throw new Error(`Bun packageManager downgrade detected: merged ${actual.value}, expected at least ${required.value}`) +} + /** * Merge two dependency objects using "newest wins" strategy * For non-comparable versions (URLs, catalog:, workspace:*), upstream (theirs) wins @@ -392,6 +437,8 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt pkg.name = newName } + fixPackageManager(pkg, relativePath, ourPkg, changes) + // 2. Preserve Kilo version if requested if (options.preserveVersion !== false) { const kiloVersion = await getCurrentVersion() @@ -612,6 +659,8 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}): pkg.name = newName } + fixPackageManager(pkg, path, kiloPkg, changes) + // 2. Preserve Kilo version if requested if (options.preserveVersion !== false) { const kiloVersion = await getCurrentVersion() @@ -824,6 +873,8 @@ export async function reconcilePackageJsonFromRefs( pkg.name = newName } + fixPackageManager(pkg, relativePath, ourPkg, changes) + if (options.preserveVersion !== false) { const kiloVersion = await getCurrentVersion() if (pkg.version !== kiloVersion) {