mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix: prevent Bun downgrades during upstream merges
This commit is contained in:
@@ -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@<semver>` 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.
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> {
|
||||
.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<void> {
|
||||
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<string> {
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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()
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
path: string,
|
||||
ours: Record<string, unknown> | 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<typeof item> => 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) {
|
||||
|
||||
Reference in New Issue
Block a user