vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user