Merge pull request #4134 from code-yeongyu/ulw/rule-comment-baseline-20260518
Optimize rules injector caches
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user