diff --git a/src/hooks/rules-injector/injector.test.ts b/src/hooks/rules-injector/injector.test.ts new file mode 100644 index 00000000..e07b7fc4 --- /dev/null +++ b/src/hooks/rules-injector/injector.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import * as fs from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as os from "node:os"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RULES_INJECTOR_STORAGE } from "./constants"; + +type StatSnapshot = { mtimeMs: number; size: number }; + +let trackedRulePath = ""; +let statSnapshots: Array = []; +let trackedReadFileCount = 0; +let mockedHomeDir = ""; + +const originalReadFileSync = fs.readFileSync.bind(fs); +const originalStatSync = fs.statSync.bind(fs); +const originalHomedir = os.homedir.bind(os); + +mock.module("node:fs", () => ({ + ...fs, + readFileSync: (filePath: string, encoding?: string) => { + if (filePath === trackedRulePath) { + trackedReadFileCount += 1; + } + return originalReadFileSync(filePath, encoding as never); + }, + statSync: (filePath: string) => { + 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); + }, +})); + +mock.module("node:os", () => ({ + ...os, + homedir: () => mockedHomeDir || originalHomedir(), +})); + +mock.module("./matcher", () => ({ + 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), +})); + +function createOutput(): { title: string; output: string; metadata: unknown } { + return { title: "tool", output: "", metadata: {} }; +} + +async function createProcessor(projectRoot: string): Promise<{ + processFilePathForInjection: ( + filePath: string, + sessionID: string, + output: { title: string; output: string; metadata: unknown } + ) => Promise; +}> { + const { createRuleInjectionProcessor } = await import("./injector"); + 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; + }, + }); +} + +function getInjectedRulesPath(sessionID: string): string { + 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; + + beforeEach(() => { + 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 }); + + writeFileSync(targetFile, "export const value = 1;\n"); + writeFileSync(ruleFile, "rule-content\n"); + + ruleRealPath = fs.realpathSync(ruleFile); + trackedRulePath = ruleFile; + statSnapshots = []; + trackedReadFileCount = 0; + mockedHomeDir = homeRoot; + }); + + afterEach(() => { + 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); + + // when + await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); + await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + + // 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); + + // when + await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); + await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + + // 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); + + // when + await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); + await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); + + // then + expect(trackedReadFileCount).toBe(2); + }); + + 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); + } + + 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); + }); +}); diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts index 9ba0324b..6b295000 100644 --- a/src/hooks/rules-injector/injector.ts +++ b/src/hooks/rules-injector/injector.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { relative, resolve } from "node:path"; import { findProjectRoot, findRuleFiles } from "./finder"; @@ -11,6 +11,7 @@ import { import { parseRuleFrontmatter } from "./parser"; import { saveInjectedRules } from "./storage"; import type { SessionInjectedRulesCache } from "./cache"; +import type { RuleMetadata } from "./types"; type ToolExecuteOutput = { title: string; @@ -32,6 +33,42 @@ type DynamicTruncator = { ) => Promise<{ result: string; truncated: boolean }>; }; +interface ParsedRuleEntry { + mtimeMs: number; + size: number; + metadata: RuleMetadata; + body: string; +} + +const parsedRuleCache = new Map(); + +function getCachedParsedRule( + filePath: string, + realPath: string +): { metadata: RuleMetadata; body: string } { + try { + const stat = statSync(filePath); + const cached = parsedRuleCache.get(realPath); + + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return { metadata: cached.metadata, body: cached.body }; + } + + const rawContent = readFileSync(filePath, "utf-8"); + const { metadata, body } = parseRuleFrontmatter(rawContent); + parsedRuleCache.set(realPath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + metadata, + body, + }); + return { metadata, body }; + } catch { + const rawContent = readFileSync(filePath, "utf-8"); + return parseRuleFrontmatter(rawContent); + } +} + function resolveFilePath( workspaceDirectory: string, path: string @@ -68,13 +105,16 @@ export function createRuleInjectionProcessor(deps: { const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved); const toInject: RuleToInject[] = []; + let dirty = false; for (const candidate of ruleFileCandidates) { if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue; try { - const rawContent = readFileSync(candidate.path, "utf-8"); - const { metadata, body } = parseRuleFrontmatter(rawContent); + const { metadata, body } = getCachedParsedRule( + candidate.path, + candidate.realPath + ); let matchReason: string; if (candidate.isSingleFile) { @@ -101,6 +141,7 @@ export function createRuleInjectionProcessor(deps: { cache.realPaths.add(candidate.realPath); cache.contentHashes.add(contentHash); + dirty = true; } catch {} } @@ -119,7 +160,9 @@ export function createRuleInjectionProcessor(deps: { output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; } - saveInjectedRules(sessionID, cache); + if (dirty) { + saveInjectedRules(sessionID, cache); + } } return { processFilePathForInjection };