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" diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 24587c9ecec..bedca6222bc 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" @@ -728,6 +732,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 @@ -785,6 +807,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.test.ts b/script/upstream/transforms/transform-package-json.test.ts new file mode 100644 index 00000000000..a239bcc5edf --- /dev/null +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "bun:test" +import { fixCatalog, fixScripts, mergeWithNewestVersions } 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) +}) + +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 bbc812ea958..501786e1453 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 } } } @@ -169,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", @@ -204,6 +221,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 +438,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 +450,8 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt "workspaces.catalog", ) } + + fixCatalog(pkg, relativePath, changes) } // 7. Transform dependency names (opencode -> kilo) @@ -617,28 +660,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 +672,8 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}): "workspaces.catalog", ) } + + fixCatalog(pkg, path, changes) } // 7. Transform dependency names (opencode -> kilo) @@ -716,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)