vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
||||
|
||||
import {
|
||||
type CodexPostCompactInput,
|
||||
type CodexPostToolUseInput,
|
||||
type CodexRulesHookOptions,
|
||||
type CodexSessionStartInput,
|
||||
type CodexUserPromptSubmitInput,
|
||||
runPostCompactHook,
|
||||
runPostToolUseHook,
|
||||
runSessionStartHook,
|
||||
runUserPromptSubmitHook,
|
||||
} from "./codex-hook.js";
|
||||
|
||||
const command = process.argv[2];
|
||||
const subcommand = process.argv[3];
|
||||
type HookCliEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse" | "PostCompact";
|
||||
|
||||
if (command === "hook" && subcommand === "session-start") {
|
||||
await runHookCli("SessionStart");
|
||||
} else if (command === "hook" && subcommand === "user-prompt-submit") {
|
||||
await runHookCli("UserPromptSubmit");
|
||||
} else if (command === "hook" && subcommand === "post-tool-use") {
|
||||
await runHookCli("PostToolUse");
|
||||
} else if (command === "hook" && subcommand === "post-compact") {
|
||||
await runHookCli("PostCompact");
|
||||
} else {
|
||||
process.stderr.write("Usage: codex-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function runHookCli(eventName: HookCliEventName): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
if (raw.trim().length === 0) return;
|
||||
const parsed = parseHookInput(raw);
|
||||
if (!parsed) return;
|
||||
const pluginDataRoot = process.env["PLUGIN_DATA"];
|
||||
const options: CodexRulesHookOptions = pluginDataRoot === undefined ? {} : { pluginDataRoot };
|
||||
const output = await runHook(eventName, parsed, options);
|
||||
if (output.length > 0) {
|
||||
processStdout.write(output);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHook(eventName: HookCliEventName, parsed: unknown, options: CodexRulesHookOptions): Promise<string> {
|
||||
switch (eventName) {
|
||||
case "SessionStart":
|
||||
return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : "";
|
||||
case "UserPromptSubmit":
|
||||
return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : "";
|
||||
case "PostToolUse":
|
||||
return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : "";
|
||||
case "PostCompact":
|
||||
return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseHookInput(raw: string): unknown | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexSessionStartInput(value: unknown): value is CodexSessionStartInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "SessionStart" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["source"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "UserPromptSubmit" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["prompt"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostToolUse" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["tool_name"] === "string" &&
|
||||
typeof value["tool_use_id"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexPostCompactInput(value: unknown): value is CodexPostCompactInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostCompact" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
(value["trigger"] === "manual" || value["trigger"] === "auto")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringOrNull(value: unknown): value is string | null {
|
||||
return typeof value === "string" || value === null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
processStdin.setEncoding("utf8");
|
||||
processStdin.on("data", (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
processStdin.once("error", reject);
|
||||
processStdin.once("end", () => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import { configFromEnvironment } from "./config.js";
|
||||
import { createHookDebugTimer } from "./debug-log.js";
|
||||
import {
|
||||
clearSessionState,
|
||||
hasPostCompactPending,
|
||||
hydrateEngineState,
|
||||
isPostCompactPending,
|
||||
markSessionCompacted,
|
||||
persistEngineState,
|
||||
sessionCachePath,
|
||||
} from "./persistent-cache.js";
|
||||
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
||||
import { createEngine } from "./rules/engine.js";
|
||||
import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
|
||||
import { hashContent } from "./rules/matcher.js";
|
||||
import { sortCandidates } from "./rules/ordering.js";
|
||||
import { findProjectRoot } from "./rules/project-root.js";
|
||||
import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js";
|
||||
import { extractCodexToolPaths } from "./tool-paths.js";
|
||||
|
||||
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
|
||||
|
||||
export type CodexSessionStartInput = {
|
||||
session_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "SessionStart";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
source: "startup" | "resume" | "clear";
|
||||
};
|
||||
|
||||
export type CodexUserPromptSubmitInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "UserPromptSubmit";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type CodexPostToolUseInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostToolUse";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_response: unknown;
|
||||
tool_use_id: string;
|
||||
};
|
||||
|
||||
export type CodexPostCompactInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostCompact";
|
||||
model: string;
|
||||
trigger: "manual" | "auto";
|
||||
};
|
||||
|
||||
export interface CodexRulesHookOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pluginDataRoot?: string;
|
||||
}
|
||||
|
||||
interface DynamicTargetFingerprint {
|
||||
targetPath: string;
|
||||
cacheKey: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export async function runSessionStartHook(
|
||||
input: CodexSessionStartInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
if (input.source === "clear") {
|
||||
clearSessionState(cachePath);
|
||||
} else if (input.source !== "resume" && !hasPostCompactPending(cachePath)) {
|
||||
clearSessionState(cachePath);
|
||||
}
|
||||
const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static");
|
||||
const transcriptPath = input.source === "clear" || postCompactPending ? null : input.transcript_path;
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
transcriptPath,
|
||||
"SessionStart",
|
||||
cachePath,
|
||||
options,
|
||||
postCompactPending ? "static" : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPostCompactHook(
|
||||
input: CodexPostCompactInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot));
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function runUserPromptSubmitHook(
|
||||
input: CodexUserPromptSubmitInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
const postCompactPending = isPostCompactPending(cachePath, "static");
|
||||
const transcriptPath = postCompactPending ? null : input.transcript_path;
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
transcriptPath,
|
||||
"UserPromptSubmit",
|
||||
cachePath,
|
||||
options,
|
||||
postCompactPending ? "static" : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPostToolUseHook(
|
||||
input: CodexPostToolUseInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<string> {
|
||||
const debugTimer = createHookDebugTimer("PostToolUse");
|
||||
const config = configFromEnvironment(options.env);
|
||||
debugTimer.lap("config", { disabled: config.disabled, mode: config.mode });
|
||||
if (config.disabled || config.mode === "off" || config.mode === "static") {
|
||||
debugTimer.done({ outputBytes: 0, reason: "disabled" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const targetPaths = extractCodexToolPaths(input, input.cwd);
|
||||
debugTimer.lap("extract", {
|
||||
targets: targetPaths.length,
|
||||
uniqueTargets: uniqueStrings(targetPaths).length,
|
||||
tool: input.tool_name,
|
||||
});
|
||||
const firstTargetPath = targetPaths[0];
|
||||
if (firstTargetPath === undefined) {
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-target" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
|
||||
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
|
||||
const transcriptPath = postCompactPending ? null : input.transcript_path;
|
||||
const engine = createRulesEngine(options);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
debugTimer.lap("hydrate", {
|
||||
dynamicDedupScopes: engine.state.dynamicDedup.size,
|
||||
dynamicTargetFingerprints: engine.state.dynamicTargetFingerprints.size,
|
||||
staticDedup: engine.state.staticDedup.size,
|
||||
});
|
||||
const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config);
|
||||
debugTimer.lap("fingerprint", { fingerprints: dynamicTargetFingerprints.length });
|
||||
const pendingTargetFingerprints = dynamicTargetFingerprints.filter(
|
||||
(target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint,
|
||||
);
|
||||
debugTimer.lap("pending", { pending: pendingTargetFingerprints.length });
|
||||
if (pendingTargetFingerprints.length === 0) {
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "no-pending" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-pending" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const loaded = engine.loadDynamicRules(
|
||||
input.cwd,
|
||||
pendingTargetFingerprints.map((target) => target.targetPath),
|
||||
);
|
||||
debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length });
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)),
|
||||
transcriptPath,
|
||||
(rule) => {
|
||||
engine.markDynamicInjected(rule);
|
||||
},
|
||||
);
|
||||
debugTimer.lap("filter", { rules: rules.length });
|
||||
for (const target of pendingTargetFingerprints) {
|
||||
engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint);
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "no-rules" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-rules" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath;
|
||||
const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath));
|
||||
debugTimer.lap("format", { blockChars: block.length, rules: rules.length });
|
||||
for (const rule of rules) {
|
||||
engine.markDynamicInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "emit" });
|
||||
const output = formatAdditionalContextOutput("PostToolUse", block);
|
||||
debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" });
|
||||
return output;
|
||||
}
|
||||
|
||||
function runStaticInjection(
|
||||
cwd: string,
|
||||
transcriptPath: string | null,
|
||||
eventName: "SessionStart" | "UserPromptSubmit",
|
||||
cachePath: string,
|
||||
options: CodexRulesHookOptions,
|
||||
completedPostCompactChannel?: "static",
|
||||
): string {
|
||||
const config = configFromEnvironment(options.env);
|
||||
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const engine = createRulesEngine(options);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
engine.state.cwd = cwd;
|
||||
|
||||
const loaded = engine.loadStaticRules(cwd);
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule)),
|
||||
transcriptPath,
|
||||
(rule) => {
|
||||
engine.markStaticInjected(rule);
|
||||
},
|
||||
);
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return "";
|
||||
}
|
||||
|
||||
const block = engine.formatStatic(rules);
|
||||
for (const rule of rules) {
|
||||
engine.markStaticInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return formatAdditionalContextOutput(eventName, block);
|
||||
}
|
||||
|
||||
function filterRulesAlreadyInTranscript(
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
transcriptPath: string | null,
|
||||
markInjected: (rule: LoadedRule) => void,
|
||||
): LoadedRule[] {
|
||||
if (rules.length === 0 || transcriptPath === null) {
|
||||
return [...rules];
|
||||
}
|
||||
|
||||
const transcriptText = readTranscriptSearchText(transcriptPath);
|
||||
if (transcriptText === null) {
|
||||
return [...rules];
|
||||
}
|
||||
|
||||
const pendingRules: LoadedRule[] = [];
|
||||
for (const rule of rules) {
|
||||
if (isRuleAlreadyInTranscript(rule, transcriptText)) {
|
||||
markInjected(rule);
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingRules.push(rule);
|
||||
}
|
||||
return pendingRules;
|
||||
}
|
||||
|
||||
function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean {
|
||||
const bodyNeedle = rule.body.trim().slice(0, 2_000);
|
||||
if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const markers = [
|
||||
`Instructions from: ${rule.path}`,
|
||||
`Instructions from: ${rule.realPath}`,
|
||||
rule.relativePath.length === 0 ? null : rule.relativePath,
|
||||
].filter((marker): marker is string => marker !== null);
|
||||
return markers.some((marker) => transcriptText.includes(marker));
|
||||
}
|
||||
|
||||
function readTranscriptSearchText(transcriptPath: string): string | null {
|
||||
try {
|
||||
const rawTranscript = readFileSync(transcriptPath, "utf8");
|
||||
return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectJsonLineStrings(rawTranscript: string): string[] {
|
||||
const values: string[] = [];
|
||||
for (const line of rawTranscript.split(/\r?\n/)) {
|
||||
if (line.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
collectStrings(parsed, values);
|
||||
} catch {
|
||||
// Non-JSON transcript lines are still covered by the raw transcript text.
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function collectStrings(value: unknown, output: string[]): void {
|
||||
if (typeof value === "string") {
|
||||
output.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectStrings(item, output);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of Object.values(value)) {
|
||||
collectStrings(item, output);
|
||||
}
|
||||
}
|
||||
|
||||
function createRulesEngine(options: CodexRulesHookOptions) {
|
||||
const config = configFromEnvironment(options.env);
|
||||
return createEngine(config, {
|
||||
findCandidates: findRuleCandidates,
|
||||
findProjectRoot,
|
||||
readFile: (path) => {
|
||||
try {
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fingerprintDynamicTargets(
|
||||
cwd: string,
|
||||
targetPaths: ReadonlyArray<string>,
|
||||
config: PiRulesConfig,
|
||||
): DynamicTargetFingerprint[] {
|
||||
const disabledSources = disabledSourcesFor(config);
|
||||
const discoveryCache = createRuleDiscoveryCache();
|
||||
const cwdProjectRoot = findProjectRoot(cwd);
|
||||
const fingerprints: DynamicTargetFingerprint[] = [];
|
||||
|
||||
for (const targetPath of uniqueStrings(targetPaths)) {
|
||||
const projectRoot =
|
||||
cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot)
|
||||
? cwdProjectRoot
|
||||
: findProjectRoot(targetPath);
|
||||
const findOptions: {
|
||||
projectRoot: string | null;
|
||||
targetFile: string;
|
||||
disabledSources?: ReadonlySet<string>;
|
||||
cache: ReturnType<typeof createRuleDiscoveryCache>;
|
||||
} = {
|
||||
projectRoot,
|
||||
targetFile: targetPath,
|
||||
cache: discoveryCache,
|
||||
};
|
||||
if (disabledSources !== undefined) {
|
||||
findOptions.disabledSources = disabledSources;
|
||||
}
|
||||
const candidates = findRuleCandidates(findOptions);
|
||||
const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001");
|
||||
const cacheKey = dynamicTargetCacheKey(targetPath);
|
||||
fingerprints.push({
|
||||
targetPath,
|
||||
cacheKey,
|
||||
fingerprint: hashContent(
|
||||
[
|
||||
"v1",
|
||||
config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","),
|
||||
projectRoot ?? "",
|
||||
cacheKey,
|
||||
candidateFingerprint,
|
||||
].join("\u0000"),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
function fingerprintCandidate(candidate: RuleCandidate): string {
|
||||
return [
|
||||
candidate.realPath,
|
||||
candidate.relativePath,
|
||||
candidate.source,
|
||||
candidate.isGlobal ? "global" : "project",
|
||||
candidate.isSingleFile ? "single" : "multi",
|
||||
String(candidate.distance),
|
||||
fileFingerprint(candidate.path),
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function fileFingerprint(filePath: string): string {
|
||||
try {
|
||||
const stats = statSync(filePath, { bigint: true });
|
||||
return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`;
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
|
||||
if (config.enabledSources === "auto") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const enabledSources = new Set(config.enabledSources);
|
||||
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
|
||||
}
|
||||
|
||||
function dynamicTargetCacheKey(targetPath: string): string {
|
||||
return toPosixPath(resolve(targetPath));
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, resolve(childPath));
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: ReadonlyArray<string>): string[] {
|
||||
const uniqueValues: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seenValues.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(value);
|
||||
uniqueValues.push(value);
|
||||
}
|
||||
return uniqueValues;
|
||||
}
|
||||
|
||||
function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string {
|
||||
if (additionalContext.trim().length === 0) return "";
|
||||
return `${JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: eventName,
|
||||
additionalContext,
|
||||
},
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
function displayPath(cwd: string, filePath: string): string {
|
||||
const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;
|
||||
// Normalize to POSIX separators so injected rule context renders the same
|
||||
// path string on Linux/macOS and Windows (Codex feeds this verbatim into
|
||||
// the model prompt, and the existing engine already emits POSIX paths).
|
||||
return toPosixPath(rel);
|
||||
}
|
||||
|
||||
function toPosixPath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
||||
import { defaultConfig } from "./rules/engine.js";
|
||||
import type { PiRulesConfig, RuleSource } from "./rules/types.js";
|
||||
|
||||
const MODE_VALUES = new Set<PiRulesConfig["mode"]>(["static", "dynamic", "both", "off"]);
|
||||
|
||||
export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig {
|
||||
const config = defaultConfig();
|
||||
config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED"));
|
||||
config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode;
|
||||
config.maxRuleChars =
|
||||
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")) ??
|
||||
config.maxRuleChars;
|
||||
config.maxResultChars =
|
||||
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ??
|
||||
config.maxResultChars;
|
||||
config.enabledSources = parseEnabledSources(
|
||||
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
function firstEnv(env: NodeJS.ProcessEnv, ...names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = env[name];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTruthy(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseMode(value: string | undefined): PiRulesConfig["mode"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return MODE_VALUES.has(normalized as PiRulesConfig["mode"]) ? (normalized as PiRulesConfig["mode"]) : undefined;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseEnabledSources(value: string | undefined): RuleSource[] | "auto" {
|
||||
if (value === undefined || value.trim().toLowerCase() === "auto") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
const validSources = new Set(SOURCE_PRIORITY.keys());
|
||||
const sources: RuleSource[] = [];
|
||||
for (const rawSource of value.split(",")) {
|
||||
const source = rawSource.trim();
|
||||
if (!validSources.has(source as RuleSource)) {
|
||||
continue;
|
||||
}
|
||||
sources.push(source as RuleSource);
|
||||
}
|
||||
return sources.length > 0 ? sources : "auto";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { debuglog } from "node:util";
|
||||
|
||||
type DebugFieldValue = boolean | number | string | null;
|
||||
|
||||
type DebugFields = Record<string, DebugFieldValue>;
|
||||
|
||||
const debug = debuglog("codex-rules");
|
||||
const noopTimer: HookDebugTimer = {
|
||||
lap: () => {},
|
||||
done: () => {},
|
||||
};
|
||||
|
||||
export interface HookDebugTimer {
|
||||
lap(phase: string, fields?: DebugFields): void;
|
||||
done(fields?: DebugFields): void;
|
||||
}
|
||||
|
||||
export function createHookDebugTimer(hookName: string): HookDebugTimer {
|
||||
if (!debug.enabled) {
|
||||
return noopTimer;
|
||||
}
|
||||
|
||||
const startMs = performance.now();
|
||||
let lastMs = startMs;
|
||||
|
||||
return {
|
||||
lap: (phase, fields = {}) => {
|
||||
const nowMs = performance.now();
|
||||
writeDebugLine(hookName, phase, nowMs - lastMs, nowMs - startMs, fields);
|
||||
lastMs = nowMs;
|
||||
},
|
||||
done: (fields = {}) => {
|
||||
const nowMs = performance.now();
|
||||
writeDebugLine(hookName, "done", nowMs - lastMs, nowMs - startMs, fields);
|
||||
lastMs = nowMs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDebugLine(
|
||||
hookName: string,
|
||||
phase: string,
|
||||
durationMs: number,
|
||||
totalMs: number,
|
||||
fields: DebugFields,
|
||||
): void {
|
||||
debug(
|
||||
"%s phase=%s ms=%s total_ms=%s%s",
|
||||
hookName,
|
||||
phase,
|
||||
durationMs.toFixed(3),
|
||||
totalMs.toFixed(3),
|
||||
formatFields(fields),
|
||||
);
|
||||
}
|
||||
|
||||
function formatFields(fields: DebugFields): string {
|
||||
const entries = Object.entries(fields);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { Engine } from "./rules/engine.js";
|
||||
|
||||
export type PostCompactPendingKind = "static" | "dynamic";
|
||||
|
||||
interface PostCompactPendingState {
|
||||
static?: boolean;
|
||||
dynamic?: boolean;
|
||||
}
|
||||
|
||||
interface SerializedSessionState {
|
||||
staticDedup: string[];
|
||||
dynamicDedup: Record<string, string[]>;
|
||||
dynamicTargetFingerprints?: Record<string, string>;
|
||||
postCompactPending?: PostCompactPendingState;
|
||||
compacted?: boolean;
|
||||
}
|
||||
|
||||
export function hydrateEngineState(engine: Engine, cachePath: string): void {
|
||||
const state = readSessionState(cachePath);
|
||||
engine.state.staticDedup.clear();
|
||||
engine.state.dynamicDedup.clear();
|
||||
engine.state.dynamicTargetFingerprints.clear();
|
||||
|
||||
for (const key of state.staticDedup) {
|
||||
engine.state.staticDedup.add(key);
|
||||
}
|
||||
for (const [scope, keys] of Object.entries(state.dynamicDedup)) {
|
||||
engine.state.dynamicDedup.set(scope, new Set(keys));
|
||||
}
|
||||
for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) {
|
||||
engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistEngineState(
|
||||
engine: Engine,
|
||||
cachePath: string,
|
||||
completedPostCompactKind?: PostCompactPendingKind,
|
||||
): void {
|
||||
const currentState = readSessionState(cachePath);
|
||||
const dynamicDedup: Record<string, string[]> = {};
|
||||
for (const [scope, keys] of engine.state.dynamicDedup.entries()) {
|
||||
dynamicDedup[scope] = [...keys];
|
||||
}
|
||||
|
||||
const postCompactPending = nextPostCompactPending(currentState, completedPostCompactKind);
|
||||
writeSessionState(cachePath, {
|
||||
staticDedup: [...engine.state.staticDedup],
|
||||
dynamicDedup,
|
||||
dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()),
|
||||
...(postCompactPending === undefined ? {} : { postCompactPending }),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionState(cachePath: string): void {
|
||||
rmSync(cachePath, { force: true });
|
||||
}
|
||||
|
||||
export function markSessionCompacted(cachePath: string): void {
|
||||
writeSessionState(cachePath, { ...emptyState(), postCompactPending: { static: true, dynamic: true } });
|
||||
}
|
||||
|
||||
export function hasPostCompactPending(cachePath: string): boolean {
|
||||
return postCompactPendingKinds(readSessionState(cachePath)).size > 0;
|
||||
}
|
||||
|
||||
export function isPostCompactPending(cachePath: string, kind: PostCompactPendingKind): boolean {
|
||||
return postCompactPendingKinds(readSessionState(cachePath)).has(kind);
|
||||
}
|
||||
|
||||
export function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string {
|
||||
const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules");
|
||||
return join(root, "sessions", `${safePathSegment(sessionId)}.json`);
|
||||
}
|
||||
|
||||
function readSessionState(cachePath: string): SerializedSessionState {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
||||
if (!isSerializedSessionState(parsed)) return emptyState();
|
||||
return parsed;
|
||||
} catch {
|
||||
return emptyState();
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionState(cachePath: string, state: SerializedSessionState): void {
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, `${JSON.stringify(state)}\n`);
|
||||
}
|
||||
|
||||
function emptyState(): SerializedSessionState {
|
||||
return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} };
|
||||
}
|
||||
|
||||
function nextPostCompactPending(
|
||||
state: SerializedSessionState,
|
||||
completedKind: PostCompactPendingKind | undefined,
|
||||
): PostCompactPendingState | undefined {
|
||||
const pendingKinds = postCompactPendingKinds(state);
|
||||
if (completedKind !== undefined) {
|
||||
pendingKinds.delete(completedKind);
|
||||
}
|
||||
|
||||
if (pendingKinds.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(pendingKinds.has("static") ? { static: true } : {}),
|
||||
...(pendingKinds.has("dynamic") ? { dynamic: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function postCompactPendingKinds(state: SerializedSessionState): Set<PostCompactPendingKind> {
|
||||
const pendingKinds = new Set<PostCompactPendingKind>();
|
||||
if (state.compacted === true || state.postCompactPending?.static === true) {
|
||||
pendingKinds.add("static");
|
||||
}
|
||||
if (state.compacted === true || state.postCompactPending?.dynamic === true) {
|
||||
pendingKinds.add("dynamic");
|
||||
}
|
||||
return pendingKinds;
|
||||
}
|
||||
|
||||
function safePathSegment(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session";
|
||||
}
|
||||
|
||||
function isSerializedSessionState(value: unknown): value is SerializedSessionState {
|
||||
if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) {
|
||||
return false;
|
||||
}
|
||||
const staticDedup = value["staticDedup"];
|
||||
const dynamicDedup = value["dynamicDedup"];
|
||||
const dynamicTargetFingerprints = value["dynamicTargetFingerprints"];
|
||||
const postCompactPending = value["postCompactPending"];
|
||||
const compacted = value["compacted"];
|
||||
return (
|
||||
staticDedup.every((item) => typeof item === "string") &&
|
||||
Object.values(dynamicDedup).every(
|
||||
(item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string"),
|
||||
) &&
|
||||
(dynamicTargetFingerprints === undefined ||
|
||||
(isRecord(dynamicTargetFingerprints) &&
|
||||
Object.entries(dynamicTargetFingerprints).every(
|
||||
([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string",
|
||||
))) &&
|
||||
(postCompactPending === undefined || isPostCompactPendingState(postCompactPending)) &&
|
||||
(compacted === undefined || typeof compacted === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function isPostCompactPendingState(value: unknown): value is PostCompactPendingState {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
(value["static"] === undefined || typeof value["static"] === "boolean") &&
|
||||
(value["dynamic"] === undefined || typeof value["dynamic"] === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { LoadedRule, SessionState } from "./types.js";
|
||||
|
||||
const DYNAMIC_SESSION_KEY = "__pi-rules-session__";
|
||||
|
||||
export function createSessionState(cwd?: string): SessionState {
|
||||
return {
|
||||
cwd,
|
||||
staticDedup: new Set(),
|
||||
dynamicDedup: new Map(),
|
||||
dynamicTargetFingerprints: new Map(),
|
||||
loadedRules: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function staticDedupKey(cwd: string, rulePath: string, contentHash: string): string {
|
||||
return `${cwd}::${rulePath}::${contentHash}`;
|
||||
}
|
||||
|
||||
export function dynamicDedupKey(rulePath: string, contentHash: string): string {
|
||||
return `${rulePath}::${contentHash}`;
|
||||
}
|
||||
|
||||
export function markStaticInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
const key = staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash);
|
||||
if (state.staticDedup.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.staticDedup.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
let keys = state.dynamicDedup.get(DYNAMIC_SESSION_KEY);
|
||||
if (keys === undefined) {
|
||||
keys = new Set();
|
||||
state.dynamicDedup.set(DYNAMIC_SESSION_KEY, keys);
|
||||
}
|
||||
|
||||
const key = dynamicDedupKey(rule.realPath, rule.contentHash);
|
||||
if (keys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
keys.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isStaticInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash));
|
||||
}
|
||||
|
||||
export function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
|
||||
return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true;
|
||||
}
|
||||
|
||||
export function clearSession(state: SessionState): void {
|
||||
state.staticDedup.clear();
|
||||
state.dynamicDedup.clear();
|
||||
state.dynamicTargetFingerprints.clear();
|
||||
state.loadedRules.length = 0;
|
||||
state.diagnostics.length = 0;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { RuleSource } from "./types.js";
|
||||
|
||||
/**
|
||||
* Project root marker files / directories used by `findProjectRoot`.
|
||||
* Walks UP from cwd until any of these is found in the directory.
|
||||
*/
|
||||
export const PROJECT_MARKERS: readonly string[] = [
|
||||
".git",
|
||||
"pnpm-workspace.yaml",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
".venv",
|
||||
];
|
||||
|
||||
/**
|
||||
* Project rule subdirectories. First tuple element is the parent dir under
|
||||
* the project root, second is the subdir scanned recursively.
|
||||
*/
|
||||
export const PROJECT_RULE_SUBDIRS: ReadonlyArray<readonly [string, string]> = [
|
||||
[".omo", "rules"],
|
||||
[".claude", "rules"],
|
||||
[".cursor", "rules"],
|
||||
[".github", "instructions"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Single-file project rules (always apply, frontmatter optional).
|
||||
*/
|
||||
export const PROJECT_SINGLE_FILES: readonly string[] = [
|
||||
".github/copilot-instructions.md",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md",
|
||||
];
|
||||
|
||||
/**
|
||||
* User-home rule directories.
|
||||
*/
|
||||
export const USER_HOME_RULE_SUBDIRS: readonly string[] = [".omo/rules", ".opencode/rules", ".claude/rules"];
|
||||
|
||||
/**
|
||||
* User-home single-file rules. The first one to exist wins per "first-match" semantics.
|
||||
*/
|
||||
export const USER_HOME_SINGLE_FILES: readonly string[] = [".config/opencode/AGENTS.md", ".claude/CLAUDE.md"];
|
||||
|
||||
/**
|
||||
* File extensions accepted as rule files in scanned directories.
|
||||
*/
|
||||
export const RULE_FILE_EXTENSIONS: readonly string[] = [".md", ".mdc"];
|
||||
|
||||
/**
|
||||
* Per-rule source priority for deterministic ordering. Lower = earlier.
|
||||
*/
|
||||
export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
|
||||
[".omo/rules", 0],
|
||||
[".claude/rules", 1],
|
||||
[".cursor/rules", 2],
|
||||
[".github/instructions", 3],
|
||||
[".github/copilot-instructions.md", 4],
|
||||
["AGENTS.md", 5],
|
||||
["CLAUDE.md", 6],
|
||||
["CONTEXT.md", 7],
|
||||
["~/.omo/rules", 100],
|
||||
["~/.opencode/rules", 101],
|
||||
["~/.claude/rules", 102],
|
||||
["~/.config/opencode/AGENTS.md", 103],
|
||||
["~/.claude/CLAUDE.md", 104],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Distance value assigned to global / user-home rules.
|
||||
*/
|
||||
export const GLOBAL_DISTANCE = 9999;
|
||||
|
||||
/**
|
||||
* Per-rule body character cap (default).
|
||||
*/
|
||||
export const DEFAULT_MAX_RULE_CHARS = 12000;
|
||||
|
||||
export const DEFAULT_MAX_SCAN_FILES = 1000;
|
||||
|
||||
/**
|
||||
* Total injected chars per tool result (default).
|
||||
*/
|
||||
export const DEFAULT_MAX_RESULT_CHARS = 40000;
|
||||
|
||||
/**
|
||||
* Truncation marker template. `{path}` is replaced with the relative path.
|
||||
*/
|
||||
export const TRUNCATION_NOTICE = "\n\n[Rule truncated. Read full rule: {path}]";
|
||||
|
||||
/**
|
||||
* Directories excluded by the recursive scanner regardless of glob settings.
|
||||
*/
|
||||
export const SCANNER_EXCLUDED_DIRS: readonly string[] = [
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".turbo",
|
||||
".next",
|
||||
"coverage",
|
||||
];
|
||||
@@ -0,0 +1,531 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
clearSession,
|
||||
createSessionState,
|
||||
isDynamicInjected as isDynamicInjectedInState,
|
||||
isStaticInjected as isStaticInjectedInState,
|
||||
markDynamicInjected as markDynamicInjectedInState,
|
||||
markStaticInjected as markStaticInjectedInState,
|
||||
} from "./cache.js";
|
||||
import {
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
DEFAULT_MAX_RULE_CHARS,
|
||||
PROJECT_SINGLE_FILES,
|
||||
SOURCE_PRIORITY,
|
||||
} from "./constants.js";
|
||||
import { createRuleDiscoveryCache, type RuleDiscoveryCache } from "./finder.js";
|
||||
import { formatDynamicBlock, formatStaticBlock } from "./formatter.js";
|
||||
import { hashContent, matchRule } from "./matcher.js";
|
||||
import { sortCandidates } from "./ordering.js";
|
||||
import { parseRule } from "./parser.js";
|
||||
import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";
|
||||
|
||||
interface LoadedRuleContent {
|
||||
frontmatter: LoadedRule["frontmatter"];
|
||||
body: string;
|
||||
contentHash: string;
|
||||
diagnostic?: string;
|
||||
}
|
||||
|
||||
type CandidateProjectMembership = Map<string, boolean>;
|
||||
type CandidateDiscoveryCache = Map<string, RuleCandidate[]>;
|
||||
type DynamicMatchCache = Map<string, MatchReason | null>;
|
||||
|
||||
const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096;
|
||||
|
||||
export interface EngineDeps {
|
||||
findCandidates: (options: {
|
||||
projectRoot: string | null;
|
||||
targetFile: string | null;
|
||||
homeDir?: string;
|
||||
disabledSources?: ReadonlySet<string>;
|
||||
skipUserHome?: boolean;
|
||||
cache?: RuleDiscoveryCache;
|
||||
}) => RuleCandidate[];
|
||||
readFile: (path: string) => string | null;
|
||||
findProjectRoot: (startPath: string) => string | null;
|
||||
matchRule?: typeof matchRule;
|
||||
}
|
||||
|
||||
export interface Engine {
|
||||
state: SessionState;
|
||||
config: PiRulesConfig;
|
||||
loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] };
|
||||
loadDynamicRules(
|
||||
cwd: string,
|
||||
targetPaths: ReadonlyArray<string>,
|
||||
): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] };
|
||||
formatStatic(rules: ReadonlyArray<LoadedRule>): string;
|
||||
formatDynamic(rules: ReadonlyArray<LoadedRule>, target: string): string;
|
||||
resetSession(cwd?: string): void;
|
||||
isStaticInjected(rule: LoadedRule): boolean;
|
||||
isDynamicInjected(rule: LoadedRule): boolean;
|
||||
markStaticInjected(rule: LoadedRule): boolean;
|
||||
markDynamicInjected(rule: LoadedRule): boolean;
|
||||
}
|
||||
|
||||
const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/")));
|
||||
|
||||
export function defaultConfig(): PiRulesConfig {
|
||||
return {
|
||||
disabled: false,
|
||||
mode: "both",
|
||||
maxRuleChars: DEFAULT_MAX_RULE_CHARS,
|
||||
maxResultChars: DEFAULT_MAX_RESULT_CHARS,
|
||||
enabledSources: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
|
||||
const state = createSessionState();
|
||||
const dynamicMatchCache: DynamicMatchCache = new Map();
|
||||
|
||||
function loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
|
||||
state.cwd = cwd;
|
||||
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
||||
return emptyLoadResult(state);
|
||||
}
|
||||
|
||||
const projectRoot = deps.findProjectRoot(cwd);
|
||||
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
|
||||
projectRoot,
|
||||
targetFile: null,
|
||||
};
|
||||
const disabledSources = disabledSourcesFor(config);
|
||||
if (disabledSources !== undefined) {
|
||||
findOptions.disabledSources = disabledSources;
|
||||
}
|
||||
const candidates = deps.findCandidates(findOptions);
|
||||
const result = loadStaticCandidates(candidates, deps, projectRoot);
|
||||
storeLastLoad(state, result.rules, result.diagnostics);
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadDynamicRules(
|
||||
cwd: string,
|
||||
targetPaths: ReadonlyArray<string>,
|
||||
): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
|
||||
state.cwd = cwd;
|
||||
if (config.disabled || config.mode === "off" || config.mode === "static" || targetPaths.length === 0) {
|
||||
return emptyLoadResult(state);
|
||||
}
|
||||
|
||||
const rules: LoadedRule[] = [];
|
||||
const diagnostics: RuleDiagnostic[] = [];
|
||||
const seenRules = new Set<string>();
|
||||
const loadedRuleContent = new Map<string, LoadedRuleContent | null>();
|
||||
const projectMembership = new Map<string, boolean>();
|
||||
const disabledSources = disabledSourcesFor(config);
|
||||
const discoveryCache = createRuleDiscoveryCache();
|
||||
const candidateDiscoveryCache: CandidateDiscoveryCache = new Map();
|
||||
const cwdProjectRoot = deps.findProjectRoot(cwd);
|
||||
|
||||
for (const targetFile of uniqueStrings(targetPaths)) {
|
||||
const projectRoot =
|
||||
cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot)
|
||||
? cwdProjectRoot
|
||||
: deps.findProjectRoot(targetFile);
|
||||
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
|
||||
projectRoot,
|
||||
targetFile,
|
||||
cache: discoveryCache,
|
||||
};
|
||||
if (disabledSources !== undefined) {
|
||||
findOptions.disabledSources = disabledSources;
|
||||
}
|
||||
const candidates = findSortedCandidatesCached(candidateDiscoveryCache, deps.findCandidates, findOptions);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const loadedRule = loadCandidate(
|
||||
candidate,
|
||||
deps,
|
||||
diagnostics,
|
||||
projectRoot,
|
||||
loadedRuleContent,
|
||||
projectMembership,
|
||||
);
|
||||
if (loadedRule === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchReason = matchDynamicRuleCached(
|
||||
dynamicMatchCache,
|
||||
projectRoot,
|
||||
targetFile,
|
||||
candidate,
|
||||
loadedRule,
|
||||
deps.matchRule ?? matchRule,
|
||||
);
|
||||
|
||||
if (matchReason === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dedupKey = ruleDedupKey(loadedRule);
|
||||
if (seenRules.has(dedupKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenRules.add(dedupKey);
|
||||
rules.push({ ...loadedRule, matchReason });
|
||||
}
|
||||
}
|
||||
|
||||
const sortedRules = sortCandidates(rules);
|
||||
storeLastLoad(state, sortedRules, diagnostics);
|
||||
return { rules: sortedRules, diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
config,
|
||||
loadStaticRules,
|
||||
loadDynamicRules,
|
||||
formatStatic: (rules) =>
|
||||
formatStaticBlock(rules, { maxRuleChars: config.maxRuleChars, maxResultChars: config.maxResultChars }),
|
||||
formatDynamic: (rules, target) =>
|
||||
formatDynamicBlock(rules, target, {
|
||||
maxRuleChars: config.maxRuleChars,
|
||||
maxResultChars: config.maxResultChars,
|
||||
}),
|
||||
resetSession: (cwd) => {
|
||||
clearSession(state);
|
||||
dynamicMatchCache.clear();
|
||||
if (cwd !== undefined) {
|
||||
state.cwd = cwd;
|
||||
}
|
||||
},
|
||||
isStaticInjected: (rule) => isStaticInjectedInState(state, rule),
|
||||
isDynamicInjected: (rule) => isDynamicInjectedInState(state, rule),
|
||||
markStaticInjected: (rule) => markStaticInjectedInState(state, rule),
|
||||
markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule),
|
||||
};
|
||||
}
|
||||
|
||||
function matchDynamicRuleCached(
|
||||
cache: DynamicMatchCache,
|
||||
projectRoot: string | null,
|
||||
targetFile: string,
|
||||
candidate: RuleCandidate,
|
||||
loadedRule: LoadedRule,
|
||||
matchRuleImpl: typeof matchRule,
|
||||
): MatchReason | null {
|
||||
const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash);
|
||||
if (cache.has(cacheKey)) {
|
||||
const cachedReason = cache.get(cacheKey) ?? null;
|
||||
cache.delete(cacheKey);
|
||||
cache.set(cacheKey, cachedReason);
|
||||
return cachedReason;
|
||||
}
|
||||
|
||||
const matchResult = matchRuleImpl({
|
||||
frontmatter: loadedRule.frontmatter,
|
||||
isSingleFile: candidate.isSingleFile,
|
||||
pathBases: pathBasesForTarget(projectRoot, targetFile, candidate),
|
||||
});
|
||||
const reason = matchResult.matched ? matchResult.reason : null;
|
||||
setDynamicMatchCacheEntry(cache, cacheKey, reason);
|
||||
return reason;
|
||||
}
|
||||
|
||||
function setDynamicMatchCacheEntry(cache: DynamicMatchCache, cacheKey: string, reason: MatchReason | null): void {
|
||||
if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) {
|
||||
const oldestCacheKey = cache.keys().next().value;
|
||||
if (oldestCacheKey !== undefined) {
|
||||
cache.delete(oldestCacheKey);
|
||||
}
|
||||
}
|
||||
cache.set(cacheKey, reason);
|
||||
}
|
||||
|
||||
function dynamicMatchCacheKey(
|
||||
projectRoot: string | null,
|
||||
targetFile: string,
|
||||
candidate: RuleCandidate,
|
||||
contentHash: string,
|
||||
): string {
|
||||
return [
|
||||
projectRoot ?? "",
|
||||
toPosixPath(resolve(targetFile)),
|
||||
candidate.realPath,
|
||||
candidate.relativePath,
|
||||
candidate.source,
|
||||
candidate.isGlobal ? "global" : "project",
|
||||
candidate.isSingleFile ? "single" : "multi",
|
||||
String(candidate.distance),
|
||||
contentHash,
|
||||
].join("\0");
|
||||
}
|
||||
|
||||
function loadStaticCandidates(candidates: ReadonlyArray<RuleCandidate>, deps: EngineDeps, projectRoot: string | null) {
|
||||
const rules: LoadedRule[] = [];
|
||||
const diagnostics: RuleDiagnostic[] = [];
|
||||
let rootSingleFileSelected = false;
|
||||
|
||||
for (const candidate of sortCandidates(candidates)) {
|
||||
if (isDedupedRootSingleFile(candidate, rootSingleFileSelected)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot);
|
||||
if (loadedRule === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchReason = staticMatchReason(loadedRule);
|
||||
if (matchReason === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRootSingleFile(candidate)) {
|
||||
rootSingleFileSelected = true;
|
||||
}
|
||||
|
||||
rules.push({ ...loadedRule, matchReason });
|
||||
}
|
||||
|
||||
return { rules: sortCandidates(rules), diagnostics };
|
||||
}
|
||||
|
||||
function loadCandidate(
|
||||
candidate: RuleCandidate,
|
||||
deps: EngineDeps,
|
||||
diagnostics: RuleDiagnostic[],
|
||||
projectRoot: string | null,
|
||||
loadedRuleContent?: Map<string, LoadedRuleContent | null>,
|
||||
projectMembership?: CandidateProjectMembership,
|
||||
): (LoadedRule & { matchReason: MatchReason }) | null {
|
||||
if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) {
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
source: candidate.path,
|
||||
message: "Rule file resolves outside project root",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const cachedContent = loadedRuleContent?.get(candidate.realPath);
|
||||
if (cachedContent !== undefined) {
|
||||
return loadedRuleFromContent(candidate, cachedContent, diagnostics);
|
||||
}
|
||||
|
||||
const content = deps.readFile(candidate.path);
|
||||
if (content === null) {
|
||||
loadedRuleContent?.set(candidate.realPath, null);
|
||||
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseRule(content);
|
||||
const loadedContent = {
|
||||
frontmatter: parsed.frontmatter,
|
||||
body: parsed.body,
|
||||
contentHash: hashContent(content),
|
||||
...(parsed.diagnostic === undefined ? {} : { diagnostic: parsed.diagnostic }),
|
||||
} satisfies LoadedRuleContent;
|
||||
loadedRuleContent?.set(candidate.realPath, loadedContent);
|
||||
return loadedRuleFromContent(candidate, loadedContent, diagnostics);
|
||||
}
|
||||
|
||||
function loadedRuleFromContent(
|
||||
candidate: RuleCandidate,
|
||||
content: LoadedRuleContent | null,
|
||||
diagnostics: RuleDiagnostic[],
|
||||
): (LoadedRule & { matchReason: MatchReason }) | null {
|
||||
if (content === null) {
|
||||
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content.diagnostic !== undefined) {
|
||||
diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic });
|
||||
}
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
frontmatter: content.frontmatter,
|
||||
body: content.body,
|
||||
contentHash: content.contentHash,
|
||||
matchReason: { kind: "no-match" },
|
||||
};
|
||||
}
|
||||
|
||||
function ruleDedupKey(rule: LoadedRule): string {
|
||||
return `${rule.realPath}::${rule.contentHash}`;
|
||||
}
|
||||
|
||||
function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string | null): boolean {
|
||||
if (candidate.isGlobal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (projectRoot === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativeRealPath = relative(realPathOrResolved(projectRoot), realPathOrResolved(candidate.realPath));
|
||||
return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath));
|
||||
}
|
||||
|
||||
function isCandidateWithinProjectCached(
|
||||
candidate: RuleCandidate,
|
||||
projectRoot: string | null,
|
||||
projectMembership: CandidateProjectMembership | undefined,
|
||||
): boolean {
|
||||
if (projectMembership === undefined) {
|
||||
return isCandidateWithinProject(candidate, projectRoot);
|
||||
}
|
||||
|
||||
const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`;
|
||||
const cached = projectMembership.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const isWithinProject = isCandidateWithinProject(candidate, projectRoot);
|
||||
projectMembership.set(cacheKey, isWithinProject);
|
||||
return isWithinProject;
|
||||
}
|
||||
|
||||
function realPathOrResolved(path: string): string {
|
||||
try {
|
||||
return realpathSync.native(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
function findSortedCandidatesCached(
|
||||
cache: CandidateDiscoveryCache,
|
||||
findCandidates: EngineDeps["findCandidates"],
|
||||
options: Parameters<EngineDeps["findCandidates"]>[0],
|
||||
): RuleCandidate[] {
|
||||
const cacheKey = candidateDiscoveryCacheKey(options);
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const candidates = sortCandidates(findCandidates(options));
|
||||
cache.set(cacheKey, candidates);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function candidateDiscoveryCacheKey(options: Parameters<EngineDeps["findCandidates"]>[0]): string {
|
||||
return [
|
||||
options.projectRoot ?? "",
|
||||
options.targetFile === null ? "" : dirname(resolve(options.targetFile)),
|
||||
...[...(options.disabledSources ?? [])].sort(),
|
||||
].join("\0");
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, resolve(childPath));
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
|
||||
}
|
||||
|
||||
function staticMatchReason(rule: LoadedRule): MatchReason | null {
|
||||
if (rule.frontmatter.alwaysApply === true) {
|
||||
return "alwaysApply";
|
||||
}
|
||||
|
||||
if (rule.isSingleFile) {
|
||||
return "single-file";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
|
||||
if (config.enabledSources === "auto") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const enabledSources = new Set(config.enabledSources);
|
||||
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
|
||||
}
|
||||
|
||||
function isDedupedRootSingleFile(candidate: RuleCandidate, rootSingleFileSelected: boolean): boolean {
|
||||
return rootSingleFileSelected && isRootSingleFile(candidate);
|
||||
}
|
||||
|
||||
function isRootSingleFile(candidate: RuleCandidate): boolean {
|
||||
return candidate.distance === 0 && candidate.isSingleFile && ROOT_SINGLE_FILE_SOURCES.has(candidate.source);
|
||||
}
|
||||
|
||||
function pathBasesForTarget(
|
||||
projectRoot: string | null,
|
||||
targetFile: string,
|
||||
candidate: RuleCandidate,
|
||||
): { projectRelative: string; scopeRelative?: string; basename: string } {
|
||||
const targetBasename = basename(targetFile);
|
||||
if (projectRoot === null) {
|
||||
return { projectRelative: targetBasename, basename: targetBasename };
|
||||
}
|
||||
|
||||
const projectRelative = toPosixPath(relative(projectRoot, targetFile));
|
||||
const scopeDirectory = scopeDirectoryForCandidate(projectRoot, candidate);
|
||||
if (scopeDirectory === null) {
|
||||
return { projectRelative, basename: targetBasename };
|
||||
}
|
||||
|
||||
return {
|
||||
projectRelative,
|
||||
scopeRelative: toPosixPath(relative(scopeDirectory, targetFile)),
|
||||
basename: targetBasename,
|
||||
};
|
||||
}
|
||||
|
||||
function scopeDirectoryForCandidate(projectRoot: string, candidate: RuleCandidate): string | null {
|
||||
if (candidate.isGlobal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidate.isSingleFile) {
|
||||
return dirname(candidate.path);
|
||||
}
|
||||
|
||||
const sourceIndex = candidate.relativePath.indexOf(candidate.source);
|
||||
if (sourceIndex === -1) {
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
const scopeRelativeDirectory = candidate.relativePath.slice(0, sourceIndex).replace(/\/$/, "");
|
||||
return scopeRelativeDirectory.length === 0 ? projectRoot : join(projectRoot, scopeRelativeDirectory);
|
||||
}
|
||||
|
||||
function toPosixPath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function storeLastLoad(
|
||||
state: SessionState,
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
diagnostics: ReadonlyArray<RuleDiagnostic>,
|
||||
): void {
|
||||
state.loadedRules.length = 0;
|
||||
state.loadedRules.push(...rules);
|
||||
state.diagnostics.length = 0;
|
||||
state.diagnostics.push(...diagnostics);
|
||||
}
|
||||
|
||||
function emptyLoadResult(state: SessionState): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
|
||||
storeLastLoad(state, [], []);
|
||||
return { rules: [], diagnostics: [] };
|
||||
}
|
||||
|
||||
function uniqueStrings(values: ReadonlyArray<string>): string[] {
|
||||
const uniqueValues: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seenValues.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenValues.add(value);
|
||||
uniqueValues.push(value);
|
||||
}
|
||||
return uniqueValues;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class UnsupportedRuleSourceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UnsupportedRuleSourceError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RuleFrontmatterParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RuleFrontmatterParseError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, posix, relative, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
GLOBAL_DISTANCE,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
PROJECT_SINGLE_FILES,
|
||||
USER_HOME_RULE_SUBDIRS,
|
||||
USER_HOME_SINGLE_FILES,
|
||||
} from "./constants.js";
|
||||
import { UnsupportedRuleSourceError } from "./errors.js";
|
||||
import { scanRuleFiles } from "./scanner.js";
|
||||
import type { RuleCandidate, RuleSource } from "./types.js";
|
||||
|
||||
interface SingleFileInfo {
|
||||
path: string;
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
export interface RuleDiscoveryCache {
|
||||
scannedRuleFiles: Map<string, ReturnType<typeof scanRuleFiles>>;
|
||||
singleFileInfo: Map<string, SingleFileInfo | null>;
|
||||
}
|
||||
|
||||
export interface FinderOptions {
|
||||
/** Project root absolute path (use findProjectRoot to get this). */
|
||||
projectRoot: string | null;
|
||||
/** Target file path (used for distance calculation in dynamic injection mode). null for static mode. */
|
||||
targetFile: string | null;
|
||||
/** User home directory (default: os.homedir()). Injectable for tests. */
|
||||
homeDir?: string;
|
||||
/** Set of disabled sources to omit from discovery. Empty by default. */
|
||||
disabledSources?: ReadonlySet<string>;
|
||||
/** Whether to skip user-home rules. Default: false. */
|
||||
skipUserHome?: boolean;
|
||||
cache?: RuleDiscoveryCache;
|
||||
}
|
||||
|
||||
interface WalkDirectory {
|
||||
directory: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
|
||||
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
|
||||
}
|
||||
|
||||
export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
|
||||
const skipUserHome = options.skipUserHome ?? false;
|
||||
if (options.projectRoot === null && skipUserHome) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const disabledSources = options.disabledSources ?? new Set<string>();
|
||||
const candidates: RuleCandidate[] = [];
|
||||
const homeDirectory = resolve(options.homeDir ?? homedir());
|
||||
|
||||
if (options.projectRoot !== null) {
|
||||
candidates.push(
|
||||
...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache),
|
||||
);
|
||||
}
|
||||
|
||||
if (!skipUserHome) {
|
||||
candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function findProjectCandidates(
|
||||
projectRoot: string,
|
||||
targetFile: string | null,
|
||||
disabledSources: ReadonlySet<string>,
|
||||
cache: RuleDiscoveryCache | undefined,
|
||||
): RuleCandidate[] {
|
||||
const rootDirectory = resolve(projectRoot);
|
||||
const walkDirectories = getWalkDirectories(rootDirectory, targetFile);
|
||||
const candidates: RuleCandidate[] = [];
|
||||
|
||||
for (const walkDirectory of walkDirectories) {
|
||||
for (const [parentDirectory, subDirectory] of PROJECT_RULE_SUBDIRS) {
|
||||
const source = toProjectRuleSource(parentDirectory, subDirectory);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ruleDirectory = join(walkDirectory.directory, parentDirectory, subDirectory);
|
||||
for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) {
|
||||
candidates.push({
|
||||
path: scannedFile.path,
|
||||
realPath: scannedFile.realPath,
|
||||
source,
|
||||
distance: targetFile === null ? 0 : walkDirectory.distance,
|
||||
isGlobal: false,
|
||||
isSingleFile: false,
|
||||
relativePath: toRelativePath(rootDirectory, scannedFile.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const walkDirectory of walkDirectories) {
|
||||
for (const ruleFile of PROJECT_SINGLE_FILES) {
|
||||
const source = toProjectSingleFileSource(ruleFile);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(walkDirectory.directory, ruleFile);
|
||||
const fileInfo = singleFileInfoCached(filePath, cache);
|
||||
if (fileInfo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: fileInfo.path,
|
||||
realPath: fileInfo.realPath,
|
||||
source,
|
||||
distance: targetFile === null ? 0 : walkDirectory.distance,
|
||||
isGlobal: false,
|
||||
isSingleFile: true,
|
||||
relativePath: toRelativePath(rootDirectory, filePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function findUserHomeCandidates(
|
||||
homeDirectory: string,
|
||||
disabledSources: ReadonlySet<string>,
|
||||
cache: RuleDiscoveryCache | undefined,
|
||||
): RuleCandidate[] {
|
||||
const candidates: RuleCandidate[] = [];
|
||||
|
||||
for (const ruleSubdir of USER_HOME_RULE_SUBDIRS) {
|
||||
const source = toUserHomeRuleSource(ruleSubdir);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ruleDirectory = join(homeDirectory, ruleSubdir);
|
||||
for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) {
|
||||
candidates.push({
|
||||
path: scannedFile.path,
|
||||
realPath: scannedFile.realPath,
|
||||
source,
|
||||
distance: GLOBAL_DISTANCE,
|
||||
isGlobal: true,
|
||||
isSingleFile: false,
|
||||
relativePath: toRelativePath(homeDirectory, scannedFile.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const ruleFile of USER_HOME_SINGLE_FILES) {
|
||||
const source = toUserHomeSingleFileSource(ruleFile);
|
||||
if (disabledSources.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(homeDirectory, ruleFile);
|
||||
const fileInfo = singleFileInfoCached(filePath, cache);
|
||||
if (fileInfo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: fileInfo.path,
|
||||
realPath: fileInfo.realPath,
|
||||
source,
|
||||
distance: GLOBAL_DISTANCE,
|
||||
isGlobal: true,
|
||||
isSingleFile: true,
|
||||
relativePath: toRelativePath(homeDirectory, filePath),
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ReturnType<typeof scanRuleFiles> {
|
||||
if (cache === undefined) {
|
||||
return scanRuleFiles({ rootDir });
|
||||
}
|
||||
|
||||
const cached = cache.scannedRuleFiles.get(rootDir);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const scannedFiles = scanRuleFiles({ rootDir });
|
||||
cache.scannedRuleFiles.set(rootDir, scannedFiles);
|
||||
return scannedFiles;
|
||||
}
|
||||
|
||||
function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null {
|
||||
if (cache === undefined) {
|
||||
return readSingleFileInfo(filePath);
|
||||
}
|
||||
|
||||
const cached = cache.singleFileInfo.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fileInfo = readSingleFileInfo(filePath);
|
||||
cache.singleFileInfo.set(filePath, fileInfo);
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] {
|
||||
if (targetFile === null) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const startDirectory = dirname(resolve(targetFile));
|
||||
if (!isSameOrChildPath(startDirectory, projectRoot)) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const walkDirectories: WalkDirectory[] = [];
|
||||
let currentDirectory = startDirectory;
|
||||
let distance = 0;
|
||||
|
||||
while (true) {
|
||||
walkDirectories.push({ directory: currentDirectory, distance });
|
||||
if (currentDirectory === projectRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
return walkDirectories;
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, childPath);
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
|
||||
}
|
||||
|
||||
function readSingleFileInfo(filePath: string): SingleFileInfo | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: filePath, realPath: resolveRealPath(filePath) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRealPath(filePath: string): string {
|
||||
try {
|
||||
return realpathSync.native(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function toRelativePath(rootDirectory: string, filePath: string): string {
|
||||
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource {
|
||||
const source = `${parentDirectory}/${subDirectory}`;
|
||||
switch (source) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toProjectSingleFileSource(ruleFile: string): RuleSource {
|
||||
switch (ruleFile) {
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
return ruleFile;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toUserHomeRuleSource(ruleSubdir: string): RuleSource {
|
||||
const source = `~/${ruleSubdir}`;
|
||||
switch (source) {
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toUserHomeSingleFileSource(ruleFile: string): RuleSource {
|
||||
const source = `~/${ruleFile}`;
|
||||
switch (source) {
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { truncateBudget, truncateRule } from "./truncator.js";
|
||||
import type { LoadedRule } from "./types.js";
|
||||
|
||||
export interface FormatOptions {
|
||||
maxRuleChars: number;
|
||||
maxResultChars: number;
|
||||
}
|
||||
|
||||
type TruncatedRule = {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function formatRule(rule: TruncatedRule): string {
|
||||
return `Instructions from: ${rule.path}\n${rule.body}`;
|
||||
}
|
||||
|
||||
function truncateRules(rules: ReadonlyArray<LoadedRule>, options: FormatOptions): TruncatedRule[] {
|
||||
const perRuleTruncated = rules.map((rule) => ({
|
||||
path: rule.path,
|
||||
relativePath: rule.relativePath,
|
||||
body: truncateRule(rule.body, { maxChars: options.maxRuleChars, relativePath: rule.relativePath }).body,
|
||||
}));
|
||||
const budgetedRules = truncateBudget({
|
||||
rules: perRuleTruncated.map((rule) => ({ body: rule.body, relativePath: rule.relativePath })),
|
||||
maxResultChars: options.maxResultChars,
|
||||
});
|
||||
const truncatedRules: TruncatedRule[] = [];
|
||||
|
||||
for (let index = 0; index < budgetedRules.length; index += 1) {
|
||||
const sourceRule = perRuleTruncated[index];
|
||||
const budgetedRule = budgetedRules[index];
|
||||
if (sourceRule === undefined || budgetedRule === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
truncatedRules.push({
|
||||
path: sourceRule.path,
|
||||
relativePath: budgetedRule.relativePath,
|
||||
body: budgetedRule.body,
|
||||
});
|
||||
}
|
||||
|
||||
return truncatedRules;
|
||||
}
|
||||
|
||||
export function formatStaticBlock(rules: ReadonlyArray<LoadedRule>, options: FormatOptions): string {
|
||||
if (rules.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `\n\n## Project Instructions\n${truncateRules(rules, options).map(formatRule).join("\n\n")}`;
|
||||
}
|
||||
|
||||
export function formatDynamicBlock(
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
targetRelativePath: string,
|
||||
options: FormatOptions,
|
||||
): string {
|
||||
if (rules.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `\n\nAdditional project instructions matched for ${targetRelativePath}:\n\n${truncateRules(rules, options)
|
||||
.map(formatRule)
|
||||
.join("\n\n")}`;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import picomatch from "picomatch";
|
||||
import type { MatchReason, RuleFrontmatter } from "./types.js";
|
||||
|
||||
export interface MatcherInput {
|
||||
frontmatter: RuleFrontmatter;
|
||||
isSingleFile: boolean;
|
||||
/** Path bases to try matching against (POSIX-normalized). */
|
||||
pathBases: { projectRelative: string; scopeRelative?: string; basename: string };
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
matched: boolean;
|
||||
reason: MatchReason;
|
||||
}
|
||||
|
||||
interface CompiledPattern {
|
||||
pattern: string;
|
||||
isMatch: (path: string) => boolean;
|
||||
}
|
||||
|
||||
interface CompiledPatternSet {
|
||||
positivePatterns: CompiledPattern[];
|
||||
negativeMatchers: Array<(path: string) => boolean>;
|
||||
}
|
||||
|
||||
const compiledPatternSets = new Map<string, CompiledPatternSet>();
|
||||
|
||||
export function matchRule(input: MatcherInput): MatchResult {
|
||||
if (input.isSingleFile) {
|
||||
return { matched: true, reason: "single-file" };
|
||||
}
|
||||
|
||||
if (input.frontmatter.alwaysApply === true) {
|
||||
return { matched: true, reason: "alwaysApply" };
|
||||
}
|
||||
|
||||
const patterns = normalizeGlobs(input.frontmatter);
|
||||
if (patterns.length === 0) {
|
||||
return noMatch();
|
||||
}
|
||||
|
||||
const pathBases = normalizedPathBases(input.pathBases);
|
||||
const { positivePatterns, negativeMatchers } = compiledPatternSetFor(patterns);
|
||||
|
||||
for (const { pattern, isMatch } of positivePatterns) {
|
||||
for (const pathBase of pathBases) {
|
||||
if (!isMatch(pathBase)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isExcluded(pathBase, negativeMatchers)) {
|
||||
return noMatch();
|
||||
}
|
||||
|
||||
return { matched: true, reason: { kind: "glob", pattern } };
|
||||
}
|
||||
}
|
||||
|
||||
return noMatch();
|
||||
}
|
||||
|
||||
export function normalizeGlobs(frontmatter: RuleFrontmatter): string[] {
|
||||
const patterns = [
|
||||
...normalizePatternList(frontmatter.globs),
|
||||
...normalizePatternList(frontmatter.paths),
|
||||
...normalizePatternList(frontmatter.applyTo),
|
||||
];
|
||||
|
||||
return [...new Set(patterns.map(normalizePath))];
|
||||
}
|
||||
|
||||
export function hashContent(body: string): string {
|
||||
return createHash("sha256").update(body).digest("hex");
|
||||
}
|
||||
|
||||
function normalizePatternList(patterns: string | string[] | undefined): string[] {
|
||||
if (patterns === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(patterns) ? patterns : [patterns];
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function normalizedPathBases(pathBases: MatcherInput["pathBases"]): string[] {
|
||||
const normalizedBases = [normalizePath(pathBases.projectRelative)];
|
||||
if (pathBases.scopeRelative !== undefined) {
|
||||
normalizedBases.push(normalizePath(pathBases.scopeRelative));
|
||||
}
|
||||
normalizedBases.push(normalizePath(pathBases.basename));
|
||||
return normalizedBases;
|
||||
}
|
||||
|
||||
function compiledPatternSetFor(patterns: ReadonlyArray<string>): CompiledPatternSet {
|
||||
const cacheKey = JSON.stringify(patterns);
|
||||
const cached = compiledPatternSets.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const compiled = compilePatternSet(patterns);
|
||||
compiledPatternSets.set(cacheKey, compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function compilePatternSet(patterns: ReadonlyArray<string>): CompiledPatternSet {
|
||||
const positivePatterns: CompiledPattern[] = [];
|
||||
const negativeMatchers: Array<(path: string) => boolean> = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.startsWith("!")) {
|
||||
negativeMatchers.push(createGlobMatcher(pattern.slice(1)));
|
||||
continue;
|
||||
}
|
||||
|
||||
positivePatterns.push({ pattern, isMatch: createGlobMatcher(pattern) });
|
||||
}
|
||||
|
||||
return { positivePatterns, negativeMatchers };
|
||||
}
|
||||
|
||||
function createGlobMatcher(pattern: string): (path: string) => boolean {
|
||||
return picomatch(normalizePath(pattern), { bash: true, dot: true });
|
||||
}
|
||||
|
||||
function isExcluded(pathBase: string, negativeMatchers: ReadonlyArray<(path: string) => boolean>): boolean {
|
||||
for (const isMatch of negativeMatchers) {
|
||||
if (isMatch(pathBase)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function noMatch(): MatchResult {
|
||||
return { matched: false, reason: { kind: "no-match" } };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SOURCE_PRIORITY } from "./constants.js";
|
||||
import type { RuleCandidate } from "./types.js";
|
||||
|
||||
export function sortCandidates<T extends RuleCandidate>(candidates: ReadonlyArray<T>): T[] {
|
||||
return candidates
|
||||
.map((candidate, index) => ({ candidate, index }))
|
||||
.sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index)
|
||||
.map(({ candidate }) => candidate);
|
||||
}
|
||||
|
||||
export function compareCandidates(a: RuleCandidate, b: RuleCandidate): number {
|
||||
return (
|
||||
compareBoolean(a.isGlobal, b.isGlobal) ||
|
||||
compareNumber(a.distance, b.distance) ||
|
||||
compareNumber(SOURCE_PRIORITY.get(a.source) ?? Infinity, SOURCE_PRIORITY.get(b.source) ?? Infinity) ||
|
||||
compareString(a.relativePath, b.relativePath) ||
|
||||
compareString(a.realPath, b.realPath)
|
||||
);
|
||||
}
|
||||
|
||||
function compareBoolean(a: boolean, b: boolean): number {
|
||||
return Number(a) - Number(b);
|
||||
}
|
||||
|
||||
function compareNumber(a: number, b: number): number {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
function compareString(a: string, b: string): number {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { RuleFrontmatterParseError } from "./errors.js";
|
||||
import type { ParsedRule, RuleFrontmatter } from "./types.js";
|
||||
|
||||
const FRONTMATTER_OPENING = "---\n";
|
||||
const FRONTMATTER_OPENING_CRLF = "---\r\n";
|
||||
|
||||
/** Parse markdown rule content and extract the supported YAML frontmatter subset. */
|
||||
export function parseRule(content: string): ParsedRule {
|
||||
const normalizedContent = stripBom(content);
|
||||
const openingLength = getOpeningDelimiterLength(normalizedContent);
|
||||
if (openingLength === 0) {
|
||||
return { frontmatter: {}, body: normalizedContent };
|
||||
}
|
||||
|
||||
const closingDelimiter = findClosingDelimiter(normalizedContent, openingLength);
|
||||
if (closingDelimiter === null) {
|
||||
return {
|
||||
frontmatter: {},
|
||||
body: normalizedContent,
|
||||
diagnostic: "Missing closing frontmatter delimiter",
|
||||
};
|
||||
}
|
||||
|
||||
const yamlContent = normalizedContent.slice(openingLength, closingDelimiter.start);
|
||||
const body = normalizedContent.slice(closingDelimiter.bodyStart);
|
||||
|
||||
try {
|
||||
return { frontmatter: parseYamlFrontmatter(yamlContent), body };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid YAML frontmatter";
|
||||
return {
|
||||
frontmatter: {},
|
||||
body: normalizedContent,
|
||||
diagnostic: `Malformed frontmatter: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stripBom(content: string): string {
|
||||
return content.startsWith("\uFEFF") ? content.slice(1) : content;
|
||||
}
|
||||
|
||||
function getOpeningDelimiterLength(content: string): number {
|
||||
if (content.startsWith(FRONTMATTER_OPENING_CRLF)) return FRONTMATTER_OPENING_CRLF.length;
|
||||
if (content.startsWith(FRONTMATTER_OPENING)) return FRONTMATTER_OPENING.length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function findClosingDelimiter(content: string, openingLength: number): { start: number; bodyStart: number } | null {
|
||||
let lineStart = openingLength;
|
||||
|
||||
while (lineStart <= content.length) {
|
||||
const nextNewline = content.indexOf("\n", lineStart);
|
||||
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
||||
const line = content.slice(lineStart, lineEnd).replace(/\r$/, "");
|
||||
|
||||
if (line === "---") {
|
||||
return {
|
||||
start: lineStart,
|
||||
bodyStart: nextNewline === -1 ? content.length : nextNewline + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextNewline === -1) break;
|
||||
lineStart = nextNewline + 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseYamlFrontmatter(yamlContent: string): RuleFrontmatter {
|
||||
const lines = yamlContent.replace(/\r\n/g, "\n").split("\n");
|
||||
const frontmatter: RuleFrontmatter = {};
|
||||
const globValues: string[] = [];
|
||||
let lineIndex = 0;
|
||||
|
||||
while (lineIndex < lines.length) {
|
||||
const rawLine = lines[lineIndex];
|
||||
if (rawLine === undefined) break;
|
||||
|
||||
const line = stripComment(rawLine).trim();
|
||||
if (line.length === 0) {
|
||||
lineIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
throw new RuleFrontmatterParseError(`Expected key-value pair on line ${lineIndex + 1}`);
|
||||
}
|
||||
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const rawValue = line.slice(colonIndex + 1).trim();
|
||||
|
||||
if (key === "description") {
|
||||
frontmatter.description = parseStringValue(rawValue);
|
||||
lineIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "alwaysApply") {
|
||||
frontmatter.alwaysApply = parseBooleanValue(rawValue, lineIndex + 1);
|
||||
lineIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "globs" || key === "paths" || key === "applyTo") {
|
||||
const parsed = parseGlobValue(rawValue, lines, lineIndex);
|
||||
for (const glob of parsed.values) {
|
||||
if (!globValues.includes(glob)) globValues.push(glob);
|
||||
}
|
||||
lineIndex += parsed.consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
lineIndex += 1;
|
||||
}
|
||||
|
||||
const singleGlob = globValues[0];
|
||||
if (globValues.length === 1 && singleGlob !== undefined) {
|
||||
frontmatter.globs = singleGlob;
|
||||
} else if (globValues.length > 1) {
|
||||
frontmatter.globs = globValues;
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
function parseBooleanValue(value: string, lineNumber: number): boolean {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
throw new RuleFrontmatterParseError(`Expected boolean on line ${lineNumber}`);
|
||||
}
|
||||
|
||||
function parseGlobValue(rawValue: string, lines: string[], lineIndex: number): { values: string[]; consumed: number } {
|
||||
if (rawValue.startsWith("[")) {
|
||||
return { values: parseInlineArray(rawValue), consumed: 1 };
|
||||
}
|
||||
|
||||
if (rawValue.length === 0) {
|
||||
return parseMultilineArray(lines, lineIndex);
|
||||
}
|
||||
|
||||
const value = parseStringValue(rawValue);
|
||||
if (value.includes(",")) {
|
||||
return {
|
||||
values: value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
consumed: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { values: [value], consumed: 1 };
|
||||
}
|
||||
|
||||
function parseMultilineArray(lines: string[], lineIndex: number): { values: string[]; consumed: number } {
|
||||
const values: string[] = [];
|
||||
let consumed = 1;
|
||||
|
||||
for (let index = lineIndex + 1; index < lines.length; index += 1) {
|
||||
const rawLine = lines[index];
|
||||
if (rawLine === undefined) break;
|
||||
|
||||
const lineWithoutComment = stripComment(rawLine);
|
||||
if (lineWithoutComment.trim().length === 0) {
|
||||
consumed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrayItem = lineWithoutComment.match(/^\s+-\s*(.*)$/);
|
||||
if (arrayItem === null) break;
|
||||
|
||||
values.push(parseStringValue(arrayItem[1] ?? ""));
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
return { values: values.filter(Boolean), consumed };
|
||||
}
|
||||
|
||||
function parseInlineArray(value: string): string[] {
|
||||
const closingBracketIndex = findClosingBracket(value);
|
||||
if (closingBracketIndex === -1) {
|
||||
throw new RuleFrontmatterParseError("Unclosed inline array");
|
||||
}
|
||||
|
||||
const trailing = value.slice(closingBracketIndex + 1).trim();
|
||||
if (trailing.length > 0) {
|
||||
throw new RuleFrontmatterParseError("Unexpected content after inline array");
|
||||
}
|
||||
|
||||
const content = value.slice(1, closingBracketIndex).trim();
|
||||
if (content.length === 0) return [];
|
||||
|
||||
return splitCommaSeparated(content).map(parseStringValue).filter(Boolean);
|
||||
}
|
||||
|
||||
function findClosingBracket(value: string): number {
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character === undefined) continue;
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote !== null && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
if (quote === null) quote = character;
|
||||
else if (quote === character) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === null && character === "]") return index;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function splitCommaSeparated(value: string): string[] {
|
||||
const values: string[] = [];
|
||||
let current = "";
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character === undefined) continue;
|
||||
|
||||
if (escaped) {
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote !== null && character === "\\") {
|
||||
current += character;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
if (quote === null) quote = character;
|
||||
else if (quote === character) quote = null;
|
||||
current += character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === null && character === ",") {
|
||||
values.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += character;
|
||||
}
|
||||
|
||||
if (quote !== null) {
|
||||
throw new RuleFrontmatterParseError("Unclosed quoted value");
|
||||
}
|
||||
|
||||
values.push(current.trim());
|
||||
return values.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseStringValue(value: string): string {
|
||||
if (value.length === 0) return "";
|
||||
if (value.startsWith('"')) return parseJsonString(value);
|
||||
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
||||
if (value.startsWith("'")) throw new RuleFrontmatterParseError("Unclosed quoted value");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseJsonString(value: string): string {
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
throw new RuleFrontmatterParseError("Invalid JSON-quoted string");
|
||||
}
|
||||
|
||||
if (typeof parsedValue !== "string") {
|
||||
throw new RuleFrontmatterParseError("Expected JSON-quoted string");
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function stripComment(line: string): string {
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const character = line[index];
|
||||
if (character === undefined) continue;
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote !== null && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
if (quote === null) quote = character;
|
||||
else if (quote === character) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === null && character === "#") return line.slice(0, index);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { PROJECT_MARKERS } from "./constants.js";
|
||||
|
||||
export function findProjectRoot(startPath: string, markers: ReadonlyArray<string> = PROJECT_MARKERS): string | null {
|
||||
const resolvedStartPath = resolve(startPath);
|
||||
|
||||
if (!existsSync(resolvedStartPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startStats = statSync(resolvedStartPath);
|
||||
let currentDirectory = startStats.isDirectory() ? resolvedStartPath : dirname(resolvedStartPath);
|
||||
const filesystemRoot = resolve("/");
|
||||
|
||||
while (true) {
|
||||
for (const marker of markers) {
|
||||
if (existsSync(join(currentDirectory, marker))) {
|
||||
return currentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDirectory === filesystemRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentDirectory = dirname(currentDirectory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type Dirent, existsSync, lstatSync, readdirSync, realpathSync, type Stats, statSync } from "node:fs";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import { DEFAULT_MAX_SCAN_FILES, RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js";
|
||||
|
||||
export interface ScanOptions {
|
||||
rootDir: string;
|
||||
excludedDirs?: ReadonlyArray<string>;
|
||||
/** Maximum recursion depth. Default: 10 */
|
||||
maxDepth?: number;
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
export interface ScannedFile {
|
||||
/** Absolute path as encountered (may be a symlink). */
|
||||
path: string;
|
||||
/** Real (resolved) path; same as path if not a symlink. */
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
export function scanRuleFiles(options: ScanOptions): ScannedFile[] {
|
||||
const rootPath = toAbsolutePath(options.rootDir);
|
||||
if (!existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let rootStats: Stats;
|
||||
try {
|
||||
rootStats = statSync(rootPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!rootStats.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: ScannedFile[] = [];
|
||||
const visitedDirectories = new Set<string>();
|
||||
const excludedDirs = new Set(options.excludedDirs ?? SCANNER_EXCLUDED_DIRS);
|
||||
const maxDepth = options.maxDepth ?? 10;
|
||||
const maxFiles = normalizeMaxFiles(options.maxFiles);
|
||||
|
||||
scanDirectory(rootPath, 0, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeMaxFiles(maxFiles: number | undefined): number {
|
||||
const value = maxFiles ?? DEFAULT_MAX_SCAN_FILES;
|
||||
if (!Number.isFinite(value) || value < 0) return DEFAULT_MAX_SCAN_FILES;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function toAbsolutePath(filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : resolve(filePath);
|
||||
}
|
||||
|
||||
function scanDirectory(
|
||||
directoryPath: string,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
maxFiles: number,
|
||||
excludedDirs: ReadonlySet<string>,
|
||||
visitedDirectories: Set<string>,
|
||||
results: ScannedFile[],
|
||||
): void {
|
||||
if (results.length >= maxFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
let realDirectoryPath: string;
|
||||
try {
|
||||
realDirectoryPath = realpathSync.native(directoryPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visitedDirectories.has(realDirectoryPath)) {
|
||||
return;
|
||||
}
|
||||
visitedDirectories.add(realDirectoryPath);
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directoryPath, { withFileTypes: true }).sort((leftEntry, rightEntry) =>
|
||||
leftEntry.name.localeCompare(rightEntry.name),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= maxFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = join(directoryPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!excludedDirs.has(entry.name) && depth < maxDepth) {
|
||||
scanDirectory(entryPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
scanSymbolicLink(entryPath, entry.name, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && isRuleFile(entry.name)) {
|
||||
results.push({ path: entryPath, realPath: resolveRealPath(entryPath) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanSymbolicLink(
|
||||
linkPath: string,
|
||||
linkName: string,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
maxFiles: number,
|
||||
excludedDirs: ReadonlySet<string>,
|
||||
visitedDirectories: Set<string>,
|
||||
results: ScannedFile[],
|
||||
): void {
|
||||
if (results.length >= maxFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetStats: Stats;
|
||||
try {
|
||||
targetStats = statSync(linkPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStats.isDirectory()) {
|
||||
if (!excludedDirs.has(linkName) && depth < maxDepth) {
|
||||
scanDirectory(linkPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStats.isFile() && isRuleFile(linkName)) {
|
||||
results.push({ path: linkPath, realPath: resolveRealPath(linkPath) });
|
||||
}
|
||||
}
|
||||
|
||||
function isRuleFile(fileName: string): boolean {
|
||||
return RULE_FILE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
|
||||
}
|
||||
|
||||
function resolveRealPath(filePath: string): string {
|
||||
try {
|
||||
const realPath = realpathSync.native(filePath);
|
||||
const fileStats = lstatSync(filePath);
|
||||
return fileStats.isSymbolicLink() ? realPath : filePath;
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TRUNCATION_NOTICE } from "./constants.js";
|
||||
import type { TruncationResult } from "./types.js";
|
||||
|
||||
type BudgetRule = {
|
||||
body: string;
|
||||
relativePath: string;
|
||||
};
|
||||
|
||||
type BudgetResult = BudgetRule & {
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function truncationNotice(relativePath: string): string {
|
||||
return TRUNCATION_NOTICE.replace("{path}", relativePath);
|
||||
}
|
||||
|
||||
function safeSliceEnd(body: string, end: number): number {
|
||||
if (end <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastCodeUnit = body.charCodeAt(end - 1);
|
||||
if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) {
|
||||
return end - 1;
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
export function truncateRule(body: string, options: { maxChars: number; relativePath: string }): TruncationResult {
|
||||
if (body.length <= options.maxChars) {
|
||||
return { body, truncated: false, originalLength: body.length };
|
||||
}
|
||||
|
||||
const notice = truncationNotice(options.relativePath);
|
||||
if (options.maxChars < notice.length) {
|
||||
return { body: notice, truncated: true, originalLength: body.length };
|
||||
}
|
||||
|
||||
const sliceEnd = safeSliceEnd(body, options.maxChars - notice.length);
|
||||
return { body: `${body.slice(0, sliceEnd)}${notice}`, truncated: true, originalLength: body.length };
|
||||
}
|
||||
|
||||
export function truncateBudget(input: { rules: ReadonlyArray<BudgetRule>; maxResultChars: number }): BudgetResult[] {
|
||||
const results: BudgetResult[] = [];
|
||||
let remainingBudget = input.maxResultChars;
|
||||
|
||||
for (const rule of input.rules) {
|
||||
if (remainingBudget >= rule.body.length) {
|
||||
results.push({ body: rule.body, truncated: false, relativePath: rule.relativePath });
|
||||
remainingBudget -= rule.body.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const notice = truncationNotice(rule.relativePath);
|
||||
if (remainingBudget <= notice.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const sliceEnd = safeSliceEnd(rule.body, remainingBudget - notice.length);
|
||||
const body = `${rule.body.slice(0, sliceEnd)}${notice}`;
|
||||
results.push({ body, truncated: true, relativePath: rule.relativePath });
|
||||
remainingBudget -= body.length;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Public types for pi-rules.
|
||||
*
|
||||
* These types are stable contracts between modules. The frontmatter type
|
||||
* mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`)
|
||||
* aliases that are normalized into `globs` internally.
|
||||
*/
|
||||
|
||||
/**
|
||||
* YAML frontmatter parsed from a rule markdown file.
|
||||
* `paths` (Claude alias) and `applyTo` (Copilot alias) are normalized into
|
||||
* `globs` by the parser before any matcher sees this struct.
|
||||
*/
|
||||
export interface RuleFrontmatter {
|
||||
description?: string;
|
||||
globs?: string | string[];
|
||||
paths?: string | string[];
|
||||
applyTo?: string | string[];
|
||||
alwaysApply?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing a rule markdown file.
|
||||
* `body` excludes the frontmatter delimiters and the YAML payload.
|
||||
*/
|
||||
export interface ParsedRule {
|
||||
frontmatter: RuleFrontmatter;
|
||||
body: string;
|
||||
/**
|
||||
* Diagnostic message if frontmatter parsing failed but the body was salvaged.
|
||||
* Empty when parsing succeeded.
|
||||
*/
|
||||
diagnostic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A discovered rule file candidate before parsing/matching.
|
||||
*
|
||||
* `path` is the absolute path as discovered (possibly via symlink).
|
||||
* `realPath` is the canonical resolved path used for dedup.
|
||||
* `source` identifies which discovery source produced this candidate.
|
||||
*/
|
||||
export interface RuleCandidate {
|
||||
path: string;
|
||||
realPath: string;
|
||||
source: RuleSource;
|
||||
/**
|
||||
* Distance from the target file directory to the directory containing this rule.
|
||||
* 0 = same directory, 9999 = global/user-home rule.
|
||||
*/
|
||||
distance: number;
|
||||
isGlobal: boolean;
|
||||
/**
|
||||
* True when this candidate is a SINGLE-FILE rule like AGENTS.md or
|
||||
* `.github/copilot-instructions.md` (frontmatter optional, applies always).
|
||||
*/
|
||||
isSingleFile: boolean;
|
||||
/**
|
||||
* Path relative to project root, POSIX-normalized. Used for matcher and display.
|
||||
* Empty string for user-home global rules.
|
||||
*/
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully-loaded rule ready for injection.
|
||||
*/
|
||||
export interface LoadedRule extends RuleCandidate {
|
||||
frontmatter: RuleFrontmatter;
|
||||
body: string;
|
||||
contentHash: string;
|
||||
matchReason: MatchReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source identifier for rule files. Used for deterministic ordering and display.
|
||||
*/
|
||||
export type RuleSource =
|
||||
| ".omo/rules"
|
||||
| ".claude/rules"
|
||||
| ".cursor/rules"
|
||||
| ".github/instructions"
|
||||
| ".github/copilot-instructions.md"
|
||||
| "AGENTS.md"
|
||||
| "CLAUDE.md"
|
||||
| "CONTEXT.md"
|
||||
| "~/.omo/rules"
|
||||
| "~/.opencode/rules"
|
||||
| "~/.claude/rules"
|
||||
| "~/.config/opencode/AGENTS.md"
|
||||
| "~/.claude/CLAUDE.md";
|
||||
|
||||
/**
|
||||
* Why a candidate matched the target file. Surfaced in the injection block so
|
||||
* the model can attribute its behavior to a specific rule.
|
||||
*/
|
||||
export type MatchReason = "alwaysApply" | "single-file" | { kind: "glob"; pattern: string } | { kind: "no-match" };
|
||||
|
||||
/**
|
||||
* Truncation result.
|
||||
*/
|
||||
export interface TruncationResult {
|
||||
body: string;
|
||||
truncated: boolean;
|
||||
originalLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration knobs resolved from env vars and package.json.
|
||||
*/
|
||||
export interface PiRulesConfig {
|
||||
disabled: boolean;
|
||||
mode: "static" | "dynamic" | "both" | "off";
|
||||
maxRuleChars: number;
|
||||
maxResultChars: number;
|
||||
enabledSources: RuleSource[] | "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session in-memory dedup state.
|
||||
*
|
||||
* `staticDedup` keys are `{cwd}::{rulePath}::{contentHash}` strings.
|
||||
* `dynamicDedup` stores session-scoped `{rulePath}::{contentHash}` strings.
|
||||
*/
|
||||
export interface SessionState {
|
||||
cwd: string | undefined;
|
||||
staticDedup: Set<string>;
|
||||
dynamicDedup: Map<string, Set<string>>;
|
||||
dynamicTargetFingerprints: Map<string, string>;
|
||||
loadedRules: LoadedRule[];
|
||||
diagnostics: RuleDiagnostic[];
|
||||
}
|
||||
|
||||
export interface RuleDiagnostic {
|
||||
severity: "warning" | "error";
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
export interface CodexPostToolUseLike {
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_response: unknown;
|
||||
}
|
||||
|
||||
const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]);
|
||||
const TRACKED_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"read_file",
|
||||
"mcp__filesystem__read_file",
|
||||
"mcp__filesystem__read_multiple_files",
|
||||
"mcp__filesystem__write_file",
|
||||
"mcp__filesystem__edit_file",
|
||||
"write",
|
||||
"edit",
|
||||
"multiedit",
|
||||
"multi_edit",
|
||||
"apply_patch",
|
||||
"bash",
|
||||
"shell_command",
|
||||
"exec_command",
|
||||
]);
|
||||
|
||||
export function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): string[] {
|
||||
const toolName = input.tool_name.toLowerCase();
|
||||
if (!TRACKED_TOOL_NAMES.has(toolName) || isFailedToolResponse(input.tool_response)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths = new Set<string>();
|
||||
const toolInput = isRecord(input.tool_input) ? input.tool_input : {};
|
||||
addCommonPathFields(paths, toolInput, cwd);
|
||||
addPatchPayloadPaths(paths, toolInput, cwd);
|
||||
addPatchRecordPaths(paths, toolInput["files"], cwd);
|
||||
addPatchRecordPaths(paths, toolInput["changes"], cwd);
|
||||
|
||||
if (COMMAND_TOOL_NAMES.has(toolName)) {
|
||||
const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd");
|
||||
const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd");
|
||||
addCommandPaths(paths, command, workdir === undefined ? cwd : resolvePath(cwd, workdir));
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function addCommonPathFields(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
|
||||
for (const key of ["path", "filePath", "file_path", "target", "targetPath", "target_path"]) {
|
||||
addPath(paths, input[key], cwd, false);
|
||||
}
|
||||
for (const key of ["paths", "filePaths", "file_paths"]) {
|
||||
addPathArray(paths, input[key], cwd, false);
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchPayloadPaths(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
|
||||
for (const key of ["input", "patch", "command", "cmd"]) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string") {
|
||||
addPatchHeaderPaths(paths, value, cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchHeaderPaths(paths: Set<string>, patch: string, cwd: string): void {
|
||||
for (const line of patch.split("\n")) {
|
||||
for (const prefix of ["*** Add File: ", "*** Update File: ", "*** Move to: "]) {
|
||||
if (line.startsWith(prefix)) {
|
||||
addPath(paths, line.slice(prefix.length).trim(), cwd, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchRecordPaths(paths: Set<string>, value: unknown, cwd: string): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
if (typeof item === "string") {
|
||||
addPath(paths, item, cwd, false);
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
addCommonPathFields(paths, item, cwd);
|
||||
for (const key of ["movePath", "move_path", "to", "from"]) {
|
||||
addPath(paths, item[key], cwd, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addCommandPaths(paths: Set<string>, command: string | undefined, cwd: string): void {
|
||||
if (command === undefined) return;
|
||||
for (const token of tokenizeShell(command)) {
|
||||
if (token.length === 0 || token.startsWith("-") || token.includes("*")) {
|
||||
continue;
|
||||
}
|
||||
addPath(paths, token, cwd, true);
|
||||
}
|
||||
}
|
||||
|
||||
function addPathArray(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
addPath(paths, item, cwd, mustExist);
|
||||
}
|
||||
}
|
||||
|
||||
function addPath(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
|
||||
if (typeof value !== "string" || value.length === 0 || looksLikeUrl(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resolvePath(cwd, value);
|
||||
if (mustExist && !isExistingFile(path)) {
|
||||
return;
|
||||
}
|
||||
paths.add(path);
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
|
||||
}
|
||||
|
||||
function isExistingFile(filePath: string): boolean {
|
||||
try {
|
||||
return existsSync(filePath) && statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeUrl(value: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
|
||||
}
|
||||
|
||||
function stringProperty(value: Record<string, unknown>, key: string): string | undefined {
|
||||
const property = value[key];
|
||||
return typeof property === "string" && property.length > 0 ? property : undefined;
|
||||
}
|
||||
|
||||
function tokenizeShell(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (const character of command) {
|
||||
if (escaped) {
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ((character === "'" || character === '"') && quote === null) {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (quote === character) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (quote === null && /\s/.test(character)) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += character;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFailedToolResponse(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user