From 61890f08575942391efecb62c99832d55e6ce722 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 12:40:12 +0900 Subject: [PATCH] fix(rules-injector): bound parsed rule cache Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/rules-injector/hook.ts | 3 +- src/hooks/rules-injector/injector.test.ts | 27 +++++++++++++++-- src/hooks/rules-injector/injector.ts | 36 +++++++++++++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/hooks/rules-injector/hook.ts b/src/hooks/rules-injector/hook.ts index 3b62d5e01..51cd31146 100644 --- a/src/hooks/rules-injector/hook.ts +++ b/src/hooks/rules-injector/hook.ts @@ -3,7 +3,7 @@ import { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { resolveSessionEventID } from "../../shared/event-session-id"; import { getRuleInjectionFilePath } from "./output-path"; import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache"; -import { createRuleInjectionProcessor } from "./injector"; +import { clearParsedRuleCache, createRuleInjectionProcessor } from "./injector"; import { clearProjectRootCache } from "./project-root-finder"; interface ToolExecuteInput { @@ -53,6 +53,7 @@ export function createRulesInjectorHook( function clearSessionState(sessionID: string): void { clearSessionCache(sessionID); clearSessionRuleScanCache(sessionID); + clearParsedRuleCache(); } const toolExecuteAfter = async ( diff --git a/src/hooks/rules-injector/injector.test.ts b/src/hooks/rules-injector/injector.test.ts index 88b8076d4..8eb23c029 100644 --- a/src/hooks/rules-injector/injector.test.ts +++ b/src/hooks/rules-injector/injector.test.ts @@ -5,7 +5,7 @@ import * as os from "node:os"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { RULES_INJECTOR_STORAGE } from "./constants"; -import { createRuleInjectionProcessor } from "./injector"; +import { clearParsedRuleCache, createRuleInjectionProcessor, getParsedRuleCacheStats } from "./injector"; type StatSnapshot = { mtimeMs: number; size: number }; @@ -55,11 +55,11 @@ async function createProcessor(projectRoot: string): Promise<{ } return cache; }, - readFileSync: (filePath: fs.PathOrFileDescriptor, options?: Parameters[1]) => { + readFileSync: (filePath: string, encoding: "utf-8") => { if (filePath === trackedRulePath) { trackedReadFileCount += 1; } - return originalReadFileSync(filePath, options as never); + return originalReadFileSync(filePath, encoding); }, statSync: (filePath: fs.PathLike) => { if (filePath === trackedRulePath) { @@ -98,6 +98,7 @@ describe("createRuleInjectionProcessor", () => { let ruleRealPath: string; beforeEach(() => { + clearParsedRuleCache(); testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`); projectRoot = join(testRoot, "project"); homeRoot = join(testRoot, "home"); @@ -125,6 +126,7 @@ describe("createRuleInjectionProcessor", () => { }); afterEach(() => { + clearParsedRuleCache(); if (fs.existsSync(testRoot)) { rmSync(testRoot, { recursive: true, force: true }); } @@ -178,6 +180,25 @@ describe("createRuleInjectionProcessor", () => { 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); + + // 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 }); + }); + it("does not save injected rules when all candidates are already cached", async () => { // given const sessionID = `dirty-no-new-${Date.now()}`; diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts index 0cd64be5b..8dc517c58 100644 --- a/src/hooks/rules-injector/injector.ts +++ b/src/hooks/rules-injector/injector.ts @@ -35,6 +35,8 @@ type DynamicTruncator = { ) => Promise<{ result: string; truncated: boolean }>; }; +type RuleFileReader = (path: string, encoding: "utf-8") => string; + interface ParsedRuleEntry { mtimeMs: number; size: number; @@ -42,8 +44,38 @@ interface ParsedRuleEntry { body: string; } +export interface ParsedRuleCacheStats { + entries: number; + bodyBytes: number; +} + +const MAX_PARSED_RULE_CACHE_ENTRIES = 256; +const MAX_PARSED_RULE_CACHE_BODY_BYTES = 64 * 1024; const parsedRuleCache = new Map(); +export function clearParsedRuleCache(): void { + 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 }; +} + +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 @@ -59,7 +91,7 @@ export function createRuleInjectionProcessor(deps: { getSessionCache: (sessionID: string) => SessionInjectedRulesCache; getSessionRuleScanCache?: (sessionID: string) => RuleScanCache; ruleFinderOptions?: FindRuleFilesOptions; - readFileSync?: typeof readFileSync; + readFileSync?: RuleFileReader; statSync?: typeof statSync; homedir?: typeof homedir; shouldApplyRule?: typeof shouldApplyRule; @@ -101,7 +133,7 @@ export function createRuleInjectionProcessor(deps: { const rawContent = readRuleFileSync(filePath, "utf-8"); const { metadata, body } = parseRuleFrontmatter(rawContent); - parsedRuleCache.set(realPath, { + setParsedRuleCacheEntry(realPath, { mtimeMs: stat.mtimeMs, size: stat.size, metadata,