diff --git a/src/hooks/rules-injector/injector.test.ts b/src/hooks/rules-injector/injector.test.ts index 8eb23c029..67fa2b9b9 100644 --- a/src/hooks/rules-injector/injector.test.ts +++ b/src/hooks/rules-injector/injector.test.ts @@ -5,13 +5,18 @@ import * as os from "node:os"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { RULES_INJECTOR_STORAGE } from "./constants"; -import { clearParsedRuleCache, createRuleInjectionProcessor, getParsedRuleCacheStats } from "./injector"; +import { + clearParsedRuleCache, + createRuleInjectionProcessor, + getParsedRuleCacheStats, +} from "./injector"; type StatSnapshot = { mtimeMs: number; size: number }; let trackedRulePath = ""; let statSnapshots: Array = []; let trackedReadFileCount = 0; +let trackedShouldApplyRuleCount = 0; let mockedHomeDir = ""; const originalReadFileSync = fs.readFileSync.bind(fs); @@ -19,246 +24,377 @@ const originalStatSync = fs.statSync.bind(fs); const originalHomedir = os.homedir.bind(os); function createOutput(): { title: string; output: string; metadata: unknown } { - return { title: "tool", output: "", metadata: {} }; + return { title: "tool", output: "", metadata: {} }; } async function createProcessor(projectRoot: string): Promise<{ - processFilePathForInjection: ( - filePath: string, - sessionID: string, - output: { title: string; output: string; metadata: unknown } - ) => Promise; + processFilePathForInjection: ( + filePath: string, + sessionID: string, + output: { title: string; output: string; metadata: unknown }, + ) => Promise; }> { - const sessionCaches = new Map< - string, - { contentHashes: Set; realPaths: Set } - >(); + const sessionCaches = new Map< + string, + { contentHashes: Set; realPaths: Set } + >(); - return createRuleInjectionProcessor({ - workspaceDirectory: projectRoot, - truncator: { - truncate: async (_sessionID: string, content: string) => ({ - result: content, - truncated: false, - }), - }, - getSessionCache: (sessionID: string) => { - if (!sessionCaches.has(sessionID)) { - sessionCaches.set(sessionID, { - contentHashes: new Set(), - realPaths: new Set(), - }); - } - const cache = sessionCaches.get(sessionID); - if (!cache) { - throw new Error("Session cache should exist"); - } - return cache; - }, - readFileSync: (filePath: string, encoding: "utf-8") => { - if (filePath === trackedRulePath) { - trackedReadFileCount += 1; - } - return originalReadFileSync(filePath, encoding); - }, - statSync: (filePath: fs.PathLike) => { - if (filePath === trackedRulePath) { - const next = statSnapshots.shift(); - if (next instanceof Error) { - throw next; - } - if (next) { - return { - mtimeMs: next.mtimeMs, - size: next.size, - isFile: () => true, - } as ReturnType; - } - } - return originalStatSync(filePath); - }, - homedir: () => mockedHomeDir || originalHomedir(), - shouldApplyRule: () => ({ applies: true, reason: "matched" }), - isDuplicateByRealPath: (realPath: string, cache: Set) => cache.has(realPath), - createContentHash: (content: string) => `hash:${content}`, - isDuplicateByContentHash: (hash: string, cache: Set) => cache.has(hash), - }); + return createRuleInjectionProcessor({ + workspaceDirectory: projectRoot, + truncator: { + truncate: async (_sessionID: string, content: string) => ({ + result: content, + truncated: false, + }), + }, + getSessionCache: (sessionID: string) => { + if (!sessionCaches.has(sessionID)) { + sessionCaches.set(sessionID, { + contentHashes: new Set(), + realPaths: new Set(), + }); + } + const cache = sessionCaches.get(sessionID); + if (!cache) { + throw new Error("Session cache should exist"); + } + return cache; + }, + readFileSync: (filePath: string, encoding: "utf-8") => { + if (filePath === trackedRulePath) { + trackedReadFileCount += 1; + } + return originalReadFileSync(filePath, encoding); + }, + statSync: (filePath: fs.PathLike) => { + if (filePath === trackedRulePath) { + const next = statSnapshots.shift(); + if (next instanceof Error) { + throw next; + } + if (next) { + return { + mtimeMs: next.mtimeMs, + size: next.size, + isFile: () => true, + } as ReturnType; + } + } + return originalStatSync(filePath); + }, + homedir: () => mockedHomeDir || originalHomedir(), + shouldApplyRule: () => { + trackedShouldApplyRuleCount += 1; + return { applies: true, reason: "matched" }; + }, + isDuplicateByRealPath: (realPath: string, cache: Set) => + cache.has(realPath), + createContentHash: (content: string) => `hash:${content}`, + isDuplicateByContentHash: (hash: string, cache: Set) => + cache.has(hash), + }); } function getInjectedRulesPath(sessionID: string): string { - return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`); + return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`); } describe("createRuleInjectionProcessor", () => { - let testRoot: string; - let projectRoot: string; - let homeRoot: string; - let targetFile: string; - let ruleFile: string; - let ruleRealPath: string; + let testRoot: string; + let projectRoot: string; + let homeRoot: string; + let targetFile: string; + let ruleFile: string; + let ruleRealPath: string; - beforeEach(() => { - clearParsedRuleCache(); - testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`); - projectRoot = join(testRoot, "project"); - homeRoot = join(testRoot, "home"); - targetFile = join(projectRoot, "src", "index.ts"); - ruleFile = join( - projectRoot, - ".github", - "instructions", - "typescript.instructions.md" - ); + beforeEach(() => { + clearParsedRuleCache(); + testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`); + projectRoot = join(testRoot, "project"); + homeRoot = join(testRoot, "home"); + targetFile = join(projectRoot, "src", "index.ts"); + ruleFile = join( + projectRoot, + ".github", + "instructions", + "typescript.instructions.md", + ); - mkdirSync(join(projectRoot, ".git"), { recursive: true }); - mkdirSync(join(projectRoot, "src"), { recursive: true }); - mkdirSync(join(projectRoot, ".github", "instructions"), { recursive: true }); - mkdirSync(homeRoot, { recursive: true }); + mkdirSync(join(projectRoot, ".git"), { recursive: true }); + mkdirSync(join(projectRoot, "src"), { recursive: true }); + mkdirSync(join(projectRoot, ".github", "instructions"), { + recursive: true, + }); + mkdirSync(homeRoot, { recursive: true }); - writeFileSync(targetFile, "export const value = 1;\n"); - writeFileSync(ruleFile, "rule-content\n"); + writeFileSync(targetFile, "export const value = 1;\n"); + writeFileSync(ruleFile, "rule-content\n"); - ruleRealPath = fs.realpathSync(ruleFile); - trackedRulePath = ruleFile; - statSnapshots = []; - trackedReadFileCount = 0; - mockedHomeDir = homeRoot; - }); + ruleRealPath = fs.realpathSync(ruleFile); + trackedRulePath = ruleFile; + statSnapshots = []; + trackedReadFileCount = 0; + trackedShouldApplyRuleCount = 0; + mockedHomeDir = homeRoot; + }); - afterEach(() => { - clearParsedRuleCache(); - if (fs.existsSync(testRoot)) { - rmSync(testRoot, { recursive: true, force: true }); - } - }); + afterEach(() => { + clearParsedRuleCache(); + if (fs.existsSync(testRoot)) { + rmSync(testRoot, { recursive: true, force: true }); + } + }); - it("reads and parses same file once when stat is unchanged", async () => { - // given - statSnapshots = [ - { mtimeMs: 1000, size: 13 }, - { mtimeMs: 1000, size: 13 }, - ]; - const processor = await createProcessor(projectRoot); + it("reads and parses same file once when stat is unchanged", async () => { + // given + statSnapshots = [ + { mtimeMs: 1000, size: 13 }, + { mtimeMs: 1000, size: 13 }, + ]; + const processor = await createProcessor(projectRoot); - // when - await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); - await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + targetFile, + "session-2", + createOutput(), + ); - // then - expect(trackedReadFileCount).toBe(1); - }); + // then + expect(trackedReadFileCount).toBe(1); + }); - it("re-reads file when mtime changes", async () => { - // given - statSnapshots = [ - { mtimeMs: 1000, size: 13 }, - { mtimeMs: 2000, size: 13 }, - ]; - const processor = await createProcessor(projectRoot); + it("re-reads file when mtime changes", async () => { + // given + statSnapshots = [ + { mtimeMs: 1000, size: 13 }, + { mtimeMs: 2000, size: 13 }, + ]; + const processor = await createProcessor(projectRoot); - // when - await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); - await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + targetFile, + "session-2", + createOutput(), + ); - // then - expect(trackedReadFileCount).toBe(2); - }); + // then + expect(trackedReadFileCount).toBe(2); + }); - it("re-reads file when size changes", async () => { - // given - statSnapshots = [ - { mtimeMs: 1000, size: 13 }, - { mtimeMs: 1000, size: 21 }, - ]; - const processor = await createProcessor(projectRoot); + it("re-reads file when size changes", async () => { + // given + statSnapshots = [ + { mtimeMs: 1000, size: 13 }, + { mtimeMs: 1000, size: 21 }, + ]; + const processor = await createProcessor(projectRoot); - // when - await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); - await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + targetFile, + "session-2", + createOutput(), + ); - // then - expect(trackedReadFileCount).toBe(2); - }); + // then + expect(trackedReadFileCount).toBe(2); + }); - it("does not cache oversized parsed rule bodies", async () => { - // given - const largeBody = "x".repeat(70 * 1024); - writeFileSync(ruleFile, largeBody); - statSnapshots = [ - { mtimeMs: 1000, size: largeBody.length }, - { mtimeMs: 1000, size: largeBody.length }, - ]; - const processor = await createProcessor(projectRoot); + it("reuses match decision when stat fingerprint and target are unchanged", async () => { + // given + statSnapshots = [ + { mtimeMs: 1000, size: 13 }, + { mtimeMs: 1000, size: 13 }, + ]; + const processor = await createProcessor(projectRoot); - // when - await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); - await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + targetFile, + "session-2", + createOutput(), + ); - // then - expect(trackedReadFileCount).toBe(2); - expect(getParsedRuleCacheStats()).toEqual({ entries: 0, bodyBytes: 0 }); - }); + // then + expect(trackedShouldApplyRuleCount).toBe(1); + }); - it("does not save injected rules when all candidates are already cached", async () => { - // given - const sessionID = `dirty-no-new-${Date.now()}`; - const injectedPath = getInjectedRulesPath(sessionID); - if (fs.existsSync(injectedPath)) { - fs.unlinkSync(injectedPath); - } + it("re-evaluates match decision when stat fingerprint changes", async () => { + // given + statSnapshots = [ + { mtimeMs: 1000, size: 13 }, + { mtimeMs: 2000, size: 13 }, + ]; + const processor = await createProcessor(projectRoot); - const { createRuleInjectionProcessor } = await import("./injector"); - const processor = createRuleInjectionProcessor({ - workspaceDirectory: projectRoot, - truncator: { - truncate: async (_sessionID: string, content: string) => ({ - result: content, - truncated: false, - }), - }, - getSessionCache: () => ({ - contentHashes: new Set(), - realPaths: new Set([ruleRealPath]), - }), - }); + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + targetFile, + "session-2", + createOutput(), + ); - // when - await processor.processFilePathForInjection(targetFile, sessionID, createOutput()); + // then + expect(trackedShouldApplyRuleCount).toBe(2); + }); - // then - expect(fs.existsSync(injectedPath)).toBe(false); - }); + it("keeps match decisions separate for different target files", async () => { + // given + const secondTargetFile = join(projectRoot, "src", "other.ts"); + writeFileSync(secondTargetFile, "export const other = 2;\n"); + statSnapshots = [ + { mtimeMs: 1000, size: 13 }, + { mtimeMs: 1000, size: 13 }, + ]; + const processor = await createProcessor(projectRoot); - it("saves injected rules when a new rule is added", async () => { - // given - const sessionID = `dirty-new-${Date.now()}`; - const injectedPath = getInjectedRulesPath(sessionID); - if (fs.existsSync(injectedPath)) { - fs.unlinkSync(injectedPath); - } - const processor = await createProcessor(projectRoot); + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + secondTargetFile, + "session-2", + createOutput(), + ); - // when - await processor.processFilePathForInjection(targetFile, sessionID, createOutput()); + // then + expect(trackedShouldApplyRuleCount).toBe(2); + }); - // then - expect(fs.existsSync(injectedPath)).toBe(true); + it("does not cache oversized parsed rule bodies", async () => { + // given + const largeBody = "x".repeat(70 * 1024); + writeFileSync(ruleFile, largeBody); + statSnapshots = [ + { mtimeMs: 1000, size: largeBody.length }, + { mtimeMs: 1000, size: largeBody.length }, + ]; + const processor = await createProcessor(projectRoot); - if (fs.existsSync(injectedPath)) { - fs.unlinkSync(injectedPath); - } - }); + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + targetFile, + "session-2", + createOutput(), + ); - it("falls back to direct read and parse when statSync throws", async () => { - // given - statSnapshots = [new Error("stat failed"), new Error("stat failed")]; - const processor = await createProcessor(projectRoot); + // then + expect(trackedReadFileCount).toBe(2); + expect(getParsedRuleCacheStats()).toEqual({ entries: 0, bodyBytes: 0 }); + }); - // when - await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); - await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + it("does not save injected rules when all candidates are already cached", async () => { + // given + const sessionID = `dirty-no-new-${Date.now()}`; + const injectedPath = getInjectedRulesPath(sessionID); + if (fs.existsSync(injectedPath)) { + fs.unlinkSync(injectedPath); + } - // then - expect(trackedReadFileCount).toBe(2); - }); + const { createRuleInjectionProcessor } = await import("./injector"); + const processor = createRuleInjectionProcessor({ + workspaceDirectory: projectRoot, + truncator: { + truncate: async (_sessionID: string, content: string) => ({ + result: content, + truncated: false, + }), + }, + getSessionCache: () => ({ + contentHashes: new Set(), + realPaths: new Set([ruleRealPath]), + }), + }); + + // when + await processor.processFilePathForInjection( + targetFile, + sessionID, + createOutput(), + ); + + // then + expect(fs.existsSync(injectedPath)).toBe(false); + }); + + it("saves injected rules when a new rule is added", async () => { + // given + const sessionID = `dirty-new-${Date.now()}`; + const injectedPath = getInjectedRulesPath(sessionID); + if (fs.existsSync(injectedPath)) { + fs.unlinkSync(injectedPath); + } + const processor = await createProcessor(projectRoot); + + // when + await processor.processFilePathForInjection( + targetFile, + sessionID, + createOutput(), + ); + + // then + expect(fs.existsSync(injectedPath)).toBe(true); + + if (fs.existsSync(injectedPath)) { + fs.unlinkSync(injectedPath); + } + }); + + it("falls back to direct read and parse when statSync throws", async () => { + // given + statSnapshots = [new Error("stat failed"), new Error("stat failed")]; + const processor = await createProcessor(projectRoot); + + // when + await processor.processFilePathForInjection( + targetFile, + "session-1", + createOutput(), + ); + await processor.processFilePathForInjection( + targetFile, + "session-2", + createOutput(), + ); + + // then + expect(trackedReadFileCount).toBe(2); + expect(trackedShouldApplyRuleCount).toBe(2); + }); }); diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts index 8dc517c58..cd23195d3 100644 --- a/src/hooks/rules-injector/injector.ts +++ b/src/hooks/rules-injector/injector.ts @@ -4,10 +4,10 @@ import { relative, resolve } from "node:path"; import { findProjectRoot, findRuleFiles } from "./finder"; import type { FindRuleFilesOptions } from "./rule-file-finder"; import { - createContentHash, - isDuplicateByContentHash, - isDuplicateByRealPath, - shouldApplyRule, + createContentHash, + isDuplicateByContentHash, + isDuplicateByRealPath, + shouldApplyRule, } from "./matcher"; import { parseRuleFrontmatter } from "./parser"; import { saveInjectedRules } from "./storage"; @@ -16,216 +16,336 @@ import type { RuleScanCache } from "./rule-scan-cache"; import type { RuleMetadata } from "./types"; type ToolExecuteOutput = { - title: string; - output: string; - metadata: unknown; + title: string; + output: string; + metadata: unknown; }; type RuleToInject = { - relativePath: string; - matchReason: string; - content: string; - distance: number; + relativePath: string; + matchReason: string; + content: string; + distance: number; }; type DynamicTruncator = { - truncate: ( - sessionID: string, - content: string - ) => Promise<{ result: string; truncated: boolean }>; + truncate: ( + sessionID: string, + content: string, + ) => Promise<{ result: string; truncated: boolean }>; }; type RuleFileReader = (path: string, encoding: "utf-8") => string; interface ParsedRuleEntry { - mtimeMs: number; - size: number; - metadata: RuleMetadata; - body: string; + mtimeMs: number; + size: number; + metadata: RuleMetadata; + body: string; } +type ParsedRule = { + metadata: RuleMetadata; + body: string; + statFingerprint: string | null; +}; + +type MatchDecisionCache = Map; + export interface ParsedRuleCacheStats { - entries: number; - bodyBytes: number; + entries: number; + bodyBytes: number; } const MAX_PARSED_RULE_CACHE_ENTRIES = 256; const MAX_PARSED_RULE_CACHE_BODY_BYTES = 64 * 1024; +const MAX_MATCH_DECISION_CACHE_ENTRIES = 4096; const parsedRuleCache = new Map(); export function clearParsedRuleCache(): void { - parsedRuleCache.clear(); + parsedRuleCache.clear(); } export function getParsedRuleCacheStats(): ParsedRuleCacheStats { - let bodyBytes = 0; - for (const entry of parsedRuleCache.values()) { - bodyBytes += Buffer.byteLength(entry.body, "utf8"); - } - return { entries: parsedRuleCache.size, bodyBytes }; + let bodyBytes = 0; + for (const entry of parsedRuleCache.values()) { + bodyBytes += Buffer.byteLength(entry.body, "utf8"); + } + return { entries: parsedRuleCache.size, bodyBytes }; } -function setParsedRuleCacheEntry(realPath: string, entry: ParsedRuleEntry): void { - if (Buffer.byteLength(entry.body, "utf8") > MAX_PARSED_RULE_CACHE_BODY_BYTES) return; - if (parsedRuleCache.size >= MAX_PARSED_RULE_CACHE_ENTRIES) { - const oldestRealPath = parsedRuleCache.keys().next().value; - if (oldestRealPath !== undefined) { - parsedRuleCache.delete(oldestRealPath); - } - } - parsedRuleCache.set(realPath, entry); +function setParsedRuleCacheEntry( + realPath: string, + entry: ParsedRuleEntry, +): void { + if (Buffer.byteLength(entry.body, "utf8") > MAX_PARSED_RULE_CACHE_BODY_BYTES) + return; + if (parsedRuleCache.size >= MAX_PARSED_RULE_CACHE_ENTRIES) { + const oldestRealPath = parsedRuleCache.keys().next().value; + if (oldestRealPath !== undefined) { + parsedRuleCache.delete(oldestRealPath); + } + } + parsedRuleCache.set(realPath, entry); } function resolveFilePath( - workspaceDirectory: string, - path: string + workspaceDirectory: string, + path: string, ): string | null { - if (!path) return null; - if (path.startsWith("/")) return path; - return resolve(workspaceDirectory, path); + if (!path) return null; + if (path.startsWith("/")) return path; + return resolve(workspaceDirectory, path); } export function createRuleInjectionProcessor(deps: { - workspaceDirectory: string; - truncator: DynamicTruncator; - getSessionCache: (sessionID: string) => SessionInjectedRulesCache; - getSessionRuleScanCache?: (sessionID: string) => RuleScanCache; - ruleFinderOptions?: FindRuleFilesOptions; - readFileSync?: RuleFileReader; - statSync?: typeof statSync; - homedir?: typeof homedir; - shouldApplyRule?: typeof shouldApplyRule; - isDuplicateByRealPath?: typeof isDuplicateByRealPath; - createContentHash?: typeof createContentHash; - isDuplicateByContentHash?: typeof isDuplicateByContentHash; - saveInjectedRules?: typeof saveInjectedRules; + workspaceDirectory: string; + truncator: DynamicTruncator; + getSessionCache: (sessionID: string) => SessionInjectedRulesCache; + getSessionRuleScanCache?: (sessionID: string) => RuleScanCache; + ruleFinderOptions?: FindRuleFilesOptions; + readFileSync?: RuleFileReader; + statSync?: typeof statSync; + homedir?: typeof homedir; + shouldApplyRule?: typeof shouldApplyRule; + isDuplicateByRealPath?: typeof isDuplicateByRealPath; + createContentHash?: typeof createContentHash; + isDuplicateByContentHash?: typeof isDuplicateByContentHash; + saveInjectedRules?: typeof saveInjectedRules; }): { - processFilePathForInjection: ( - filePath: string, - sessionID: string, - output: ToolExecuteOutput - ) => Promise; + processFilePathForInjection: ( + filePath: string, + sessionID: string, + output: ToolExecuteOutput, + ) => Promise; } { - const { - workspaceDirectory, - truncator, - getSessionCache, - getSessionRuleScanCache, - ruleFinderOptions, - readFileSync: readRuleFileSync = readFileSync, - statSync: statRuleSync = statSync, - homedir: getHomeDir = homedir, - shouldApplyRule: shouldApplyRuleImpl = shouldApplyRule, - isDuplicateByRealPath: isDuplicateByRealPathImpl = isDuplicateByRealPath, - createContentHash: createContentHashImpl = createContentHash, - isDuplicateByContentHash: isDuplicateByContentHashImpl = isDuplicateByContentHash, - saveInjectedRules: saveInjectedRulesImpl = saveInjectedRules, - } = deps; + const { + workspaceDirectory, + truncator, + getSessionCache, + getSessionRuleScanCache, + ruleFinderOptions, + readFileSync: readRuleFileSync = readFileSync, + statSync: statRuleSync = statSync, + homedir: getHomeDir = homedir, + shouldApplyRule: shouldApplyRuleImpl = shouldApplyRule, + isDuplicateByRealPath: isDuplicateByRealPathImpl = isDuplicateByRealPath, + createContentHash: createContentHashImpl = createContentHash, + isDuplicateByContentHash: + isDuplicateByContentHashImpl = isDuplicateByContentHash, + saveInjectedRules: saveInjectedRulesImpl = saveInjectedRules, + } = deps; - function getParsedRule(filePath: string, realPath: string): { metadata: RuleMetadata; body: string } { - try { - const stat = statRuleSync(filePath); - const cached = parsedRuleCache.get(realPath); + const matchDecisionCache: MatchDecisionCache = new Map(); - if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { - return { metadata: cached.metadata, body: cached.body }; - } + function getParsedRule(filePath: string, realPath: string): ParsedRule { + try { + const stat = statRuleSync(filePath); + const statFingerprint = `${stat.mtimeMs}:${stat.size}`; + const cached = parsedRuleCache.get(realPath); - const rawContent = readRuleFileSync(filePath, "utf-8"); - const { metadata, body } = parseRuleFrontmatter(rawContent); - setParsedRuleCacheEntry(realPath, { - mtimeMs: stat.mtimeMs, - size: stat.size, - metadata, - body, - }); - return { metadata, body }; - } catch { - const rawContent = readRuleFileSync(filePath, "utf-8"); - return parseRuleFrontmatter(rawContent); - } - } + if ( + cached && + cached.mtimeMs === stat.mtimeMs && + cached.size === stat.size + ) { + return { + metadata: cached.metadata, + body: cached.body, + statFingerprint, + }; + } - async function processFilePathForInjection( - filePath: string, - sessionID: string, - output: ToolExecuteOutput - ): Promise { - const resolved = resolveFilePath(workspaceDirectory, filePath); - if (!resolved) return; + const rawContent = readRuleFileSync(filePath, "utf-8"); + const { metadata, body } = parseRuleFrontmatter(rawContent); + setParsedRuleCacheEntry(realPath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + metadata, + body, + }); + return { metadata, body, statFingerprint }; + } catch { + const rawContent = readRuleFileSync(filePath, "utf-8"); + const { metadata, body } = parseRuleFrontmatter(rawContent); + return { metadata, body, statFingerprint: null }; + } + } - const projectRoot = findProjectRoot(resolved); - const cache = getSessionCache(sessionID); - const ruleScanCache = getSessionRuleScanCache?.(sessionID); - const home = getHomeDir(); + async function processFilePathForInjection( + filePath: string, + sessionID: string, + output: ToolExecuteOutput, + ): Promise { + const resolved = resolveFilePath(workspaceDirectory, filePath); + if (!resolved) return; - const ruleFileCandidates = findRuleFiles( - projectRoot, - home, - resolved, - ruleFinderOptions, - ruleScanCache, - ); - const toInject: RuleToInject[] = []; - let dirty = false; + const projectRoot = findProjectRoot(resolved); + const cache = getSessionCache(sessionID); + const ruleScanCache = getSessionRuleScanCache?.(sessionID); + const home = getHomeDir(); - for (const candidate of ruleFileCandidates) { - if (isDuplicateByRealPathImpl(candidate.realPath, cache.realPaths)) continue; + const ruleFileCandidates = findRuleFiles( + projectRoot, + home, + resolved, + ruleFinderOptions, + ruleScanCache, + ); + const toInject: RuleToInject[] = []; + let dirty = false; - try { - const { metadata, body } = getParsedRule( - candidate.path, - candidate.realPath - ); + for (const candidate of ruleFileCandidates) { + if (isDuplicateByRealPathImpl(candidate.realPath, cache.realPaths)) + continue; - let matchReason: string; - if (candidate.isSingleFile) { - matchReason = "copilot-instructions (always apply)"; - } else { - const matchResult = shouldApplyRuleImpl(metadata, resolved, projectRoot); - if (!matchResult.applies) continue; - matchReason = matchResult.reason ?? "matched"; - } + try { + const { metadata, body, statFingerprint } = getParsedRule( + candidate.path, + candidate.realPath, + ); - const contentHash = createContentHashImpl(body); - if (isDuplicateByContentHashImpl(contentHash, cache.contentHashes)) continue; + let matchReason: string; + if (candidate.isSingleFile) { + matchReason = "copilot-instructions (always apply)"; + } else { + const cachedMatchReason = getCachedMatchReason( + matchDecisionCache, + projectRoot, + resolved, + candidate.realPath, + statFingerprint, + ); + if (cachedMatchReason !== undefined) { + if (cachedMatchReason === null) continue; + matchReason = cachedMatchReason; + } else { + const matchResult = shouldApplyRuleImpl( + metadata, + resolved, + projectRoot, + ); + if (!matchResult.applies) { + setCachedMatchReason( + matchDecisionCache, + projectRoot, + resolved, + candidate.realPath, + statFingerprint, + null, + ); + continue; + } + matchReason = matchResult.reason ?? "matched"; + setCachedMatchReason( + matchDecisionCache, + projectRoot, + resolved, + candidate.realPath, + statFingerprint, + matchReason, + ); + } + } - const relativePath = projectRoot - ? relative(projectRoot, candidate.path) - : candidate.path; + const contentHash = createContentHashImpl(body); + if (isDuplicateByContentHashImpl(contentHash, cache.contentHashes)) + continue; - toInject.push({ - relativePath, - matchReason, - content: body, - distance: candidate.distance, - }); + const relativePath = projectRoot + ? relative(projectRoot, candidate.path) + : candidate.path; - cache.realPaths.add(candidate.realPath); - cache.contentHashes.add(contentHash); - dirty = true; - } catch {} - } + toInject.push({ + relativePath, + matchReason, + content: body, + distance: candidate.distance, + }); - if (toInject.length === 0) return; + cache.realPaths.add(candidate.realPath); + cache.contentHashes.add(contentHash); + dirty = true; + } catch {} + } - toInject.sort((a, b) => a.distance - b.distance); + if (toInject.length === 0) return; - for (const rule of toInject) { - const { result, truncated } = await truncator.truncate( - sessionID, - rule.content - ); - const truncationNotice = truncated - ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]` - : ""; - output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; - } + toInject.sort((a, b) => a.distance - b.distance); - if (dirty) { - saveInjectedRulesImpl(sessionID, cache); - } - } + for (const rule of toInject) { + const { result, truncated } = await truncator.truncate( + sessionID, + rule.content, + ); + const truncationNotice = truncated + ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]` + : ""; + output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; + } - return { processFilePathForInjection }; + if (dirty) { + saveInjectedRulesImpl(sessionID, cache); + } + } + + return { processFilePathForInjection }; +} + +function getCachedMatchReason( + cache: MatchDecisionCache, + projectRoot: string | null, + resolvedFilePath: string, + realPath: string, + statFingerprint: string | null, +): string | null | undefined { + const cacheKey = matchDecisionCacheKey( + projectRoot, + resolvedFilePath, + realPath, + statFingerprint, + ); + if (cacheKey === null || !cache.has(cacheKey)) return undefined; + + const cached = cache.get(cacheKey) ?? null; + cache.delete(cacheKey); + cache.set(cacheKey, cached); + return cached; +} + +function setCachedMatchReason( + cache: MatchDecisionCache, + projectRoot: string | null, + resolvedFilePath: string, + realPath: string, + statFingerprint: string | null, + matchReason: string | null, +): void { + const cacheKey = matchDecisionCacheKey( + projectRoot, + resolvedFilePath, + realPath, + statFingerprint, + ); + if (cacheKey === null) return; + + if (cache.size >= MAX_MATCH_DECISION_CACHE_ENTRIES) { + const oldestCacheKey = cache.keys().next().value; + if (oldestCacheKey !== undefined) { + cache.delete(oldestCacheKey); + } + } + cache.set(cacheKey, matchReason); +} + +function matchDecisionCacheKey( + projectRoot: string | null, + resolvedFilePath: string, + realPath: string, + statFingerprint: string | null, +): string | null { + if (statFingerprint === null) return null; + return [projectRoot ?? "", resolvedFilePath, realPath, statFingerprint].join( + "\0", + ); }