vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}

This commit is contained in:
YeonGyu-Kim
2026-05-25 22:24:37 +09:00
parent 06c86f526a
commit 2415f37bc0
260 changed files with 22715 additions and 0 deletions
@@ -0,0 +1,99 @@
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CodexPostToolUseInput } from "../src/codex-hook.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
function makeTempProject(ruleCount: number): { root: string; pluginData: string; targetPath: string } {
const root = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-project-"));
const pluginData = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-data-"));
tempDirectories.push(root, pluginData);
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
fs.mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
fs.mkdirSync(path.join(root, "src"), { recursive: true });
const targetPath = path.join(root, "src", "app.ts");
fs.writeFileSync(targetPath, "export const app = true;\n");
for (let index = 0; index < ruleCount; index += 1) {
fs.writeFileSync(
path.join(root, ".omo", "rules", `rule-${index}.md`),
["---", 'globs: "**/*.ts"', "---", "", `Rule ${index}`].join("\n"),
);
}
return { root, pluginData, targetPath };
}
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "file contents" },
tool_use_id: "call-1",
};
}
function isProjectRuleRead(filePath: unknown): boolean {
return String(filePath).includes(`${path.sep}.omo${path.sep}rules${path.sep}`);
}
describe("codex rules hook performance", () => {
it("#given unchanged dynamic target #when PostToolUse repeats #then rule files are not reread for fingerprinting", async () => {
// given
const { root, pluginData, targetPath } = makeTempProject(3);
let ruleFileReads = 0;
const originalReadFileSync = fs.readFileSync;
const wrappedReadFileSync = ((...args: Parameters<typeof fs.readFileSync>) => {
if (isProjectRuleRead(args[0])) {
ruleFileReads += 1;
}
return originalReadFileSync(...args);
}) as typeof fs.readFileSync;
fs.readFileSync = wrappedReadFileSync;
syncBuiltinESMExports();
const { runPostToolUseHook } = await import("../src/codex-hook.js");
try {
// when
const firstOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
const firstRunRuleFileReads = ruleFileReads;
ruleFileReads = 0;
const secondOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
// then
expect(firstOutput).toContain("Rule 0");
expect(firstRunRuleFileReads).toBe(3);
expect(secondOutput).toBe("");
expect(ruleFileReads).toBe(0);
} finally {
fs.readFileSync = originalReadFileSync;
syncBuiltinESMExports();
}
});
});
@@ -0,0 +1,675 @@
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
type CodexPostCompactInput,
type CodexPostToolUseInput,
type CodexSessionStartInput,
runPostCompactHook,
runPostToolUseHook,
runSessionStartHook,
runUserPromptSubmitHook,
} from "../src/codex-hook.js";
type CliResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
type SessionCache = {
staticDedup?: string[];
dynamicDedup?: Record<string, string[]>;
dynamicTargetFingerprints?: Record<string, string>;
};
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
function runHookCli(input: string, subcommand = "post-tool-use", env: NodeJS.ProcessEnv = {}): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI_PATH, "hook", subcommand], {
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (exitCode) => {
resolve({ exitCode, stdout, stderr });
});
child.stdin.end(input);
});
}
const tempDirectories: string[] = [];
const PROJECT_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
};
const RULES_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: ".omo/rules",
};
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeTempProject(): { root: string; pluginData: string } {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-project-"));
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-data-"));
tempDirectories.push(root, pluginData);
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring.");
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
[
"---",
"description: TypeScript",
'globs: ["**/*.ts", "**/*.tsx"]',
"---",
"",
"Prefer strict TypeScript for all source files.",
].join("\n"),
);
mkdirSync(path.join(root, "src"), { recursive: true });
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
return { root, pluginData };
}
function sessionStartInput(root: string): CodexSessionStartInput {
return {
session_id: "session-1",
transcript_path: null,
cwd: root,
hook_event_name: "SessionStart",
model: "gpt-5.5",
permission_mode: "default",
source: "startup",
};
}
function postCompactInput(root: string): CodexPostCompactInput {
return {
session_id: "session-1",
turn_id: "turn-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostCompact",
model: "gpt-5.5",
trigger: "manual",
};
}
function userPromptSubmitInput(
root: string,
transcriptPath: string | null = null,
): Parameters<typeof runUserPromptSubmitHook>[0] {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
};
}
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "file contents" },
tool_use_id: "call-1",
};
}
function parseHookOutput(output: string): {
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
} {
expect(output.trim().length).toBeGreaterThan(0);
return JSON.parse(output) as {
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
};
}
function writeTranscriptWithContext(root: string, ...additionalContexts: string[]): string {
const transcriptPath = path.join(root, "transcript.jsonl");
writeFileSync(
transcriptPath,
`${additionalContexts
.map((additionalContext) => JSON.stringify({ hookSpecificOutput: { additionalContext } }))
.join("\n")}\n`,
);
return transcriptPath;
}
function occurrenceCount(value: string, search: string): number {
return value.split(search).length - 1;
}
function sessionCacheFilePath(pluginData: string, sessionId = "session-1"): string {
return path.join(pluginData, "sessions", `${sessionId}.json`);
}
function readSessionCache(pluginData: string): SessionCache {
return JSON.parse(readFileSync(sessionCacheFilePath(pluginData), "utf8")) as SessionCache;
}
function writeTypeScriptRule(root: string, globExpression: string, body: string): void {
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
["---", "description: TypeScript", `globs: ${globExpression}`, "---", "", body].join("\n"),
);
}
describe("codex rules hooks", () => {
it("#given project rules #when SessionStart runs #then emits static additional context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
const parsed = parseHookOutput(output);
expect(parsed.hookSpecificOutput?.hookEventName).toBe("SessionStart");
expect(parsed.hookSpecificOutput?.additionalContext).toContain("## Project Instructions");
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Always wear safety goggles");
});
it("#given static context already injected #when UserPromptSubmit runs #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
await runSessionStartHook(sessionStartInput(root), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const output = await runUserPromptSubmitHook(
{
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given resumed session #when SessionStart runs #then it preserves the session cache", async () => {
// given
const { root, pluginData } = makeTempProject();
const input = sessionStartInput(root);
await runSessionStartHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const resumeOutput = await runSessionStartHook(
{ ...input, source: "resume" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
const clearOutput = await runSessionStartHook(
{ ...input, source: "clear" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(resumeOutput).toBe("");
expect(parseHookOutput(clearOutput).hookSpecificOutput?.additionalContext).toContain(
"Always wear safety goggles",
);
});
it("#given static context remains in transcript but cache is missing #when SessionStart runs #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
rmSync(sessionCacheFilePath(pluginData), { force: true });
// when
const output = await runSessionStartHook(
{ ...sessionStartInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
expect(readSessionCache(pluginData).staticDedup).toHaveLength(1);
});
it("#given read-file tool result #when PostToolUse runs #then emits matching dynamic rule context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
// The literal "src/app.ts" pins POSIX separators and acts as the Windows
// regression line: prior versions emitted "src\\app.ts" on Windows.
const parsed = parseHookOutput(output);
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
expect(parsed.hookSpecificOutput?.additionalContext).toContain(
"Additional project instructions matched for src/app.ts",
);
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
expect(parsed.hookSpecificOutput?.additionalContext ?? "").not.toContain("src\\app.ts");
expect(output).not.toContain("updatedMCPToolOutput");
expect(output).not.toContain("suppressOutput");
expect(output).not.toContain('"decision"');
});
it("#given multiple target paths matching one rule #when PostToolUse runs #then emits dynamic context once for the first target", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstFilePath = path.join(root, "src", "app.ts");
const secondFilePath = path.join(root, "src", "other.ts");
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, firstFilePath),
tool_name: "mcp__filesystem__read_multiple_files",
tool_input: { paths: [firstFilePath, secondFilePath, firstFilePath] },
},
{
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
},
);
// then
const parsed = parseHookOutput(output);
const additionalContext = parsed.hookSpecificOutput?.additionalContext ?? "";
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
expect(additionalContext).toContain("Additional project instructions matched for src/app.ts");
expect(additionalContext).not.toContain("src\\app.ts");
expect(occurrenceCount(additionalContext, "Prefer strict TypeScript")).toBe(1);
});
it("#given dynamic context already injected #when PostToolUse repeats #then emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const cachedState = readSessionCache(pluginData);
// when
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// then
expect(output).toBe("");
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
});
it("#given dynamic context remains in transcript but cache is missing #when PostToolUse repeats #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
rmSync(sessionCacheFilePath(pluginData), { force: true });
// when
const output = await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
const cachedState = readSessionCache(pluginData);
expect(output).toBe("");
expect(Object.values(cachedState.dynamicDedup ?? {}).flat()).toHaveLength(2);
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
});
it("#given cached target in one session #when another session reads it #then PostToolUse rechecks independently", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
await runPostToolUseHook(postToolUseInput(root, filePath), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const output = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), session_id: "session-2" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given cached dynamic target #when rule frontmatter changes #then PostToolUse rechecks the target", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
writeTypeScriptRule(root, '"**/*.ts"', "Prefer readonly TypeScript after rule edits.");
// when
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain(
"Prefer readonly TypeScript after rule edits.",
);
});
it("#given cached dynamic context #when PostCompact runs #then PostToolUse can re-inject after compaction", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
expect(
await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
),
).toBe("");
// when
const compactOutput = await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
const output = await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(compactOutput).toBe("");
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given compacted transcript #when static re-injects before dynamic #then dynamic still re-injects", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithContext(
root,
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
);
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicReinjectOutput = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Always wear safety goggles",
);
expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Prefer strict TypeScript",
);
});
it("#given compacted transcript #when dynamic re-injects before static #then static still re-injects", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithContext(
root,
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
);
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const dynamicReinjectOutput = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Prefer strict TypeScript",
);
expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Always wear safety goggles",
);
});
it("#given legacy session cache #when PostToolUse hydrates state #then it accepts the old shape", async () => {
// given
const { root, pluginData } = makeTempProject();
mkdirSync(path.join(pluginData, "sessions"), { recursive: true });
writeFileSync(sessionCacheFilePath(pluginData), `${JSON.stringify({ staticDedup: [], dynamicDedup: {} })}\n`);
// when
const output = await runPostToolUseHook(postToolUseInput(root, path.join(root, "src", "app.ts")), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given static-only mode #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: {
...PROJECT_ONLY_ENV,
CODEX_RULES_MODE: "static",
},
});
// then
expect(output).toBe("");
});
it("#given rules disabled #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: {
...PROJECT_ONLY_ENV,
CODEX_RULES_DISABLED: "true",
},
});
// then
expect(output).toBe("");
});
it("#given failed tool response #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, filePath),
tool_response: { is_error: true },
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given tracked tool without path #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, ""),
tool_input: {},
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "break;\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "[]\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given debug timing enabled #when PostToolUse hook CLI runs #then phase logs go to stderr only", async () => {
// given
const { root, pluginData } = makeTempProject();
const input = `${JSON.stringify(postToolUseInput(root, path.join(root, "src", "app.ts")))}\n`;
// when
const result = await runHookCli(input, "post-tool-use", {
NODE_DEBUG: "codex-rules",
PLUGIN_DATA: pluginData,
});
// then
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("hookSpecificOutput");
expect(result.stderr).toContain("PostToolUse");
expect(result.stderr).toContain("extract");
expect(result.stderr).toContain("fingerprint");
expect(result.stderr).toContain("load");
expect(result.stderr).toContain("persist");
expect(result.stderr).toContain("ms");
});
it("#given malformed post-compact stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = `${JSON.stringify({ hook_event_name: "PostCompact", session_id: "s", turn_id: "t" })}\n`;
// when
const result = await runHookCli(input, "post-compact");
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
});
@@ -0,0 +1,192 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
import { matchRule as defaultMatchRule } from "../src/rules/matcher.js";
import type { RuleCandidate } from "../src/rules/types.js";
const projectRoot = "/tmp/codex-rules-engine";
function makeCandidate(): RuleCandidate {
return {
path: join(projectRoot, ".omo", "rules", "typescript.md"),
realPath: join(projectRoot, ".omo", "rules", "typescript.md"),
source: ".omo/rules",
distance: 0,
isGlobal: false,
isSingleFile: false,
relativePath: ".omo/rules/typescript.md",
};
}
describe("rule engine dynamic matching", () => {
it("#given duplicate target paths #when loading dynamic rules #then repeated discovery and parsing work is avoided", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
const counters = {
findProjectRoot: 0,
findCandidates: 0,
readFile: 0,
};
const deps = {
findProjectRoot: () => {
counters.findProjectRoot += 1;
return projectRoot;
},
findCandidates: () => {
counters.findCandidates += 1;
return [candidate];
},
readFile: () => {
counters.readFile += 1;
return ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n");
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const result = engine.loadDynamicRules(projectRoot, [targetPath, targetPath, targetPath]);
// then
expect(result.rules).toHaveLength(1);
expect(counters).toEqual({
findProjectRoot: 1,
findCandidates: 1,
readFile: 1,
});
});
it("#given distinct target files in same directory #when loading dynamic rules #then candidate discovery is reused", () => {
// given
const firstTarget = join(projectRoot, "src", "first.ts");
const secondTarget = join(projectRoot, "src", "second.ts");
const thirdTarget = join(projectRoot, "src", "third.ts");
const candidate = makeCandidate();
let findCandidatesCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => {
findCandidatesCalls += 1;
return [candidate];
},
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const result = engine.loadDynamicRules(projectRoot, [firstTarget, secondTarget, thirdTarget]);
// then
expect(result.rules).toHaveLength(1);
expect(findCandidatesCalls).toBe(1);
});
it("#given same rule content and target across loads #when loading dynamic rules repeats #then cached match decision is reused", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(firstResult.rules).toHaveLength(1);
expect(secondResult.rules).toHaveLength(1);
expect(matchCalls).toBe(1);
});
it("#given same rule path changes body #when loading dynamic rules repeats #then cached match decision invalidates", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let body = "Prefer strict TypeScript.";
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", "globs: **/*.ts", "---", "", body].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
engine.loadDynamicRules(projectRoot, [targetPath]);
body = "Prefer readonly TypeScript.";
engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(matchCalls).toBe(2);
});
it("#given same rule path changes frontmatter #when loading dynamic rules repeats #then cached match decision invalidates", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let globs = "**/*.ts";
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", `globs: ${globs}`, "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
globs = "**/*.tsx";
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(firstResult.rules).toHaveLength(1);
expect(secondResult.rules).toHaveLength(0);
expect(matchCalls).toBe(2);
});
it("#given same rule and different targets #when loading dynamic rules repeats #then target-specific decisions do not leak", () => {
// given
const sourceTarget = join(projectRoot, "src", "app.ts");
const testTarget = join(projectRoot, "src", "app.test.ts");
const candidate = makeCandidate();
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () =>
["---", 'globs: ["**/*.ts", "!**/*.test.ts"]', "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const sourceResult = engine.loadDynamicRules(projectRoot, [sourceTarget]);
const testResult = engine.loadDynamicRules(projectRoot, [testTarget]);
// then
expect(sourceResult.rules).toHaveLength(1);
expect(testResult.rules).toHaveLength(0);
expect(matchCalls).toBe(2);
});
});
@@ -0,0 +1,96 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { findRuleCandidates } from "../src/rules/finder.js";
import type { RuleCandidate } from "../src/rules/types.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeProject(): { projectRoot: string; homeRoot: string; targetPath: string } {
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-project-"));
const homeRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-home-"));
tempDirectories.push(projectRoot, homeRoot);
mkdirSync(join(projectRoot, "src", ".omo", "rules"), { recursive: true });
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
mkdirSync(join(homeRoot, ".opencode", "rules"), { recursive: true });
mkdirSync(join(homeRoot, ".config", "opencode"), { recursive: true });
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(join(projectRoot, "AGENTS.md"), "Project rule\n");
writeFileSync(join(projectRoot, "src", ".omo", "rules", "local.md"), "Local rule\n");
writeFileSync(join(projectRoot, ".omo", "rules", "root.md"), "Root rule\n");
writeFileSync(join(homeRoot, ".opencode", "rules", "global.md"), "Global rule\n");
writeFileSync(join(homeRoot, ".config", "opencode", "AGENTS.md"), "Home rule\n");
const targetPath = join(projectRoot, "src", "app.ts");
writeFileSync(targetPath, "export const app = true;\n");
return { projectRoot, homeRoot, targetPath };
}
function candidateSummary(candidate: RuleCandidate): string {
return `${candidate.source}:${candidate.distance}:${candidate.relativePath}`;
}
describe("findRuleCandidates", () => {
it("#given project and user-home rules #when target file is inside project #then candidates keep source distance", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({ projectRoot, targetFile: targetPath, homeDir: homeRoot });
// then
expect(candidates.map(candidateSummary)).toEqual([
".omo/rules:0:src/.omo/rules/local.md",
".omo/rules:1:.omo/rules/root.md",
"AGENTS.md:1:AGENTS.md",
"~/.opencode/rules:9999:.opencode/rules/global.md",
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
]);
});
it("#given disabled source #when finding candidates #then matching source is omitted", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
disabledSources: new Set([".omo/rules", "~/.opencode/rules"]),
});
// then
expect(candidates.map(candidateSummary)).toEqual([
"AGENTS.md:1:AGENTS.md",
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
]);
});
it("#given skip user home #when finding candidates #then only project rules are returned", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
skipUserHome: true,
});
// then
expect(candidates.map(candidateSummary)).toEqual([
".omo/rules:0:src/.omo/rules/local.md",
".omo/rules:1:.omo/rules/root.md",
"AGENTS.md:1:AGENTS.md",
]);
});
});
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { matchRule, normalizeGlobs } from "../src/rules/matcher.js";
import type { RuleFrontmatter } from "../src/rules/types.js";
function matchFrontmatter(
frontmatter: RuleFrontmatter,
pathBases: {
projectRelative: string;
scopeRelative?: string;
basename?: string;
},
): ReturnType<typeof matchRule> {
const scopeRelative = pathBases.scopeRelative;
const pathBase = {
projectRelative: pathBases.projectRelative,
basename: pathBases.basename ?? pathBases.projectRelative.split("/").at(-1) ?? pathBases.projectRelative,
...(scopeRelative === undefined ? {} : { scopeRelative }),
};
return matchRule({
frontmatter,
isSingleFile: false,
pathBases: pathBase,
});
}
function matchGlobs(globs: string | string[], projectRelative: string): boolean {
return matchFrontmatter({ globs } satisfies RuleFrontmatter, { projectRelative }).matched;
}
describe("matchRule", () => {
it("#given single-file rule #when matching any target #then it always matches", () => {
// given
const frontmatter = {} satisfies RuleFrontmatter;
// when
const result = matchRule({
frontmatter,
isSingleFile: true,
pathBases: { projectRelative: "docs/readme.md", basename: "readme.md" },
});
// then
expect(result).toEqual({ matched: true, reason: "single-file" });
});
it("#given always apply rule #when no glob is configured #then it matches", () => {
// given
const frontmatter = { alwaysApply: true } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: "alwaysApply" });
});
it("#given rule without patterns #when target is checked #then no match is returned", () => {
// given
const frontmatter = {} satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
// then
expect(result).toEqual({ matched: false, reason: { kind: "no-match" } });
});
it("#given recursive glob #when target is nested #then matches without runtime dependencies", () => {
// given
const globs = "**/*.ts";
// when
const matched = matchGlobs(globs, "src/features/app.ts");
// then
expect(matched).toBe(true);
});
it("#given paths alias #when target matches #then glob match is returned", () => {
// given
const frontmatter = { paths: "src/**/*.ts" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given applyTo alias #when basename matches #then glob match is returned", () => {
// given
const frontmatter = { applyTo: "*.md" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "docs/README.md" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "*.md" } });
});
it("#given scope-relative target #when scoped path matches #then glob match is returned", () => {
// given
const frontmatter = { globs: "components/**/*.tsx" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, {
projectRelative: "packages/ui/components/button.tsx",
scopeRelative: "components/button.tsx",
});
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "components/**/*.tsx" } });
});
it("#given backslash glob and target #when matching #then paths are normalized", () => {
// given
const frontmatter = { globs: "src\\**\\*.ts" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src\\features\\app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given multiple positive globs #when later glob matches #then matching pattern is reported", () => {
// given
const frontmatter = { globs: ["docs/**/*.md", "src/**/*.ts"] } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given negative glob #when target is excluded #then no match is returned", () => {
// given
const globs = ["**/*.ts", "!**/*.test.ts"];
// when
const matched = matchGlobs(globs, "src/features/app.test.ts");
// then
expect(matched).toBe(false);
});
it("#given question-mark glob #when one filename character differs #then target matches", () => {
// given
const globs = "src/app-?.ts";
// when
const matched = matchGlobs(globs, "src/app-a.ts");
// then
expect(matched).toBe(true);
});
it("#given brace glob #when target extension is listed #then matches", () => {
// given
const globs = "src/**/*.{ts,tsx}";
// when
const matched = matchGlobs(globs, "src/features/app.tsx");
// then
expect(matched).toBe(true);
});
it("#given character class glob #when matching listed extension #then target matches", () => {
// given
const globs = "src/**/*.[tj]s";
// when
const matched = matchGlobs(globs, "src/features/app.ts");
// then
expect(matched).toBe(true);
});
it("#given extglob pattern #when matching allowed extension #then target matches", () => {
// given
const globs = "src/**/*.@(ts|tsx)";
// when
const matched = matchGlobs(globs, "src/features/app.tsx");
// then
expect(matched).toBe(true);
});
it("#given duplicate normalized patterns #when normalizing #then first unique pattern order is kept", () => {
// given
const frontmatter = {
globs: ["src\\**\\*.ts", "src/**/*.ts", "!src/**/*.test.ts"],
paths: "!src/**/*.test.ts",
} satisfies RuleFrontmatter;
// when
const patterns = normalizeGlobs(frontmatter);
// then
expect(patterns).toEqual(["src/**/*.ts", "!src/**/*.test.ts"]);
});
});
@@ -0,0 +1,144 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly type: string;
readonly packageManager: string;
readonly bin: Record<string, string>;
readonly dependencies?: Record<string, unknown>;
};
type PluginJson = {
readonly hooks: string;
};
type HookCommand = {
readonly command: string;
};
type HookEntry = {
readonly matcher?: string;
readonly hooks: readonly HookCommand[];
};
type HooksJson = {
readonly hooks: Record<string, readonly HookEntry[]>;
};
function readPackageJson(path: string): PackageJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
return parsed;
}
function readPluginJson(path: string): PluginJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`);
return parsed;
}
function readHooksJson(path: string): HooksJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
return parsed;
}
describe("plugin package metadata", () => {
it("#given packaged plugin files #when validating entrypoints #then hook commands use portable plugin root interpolation", () => {
// given
const packageJson = readPackageJson("package.json");
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
// when
const hookConfig = hooksJson.hooks;
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
const commands = [
hookConfig["SessionStart"]?.[0]?.hooks[0]?.command,
hookConfig["UserPromptSubmit"]?.[0]?.hooks[0]?.command,
hookConfig["PostToolUse"]?.[0]?.hooks[0]?.command,
hookConfig["PostCompact"]?.[0]?.hooks[0]?.command,
];
const postToolUseMatcher = hookConfig["PostToolUse"]?.[0]?.matcher ?? "";
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies ?? {}).toEqual({ picomatch: "^4.0.3" });
expect(packageJson.bin["codex-rules"]).toBe("./dist/cli.js");
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(commands).toEqual([
`node "${pluginRoot}/dist/cli.js" hook session-start`,
`node "${pluginRoot}/dist/cli.js" hook user-prompt-submit`,
`node "${pluginRoot}/dist/cli.js" hook post-tool-use`,
`node "${pluginRoot}/dist/cli.js" hook post-compact`,
]);
expect(postToolUseMatcher).toBe("^apply_patch$");
const postToolUseMatcherRegex = new RegExp(postToolUseMatcher);
expect(postToolUseMatcherRegex.test("apply_patch")).toBe(true);
expect(
[
"read",
"Read",
"read_file",
"mcp__filesystem__read_file",
"mcp__filesystem__read_multiple_files",
"mcp__filesystem__write_file",
"mcp__filesystem__edit_file",
"write",
"Write",
"edit",
"Edit",
"multi_edit",
"MultiEdit",
"multiedit",
"exec_command",
"shell_command",
"bash",
"Bash",
].some((toolName) => postToolUseMatcherRegex.test(toolName)),
).toBe(false);
});
});
function isPackageJson(value: unknown): value is PackageJson {
if (!isRecord(value)) return false;
const dependencies = value["dependencies"];
return (
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
isStringRecord(value["bin"]) &&
(dependencies === undefined || isRecord(dependencies))
);
}
function isPluginJson(value: unknown): value is PluginJson {
return isRecord(value) && typeof value["hooks"] === "string";
}
function isHooksJson(value: unknown): value is HooksJson {
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
return Object.values(value["hooks"]).every(isHookEntries);
}
function isHookEntries(value: unknown): value is readonly HookEntry[] {
return Array.isArray(value) && value.every(isHookEntry);
}
function isHookEntry(value: unknown): value is HookEntry {
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
}
function isHookCommand(value: unknown): value is HookCommand {
return isRecord(value) && typeof value["command"] === "string";
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,63 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { scanRuleFiles } from "../src/rules/scanner.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("scanRuleFiles", () => {
it("#given more rule files than max #when scanning #then returns only capped files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(root, `rule-${index}.md`), `Rule ${index}\n`);
}
// when
const files = scanRuleFiles({ rootDir: root, maxFiles: 2 });
// then
expect(files).toHaveLength(2);
});
it("#given rule files and an excluded directory #when scanning #then returns sorted non-excluded files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
mkdirSync(join(root, "dist"), { recursive: true });
writeFileSync(join(root, "beta.md"), "Beta\n");
writeFileSync(join(root, "alpha.md"), "Alpha\n");
writeFileSync(join(root, "dist", "ignored.md"), "Ignored\n");
// when
const files = scanRuleFiles({ rootDir: root });
// then
expect(files.map((file) => file.path)).toEqual([join(root, "alpha.md"), join(root, "beta.md")]);
});
it("#given symlink loop #when scanning #then traversal terminates without duplicate files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
const nested = join(root, "nested");
mkdirSync(nested, { recursive: true });
writeFileSync(join(root, "root.md"), "Root\n");
symlinkSync(root, join(nested, "loop"));
// when
const files = scanRuleFiles({ rootDir: root });
// then
expect(files.map((file) => file.path)).toEqual([join(root, "root.md")]);
});
});
@@ -0,0 +1,198 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type CodexPostToolUseLike, extractCodexToolPaths } from "../src/tool-paths.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeProject(): string {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-paths-"));
tempDirectories.push(root);
mkdirSync(path.join(root, "src"), { recursive: true });
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
return root;
}
function postToolUse(input: { toolName: string; toolInput?: unknown; toolResponse?: unknown }): CodexPostToolUseLike {
return {
tool_name: input.toolName,
tool_input: input.toolInput ?? {},
tool_response: input.toolResponse ?? { text: "ok" },
};
}
describe("extractCodexToolPaths", () => {
it("#given filesystem read payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__read_file",
toolInput: { path: "src/app.ts" },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given apply_patch payload #when extracting #then returns patched file paths", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "apply_patch",
toolInput: {
command: [
"*** Begin Patch",
"*** Update File: src/app.ts",
"@@",
"+export const changed = true;",
"*** End Patch",
].join("\n"),
},
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given apply_patch add update and move payload #when extracting #then returns each target once", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "apply_patch",
toolInput: {
command: [
"*** Begin Patch",
"*** Add File: src/new.ts",
"+export const created = true;",
"*** Update File: src/app.ts",
"*** Move to: src/moved.ts",
"@@",
"-export const app = true;",
"+export const moved = true;",
"*** Update File: src/moved.ts",
"@@",
"-export const moved = true;",
"+export const moved = false;",
"*** End Patch",
].join("\n"),
},
}),
root,
);
// then
expect(paths).toEqual([
path.join(root, "src", "new.ts"),
path.join(root, "src", "app.ts"),
path.join(root, "src", "moved.ts"),
]);
});
it("#given mcp write-file payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__write_file",
toolInput: { path: "src/app.ts", content: "export const app = true;\n" },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given mcp edit-file payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__edit_file",
toolInput: { path: "src/app.ts", edits: [] },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given mcp read-multiple-files payload #when extracting #then returns all resolved paths", () => {
// given
const root = makeProject();
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__read_multiple_files",
toolInput: { paths: ["src/app.ts", "src/other.ts"] },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts"), path.join(root, "src", "other.ts")]);
});
it("#given shell command payload #when extracting #then returns only existing file tokens", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "exec_command",
toolInput: { cmd: "sed -n '1,80p' src/app.ts src/missing.ts", workdir: root },
}),
"/tmp",
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given failed tracked tool payload #when extracting #then returns no paths", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "read",
toolInput: { path: "src/app.ts" },
toolResponse: { is_error: true },
}),
root,
);
// then
expect(paths).toEqual([]);
});
});