2026-02-13 11:46:45 +09:00
|
|
|
import { readFileSync, statSync } from "node:fs";
|
2026-02-08 13:57:26 +09:00
|
|
|
import { homedir } from "node:os";
|
|
|
|
|
import { relative, resolve } from "node:path";
|
|
|
|
|
import { findProjectRoot, findRuleFiles } from "./finder";
|
2026-03-29 10:13:55 +07:00
|
|
|
import type { FindRuleFilesOptions } from "./rule-file-finder";
|
2026-02-08 13:57:26 +09:00
|
|
|
import {
|
|
|
|
|
createContentHash,
|
|
|
|
|
isDuplicateByContentHash,
|
|
|
|
|
isDuplicateByRealPath,
|
|
|
|
|
shouldApplyRule,
|
|
|
|
|
} from "./matcher";
|
|
|
|
|
import { parseRuleFrontmatter } from "./parser";
|
|
|
|
|
import { saveInjectedRules } from "./storage";
|
|
|
|
|
import type { SessionInjectedRulesCache } from "./cache";
|
2026-04-18 14:13:51 +09:00
|
|
|
import type { RuleScanCache } from "./rule-scan-cache";
|
2026-02-13 11:46:45 +09:00
|
|
|
import type { RuleMetadata } from "./types";
|
2026-02-08 13:57:26 +09:00
|
|
|
|
|
|
|
|
type ToolExecuteOutput = {
|
|
|
|
|
title: string;
|
|
|
|
|
output: string;
|
|
|
|
|
metadata: unknown;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type RuleToInject = {
|
|
|
|
|
relativePath: string;
|
|
|
|
|
matchReason: string;
|
|
|
|
|
content: string;
|
|
|
|
|
distance: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type DynamicTruncator = {
|
|
|
|
|
truncate: (
|
|
|
|
|
sessionID: string,
|
|
|
|
|
content: string
|
|
|
|
|
) => Promise<{ result: string; truncated: boolean }>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 12:40:12 +09:00
|
|
|
type RuleFileReader = (path: string, encoding: "utf-8") => string;
|
|
|
|
|
|
2026-02-13 11:46:45 +09:00
|
|
|
interface ParsedRuleEntry {
|
|
|
|
|
mtimeMs: number;
|
|
|
|
|
size: number;
|
|
|
|
|
metadata: RuleMetadata;
|
|
|
|
|
body: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 12:40:12 +09:00
|
|
|
export interface ParsedRuleCacheStats {
|
|
|
|
|
entries: number;
|
|
|
|
|
bodyBytes: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_PARSED_RULE_CACHE_ENTRIES = 256;
|
|
|
|
|
const MAX_PARSED_RULE_CACHE_BODY_BYTES = 64 * 1024;
|
2026-02-13 11:46:45 +09:00
|
|
|
const parsedRuleCache = new Map<string, ParsedRuleEntry>();
|
|
|
|
|
|
2026-05-18 12:40:12 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 13:57:26 +09:00
|
|
|
function resolveFilePath(
|
|
|
|
|
workspaceDirectory: string,
|
|
|
|
|
path: string
|
|
|
|
|
): string | null {
|
|
|
|
|
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;
|
2026-04-18 14:13:51 +09:00
|
|
|
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
|
2026-03-29 10:13:55 +07:00
|
|
|
ruleFinderOptions?: FindRuleFilesOptions;
|
2026-05-18 12:40:12 +09:00
|
|
|
readFileSync?: RuleFileReader;
|
2026-04-10 15:53:10 +09:00
|
|
|
statSync?: typeof statSync;
|
|
|
|
|
homedir?: typeof homedir;
|
|
|
|
|
shouldApplyRule?: typeof shouldApplyRule;
|
|
|
|
|
isDuplicateByRealPath?: typeof isDuplicateByRealPath;
|
|
|
|
|
createContentHash?: typeof createContentHash;
|
|
|
|
|
isDuplicateByContentHash?: typeof isDuplicateByContentHash;
|
|
|
|
|
saveInjectedRules?: typeof saveInjectedRules;
|
2026-02-08 13:57:26 +09:00
|
|
|
}): {
|
|
|
|
|
processFilePathForInjection: (
|
|
|
|
|
filePath: string,
|
|
|
|
|
sessionID: string,
|
|
|
|
|
output: ToolExecuteOutput
|
|
|
|
|
) => Promise<void>;
|
|
|
|
|
} {
|
2026-04-10 15:53:10 +09:00
|
|
|
const {
|
|
|
|
|
workspaceDirectory,
|
|
|
|
|
truncator,
|
|
|
|
|
getSessionCache,
|
2026-04-18 14:13:51 +09:00
|
|
|
getSessionRuleScanCache,
|
2026-04-10 15:53:10 +09:00
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
|
|
|
return { metadata: cached.metadata, body: cached.body };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawContent = readRuleFileSync(filePath, "utf-8");
|
|
|
|
|
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
2026-05-18 12:40:12 +09:00
|
|
|
setParsedRuleCacheEntry(realPath, {
|
2026-04-10 15:53:10 +09:00
|
|
|
mtimeMs: stat.mtimeMs,
|
|
|
|
|
size: stat.size,
|
|
|
|
|
metadata,
|
|
|
|
|
body,
|
|
|
|
|
});
|
|
|
|
|
return { metadata, body };
|
|
|
|
|
} catch {
|
|
|
|
|
const rawContent = readRuleFileSync(filePath, "utf-8");
|
|
|
|
|
return parseRuleFrontmatter(rawContent);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 13:57:26 +09:00
|
|
|
|
|
|
|
|
async function processFilePathForInjection(
|
|
|
|
|
filePath: string,
|
|
|
|
|
sessionID: string,
|
|
|
|
|
output: ToolExecuteOutput
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const resolved = resolveFilePath(workspaceDirectory, filePath);
|
|
|
|
|
if (!resolved) return;
|
|
|
|
|
|
|
|
|
|
const projectRoot = findProjectRoot(resolved);
|
|
|
|
|
const cache = getSessionCache(sessionID);
|
2026-04-18 14:13:51 +09:00
|
|
|
const ruleScanCache = getSessionRuleScanCache?.(sessionID);
|
2026-04-10 15:53:10 +09:00
|
|
|
const home = getHomeDir();
|
2026-02-08 13:57:26 +09:00
|
|
|
|
2026-04-18 14:13:51 +09:00
|
|
|
const ruleFileCandidates = findRuleFiles(
|
|
|
|
|
projectRoot,
|
|
|
|
|
home,
|
|
|
|
|
resolved,
|
|
|
|
|
ruleFinderOptions,
|
|
|
|
|
ruleScanCache,
|
|
|
|
|
);
|
2026-02-08 13:57:26 +09:00
|
|
|
const toInject: RuleToInject[] = [];
|
2026-02-13 11:46:45 +09:00
|
|
|
let dirty = false;
|
2026-02-08 13:57:26 +09:00
|
|
|
|
|
|
|
|
for (const candidate of ruleFileCandidates) {
|
2026-04-10 15:53:10 +09:00
|
|
|
if (isDuplicateByRealPathImpl(candidate.realPath, cache.realPaths)) continue;
|
2026-02-08 13:57:26 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-04-10 15:53:10 +09:00
|
|
|
const { metadata, body } = getParsedRule(
|
2026-02-13 11:46:45 +09:00
|
|
|
candidate.path,
|
|
|
|
|
candidate.realPath
|
|
|
|
|
);
|
2026-02-08 13:57:26 +09:00
|
|
|
|
|
|
|
|
let matchReason: string;
|
|
|
|
|
if (candidate.isSingleFile) {
|
|
|
|
|
matchReason = "copilot-instructions (always apply)";
|
|
|
|
|
} else {
|
2026-04-10 15:53:10 +09:00
|
|
|
const matchResult = shouldApplyRuleImpl(metadata, resolved, projectRoot);
|
2026-02-08 13:57:26 +09:00
|
|
|
if (!matchResult.applies) continue;
|
|
|
|
|
matchReason = matchResult.reason ?? "matched";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:53:10 +09:00
|
|
|
const contentHash = createContentHashImpl(body);
|
|
|
|
|
if (isDuplicateByContentHashImpl(contentHash, cache.contentHashes)) continue;
|
2026-02-08 13:57:26 +09:00
|
|
|
|
|
|
|
|
const relativePath = projectRoot
|
|
|
|
|
? relative(projectRoot, candidate.path)
|
|
|
|
|
: candidate.path;
|
|
|
|
|
|
|
|
|
|
toInject.push({
|
|
|
|
|
relativePath,
|
|
|
|
|
matchReason,
|
|
|
|
|
content: body,
|
|
|
|
|
distance: candidate.distance,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cache.realPaths.add(candidate.realPath);
|
|
|
|
|
cache.contentHashes.add(contentHash);
|
2026-02-13 11:46:45 +09:00
|
|
|
dirty = true;
|
2026-02-08 13:57:26 +09:00
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (toInject.length === 0) return;
|
|
|
|
|
|
|
|
|
|
toInject.sort((a, b) => a.distance - b.distance);
|
|
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 11:46:45 +09:00
|
|
|
if (dirty) {
|
2026-04-10 15:53:10 +09:00
|
|
|
saveInjectedRulesImpl(sessionID, cache);
|
2026-02-13 11:46:45 +09:00
|
|
|
}
|
2026-02-08 13:57:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { processFilePathForInjection };
|
|
|
|
|
}
|