diff --git a/.kilo/skills/kilocode-merge-minimizer/SKILL.md b/.kilo/skills/kilocode-merge-minimizer/SKILL.md index 9c4798de73..1250e6b152 100644 --- a/.kilo/skills/kilocode-merge-minimizer/SKILL.md +++ b/.kilo/skills/kilocode-merge-minimizer/SKILL.md @@ -97,10 +97,10 @@ registerKiloFeature(app) After editing shared files or marker comments, run: ```bash -bun run script/check-opencode-annotations.ts +bun run script/check-opencode-annotations.ts --worktree ``` -If the PR uses a non-default comparison base, pass the correct base ref: +If checking committed PR changes against a non-default comparison base, pass the correct base ref without `--worktree`: ```bash bun run script/check-opencode-annotations.ts --base diff --git a/AGENTS.md b/AGENTS.md index 5382092094..54a75059e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang - **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing. - **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale. - **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing. -- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name. +- **opencode annotation check**: `bun run script/check-opencode-annotations.ts --worktree` from repo root when verifying local agent changes. CI runs `bun run script/check-opencode-annotations.ts` on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name. - **Effect facade ratchet**: Do not add runtime-backed Promise facades to shared `packages/opencode/src` Effect services; use service dependencies, `AppRuntime`, or Kilo-owned boundaries. Run `bun run script/check-opencode-promise-facades.ts` when touching service adapters. - **workflow allowlist**: `bun run script/check-workflows.ts` from repo root. CI runs this as part of the annotations workflow — any `.yml` / `.yaml` file added to or removed from `.github/workflows/` must be reflected in the hardcoded list in `script/check-workflows.ts`. Prevents upstream-merged workflows from silently starting to run in our CI. - **Backend/SDK programmatic testing**: see [TESTING.md](./TESTING.md) for spawning the local main-branch backend (`bun dev serve`) and driving it via `curl` — use this instead of `kilo serve` (prod binary) when testing backend fixes. @@ -36,7 +36,7 @@ Before saying an implementation is ready, run the smallest relevant checks that | VS Code extension | From `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit` or `bun run test` | | Extension build/package | From `packages/kilo-vscode/`: `bun run compile` or `bun run package` when touching build, packaging, SDK, or webview integration paths | | JetBrains plugin | From `packages/kilo-jetbrains/`: `./gradlew typecheck`, `./gradlew test`. Requires Java 21; do not run `java -version` as a routine preflight. Check Java only after a Java-version or missing-Java failure. | -| CI-only guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts`, or source link extraction | +| CI/local guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts --worktree`, or source link extraction | Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. Use package-level tests instead. diff --git a/packages/script/tests/check-opencode-annotations.test.ts b/packages/script/tests/check-opencode-annotations.test.ts index 2afb508e67..fc86218c12 100644 --- a/packages/script/tests/check-opencode-annotations.test.ts +++ b/packages/script/tests/check-opencode-annotations.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test" +import { spawnSync } from "node:child_process" +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import os from "node:os" import path from "node:path" const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".yml", ".yaml", ".toml", ".sh", ".bash", ".zsh"]) @@ -83,6 +86,137 @@ function coveredLines(text: string): Set { return covered } +const SCRIPT = path.resolve(import.meta.dir, "../../../script/check-opencode-annotations.ts") + +function exec(root: string, args: string[]) { + const out = spawnSync("git", args, { cwd: root, encoding: "utf8" }) + if (out.status === 0) return + throw new Error(out.stderr || out.stdout || `git ${args.join(" ")} failed`) +} + +function repo() { + const root = mkdtempSync(path.join(os.tmpdir(), "kilo-annotations-")) + mkdirSync(path.join(root, "script"), { recursive: true }) + mkdirSync(path.join(root, "packages/opencode/src"), { recursive: true }) + copyFileSync(SCRIPT, path.join(root, "script/check-opencode-annotations.ts")) + writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 1\n") + exec(root, ["init"]) + exec(root, ["checkout", "-B", "main"]) + exec(root, ["add", "."]) + exec(root, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "-m", "init"]) + exec(root, ["update-ref", "refs/remotes/origin/main", "HEAD"]) + return root +} + +function check(root: string, args: string[] = []) { + return spawnSync(process.execPath, ["run", "script/check-opencode-annotations.ts", ...args], { + cwd: root, + encoding: "utf8", + }) +} + +// ─── CLI worktree mode ─────────────────────────────────────────────────────── + +describe("CLI worktree mode", () => { + test("default mode ignores local edits, worktree mode reports them", () => { + const root = repo() + try { + writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 2\n") + + const head = check(root) + expect(head.status).toBe(0) + expect(head.stdout).toContain("No shared upstream source files changed") + + const local = check(root, ["--worktree"]) + expect(local.status).toBe(1) + expect(local.stderr).toContain("packages/opencode/src/shared.ts:1") + expect(local.stderr).toContain("export const value = 2") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test("worktree mode reports untracked shared source files", () => { + const root = repo() + try { + writeFileSync(path.join(root, "packages/opencode/src/new.ts"), "export const value = 1\n") + + const local = check(root, ["--worktree"]) + expect(local.status).toBe(1) + expect(local.stderr).toContain("packages/opencode/src/new.ts:1") + expect(local.stderr).toContain("export const value = 1") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test("worktree mode reports staged shared source edits", () => { + const root = repo() + try { + writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 4\n") + exec(root, ["add", "packages/opencode/src/shared.ts"]) + + const local = check(root, ["--worktree"]) + expect(local.status).toBe(1) + expect(local.stderr).toContain("packages/opencode/src/shared.ts:1") + expect(local.stderr).toContain("export const value = 4") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test("worktree mode checks local edits on upstream merge branches", () => { + const root = repo() + try { + exec(root, ["checkout", "-B", "upstream"]) + writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 2\n") + exec(root, ["add", "."]) + exec(root, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "-m", "upstream"]) + exec(root, ["checkout", "main"]) + exec(root, ["merge", "--no-ff", "-m", "Merge: upstream opencode", "upstream"]) + + const head = check(root) + expect(head.status).toBe(0) + expect(head.stdout).toContain("Skipping shared upstream annotation check") + + const upstream = check(root, ["--worktree"]) + expect(upstream.status).toBe(0) + expect(upstream.stdout).toContain("No shared upstream source files changed") + + writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 3\n") + + const local = check(root, ["--worktree"]) + expect(local.status).toBe(1) + expect(local.stderr).toContain("packages/opencode/src/shared.ts:1") + expect(local.stderr).toContain("export const value = 3") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test("worktree mode rejects base refs", () => { + const root = repo() + try { + const local = check(root, ["--worktree", "--base", "origin/main"]) + expect(local.status).toBe(1) + expect(local.stderr).toContain("--base cannot be used with --worktree") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test("unknown arguments fail instead of falling back to default mode", () => { + const root = repo() + try { + const local = check(root, ["--worktre"]) + expect(local.status).toBe(1) + expect(local.stderr).toContain("Unknown argument: --worktre") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + // ─── hasMarker tests ────────────────────────────────────────────────────────── describe("hasMarker", () => { diff --git a/script/check-opencode-annotations.ts b/script/check-opencode-annotations.ts index 3d68a05060..c49fe4884c 100644 --- a/script/check-opencode-annotations.ts +++ b/script/check-opencode-annotations.ts @@ -5,8 +5,9 @@ * is annotated with a kilocode_change marker. * * Usage: - * bun run script/check-opencode-annotations.ts # diff against origin/main - * bun run script/check-opencode-annotations.ts --base # diff against + * bun run script/check-opencode-annotations.ts # diff origin/main...HEAD + * bun run script/check-opencode-annotations.ts --base # diff ...HEAD + * bun run script/check-opencode-annotations.ts --worktree # diff HEAD..worktree plus untracked files * * A line is "covered" if it: * - contains a kilocode_change marker comment (inline annotation) @@ -54,8 +55,24 @@ const EXEMPT_SCOPES = [ ] const args = process.argv.slice(2) +const unknown = args.find((arg, i) => arg !== "--base" && arg !== "--worktree" && args[i - 1] !== "--base") +if (unknown) { + console.error(`Unknown argument: ${unknown}`) + process.exit(1) +} const baseIdx = args.indexOf("--base") -const base = baseIdx !== -1 ? args[baseIdx + 1] : "origin/main" +const worktree = args.includes("--worktree") +if (worktree && baseIdx !== -1) { + console.error("--base cannot be used with --worktree") + process.exit(1) +} +const base = (() => { + if (baseIdx === -1) return "origin/main" + const ref = args[baseIdx + 1] + if (ref && !ref.startsWith("--")) return ref + console.error("Missing value for --base") + process.exit(1) +})() function run(cmd: string, args: string[]) { const result = spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8" }) @@ -67,11 +84,23 @@ function run(cmd: string, args: string[]) { return result.stdout?.trim() ?? "" } -function changedFiles() { - const out = run("git", ["diff", "--name-only", "--diff-filter=AMRT", `${base}...HEAD`, "--", ...SCOPES]) +const ref = worktree ? "HEAD" : `${base}...HEAD` + +function lines(out: string) { return out ? out.split("\n").filter(Boolean) : [] } +function untracked(file?: string) { + if (!worktree) return [] + const pathspec = file ? [file] : SCOPES + return lines(run("git", ["ls-files", "--others", "--exclude-standard", "--", ...pathspec])) +} + +function changedFiles() { + const out = run("git", ["diff", "--name-only", "--diff-filter=AMRT", ref, "--", ...SCOPES]) + return [...new Set([...lines(out), ...untracked()])] +} + function isUpstreamMerge() { const out = run("git", ["log", "--format=%P%x09%s", `${base}..HEAD`]) return out.split("\n").some((line) => { @@ -102,8 +131,8 @@ function isSource(file: string) { return content(file).startsWith("#!") // kilocode_change } -// Parses the unified=0 diff for `file` against `base` and returns: -// - added: every added line number on HEAD +// Parses the unified=0 diff for `file` against the selected target and returns: +// - added: every added line number on the checked version // - revert: true when the file's diff removes any kilocode_change marker. // In that case the changes are reverting Kilo modifications back to the // upstream baseline, so newly added lines (which are restoring upstream @@ -112,8 +141,15 @@ function isSource(file: string) { // hunks than the marker itself, so we use file-level detection rather // than hunk-level to avoid false positives on legitimate reverts. function addedLines(file: string): { added: Set; revert: boolean } { - const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", `${base}...HEAD`, "--", file]) const added = new Set() + if (untracked(file).includes(file)) { + const text = content(file) + const count = text.split(/\r?\n/).length + for (const n of Array.from({ length: count }, (_, i) => i + 1)) added.add(n) + return { added, revert: false } + } + + const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", ref, "--", file]) let revert = false const all = diff.split("\n") @@ -208,7 +244,7 @@ function coveredLines(text: string): { lines: string[]; covered: Set } { // --- main --- -if (isUpstreamMerge()) { +if (!worktree && isUpstreamMerge()) { console.log("Skipping shared upstream annotation check — upstream merge detected.") process.exit(0) }