perf(rules-injector): add mtime-based parse cache and dirty-write gate

This commit is contained in:
YeonGyu-Kim 2026-02-13 11:46:45 +09:00
parent 4a991b5a83
commit 75f35f1337
2 changed files with 302 additions and 4 deletions

View File

@ -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<StatSnapshot | Error> = [];
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<typeof originalStatSync>;
}
}
return originalStatSync(filePath);
},
}));
mock.module("node:os", () => ({
...os,
homedir: () => mockedHomeDir || originalHomedir(),
}));
mock.module("./matcher", () => ({
shouldApplyRule: () => ({ applies: true, reason: "matched" }),
isDuplicateByRealPath: (realPath: string, cache: Set<string>) =>
cache.has(realPath),
createContentHash: (content: string) => `hash:${content}`,
isDuplicateByContentHash: (hash: string, cache: Set<string>) => 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<void>;
}> {
const { createRuleInjectionProcessor } = await import("./injector");
const sessionCaches = new Map<
string,
{ contentHashes: Set<string>; realPaths: Set<string> }
>();
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<string>(),
realPaths: new Set<string>(),
});
}
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<string>(),
realPaths: new Set<string>([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);
});
});

View File

@ -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<string, ParsedRuleEntry>();
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 };