From b86aa3ff9208fab39ed1c16ef74cfcd321a90d29 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:20 +0900 Subject: [PATCH] feat(omo-codex): batch 100 (17 files) --- .../components/rules/src/rules/cache.ts | 64 +++ .../components/rules/src/rules/constants.ts | 115 ++++ .../components/rules/src/rules/engine.ts | 535 ++++++++++++++++++ .../components/rules/src/rules/errors.ts | 13 + .../rules/src/rules/finder-cache.ts | 73 +++ .../rules/src/rules/finder-paths.ts | 47 ++ .../rules/src/rules/finder-sources.ts | 50 ++ .../components/rules/src/rules/finder.ts | 207 +++++++ .../components/rules/src/rules/formatter.ts | 123 ++++ .../components/rules/src/rules/matcher.ts | 142 +++++ .../components/rules/src/rules/ordering.ts | 33 ++ .../components/rules/src/rules/parser.ts | 326 +++++++++++ .../components/rules/src/rules/plugin-root.ts | 55 ++ .../rules/src/rules/project-root.ts | 30 + .../components/rules/src/rules/scanner.ts | 162 ++++++ .../components/rules/src/rules/truncator.ts | 67 +++ .../components/rules/src/rules/types.ts | 141 +++++ 17 files changed, 2183 insertions(+) create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/cache.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/constants.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/engine.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/errors.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/finder.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/formatter.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/matcher.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/ordering.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/parser.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/project-root.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/scanner.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/truncator.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/types.ts diff --git a/packages/omo-codex/plugin/components/rules/src/rules/cache.ts b/packages/omo-codex/plugin/components/rules/src/rules/cache.ts new file mode 100644 index 000000000..2433d4543 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/cache.ts @@ -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; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/constants.ts b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts new file mode 100644 index 000000000..974592753 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts @@ -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 = [ + [".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 = 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", +]; diff --git a/packages/omo-codex/plugin/components/rules/src/rules/engine.ts b/packages/omo-codex/plugin/components/rules/src/rules/engine.ts new file mode 100644 index 000000000..8634e7293 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/engine.ts @@ -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; +type CandidateDiscoveryCache = Map; +type DynamicMatchCache = Map; + +const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096; + +export interface EngineDeps { + findCandidates: (options: { + projectRoot: string | null; + targetFile: string | null; + homeDir?: string; + disabledSources?: ReadonlySet; + 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, + ): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] }; + formatStatic(rules: ReadonlyArray): string; + formatDynamic(rules: ReadonlyArray, 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[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, + ): { 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(); + const loadedRuleContent = new Map(); + const projectMembership = new Map(); + 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[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, 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, + 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[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[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 | 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, + diagnostics: ReadonlyArray, +): 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[] { + const uniqueValues: string[] = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/errors.ts b/packages/omo-codex/plugin/components/rules/src/rules/errors.ts new file mode 100644 index 000000000..99e49bed9 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/errors.ts @@ -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"; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts new file mode 100644 index 000000000..f9fe6af30 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts @@ -0,0 +1,73 @@ +import { existsSync, realpathSync, statSync } from "node:fs"; + +import { scanRuleFiles } from "./scanner.js"; + +type ScannedRuleFiles = ReturnType; + +interface SingleFileInfo { + readonly path: string; + readonly realPath: string; +} + +export interface RuleDiscoveryCache { + readonly scannedRuleFiles: Map; + readonly singleFileInfo: Map; +} + +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; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts new file mode 100644 index 000000000..c1c54ff38 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts @@ -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("/")); +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts new file mode 100644 index 000000000..5277323bd --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts @@ -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}`); + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder.ts new file mode 100644 index 000000000..bbe01a9b2 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder.ts @@ -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; + /** 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; + readonly cache?: RuleDiscoveryCache; + readonly pluginRoot?: string; +} + +export function findRuleCandidates(options: FinderOptions): RuleCandidate[] { + const skipUserHome = options.skipUserHome ?? false; + const disabledSources = options.disabledSources ?? new Set(); + 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, + 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, + 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; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts b/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts new file mode 100644 index 000000000..2f96db9ae --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts @@ -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, 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, 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[] { + const uniqueRules: LoadedRule[] = []; + const seenBodies = new Set(); + const userDescriptions = new Set(); + 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, + 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(); +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/matcher.ts b/packages/omo-codex/plugin/components/rules/src/rules/matcher.ts new file mode 100644 index 000000000..227e427e1 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/matcher.ts @@ -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(); + +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): 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): 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" } }; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/ordering.ts b/packages/omo-codex/plugin/components/rules/src/rules/ordering.ts new file mode 100644 index 000000000..c811e2f2f --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/ordering.ts @@ -0,0 +1,33 @@ +import { SOURCE_PRIORITY } from "./constants.js"; +import type { RuleCandidate } from "./types.js"; + +export function sortCandidates(candidates: ReadonlyArray): 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; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/parser.ts b/packages/omo-codex/plugin/components/rules/src/rules/parser.ts new file mode 100644 index 000000000..34f13d0d0 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/parser.ts @@ -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; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts b/packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts new file mode 100644 index 000000000..c3ee49c34 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts @@ -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; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/project-root.ts b/packages/omo-codex/plugin/components/rules/src/rules/project-root.ts new file mode 100644 index 000000000..525358e84 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/project-root.ts @@ -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 = 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); + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/scanner.ts b/packages/omo-codex/plugin/components/rules/src/rules/scanner.ts new file mode 100644 index 000000000..8c6f4ef77 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/scanner.ts @@ -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; + /** 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(); + 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, + visitedDirectories: Set, + 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, + visitedDirectories: Set, + 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; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/truncator.ts b/packages/omo-codex/plugin/components/rules/src/rules/truncator.ts new file mode 100644 index 000000000..2c54bea8a --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/truncator.ts @@ -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; 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; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/types.ts b/packages/omo-codex/plugin/components/rules/src/rules/types.ts new file mode 100644 index 000000000..bb914bb1e --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/types.ts @@ -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; + dynamicDedup: Map>; + dynamicTargetFingerprints: Map; + loadedRules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; +} + +export interface RuleDiagnostic { + severity: "warning" | "error"; + source: string; + message: string; +}