feat(omo-codex): batch 100 (17 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:20 +09:00
parent 882ac40c83
commit b86aa3ff92
17 changed files with 2183 additions and 0 deletions
@@ -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;
}