feat(omo-codex): batch 100 (17 files)
This commit is contained in:
@@ -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,115 @@
|
||||
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"];
|
||||
|
||||
/**
|
||||
* Bundled plugin rule directory relative to the rules component root.
|
||||
*/
|
||||
export const BUNDLED_RULE_SUBDIR = "bundled-rules";
|
||||
|
||||
/**
|
||||
* 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],
|
||||
["plugin-bundled", 200],
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
export const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 3500;
|
||||
|
||||
export const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 4000;
|
||||
|
||||
/**
|
||||
* Truncation marker template. `{path}` is replaced with the relative path.
|
||||
*/
|
||||
export const TRUNCATION_NOTICE = "\n\n[Truncated. Full: {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,535 @@
|
||||
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,
|
||||
DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
|
||||
DEFAULT_POST_COMPACT_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,
|
||||
postCompactMaxRuleChars: DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
|
||||
postCompactMaxResultChars: DEFAULT_POST_COMPACT_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,73 @@
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
|
||||
import { scanRuleFiles } from "./scanner.js";
|
||||
|
||||
type ScannedRuleFiles = ReturnType<typeof scanRuleFiles>;
|
||||
|
||||
interface SingleFileInfo {
|
||||
readonly path: string;
|
||||
readonly realPath: string;
|
||||
}
|
||||
|
||||
export interface RuleDiscoveryCache {
|
||||
readonly scannedRuleFiles: Map<string, ScannedRuleFiles>;
|
||||
readonly singleFileInfo: Map<string, SingleFileInfo | null>;
|
||||
}
|
||||
|
||||
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
|
||||
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
|
||||
}
|
||||
|
||||
export function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ScannedRuleFiles {
|
||||
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;
|
||||
}
|
||||
|
||||
export 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 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { dirname, posix, relative, resolve } from "node:path";
|
||||
|
||||
export interface WalkDirectory {
|
||||
readonly directory: string;
|
||||
readonly distance: number;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function toRelativePath(rootDirectory: string, filePath: string): string {
|
||||
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, childPath);
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { UnsupportedRuleSourceError } from "./errors.js";
|
||||
import type { RuleSource } from "./types.js";
|
||||
|
||||
export 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export 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,207 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
BUNDLED_RULE_SUBDIR,
|
||||
GLOBAL_DISTANCE,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
PROJECT_SINGLE_FILES,
|
||||
USER_HOME_RULE_SUBDIRS,
|
||||
USER_HOME_SINGLE_FILES,
|
||||
} from "./constants.js";
|
||||
import { type RuleDiscoveryCache, scanRuleFilesCached, singleFileInfoCached } from "./finder-cache.js";
|
||||
import { getWalkDirectories, toRelativePath } from "./finder-paths.js";
|
||||
import {
|
||||
toProjectRuleSource,
|
||||
toProjectSingleFileSource,
|
||||
toUserHomeRuleSource,
|
||||
toUserHomeSingleFileSource,
|
||||
} from "./finder-sources.js";
|
||||
import { resolvePluginRulesRoot } from "./plugin-root.js";
|
||||
import type { RuleCandidate } from "./types.js";
|
||||
|
||||
export type { RuleDiscoveryCache } from "./finder-cache.js";
|
||||
export { createRuleDiscoveryCache } from "./finder-cache.js";
|
||||
|
||||
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;
|
||||
/** Plugin root directory. Defaults to PLUGIN_ROOT env or this package root. */
|
||||
pluginRoot?: string;
|
||||
cache?: RuleDiscoveryCache;
|
||||
}
|
||||
|
||||
interface PluginBundledFinderOptions {
|
||||
readonly disabledSources?: ReadonlySet<string>;
|
||||
readonly cache?: RuleDiscoveryCache;
|
||||
readonly pluginRoot?: string;
|
||||
}
|
||||
|
||||
export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
|
||||
const skipUserHome = options.skipUserHome ?? false;
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
const pluginBundledOptions: PluginBundledFinderOptions = {
|
||||
disabledSources,
|
||||
...(options.cache === undefined ? {} : { cache: options.cache }),
|
||||
...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }),
|
||||
};
|
||||
candidates.push(...findPluginBundledCandidates(pluginBundledOptions));
|
||||
|
||||
if (!skipUserHome) {
|
||||
candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function findPluginBundledCandidates(options: PluginBundledFinderOptions = {}): RuleCandidate[] {
|
||||
if (options.disabledSources?.has("plugin-bundled") === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pluginRoot = resolvePluginRulesRoot(options.pluginRoot);
|
||||
const ruleDirectory = join(pluginRoot, BUNDLED_RULE_SUBDIR);
|
||||
const candidates: RuleCandidate[] = [];
|
||||
for (const scannedFile of scanRuleFilesCached(ruleDirectory, options.cache)) {
|
||||
candidates.push({
|
||||
path: scannedFile.path,
|
||||
realPath: scannedFile.realPath,
|
||||
source: "plugin-bundled",
|
||||
distance: GLOBAL_DISTANCE,
|
||||
isGlobal: true,
|
||||
isSingleFile: false,
|
||||
relativePath: toRelativePath(pluginRoot, scannedFile.path),
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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;
|
||||
};
|
||||
|
||||
type NormalizedRule = TruncatedRule & {
|
||||
source: LoadedRule["source"];
|
||||
};
|
||||
|
||||
function formatRule(rule: TruncatedRule): string {
|
||||
const body = normalizeRuleBody(rule.body);
|
||||
if (body.length === 0) {
|
||||
return `Instructions from: ${rule.path}`;
|
||||
}
|
||||
return `Instructions from: ${rule.path}\n\n${body}`;
|
||||
}
|
||||
|
||||
function truncateRules(rules: ReadonlyArray<LoadedRule>, options: FormatOptions): TruncatedRule[] {
|
||||
const perRuleNormalized: NormalizedRule[] = rules.map((rule) => ({
|
||||
path: rule.path,
|
||||
relativePath: rule.relativePath,
|
||||
body: normalizeRuleBody(rule.body),
|
||||
source: rule.source,
|
||||
}));
|
||||
const perRuleResultChars = Math.floor(options.maxResultChars / Math.max(1, perRuleNormalized.length));
|
||||
const perRuleBudgeted = perRuleNormalized.map((rule) => ({
|
||||
path: rule.path,
|
||||
relativePath: rule.relativePath,
|
||||
body:
|
||||
rule.source === "plugin-bundled"
|
||||
? truncateRule(rule.body, { maxChars: perRuleResultChars, relativePath: rule.relativePath }).body
|
||||
: truncateRule(rule.body, {
|
||||
maxChars: Math.min(options.maxRuleChars, perRuleResultChars),
|
||||
relativePath: rule.relativePath,
|
||||
}).body,
|
||||
}));
|
||||
const budgetedRules = truncateBudget({
|
||||
rules: perRuleBudgeted.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 = perRuleBudgeted[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 [
|
||||
"## Project Instructions",
|
||||
"",
|
||||
truncateRules(uniqueRulesByBody(rules), options).map(formatRule).join("\n\n"),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function uniqueRulesByBody(rules: ReadonlyArray<LoadedRule>): LoadedRule[] {
|
||||
const uniqueRules: LoadedRule[] = [];
|
||||
const seenBodies = new Set<string>();
|
||||
const userDescriptions = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
const descriptionKey = rule.frontmatter.description?.trim();
|
||||
if (rule.source === "plugin-bundled" && descriptionKey !== undefined && userDescriptions.has(descriptionKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bodyKey = normalizeRuleBody(rule.body);
|
||||
if (seenBodies.has(bodyKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenBodies.add(bodyKey);
|
||||
if (descriptionKey !== undefined && rule.source !== "plugin-bundled") {
|
||||
userDescriptions.add(descriptionKey);
|
||||
}
|
||||
uniqueRules.push(rule);
|
||||
}
|
||||
return uniqueRules;
|
||||
}
|
||||
|
||||
export function formatDynamicBlock(
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
targetRelativePath: string,
|
||||
options: FormatOptions,
|
||||
): string {
|
||||
if (rules.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
`Additional project instructions matched for ${targetRelativePath}:`,
|
||||
"",
|
||||
truncateRules(rules, options).map(formatRule).join("\n\n"),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function normalizeRuleBody(body: string): string {
|
||||
return body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
||||
}
|
||||
@@ -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,55 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json");
|
||||
|
||||
export function resolvePluginRulesRoot(pluginRoot: string | undefined, moduleUrl = import.meta.url): string {
|
||||
const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"];
|
||||
if (configuredRoot !== undefined && configuredRoot.trim().length > 0) {
|
||||
return resolveRulesComponentRoot(resolve(configuredRoot));
|
||||
}
|
||||
|
||||
const discoveredRoot = findNearestPluginRoot(dirname(fileURLToPath(moduleUrl)));
|
||||
if (discoveredRoot !== null) {
|
||||
return resolveRulesComponentRoot(discoveredRoot);
|
||||
}
|
||||
|
||||
return fileURLToPath(new URL("../../..", moduleUrl));
|
||||
}
|
||||
|
||||
function findNearestPluginRoot(startDirectory: string): string | null {
|
||||
let currentDirectory = resolve(startDirectory);
|
||||
while (true) {
|
||||
if (isFile(join(currentDirectory, PLUGIN_MANIFEST_PATH))) {
|
||||
return currentDirectory;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
return null;
|
||||
}
|
||||
currentDirectory = parentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRulesComponentRoot(pluginRoot: string): string {
|
||||
const componentRoot = join(pluginRoot, "components", "rules");
|
||||
return isDirectory(componentRoot) ? componentRoot : pluginRoot;
|
||||
}
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
/**
|
||||
* 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"
|
||||
| "plugin-bundled"
|
||||
| "~/.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;
|
||||
postCompactMaxRuleChars: number;
|
||||
postCompactMaxResultChars: 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;
|
||||
}
|
||||
Reference in New Issue
Block a user