From 27da09614efa15375cd2d32ce2e8cfdbc12e60a2 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 7 May 2026 12:26:10 +0200 Subject: [PATCH 1/4] fix: restore root package.json entries dropped by upstream-compat The automated kilo compat transform keeps upstream's root scripts/catalog wholesale and only re-applies a handful of Kilo-specific scripts, which silently dropped: - postinstall's `&& bun run script/setup-git.ts` tail (needed to set merge.conflictStyle=zdiff3 locally, which upstream merges rely on) - the `dev-setup` script shorthand - kept dead `dev:desktop` / `dev:web` / `dev:console` scripts whose target packages aren't tracked in Kilo - kept `@sentry/solid` / `@sentry/vite-plugin` catalog entries that have zero consumers in our tree --- package.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d5cbbaf4375..e4f650194da 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,11 @@ "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev:desktop": "bun --cwd packages/desktop-electron dev", - "dev:web": "bun --cwd packages/app dev", - "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", + "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", - "postinstall": "bun run --cwd packages/opencode fix-node-pty", + "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", @@ -76,8 +74,6 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", - "@sentry/solid": "10.36.0", - "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" From a156e7a98d01a7b343a2669dac85128c0f463346 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 7 May 2026 13:41:00 +0200 Subject: [PATCH 2/4] chore(upstream): preserve & prune root package.json entries on merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transform that materialises the 'kilo compat for vX.Y.Z' commit during an upstream merge takes upstream's package.json wholesale and re-applies a hand-picked list of Kilo scripts. That list was incomplete: it only covered `extension`, `changeset`, `changeset:version`, `test`, and `test:ci`, so every merge silently dropped Kilo's `postinstall` tail (`&& bun run script/setup-git.ts`) and the `dev-setup` root shortcut, and it never pruned upstream-only scripts (`dev:desktop` / `dev:web` / `dev:console`) or upstream-only catalog entries (`@sentry/solid`, `@sentry/vite-plugin`) whose target packages Kilo doesn't ship. - replace the per-script if-blocks with data-driven PRESERVE_SCRIPTS - add DELETE_UPSTREAM_SCRIPTS for scripts that reference packages Kilo doesn't ship - add DELETE_UPSTREAM_CATALOG for catalog entries with zero Kilo consumers - apply the same policy in both transformPackageJson (conflict path) and transformAllPackageJson (pre-merge sweep path) — previously the pre-merge sweep was missing `changeset` / `changeset:version` preservation - new tests covering preservation, deletion, opencode test scripts, and the catalog pruning --- .../transforms/transform-package-json.test.ts | 86 ++++++++++++ .../transforms/transform-package-json.ts | 131 +++++++++--------- 2 files changed, 155 insertions(+), 62 deletions(-) create mode 100644 script/upstream/transforms/transform-package-json.test.ts diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts new file mode 100644 index 00000000000..11016f0e135 --- /dev/null +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test" +import { fixCatalog, fixScripts } from "./transform-package-json" + +test("fixScripts preserves Kilo-only root scripts from base", () => { + const ours = { + scripts: { + "dev-setup": "kilo dev-setup", + "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", + "extension": "bun --cwd packages/kilo-vscode script/launch.ts", + }, + } + const pkg: Record = { + scripts: { postinstall: "bun run --cwd packages/opencode fix-node-pty" }, + } + const changes: string[] = [] + fixScripts(pkg, "package.json", ours, changes) + const scripts = pkg.scripts as Record + expect(scripts.postinstall).toBe(ours.scripts.postinstall) + expect(scripts["dev-setup"]).toBe(ours.scripts["dev-setup"]) + expect(scripts.extension).toBe(ours.scripts.extension) + expect(changes.some((c) => c.includes("postinstall"))).toBe(true) + expect(changes.some((c) => c.includes("dev-setup"))).toBe(true) +}) + +test("fixScripts removes upstream-only dead scripts from root", () => { + const pkg: Record = { + scripts: { + "dev": "bun run --cwd packages/opencode src/index.ts", + "dev:desktop": "bun --cwd packages/desktop-electron dev", + "dev:web": "bun --cwd packages/app dev", + "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", + }, + } + const changes: string[] = [] + fixScripts(pkg, "package.json", null, changes) + const scripts = pkg.scripts as Record + expect(scripts.dev).toBeDefined() + expect(scripts["dev:desktop"]).toBeUndefined() + expect(scripts["dev:web"]).toBeUndefined() + expect(scripts["dev:console"]).toBeUndefined() + expect(changes.length).toBe(3) +}) + +test("fixScripts preserves opencode test scripts", () => { + const ours = { scripts: { test: "bun test", "test:ci": "bun test --ci" } } + const pkg: Record = { scripts: { test: "vitest" } } + const changes: string[] = [] + fixScripts(pkg, "packages/opencode/package.json", ours, changes) + const scripts = pkg.scripts as Record + expect(scripts.test).toBe("bun test") + expect(scripts["test:ci"]).toBe("bun test --ci") +}) + +test("fixScripts leaves unknown packages untouched", () => { + const pkg: Record = { scripts: { build: "tsc" } } + const changes: string[] = [] + fixScripts(pkg, "packages/some-unknown/package.json", null, changes) + expect((pkg.scripts as Record).build).toBe("tsc") + expect(changes.length).toBe(0) +}) + +test("fixCatalog removes upstream-only desktop sentry entries", () => { + const pkg: Record = { + workspaces: { + catalog: { + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", + "solid-js": "1.9.12", + }, + }, + } + const changes: string[] = [] + fixCatalog(pkg, "package.json", changes) + const cat = (pkg.workspaces as { catalog: Record }).catalog + expect(cat["@sentry/solid"]).toBeUndefined() + expect(cat["@sentry/vite-plugin"]).toBeUndefined() + expect(cat["solid-js"]).toBe("1.9.12") + expect(changes.length).toBe(2) +}) + +test("fixCatalog is a no-op when catalog is absent", () => { + const pkg: Record = {} + const changes: string[] = [] + fixCatalog(pkg, "package.json", changes) + expect(changes.length).toBe(0) +}) diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index bbc812ea958..327ee30facc 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -204,6 +204,69 @@ const TRANSFORM_PACKAGE_NAMES: Record = { "packages/sdk/js/package.json": "@kilocode/sdk", } +// Kilo-specific scripts to preserve from the base branch per package.json. +// Upstream's version wholesale-replaces the scripts block, so anything listed +// here gets re-applied from ours after taking theirs. +const PRESERVE_SCRIPTS: Record = { + "package.json": ["extension", "changeset", "changeset:version", "dev-setup", "postinstall"], + "packages/opencode/package.json": ["test", "test:ci"], +} + +// Upstream-only scripts to delete per package.json. These reference packages +// Kilo doesn't ship (desktop-electron, console/app, app) and would otherwise +// reappear on every merge. +const DELETE_UPSTREAM_SCRIPTS: Record = { + "package.json": ["dev:desktop", "dev:web", "dev:console"], +} + +// Upstream-only catalog entries to delete per package.json. These are pulled +// in by upstream features (e.g. desktop Sentry integration) that Kilo doesn't +// ship, so they add install weight with zero consumers in our tree. +const DELETE_UPSTREAM_CATALOG: Record = { + "package.json": ["@sentry/solid", "@sentry/vite-plugin"], +} + +/** + * Re-apply Kilo-specific scripts on top of the upstream-shaped scripts block, + * and prune upstream-only scripts that target packages Kilo doesn't ship. + */ +export function fixScripts(pkg: Record, path: string, ours: Record | null, changes: string[]): void { + const theirs = (pkg.scripts as Record | undefined) || {} + const oursScripts = (ours?.scripts as Record | undefined) || {} + + for (const name of PRESERVE_SCRIPTS[path] || []) { + const val = oursScripts[name] + if (val && theirs[name] !== val) { + theirs[name] = val + changes.push(`scripts.${name}: preserved from base`) + } + } + + for (const name of DELETE_UPSTREAM_SCRIPTS[path] || []) { + if (theirs[name]) { + delete theirs[name] + changes.push(`scripts.${name}: removed (upstream-only, no Kilo target)`) + } + } + + if (Object.keys(theirs).length > 0) pkg.scripts = theirs +} + +/** + * Prune upstream-only catalog entries that have no consumers in Kilo. + */ +export function fixCatalog(pkg: Record, path: string, changes: string[]): void { + const ws = pkg.workspaces as { catalog?: Record } | undefined + const cat = ws?.catalog + if (!cat) return + for (const name of DELETE_UPSTREAM_CATALOG[path] || []) { + if (cat[name]) { + delete cat[name] + changes.push(`workspaces.catalog.${name}: removed (upstream-only, no Kilo consumer)`) + } + } +} + /** * Check if file is a package.json */ @@ -358,46 +421,7 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt changes.push(`workspaces.packages: preserved Kilo's workspace configuration`) } - const ourScripts = ourPkg.scripts as Record | undefined - if (relativePath === "package.json" && ourScripts?.extension && pkg.scripts?.extension !== ourScripts.extension) { - pkg.scripts = pkg.scripts || {} - pkg.scripts.extension = ourScripts.extension - changes.push(`scripts.extension: preserved Kilo's extension script`) - } - if (relativePath === "package.json" && ourScripts?.changeset && pkg.scripts?.changeset !== ourScripts.changeset) { - pkg.scripts = pkg.scripts || {} - pkg.scripts.changeset = ourScripts.changeset - changes.push(`scripts.changeset: preserved Kilo's changeset script`) - } - if ( - relativePath === "package.json" && - ourScripts?.["changeset:version"] && - pkg.scripts?.["changeset:version"] !== ourScripts["changeset:version"] - ) { - pkg.scripts = pkg.scripts || {} - pkg.scripts["changeset:version"] = ourScripts["changeset:version"] - changes.push(`scripts.changeset:version: preserved Kilo's changeset:version script`) - } - - // Preserve Kilo's test runner scripts for packages/opencode - if ( - relativePath === "packages/opencode/package.json" && - ourScripts?.test && - pkg.scripts?.test !== ourScripts.test - ) { - pkg.scripts = pkg.scripts || {} - pkg.scripts.test = ourScripts.test - changes.push(`scripts.test: preserved Kilo's test runner script`) - } - if ( - relativePath === "packages/opencode/package.json" && - ourScripts?.["test:ci"] && - pkg.scripts?.["test:ci"] !== ourScripts["test:ci"] - ) { - pkg.scripts = pkg.scripts || {} - pkg.scripts["test:ci"] = ourScripts["test:ci"] - changes.push(`scripts.test:ci: preserved Kilo's CI test runner script`) - } + fixScripts(pkg, relativePath, ourPkg, changes) // Merge catalog with "newest wins" strategy if (ourWorkspaces?.catalog || theirWorkspaces?.catalog) { @@ -409,6 +433,8 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt "workspaces.catalog", ) } + + fixCatalog(pkg, relativePath, changes) } // 7. Transform dependency names (opencode -> kilo) @@ -617,28 +643,7 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}): changes.push(`workspaces.packages: preserved Kilo's workspace configuration`) } - const kiloScripts = kiloPkg.scripts as Record | undefined - if (path === "package.json" && kiloScripts?.extension && pkg.scripts?.extension !== kiloScripts.extension) { - pkg.scripts = pkg.scripts || {} - pkg.scripts.extension = kiloScripts.extension - changes.push(`scripts.extension: preserved Kilo's extension script`) - } - - // Preserve Kilo's test runner scripts for packages/opencode - if (path === "packages/opencode/package.json" && kiloScripts?.test && pkg.scripts?.test !== kiloScripts.test) { - pkg.scripts = pkg.scripts || {} - pkg.scripts.test = kiloScripts.test - changes.push(`scripts.test: preserved Kilo's test runner script`) - } - if ( - path === "packages/opencode/package.json" && - kiloScripts?.["test:ci"] && - pkg.scripts?.["test:ci"] !== kiloScripts["test:ci"] - ) { - pkg.scripts = pkg.scripts || {} - pkg.scripts["test:ci"] = kiloScripts["test:ci"] - changes.push(`scripts.test:ci: preserved Kilo's CI test runner script`) - } + fixScripts(pkg, path, kiloPkg, changes) // Merge catalog with "newest wins" strategy if (kiloWorkspaces?.catalog || upstreamWorkspaces?.catalog) { @@ -650,6 +655,8 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}): "workspaces.catalog", ) } + + fixCatalog(pkg, path, changes) } // 7. Transform dependency names (opencode -> kilo) From d79664f0841f93dace85c35239c12a8d64bec35f Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 7 May 2026 16:44:50 +0200 Subject: [PATCH 3/4] fix(upstream-merge): preserve ours' key order in package.json dep merge Seeding the result with theirs' keys and appending ours-only keys at the end caused kilo-only deps (e.g. rotating-file-stream in packages/core) to relocate from the middle of the deps block to the end during the pre-merge transform. Git's textual 3-way merge then saw ours keeping the line in place and theirs adding the same key elsewhere, producing a duplicate JSON key in the merged file. Iterate ours first so kilo-only deps stay in their original position, then append any theirs-only keys at the end. --- .../transforms/transform-package-json.test.ts | 32 ++++++++- .../transforms/transform-package-json.ts | 66 ++++++++++--------- 2 files changed, 67 insertions(+), 31 deletions(-) diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index 11016f0e135..a239bcc5edf 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { fixCatalog, fixScripts } from "./transform-package-json" +import { fixCatalog, fixScripts, mergeWithNewestVersions } from "./transform-package-json" test("fixScripts preserves Kilo-only root scripts from base", () => { const ours = { @@ -84,3 +84,33 @@ test("fixCatalog is a no-op when catalog is absent", () => { fixCatalog(pkg, "package.json", changes) expect(changes.length).toBe(0) }) + +test("mergeWithNewestVersions preserves ours' key order so kilo-only deps don't relocate", () => { + // Regression: when ours has a kilo-only dep in the middle (e.g. rotating-file-stream + // alphabetically between npm-package-arg and semver) and theirs lacks it, the merge + // result must keep that key in its original position. Previously this function + // started from theirs' keys and appended ours-only keys at the end, causing git's + // textual 3-way merge to produce a duplicate JSON key. + const ours = { + "npm-package-arg": "13.0.2", + "rotating-file-stream": "3.2.9", + semver: "^7.6.3", + zod: "catalog:", + } + const theirs = { + "npm-package-arg": "13.0.2", + semver: "^7.6.3", + zod: "catalog:", + } + const changes: string[] = [] + const result = mergeWithNewestVersions(ours, theirs, changes, "dependencies") + expect(Object.keys(result)).toEqual(["npm-package-arg", "rotating-file-stream", "semver", "zod"]) +}) + +test("mergeWithNewestVersions appends theirs-only keys at the end", () => { + const ours = { a: "1.0.0", b: "1.0.0" } + const theirs = { a: "1.0.0", c: "1.0.0" } + const changes: string[] = [] + const result = mergeWithNewestVersions(ours, theirs, changes, "dependencies") + expect(Object.keys(result)).toEqual(["a", "b", "c"]) +}) diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 327ee30facc..45f396a18ff 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -108,8 +108,14 @@ function compareVersions(a: string, b: string): number | null { /** * Merge two dependency objects using "newest wins" strategy * For non-comparable versions (URLs, catalog:, workspace:*), upstream (theirs) wins + * + * Key order preserves ours' order first (so kilo-only deps stay in their + * original position), then appends theirs-only keys at the end. This avoids + * relocating existing keys, which would otherwise let git's textual merge + * produce duplicate JSON keys (ours keeps the line in place, theirs appears + * to "add" the same key elsewhere → both survive the merge). */ -function mergeWithNewestVersions( +export function mergeWithNewestVersions( ours: Record | undefined, theirs: Record | undefined, changes: string[], @@ -117,38 +123,38 @@ function mergeWithNewestVersions( ): Record { const result: Record = {} - // Start with all of theirs - if (theirs) { - for (const [name, version] of Object.entries(theirs)) { - result[name] = version + // Seed with ours' keys in ours' order, applying newest-wins per key. + if (ours) { + for (const [name, ourVersion] of Object.entries(ours)) { + const theirVersion = theirs?.[name] + if (theirVersion === undefined) { + result[name] = ourVersion + changes.push(`${section}: preserved ${name}@${ourVersion} (kilo-only)`) + continue + } + if (ourVersion === theirVersion) { + result[name] = theirVersion + continue + } + const cmp = compareVersions(ourVersion, theirVersion) + if (cmp === null) { + result[name] = theirVersion + changes.push(`${section}: ${name} kept upstream ${theirVersion} (special format)`) + } else if (cmp > 0) { + result[name] = ourVersion + changes.push(`${section}: ${name} ${theirVersion} -> ${ourVersion} (kilo newer)`) + } else { + result[name] = theirVersion + if (cmp < 0) changes.push(`${section}: ${name} kept upstream ${theirVersion} (upstream newer)`) + } } } - // Merge in ours, keeping newer versions - if (ours) { - for (const [name, ourVersion] of Object.entries(ours)) { - const theirVersion = result[name] - - if (!theirVersion) { - // Dependency only exists in ours - keep it - result[name] = ourVersion - changes.push(`${section}: preserved ${name}@${ourVersion} (kilo-only)`) - } else if (ourVersion !== theirVersion) { - // Both have it with different versions - compare - const comparison = compareVersions(ourVersion, theirVersion) - - if (comparison === null) { - // Can't compare (special format) - upstream wins per user preference - changes.push(`${section}: ${name} kept upstream ${theirVersion} (special format)`) - } else if (comparison > 0) { - // Ours is newer - result[name] = ourVersion - changes.push(`${section}: ${name} ${theirVersion} -> ${ourVersion} (kilo newer)`) - } else if (comparison < 0) { - // Theirs is newer - already in result - changes.push(`${section}: ${name} kept upstream ${theirVersion} (upstream newer)`) - } - // If equal, keep theirs (already in result) + // Append any theirs-only keys at the end, preserving theirs' relative order. + if (theirs) { + for (const [name, version] of Object.entries(theirs)) { + if (result[name] === undefined) { + result[name] = version } } } From ee3ee5b06b9b8c06d2ef2a5ce0799c92fc5bdb91 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 7 May 2026 17:41:58 +0200 Subject: [PATCH 4/4] fix(upstream-merge): always reconcile package.json post-merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git rerere learns conflict resolutions from past upstream merges and replays them on the next merge. When past resolutions used the buggy mergeWithNewestVersions ordering, rerere auto-resolves package.json files with stale content before transformConflictedPackageJson ever gets a chance to run — so the fixed merge logic never reaches the file. Add reconcileAllPackageJson, which runs after every merge (clean, auto-resolved, or partially conflicted) and rewrites every package.json that the merge touched from the kilo branch's pre-merge HEAD and the opencode compat branch using the same merge logic. Files that are still conflicted are skipped so manual resolution isn't silently overwritten. This makes our merge logic the source of truth for package.json content, regardless of what rerere or git's textual merge produced. --- script/upstream/merge.ts | 35 ++- .../transforms/transform-package-json.ts | 240 ++++++++++++++++++ 2 files changed, 274 insertions(+), 1 deletion(-) diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 02933816c1f..332e7795450 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -33,7 +33,11 @@ import { skipFiles } from "./transforms/skip-files" import { transformConflictedI18n, transformAllI18n } from "./transforms/transform-i18n" // New transforms for auto-resolving more conflict types import { transformConflictedTakeTheirs, transformAllTakeTheirs } from "./transforms/transform-take-theirs" -import { transformConflictedPackageJson, transformAllPackageJson } from "./transforms/transform-package-json" +import { + transformConflictedPackageJson, + transformAllPackageJson, + reconcileAllPackageJson, +} from "./transforms/transform-package-json" import { transformConflictedScripts, transformAllScripts } from "./transforms/transform-scripts" import { transformConflictedExtensions, transformAllExtensions } from "./transforms/transform-extensions" import { transformConflictedWeb, transformAllWeb } from "./transforms/transform-web" @@ -707,6 +711,24 @@ async function main() { } } + // Reconcile every package.json that the merge touched, regardless of + // whether it was conflicted, auto-resolved by rerere, or merged textually. + // rerere can replay stale resolutions that bypass our package.json + // transform entirely, so always run our merge logic as the final word for + // package.json content. Skip files that are still conflicted so the user + // can resolve them manually instead of silently overwriting markers. + const stillConflicted = new Set(await git.getConflictedFiles()) + const reconcileResults = await reconcileAllPackageJson({ + oursRef: baseSha, + theirsRef: opencodeBranch, + verbose: options.verbose, + skip: stillConflicted, + }) + const reconcileCount = reconcileResults.filter((r) => r.action === "transformed" && r.changes.length > 0).length + if (reconcileCount > 0) { + logger.success(`Reconciled ${reconcileCount} package.json file(s) post-merge`) + } + // Check remaining conflicts const remaining = await git.getConflictedFiles() // Combine git-reported conflicts with files flagged due to kilocode_change markers @@ -764,6 +786,17 @@ async function main() { } } else { logger.success("Merge completed without conflicts!") + // Same reconcile pass as the conflict path: ensure rerere or git's textual + // merge can't slip stale package.json resolutions through. + const reconcileResults = await reconcileAllPackageJson({ + oursRef: baseSha, + theirsRef: opencodeBranch, + verbose: options.verbose, + }) + const reconcileCount = reconcileResults.filter((r) => r.action === "transformed" && r.changes.length > 0).length + if (reconcileCount > 0) { + logger.success(`Reconciled ${reconcileCount} package.json file(s) post-merge`) + } await git.stageAll() const hasChanges = await git.hasUncommittedChanges() if (hasChanges) { diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 45f396a18ff..501786e1453 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -175,6 +175,17 @@ export interface PackageJsonOptions { preserveVersion?: boolean } +export interface ReconcileOptions extends PackageJsonOptions { + oursRef: string + theirsRef: string + /** + * Files to skip (e.g. still-conflicted files where the user is going to + * resolve manually). The reconciler would otherwise overwrite the conflict + * markers and silently auto-resolve. + */ + skip?: Set +} + // Package name mappings const PACKAGE_NAME_MAP: Record = { "opencode-ai": "@kilocode/cli", @@ -729,6 +740,235 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}): return results } +/** + * Reconcile a single package.json after a merge has finished, regardless of + * whether it was conflicted or auto-resolved (by rerere or git's textual + * merge). Reads ours from `oursRef` and theirs from `theirsRef`, then applies + * the same merge logic used for conflict resolution and writes the result to + * the working tree. Stages the file. + * + * This is needed because rerere can replay stale resolutions for files like + * `package.json` that include cosmetic reordering — those resolutions bypass + * `transformConflictedPackageJson` entirely. Running this reconciler after + * the merge guarantees our merge logic always wins. + * + * Returns "skipped" if neither side touched the file (or both sides match) so + * callers can avoid unnecessary churn. Returns "flagged" if ours has + * kilocode_change markers (manual review needed). + */ +export async function reconcilePackageJsonFromRefs( + file: string, + options: ReconcileOptions, +): Promise { + const changes: string[] = [] + const dryRun = options.dryRun ?? false + + if (await oursHasKilocodeChanges(file)) { + warn(`${file} has kilocode_change markers — skipping reconcile, needs manual resolution`) + return { file, action: "flagged", changes: [], dryRun } + } + + let ourPkg: Record | null = null + try { + const ourContent = await $`git show ${options.oursRef}:${file}`.text() + ourPkg = JSON.parse(ourContent) + } catch { + // file didn't exist in ours - that's fine + } + + let pkg: Record | null = null + try { + const theirContent = await $`git show ${options.theirsRef}:${file}`.text() + pkg = JSON.parse(theirContent) + } catch { + // file didn't exist in theirs either - nothing to reconcile + } + + if (!pkg) { + if (!ourPkg) return { file, action: "skipped", changes: [], dryRun } + pkg = JSON.parse(JSON.stringify(ourPkg)) + } + + const relativePath = file.replace(process.cwd() + "/", "") + const newName = TRANSFORM_PACKAGE_NAMES[relativePath] + if (newName && pkg.name !== newName) { + changes.push(`name: ${pkg.name} -> ${newName}`) + pkg.name = newName + } + + if (options.preserveVersion !== false) { + const kiloVersion = await getCurrentVersion() + if (pkg.version !== kiloVersion) { + changes.push(`version: ${pkg.version} -> ${kiloVersion}`) + pkg.version = kiloVersion + } + } + + if (ourPkg) { + pkg.dependencies = mergeWithNewestVersions( + ourPkg.dependencies as Record | undefined, + pkg.dependencies as Record | undefined, + changes, + "dependencies", + ) + pkg.devDependencies = mergeWithNewestVersions( + ourPkg.devDependencies as Record | undefined, + pkg.devDependencies as Record | undefined, + changes, + "devDependencies", + ) + pkg.peerDependencies = mergeWithNewestVersions( + ourPkg.peerDependencies as Record | undefined, + pkg.peerDependencies as Record | undefined, + changes, + "peerDependencies", + ) + + const ourOverrides = ourPkg.overrides as Record | undefined + if (ourOverrides || pkg.overrides) { + pkg.overrides = mergeWithNewestVersions( + ourOverrides, + pkg.overrides as Record | undefined, + changes, + "overrides", + ) + } + + const ourPatched = ourPkg.patchedDependencies as Record | undefined + if (ourPatched) { + pkg.patchedDependencies = (pkg.patchedDependencies as Record) || {} + const patched = pkg.patchedDependencies as Record + for (const [name, patch] of Object.entries(ourPatched)) { + if (!patched[name]) { + patched[name] = patch + changes.push(`patchedDependencies: preserved ${name}`) + } + } + } + + const ourRepo = ourPkg.repository + if (ourRepo && JSON.stringify(pkg.repository) !== JSON.stringify(ourRepo)) { + pkg.repository = ourRepo + changes.push(`repository: preserved Kilo's repository configuration`) + } + + const ourWs = ourPkg.workspaces as { packages?: string[]; catalog?: Record } | undefined + const theirWs = pkg.workspaces as { packages?: string[]; catalog?: Record } | undefined + + if (relativePath === "package.json" && ourWs?.packages) { + pkg.workspaces = (pkg.workspaces as Record) || {} + ;(pkg.workspaces as { packages: string[] }).packages = ourWs.packages + changes.push(`workspaces.packages: preserved Kilo's workspace configuration`) + } + + fixScripts(pkg, relativePath, ourPkg, changes) + + if (ourWs?.catalog || theirWs?.catalog) { + pkg.workspaces = (pkg.workspaces as Record) || {} + ;(pkg.workspaces as { catalog: Record }).catalog = mergeWithNewestVersions( + ourWs?.catalog, + theirWs?.catalog, + changes, + "workspaces.catalog", + ) + } + + fixCatalog(pkg, relativePath, changes) + } + + if (pkg.dependencies) { + const { result, changes: depChanges } = transformDependencies(pkg.dependencies as Record) + pkg.dependencies = result + changes.push(...depChanges.map((c) => `dependencies: ${c}`)) + } + if (pkg.devDependencies) { + const { result, changes: devChanges } = transformDependencies(pkg.devDependencies as Record) + if (devChanges.length > 0) { + pkg.devDependencies = result + changes.push(...devChanges.map((c) => `devDependencies: ${c}`)) + } + } + if (pkg.peerDependencies) { + const { result, changes: peerChanges } = transformDependencies(pkg.peerDependencies as Record) + if (peerChanges.length > 0) { + pkg.peerDependencies = result + changes.push(...peerChanges.map((c) => `peerDependencies: ${c}`)) + } + } + + const kiloDeps = KILO_DEPENDENCIES[relativePath] + if (kiloDeps) { + pkg.dependencies = (pkg.dependencies as Record) || {} + const deps = pkg.dependencies as Record + for (const [name, version] of Object.entries(kiloDeps)) { + if (!deps[name]) { + deps[name] = version + changes.push(`injected: ${name}`) + } + } + } + + const kiloBin = KILO_BIN[relativePath] + if (kiloBin) { + pkg.bin = kiloBin + changes.push(`bin: set Kilo bin entries`) + } + + if (dryRun) { + info(`[DRY-RUN] Would reconcile ${file}: ${changes.length} changes`) + return { file, action: "transformed", changes, dryRun: true } + } + + const newContent = JSON.stringify(pkg, null, 2) + "\n" + await Bun.write(file, newContent) + await $`git add ${file}`.quiet().nothrow() + + if (changes.length > 0) { + success(`Reconciled ${file}: ${changes.length} changes`) + if (options.verbose) { + for (const change of changes) debug(` - ${change}`) + } + } + + return { file, action: "transformed", changes, dryRun: false } +} + +/** + * Reconcile every package.json that differs between `oursRef` and `theirsRef` + * after a merge. This is meant to run after `git merge` (whether the merge + * was clean, conflict-resolved, or rerere-replayed) to ensure our merge logic + * is the source of truth for package.json content. + */ +export async function reconcileAllPackageJson(options: ReconcileOptions): Promise { + // Collect every package.json that differs in either direction so we cover + // upstream-only and kilo-only files alike. + const diffOurs = await $`git diff --name-only ${options.oursRef} -- '*package.json'`.text() + const diffTheirs = await $`git diff --name-only ${options.theirsRef} -- '*package.json'`.text() + const candidates = new Set() + for (const line of [...diffOurs.split("\n"), ...diffTheirs.split("\n")]) { + const path = line.trim() + if (!path) continue + if (path.includes("node_modules")) continue + if (!path.endsWith("package.json")) continue + candidates.add(path) + } + + const results: PackageJsonResult[] = [] + for (const file of candidates) { + if (options.skip?.has(file)) { + results.push({ file, action: "skipped", changes: [], dryRun: options.dryRun ?? false }) + continue + } + const f = Bun.file(file) + if (!(await f.exists())) { + // file was removed by the merge - nothing to reconcile + continue + } + results.push(await reconcilePackageJsonFromRefs(file, options)) + } + return results +} + // CLI entry point if (import.meta.main) { const args = process.argv.slice(2)