From 234429737a4aea57df4304acf0bd3375bf0efbd3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 13:01:15 +0900 Subject: [PATCH] feat(omo-claude): add sync-components vendoring script with --check drift gate Copies 6 omo-codex components (rules, comment-checker, lsp, ultrawork, ultragoal, start-work-continuation) into the CC plugin tree and applies a declarative patch manifest: relax model/turn_id validators, CLAUDE_PLUGIN_ROOT/ DATA env fallbacks, .codex-plugin->.claude-plugin, rules matcher widened to Write|Edit|MultiEdit, start-work-continuation claude: session prefix. ultragoal copied verbatim (deep refactor is a later task). --check detects vendor drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/omo-claude/plugin/scripts/.gitkeep | 0 .../plugin/scripts/sync-components.mjs | 563 ++++++++++++++++++ .../plugin/scripts/sync-components.test.mjs | 87 +++ 3 files changed, 650 insertions(+) delete mode 100644 packages/omo-claude/plugin/scripts/.gitkeep create mode 100644 packages/omo-claude/plugin/scripts/sync-components.mjs create mode 100644 packages/omo-claude/plugin/scripts/sync-components.test.mjs diff --git a/packages/omo-claude/plugin/scripts/.gitkeep b/packages/omo-claude/plugin/scripts/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/omo-claude/plugin/scripts/sync-components.mjs b/packages/omo-claude/plugin/scripts/sync-components.mjs new file mode 100644 index 000000000..c51462b58 --- /dev/null +++ b/packages/omo-claude/plugin/scripts/sync-components.mjs @@ -0,0 +1,563 @@ +#!/usr/bin/env node +// Vendors omo-codex plugin components into the omo-claude (Claude Code) plugin. +// +// For each handled component this script copies the component's runtime-source +// surface (src/, package.json, tsconfig.build.json, hooks/hooks.json, skills/) +// from packages/omo-codex/plugin/components// into +// packages/omo-claude/plugin/components//, then applies a DECLARATIVE PATCH +// MANIFEST of string replacements to turn the Codex component into the vendored +// Claude Code component. +// +// Modes: +// node sync-components.mjs [componentName] sync one component (or all) +// node sync-components.mjs --check re-derive the patched output and +// compare against the dest on disk; +// exit nonzero with a diff summary if +// they differ (detects hand-drift). +// +// Path resolution mirrors sync-skills.mjs / sync-telemetry-component.mjs: all +// roots are derived from import.meta.url so the script is location-stable. +// +// The telemetry component is intentionally EXCLUDED here (it is owned by a +// different track and synced by scripts/sync-telemetry-component.mjs). + +import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const CLAUDE_PLUGIN_ROOT = dirname(SCRIPT_DIR); +const CLAUDE_PACKAGE_ROOT = dirname(CLAUDE_PLUGIN_ROOT); +const PACKAGES_ROOT = dirname(CLAUDE_PACKAGE_ROOT); + +export const SOURCE_COMPONENTS_ROOT = join(PACKAGES_ROOT, "omo-codex", "plugin", "components"); +export const DEST_COMPONENTS_ROOT = join(CLAUDE_PLUGIN_ROOT, "components"); + +// Components handled by this script (telemetry is EXCLUDED — different track). +export const HANDLED_COMPONENTS = [ + "rules", + "comment-checker", + "lsp", + "ultrawork", + "ultragoal", + "start-work-continuation", +]; + +// Per-component copy surface. Directories are copied recursively; files are +// copied verbatim. Entries that do not exist in the source are skipped. +const COPY_DIRS = ["src", "skills"]; +const COPY_FILES = ["package.json", "tsconfig.build.json", join("hooks", "hooks.json")]; + +// Shared hooks.json env-var transform applied to EVERY handled component that +// ships a hooks/hooks.json. Only replaces tokens that are actually present. +const HOOKS_ENV_REPLACEMENTS = [ + { find: "${PLUGIN_ROOT}", replace: "${CLAUDE_PLUGIN_ROOT}" }, + { find: "${PLUGIN_DATA}", replace: "${CLAUDE_PLUGIN_DATA}" }, +]; + +// --------------------------------------------------------------------------- +// DECLARATIVE PATCH MANIFEST +// +// Each entry: { component, file, replacements: [{ find, replace, required? }] } +// - `file` is relative to the component dest root. +// - `find` is an exact substring (replaceAll); a `required:true` replacement +// whose `find` does not occur in the source throws. +// - hooks/hooks.json env-var transforms are injected automatically for all +// handled components (see buildHooksJsonReplacements) and need NOT be listed +// here. +// --------------------------------------------------------------------------- +export const PATCH_MANIFEST = [ + // ----- rules ----------------------------------------------------------- + { + component: "rules", + file: "src/cli.ts", + replacements: [ + // PLUGIN_DATA env fallback (cli.ts reads it for the cache root). + { + find: 'const pluginDataRoot = process.env["PLUGIN_DATA"];', + replace: + 'const pluginDataRoot = process.env["CLAUDE_PLUGIN_DATA"] ?? process.env["PLUGIN_DATA"];', + required: true, + }, + // UserPromptSubmit: drop turn_id requirement, make model optional. + { + find: + '\t\tvalue["hook_event_name"] === "UserPromptSubmit" &&\n' + + '\t\ttypeof value["session_id"] === "string" &&\n' + + '\t\ttypeof value["turn_id"] === "string" &&\n' + + '\t\tisStringOrNull(value["transcript_path"]) &&\n' + + '\t\ttypeof value["cwd"] === "string" &&\n' + + '\t\ttypeof value["model"] === "string" &&\n' + + '\t\ttypeof value["permission_mode"] === "string" &&\n' + + '\t\ttypeof value["prompt"] === "string"', + replace: + '\t\tvalue["hook_event_name"] === "UserPromptSubmit" &&\n' + + '\t\ttypeof value["session_id"] === "string" &&\n' + + '\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&\n' + + '\t\tisStringOrNull(value["transcript_path"]) &&\n' + + '\t\ttypeof value["cwd"] === "string" &&\n' + + '\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&\n' + + '\t\ttypeof value["permission_mode"] === "string" &&\n' + + '\t\ttypeof value["prompt"] === "string"', + required: true, + }, + // PostToolUse: drop turn_id requirement, make model optional. + { + find: + '\t\tvalue["hook_event_name"] === "PostToolUse" &&\n' + + '\t\ttypeof value["session_id"] === "string" &&\n' + + '\t\ttypeof value["turn_id"] === "string" &&\n' + + '\t\tisStringOrNull(value["transcript_path"]) &&\n' + + '\t\ttypeof value["cwd"] === "string" &&\n' + + '\t\ttypeof value["model"] === "string" &&\n' + + '\t\ttypeof value["permission_mode"] === "string" &&\n' + + '\t\ttypeof value["tool_name"] === "string" &&\n' + + '\t\ttypeof value["tool_use_id"] === "string"', + replace: + '\t\tvalue["hook_event_name"] === "PostToolUse" &&\n' + + '\t\ttypeof value["session_id"] === "string" &&\n' + + '\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&\n' + + '\t\tisStringOrNull(value["transcript_path"]) &&\n' + + '\t\ttypeof value["cwd"] === "string" &&\n' + + '\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&\n' + + '\t\ttypeof value["permission_mode"] === "string" &&\n' + + '\t\ttypeof value["tool_name"] === "string" &&\n' + + '\t\ttypeof value["tool_use_id"] === "string"', + required: true, + }, + // PostCompact: drop turn_id requirement, make model optional. + { + find: + '\t\tvalue["hook_event_name"] === "PostCompact" &&\n' + + '\t\ttypeof value["session_id"] === "string" &&\n' + + '\t\ttypeof value["turn_id"] === "string" &&\n' + + '\t\tisStringOrNull(value["transcript_path"]) &&\n' + + '\t\ttypeof value["cwd"] === "string" &&\n' + + '\t\ttypeof value["model"] === "string" &&\n' + + '\t\t(value["trigger"] === "manual" || value["trigger"] === "auto")', + replace: + '\t\tvalue["hook_event_name"] === "PostCompact" &&\n' + + '\t\ttypeof value["session_id"] === "string" &&\n' + + '\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&\n' + + '\t\tisStringOrNull(value["transcript_path"]) &&\n' + + '\t\ttypeof value["cwd"] === "string" &&\n' + + '\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&\n' + + '\t\t(value["trigger"] === "manual" || value["trigger"] === "auto")', + required: true, + }, + ], + }, + { + component: "rules", + file: "src/rules/plugin-root.ts", + replacements: [ + { + find: 'const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json");', + replace: 'const PLUGIN_MANIFEST_PATH = join(".claude-plugin", "plugin.json");', + required: true, + }, + { + find: 'const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"];', + replace: + 'const configuredRoot = pluginRoot ?? process.env["CLAUDE_PLUGIN_ROOT"] ?? process.env["PLUGIN_ROOT"];', + required: true, + }, + ], + }, + { + component: "rules", + file: "src/persistent-cache.ts", + replacements: [ + { + find: + 'const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules");', + replace: + 'const root = pluginDataRoot ?? process.env["CLAUDE_PLUGIN_DATA"] ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".claude", "omo-claude-rules");', + required: true, + }, + ], + }, + { + component: "rules", + file: "src/config.ts", + // Add an OMO_CLAUDE_RULES_* alias as the highest-priority lookup in each + // firstEnv() call, keeping the existing CODEX_RULES_* / PI_RULES_* aliases. + replacements: [ + { + find: 'firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED")', + replace: + 'firstEnv(env, "OMO_CLAUDE_RULES_DISABLE_BUNDLED", "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED")', + required: true, + }, + { + find: 'firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED")', + replace: + 'firstEnv(env, "OMO_CLAUDE_RULES_DISABLED", "CODEX_RULES_DISABLED", "PI_RULES_DISABLED")', + required: true, + }, + { + find: 'firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")', + replace: 'firstEnv(env, "OMO_CLAUDE_RULES_MODE", "CODEX_RULES_MODE", "PI_RULES_MODE")', + required: true, + }, + { + find: 'firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")', + replace: + 'firstEnv(env, "OMO_CLAUDE_RULES_MAX_RULE_CHARS", "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")', + required: true, + }, + { + find: 'firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")', + replace: + 'firstEnv(env, "OMO_CLAUDE_RULES_MAX_RESULT_CHARS", "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")', + required: true, + }, + { + find: + 'firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS")', + replace: + 'firstEnv(env, "OMO_CLAUDE_RULES_POST_COMPACT_MAX_RULE_CHARS", "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS")', + required: true, + }, + { + find: + 'firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS")', + replace: + 'firstEnv(env, "OMO_CLAUDE_RULES_POST_COMPACT_MAX_RESULT_CHARS", "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS")', + required: true, + }, + { + find: 'firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES")', + replace: + 'firstEnv(env, "OMO_CLAUDE_RULES_ENABLED_SOURCES", "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES")', + required: true, + }, + ], + }, + { + component: "rules", + file: "hooks/hooks.json", + // Widen the PostToolUse matcher so CC native edit tools fire the hook. + replacements: [ + { + find: '"matcher": "^apply_patch$",', + replace: '"matcher": "^(apply_patch|Write|Edit|MultiEdit)$",', + required: true, + }, + ], + }, + + // ----- comment-checker ------------------------------------------------- + { + component: "comment-checker", + file: "src/codex-hook.ts", + replacements: [ + // Make the type fields model?/turn_id? optional. + { + find: "\tsession_id: string;\n\tturn_id: string;\n", + replace: "\tsession_id: string;\n\tturn_id?: string;\n", + required: true, + }, + { + find: '\thook_event_name: "PostToolUse";\n\tmodel: string;\n', + replace: '\thook_event_name: "PostToolUse";\n\tmodel?: string;\n', + required: true, + }, + // Weaken the runtime validator (keep permission_mode required). + { + find: '\t\ttypeof value["turn_id"] === "string" &&', + replace: + '\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&', + required: true, + }, + { + find: '\t\ttypeof value["model"] === "string" &&', + replace: '\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&', + required: true, + }, + ], + }, + + // ----- lsp ------------------------------------------------------------- + // hooks.json env-var transform only (handled automatically). + + // ----- ultrawork ------------------------------------------------------- + // hooks.json env-var transform only (handled automatically). + + // ----- ultragoal ------------------------------------------------------- + // COPY VERBATIM — no patch entries (its deep refactor is a separate task). + // The hooks.json env-var transform still applies (handled automatically). + + // ----- start-work-continuation ---------------------------------------- + { + component: "start-work-continuation", + file: "src/boulder-reader.ts", + replacements: [ + { + find: "const work = findMatchingWork(parsed, `codex:${sessionId}`);", + replace: "const work = findMatchingWork(parsed, `claude:${sessionId}`);", + required: true, + }, + ], + }, + { + component: "start-work-continuation", + file: "src/codex-hook.ts", + // Make model/turn_id optional in the Stop/SubagentStop validator (keep + // permission_mode required). + replacements: [ + { + find: '\t\ttypeof value["turn_id"] === "string" &&', + replace: + '\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&', + required: true, + }, + { + find: '\t\ttypeof value["model"] === "string" &&', + replace: '\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&', + required: true, + }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Core: derive the expected vendored tree (in memory), then either write it or +// compare it against what is currently on disk. +// --------------------------------------------------------------------------- + +class PatchError extends Error {} + +function buildPatchIndex(components) { + const index = new Map(); + for (const component of components) { + index.set(component, new Map()); + } + for (const entry of PATCH_MANIFEST) { + if (!index.has(entry.component)) continue; + index.get(entry.component).set(normalizeRel(entry.file), entry.replacements); + } + // Inject the shared hooks.json env-var transform for every handled component. + for (const component of components) { + const hooksRel = normalizeRel(join("hooks", "hooks.json")); + const existing = index.get(component).get(hooksRel) ?? []; + index.get(component).set(hooksRel, [ + ...HOOKS_ENV_REPLACEMENTS.map((replacement) => ({ ...replacement, required: false })), + ...existing, + ]); + } + return index; +} + +function normalizeRel(relPath) { + return relPath.split(/[\\/]/).join("/"); +} + +function applyReplacements(component, relPath, text, replacements, matched) { + let result = text; + for (const { find, replace, required } of replacements) { + const hit = result.includes(find); + matched.push({ component, file: relPath, find, replace, matched: hit, required: Boolean(required) }); + if (!hit) { + if (required) { + throw new PatchError( + `required replacement did not match in ${component}/${relPath}: ${JSON.stringify(find)}`, + ); + } + continue; + } + result = result.split(find).join(replace); + } + return result; +} + +async function pathExists(target) { + try { + await stat(target); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return false; + throw error; + } +} + +async function collectFiles(dir) { + const out = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectFiles(full))); + } else if (entry.isFile()) { + out.push(full); + } + } + return out; +} + +// Returns the set of dest files (relative to the component dest root) that the +// sync would produce for one component, along with their expected Buffer/text +// content. Patched files carry their post-patch text. +async function deriveComponentFiles(component, patchIndex, matched) { + const sourceRoot = join(SOURCE_COMPONENTS_ROOT, component); + const replacementsByRel = patchIndex.get(component) ?? new Map(); + const files = new Map(); // relPath -> { content: Buffer, patched: boolean } + + const sourceFiles = []; + for (const dirName of COPY_DIRS) { + const dirPath = join(sourceRoot, dirName); + if (await pathExists(dirPath)) { + sourceFiles.push(...(await collectFiles(dirPath))); + } + } + for (const fileRel of COPY_FILES) { + const filePath = join(sourceRoot, fileRel); + if (await pathExists(filePath)) { + sourceFiles.push(filePath); + } + } + + for (const sourcePath of sourceFiles) { + const relPath = normalizeRel(relative(sourceRoot, sourcePath)); + const replacements = replacementsByRel.get(relPath); + if (replacements && replacements.length > 0) { + const sourceText = await readFile(sourcePath, "utf8"); + const patchedText = applyReplacements(component, relPath, sourceText, replacements, matched); + files.set(relPath, { content: Buffer.from(patchedText, "utf8"), patched: true }); + } else { + files.set(relPath, { content: await readFile(sourcePath), patched: false }); + } + } + + // A required patch targeting a file that does not exist in the source is a + // manifest bug — surface it loudly. + for (const [relPath, replacements] of replacementsByRel.entries()) { + if (files.has(relPath)) continue; + const hasRequired = replacements.some((replacement) => replacement.required); + if (hasRequired) { + throw new PatchError(`patch target missing from source: ${component}/${relPath}`); + } + } + + return files; +} + +async function writeComponent(component, files) { + const destRoot = join(DEST_COMPONENTS_ROOT, component); + await rm(destRoot, { recursive: true, force: true }); + for (const [relPath, { content }] of files) { + const destPath = join(destRoot, relPath); + await mkdir(dirname(destPath), { recursive: true }); + await writeFile(destPath, content); + } +} + +async function readDestFiles(component) { + const destRoot = join(DEST_COMPONENTS_ROOT, component); + const files = new Map(); + if (!(await pathExists(destRoot))) return files; + for (const fullPath of await collectFiles(destRoot)) { + const relPath = normalizeRel(relative(destRoot, fullPath)); + files.set(relPath, await readFile(fullPath)); + } + return files; +} + +function diffComponent(component, expected, actual) { + const drift = []; + for (const [relPath, { content }] of expected) { + const onDisk = actual.get(relPath); + if (onDisk === undefined) { + drift.push(`${component}/${relPath}: missing on disk`); + } else if (!onDisk.equals(content)) { + drift.push(`${component}/${relPath}: content differs`); + } + } + for (const relPath of actual.keys()) { + if (!expected.has(relPath)) { + drift.push(`${component}/${relPath}: unexpected file on disk`); + } + } + return drift; +} + +export async function syncComponents(options = {}) { + const check = options.check ?? false; + const components = + options.components && options.components.length > 0 ? options.components : HANDLED_COMPONENTS; + + for (const component of components) { + if (!HANDLED_COMPONENTS.includes(component)) { + throw new Error( + `unknown component: ${component} (handled: ${HANDLED_COMPONENTS.join(", ")})`, + ); + } + } + + const patchIndex = buildPatchIndex(components); + const matched = []; + const drift = []; + + for (const component of components) { + const expected = await deriveComponentFiles(component, patchIndex, matched); + if (check) { + const actual = await readDestFiles(component); + drift.push(...diffComponent(component, expected, actual)); + } else { + await writeComponent(component, expected); + } + } + + return { check, components, matched, drift }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(args) { + const parsed = { check: false, components: [] }; + for (const arg of args) { + if (arg === "--check") { + parsed.check = true; + continue; + } + if (arg.startsWith("--")) { + throw new Error(`unknown argument: ${arg}`); + } + parsed.components.push(arg); + } + return parsed; +} + +function isNodeError(error) { + return error instanceof Error && "code" in error; +} + +async function main() { + const { check, components } = parseArgs(process.argv.slice(2)); + const result = await syncComponents({ check, components }); + + if (check) { + if (result.drift.length > 0) { + console.error("vendored components out of sync:"); + for (const line of result.drift) { + console.error(` - ${line}`); + } + process.exitCode = 1; + return; + } + console.log(`vendored components in sync (${result.components.length} checked)`); + return; + } + + console.log(`synced components: ${result.components.join(", ")}`); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/packages/omo-claude/plugin/scripts/sync-components.test.mjs b/packages/omo-claude/plugin/scripts/sync-components.test.mjs new file mode 100644 index 000000000..bc75deaae --- /dev/null +++ b/packages/omo-claude/plugin/scripts/sync-components.test.mjs @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { HANDLED_COMPONENTS, PATCH_MANIFEST, syncComponents } from "./sync-components.mjs"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const SCRIPT_PATH = join(SCRIPT_DIR, "sync-components.mjs"); +const PLUGIN_ROOT = dirname(SCRIPT_DIR); +const DRIFT_FILE = join(PLUGIN_ROOT, "components", "lsp", "src", "codex-hook.ts"); + +function runCli(args) { + return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { encoding: "utf8" }); +} + +test("sync then --check is GREEN (exit 0)", () => { + const sync = runCli([]); + assert.equal(sync.status, 0, `sync failed: ${sync.stderr}`); + + const check = runCli(["--check"]); + assert.equal(check.status, 0, `--check should be green after sync but got: ${check.stderr}`); + assert.match(check.stdout, /in sync/); +}); + +test("--check is RED (nonzero) after hand-drift, then re-sync restores GREEN", async () => { + runCli([]); + + const original = await readFile(DRIFT_FILE, "utf8"); + try { + await writeFile(DRIFT_FILE, `${original}\n// drift\n`); + const drifted = runCli(["--check"]); + assert.notEqual(drifted.status, 0, "--check must fail after hand-drift"); + assert.match(drifted.stderr, /out of sync/); + assert.match(drifted.stderr, /lsp\/src\/codex-hook\.ts/); + } finally { + // Re-sync to restore the vendored tree regardless of assertion outcome. + const restore = runCli([]); + assert.equal(restore.status, 0, `restore sync failed: ${restore.stderr}`); + } + + const check = runCli(["--check"]); + assert.equal(check.status, 0, `--check should be green again after re-sync: ${check.stderr}`); +}); + +test("sync is idempotent (running twice yields no drift)", () => { + assert.equal(runCli([]).status, 0); + assert.equal(runCli([]).status, 0); + assert.equal(runCli(["--check"]).status, 0); +}); + +test("a single component can be synced and check passes", () => { + const sync = runCli(["rules"]); + assert.equal(sync.status, 0, sync.stderr); + assert.match(sync.stdout, /rules/); + // Re-sync all so the rest of the suite/state is whole. + assert.equal(runCli([]).status, 0); + assert.equal(runCli(["--check"]).status, 0); +}); + +test("unknown component is rejected", () => { + const result = runCli(["does-not-exist"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /unknown component/); +}); + +test("every required replacement in the manifest matches the source", async () => { + const matched = []; + await syncComponents({ check: true, components: HANDLED_COMPONENTS }); + // syncComponents records matches via its internal collector; re-derive by + // running a check pass and asserting no required replacement is unmatched. + const result = await syncComponents({ check: true }); + for (const entry of result.matched) { + if (entry.required) { + assert.equal( + entry.matched, + true, + `required replacement did not match: ${entry.component}/${entry.file} :: ${entry.find}`, + ); + } + } + assert.ok(result.matched.length >= PATCH_MANIFEST.length, "expected manifest replacements to be exercised"); + // matched local kept for clarity; not otherwise used. + void matched; +});