perf(rules-injector): cache match decisions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -5,13 +5,18 @@ import * as os from "node:os";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { RULES_INJECTOR_STORAGE } from "./constants";
|
||||
import { clearParsedRuleCache, createRuleInjectionProcessor, getParsedRuleCacheStats } from "./injector";
|
||||
import {
|
||||
clearParsedRuleCache,
|
||||
createRuleInjectionProcessor,
|
||||
getParsedRuleCacheStats,
|
||||
} from "./injector";
|
||||
|
||||
type StatSnapshot = { mtimeMs: number; size: number };
|
||||
|
||||
let trackedRulePath = "";
|
||||
let statSnapshots: Array<StatSnapshot | Error> = [];
|
||||
let trackedReadFileCount = 0;
|
||||
let trackedShouldApplyRuleCount = 0;
|
||||
let mockedHomeDir = "";
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs);
|
||||
@@ -19,246 +24,377 @@ const originalStatSync = fs.statSync.bind(fs);
|
||||
const originalHomedir = os.homedir.bind(os);
|
||||
|
||||
function createOutput(): { title: string; output: string; metadata: unknown } {
|
||||
return { title: "tool", output: "", metadata: {} };
|
||||
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>;
|
||||
processFilePathForInjection: (
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: { title: string; output: string; metadata: unknown },
|
||||
) => Promise<void>;
|
||||
}> {
|
||||
const sessionCaches = new Map<
|
||||
string,
|
||||
{ contentHashes: Set<string>; realPaths: Set<string> }
|
||||
>();
|
||||
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;
|
||||
},
|
||||
readFileSync: (filePath: string, encoding: "utf-8") => {
|
||||
if (filePath === trackedRulePath) {
|
||||
trackedReadFileCount += 1;
|
||||
}
|
||||
return originalReadFileSync(filePath, encoding);
|
||||
},
|
||||
statSync: (filePath: fs.PathLike) => {
|
||||
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);
|
||||
},
|
||||
homedir: () => mockedHomeDir || originalHomedir(),
|
||||
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),
|
||||
});
|
||||
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;
|
||||
},
|
||||
readFileSync: (filePath: string, encoding: "utf-8") => {
|
||||
if (filePath === trackedRulePath) {
|
||||
trackedReadFileCount += 1;
|
||||
}
|
||||
return originalReadFileSync(filePath, encoding);
|
||||
},
|
||||
statSync: (filePath: fs.PathLike) => {
|
||||
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);
|
||||
},
|
||||
homedir: () => mockedHomeDir || originalHomedir(),
|
||||
shouldApplyRule: () => {
|
||||
trackedShouldApplyRuleCount += 1;
|
||||
return { 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 getInjectedRulesPath(sessionID: string): string {
|
||||
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
|
||||
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;
|
||||
let testRoot: string;
|
||||
let projectRoot: string;
|
||||
let homeRoot: string;
|
||||
let targetFile: string;
|
||||
let ruleFile: string;
|
||||
let ruleRealPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
clearParsedRuleCache();
|
||||
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"
|
||||
);
|
||||
beforeEach(() => {
|
||||
clearParsedRuleCache();
|
||||
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 });
|
||||
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");
|
||||
writeFileSync(targetFile, "export const value = 1;\n");
|
||||
writeFileSync(ruleFile, "rule-content\n");
|
||||
|
||||
ruleRealPath = fs.realpathSync(ruleFile);
|
||||
trackedRulePath = ruleFile;
|
||||
statSnapshots = [];
|
||||
trackedReadFileCount = 0;
|
||||
mockedHomeDir = homeRoot;
|
||||
});
|
||||
ruleRealPath = fs.realpathSync(ruleFile);
|
||||
trackedRulePath = ruleFile;
|
||||
statSnapshots = [];
|
||||
trackedReadFileCount = 0;
|
||||
trackedShouldApplyRuleCount = 0;
|
||||
mockedHomeDir = homeRoot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearParsedRuleCache();
|
||||
if (fs.existsSync(testRoot)) {
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
afterEach(() => {
|
||||
clearParsedRuleCache();
|
||||
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);
|
||||
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());
|
||||
// when
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-1",
|
||||
createOutput(),
|
||||
);
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-2",
|
||||
createOutput(),
|
||||
);
|
||||
|
||||
// then
|
||||
expect(trackedReadFileCount).toBe(1);
|
||||
});
|
||||
// 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);
|
||||
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());
|
||||
// when
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-1",
|
||||
createOutput(),
|
||||
);
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-2",
|
||||
createOutput(),
|
||||
);
|
||||
|
||||
// then
|
||||
expect(trackedReadFileCount).toBe(2);
|
||||
});
|
||||
// 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);
|
||||
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());
|
||||
// when
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-1",
|
||||
createOutput(),
|
||||
);
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-2",
|
||||
createOutput(),
|
||||
);
|
||||
|
||||
// then
|
||||
expect(trackedReadFileCount).toBe(2);
|
||||
});
|
||||
// then
|
||||
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);
|
||||
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());
|
||||
// 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 });
|
||||
});
|
||||
// then
|
||||
expect(trackedShouldApplyRuleCount).toBe(1);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
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,
|
||||
"session-1",
|
||||
createOutput(),
|
||||
);
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-2",
|
||||
createOutput(),
|
||||
);
|
||||
|
||||
// when
|
||||
await processor.processFilePathForInjection(targetFile, sessionID, createOutput());
|
||||
// then
|
||||
expect(trackedShouldApplyRuleCount).toBe(2);
|
||||
});
|
||||
|
||||
// then
|
||||
expect(fs.existsSync(injectedPath)).toBe(false);
|
||||
});
|
||||
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);
|
||||
|
||||
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,
|
||||
"session-1",
|
||||
createOutput(),
|
||||
);
|
||||
await processor.processFilePathForInjection(
|
||||
secondTargetFile,
|
||||
"session-2",
|
||||
createOutput(),
|
||||
);
|
||||
|
||||
// when
|
||||
await processor.processFilePathForInjection(targetFile, sessionID, createOutput());
|
||||
// then
|
||||
expect(trackedShouldApplyRuleCount).toBe(2);
|
||||
});
|
||||
|
||||
// then
|
||||
expect(fs.existsSync(injectedPath)).toBe(true);
|
||||
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);
|
||||
|
||||
if (fs.existsSync(injectedPath)) {
|
||||
fs.unlinkSync(injectedPath);
|
||||
}
|
||||
});
|
||||
// when
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-1",
|
||||
createOutput(),
|
||||
);
|
||||
await processor.processFilePathForInjection(
|
||||
targetFile,
|
||||
"session-2",
|
||||
createOutput(),
|
||||
);
|
||||
|
||||
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);
|
||||
// then
|
||||
expect(trackedReadFileCount).toBe(2);
|
||||
expect(getParsedRuleCacheStats()).toEqual({ entries: 0, bodyBytes: 0 });
|
||||
});
|
||||
|
||||
// when
|
||||
await processor.processFilePathForInjection(targetFile, "session-1", createOutput());
|
||||
await processor.processFilePathForInjection(targetFile, "session-2", createOutput());
|
||||
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);
|
||||
}
|
||||
|
||||
// then
|
||||
expect(trackedReadFileCount).toBe(2);
|
||||
});
|
||||
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);
|
||||
expect(trackedShouldApplyRuleCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,10 @@ import { relative, resolve } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
import type { FindRuleFilesOptions } from "./rule-file-finder";
|
||||
import {
|
||||
createContentHash,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
shouldApplyRule,
|
||||
createContentHash,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
shouldApplyRule,
|
||||
} from "./matcher";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
import { saveInjectedRules } from "./storage";
|
||||
@@ -16,216 +16,336 @@ import type { RuleScanCache } from "./rule-scan-cache";
|
||||
import type { RuleMetadata } from "./types";
|
||||
|
||||
type ToolExecuteOutput = {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
};
|
||||
|
||||
type RuleToInject = {
|
||||
relativePath: string;
|
||||
matchReason: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
relativePath: string;
|
||||
matchReason: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
};
|
||||
|
||||
type DynamicTruncator = {
|
||||
truncate: (
|
||||
sessionID: string,
|
||||
content: string
|
||||
) => Promise<{ result: string; truncated: boolean }>;
|
||||
truncate: (
|
||||
sessionID: string,
|
||||
content: string,
|
||||
) => Promise<{ result: string; truncated: boolean }>;
|
||||
};
|
||||
|
||||
type RuleFileReader = (path: string, encoding: "utf-8") => string;
|
||||
|
||||
interface ParsedRuleEntry {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
metadata: RuleMetadata;
|
||||
body: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
metadata: RuleMetadata;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type ParsedRule = {
|
||||
metadata: RuleMetadata;
|
||||
body: string;
|
||||
statFingerprint: string | null;
|
||||
};
|
||||
|
||||
type MatchDecisionCache = Map<string, string | null>;
|
||||
|
||||
export interface ParsedRuleCacheStats {
|
||||
entries: number;
|
||||
bodyBytes: number;
|
||||
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>();
|
||||
|
||||
export function clearParsedRuleCache(): void {
|
||||
parsedRuleCache.clear();
|
||||
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 };
|
||||
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 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
|
||||
workspaceDirectory: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(workspaceDirectory, path);
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(workspaceDirectory, path);
|
||||
}
|
||||
|
||||
export function createRuleInjectionProcessor(deps: {
|
||||
workspaceDirectory: string;
|
||||
truncator: DynamicTruncator;
|
||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
|
||||
ruleFinderOptions?: FindRuleFilesOptions;
|
||||
readFileSync?: RuleFileReader;
|
||||
statSync?: typeof statSync;
|
||||
homedir?: typeof homedir;
|
||||
shouldApplyRule?: typeof shouldApplyRule;
|
||||
isDuplicateByRealPath?: typeof isDuplicateByRealPath;
|
||||
createContentHash?: typeof createContentHash;
|
||||
isDuplicateByContentHash?: typeof isDuplicateByContentHash;
|
||||
saveInjectedRules?: typeof saveInjectedRules;
|
||||
workspaceDirectory: string;
|
||||
truncator: DynamicTruncator;
|
||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
|
||||
ruleFinderOptions?: FindRuleFilesOptions;
|
||||
readFileSync?: RuleFileReader;
|
||||
statSync?: typeof statSync;
|
||||
homedir?: typeof homedir;
|
||||
shouldApplyRule?: typeof shouldApplyRule;
|
||||
isDuplicateByRealPath?: typeof isDuplicateByRealPath;
|
||||
createContentHash?: typeof createContentHash;
|
||||
isDuplicateByContentHash?: typeof isDuplicateByContentHash;
|
||||
saveInjectedRules?: typeof saveInjectedRules;
|
||||
}): {
|
||||
processFilePathForInjection: (
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
) => Promise<void>;
|
||||
processFilePathForInjection: (
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput,
|
||||
) => Promise<void>;
|
||||
} {
|
||||
const {
|
||||
workspaceDirectory,
|
||||
truncator,
|
||||
getSessionCache,
|
||||
getSessionRuleScanCache,
|
||||
ruleFinderOptions,
|
||||
readFileSync: readRuleFileSync = readFileSync,
|
||||
statSync: statRuleSync = statSync,
|
||||
homedir: getHomeDir = homedir,
|
||||
shouldApplyRule: shouldApplyRuleImpl = shouldApplyRule,
|
||||
isDuplicateByRealPath: isDuplicateByRealPathImpl = isDuplicateByRealPath,
|
||||
createContentHash: createContentHashImpl = createContentHash,
|
||||
isDuplicateByContentHash: isDuplicateByContentHashImpl = isDuplicateByContentHash,
|
||||
saveInjectedRules: saveInjectedRulesImpl = saveInjectedRules,
|
||||
} = deps;
|
||||
const {
|
||||
workspaceDirectory,
|
||||
truncator,
|
||||
getSessionCache,
|
||||
getSessionRuleScanCache,
|
||||
ruleFinderOptions,
|
||||
readFileSync: readRuleFileSync = readFileSync,
|
||||
statSync: statRuleSync = statSync,
|
||||
homedir: getHomeDir = homedir,
|
||||
shouldApplyRule: shouldApplyRuleImpl = shouldApplyRule,
|
||||
isDuplicateByRealPath: isDuplicateByRealPathImpl = isDuplicateByRealPath,
|
||||
createContentHash: createContentHashImpl = createContentHash,
|
||||
isDuplicateByContentHash:
|
||||
isDuplicateByContentHashImpl = isDuplicateByContentHash,
|
||||
saveInjectedRules: saveInjectedRulesImpl = saveInjectedRules,
|
||||
} = deps;
|
||||
|
||||
function getParsedRule(filePath: string, realPath: string): { metadata: RuleMetadata; body: string } {
|
||||
try {
|
||||
const stat = statRuleSync(filePath);
|
||||
const cached = parsedRuleCache.get(realPath);
|
||||
const matchDecisionCache: MatchDecisionCache = new Map();
|
||||
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||
return { metadata: cached.metadata, body: cached.body };
|
||||
}
|
||||
function getParsedRule(filePath: string, realPath: string): ParsedRule {
|
||||
try {
|
||||
const stat = statRuleSync(filePath);
|
||||
const statFingerprint = `${stat.mtimeMs}:${stat.size}`;
|
||||
const cached = parsedRuleCache.get(realPath);
|
||||
|
||||
const rawContent = readRuleFileSync(filePath, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
setParsedRuleCacheEntry(realPath, {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
metadata,
|
||||
body,
|
||||
});
|
||||
return { metadata, body };
|
||||
} catch {
|
||||
const rawContent = readRuleFileSync(filePath, "utf-8");
|
||||
return parseRuleFrontmatter(rawContent);
|
||||
}
|
||||
}
|
||||
if (
|
||||
cached &&
|
||||
cached.mtimeMs === stat.mtimeMs &&
|
||||
cached.size === stat.size
|
||||
) {
|
||||
return {
|
||||
metadata: cached.metadata,
|
||||
body: cached.body,
|
||||
statFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(workspaceDirectory, filePath);
|
||||
if (!resolved) return;
|
||||
const rawContent = readRuleFileSync(filePath, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
setParsedRuleCacheEntry(realPath, {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
metadata,
|
||||
body,
|
||||
});
|
||||
return { metadata, body, statFingerprint };
|
||||
} catch {
|
||||
const rawContent = readRuleFileSync(filePath, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
return { metadata, body, statFingerprint: null };
|
||||
}
|
||||
}
|
||||
|
||||
const projectRoot = findProjectRoot(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const ruleScanCache = getSessionRuleScanCache?.(sessionID);
|
||||
const home = getHomeDir();
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput,
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(workspaceDirectory, filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const ruleFileCandidates = findRuleFiles(
|
||||
projectRoot,
|
||||
home,
|
||||
resolved,
|
||||
ruleFinderOptions,
|
||||
ruleScanCache,
|
||||
);
|
||||
const toInject: RuleToInject[] = [];
|
||||
let dirty = false;
|
||||
const projectRoot = findProjectRoot(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const ruleScanCache = getSessionRuleScanCache?.(sessionID);
|
||||
const home = getHomeDir();
|
||||
|
||||
for (const candidate of ruleFileCandidates) {
|
||||
if (isDuplicateByRealPathImpl(candidate.realPath, cache.realPaths)) continue;
|
||||
const ruleFileCandidates = findRuleFiles(
|
||||
projectRoot,
|
||||
home,
|
||||
resolved,
|
||||
ruleFinderOptions,
|
||||
ruleScanCache,
|
||||
);
|
||||
const toInject: RuleToInject[] = [];
|
||||
let dirty = false;
|
||||
|
||||
try {
|
||||
const { metadata, body } = getParsedRule(
|
||||
candidate.path,
|
||||
candidate.realPath
|
||||
);
|
||||
for (const candidate of ruleFileCandidates) {
|
||||
if (isDuplicateByRealPathImpl(candidate.realPath, cache.realPaths))
|
||||
continue;
|
||||
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const matchResult = shouldApplyRuleImpl(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
matchReason = matchResult.reason ?? "matched";
|
||||
}
|
||||
try {
|
||||
const { metadata, body, statFingerprint } = getParsedRule(
|
||||
candidate.path,
|
||||
candidate.realPath,
|
||||
);
|
||||
|
||||
const contentHash = createContentHashImpl(body);
|
||||
if (isDuplicateByContentHashImpl(contentHash, cache.contentHashes)) continue;
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const cachedMatchReason = getCachedMatchReason(
|
||||
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";
|
||||
setCachedMatchReason(
|
||||
matchDecisionCache,
|
||||
projectRoot,
|
||||
resolved,
|
||||
candidate.realPath,
|
||||
statFingerprint,
|
||||
matchReason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const relativePath = projectRoot
|
||||
? relative(projectRoot, candidate.path)
|
||||
: candidate.path;
|
||||
const contentHash = createContentHashImpl(body);
|
||||
if (isDuplicateByContentHashImpl(contentHash, cache.contentHashes))
|
||||
continue;
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
const relativePath = projectRoot
|
||||
? relative(projectRoot, candidate.path)
|
||||
: candidate.path;
|
||||
|
||||
cache.realPaths.add(candidate.realPath);
|
||||
cache.contentHashes.add(contentHash);
|
||||
dirty = true;
|
||||
} catch {}
|
||||
}
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
cache.realPaths.add(candidate.realPath);
|
||||
cache.contentHashes.add(contentHash);
|
||||
dirty = true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
for (const rule of toInject) {
|
||||
const { result, truncated } = await truncator.truncate(
|
||||
sessionID,
|
||||
rule.content
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
|
||||
}
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
if (dirty) {
|
||||
saveInjectedRulesImpl(sessionID, cache);
|
||||
}
|
||||
}
|
||||
for (const rule of toInject) {
|
||||
const { result, truncated } = await truncator.truncate(
|
||||
sessionID,
|
||||
rule.content,
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
|
||||
}
|
||||
|
||||
return { processFilePathForInjection };
|
||||
if (dirty) {
|
||||
saveInjectedRulesImpl(sessionID, cache);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user