Merge pull request #4134 from code-yeongyu/ulw/rule-comment-baseline-20260518

Optimize rules injector caches
This commit is contained in:
YeonGyu-Kim
2026-05-18 13:37:09 +09:00
committed by GitHub
5 changed files with 764 additions and 334 deletions
+2 -1
View File
@@ -3,7 +3,7 @@ import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id"; import { resolveSessionEventID } from "../../shared/event-session-id";
import { getRuleInjectionFilePath } from "./output-path"; import { getRuleInjectionFilePath } from "./output-path";
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache"; import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
import { createRuleInjectionProcessor } from "./injector"; import { clearParsedRuleCache, createRuleInjectionProcessor } from "./injector";
import { clearProjectRootCache } from "./project-root-finder"; import { clearProjectRootCache } from "./project-root-finder";
interface ToolExecuteInput { interface ToolExecuteInput {
@@ -53,6 +53,7 @@ export function createRulesInjectorHook(
function clearSessionState(sessionID: string): void { function clearSessionState(sessionID: string): void {
clearSessionCache(sessionID); clearSessionCache(sessionID);
clearSessionRuleScanCache(sessionID); clearSessionRuleScanCache(sessionID);
clearParsedRuleCache();
} }
const toolExecuteAfter = async ( const toolExecuteAfter = async (
+176 -19
View File
@@ -5,13 +5,18 @@ import * as os from "node:os";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { RULES_INJECTOR_STORAGE } from "./constants"; import { RULES_INJECTOR_STORAGE } from "./constants";
import { createRuleInjectionProcessor } from "./injector"; import {
clearParsedRuleCache,
createRuleInjectionProcessor,
getParsedRuleCacheStats,
} from "./injector";
type StatSnapshot = { mtimeMs: number; size: number }; type StatSnapshot = { mtimeMs: number; size: number };
let trackedRulePath = ""; let trackedRulePath = "";
let statSnapshots: Array<StatSnapshot | Error> = []; let statSnapshots: Array<StatSnapshot | Error> = [];
let trackedReadFileCount = 0; let trackedReadFileCount = 0;
let trackedShouldApplyRuleCount = 0;
let mockedHomeDir = ""; let mockedHomeDir = "";
const originalReadFileSync = fs.readFileSync.bind(fs); const originalReadFileSync = fs.readFileSync.bind(fs);
@@ -26,7 +31,7 @@ async function createProcessor(projectRoot: string): Promise<{
processFilePathForInjection: ( processFilePathForInjection: (
filePath: string, filePath: string,
sessionID: string, sessionID: string,
output: { title: string; output: string; metadata: unknown } output: { title: string; output: string; metadata: unknown },
) => Promise<void>; ) => Promise<void>;
}> { }> {
const sessionCaches = new Map< const sessionCaches = new Map<
@@ -55,11 +60,11 @@ async function createProcessor(projectRoot: string): Promise<{
} }
return cache; return cache;
}, },
readFileSync: (filePath: fs.PathOrFileDescriptor, options?: Parameters<typeof originalReadFileSync>[1]) => { readFileSync: (filePath: string, encoding: "utf-8") => {
if (filePath === trackedRulePath) { if (filePath === trackedRulePath) {
trackedReadFileCount += 1; trackedReadFileCount += 1;
} }
return originalReadFileSync(filePath, options as never); return originalReadFileSync(filePath, encoding);
}, },
statSync: (filePath: fs.PathLike) => { statSync: (filePath: fs.PathLike) => {
if (filePath === trackedRulePath) { if (filePath === trackedRulePath) {
@@ -78,10 +83,15 @@ async function createProcessor(projectRoot: string): Promise<{
return originalStatSync(filePath); return originalStatSync(filePath);
}, },
homedir: () => mockedHomeDir || originalHomedir(), homedir: () => mockedHomeDir || originalHomedir(),
shouldApplyRule: () => ({ applies: true, reason: "matched" }), shouldApplyRule: () => {
isDuplicateByRealPath: (realPath: string, cache: Set<string>) => cache.has(realPath), trackedShouldApplyRuleCount += 1;
return { applies: true, reason: "matched" };
},
isDuplicateByRealPath: (realPath: string, cache: Set<string>) =>
cache.has(realPath),
createContentHash: (content: string) => `hash:${content}`, createContentHash: (content: string) => `hash:${content}`,
isDuplicateByContentHash: (hash: string, cache: Set<string>) => cache.has(hash), isDuplicateByContentHash: (hash: string, cache: Set<string>) =>
cache.has(hash),
}); });
} }
@@ -98,6 +108,7 @@ describe("createRuleInjectionProcessor", () => {
let ruleRealPath: string; let ruleRealPath: string;
beforeEach(() => { beforeEach(() => {
clearParsedRuleCache();
testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`); testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`);
projectRoot = join(testRoot, "project"); projectRoot = join(testRoot, "project");
homeRoot = join(testRoot, "home"); homeRoot = join(testRoot, "home");
@@ -106,12 +117,14 @@ describe("createRuleInjectionProcessor", () => {
projectRoot, projectRoot,
".github", ".github",
"instructions", "instructions",
"typescript.instructions.md" "typescript.instructions.md",
); );
mkdirSync(join(projectRoot, ".git"), { recursive: true }); mkdirSync(join(projectRoot, ".git"), { recursive: true });
mkdirSync(join(projectRoot, "src"), { recursive: true }); mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(join(projectRoot, ".github", "instructions"), { recursive: true }); mkdirSync(join(projectRoot, ".github", "instructions"), {
recursive: true,
});
mkdirSync(homeRoot, { recursive: true }); mkdirSync(homeRoot, { recursive: true });
writeFileSync(targetFile, "export const value = 1;\n"); writeFileSync(targetFile, "export const value = 1;\n");
@@ -121,10 +134,12 @@ describe("createRuleInjectionProcessor", () => {
trackedRulePath = ruleFile; trackedRulePath = ruleFile;
statSnapshots = []; statSnapshots = [];
trackedReadFileCount = 0; trackedReadFileCount = 0;
trackedShouldApplyRuleCount = 0;
mockedHomeDir = homeRoot; mockedHomeDir = homeRoot;
}); });
afterEach(() => { afterEach(() => {
clearParsedRuleCache();
if (fs.existsSync(testRoot)) { if (fs.existsSync(testRoot)) {
rmSync(testRoot, { recursive: true, force: true }); rmSync(testRoot, { recursive: true, force: true });
} }
@@ -139,8 +154,16 @@ describe("createRuleInjectionProcessor", () => {
const processor = await createProcessor(projectRoot); const processor = await createProcessor(projectRoot);
// when // when
await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(
await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); targetFile,
"session-1",
createOutput(),
);
await processor.processFilePathForInjection(
targetFile,
"session-2",
createOutput(),
);
// then // then
expect(trackedReadFileCount).toBe(1); expect(trackedReadFileCount).toBe(1);
@@ -155,8 +178,16 @@ describe("createRuleInjectionProcessor", () => {
const processor = await createProcessor(projectRoot); const processor = await createProcessor(projectRoot);
// when // when
await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(
await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); targetFile,
"session-1",
createOutput(),
);
await processor.processFilePathForInjection(
targetFile,
"session-2",
createOutput(),
);
// then // then
expect(trackedReadFileCount).toBe(2); expect(trackedReadFileCount).toBe(2);
@@ -171,13 +202,122 @@ describe("createRuleInjectionProcessor", () => {
const processor = await createProcessor(projectRoot); const processor = await createProcessor(projectRoot);
// when // when
await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(
await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); targetFile,
"session-1",
createOutput(),
);
await processor.processFilePathForInjection(
targetFile,
"session-2",
createOutput(),
);
// then // then
expect(trackedReadFileCount).toBe(2); expect(trackedReadFileCount).toBe(2);
}); });
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(),
);
// then
expect(trackedShouldApplyRuleCount).toBe(1);
});
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);
// when
await processor.processFilePathForInjection(
targetFile,
"session-1",
createOutput(),
);
await processor.processFilePathForInjection(
targetFile,
"session-2",
createOutput(),
);
// then
expect(trackedShouldApplyRuleCount).toBe(2);
});
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);
// when
await processor.processFilePathForInjection(
targetFile,
"session-1",
createOutput(),
);
await processor.processFilePathForInjection(
secondTargetFile,
"session-2",
createOutput(),
);
// then
expect(trackedShouldApplyRuleCount).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 () => { it("does not save injected rules when all candidates are already cached", async () => {
// given // given
const sessionID = `dirty-no-new-${Date.now()}`; const sessionID = `dirty-no-new-${Date.now()}`;
@@ -202,7 +342,11 @@ describe("createRuleInjectionProcessor", () => {
}); });
// when // when
await processor.processFilePathForInjection(targetFile, sessionID, createOutput()); await processor.processFilePathForInjection(
targetFile,
sessionID,
createOutput(),
);
// then // then
expect(fs.existsSync(injectedPath)).toBe(false); expect(fs.existsSync(injectedPath)).toBe(false);
@@ -218,7 +362,11 @@ describe("createRuleInjectionProcessor", () => {
const processor = await createProcessor(projectRoot); const processor = await createProcessor(projectRoot);
// when // when
await processor.processFilePathForInjection(targetFile, sessionID, createOutput()); await processor.processFilePathForInjection(
targetFile,
sessionID,
createOutput(),
);
// then // then
expect(fs.existsSync(injectedPath)).toBe(true); expect(fs.existsSync(injectedPath)).toBe(true);
@@ -234,10 +382,19 @@ describe("createRuleInjectionProcessor", () => {
const processor = await createProcessor(projectRoot); const processor = await createProcessor(projectRoot);
// when // when
await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(
await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); targetFile,
"session-1",
createOutput(),
);
await processor.processFilePathForInjection(
targetFile,
"session-2",
createOutput(),
);
// then // then
expect(trackedReadFileCount).toBe(2); expect(trackedReadFileCount).toBe(2);
expect(trackedShouldApplyRuleCount).toBe(2);
}); });
}); });
+171 -19
View File
@@ -31,10 +31,12 @@ type RuleToInject = {
type DynamicTruncator = { type DynamicTruncator = {
truncate: ( truncate: (
sessionID: string, sessionID: string,
content: string content: string,
) => Promise<{ result: string; truncated: boolean }>; ) => Promise<{ result: string; truncated: boolean }>;
}; };
type RuleFileReader = (path: string, encoding: "utf-8") => string;
interface ParsedRuleEntry { interface ParsedRuleEntry {
mtimeMs: number; mtimeMs: number;
size: number; size: number;
@@ -42,11 +44,54 @@ interface ParsedRuleEntry {
body: string; body: string;
} }
type ParsedRule = {
metadata: RuleMetadata;
body: string;
statFingerprint: string | null;
};
type MatchDecisionCache = Map<string, string | null>;
export interface ParsedRuleCacheStats {
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<string, ParsedRuleEntry>(); const parsedRuleCache = new Map<string, ParsedRuleEntry>();
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( function resolveFilePath(
workspaceDirectory: string, workspaceDirectory: string,
path: string path: string,
): string | null { ): string | null {
if (!path) return null; if (!path) return null;
if (path.startsWith("/")) return path; if (path.startsWith("/")) return path;
@@ -59,7 +104,7 @@ export function createRuleInjectionProcessor(deps: {
getSessionCache: (sessionID: string) => SessionInjectedRulesCache; getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache; getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
ruleFinderOptions?: FindRuleFilesOptions; ruleFinderOptions?: FindRuleFilesOptions;
readFileSync?: typeof readFileSync; readFileSync?: RuleFileReader;
statSync?: typeof statSync; statSync?: typeof statSync;
homedir?: typeof homedir; homedir?: typeof homedir;
shouldApplyRule?: typeof shouldApplyRule; shouldApplyRule?: typeof shouldApplyRule;
@@ -71,7 +116,7 @@ export function createRuleInjectionProcessor(deps: {
processFilePathForInjection: ( processFilePathForInjection: (
filePath: string, filePath: string,
sessionID: string, sessionID: string,
output: ToolExecuteOutput output: ToolExecuteOutput,
) => Promise<void>; ) => Promise<void>;
} { } {
const { const {
@@ -86,38 +131,51 @@ export function createRuleInjectionProcessor(deps: {
shouldApplyRule: shouldApplyRuleImpl = shouldApplyRule, shouldApplyRule: shouldApplyRuleImpl = shouldApplyRule,
isDuplicateByRealPath: isDuplicateByRealPathImpl = isDuplicateByRealPath, isDuplicateByRealPath: isDuplicateByRealPathImpl = isDuplicateByRealPath,
createContentHash: createContentHashImpl = createContentHash, createContentHash: createContentHashImpl = createContentHash,
isDuplicateByContentHash: isDuplicateByContentHashImpl = isDuplicateByContentHash, isDuplicateByContentHash:
isDuplicateByContentHashImpl = isDuplicateByContentHash,
saveInjectedRules: saveInjectedRulesImpl = saveInjectedRules, saveInjectedRules: saveInjectedRulesImpl = saveInjectedRules,
} = deps; } = deps;
function getParsedRule(filePath: string, realPath: string): { metadata: RuleMetadata; body: string } { const matchDecisionCache: MatchDecisionCache = new Map();
function getParsedRule(filePath: string, realPath: string): ParsedRule {
try { try {
const stat = statRuleSync(filePath); const stat = statRuleSync(filePath);
const statFingerprint = `${stat.mtimeMs}:${stat.size}`;
const cached = parsedRuleCache.get(realPath); const cached = parsedRuleCache.get(realPath);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { if (
return { metadata: cached.metadata, body: cached.body }; cached &&
cached.mtimeMs === stat.mtimeMs &&
cached.size === stat.size
) {
return {
metadata: cached.metadata,
body: cached.body,
statFingerprint,
};
} }
const rawContent = readRuleFileSync(filePath, "utf-8"); const rawContent = readRuleFileSync(filePath, "utf-8");
const { metadata, body } = parseRuleFrontmatter(rawContent); const { metadata, body } = parseRuleFrontmatter(rawContent);
parsedRuleCache.set(realPath, { setParsedRuleCacheEntry(realPath, {
mtimeMs: stat.mtimeMs, mtimeMs: stat.mtimeMs,
size: stat.size, size: stat.size,
metadata, metadata,
body, body,
}); });
return { metadata, body }; return { metadata, body, statFingerprint };
} catch { } catch {
const rawContent = readRuleFileSync(filePath, "utf-8"); const rawContent = readRuleFileSync(filePath, "utf-8");
return parseRuleFrontmatter(rawContent); const { metadata, body } = parseRuleFrontmatter(rawContent);
return { metadata, body, statFingerprint: null };
} }
} }
async function processFilePathForInjection( async function processFilePathForInjection(
filePath: string, filePath: string,
sessionID: string, sessionID: string,
output: ToolExecuteOutput output: ToolExecuteOutput,
): Promise<void> { ): Promise<void> {
const resolved = resolveFilePath(workspaceDirectory, filePath); const resolved = resolveFilePath(workspaceDirectory, filePath);
if (!resolved) return; if (!resolved) return;
@@ -138,25 +196,61 @@ export function createRuleInjectionProcessor(deps: {
let dirty = false; let dirty = false;
for (const candidate of ruleFileCandidates) { for (const candidate of ruleFileCandidates) {
if (isDuplicateByRealPathImpl(candidate.realPath, cache.realPaths)) continue; if (isDuplicateByRealPathImpl(candidate.realPath, cache.realPaths))
continue;
try { try {
const { metadata, body } = getParsedRule( const { metadata, body, statFingerprint } = getParsedRule(
candidate.path, candidate.path,
candidate.realPath candidate.realPath,
); );
let matchReason: string; let matchReason: string;
if (candidate.isSingleFile) { if (candidate.isSingleFile) {
matchReason = "copilot-instructions (always apply)"; matchReason = "copilot-instructions (always apply)";
} else { } else {
const matchResult = shouldApplyRuleImpl(metadata, resolved, projectRoot); const cachedMatchReason = getCachedMatchReason(
if (!matchResult.applies) continue; 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"; matchReason = matchResult.reason ?? "matched";
setCachedMatchReason(
matchDecisionCache,
projectRoot,
resolved,
candidate.realPath,
statFingerprint,
matchReason,
);
}
} }
const contentHash = createContentHashImpl(body); const contentHash = createContentHashImpl(body);
if (isDuplicateByContentHashImpl(contentHash, cache.contentHashes)) continue; if (isDuplicateByContentHashImpl(contentHash, cache.contentHashes))
continue;
const relativePath = projectRoot const relativePath = projectRoot
? relative(projectRoot, candidate.path) ? relative(projectRoot, candidate.path)
@@ -182,7 +276,7 @@ export function createRuleInjectionProcessor(deps: {
for (const rule of toInject) { for (const rule of toInject) {
const { result, truncated } = await truncator.truncate( const { result, truncated } = await truncator.truncate(
sessionID, sessionID,
rule.content rule.content,
); );
const truncationNotice = truncated const truncationNotice = truncated
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]` ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
@@ -197,3 +291,61 @@ export function createRuleInjectionProcessor(deps: {
return { processFilePathForInjection }; 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",
);
}
+83
View File
@@ -0,0 +1,83 @@
/// <reference path="../../../bun-test.d.ts" />
import { beforeEach, describe, expect, it } from "bun:test"
import {
createContentHash,
getMatcherCacheStats,
isDuplicateByContentHash,
isDuplicateByRealPath,
resetMatcherCache,
shouldApplyRule,
} from "./matcher"
describe("shouldApplyRule", () => {
beforeEach(() => {
resetMatcherCache()
})
it("#given repeated glob metadata #when matching many files #then compiles each pattern once", () => {
// given
const metadata = { globs: ["src/**/*.ts", "test/**/*.ts"] }
const projectRoot = "/workspace/project"
// when
for (let index = 0; index < 20; index += 1) {
shouldApplyRule(metadata, `${projectRoot}/src/file-${index}.ts`, projectRoot)
shouldApplyRule(metadata, `${projectRoot}/test/file-${index}.ts`, projectRoot)
}
// then
expect(getMatcherCacheStats()).toEqual({ entries: 2 })
})
it("#given many unique globs #when matching repeatedly #then matcher cache stays bounded", () => {
// given
const projectRoot = "/workspace/project"
// when
for (let index = 0; index < 300; index += 1) {
shouldApplyRule({ globs: `src/file-${index}.ts` }, `${projectRoot}/src/file-${index}.ts`, projectRoot)
}
// then
expect(getMatcherCacheStats().entries <= 256).toBe(true)
})
it("#given matching glob #when path is under project root #then returns matching reason", () => {
// given / when
const result = shouldApplyRule({ globs: "src/**/*.ts" }, "/workspace/project/src/index.ts", "/workspace/project")
// then
expect(result).toEqual({ applies: true, reason: "glob: src/**/*.ts" })
})
it("#given always apply metadata #when no globs exist #then applies without compiling matchers", () => {
// given / when
const result = shouldApplyRule({ alwaysApply: true }, "/workspace/project/src/index.ts", "/workspace/project")
// then
expect(result).toEqual({ applies: true, reason: "alwaysApply" })
expect(getMatcherCacheStats()).toEqual({ entries: 0 })
})
})
describe("rule duplicate helpers", () => {
it("#given real path cache #when path exists #then reports duplicate", () => {
// given
const cache = new Set(["/workspace/project/AGENTS.md"])
// when / then
expect(isDuplicateByRealPath("/workspace/project/AGENTS.md", cache)).toBe(true)
expect(isDuplicateByRealPath("/workspace/project/src/AGENTS.md", cache)).toBe(false)
})
it("#given content #when hashing #then duplicate helper uses truncated hash", () => {
// given
const hash = createContentHash("rule-content")
const cache = new Set([hash])
// when / then
expect(hash).toHaveLength(16)
expect(isDuplicateByContentHash(hash, cache)).toBe(true)
})
})
+38 -1
View File
@@ -3,11 +3,48 @@ import { relative } from "node:path"
import picomatch from "picomatch" import picomatch from "picomatch"
import type { RuleMetadata } from "./types" import type { RuleMetadata } from "./types"
type PathMatcher = (path: string) => boolean
export interface MatchResult { export interface MatchResult {
applies: boolean applies: boolean
reason?: string reason?: string
} }
export interface MatcherCacheStats {
entries: number
}
const PICOMATCH_OPTIONS = { dot: true, bash: true } as const
const MAX_MATCHER_CACHE_ENTRIES = 256
const matcherCache = new Map<string, PathMatcher>()
function matcherFor(pattern: string): PathMatcher {
const cached = matcherCache.get(pattern)
if (cached) {
matcherCache.delete(pattern)
matcherCache.set(pattern, cached)
return cached
}
const matcher = picomatch(pattern, PICOMATCH_OPTIONS)
if (matcherCache.size >= MAX_MATCHER_CACHE_ENTRIES) {
const oldestPattern = matcherCache.keys().next().value
if (oldestPattern !== undefined) {
matcherCache.delete(oldestPattern)
}
}
matcherCache.set(pattern, matcher)
return matcher
}
export function resetMatcherCache(): void {
matcherCache.clear()
}
export function getMatcherCacheStats(): MatcherCacheStats {
return { entries: matcherCache.size }
}
/** /**
* Check if a rule should apply to the current file based on metadata * Check if a rule should apply to the current file based on metadata
*/ */
@@ -33,7 +70,7 @@ export function shouldApplyRule(
const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath
for (const pattern of patterns) { for (const pattern of patterns) {
if (picomatch.isMatch(relativePath, pattern, { dot: true, bash: true })) { if (matcherFor(pattern)(relativePath)) {
return { applies: true, reason: `glob: ${pattern}` } return { applies: true, reason: `glob: ${pattern}` }
} }
} }