diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9363fdb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings: store LF in git, check out LF on every platform. +# Required so biome's --check passes on Windows (default core.autocrlf=true). +* text=auto eol=lf + +# Explicit binary types +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tgz binary +*.gz binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 495dba0..6cc7a16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] node: ["20", "22"] steps: - name: Checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f19670..85c37a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- Add `PostToolUse` matching for MCP filesystem write, edit, and multi-read payloads. +- Harden dynamic hook coverage for additional-context JSON output, disabled/static modes, failed tool responses, and duplicate suppression. +- Remove redundant apply_patch path scanning and stale tracked-tool constants. +- Use portable Codex hook interpolation and add package smoke coverage for hook entrypoints. +- Cap recursive rule directory scans and run CI on Windows in addition to Ubuntu and macOS. +- Replace the external glob matcher dependency with an internal matcher so clean Codex plugin installs run without `node_modules`. + ## 0.1.0 - 2026-05-15 - Port `pi-rules` rule loading, matching, formatting, truncation, and deduplication to a Codex plugin. diff --git a/README.md b/README.md index 2e871be..bd4c18b 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,14 @@ Codex plugin that injects local project rule files into model context through li It ports the `pi-rules` rule injector to Codex: - `SessionStart` and `UserPromptSubmit` load static project instructions once per session. -- `PostToolUse` watches file reads and edits, then injects matching file-specific rules. +- `PostToolUse` watches supported file reads, edits, `apply_patch`, MCP filesystem payloads, and shell command file references, then injects matching file-specific rules as additional context. +- `PostCompact` clears the per-session injection cache after manual or automatic compaction so relevant rules can be reintroduced into the compacted conversation. - Session-level deduplication prevents the same rule from being repeated after it has been injected. +`PostToolUse` output is context-only: it emits `hookSpecificOutput.additionalContext` and does not rewrite tool output. + +The runtime has no npm production dependencies, so a clean Codex marketplace copy can run without a follow-up `npm install`. + ## Rule Sources Project-level sources: @@ -44,7 +49,7 @@ codex plugin marketplace add /Users/yeongyu/local-workspaces/codex-plugins node /Users/yeongyu/local-workspaces/codex-plugins/scripts/install-local.mjs /Users/yeongyu/local-workspaces/codex-plugins ``` -The local installer builds the plugin, copies a clean cache entry to: +The local installer builds the plugin and copies a clean cache entry to: ```text ~/.codex/plugins/cache/code-yeongyu-codex-plugins/codex-rules/0.1.0 @@ -55,6 +60,7 @@ It also enables: ```toml [features] plugins = true +plugin_hooks = true [plugins."codex-rules@code-yeongyu-codex-plugins"] enabled = true @@ -80,6 +86,7 @@ For migration from `pi-rules`, equivalent `PI_RULES_*` variables are accepted as npm install npm test npm run check +npm run typecheck npm pack --dry-run ``` diff --git a/biome.json b/biome.json index 792c0b3..5aa1a0d 100644 --- a/biome.json +++ b/biome.json @@ -5,12 +5,19 @@ "rules": { "recommended": true, "style": { - "noNonNullAssertion": "off", + "noDefaultExport": "error", + "noEnum": "error", + "noNonNullAssertion": "error", + "useImportType": "error", "useConst": "error", "useNodejsImportProtocol": "off" }, + "complexity": { + "useLiteralKeys": "off" + }, "suspicious": { "noExplicitAny": "error", + "noTsIgnore": "error", "noControlCharactersInRegex": "off", "noEmptyInterface": "off" } @@ -24,6 +31,18 @@ "lineWidth": 120 }, "files": { - "includes": ["src/**/*.ts", "test/**/*.ts", "!**/node_modules/**/*", "!**/dist/**/*"] - } + "includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"] + }, + "overrides": [ + { + "includes": ["vitest.config.ts"], + "linter": { + "rules": { + "style": { + "noDefaultExport": "off" + } + } + } + } + ] } diff --git a/dist/cli.d.ts b/dist/cli.d.ts index faaadd5..b798801 100644 --- a/dist/cli.d.ts +++ b/dist/cli.d.ts @@ -1,3 +1,2 @@ #!/usr/bin/env node export {}; -//# sourceMappingURL=cli.d.ts.map \ No newline at end of file diff --git a/dist/cli.d.ts.map b/dist/cli.d.ts.map deleted file mode 100644 index f022439..0000000 --- a/dist/cli.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/cli.js b/dist/cli.js index 8fd1ecb..e44c3e7 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import { stdin as processStdin, stdout as processStdout } from "node:process"; -import { runPostToolUseHook, runSessionStartHook, runUserPromptSubmitHook, } from "./codex-hook.js"; +import { runPostCompactHook, runPostToolUseHook, runSessionStartHook, runUserPromptSubmitHook, } from "./codex-hook.js"; const command = process.argv[2]; const subcommand = process.argv[3]; if (command === "hook" && subcommand === "session-start") { @@ -12,25 +12,97 @@ else if (command === "hook" && subcommand === "user-prompt-submit") { else if (command === "hook" && subcommand === "post-tool-use") { await runHookCli("PostToolUse"); } +else if (command === "hook" && subcommand === "post-compact") { + await runHookCli("PostCompact"); +} else { - process.stderr.write("Usage: codex-rules hook [session-start|user-prompt-submit|post-tool-use]\n"); + process.stderr.write("Usage: codex-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n"); process.exitCode = 1; } async function runHookCli(eventName) { const raw = await readStdin(); if (raw.trim().length === 0) return; - const parsed = JSON.parse(raw); - const options = { pluginDataRoot: process.env.PLUGIN_DATA }; - const output = eventName === "SessionStart" - ? await runSessionStartHook(parsed, options) - : eventName === "UserPromptSubmit" - ? await runUserPromptSubmitHook(parsed, options) - : await runPostToolUseHook(parsed, options); + const parsed = parseHookInput(raw); + if (!parsed) + return; + const pluginDataRoot = process.env["PLUGIN_DATA"]; + const options = pluginDataRoot === undefined ? {} : { pluginDataRoot }; + const output = await runHook(eventName, parsed, options); if (output.length > 0) { processStdout.write(output); } } +async function runHook(eventName, parsed, options) { + switch (eventName) { + case "SessionStart": + return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : ""; + case "UserPromptSubmit": + return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : ""; + case "PostToolUse": + return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : ""; + case "PostCompact": + return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : ""; + } +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch { + return undefined; + } +} +function isCodexSessionStartInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "SessionStart" && + typeof value["session_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["source"] === "string"); +} +function isCodexUserPromptSubmitInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["prompt"] === "string"); +} +function isCodexPostToolUseInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string"); +} +function isCodexPostCompactInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "PostCompact" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + (value["trigger"] === "manual" || value["trigger"] === "auto")); +} +function isStringOrNull(value) { + return typeof value === "string" || value === null; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} function readStdin() { return new Promise((resolve, reject) => { let data = ""; @@ -44,4 +116,3 @@ function readStdin() { }); }); } -//# sourceMappingURL=cli.js.map \ No newline at end of file diff --git a/dist/cli.js.map b/dist/cli.js.map deleted file mode 100644 index 246e8bb..0000000 --- a/dist/cli.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,KAAK,IAAI,YAAY,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,cAAc,CAAC;AAE9E,OAAO,EAIN,kBAAkB,EAClB,mBAAmB,EACnB,uBAAuB,GACvB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAChC,MAAM,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAEnC,IAAI,OAAO,KAAK,MAAM,IAAI,UAAU,KAAK,eAAe,EAAE,CAAC;IAC1D,MAAM,UAAU,CAAC,cAAc,CAAC,CAAC;AAClC,CAAC;KAAM,IAAI,OAAO,KAAK,MAAM,IAAI,UAAU,KAAK,oBAAoB,EAAE,CAAC;IACtE,MAAM,UAAU,CAAC,kBAAkB,CAAC,CAAC;AACtC,CAAC;KAAM,IAAI,OAAO,KAAK,MAAM,IAAI,UAAU,KAAK,eAAe,EAAE,CAAC;IACjE,MAAM,UAAU,CAAC,aAAa,CAAC,CAAC;AACjC,CAAC;KAAM,CAAC;IACP,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,4EAA4E,CAAC,CAAC;IACnG,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;AACtB,CAAC;AAED,KAAK,UAAU,UAAU,CAAC,SAA8D;IACvF,MAAM,GAAG,GAAG,MAAM,SAAS,EAAE,CAAC;IAC9B,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IACpC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC/B,MAAM,OAAO,GAAG,EAAE,cAAc,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;IAC5D,MAAM,MAAM,GACX,SAAS,KAAK,cAAc;QAC3B,CAAC,CAAC,MAAM,mBAAmB,CAAC,MAAgC,EAAE,OAAO,CAAC;QACtE,CAAC,CAAC,SAAS,KAAK,kBAAkB;YACjC,CAAC,CAAC,MAAM,uBAAuB,CAAC,MAAoC,EAAE,OAAO,CAAC;YAC9E,CAAC,CAAC,MAAM,kBAAkB,CAAC,MAA+B,EAAE,OAAO,CAAC,CAAC;IACxE,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvB,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7B,CAAC;AACF,CAAC;AAED,SAAS,SAAS;IACjB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACtC,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,YAAY,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QACjC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACzC,IAAI,IAAI,KAAK,CAAC;QACf,CAAC,CAAC,CAAC;QACH,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACnC,YAAY,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,EAAE;YAC7B,OAAO,CAAC,IAAI,CAAC,CAAC;QACf,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/dist/codex-hook.d.ts b/dist/codex-hook.d.ts index c8420ed..aa451a1 100644 --- a/dist/codex-hook.d.ts +++ b/dist/codex-hook.d.ts @@ -30,11 +30,20 @@ export type CodexPostToolUseInput = { tool_response: unknown; tool_use_id: string; }; +export type CodexPostCompactInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostCompact"; + model: string; + trigger: "manual" | "auto"; +}; export interface CodexRulesHookOptions { env?: NodeJS.ProcessEnv; pluginDataRoot?: string; } export declare function runSessionStartHook(input: CodexSessionStartInput, options?: CodexRulesHookOptions): Promise; +export declare function runPostCompactHook(input: CodexPostCompactInput, options?: CodexRulesHookOptions): Promise; export declare function runUserPromptSubmitHook(input: CodexUserPromptSubmitInput, options?: CodexRulesHookOptions): Promise; export declare function runPostToolUseHook(input: CodexPostToolUseInput, options?: CodexRulesHookOptions): Promise; -//# sourceMappingURL=codex-hook.d.ts.map \ No newline at end of file diff --git a/dist/codex-hook.d.ts.map b/dist/codex-hook.d.ts.map deleted file mode 100644 index b32101e..0000000 --- a/dist/codex-hook.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"codex-hook.d.ts","sourceRoot":"","sources":["../src/codex-hook.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,sBAAsB,GAAG;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE,cAAc,CAAC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAC;CACvC,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE,kBAAkB,CAAC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE,aAAa,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,OAAO,CAAC;IACpB,aAAa,EAAE,OAAO,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACrC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wBAAsB,mBAAmB,CACxC,KAAK,EAAE,sBAAsB,EAC7B,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED,wBAAsB,uBAAuB,CAC5C,KAAK,EAAE,0BAA0B,EACjC,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,MAAM,CAAC,CAGjB;AAED,wBAAsB,kBAAkB,CACvC,KAAK,EAAE,qBAAqB,EAC5B,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,MAAM,CAAC,CA6BjB"} \ No newline at end of file diff --git a/dist/codex-hook.js b/dist/codex-hook.js index fba17d6..be55e8a 100644 --- a/dist/codex-hook.js +++ b/dist/codex-hook.js @@ -1,16 +1,25 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, relative } from "node:path"; +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; import { configFromEnvironment } from "./config.js"; import { clearSessionState, hydrateEngineState, persistEngineState, sessionCachePath } from "./persistent-cache.js"; +import { SOURCE_PRIORITY } from "./rules/constants.js"; import { createEngine } from "./rules/engine.js"; -import { findRuleCandidates } from "./rules/finder.js"; +import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js"; +import { hashContent } from "./rules/matcher.js"; +import { sortCandidates } from "./rules/ordering.js"; import { findProjectRoot } from "./rules/project-root.js"; import { extractCodexToolPaths } from "./tool-paths.js"; export async function runSessionStartHook(input, options = {}) { const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); - clearSessionState(cachePath); + if (input.source !== "resume") { + clearSessionState(cachePath); + } return runStaticInjection(input.cwd, "SessionStart", cachePath, options); } +export async function runPostCompactHook(input, options = {}) { + clearSessionState(sessionCachePath(input.session_id, options.pluginDataRoot)); + return ""; +} export async function runUserPromptSubmitHook(input, options = {}) { const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); return runStaticInjection(input.cwd, "UserPromptSubmit", cachePath, options); @@ -28,13 +37,23 @@ export async function runPostToolUseHook(input, options = {}) { const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); const engine = createRulesEngine(options); hydrateEngineState(engine, cachePath); - const loaded = engine.loadDynamicRules(input.cwd, targetPaths); + const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config); + const pendingTargetFingerprints = dynamicTargetFingerprints.filter((target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint); + if (pendingTargetFingerprints.length === 0) { + persistEngineState(engine, cachePath); + return ""; + } + const loaded = engine.loadDynamicRules(input.cwd, pendingTargetFingerprints.map((target) => target.targetPath)); const rules = loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)); + for (const target of pendingTargetFingerprints) { + engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint); + } if (rules.length === 0) { persistEngineState(engine, cachePath); return ""; } - const block = engine.formatDynamic(rules, displayPath(input.cwd, firstTargetPath)); + const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath; + const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath)); for (const rule of rules) { engine.markDynamicInjected(rule); } @@ -77,6 +96,87 @@ function createRulesEngine(options) { }, }); } +function fingerprintDynamicTargets(cwd, targetPaths, config) { + const disabledSources = disabledSourcesFor(config); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = findProjectRoot(cwd); + const fingerprints = []; + for (const targetPath of uniqueStrings(targetPaths)) { + const projectRoot = cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot) + ? cwdProjectRoot + : findProjectRoot(targetPath); + const findOptions = { + projectRoot, + targetFile: targetPath, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = findRuleCandidates(findOptions); + const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001"); + const cacheKey = dynamicTargetCacheKey(targetPath); + fingerprints.push({ + targetPath, + cacheKey, + fingerprint: hashContent([ + "v1", + config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","), + projectRoot ?? "", + cacheKey, + candidateFingerprint, + ].join("\u0000")), + }); + } + return fingerprints; +} +function fingerprintCandidate(candidate) { + return [ + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + fileFingerprint(candidate.path), + ].join("\u0000"); +} +function fileFingerprint(filePath) { + try { + const stats = statSync(filePath, { bigint: true }); + const contentHash = hashContent(readFileSync(filePath, "utf8")); + return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}:${contentHash}`; + } + catch { + return "missing"; + } +} +function disabledSourcesFor(config) { + if (config.enabledSources === "auto") { + return undefined; + } + const enabledSources = new Set(config.enabledSources); + return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); +} +function dynamicTargetCacheKey(targetPath) { + return toPosixPath(resolve(targetPath)); +} +function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} +function uniqueStrings(values) { + const uniqueValues = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} function formatAdditionalContextOutput(eventName, additionalContext) { if (additionalContext.trim().length === 0) return ""; @@ -88,6 +188,12 @@ function formatAdditionalContextOutput(eventName, additionalContext) { })}\n`; } function displayPath(cwd, filePath) { - return isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + // Normalize to POSIX separators so injected rule context renders the same + // path string on Linux/macOS and Windows (Codex feeds this verbatim into + // the model prompt, and the existing engine already emits POSIX paths). + return toPosixPath(rel); +} +function toPosixPath(path) { + return path.replaceAll("\\", "/"); } -//# sourceMappingURL=codex-hook.js.map \ No newline at end of file diff --git a/dist/codex-hook.js.map b/dist/codex-hook.js.map deleted file mode 100644 index 655e4e7..0000000 --- a/dist/codex-hook.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"codex-hook.js","sourceRoot":"","sources":["../src/codex-hook.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/config.d.ts b/dist/config.d.ts index 9ca38ef..ef07d08 100644 --- a/dist/config.d.ts +++ b/dist/config.d.ts @@ -1,3 +1,2 @@ import type { PiRulesConfig } from "./rules/types.js"; export declare function configFromEnvironment(env?: NodeJS.ProcessEnv): PiRulesConfig; -//# sourceMappingURL=config.d.ts.map \ No newline at end of file diff --git a/dist/config.d.ts.map b/dist/config.d.ts.map deleted file mode 100644 index e24c711..0000000 --- a/dist/config.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,kBAAkB,CAAC;AAIlE,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,aAAa,CAczF"} \ No newline at end of file diff --git a/dist/config.js b/dist/config.js index c5aae24..61163c7 100644 --- a/dist/config.js +++ b/dist/config.js @@ -55,4 +55,3 @@ function parseEnabledSources(value) { } return sources.length > 0 ? sources : "auto"; } -//# sourceMappingURL=config.js.map \ No newline at end of file diff --git a/dist/config.js.map b/dist/config.js.map deleted file mode 100644 index 70ebfb8..0000000 --- a/dist/config.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/persistent-cache.d.ts b/dist/persistent-cache.d.ts index 61ab7d3..2337103 100644 --- a/dist/persistent-cache.d.ts +++ b/dist/persistent-cache.d.ts @@ -3,4 +3,3 @@ export declare function hydrateEngineState(engine: Engine, cachePath: string): v export declare function persistEngineState(engine: Engine, cachePath: string): void; export declare function clearSessionState(cachePath: string): void; export declare function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string; -//# sourceMappingURL=persistent-cache.d.ts.map \ No newline at end of file diff --git a/dist/persistent-cache.d.ts.map b/dist/persistent-cache.d.ts.map deleted file mode 100644 index 6104227..0000000 --- a/dist/persistent-cache.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"persistent-cache.d.ts","sourceRoot":"","sources":["../src/persistent-cache.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAOhD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAW1E;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAU1E;AAED,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAG9F"} \ No newline at end of file diff --git a/dist/persistent-cache.js b/dist/persistent-cache.js index f08c082..b16823f 100644 --- a/dist/persistent-cache.js +++ b/dist/persistent-cache.js @@ -5,12 +5,16 @@ export function hydrateEngineState(engine, cachePath) { const state = readSessionState(cachePath); engine.state.staticDedup.clear(); engine.state.dynamicDedup.clear(); + engine.state.dynamicTargetFingerprints.clear(); for (const key of state.staticDedup) { engine.state.staticDedup.add(key); } for (const [scope, keys] of Object.entries(state.dynamicDedup)) { engine.state.dynamicDedup.set(scope, new Set(keys)); } + for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) { + engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint); + } } export function persistEngineState(engine, cachePath) { const dynamicDedup = {}; @@ -20,13 +24,14 @@ export function persistEngineState(engine, cachePath) { writeSessionState(cachePath, { staticDedup: [...engine.state.staticDedup], dynamicDedup, + dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()), }); } export function clearSessionState(cachePath) { rmSync(cachePath, { force: true }); } export function sessionCachePath(sessionId, pluginDataRoot) { - const root = pluginDataRoot ?? process.env.PLUGIN_DATA ?? join(homedir(), ".codex", "codex-rules"); + const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules"); return join(root, "sessions", `${safePathSegment(sessionId)}.json`); } function readSessionState(cachePath) { @@ -45,19 +50,24 @@ function writeSessionState(cachePath, state) { writeFileSync(cachePath, `${JSON.stringify(state)}\n`); } function emptyState() { - return { staticDedup: [], dynamicDedup: {} }; + return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} }; } function safePathSegment(value) { return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session"; } function isSerializedSessionState(value) { - if (!isRecord(value) || !Array.isArray(value.staticDedup) || !isRecord(value.dynamicDedup)) { + if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) { return false; } - return (value.staticDedup.every((item) => typeof item === "string") && - Object.values(value.dynamicDedup).every((item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string"))); + const staticDedup = value["staticDedup"]; + const dynamicDedup = value["dynamicDedup"]; + const dynamicTargetFingerprints = value["dynamicTargetFingerprints"]; + return (staticDedup.every((item) => typeof item === "string") && + Object.values(dynamicDedup).every((item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string")) && + (dynamicTargetFingerprints === undefined || + (isRecord(dynamicTargetFingerprints) && + Object.entries(dynamicTargetFingerprints).every(([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string")))); } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -//# sourceMappingURL=persistent-cache.js.map \ No newline at end of file diff --git a/dist/persistent-cache.js.map b/dist/persistent-cache.js.map deleted file mode 100644 index ac87ac1..0000000 --- a/dist/persistent-cache.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"persistent-cache.js","sourceRoot":"","sources":["../src/persistent-cache.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/rules/cache.d.ts b/dist/rules/cache.d.ts index be40aa8..03c922e 100644 --- a/dist/rules/cache.d.ts +++ b/dist/rules/cache.d.ts @@ -7,4 +7,3 @@ export declare function markDynamicInjected(state: SessionState, rule: LoadedRul export declare function isStaticInjected(state: SessionState, rule: LoadedRule): boolean; export declare function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean; export declare function clearSession(state: SessionState): void; -//# sourceMappingURL=cache.d.ts.map \ No newline at end of file diff --git a/dist/rules/cache.d.ts.map b/dist/rules/cache.d.ts.map deleted file mode 100644 index 9357d1c..0000000 --- a/dist/rules/cache.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/rules/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAI3D,wBAAgB,kBAAkB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,YAAY,CAE7D;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAEzF;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAQjF;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAclF;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAE/E;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAEhF;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAKtD"} \ No newline at end of file diff --git a/dist/rules/cache.js b/dist/rules/cache.js index 2b500fe..459b78c 100644 --- a/dist/rules/cache.js +++ b/dist/rules/cache.js @@ -1,6 +1,13 @@ const DYNAMIC_SESSION_KEY = "__pi-rules-session__"; export function createSessionState(cwd) { - return { cwd, staticDedup: new Set(), dynamicDedup: new Map(), loadedRules: [], diagnostics: [] }; + return { + cwd, + staticDedup: new Set(), + dynamicDedup: new Map(), + dynamicTargetFingerprints: new Map(), + loadedRules: [], + diagnostics: [], + }; } export function staticDedupKey(cwd, rulePath, contentHash) { return `${cwd}::${rulePath}::${contentHash}`; @@ -38,7 +45,7 @@ export function isDynamicInjected(state, rule) { export function clearSession(state) { state.staticDedup.clear(); state.dynamicDedup.clear(); + state.dynamicTargetFingerprints.clear(); state.loadedRules.length = 0; state.diagnostics.length = 0; } -//# sourceMappingURL=cache.js.map \ No newline at end of file diff --git a/dist/rules/cache.js.map b/dist/rules/cache.js.map deleted file mode 100644 index 81a3781..0000000 --- a/dist/rules/cache.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/rules/cache.ts"],"names":[],"mappings":"AAEA,MAAM,mBAAmB,GAAG,sBAAsB,CAAC;AAEnD,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC9C,OAAO,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,GAAG,EAAE,EAAE,YAAY,EAAE,IAAI,GAAG,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC;AACnG,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,GAAW,EAAE,QAAgB,EAAE,WAAmB;IAChF,OAAO,GAAG,GAAG,KAAK,QAAQ,KAAK,WAAW,EAAE,CAAC;AAC9C,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,QAAgB,EAAE,WAAmB;IACpE,OAAO,GAAG,QAAQ,KAAK,WAAW,EAAE,CAAC;AACtC,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,KAAmB,EAAE,IAAgB;IACvE,MAAM,GAAG,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG,IAAI,EAAE,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;IAC7E,IAAI,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QAChC,OAAO,KAAK,CAAC;IACd,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC3B,OAAO,IAAI,CAAC;AACb,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,KAAmB,EAAE,IAAgB;IACxE,IAAI,IAAI,GAAG,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC;IACvD,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACxB,IAAI,GAAG,IAAI,GAAG,EAAE,CAAC;QACjB,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,CAAC,CAAC;IACnD,CAAC;IAED,MAAM,GAAG,GAAG,eAAe,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;IAC7D,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QACnB,OAAO,KAAK,CAAC;IACd,CAAC;IAED,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACd,OAAO,IAAI,CAAC;AACb,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAmB,EAAE,IAAgB;IACrE,OAAO,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,cAAc,CAAC,KAAK,CAAC,GAAG,IAAI,EAAE,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC;AAChG,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,KAAmB,EAAE,IAAgB;IACtE,OAAO,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,mBAAmB,CAAC,EAAE,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC,KAAK,IAAI,CAAC;AACpH,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAmB;IAC/C,KAAK,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;IAC1B,KAAK,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC;IAC3B,KAAK,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC;IAC7B,KAAK,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9B,CAAC"} \ No newline at end of file diff --git a/dist/rules/constants.d.ts b/dist/rules/constants.d.ts index 5886ded..fc1de66 100644 --- a/dist/rules/constants.d.ts +++ b/dist/rules/constants.d.ts @@ -37,6 +37,7 @@ export declare const GLOBAL_DISTANCE = 9999; * Per-rule body character cap (default). */ export declare const DEFAULT_MAX_RULE_CHARS = 12000; +export declare const DEFAULT_MAX_SCAN_FILES = 1000; /** * Total injected chars per tool result (default). */ @@ -45,13 +46,7 @@ export declare const DEFAULT_MAX_RESULT_CHARS = 40000; * Truncation marker template. `{path}` is replaced with the relative path. */ export declare const TRUNCATION_NOTICE = "\n\n[Rule truncated. Read full rule: {path}]"; -/** - * Built-in tool names whose results trigger dynamic rule injection. - */ -export declare const TRACKED_BUILTIN_TOOLS: readonly string[]; -export declare const TRACKED_BUILTIN_TOOL_SET: ReadonlySet; /** * Directories excluded by the recursive scanner regardless of glob settings. */ export declare const SCANNER_EXCLUDED_DIRS: readonly string[]; -//# sourceMappingURL=constants.d.ts.map \ No newline at end of file diff --git a/dist/rules/constants.d.ts.map b/dist/rules/constants.d.ts.map deleted file mode 100644 index aec4f2c..0000000 --- a/dist/rules/constants.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/rules/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAQ5C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAKzE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,oBAAoB,EAAE,SAAS,MAAM,EAKjD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAA4D,CAAC;AAEjH;;GAEG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAAwD,CAAC;AAE7G;;GAEG;AACH,eAAO,MAAM,oBAAoB,EAAE,SAAS,MAAM,EAAoB,CAAC;AAEvE;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,UAAU,EAAE,MAAM,CAc1D,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC;;GAEG;AACH,eAAO,MAAM,sBAAsB,QAAQ,CAAC;AAE5C;;GAEG;AACH,eAAO,MAAM,wBAAwB,QAAQ,CAAC;AAE9C;;GAEG;AACH,eAAO,MAAM,iBAAiB,iDAAiD,CAAC;AAEhF;;GAEG;AACH,eAAO,MAAM,qBAAqB,EAAE,SAAS,MAAM,EAA8B,CAAC;AAClF,eAAO,MAAM,wBAAwB,EAAE,WAAW,CAAC,MAAM,CAAkC,CAAC;AAE5F;;GAEG;AACH,eAAO,MAAM,qBAAqB,EAAE,SAAS,MAAM,EAQlD,CAAC"} \ No newline at end of file diff --git a/dist/rules/constants.js b/dist/rules/constants.js index 34d264c..1019e06 100644 --- a/dist/rules/constants.js +++ b/dist/rules/constants.js @@ -68,6 +68,7 @@ export const GLOBAL_DISTANCE = 9999; * Per-rule body character cap (default). */ export const DEFAULT_MAX_RULE_CHARS = 12000; +export const DEFAULT_MAX_SCAN_FILES = 1000; /** * Total injected chars per tool result (default). */ @@ -76,11 +77,6 @@ export const DEFAULT_MAX_RESULT_CHARS = 40000; * Truncation marker template. `{path}` is replaced with the relative path. */ export const TRUNCATION_NOTICE = "\n\n[Rule truncated. Read full rule: {path}]"; -/** - * Built-in tool names whose results trigger dynamic rule injection. - */ -export const TRACKED_BUILTIN_TOOLS = ["read", "edit", "write"]; -export const TRACKED_BUILTIN_TOOL_SET = new Set(TRACKED_BUILTIN_TOOLS); /** * Directories excluded by the recursive scanner regardless of glob settings. */ @@ -93,4 +89,3 @@ export const SCANNER_EXCLUDED_DIRS = [ ".next", "coverage", ]; -//# sourceMappingURL=constants.js.map \ No newline at end of file diff --git a/dist/rules/constants.js.map b/dist/rules/constants.js.map deleted file mode 100644 index 93a74b2..0000000 --- a/dist/rules/constants.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/rules/constants.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAsB;IACjD,MAAM;IACN,qBAAqB;IACrB,cAAc;IACd,gBAAgB;IAChB,YAAY;IACZ,QAAQ;IACR,OAAO;CACP,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAA6C;IAC7E,CAAC,WAAW,EAAE,OAAO,CAAC;IACtB,CAAC,SAAS,EAAE,OAAO,CAAC;IACpB,CAAC,SAAS,EAAE,OAAO,CAAC;IACpB,CAAC,SAAS,EAAE,cAAc,CAAC;CAC3B,CAAC;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAsB;IACtD,iCAAiC;IACjC,WAAW;IACX,WAAW;IACX,YAAY;CACZ,CAAC;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAsB,CAAC,iBAAiB,EAAE,iBAAiB,EAAE,eAAe,CAAC,CAAC;AAEjH;;GAEG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAsB,CAAC,4BAA4B,EAAE,mBAAmB,CAAC,CAAC;AAE7G;;GAEG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAsB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAEvE;;GAEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAoC,IAAI,GAAG,CAAC;IACvE,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACtB,CAAC,eAAe,EAAE,CAAC,CAAC;IACpB,CAAC,eAAe,EAAE,CAAC,CAAC;IACpB,CAAC,sBAAsB,EAAE,CAAC,CAAC;IAC3B,CAAC,iCAAiC,EAAE,CAAC,CAAC;IACtC,CAAC,WAAW,EAAE,CAAC,CAAC;IAChB,CAAC,WAAW,EAAE,CAAC,CAAC;IAChB,CAAC,YAAY,EAAE,CAAC,CAAC;IACjB,CAAC,mBAAmB,EAAE,GAAG,CAAC;IAC1B,CAAC,mBAAmB,EAAE,GAAG,CAAC;IAC1B,CAAC,iBAAiB,EAAE,GAAG,CAAC;IACxB,CAAC,8BAA8B,EAAE,GAAG,CAAC;IACrC,CAAC,qBAAqB,EAAE,GAAG,CAAC;CAC5B,CAAC,CAAC;AAEH;;GAEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,IAAI,CAAC;AAEpC;;GAEG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,KAAK,CAAC;AAE5C;;GAEG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,KAAK,CAAC;AAE9C;;GAEG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,8CAA8C,CAAC;AAEhF;;GAEG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AAClF,MAAM,CAAC,MAAM,wBAAwB,GAAwB,IAAI,GAAG,CAAC,qBAAqB,CAAC,CAAC;AAE5F;;GAEG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAsB;IACvD,cAAc;IACd,MAAM;IACN,MAAM;IACN,OAAO;IACP,QAAQ;IACR,OAAO;IACP,UAAU;CACV,CAAC"} \ No newline at end of file diff --git a/dist/rules/engine.d.ts b/dist/rules/engine.d.ts index 878bc3f..a2ba7c0 100644 --- a/dist/rules/engine.d.ts +++ b/dist/rules/engine.d.ts @@ -1,3 +1,5 @@ +import { type RuleDiscoveryCache } from "./finder.js"; +import { matchRule } from "./matcher.js"; import type { LoadedRule, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js"; export interface EngineDeps { findCandidates: (options: { @@ -6,9 +8,11 @@ export interface EngineDeps { homeDir?: string; disabledSources?: ReadonlySet; skipUserHome?: boolean; + cache?: RuleDiscoveryCache; }) => RuleCandidate[]; readFile: (path: string) => string | null; findProjectRoot: (startPath: string) => string | null; + matchRule?: typeof matchRule; } export interface Engine { state: SessionState; @@ -31,4 +35,3 @@ export interface Engine { } export declare function defaultConfig(): PiRulesConfig; export declare function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine; -//# sourceMappingURL=engine.d.ts.map \ No newline at end of file diff --git a/dist/rules/engine.d.ts.map b/dist/rules/engine.d.ts.map deleted file mode 100644 index 4c82e41..0000000 --- a/dist/rules/engine.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/rules/engine.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,UAAU,EAAe,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEtH,MAAM,WAAW,UAAU;IAC1B,cAAc,EAAE,CAAC,OAAO,EAAE;QACzB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;QAC3B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;QAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;QACtC,YAAY,CAAC,EAAE,OAAO,CAAC;KACvB,KAAK,aAAa,EAAE,CAAC;IACtB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAC1C,eAAe,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACtD;AAED,MAAM,WAAW,MAAM;IACtB,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,aAAa,CAAC;IACtB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,UAAU,EAAE,CAAC;QAAC,WAAW,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC;IACrF,gBAAgB,CACf,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,GAChC;QAAE,KAAK,EAAE,UAAU,EAAE,CAAC;QAAC,WAAW,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC;IAC1D,YAAY,CAAC,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,MAAM,CAAC;IACvD,aAAa,CAAC,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxE,YAAY,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,gBAAgB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC;IAC5C,iBAAiB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC;IAC7C,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC;IAC9C,mBAAmB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC;CAC/C;AAID,wBAAgB,aAAa,IAAI,aAAa,CAQ7C;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA4F5E"} \ No newline at end of file diff --git a/dist/rules/engine.js b/dist/rules/engine.js index 079d3be..463db4f 100644 --- a/dist/rules/engine.js +++ b/dist/rules/engine.js @@ -2,10 +2,12 @@ import { realpathSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { clearSession, createSessionState, isDynamicInjected as isDynamicInjectedInState, isStaticInjected as isStaticInjectedInState, markDynamicInjected as markDynamicInjectedInState, markStaticInjected as markStaticInjectedInState, } from "./cache.js"; import { DEFAULT_MAX_RESULT_CHARS, DEFAULT_MAX_RULE_CHARS, PROJECT_SINGLE_FILES, SOURCE_PRIORITY, } from "./constants.js"; +import { createRuleDiscoveryCache } from "./finder.js"; import { formatDynamicBlock, formatStaticBlock } from "./formatter.js"; import { hashContent, matchRule } from "./matcher.js"; import { sortCandidates } from "./ordering.js"; import { parseRule } from "./parser.js"; +const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096; const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/"))); export function defaultConfig() { return { @@ -18,17 +20,22 @@ export function defaultConfig() { } export function createEngine(config, deps) { const state = createSessionState(); + const dynamicMatchCache = new Map(); function loadStaticRules(cwd) { state.cwd = cwd; if (config.disabled || config.mode === "off" || config.mode === "dynamic") { return emptyLoadResult(state); } const projectRoot = deps.findProjectRoot(cwd); - const candidates = deps.findCandidates({ + const findOptions = { projectRoot, targetFile: null, - disabledSources: disabledSourcesFor(config), - }); + }; + const disabledSources = disabledSourcesFor(config); + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = deps.findCandidates(findOptions); const result = loadStaticCandidates(candidates, deps, projectRoot); storeLastLoad(state, result.rules, result.diagnostics); return result; @@ -41,21 +48,31 @@ export function createEngine(config, deps) { const rules = []; const diagnostics = []; const seenRules = new Set(); + const loadedRuleContent = new Map(); + const projectMembership = new Map(); const disabledSources = disabledSourcesFor(config); - for (const targetFile of targetPaths) { - const projectRoot = deps.findProjectRoot(targetFile); - const candidates = deps.findCandidates({ projectRoot, targetFile, disabledSources }); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = deps.findProjectRoot(cwd); + for (const targetFile of uniqueStrings(targetPaths)) { + const projectRoot = cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot) + ? cwdProjectRoot + : deps.findProjectRoot(targetFile); + const findOptions = { + projectRoot, + targetFile, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = deps.findCandidates(findOptions); for (const candidate of sortCandidates(candidates)) { - const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot); + const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot, loadedRuleContent, projectMembership); if (loadedRule === null) { continue; } - const matchResult = matchRule({ - frontmatter: loadedRule.frontmatter, - isSingleFile: candidate.isSingleFile, - pathBases: pathBasesForTarget(projectRoot, targetFile, candidate), - }); - if (!matchResult.matched) { + const matchReason = matchDynamicRuleCached(dynamicMatchCache, projectRoot, targetFile, candidate, loadedRule, deps.matchRule ?? matchRule); + if (matchReason === null) { continue; } const dedupKey = ruleDedupKey(loadedRule); @@ -63,7 +80,7 @@ export function createEngine(config, deps) { continue; } seenRules.add(dedupKey); - rules.push({ ...loadedRule, matchReason: matchResult.reason }); + rules.push({ ...loadedRule, matchReason }); } } const sortedRules = sortCandidates(rules); @@ -82,6 +99,7 @@ export function createEngine(config, deps) { }), resetSession: (cwd) => { clearSession(state); + dynamicMatchCache.clear(); if (cwd !== undefined) { state.cwd = cwd; } @@ -92,6 +110,45 @@ export function createEngine(config, deps) { markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule), }; } +function matchDynamicRuleCached(cache, projectRoot, targetFile, candidate, loadedRule, matchRuleImpl) { + const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash); + if (cache.has(cacheKey)) { + const cachedReason = cache.get(cacheKey) ?? null; + cache.delete(cacheKey); + cache.set(cacheKey, cachedReason); + return cachedReason; + } + const matchResult = matchRuleImpl({ + frontmatter: loadedRule.frontmatter, + isSingleFile: candidate.isSingleFile, + pathBases: pathBasesForTarget(projectRoot, targetFile, candidate), + }); + const reason = matchResult.matched ? matchResult.reason : null; + setDynamicMatchCacheEntry(cache, cacheKey, reason); + return reason; +} +function setDynamicMatchCacheEntry(cache, cacheKey, reason) { + if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) { + const oldestCacheKey = cache.keys().next().value; + if (oldestCacheKey !== undefined) { + cache.delete(oldestCacheKey); + } + } + cache.set(cacheKey, reason); +} +function dynamicMatchCacheKey(projectRoot, targetFile, candidate, contentHash) { + return [ + projectRoot ?? "", + toPosixPath(resolve(targetFile)), + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + contentHash, + ].join("\0"); +} function loadStaticCandidates(candidates, deps, projectRoot) { const rules = []; const diagnostics = []; @@ -115,8 +172,8 @@ function loadStaticCandidates(candidates, deps, projectRoot) { } return { rules: sortCandidates(rules), diagnostics }; } -function loadCandidate(candidate, deps, diagnostics, projectRoot) { - if (!isCandidateWithinProject(candidate, projectRoot)) { +function loadCandidate(candidate, deps, diagnostics, projectRoot, loadedRuleContent, projectMembership) { + if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) { diagnostics.push({ severity: "warning", source: candidate.path, @@ -124,20 +181,39 @@ function loadCandidate(candidate, deps, diagnostics, projectRoot) { }); return null; } + const cachedContent = loadedRuleContent?.get(candidate.realPath); + if (cachedContent !== undefined) { + return loadedRuleFromContent(candidate, cachedContent, diagnostics); + } const content = deps.readFile(candidate.path); if (content === null) { + loadedRuleContent?.set(candidate.realPath, null); diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); return null; } const parsed = parseRule(content); - if (parsed.diagnostic !== undefined) { - diagnostics.push({ severity: "warning", source: candidate.path, message: parsed.diagnostic }); + const loadedContent = { + frontmatter: parsed.frontmatter, + body: parsed.body, + contentHash: hashContent(content), + ...(parsed.diagnostic === undefined ? {} : { diagnostic: parsed.diagnostic }), + }; + loadedRuleContent?.set(candidate.realPath, loadedContent); + return loadedRuleFromContent(candidate, loadedContent, diagnostics); +} +function loadedRuleFromContent(candidate, content, diagnostics) { + if (content === null) { + diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); + return null; + } + if (content.diagnostic !== undefined) { + diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic }); } return { ...candidate, - frontmatter: parsed.frontmatter, - body: parsed.body, - contentHash: hashContent(parsed.body), + frontmatter: content.frontmatter, + body: content.body, + contentHash: content.contentHash, matchReason: { kind: "no-match" }, }; } @@ -154,6 +230,19 @@ function isCandidateWithinProject(candidate, projectRoot) { const relativeRealPath = relative(realPathOrResolved(projectRoot), realPathOrResolved(candidate.realPath)); return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath)); } +function isCandidateWithinProjectCached(candidate, projectRoot, projectMembership) { + if (projectMembership === undefined) { + return isCandidateWithinProject(candidate, projectRoot); + } + const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`; + const cached = projectMembership.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const isWithinProject = isCandidateWithinProject(candidate, projectRoot); + projectMembership.set(cacheKey, isWithinProject); + return isWithinProject; +} function realPathOrResolved(path) { try { return realpathSync.native(path); @@ -162,6 +251,10 @@ function realPathOrResolved(path) { return resolve(path); } } +function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} function staticMatchReason(rule) { if (rule.frontmatter.alwaysApply === true) { return "alwaysApply"; @@ -227,4 +320,15 @@ function emptyLoadResult(state) { storeLastLoad(state, [], []); return { rules: [], diagnostics: [] }; } -//# sourceMappingURL=engine.js.map \ No newline at end of file +function uniqueStrings(values) { + const uniqueValues = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/dist/rules/engine.js.map b/dist/rules/engine.js.map deleted file mode 100644 index 6b49443..0000000 --- a/dist/rules/engine.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/rules/engine.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/rules/errors.d.ts b/dist/rules/errors.d.ts index 6aa74d3..cc98d46 100644 --- a/dist/rules/errors.d.ts +++ b/dist/rules/errors.d.ts @@ -4,4 +4,3 @@ export declare class UnsupportedRuleSourceError extends Error { export declare class RuleFrontmatterParseError extends Error { constructor(message: string); } -//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/dist/rules/errors.d.ts.map b/dist/rules/errors.d.ts.map deleted file mode 100644 index 5d7d1e3..0000000 --- a/dist/rules/errors.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/rules/errors.ts"],"names":[],"mappings":"AAAA,qBAAa,0BAA2B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI3B;AAED,qBAAa,yBAA0B,SAAQ,KAAK;gBACvC,OAAO,EAAE,MAAM;CAI3B"} \ No newline at end of file diff --git a/dist/rules/errors.js b/dist/rules/errors.js index 4896939..0fb83e8 100644 --- a/dist/rules/errors.js +++ b/dist/rules/errors.js @@ -10,4 +10,3 @@ export class RuleFrontmatterParseError extends Error { this.name = "RuleFrontmatterParseError"; } } -//# sourceMappingURL=errors.js.map \ No newline at end of file diff --git a/dist/rules/errors.js.map b/dist/rules/errors.js.map deleted file mode 100644 index 6c33c54..0000000 --- a/dist/rules/errors.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/rules/errors.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,0BAA2B,SAAQ,KAAK;IACpD,YAAY,OAAe;QAC1B,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,4BAA4B,CAAC;IAC1C,CAAC;CACD;AAED,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IACnD,YAAY,OAAe;QAC1B,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;IACzC,CAAC;CACD"} \ No newline at end of file diff --git a/dist/rules/finder.d.ts b/dist/rules/finder.d.ts index d7b473d..ee2a5f6 100644 --- a/dist/rules/finder.d.ts +++ b/dist/rules/finder.d.ts @@ -1,4 +1,13 @@ +import { scanRuleFiles } from "./scanner.js"; import type { RuleCandidate } from "./types.js"; +interface SingleFileInfo { + path: string; + realPath: string; +} +export interface RuleDiscoveryCache { + scannedRuleFiles: Map>; + singleFileInfo: Map; +} export interface FinderOptions { /** Project root absolute path (use findProjectRoot to get this). */ projectRoot: string | null; @@ -10,6 +19,8 @@ export interface FinderOptions { disabledSources?: ReadonlySet; /** Whether to skip user-home rules. Default: false. */ skipUserHome?: boolean; + cache?: RuleDiscoveryCache; } +export declare function createRuleDiscoveryCache(): RuleDiscoveryCache; export declare function findRuleCandidates(options: FinderOptions): RuleCandidate[]; -//# sourceMappingURL=finder.d.ts.map \ No newline at end of file +export {}; diff --git a/dist/rules/finder.d.ts.map b/dist/rules/finder.d.ts.map deleted file mode 100644 index aa49bb2..0000000 --- a/dist/rules/finder.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"finder.d.ts","sourceRoot":"","sources":["../../src/rules/finder.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,YAAY,CAAC;AAE5D,MAAM,WAAW,aAAa;IAC7B,oEAAoE;IACpE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,wGAAwG;IACxG,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,uDAAuD;IACvD,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAOD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,aAAa,GAAG,aAAa,EAAE,CAmB1E"} \ No newline at end of file diff --git a/dist/rules/finder.js b/dist/rules/finder.js index a07bea5..f8637b0 100644 --- a/dist/rules/finder.js +++ b/dist/rules/finder.js @@ -4,6 +4,9 @@ import { dirname, join, posix, relative, resolve } from "node:path"; import { GLOBAL_DISTANCE, PROJECT_RULE_SUBDIRS, PROJECT_SINGLE_FILES, USER_HOME_RULE_SUBDIRS, USER_HOME_SINGLE_FILES, } from "./constants.js"; import { UnsupportedRuleSourceError } from "./errors.js"; import { scanRuleFiles } from "./scanner.js"; +export function createRuleDiscoveryCache() { + return { scannedRuleFiles: new Map(), singleFileInfo: new Map() }; +} export function findRuleCandidates(options) { const skipUserHome = options.skipUserHome ?? false; if (options.projectRoot === null && skipUserHome) { @@ -13,14 +16,14 @@ export function findRuleCandidates(options) { const candidates = []; const homeDirectory = resolve(options.homeDir ?? homedir()); if (options.projectRoot !== null) { - candidates.push(...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources)); + candidates.push(...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache)); } if (!skipUserHome) { - candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources)); + candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache)); } return candidates; } -function findProjectCandidates(projectRoot, targetFile, disabledSources) { +function findProjectCandidates(projectRoot, targetFile, disabledSources, cache) { const rootDirectory = resolve(projectRoot); const walkDirectories = getWalkDirectories(rootDirectory, targetFile); const candidates = []; @@ -31,10 +34,10 @@ function findProjectCandidates(projectRoot, targetFile, disabledSources) { continue; } const ruleDirectory = join(walkDirectory.directory, parentDirectory, subDirectory); - for (const scannedFile of scanRuleFiles({ rootDir: ruleDirectory })) { + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { candidates.push({ path: scannedFile.path, - realPath: resolveRealPath(scannedFile.path), + realPath: scannedFile.realPath, source, distance: targetFile === null ? 0 : walkDirectory.distance, isGlobal: false, @@ -51,12 +54,13 @@ function findProjectCandidates(projectRoot, targetFile, disabledSources) { continue; } const filePath = join(walkDirectory.directory, ruleFile); - if (!isFile(filePath)) { + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { continue; } candidates.push({ - path: filePath, - realPath: resolveRealPath(filePath), + path: fileInfo.path, + realPath: fileInfo.realPath, source, distance: targetFile === null ? 0 : walkDirectory.distance, isGlobal: false, @@ -67,7 +71,7 @@ function findProjectCandidates(projectRoot, targetFile, disabledSources) { } return candidates; } -function findUserHomeCandidates(homeDirectory, disabledSources) { +function findUserHomeCandidates(homeDirectory, disabledSources, cache) { const candidates = []; for (const ruleSubdir of USER_HOME_RULE_SUBDIRS) { const source = toUserHomeRuleSource(ruleSubdir); @@ -75,10 +79,10 @@ function findUserHomeCandidates(homeDirectory, disabledSources) { continue; } const ruleDirectory = join(homeDirectory, ruleSubdir); - for (const scannedFile of scanRuleFiles({ rootDir: ruleDirectory })) { + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { candidates.push({ path: scannedFile.path, - realPath: resolveRealPath(scannedFile.path), + realPath: scannedFile.realPath, source, distance: GLOBAL_DISTANCE, isGlobal: true, @@ -93,12 +97,13 @@ function findUserHomeCandidates(homeDirectory, disabledSources) { continue; } const filePath = join(homeDirectory, ruleFile); - if (!isFile(filePath)) { + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { continue; } candidates.push({ - path: filePath, - realPath: resolveRealPath(filePath), + path: fileInfo.path, + realPath: fileInfo.realPath, source, distance: GLOBAL_DISTANCE, isGlobal: true, @@ -108,6 +113,30 @@ function findUserHomeCandidates(homeDirectory, disabledSources) { } return candidates; } +function scanRuleFilesCached(rootDir, cache) { + if (cache === undefined) { + return scanRuleFiles({ rootDir }); + } + const cached = cache.scannedRuleFiles.get(rootDir); + if (cached !== undefined) { + return cached; + } + const scannedFiles = scanRuleFiles({ rootDir }); + cache.scannedRuleFiles.set(rootDir, scannedFiles); + return scannedFiles; +} +function singleFileInfoCached(filePath, cache) { + if (cache === undefined) { + return readSingleFileInfo(filePath); + } + const cached = cache.singleFileInfo.get(filePath); + if (cached !== undefined) { + return cached; + } + const fileInfo = readSingleFileInfo(filePath); + cache.singleFileInfo.set(filePath, fileInfo); + return fileInfo; +} function getWalkDirectories(projectRoot, targetFile) { if (targetFile === null) { return [{ directory: projectRoot, distance: 0 }]; @@ -137,15 +166,18 @@ function isSameOrChildPath(childPath, parentPath) { const childRelativePath = relative(parentPath, childPath); return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/")); } -function isFile(filePath) { +function readSingleFileInfo(filePath) { if (!existsSync(filePath)) { - return false; + return null; } try { - return statSync(filePath).isFile(); + if (!statSync(filePath).isFile()) { + return null; + } + return { path: filePath, realPath: resolveRealPath(filePath) }; } catch { - return false; + return null; } } function resolveRealPath(filePath) { @@ -203,4 +235,3 @@ function toUserHomeSingleFileSource(ruleFile) { throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`); } } -//# sourceMappingURL=finder.js.map \ No newline at end of file diff --git a/dist/rules/finder.js.map b/dist/rules/finder.js.map deleted file mode 100644 index aba8ccc..0000000 --- a/dist/rules/finder.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"finder.js","sourceRoot":"","sources":["../../src/rules/finder.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/rules/formatter.d.ts b/dist/rules/formatter.d.ts index d514539..b4bd384 100644 --- a/dist/rules/formatter.d.ts +++ b/dist/rules/formatter.d.ts @@ -5,4 +5,3 @@ export interface FormatOptions { } export declare function formatStaticBlock(rules: ReadonlyArray, options: FormatOptions): string; export declare function formatDynamicBlock(rules: ReadonlyArray, targetRelativePath: string, options: FormatOptions): string; -//# sourceMappingURL=formatter.d.ts.map \ No newline at end of file diff --git a/dist/rules/formatter.d.ts.map b/dist/rules/formatter.d.ts.map deleted file mode 100644 index 17f2b41..0000000 --- a/dist/rules/formatter.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"formatter.d.ts","sourceRoot":"","sources":["../../src/rules/formatter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;CACvB;AAyCD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,aAAa,GAAG,MAAM,CAMlG;AAED,wBAAgB,kBAAkB,CACjC,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,EAChC,kBAAkB,EAAE,MAAM,EAC1B,OAAO,EAAE,aAAa,GACpB,MAAM,CAQR"} \ No newline at end of file diff --git a/dist/rules/formatter.js b/dist/rules/formatter.js index 47ef5ed..e1d446f 100644 --- a/dist/rules/formatter.js +++ b/dist/rules/formatter.js @@ -41,4 +41,3 @@ export function formatDynamicBlock(rules, targetRelativePath, options) { .map(formatRule) .join("\n\n")}`; } -//# sourceMappingURL=formatter.js.map \ No newline at end of file diff --git a/dist/rules/formatter.js.map b/dist/rules/formatter.js.map deleted file mode 100644 index 34c3c59..0000000 --- a/dist/rules/formatter.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"formatter.js","sourceRoot":"","sources":["../../src/rules/formatter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAc9D,SAAS,UAAU,CAAC,IAAmB;IACtC,OAAO,sBAAsB,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,EAAE,CAAC;AACxD,CAAC;AAED,SAAS,aAAa,CAAC,KAAgC,EAAE,OAAsB;IAC9E,MAAM,gBAAgB,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAC7C,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,IAAI,EAAE,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,OAAO,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC,IAAI;KACvG,CAAC,CAAC,CAAC;IACJ,MAAM,aAAa,GAAG,cAAc,CAAC;QACpC,KAAK,EAAE,gBAAgB,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;QAC7F,cAAc,EAAE,OAAO,CAAC,cAAc;KACtC,CAAC,CAAC;IACH,MAAM,cAAc,GAAoB,EAAE,CAAC;IAE3C,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,aAAa,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC9D,MAAM,UAAU,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;QAC3C,MAAM,YAAY,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,UAAU,KAAK,SAAS,IAAI,YAAY,KAAK,SAAS,EAAE,CAAC;YAC5D,SAAS;QACV,CAAC;QAED,cAAc,CAAC,IAAI,CAAC;YACnB,IAAI,EAAE,UAAU,CAAC,IAAI;YACrB,YAAY,EAAE,YAAY,CAAC,YAAY;YACvC,IAAI,EAAE,YAAY,CAAC,IAAI;SACvB,CAAC,CAAC;IACJ,CAAC;IAED,OAAO,cAAc,CAAC;AACvB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,KAAgC,EAAE,OAAsB;IACzF,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,EAAE,CAAC;IACX,CAAC;IAED,OAAO,gCAAgC,aAAa,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;AACrG,CAAC;AAED,MAAM,UAAU,kBAAkB,CACjC,KAAgC,EAChC,kBAA0B,EAC1B,OAAsB;IAEtB,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,EAAE,CAAC;IACX,CAAC;IAED,OAAO,mDAAmD,kBAAkB,QAAQ,aAAa,CAAC,KAAK,EAAE,OAAO,CAAC;SAC/G,GAAG,CAAC,UAAU,CAAC;SACf,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;AAClB,CAAC"} \ No newline at end of file diff --git a/dist/rules/matcher.d.ts b/dist/rules/matcher.d.ts index 68bffb6..1aee611 100644 --- a/dist/rules/matcher.d.ts +++ b/dist/rules/matcher.d.ts @@ -16,4 +16,3 @@ export interface MatchResult { export declare function matchRule(input: MatcherInput): MatchResult; export declare function normalizeGlobs(frontmatter: RuleFrontmatter): string[]; export declare function hashContent(body: string): string; -//# sourceMappingURL=matcher.d.ts.map \ No newline at end of file diff --git a/dist/rules/matcher.d.ts.map b/dist/rules/matcher.d.ts.map deleted file mode 100644 index 0362008..0000000 --- a/dist/rules/matcher.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"matcher.d.ts","sourceRoot":"","sources":["../../src/rules/matcher.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE/D,MAAM,WAAW,YAAY;IAC5B,WAAW,EAAE,eAAe,CAAC;IAC7B,YAAY,EAAE,OAAO,CAAC;IACtB,6DAA6D;IAC7D,SAAS,EAAE;QAAE,eAAe,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACjF;AAED,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,WAAW,CAAC;CACpB;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,YAAY,GAAG,WAAW,CAyC1D;AAED,wBAAgB,cAAc,CAAC,WAAW,EAAE,eAAe,GAAG,MAAM,EAAE,CAQrE;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD"} \ No newline at end of file diff --git a/dist/rules/matcher.js b/dist/rules/matcher.js index 7c4ced7..7e2e2f6 100644 --- a/dist/rules/matcher.js +++ b/dist/rules/matcher.js @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import picomatch from "picomatch"; +const compiledPatternSets = new Map(); export function matchRule(input) { if (input.isSingleFile) { return { matched: true, reason: "single-file" }; @@ -11,16 +11,9 @@ export function matchRule(input) { if (patterns.length === 0) { return noMatch(); } - const pathBases = [ - normalizePath(input.pathBases.projectRelative), - input.pathBases.scopeRelative ? normalizePath(input.pathBases.scopeRelative) : undefined, - normalizePath(input.pathBases.basename), - ].filter((pathBase) => pathBase !== undefined); - const positivePatterns = patterns.filter((pattern) => !pattern.startsWith("!")); - const negativePatterns = patterns.filter((pattern) => pattern.startsWith("!")); - const negativeMatchers = negativePatterns.map((pattern) => picomatch(pattern.slice(1), { bash: true, dot: true })); - for (const pattern of positivePatterns) { - const isMatch = picomatch(pattern, { bash: true, dot: true }); + const pathBases = normalizedPathBases(input.pathBases); + const { positivePatterns, negativeMatchers } = compiledPatternSetFor(patterns); + for (const { pattern, isMatch } of positivePatterns) { for (const pathBase of pathBases) { if (!isMatch(pathBase)) { continue; @@ -53,6 +46,85 @@ function normalizePatternList(patterns) { function normalizePath(path) { return path.replaceAll("\\", "/"); } +function normalizedPathBases(pathBases) { + const normalizedBases = [normalizePath(pathBases.projectRelative)]; + if (pathBases.scopeRelative !== undefined) { + normalizedBases.push(normalizePath(pathBases.scopeRelative)); + } + normalizedBases.push(normalizePath(pathBases.basename)); + return normalizedBases; +} +function compiledPatternSetFor(patterns) { + const cacheKey = JSON.stringify(patterns); + const cached = compiledPatternSets.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const compiled = compilePatternSet(patterns); + compiledPatternSets.set(cacheKey, compiled); + return compiled; +} +function compilePatternSet(patterns) { + const positivePatterns = []; + const negativeMatchers = []; + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + negativeMatchers.push(createGlobMatcher(pattern.slice(1))); + continue; + } + positivePatterns.push({ pattern, isMatch: createGlobMatcher(pattern) }); + } + return { positivePatterns, negativeMatchers }; +} +function createGlobMatcher(pattern) { + const expression = globToRegExp(normalizePath(pattern)); + return (path) => expression.test(path); +} +function globToRegExp(pattern) { + let source = "^"; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + const nextChar = pattern[index + 1]; + if (char === "*" && nextChar === "*") { + const afterGlobStar = pattern[index + 2]; + if (afterGlobStar === "/") { + source += "(?:.*/)?"; + index += 2; + } + else { + source += ".*"; + index += 1; + } + continue; + } + if (char === "*") { + source += "[^/]*"; + continue; + } + if (char === "?") { + source += "[^/]"; + continue; + } + if (char === "{") { + const closeIndex = pattern.indexOf("}", index + 1); + if (closeIndex !== -1) { + const alternatives = pattern + .slice(index + 1, closeIndex) + .split(",") + .map(escapeRegExp) + .join("|"); + source += `(?:${alternatives})`; + index = closeIndex; + continue; + } + } + source += escapeRegExp(char ?? ""); + } + return new RegExp(`${source}$`); +} +function escapeRegExp(value) { + return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&"); +} function isExcluded(pathBase, negativeMatchers) { for (const isMatch of negativeMatchers) { if (isMatch(pathBase)) { @@ -64,4 +136,3 @@ function isExcluded(pathBase, negativeMatchers) { function noMatch() { return { matched: false, reason: { kind: "no-match" } }; } -//# sourceMappingURL=matcher.js.map \ No newline at end of file diff --git a/dist/rules/matcher.js.map b/dist/rules/matcher.js.map deleted file mode 100644 index c0d1c50..0000000 --- a/dist/rules/matcher.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"matcher.js","sourceRoot":"","sources":["../../src/rules/matcher.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/rules/ordering.d.ts b/dist/rules/ordering.d.ts index 2d42759..f30d972 100644 --- a/dist/rules/ordering.d.ts +++ b/dist/rules/ordering.d.ts @@ -1,4 +1,3 @@ import type { RuleCandidate } from "./types.js"; export declare function sortCandidates(candidates: ReadonlyArray): T[]; export declare function compareCandidates(a: RuleCandidate, b: RuleCandidate): number; -//# sourceMappingURL=ordering.d.ts.map \ No newline at end of file diff --git a/dist/rules/ordering.d.ts.map b/dist/rules/ordering.d.ts.map deleted file mode 100644 index e2d51e9..0000000 --- a/dist/rules/ordering.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ordering.d.ts","sourceRoot":"","sources":["../../src/rules/ordering.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,cAAc,CAAC,CAAC,SAAS,aAAa,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAKzF;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,GAAG,MAAM,CAQ5E"} \ No newline at end of file diff --git a/dist/rules/ordering.js b/dist/rules/ordering.js index d4e91ef..5308e48 100644 --- a/dist/rules/ordering.js +++ b/dist/rules/ordering.js @@ -25,4 +25,3 @@ function compareString(a, b) { return 1; return 0; } -//# sourceMappingURL=ordering.js.map \ No newline at end of file diff --git a/dist/rules/ordering.js.map b/dist/rules/ordering.js.map deleted file mode 100644 index a3b617e..0000000 --- a/dist/rules/ordering.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ordering.js","sourceRoot":"","sources":["../../src/rules/ordering.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAGjD,MAAM,UAAU,cAAc,CAA0B,UAA4B;IACnF,OAAO,UAAU;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;SACjD,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,iBAAiB,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;SACrG,GAAG,CAAC,CAAC,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC;AACrC,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,CAAgB,EAAE,CAAgB;IACnE,OAAO,CACN,cAAc,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,QAAQ,CAAC;QACtC,aAAa,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,QAAQ,CAAC;QACrC,aAAa,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,QAAQ,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,QAAQ,CAAC;QACnG,aAAa,CAAC,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,YAAY,CAAC;QAC7C,aAAa,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,QAAQ,CAAC,CACrC,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,CAAU,EAAE,CAAU;IAC7C,OAAO,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;AAC9B,CAAC;AAED,SAAS,aAAa,CAAC,CAAS,EAAE,CAAS;IAC1C,OAAO,CAAC,GAAG,CAAC,CAAC;AACd,CAAC;AAED,SAAS,aAAa,CAAC,CAAS,EAAE,CAAS;IAC1C,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC,CAAC;IACrB,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC;IACpB,OAAO,CAAC,CAAC;AACV,CAAC"} \ No newline at end of file diff --git a/dist/rules/parser.d.ts b/dist/rules/parser.d.ts index 8096ce0..cb945a5 100644 --- a/dist/rules/parser.d.ts +++ b/dist/rules/parser.d.ts @@ -1,4 +1,3 @@ import type { ParsedRule } from "./types.js"; /** Parse markdown rule content and extract the supported YAML frontmatter subset. */ export declare function parseRule(content: string): ParsedRule; -//# sourceMappingURL=parser.d.ts.map \ No newline at end of file diff --git a/dist/rules/parser.d.ts.map b/dist/rules/parser.d.ts.map deleted file mode 100644 index b3b1a89..0000000 --- a/dist/rules/parser.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/rules/parser.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAmB,MAAM,YAAY,CAAC;AAK9D,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CA6BrD"} \ No newline at end of file diff --git a/dist/rules/parser.js b/dist/rules/parser.js index 1ef5b27..3e2e3fe 100644 --- a/dist/rules/parser.js +++ b/dist/rules/parser.js @@ -99,8 +99,9 @@ function parseYamlFrontmatter(yamlContent) { } lineIndex += 1; } - if (globValues.length === 1) { - frontmatter.globs = globValues[0]; + const singleGlob = globValues[0]; + if (globValues.length === 1 && singleGlob !== undefined) { + frontmatter.globs = singleGlob; } else if (globValues.length > 1) { frontmatter.globs = globValues; @@ -285,4 +286,3 @@ function stripComment(line) { } return line; } -//# sourceMappingURL=parser.js.map \ No newline at end of file diff --git a/dist/rules/parser.js.map b/dist/rules/parser.js.map deleted file mode 100644 index 5ca93f9..0000000 --- a/dist/rules/parser.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"parser.js","sourceRoot":"","sources":["../../src/rules/parser.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/rules/project-root.d.ts b/dist/rules/project-root.d.ts index 51fc133..079a718 100644 --- a/dist/rules/project-root.d.ts +++ b/dist/rules/project-root.d.ts @@ -1,2 +1 @@ export declare function findProjectRoot(startPath: string, markers?: ReadonlyArray): string | null; -//# sourceMappingURL=project-root.d.ts.map \ No newline at end of file diff --git a/dist/rules/project-root.d.ts.map b/dist/rules/project-root.d.ts.map deleted file mode 100644 index 3621faa..0000000 --- a/dist/rules/project-root.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"project-root.d.ts","sourceRoot":"","sources":["../../src/rules/project-root.ts"],"names":[],"mappings":"AAKA,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,aAAa,CAAC,MAAM,CAAmB,GAAG,MAAM,GAAG,IAAI,CAwBlH"} \ No newline at end of file diff --git a/dist/rules/project-root.js b/dist/rules/project-root.js index 1a65817..d9b4b5d 100644 --- a/dist/rules/project-root.js +++ b/dist/rules/project-root.js @@ -21,4 +21,3 @@ export function findProjectRoot(startPath, markers = PROJECT_MARKERS) { currentDirectory = dirname(currentDirectory); } } -//# sourceMappingURL=project-root.js.map \ No newline at end of file diff --git a/dist/rules/project-root.js.map b/dist/rules/project-root.js.map deleted file mode 100644 index 039df98..0000000 --- a/dist/rules/project-root.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"project-root.js","sourceRoot":"","sources":["../../src/rules/project-root.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEnD,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,UAAU,eAAe,CAAC,SAAiB,EAAE,UAAiC,eAAe;IAClG,MAAM,iBAAiB,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IAE7C,IAAI,CAAC,UAAU,CAAC,iBAAiB,CAAC,EAAE,CAAC;QACpC,OAAO,IAAI,CAAC;IACb,CAAC;IAED,MAAM,UAAU,GAAG,QAAQ,CAAC,iBAAiB,CAAC,CAAC;IAC/C,IAAI,gBAAgB,GAAG,UAAU,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACjG,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAEpC,OAAO,IAAI,EAAE,CAAC;QACb,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC9B,IAAI,UAAU,CAAC,IAAI,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;gBAChD,OAAO,gBAAgB,CAAC;YACzB,CAAC;QACF,CAAC;QAED,IAAI,gBAAgB,KAAK,cAAc,EAAE,CAAC;YACzC,OAAO,IAAI,CAAC;QACb,CAAC;QAED,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC9C,CAAC;AACF,CAAC"} \ No newline at end of file diff --git a/dist/rules/scanner.d.ts b/dist/rules/scanner.d.ts index 70bc265..7a66748 100644 --- a/dist/rules/scanner.d.ts +++ b/dist/rules/scanner.d.ts @@ -3,6 +3,7 @@ export interface ScanOptions { excludedDirs?: ReadonlyArray; /** Maximum recursion depth. Default: 10 */ maxDepth?: number; + maxFiles?: number; } export interface ScannedFile { /** Absolute path as encountered (may be a symlink). */ @@ -11,4 +12,3 @@ export interface ScannedFile { realPath: string; } export declare function scanRuleFiles(options: ScanOptions): ScannedFile[]; -//# sourceMappingURL=scanner.d.ts.map \ No newline at end of file diff --git a/dist/rules/scanner.d.ts.map b/dist/rules/scanner.d.ts.map deleted file mode 100644 index 998db9a..0000000 --- a/dist/rules/scanner.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"scanner.d.ts","sourceRoot":"","sources":["../../src/rules/scanner.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACrC,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC3B,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAwBjE"} \ No newline at end of file diff --git a/dist/rules/scanner.js b/dist/rules/scanner.js index 8b27872..6d35993 100644 --- a/dist/rules/scanner.js +++ b/dist/rules/scanner.js @@ -1,6 +1,6 @@ import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; -import { RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js"; +import { DEFAULT_MAX_SCAN_FILES, RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js"; export function scanRuleFiles(options) { const rootPath = toAbsolutePath(options.rootDir); if (!existsSync(rootPath)) { @@ -20,13 +20,23 @@ export function scanRuleFiles(options) { const visitedDirectories = new Set(); const excludedDirs = new Set(options.excludedDirs ?? SCANNER_EXCLUDED_DIRS); const maxDepth = options.maxDepth ?? 10; - scanDirectory(rootPath, 0, maxDepth, excludedDirs, visitedDirectories, results); + const maxFiles = normalizeMaxFiles(options.maxFiles); + scanDirectory(rootPath, 0, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); return results; } +function normalizeMaxFiles(maxFiles) { + const value = maxFiles ?? DEFAULT_MAX_SCAN_FILES; + if (!Number.isFinite(value) || value < 0) + return DEFAULT_MAX_SCAN_FILES; + return Math.floor(value); +} function toAbsolutePath(filePath) { return isAbsolute(filePath) ? filePath : resolve(filePath); } -function scanDirectory(directoryPath, depth, maxDepth, excludedDirs, visitedDirectories, results) { +function scanDirectory(directoryPath, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results) { + if (results.length >= maxFiles) { + return; + } let realDirectoryPath; try { realDirectoryPath = realpathSync.native(directoryPath); @@ -46,15 +56,18 @@ function scanDirectory(directoryPath, depth, maxDepth, excludedDirs, visitedDire return; } for (const entry of entries) { + if (results.length >= maxFiles) { + return; + } const entryPath = join(directoryPath, entry.name); if (entry.isDirectory()) { if (!excludedDirs.has(entry.name) && depth < maxDepth) { - scanDirectory(entryPath, depth + 1, maxDepth, excludedDirs, visitedDirectories, results); + scanDirectory(entryPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); } continue; } if (entry.isSymbolicLink()) { - scanSymbolicLink(entryPath, entry.name, depth, maxDepth, excludedDirs, visitedDirectories, results); + scanSymbolicLink(entryPath, entry.name, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); continue; } if (entry.isFile() && isRuleFile(entry.name)) { @@ -62,7 +75,10 @@ function scanDirectory(directoryPath, depth, maxDepth, excludedDirs, visitedDire } } } -function scanSymbolicLink(linkPath, linkName, depth, maxDepth, excludedDirs, visitedDirectories, results) { +function scanSymbolicLink(linkPath, linkName, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results) { + if (results.length >= maxFiles) { + return; + } let targetStats; try { targetStats = statSync(linkPath); @@ -72,7 +88,7 @@ function scanSymbolicLink(linkPath, linkName, depth, maxDepth, excludedDirs, vis } if (targetStats.isDirectory()) { if (!excludedDirs.has(linkName) && depth < maxDepth) { - scanDirectory(linkPath, depth + 1, maxDepth, excludedDirs, visitedDirectories, results); + scanDirectory(linkPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); } return; } @@ -93,4 +109,3 @@ function resolveRealPath(filePath) { return filePath; } } -//# sourceMappingURL=scanner.js.map \ No newline at end of file diff --git a/dist/rules/scanner.js.map b/dist/rules/scanner.js.map deleted file mode 100644 index ef7cb48..0000000 --- a/dist/rules/scanner.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"scanner.js","sourceRoot":"","sources":["../../src/rules/scanner.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/rules/truncator.d.ts b/dist/rules/truncator.d.ts index beba866..86a2634 100644 --- a/dist/rules/truncator.d.ts +++ b/dist/rules/truncator.d.ts @@ -15,4 +15,3 @@ export declare function truncateBudget(input: { maxResultChars: number; }): BudgetResult[]; export {}; -//# sourceMappingURL=truncator.d.ts.map \ No newline at end of file diff --git a/dist/rules/truncator.d.ts.map b/dist/rules/truncator.d.ts.map deleted file mode 100644 index 38be2bb..0000000 --- a/dist/rules/truncator.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"truncator.d.ts","sourceRoot":"","sources":["../../src/rules/truncator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,KAAK,UAAU,GAAG;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,KAAK,YAAY,GAAG,UAAU,GAAG;IAChC,SAAS,EAAE,OAAO,CAAC;CACnB,CAAC;AAmBF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,gBAAgB,CAYhH;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,YAAY,EAAE,CAuBlH"} \ No newline at end of file diff --git a/dist/rules/truncator.js b/dist/rules/truncator.js index 603815f..7ee0f78 100644 --- a/dist/rules/truncator.js +++ b/dist/rules/truncator.js @@ -43,4 +43,3 @@ export function truncateBudget(input) { } return results; } -//# sourceMappingURL=truncator.js.map \ No newline at end of file diff --git a/dist/rules/truncator.js.map b/dist/rules/truncator.js.map deleted file mode 100644 index 6e4bb82..0000000 --- a/dist/rules/truncator.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"truncator.js","sourceRoot":"","sources":["../../src/rules/truncator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAYnD,SAAS,gBAAgB,CAAC,YAAoB;IAC7C,OAAO,iBAAiB,CAAC,OAAO,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;AAC1D,CAAC;AAED,SAAS,YAAY,CAAC,IAAY,EAAE,GAAW;IAC9C,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC;QACd,OAAO,CAAC,CAAC;IACV,CAAC;IAED,MAAM,YAAY,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;IAC9C,IAAI,YAAY,IAAI,MAAM,IAAI,YAAY,IAAI,MAAM,EAAE,CAAC;QACtD,OAAO,GAAG,GAAG,CAAC,CAAC;IAChB,CAAC;IAED,OAAO,GAAG,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,OAAmD;IAC7F,IAAI,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;QACrC,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;IAChE,CAAC;IAED,MAAM,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IACtD,IAAI,OAAO,CAAC,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;QACtC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;IACvE,CAAC;IAED,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;IACtE,OAAO,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,GAAG,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;AACtG,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAmE;IACjG,MAAM,OAAO,GAAmB,EAAE,CAAC;IACnC,IAAI,eAAe,GAAG,KAAK,CAAC,cAAc,CAAC;IAE3C,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;QAChC,IAAI,eAAe,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YACzC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;YACrF,eAAe,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC;YACpC,SAAS;QACV,CAAC;QAED,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QACnD,IAAI,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YACtC,MAAM;QACP,CAAC;QAED,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;QAC1E,MAAM,IAAI,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,GAAG,MAAM,EAAE,CAAC;QACxD,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;QACzE,eAAe,IAAI,IAAI,CAAC,MAAM,CAAC;IAChC,CAAC;IAED,OAAO,OAAO,CAAC;AAChB,CAAC"} \ No newline at end of file diff --git a/dist/rules/types.d.ts b/dist/rules/types.d.ts index 7ad65a6..9f8e8c3 100644 --- a/dist/rules/types.d.ts +++ b/dist/rules/types.d.ts @@ -109,6 +109,7 @@ export interface SessionState { cwd: string | undefined; staticDedup: Set; dynamicDedup: Map>; + dynamicTargetFingerprints: Map; loadedRules: LoadedRule[]; diagnostics: RuleDiagnostic[]; } @@ -117,4 +118,3 @@ export interface RuleDiagnostic { source: string; message: string; } -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/rules/types.d.ts.map b/dist/rules/types.d.ts.map deleted file mode 100644 index b1a9acb..0000000 --- a/dist/rules/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/rules/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IAC1B,WAAW,EAAE,eAAe,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,UAAU,CAAC;IACnB;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,UAAW,SAAQ,aAAa;IAChD,WAAW,EAAE,eAAe,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,WAAW,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GACnB,iBAAiB,GACjB,eAAe,GACf,eAAe,GACf,sBAAsB,GACtB,iCAAiC,GACjC,WAAW,GACX,WAAW,GACX,YAAY,GACZ,mBAAmB,GACnB,mBAAmB,GACnB,iBAAiB,GACjB,8BAA8B,GAC9B,qBAAqB,CAAC;AAEzB;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,aAAa,GAAG,aAAa,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,CAAC;AAEnH;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC7B,QAAQ,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,GAAG,KAAK,CAAC;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,UAAU,EAAE,GAAG,MAAM,CAAC;CACtC;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC5B,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACzB,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IACvC,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,WAAW,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,EAAE,SAAS,GAAG,OAAO,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CAChB"} \ No newline at end of file diff --git a/dist/rules/types.js b/dist/rules/types.js index 31f8fd1..4244d3b 100644 --- a/dist/rules/types.js +++ b/dist/rules/types.js @@ -6,4 +6,3 @@ * aliases that are normalized into `globs` internally. */ export {}; -//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/rules/types.js.map b/dist/rules/types.js.map deleted file mode 100644 index b9dda41..0000000 --- a/dist/rules/types.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/rules/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"} \ No newline at end of file diff --git a/dist/tool-paths.d.ts b/dist/tool-paths.d.ts index 8bb9a32..c4aa2f5 100644 --- a/dist/tool-paths.d.ts +++ b/dist/tool-paths.d.ts @@ -4,4 +4,3 @@ export interface CodexPostToolUseLike { tool_response: unknown; } export declare function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): string[]; -//# sourceMappingURL=tool-paths.d.ts.map \ No newline at end of file diff --git a/dist/tool-paths.d.ts.map b/dist/tool-paths.d.ts.map deleted file mode 100644 index 35a62a1..0000000 --- a/dist/tool-paths.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"tool-paths.d.ts","sourceRoot":"","sources":["../src/tool-paths.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,oBAAoB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,OAAO,CAAC;IACpB,aAAa,EAAE,OAAO,CAAC;CACvB;AAkBD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,oBAAoB,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBxF"} \ No newline at end of file diff --git a/dist/tool-paths.js b/dist/tool-paths.js index 7d33486..2cd742e 100644 --- a/dist/tool-paths.js +++ b/dist/tool-paths.js @@ -1,11 +1,13 @@ import { existsSync, statSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]); -const PATCH_TOOL_NAMES = new Set(["apply_patch"]); const TRACKED_TOOL_NAMES = new Set([ "read", "read_file", "mcp__filesystem__read_file", + "mcp__filesystem__read_multiple_files", + "mcp__filesystem__write_file", + "mcp__filesystem__edit_file", "write", "edit", "multiedit", @@ -24,11 +26,8 @@ export function extractCodexToolPaths(input, cwd) { const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; addCommonPathFields(paths, toolInput, cwd); addPatchPayloadPaths(paths, toolInput, cwd); - addPatchRecordPaths(paths, toolInput.files, cwd); - addPatchRecordPaths(paths, toolInput.changes, cwd); - if (PATCH_TOOL_NAMES.has(toolName)) { - addPatchPayloadPaths(paths, toolInput, cwd); - } + addPatchRecordPaths(paths, toolInput["files"], cwd); + addPatchRecordPaths(paths, toolInput["changes"], cwd); if (COMMAND_TOOL_NAMES.has(toolName)) { const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd"); const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd"); @@ -165,6 +164,5 @@ function isRecord(value) { function isFailedToolResponse(value) { if (!isRecord(value)) return false; - return value.isError === true || value.is_error === true || value.error === true || value.status === "error"; + return (value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"); } -//# sourceMappingURL=tool-paths.js.map \ No newline at end of file diff --git a/dist/tool-paths.js.map b/dist/tool-paths.js.map deleted file mode 100644 index cd986ee..0000000 --- a/dist/tool-paths.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"tool-paths.js","sourceRoot":"","sources":["../src/tool-paths.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/hooks/hooks.json b/hooks/hooks.json index 1d08839..bb72c7d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "node \"$PLUGIN_ROOT/dist/cli.js\" hook session-start", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook session-start", "timeout": 10, "statusMessage": "loading project rules" } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "node \"$PLUGIN_ROOT/dist/cli.js\" hook user-prompt-submit", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit", "timeout": 10, "statusMessage": "loading project rules" } @@ -26,16 +26,29 @@ ], "PostToolUse": [ { - "matcher": "^(apply_patch|read|Read|read_file|mcp__filesystem__read_file|write|Write|edit|Edit|multi_edit|MultiEdit|multiedit|bash|Bash|shell_command|exec_command)$", + "matcher": "^(apply_patch|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|bash|Bash|shell_command|exec_command)$", "hooks": [ { "type": "command", - "command": "node \"$PLUGIN_ROOT/dist/cli.js\" hook post-tool-use", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use", "timeout": 10, "statusMessage": "matching project rules" } ] } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-compact", + "timeout": 10, + "statusMessage": "resetting project rule cache" + } + ] + } ] } } diff --git a/package-lock.json b/package-lock.json index b90e452..46abb68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,12 @@ "name": "@code-yeongyu/codex-rules", "version": "0.1.0", "license": "MIT", - "dependencies": { - "picomatch": "^4.0.3" - }, "bin": { "codex-rules": "dist/cli.js" }, "devDependencies": { "@biomejs/biome": "2.4.15", "@types/node": "^25.7.0", - "@types/picomatch": "^4.0.0", "typescript": "^6.0.3", "vitest": "^4.1.5" }, @@ -605,13 +601,6 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", @@ -1153,6 +1142,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index c0f7593..7614d49 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "description": "Codex plugin that injects project rule files into model context through lifecycle hooks.", "type": "module", + "packageManager": "npm@11.12.1", "license": "MIT", "homepage": "https://github.com/code-yeongyu/codex-rules", "repository": { @@ -38,18 +39,15 @@ "build": "tsc -p tsconfig.build.json", "test": "vitest --run", "test:watch": "vitest", + "bench": "npm run build --silent && node scripts/bench-codex-rules.mjs", "typecheck": "tsc --noEmit", "lint": "biome check .", "lint:fix": "biome check --write .", "check": "tsc --noEmit && biome check . && npm run build" }, - "dependencies": { - "picomatch": "^4.0.3" - }, "devDependencies": { "@biomejs/biome": "2.4.15", "@types/node": "^25.7.0", - "@types/picomatch": "^4.0.0", "typescript": "^6.0.3", "vitest": "^4.1.5" }, diff --git a/scripts/bench-codex-rules.mjs b/scripts/bench-codex-rules.mjs new file mode 100644 index 0000000..b9a3dbb --- /dev/null +++ b/scripts/bench-codex-rules.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runPostToolUseHook } from "../dist/codex-hook.js"; +import { createEngine, defaultConfig } from "../dist/rules/engine.js"; + +const ITERATIONS = 40; +const WARMUP_ITERATIONS = 5; +const RULE_COUNT = 120; +const DISTINCT_TARGET_COUNT = 80; +const DUPLICATE_TARGET_COUNT = 240; + +const args = process.argv.slice(2); +const writeBaselinePath = readOption("--write-baseline"); +const comparePath = readOption("--compare"); + +const result = await runBenchmark(); + +if (writeBaselinePath !== undefined) { + writeFileSync(writeBaselinePath, `${JSON.stringify(result, null, "\t")}\n`); +} + +if (comparePath !== undefined) { + const baseline = JSON.parse(readFileSync(comparePath, "utf8")); + const failures = compareResults(baseline, result); + if (failures.length > 0) { + for (const failure of failures) { + process.stderr.write(`${failure}\n`); + } + process.exitCode = 1; + } +} + +process.stdout.write(`${JSON.stringify(result, null, "\t")}\n`); + +function readOption(name) { + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +async function runBenchmark() { + const scenarios = [ + runScenario("duplicate-targets", duplicateTargets, DUPLICATE_TARGET_COUNT), + runScenario("distinct-targets", distinctTargets, DISTINCT_TARGET_COUNT), + ]; + return { + commit: gitCommit(), + iterations: ITERATIONS, + warmupIterations: WARMUP_ITERATIONS, + ruleCount: RULE_COUNT, + scenarios, + hookFastPath: await runHookFastPathScenario(), + }; +} + +async function runHookFastPathScenario() { + const durations = []; + let repeatOutputBytes = 0; + + for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) { + const run = await measureHookFastPathRun(); + if (iteration >= WARMUP_ITERATIONS) { + durations.push(run.repeatDurationMs); + repeatOutputBytes += run.repeatOutputBytes; + } + } + + return { + name: "repeat-post-tool-use", + medianRepeatMs: median(durations), + minRepeatMs: Math.min(...durations), + maxRepeatMs: Math.max(...durations), + repeatOutputBytes, + }; +} + +async function measureHookFastPathRun() { + const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-hook-bench-")); + const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-hook-data-")); + try { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + mkdirSync(join(projectRoot, ".sisyphus", "rules"), { recursive: true }); + writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "bench" })); + writeFileSync(join(projectRoot, "src", "app.ts"), "export const app = true;\n"); + for (let index = 0; index < RULE_COUNT; index += 1) { + writeFileSync(join(projectRoot, ".sisyphus", "rules", `rule-${index}.md`), ruleContent(`rule-${index}`)); + } + + const input = { + session_id: "bench-session", + turn_id: "bench-turn", + transcript_path: null, + cwd: projectRoot, + hook_event_name: "PostToolUse", + model: "gpt-5.5", + permission_mode: "default", + tool_name: "mcp__filesystem__read_file", + tool_input: { path: join(projectRoot, "src", "app.ts") }, + tool_response: { text: "file contents" }, + tool_use_id: "bench-call", + }; + + await runPostToolUseHook(input, { + pluginDataRoot: pluginData, + env: { CODEX_RULES_ENABLED_SOURCES: ".sisyphus/rules" }, + }); + const start = process.hrtime.bigint(); + const repeatOutput = await runPostToolUseHook(input, { + pluginDataRoot: pluginData, + env: { CODEX_RULES_ENABLED_SOURCES: ".sisyphus/rules" }, + }); + return { + repeatDurationMs: Number(process.hrtime.bigint() - start) / 1_000_000, + repeatOutputBytes: Buffer.byteLength(repeatOutput), + }; + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(pluginData, { recursive: true, force: true }); + } +} + +function runScenario(name, targetFactory, targetCount) { + const durations = []; + let counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 }; + + for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) { + const run = measureRun(targetFactory); + if (iteration >= WARMUP_ITERATIONS) { + durations.push(run.durationMs); + counters = addCounters(counters, run.counters); + } + } + + return { + name, + targetCount, + medianMs: median(durations), + minMs: Math.min(...durations), + maxMs: Math.max(...durations), + counters, + }; +} + +function measureRun(targetPaths) { + const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-bench-")); + try { + const candidates = makeCandidates(projectRoot); + mkdirSync(join(projectRoot, ".sisyphus", "rules"), { recursive: true }); + for (const candidate of candidates) { + writeFileSync(candidate.path, ""); + } + const counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 }; + const engine = createEngine(defaultConfig(), { + findProjectRoot: () => { + counters.findProjectRoot += 1; + return projectRoot; + }, + findCandidates: () => { + counters.findCandidates += 1; + return candidates; + }, + readFile: (path) => { + counters.readFile += 1; + return ruleContent(path); + }, + }); + const generatedTargetPaths = targetPaths(projectRoot); + const start = process.hrtime.bigint(); + engine.loadDynamicRules(projectRoot, generatedTargetPaths); + const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000; + return { durationMs, counters }; + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +} + +function duplicateTargets(projectRoot) { + const targetPath = join(projectRoot, "src", "app.ts"); + return Array.from({ length: DUPLICATE_TARGET_COUNT }, () => targetPath); +} + +function distinctTargets(projectRoot) { + return Array.from({ length: DISTINCT_TARGET_COUNT }, (_, index) => join(projectRoot, "src", `file-${index}.ts`)); +} + +function makeCandidates(projectRoot) { + return Array.from({ length: RULE_COUNT }, (_, index) => ({ + path: join(projectRoot, ".sisyphus", "rules", `rule-${index}.md`), + realPath: join(projectRoot, ".sisyphus", "rules", `rule-${index}.md`), + source: ".sisyphus/rules", + distance: 0, + isGlobal: false, + isSingleFile: false, + relativePath: `.sisyphus/rules/rule-${index}.md`, + })); +} + +function ruleContent(path) { + return ["---", "globs: **/*.ts", "---", "", `Rule from ${path}`].join("\n"); +} + +function addCounters(left, right) { + return { + findProjectRoot: left.findProjectRoot + right.findProjectRoot, + findCandidates: left.findCandidates + right.findCandidates, + readFile: left.readFile + right.readFile, + }; +} + +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.floor(sorted.length / 2); + return sorted[index] ?? 0; +} + +function gitCommit() { + try { + return execFileSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +} + +function compareResults(baseline, current) { + const failures = []; + for (const scenario of current.scenarios) { + const baselineScenario = baseline.scenarios.find((candidate) => candidate.name === scenario.name); + if (baselineScenario === undefined) { + failures.push(`missing baseline scenario: ${scenario.name}`); + continue; + } + + for (const counterName of ["findProjectRoot", "findCandidates", "readFile"]) { + if (scenario.counters[counterName] > baselineScenario.counters[counterName]) { + failures.push( + `${scenario.name}.${counterName} regressed: ${scenario.counters[counterName]} > ${baselineScenario.counters[counterName]}`, + ); + } + } + } + if (baseline.hookFastPath === undefined) { + failures.push("missing baseline hookFastPath scenario"); + } else { + if (current.hookFastPath.repeatOutputBytes > baseline.hookFastPath.repeatOutputBytes) { + failures.push( + `hookFastPath.repeatOutputBytes regressed: ${current.hookFastPath.repeatOutputBytes} > ${baseline.hookFastPath.repeatOutputBytes}`, + ); + } + + const maxMedianRepeatMs = baseline.hookFastPath.medianRepeatMs * 1.5; + if (current.hookFastPath.medianRepeatMs > maxMedianRepeatMs) { + failures.push( + `hookFastPath.medianRepeatMs regressed: ${current.hookFastPath.medianRepeatMs} > ${maxMedianRepeatMs}`, + ); + } + } + return failures; +} diff --git a/skills/rules/SKILL.md b/skills/rules/SKILL.md index 6e6538c..0afd2b2 100644 --- a/skills/rules/SKILL.md +++ b/skills/rules/SKILL.md @@ -8,7 +8,9 @@ description: Use when the user asks about Codex Rules behavior, injected project Codex Rules is automatic once the plugin is enabled. It injects: - static project instructions on `SessionStart` and `UserPromptSubmit` -- matching file-specific rules after tracked `PostToolUse` file reads/edits +- matching file-specific rules after tracked `PostToolUse` file reads, edits, patches, MCP filesystem payloads, and shell command file references + +Dynamic `PostToolUse` output is injected as additional context and is deduplicated per plugin data session. Codex Rules does not rewrite tool output. Supported project sources: diff --git a/src/cli.ts b/src/cli.ts index 128a0ad..64fa4dd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,9 +2,12 @@ import { stdin as processStdin, stdout as processStdout } from "node:process"; import { + type CodexPostCompactInput, type CodexPostToolUseInput, + type CodexRulesHookOptions, type CodexSessionStartInput, type CodexUserPromptSubmitInput, + runPostCompactHook, runPostToolUseHook, runSessionStartHook, runUserPromptSubmitHook, @@ -12,6 +15,7 @@ import { const command = process.argv[2]; const subcommand = process.argv[3]; +type HookCliEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse" | "PostCompact"; if (command === "hook" && subcommand === "session-start") { await runHookCli("SessionStart"); @@ -19,27 +23,111 @@ if (command === "hook" && subcommand === "session-start") { await runHookCli("UserPromptSubmit"); } else if (command === "hook" && subcommand === "post-tool-use") { await runHookCli("PostToolUse"); +} else if (command === "hook" && subcommand === "post-compact") { + await runHookCli("PostCompact"); } else { - process.stderr.write("Usage: codex-rules hook [session-start|user-prompt-submit|post-tool-use]\n"); + process.stderr.write("Usage: codex-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n"); process.exitCode = 1; } -async function runHookCli(eventName: "SessionStart" | "UserPromptSubmit" | "PostToolUse"): Promise { +async function runHookCli(eventName: HookCliEventName): Promise { const raw = await readStdin(); if (raw.trim().length === 0) return; - const parsed = JSON.parse(raw); - const options = { pluginDataRoot: process.env.PLUGIN_DATA }; - const output = - eventName === "SessionStart" - ? await runSessionStartHook(parsed as CodexSessionStartInput, options) - : eventName === "UserPromptSubmit" - ? await runUserPromptSubmitHook(parsed as CodexUserPromptSubmitInput, options) - : await runPostToolUseHook(parsed as CodexPostToolUseInput, options); + const parsed = parseHookInput(raw); + if (!parsed) return; + const pluginDataRoot = process.env["PLUGIN_DATA"]; + const options: CodexRulesHookOptions = pluginDataRoot === undefined ? {} : { pluginDataRoot }; + const output = await runHook(eventName, parsed, options); if (output.length > 0) { processStdout.write(output); } } +async function runHook(eventName: HookCliEventName, parsed: unknown, options: CodexRulesHookOptions): Promise { + switch (eventName) { + case "SessionStart": + return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : ""; + case "UserPromptSubmit": + return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : ""; + case "PostToolUse": + return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : ""; + case "PostCompact": + return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : ""; + } +} + +function parseHookInput(raw: string): unknown | undefined { + try { + const parsed: unknown = JSON.parse(raw); + return parsed; + } catch { + return undefined; + } +} + +function isCodexSessionStartInput(value: unknown): value is CodexSessionStartInput { + return ( + isRecord(value) && + value["hook_event_name"] === "SessionStart" && + typeof value["session_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["source"] === "string" + ); +} + +function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput { + return ( + isRecord(value) && + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["prompt"] === "string" + ); +} + +function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput { + return ( + isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string" + ); +} + +function isCodexPostCompactInput(value: unknown): value is CodexPostCompactInput { + return ( + isRecord(value) && + value["hook_event_name"] === "PostCompact" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + (value["trigger"] === "manual" || value["trigger"] === "auto") + ); +} + +function isStringOrNull(value: unknown): value is string | null { + return typeof value === "string" || value === null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ""; diff --git a/src/codex-hook.ts b/src/codex-hook.ts index a442a1f..0941e9b 100644 --- a/src/codex-hook.ts +++ b/src/codex-hook.ts @@ -1,14 +1,18 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, relative } from "node:path"; +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; import { configFromEnvironment } from "./config.js"; import { clearSessionState, hydrateEngineState, persistEngineState, sessionCachePath } from "./persistent-cache.js"; +import { SOURCE_PRIORITY } from "./rules/constants.js"; import { createEngine } from "./rules/engine.js"; -import { findRuleCandidates } from "./rules/finder.js"; +import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js"; +import { hashContent } from "./rules/matcher.js"; +import { sortCandidates } from "./rules/ordering.js"; import { findProjectRoot } from "./rules/project-root.js"; +import type { PiRulesConfig, RuleCandidate } from "./rules/types.js"; import { extractCodexToolPaths } from "./tool-paths.js"; -type HookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; +type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; export type CodexSessionStartInput = { session_id: string; @@ -45,20 +49,46 @@ export type CodexPostToolUseInput = { tool_use_id: string; }; +export type CodexPostCompactInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostCompact"; + model: string; + trigger: "manual" | "auto"; +}; + export interface CodexRulesHookOptions { env?: NodeJS.ProcessEnv; pluginDataRoot?: string; } +interface DynamicTargetFingerprint { + targetPath: string; + cacheKey: string; + fingerprint: string; +} + export async function runSessionStartHook( input: CodexSessionStartInput, options: CodexRulesHookOptions = {}, ): Promise { const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); - clearSessionState(cachePath); + if (input.source !== "resume") { + clearSessionState(cachePath); + } return runStaticInjection(input.cwd, "SessionStart", cachePath, options); } +export async function runPostCompactHook( + input: CodexPostCompactInput, + options: CodexRulesHookOptions = {}, +): Promise { + clearSessionState(sessionCachePath(input.session_id, options.pluginDataRoot)); + return ""; +} + export async function runUserPromptSubmitHook( input: CodexUserPromptSubmitInput, options: CodexRulesHookOptions = {}, @@ -85,15 +115,30 @@ export async function runPostToolUseHook( const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); const engine = createRulesEngine(options); hydrateEngineState(engine, cachePath); + const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config); + const pendingTargetFingerprints = dynamicTargetFingerprints.filter( + (target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint, + ); + if (pendingTargetFingerprints.length === 0) { + persistEngineState(engine, cachePath); + return ""; + } - const loaded = engine.loadDynamicRules(input.cwd, targetPaths); + const loaded = engine.loadDynamicRules( + input.cwd, + pendingTargetFingerprints.map((target) => target.targetPath), + ); const rules = loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)); + for (const target of pendingTargetFingerprints) { + engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint); + } if (rules.length === 0) { persistEngineState(engine, cachePath); return ""; } - const block = engine.formatDynamic(rules, displayPath(input.cwd, firstTargetPath)); + const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath; + const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath)); for (const rule of rules) { engine.markDynamicInjected(rule); } @@ -146,7 +191,110 @@ function createRulesEngine(options: CodexRulesHookOptions) { }); } -function formatAdditionalContextOutput(eventName: HookEventName, additionalContext: string): string { +function fingerprintDynamicTargets( + cwd: string, + targetPaths: ReadonlyArray, + config: PiRulesConfig, +): DynamicTargetFingerprint[] { + const disabledSources = disabledSourcesFor(config); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = findProjectRoot(cwd); + const fingerprints: DynamicTargetFingerprint[] = []; + + for (const targetPath of uniqueStrings(targetPaths)) { + const projectRoot = + cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot) + ? cwdProjectRoot + : findProjectRoot(targetPath); + const findOptions: { + projectRoot: string | null; + targetFile: string; + disabledSources?: ReadonlySet; + cache: ReturnType; + } = { + projectRoot, + targetFile: targetPath, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = findRuleCandidates(findOptions); + const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001"); + const cacheKey = dynamicTargetCacheKey(targetPath); + fingerprints.push({ + targetPath, + cacheKey, + fingerprint: hashContent( + [ + "v1", + config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","), + projectRoot ?? "", + cacheKey, + candidateFingerprint, + ].join("\u0000"), + ), + }); + } + + return fingerprints; +} + +function fingerprintCandidate(candidate: RuleCandidate): string { + return [ + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + fileFingerprint(candidate.path), + ].join("\u0000"); +} + +function fileFingerprint(filePath: string): string { + try { + const stats = statSync(filePath, { bigint: true }); + const contentHash = hashContent(readFileSync(filePath, "utf8")); + return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}:${contentHash}`; + } catch { + return "missing"; + } +} + +function disabledSourcesFor(config: PiRulesConfig): ReadonlySet | undefined { + if (config.enabledSources === "auto") { + return undefined; + } + + const enabledSources = new Set(config.enabledSources); + return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); +} + +function dynamicTargetCacheKey(targetPath: string): string { + return toPosixPath(resolve(targetPath)); +} + +function isSameOrChildPath(childPath: string, parentPath: string): boolean { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} + +function uniqueStrings(values: ReadonlyArray): string[] { + const uniqueValues: string[] = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} + +function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string { if (additionalContext.trim().length === 0) return ""; return `${JSON.stringify({ hookSpecificOutput: { @@ -157,5 +305,13 @@ function formatAdditionalContextOutput(eventName: HookEventName, additionalConte } function displayPath(cwd: string, filePath: string): string { - return isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + // Normalize to POSIX separators so injected rule context renders the same + // path string on Linux/macOS and Windows (Codex feeds this verbatim into + // the model prompt, and the existing engine already emits POSIX paths). + return toPosixPath(rel); +} + +function toPosixPath(path: string): string { + return path.replaceAll("\\", "/"); } diff --git a/src/persistent-cache.ts b/src/persistent-cache.ts index 67e1c31..a7d8d5d 100644 --- a/src/persistent-cache.ts +++ b/src/persistent-cache.ts @@ -7,12 +7,14 @@ import type { Engine } from "./rules/engine.js"; interface SerializedSessionState { staticDedup: string[]; dynamicDedup: Record; + dynamicTargetFingerprints?: Record; } export function hydrateEngineState(engine: Engine, cachePath: string): void { const state = readSessionState(cachePath); engine.state.staticDedup.clear(); engine.state.dynamicDedup.clear(); + engine.state.dynamicTargetFingerprints.clear(); for (const key of state.staticDedup) { engine.state.staticDedup.add(key); @@ -20,6 +22,9 @@ export function hydrateEngineState(engine: Engine, cachePath: string): void { for (const [scope, keys] of Object.entries(state.dynamicDedup)) { engine.state.dynamicDedup.set(scope, new Set(keys)); } + for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) { + engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint); + } } export function persistEngineState(engine: Engine, cachePath: string): void { @@ -31,6 +36,7 @@ export function persistEngineState(engine: Engine, cachePath: string): void { writeSessionState(cachePath, { staticDedup: [...engine.state.staticDedup], dynamicDedup, + dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()), }); } @@ -39,7 +45,7 @@ export function clearSessionState(cachePath: string): void { } export function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string { - const root = pluginDataRoot ?? process.env.PLUGIN_DATA ?? join(homedir(), ".codex", "codex-rules"); + const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules"); return join(root, "sessions", `${safePathSegment(sessionId)}.json`); } @@ -59,7 +65,7 @@ function writeSessionState(cachePath: string, state: SerializedSessionState): vo } function emptyState(): SerializedSessionState { - return { staticDedup: [], dynamicDedup: {} }; + return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} }; } function safePathSegment(value: string): string { @@ -67,14 +73,22 @@ function safePathSegment(value: string): string { } function isSerializedSessionState(value: unknown): value is SerializedSessionState { - if (!isRecord(value) || !Array.isArray(value.staticDedup) || !isRecord(value.dynamicDedup)) { + if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) { return false; } + const staticDedup = value["staticDedup"]; + const dynamicDedup = value["dynamicDedup"]; + const dynamicTargetFingerprints = value["dynamicTargetFingerprints"]; return ( - value.staticDedup.every((item) => typeof item === "string") && - Object.values(value.dynamicDedup).every( + staticDedup.every((item) => typeof item === "string") && + Object.values(dynamicDedup).every( (item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string"), - ) + ) && + (dynamicTargetFingerprints === undefined || + (isRecord(dynamicTargetFingerprints) && + Object.entries(dynamicTargetFingerprints).every( + ([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string", + ))) ); } diff --git a/src/rules/cache.ts b/src/rules/cache.ts index ab8153a..2433d45 100644 --- a/src/rules/cache.ts +++ b/src/rules/cache.ts @@ -3,7 +3,14 @@ import type { LoadedRule, SessionState } from "./types.js"; const DYNAMIC_SESSION_KEY = "__pi-rules-session__"; export function createSessionState(cwd?: string): SessionState { - return { cwd, staticDedup: new Set(), dynamicDedup: new Map(), loadedRules: [], diagnostics: [] }; + return { + cwd, + staticDedup: new Set(), + dynamicDedup: new Map(), + dynamicTargetFingerprints: new Map(), + loadedRules: [], + diagnostics: [], + }; } export function staticDedupKey(cwd: string, rulePath: string, contentHash: string): string { @@ -51,6 +58,7 @@ export function isDynamicInjected(state: SessionState, rule: LoadedRule): boolea export function clearSession(state: SessionState): void { state.staticDedup.clear(); state.dynamicDedup.clear(); + state.dynamicTargetFingerprints.clear(); state.loadedRules.length = 0; state.diagnostics.length = 0; } diff --git a/src/rules/constants.ts b/src/rules/constants.ts index c4bd466..f30be5c 100644 --- a/src/rules/constants.ts +++ b/src/rules/constants.ts @@ -79,6 +79,8 @@ export const GLOBAL_DISTANCE = 9999; */ export const DEFAULT_MAX_RULE_CHARS = 12000; +export const DEFAULT_MAX_SCAN_FILES = 1000; + /** * Total injected chars per tool result (default). */ @@ -89,12 +91,6 @@ export const DEFAULT_MAX_RESULT_CHARS = 40000; */ export const TRUNCATION_NOTICE = "\n\n[Rule truncated. Read full rule: {path}]"; -/** - * Built-in tool names whose results trigger dynamic rule injection. - */ -export const TRACKED_BUILTIN_TOOLS: readonly string[] = ["read", "edit", "write"]; -export const TRACKED_BUILTIN_TOOL_SET: ReadonlySet = new Set(TRACKED_BUILTIN_TOOLS); - /** * Directories excluded by the recursive scanner regardless of glob settings. */ diff --git a/src/rules/engine.ts b/src/rules/engine.ts index 3d9f3e8..d7d27f9 100644 --- a/src/rules/engine.ts +++ b/src/rules/engine.ts @@ -15,12 +15,25 @@ import { PROJECT_SINGLE_FILES, SOURCE_PRIORITY, } from "./constants.js"; +import { createRuleDiscoveryCache, type RuleDiscoveryCache } from "./finder.js"; import { formatDynamicBlock, formatStaticBlock } from "./formatter.js"; import { hashContent, matchRule } from "./matcher.js"; import { sortCandidates } from "./ordering.js"; import { parseRule } from "./parser.js"; import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js"; +interface LoadedRuleContent { + frontmatter: LoadedRule["frontmatter"]; + body: string; + contentHash: string; + diagnostic?: string; +} + +type CandidateProjectMembership = Map; +type DynamicMatchCache = Map; + +const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096; + export interface EngineDeps { findCandidates: (options: { projectRoot: string | null; @@ -28,9 +41,11 @@ export interface EngineDeps { homeDir?: string; disabledSources?: ReadonlySet; skipUserHome?: boolean; + cache?: RuleDiscoveryCache; }) => RuleCandidate[]; readFile: (path: string) => string | null; findProjectRoot: (startPath: string) => string | null; + matchRule?: typeof matchRule; } export interface Engine { @@ -64,6 +79,7 @@ export function defaultConfig(): PiRulesConfig { export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine { const state = createSessionState(); + const dynamicMatchCache: DynamicMatchCache = new Map(); function loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } { state.cwd = cwd; @@ -72,11 +88,15 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine { } const projectRoot = deps.findProjectRoot(cwd); - const candidates = deps.findCandidates({ + const findOptions: Parameters[0] = { projectRoot, targetFile: null, - disabledSources: disabledSourcesFor(config), - }); + }; + const disabledSources = disabledSourcesFor(config); + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = deps.findCandidates(findOptions); const result = loadStaticCandidates(candidates, deps, projectRoot); storeLastLoad(state, result.rules, result.diagnostics); return result; @@ -94,25 +114,50 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine { const rules: LoadedRule[] = []; const diagnostics: RuleDiagnostic[] = []; const seenRules = new Set(); + const loadedRuleContent = new Map(); + const projectMembership = new Map(); const disabledSources = disabledSourcesFor(config); - - for (const targetFile of targetPaths) { - const projectRoot = deps.findProjectRoot(targetFile); - const candidates = deps.findCandidates({ projectRoot, targetFile, disabledSources }); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = deps.findProjectRoot(cwd); + + for (const targetFile of uniqueStrings(targetPaths)) { + const projectRoot = + cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot) + ? cwdProjectRoot + : deps.findProjectRoot(targetFile); + const findOptions: Parameters[0] = { + projectRoot, + targetFile, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = deps.findCandidates(findOptions); for (const candidate of sortCandidates(candidates)) { - const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot); + const loadedRule = loadCandidate( + candidate, + deps, + diagnostics, + projectRoot, + loadedRuleContent, + projectMembership, + ); if (loadedRule === null) { continue; } - const matchResult = matchRule({ - frontmatter: loadedRule.frontmatter, - isSingleFile: candidate.isSingleFile, - pathBases: pathBasesForTarget(projectRoot, targetFile, candidate), - }); + const matchReason = matchDynamicRuleCached( + dynamicMatchCache, + projectRoot, + targetFile, + candidate, + loadedRule, + deps.matchRule ?? matchRule, + ); - if (!matchResult.matched) { + if (matchReason === null) { continue; } @@ -122,7 +167,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine { } seenRules.add(dedupKey); - rules.push({ ...loadedRule, matchReason: matchResult.reason }); + rules.push({ ...loadedRule, matchReason }); } } @@ -145,6 +190,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine { }), resetSession: (cwd) => { clearSession(state); + dynamicMatchCache.clear(); if (cwd !== undefined) { state.cwd = cwd; } @@ -156,6 +202,61 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine { }; } +function matchDynamicRuleCached( + cache: DynamicMatchCache, + projectRoot: string | null, + targetFile: string, + candidate: RuleCandidate, + loadedRule: LoadedRule, + matchRuleImpl: typeof matchRule, +): MatchReason | null { + const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash); + if (cache.has(cacheKey)) { + const cachedReason = cache.get(cacheKey) ?? null; + cache.delete(cacheKey); + cache.set(cacheKey, cachedReason); + return cachedReason; + } + + const matchResult = matchRuleImpl({ + frontmatter: loadedRule.frontmatter, + isSingleFile: candidate.isSingleFile, + pathBases: pathBasesForTarget(projectRoot, targetFile, candidate), + }); + const reason = matchResult.matched ? matchResult.reason : null; + setDynamicMatchCacheEntry(cache, cacheKey, reason); + return reason; +} + +function setDynamicMatchCacheEntry(cache: DynamicMatchCache, cacheKey: string, reason: MatchReason | null): void { + if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) { + const oldestCacheKey = cache.keys().next().value; + if (oldestCacheKey !== undefined) { + cache.delete(oldestCacheKey); + } + } + cache.set(cacheKey, reason); +} + +function dynamicMatchCacheKey( + projectRoot: string | null, + targetFile: string, + candidate: RuleCandidate, + contentHash: string, +): string { + return [ + projectRoot ?? "", + toPosixPath(resolve(targetFile)), + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + contentHash, + ].join("\0"); +} + function loadStaticCandidates(candidates: ReadonlyArray, deps: EngineDeps, projectRoot: string | null) { const rules: LoadedRule[] = []; const diagnostics: RuleDiagnostic[] = []; @@ -191,8 +292,10 @@ function loadCandidate( deps: EngineDeps, diagnostics: RuleDiagnostic[], projectRoot: string | null, + loadedRuleContent?: Map, + projectMembership?: CandidateProjectMembership, ): (LoadedRule & { matchReason: MatchReason }) | null { - if (!isCandidateWithinProject(candidate, projectRoot)) { + if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) { diagnostics.push({ severity: "warning", source: candidate.path, @@ -201,22 +304,48 @@ function loadCandidate( return null; } + const cachedContent = loadedRuleContent?.get(candidate.realPath); + if (cachedContent !== undefined) { + return loadedRuleFromContent(candidate, cachedContent, diagnostics); + } + const content = deps.readFile(candidate.path); if (content === null) { + loadedRuleContent?.set(candidate.realPath, null); diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); return null; } const parsed = parseRule(content); - if (parsed.diagnostic !== undefined) { - diagnostics.push({ severity: "warning", source: candidate.path, message: parsed.diagnostic }); + const loadedContent = { + frontmatter: parsed.frontmatter, + body: parsed.body, + contentHash: hashContent(content), + ...(parsed.diagnostic === undefined ? {} : { diagnostic: parsed.diagnostic }), + } satisfies LoadedRuleContent; + loadedRuleContent?.set(candidate.realPath, loadedContent); + return loadedRuleFromContent(candidate, loadedContent, diagnostics); +} + +function loadedRuleFromContent( + candidate: RuleCandidate, + content: LoadedRuleContent | null, + diagnostics: RuleDiagnostic[], +): (LoadedRule & { matchReason: MatchReason }) | null { + if (content === null) { + diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); + return null; + } + + if (content.diagnostic !== undefined) { + diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic }); } return { ...candidate, - frontmatter: parsed.frontmatter, - body: parsed.body, - contentHash: hashContent(parsed.body), + frontmatter: content.frontmatter, + body: content.body, + contentHash: content.contentHash, matchReason: { kind: "no-match" }, }; } @@ -238,6 +367,26 @@ function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath)); } +function isCandidateWithinProjectCached( + candidate: RuleCandidate, + projectRoot: string | null, + projectMembership: CandidateProjectMembership | undefined, +): boolean { + if (projectMembership === undefined) { + return isCandidateWithinProject(candidate, projectRoot); + } + + const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`; + const cached = projectMembership.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const isWithinProject = isCandidateWithinProject(candidate, projectRoot); + projectMembership.set(cacheKey, isWithinProject); + return isWithinProject; +} + function realPathOrResolved(path: string): string { try { return realpathSync.native(path); @@ -246,6 +395,11 @@ function realPathOrResolved(path: string): string { } } +function isSameOrChildPath(childPath: string, parentPath: string): boolean { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} + function staticMatchReason(rule: LoadedRule): MatchReason | null { if (rule.frontmatter.alwaysApply === true) { return "alwaysApply"; @@ -335,3 +489,17 @@ function emptyLoadResult(state: SessionState): { rules: LoadedRule[]; diagnostic storeLastLoad(state, [], []); return { rules: [], diagnostics: [] }; } + +function uniqueStrings(values: ReadonlyArray): string[] { + const uniqueValues: string[] = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/src/rules/finder.ts b/src/rules/finder.ts index 931c0db..f6cabd1 100644 --- a/src/rules/finder.ts +++ b/src/rules/finder.ts @@ -13,6 +13,16 @@ import { UnsupportedRuleSourceError } from "./errors.js"; import { scanRuleFiles } from "./scanner.js"; import type { RuleCandidate, RuleSource } from "./types.js"; +interface SingleFileInfo { + path: string; + realPath: string; +} + +export interface RuleDiscoveryCache { + scannedRuleFiles: Map>; + singleFileInfo: Map; +} + export interface FinderOptions { /** Project root absolute path (use findProjectRoot to get this). */ projectRoot: string | null; @@ -24,6 +34,7 @@ export interface FinderOptions { disabledSources?: ReadonlySet; /** Whether to skip user-home rules. Default: false. */ skipUserHome?: boolean; + cache?: RuleDiscoveryCache; } interface WalkDirectory { @@ -31,6 +42,10 @@ interface WalkDirectory { distance: number; } +export function createRuleDiscoveryCache(): RuleDiscoveryCache { + return { scannedRuleFiles: new Map(), singleFileInfo: new Map() }; +} + export function findRuleCandidates(options: FinderOptions): RuleCandidate[] { const skipUserHome = options.skipUserHome ?? false; if (options.projectRoot === null && skipUserHome) { @@ -42,11 +57,13 @@ export function findRuleCandidates(options: FinderOptions): RuleCandidate[] { const homeDirectory = resolve(options.homeDir ?? homedir()); if (options.projectRoot !== null) { - candidates.push(...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources)); + candidates.push( + ...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache), + ); } if (!skipUserHome) { - candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources)); + candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache)); } return candidates; @@ -56,6 +73,7 @@ function findProjectCandidates( projectRoot: string, targetFile: string | null, disabledSources: ReadonlySet, + cache: RuleDiscoveryCache | undefined, ): RuleCandidate[] { const rootDirectory = resolve(projectRoot); const walkDirectories = getWalkDirectories(rootDirectory, targetFile); @@ -69,10 +87,10 @@ function findProjectCandidates( } const ruleDirectory = join(walkDirectory.directory, parentDirectory, subDirectory); - for (const scannedFile of scanRuleFiles({ rootDir: ruleDirectory })) { + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { candidates.push({ path: scannedFile.path, - realPath: resolveRealPath(scannedFile.path), + realPath: scannedFile.realPath, source, distance: targetFile === null ? 0 : walkDirectory.distance, isGlobal: false, @@ -91,13 +109,14 @@ function findProjectCandidates( } const filePath = join(walkDirectory.directory, ruleFile); - if (!isFile(filePath)) { + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { continue; } candidates.push({ - path: filePath, - realPath: resolveRealPath(filePath), + path: fileInfo.path, + realPath: fileInfo.realPath, source, distance: targetFile === null ? 0 : walkDirectory.distance, isGlobal: false, @@ -110,7 +129,11 @@ function findProjectCandidates( return candidates; } -function findUserHomeCandidates(homeDirectory: string, disabledSources: ReadonlySet): RuleCandidate[] { +function findUserHomeCandidates( + homeDirectory: string, + disabledSources: ReadonlySet, + cache: RuleDiscoveryCache | undefined, +): RuleCandidate[] { const candidates: RuleCandidate[] = []; for (const ruleSubdir of USER_HOME_RULE_SUBDIRS) { @@ -120,10 +143,10 @@ function findUserHomeCandidates(homeDirectory: string, disabledSources: Readonly } const ruleDirectory = join(homeDirectory, ruleSubdir); - for (const scannedFile of scanRuleFiles({ rootDir: ruleDirectory })) { + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { candidates.push({ path: scannedFile.path, - realPath: resolveRealPath(scannedFile.path), + realPath: scannedFile.realPath, source, distance: GLOBAL_DISTANCE, isGlobal: true, @@ -140,13 +163,14 @@ function findUserHomeCandidates(homeDirectory: string, disabledSources: Readonly } const filePath = join(homeDirectory, ruleFile); - if (!isFile(filePath)) { + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { continue; } candidates.push({ - path: filePath, - realPath: resolveRealPath(filePath), + path: fileInfo.path, + realPath: fileInfo.realPath, source, distance: GLOBAL_DISTANCE, isGlobal: true, @@ -158,6 +182,36 @@ function findUserHomeCandidates(homeDirectory: string, disabledSources: Readonly return candidates; } +function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ReturnType { + if (cache === undefined) { + return scanRuleFiles({ rootDir }); + } + + const cached = cache.scannedRuleFiles.get(rootDir); + if (cached !== undefined) { + return cached; + } + + const scannedFiles = scanRuleFiles({ rootDir }); + cache.scannedRuleFiles.set(rootDir, scannedFiles); + return scannedFiles; +} + +function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null { + if (cache === undefined) { + return readSingleFileInfo(filePath); + } + + const cached = cache.singleFileInfo.get(filePath); + if (cached !== undefined) { + return cached; + } + + const fileInfo = readSingleFileInfo(filePath); + cache.singleFileInfo.set(filePath, fileInfo); + return fileInfo; +} + function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] { if (targetFile === null) { return [{ directory: projectRoot, distance: 0 }]; @@ -195,15 +249,19 @@ function isSameOrChildPath(childPath: string, parentPath: string): boolean { return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/")); } -function isFile(filePath: string): boolean { +function readSingleFileInfo(filePath: string): SingleFileInfo | null { if (!existsSync(filePath)) { - return false; + return null; } try { - return statSync(filePath).isFile(); + if (!statSync(filePath).isFile()) { + return null; + } + + return { path: filePath, realPath: resolveRealPath(filePath) }; } catch { - return false; + return null; } } diff --git a/src/rules/matcher.ts b/src/rules/matcher.ts index 40bded9..4d69da8 100644 --- a/src/rules/matcher.ts +++ b/src/rules/matcher.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import picomatch from "picomatch"; import type { MatchReason, RuleFrontmatter } from "./types.js"; export interface MatcherInput { @@ -14,6 +13,18 @@ export interface MatchResult { reason: MatchReason; } +interface CompiledPattern { + pattern: string; + isMatch: (path: string) => boolean; +} + +interface CompiledPatternSet { + positivePatterns: CompiledPattern[]; + negativeMatchers: Array<(path: string) => boolean>; +} + +const compiledPatternSets = new Map(); + export function matchRule(input: MatcherInput): MatchResult { if (input.isSingleFile) { return { matched: true, reason: "single-file" }; @@ -28,19 +39,10 @@ export function matchRule(input: MatcherInput): MatchResult { return noMatch(); } - const pathBases = [ - normalizePath(input.pathBases.projectRelative), - input.pathBases.scopeRelative ? normalizePath(input.pathBases.scopeRelative) : undefined, - normalizePath(input.pathBases.basename), - ].filter((pathBase): pathBase is string => pathBase !== undefined); - - const positivePatterns = patterns.filter((pattern) => !pattern.startsWith("!")); - const negativePatterns = patterns.filter((pattern) => pattern.startsWith("!")); - const negativeMatchers = negativePatterns.map((pattern) => picomatch(pattern.slice(1), { bash: true, dot: true })); - - for (const pattern of positivePatterns) { - const isMatch = picomatch(pattern, { bash: true, dot: true }); + const pathBases = normalizedPathBases(input.pathBases); + const { positivePatterns, negativeMatchers } = compiledPatternSetFor(patterns); + for (const { pattern, isMatch } of positivePatterns) { for (const pathBase of pathBases) { if (!isMatch(pathBase)) { continue; @@ -83,6 +85,100 @@ function normalizePath(path: string): string { return path.replaceAll("\\", "/"); } +function normalizedPathBases(pathBases: MatcherInput["pathBases"]): string[] { + const normalizedBases = [normalizePath(pathBases.projectRelative)]; + if (pathBases.scopeRelative !== undefined) { + normalizedBases.push(normalizePath(pathBases.scopeRelative)); + } + normalizedBases.push(normalizePath(pathBases.basename)); + return normalizedBases; +} + +function compiledPatternSetFor(patterns: ReadonlyArray): CompiledPatternSet { + const cacheKey = JSON.stringify(patterns); + const cached = compiledPatternSets.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const compiled = compilePatternSet(patterns); + compiledPatternSets.set(cacheKey, compiled); + return compiled; +} + +function compilePatternSet(patterns: ReadonlyArray): CompiledPatternSet { + const positivePatterns: CompiledPattern[] = []; + const negativeMatchers: Array<(path: string) => boolean> = []; + + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + negativeMatchers.push(createGlobMatcher(pattern.slice(1))); + continue; + } + + positivePatterns.push({ pattern, isMatch: createGlobMatcher(pattern) }); + } + + return { positivePatterns, negativeMatchers }; +} + +function createGlobMatcher(pattern: string): (path: string) => boolean { + const expression = globToRegExp(normalizePath(pattern)); + return (path: string) => expression.test(path); +} + +function globToRegExp(pattern: string): RegExp { + let source = "^"; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + const nextChar = pattern[index + 1]; + + if (char === "*" && nextChar === "*") { + const afterGlobStar = pattern[index + 2]; + if (afterGlobStar === "/") { + source += "(?:.*/)?"; + index += 2; + } else { + source += ".*"; + index += 1; + } + continue; + } + + if (char === "*") { + source += "[^/]*"; + continue; + } + + if (char === "?") { + source += "[^/]"; + continue; + } + + if (char === "{") { + const closeIndex = pattern.indexOf("}", index + 1); + if (closeIndex !== -1) { + const alternatives = pattern + .slice(index + 1, closeIndex) + .split(",") + .map(escapeRegExp) + .join("|"); + source += `(?:${alternatives})`; + index = closeIndex; + continue; + } + } + + source += escapeRegExp(char ?? ""); + } + + return new RegExp(`${source}$`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&"); +} + function isExcluded(pathBase: string, negativeMatchers: ReadonlyArray<(path: string) => boolean>): boolean { for (const isMatch of negativeMatchers) { if (isMatch(pathBase)) { diff --git a/src/rules/parser.ts b/src/rules/parser.ts index eb03579..34f13d0 100644 --- a/src/rules/parser.ts +++ b/src/rules/parser.ts @@ -116,8 +116,9 @@ function parseYamlFrontmatter(yamlContent: string): RuleFrontmatter { lineIndex += 1; } - if (globValues.length === 1) { - frontmatter.globs = globValues[0]; + const singleGlob = globValues[0]; + if (globValues.length === 1 && singleGlob !== undefined) { + frontmatter.globs = singleGlob; } else if (globValues.length > 1) { frontmatter.globs = globValues; } diff --git a/src/rules/scanner.ts b/src/rules/scanner.ts index 839985a..8c6f4ef 100644 --- a/src/rules/scanner.ts +++ b/src/rules/scanner.ts @@ -1,13 +1,14 @@ import { type Dirent, existsSync, lstatSync, readdirSync, realpathSync, type Stats, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; -import { RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js"; +import { DEFAULT_MAX_SCAN_FILES, RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js"; export interface ScanOptions { rootDir: string; excludedDirs?: ReadonlyArray; /** Maximum recursion depth. Default: 10 */ maxDepth?: number; + maxFiles?: number; } export interface ScannedFile { @@ -38,11 +39,18 @@ export function scanRuleFiles(options: ScanOptions): ScannedFile[] { const visitedDirectories = new Set(); const excludedDirs = new Set(options.excludedDirs ?? SCANNER_EXCLUDED_DIRS); const maxDepth = options.maxDepth ?? 10; + const maxFiles = normalizeMaxFiles(options.maxFiles); - scanDirectory(rootPath, 0, maxDepth, excludedDirs, visitedDirectories, results); + scanDirectory(rootPath, 0, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); return results; } +function normalizeMaxFiles(maxFiles: number | undefined): number { + const value = maxFiles ?? DEFAULT_MAX_SCAN_FILES; + if (!Number.isFinite(value) || value < 0) return DEFAULT_MAX_SCAN_FILES; + return Math.floor(value); +} + function toAbsolutePath(filePath: string): string { return isAbsolute(filePath) ? filePath : resolve(filePath); } @@ -51,10 +59,15 @@ function scanDirectory( directoryPath: string, depth: number, maxDepth: number, + maxFiles: number, excludedDirs: ReadonlySet, visitedDirectories: Set, results: ScannedFile[], ): void { + if (results.length >= maxFiles) { + return; + } + let realDirectoryPath: string; try { realDirectoryPath = realpathSync.native(directoryPath); @@ -77,17 +90,21 @@ function scanDirectory( } for (const entry of entries) { + if (results.length >= maxFiles) { + return; + } + const entryPath = join(directoryPath, entry.name); if (entry.isDirectory()) { if (!excludedDirs.has(entry.name) && depth < maxDepth) { - scanDirectory(entryPath, depth + 1, maxDepth, excludedDirs, visitedDirectories, results); + scanDirectory(entryPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); } continue; } if (entry.isSymbolicLink()) { - scanSymbolicLink(entryPath, entry.name, depth, maxDepth, excludedDirs, visitedDirectories, results); + scanSymbolicLink(entryPath, entry.name, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); continue; } @@ -102,10 +119,15 @@ function scanSymbolicLink( linkName: string, depth: number, maxDepth: number, + maxFiles: number, excludedDirs: ReadonlySet, visitedDirectories: Set, results: ScannedFile[], ): void { + if (results.length >= maxFiles) { + return; + } + let targetStats: Stats; try { targetStats = statSync(linkPath); @@ -115,7 +137,7 @@ function scanSymbolicLink( if (targetStats.isDirectory()) { if (!excludedDirs.has(linkName) && depth < maxDepth) { - scanDirectory(linkPath, depth + 1, maxDepth, excludedDirs, visitedDirectories, results); + scanDirectory(linkPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); } return; } diff --git a/src/rules/types.ts b/src/rules/types.ts index db67552..84c8929 100644 --- a/src/rules/types.ts +++ b/src/rules/types.ts @@ -126,6 +126,7 @@ export interface SessionState { cwd: string | undefined; staticDedup: Set; dynamicDedup: Map>; + dynamicTargetFingerprints: Map; loadedRules: LoadedRule[]; diagnostics: RuleDiagnostic[]; } diff --git a/src/tool-paths.ts b/src/tool-paths.ts index b7cea83..5974c4a 100644 --- a/src/tool-paths.ts +++ b/src/tool-paths.ts @@ -8,11 +8,13 @@ export interface CodexPostToolUseLike { } const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]); -const PATCH_TOOL_NAMES = new Set(["apply_patch"]); const TRACKED_TOOL_NAMES = new Set([ "read", "read_file", "mcp__filesystem__read_file", + "mcp__filesystem__read_multiple_files", + "mcp__filesystem__write_file", + "mcp__filesystem__edit_file", "write", "edit", "multiedit", @@ -33,12 +35,9 @@ export function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; addCommonPathFields(paths, toolInput, cwd); addPatchPayloadPaths(paths, toolInput, cwd); - addPatchRecordPaths(paths, toolInput.files, cwd); - addPatchRecordPaths(paths, toolInput.changes, cwd); + addPatchRecordPaths(paths, toolInput["files"], cwd); + addPatchRecordPaths(paths, toolInput["changes"], cwd); - if (PATCH_TOOL_NAMES.has(toolName)) { - addPatchPayloadPaths(paths, toolInput, cwd); - } if (COMMAND_TOOL_NAMES.has(toolName)) { const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd"); const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd"); @@ -187,5 +186,7 @@ function isRecord(value: unknown): value is Record { function isFailedToolResponse(value: unknown): boolean { if (!isRecord(value)) return false; - return value.isError === true || value.is_error === true || value.error === true || value.status === "error"; + return ( + value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error" + ); } diff --git a/test/codex-hook.test.ts b/test/codex-hook.test.ts index 9b07fb6..3222606 100644 --- a/test/codex-hook.test.ts +++ b/test/codex-hook.test.ts @@ -1,21 +1,66 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +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; + dynamicTargetFingerprints?: Record; +}; + +const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function runHookCli(input: string, subcommand = "post-tool-use"): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLI_PATH, "hook", subcommand], { + 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,.sisyphus/rules", }; +const RULES_ONLY_ENV = { + CODEX_RULES_ENABLED_SOURCES: ".sisyphus/rules", +}; + afterEach(() => { for (const directory of tempDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); @@ -42,6 +87,7 @@ function makeTempProject(): { root: string; pluginData: string } { ); 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 }; } @@ -57,6 +103,18 @@ function sessionStartInput(root: string): CodexSessionStartInput { }; } +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 postToolUseInput(root: string, filePath: string): CodexPostToolUseInput { return { session_id: "session-1", @@ -88,6 +146,25 @@ function parseHookOutput(output: string): { }; } +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, ".sisyphus", "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 @@ -130,6 +207,29 @@ describe("codex rules hooks", () => { 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 read-file tool result #when PostToolUse runs #then emits matching dynamic rule context", async () => { // given const { root, pluginData } = makeTempProject(); @@ -142,11 +242,244 @@ describe("codex rules hooks", () => { }); // 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 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); + await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + expect(await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV })).toBe(""); + + // when + const compactOutput = await runPostCompactHook(postCompactInput(root), { pluginDataRoot: pluginData }); + const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + + // then + expect(compactOutput).toBe(""); + expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript"); + }); + + 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 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: "", + }); }); }); diff --git a/test/engine.test.ts b/test/engine.test.ts new file mode 100644 index 0000000..4c04fa0 --- /dev/null +++ b/test/engine.test.ts @@ -0,0 +1,167 @@ +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, ".sisyphus", "rules", "typescript.md"), + realPath: join(projectRoot, ".sisyphus", "rules", "typescript.md"), + source: ".sisyphus/rules", + distance: 0, + isGlobal: false, + isSingleFile: false, + relativePath: ".sisyphus/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 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); + }); +}); diff --git a/test/finder.test.ts b/test/finder.test.ts new file mode 100644 index 0000000..88d5b68 --- /dev/null +++ b/test/finder.test.ts @@ -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", ".sisyphus", "rules"), { recursive: true }); + mkdirSync(join(projectRoot, ".sisyphus", "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", ".sisyphus", "rules", "local.md"), "Local rule\n"); + writeFileSync(join(projectRoot, ".sisyphus", "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([ + ".sisyphus/rules:0:src/.sisyphus/rules/local.md", + ".sisyphus/rules:1:.sisyphus/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([".sisyphus/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([ + ".sisyphus/rules:0:src/.sisyphus/rules/local.md", + ".sisyphus/rules:1:.sisyphus/rules/root.md", + "AGENTS.md:1:AGENTS.md", + ]); + }); +}); diff --git a/test/matcher.test.ts b/test/matcher.test.ts new file mode 100644 index 0000000..a4e7150 --- /dev/null +++ b/test/matcher.test.ts @@ -0,0 +1,184 @@ +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 { + 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 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"]); + }); +}); diff --git a/test/package-smoke.test.ts b/test/package-smoke.test.ts new file mode 100644 index 0000000..97e5b03 --- /dev/null +++ b/test/package-smoke.test.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = { + readonly type: string; + readonly packageManager: string; + readonly bin: Record; + readonly dependencies?: Record; +}; + +type PluginJson = { + readonly hooks: string; +}; + +type HookCommand = { + readonly command: string; +}; + +type HookEntry = { + readonly hooks: readonly HookCommand[]; +}; + +type HooksJson = { + readonly hooks: Record; +}; + +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, + ]; + + // then + expect(packageJson.type).toBe("module"); + expect(packageJson.packageManager).toBe("npm@11.12.1"); + expect(packageJson.dependencies ?? {}).toEqual({}); + 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`, + ]); + }); +}); + +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 { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/test/scanner.test.ts b/test/scanner.test.ts new file mode 100644 index 0000000..fb17863 --- /dev/null +++ b/test/scanner.test.ts @@ -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")]); + }); +}); diff --git a/test/tool-paths.test.ts b/test/tool-paths.test.ts index acf0181..17be094 100644 --- a/test/tool-paths.test.ts +++ b/test/tool-paths.test.ts @@ -72,6 +72,95 @@ describe("extractCodexToolPaths", () => { 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(); diff --git a/tsconfig.build.json b/tsconfig.build.json index 4d6121e..5b5bbca 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,8 +3,6 @@ "compilerOptions": { "allowImportingTsExtensions": false, "declaration": true, - "declarationMap": true, - "sourceMap": true, "outDir": "dist", "rootDir": "src", "noEmit": false diff --git a/tsconfig.json b/tsconfig.json index dae035d..342229c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,10 @@ "moduleResolution": "Node16", "lib": ["ES2022"], "strict": true, + "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "verbatimModuleSyntax": true, "noImplicitOverride": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, diff --git a/vitest.config.ts b/vitest.config.ts index e92f387..c4fddb4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + pool: "threads", }, });