feat(rules): add shared rules-core package

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-18 21:18:56 +09:00
parent 472c293141
commit fb7d47f1b7
16 changed files with 909 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./src/index";
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@oh-my-opencode/rules-core",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Pure TypeScript rule discovery, matching, and nested AGENTS.md context utilities for oh-my-opencode.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
}
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
},
"dependencies": {
"picomatch": "^4.0.4"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { existsSync, statSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { AGENTS_FILENAME } from "./constants";
import type { AgentsMdCache } from "./types";
export interface FindAgentsMdUpInput {
readonly startDir: string;
readonly rootDir: string;
readonly skipRoot?: boolean;
readonly cache?: AgentsMdCache;
}
export async function findAgentsMdUp(input: FindAgentsMdUpInput): Promise<string[]> {
const startDir = resolve(input.startDir);
const rootDir = resolve(input.rootDir);
const skipRoot = input.skipRoot ?? true;
const cacheKey = [startDir, rootDir, skipRoot ? "1" : "0"].join("\0");
const cached = input.cache?.get(cacheKey);
if (cached) return [...cached];
const found: string[] = [];
let current = startDir;
while (true) {
const isRootDir = current === rootDir;
if (!(skipRoot && isRootDir)) {
const agentsPath = join(current, AGENTS_FILENAME);
if (isFile(agentsPath)) found.push(agentsPath);
}
if (isRootDir) break;
const parent = dirname(current);
if (parent === current || !isSameOrChildPath(parent, rootDir)) break;
current = parent;
}
const result = found.reverse();
input.cache?.set(cacheKey, result);
return result;
}
function isFile(path: string): boolean {
if (!existsSync(path)) return false;
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const relativePath = relative(parentPath, childPath);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
+26
View File
@@ -0,0 +1,26 @@
import type { AgentsMdCache, DirectoryScanEntry, RuleFileCandidate, RuleScanCache, RuleScanCacheStats } from "./types";
export function createRuleScanCache(): RuleScanCache {
const candidateCache = new Map<string, readonly RuleFileCandidate[]>();
const directoryCache = new Map<string, readonly DirectoryScanEntry[]>();
return {
get: (key) => candidateCache.get(key),
set: (key, value) => candidateCache.set(key, value),
getDirScan: (dir) => directoryCache.get(dir),
setDirScan: (dir, entries) => directoryCache.set(dir, entries),
stats: (): RuleScanCacheStats => ({ candidateEntries: candidateCache.size, directoryEntries: directoryCache.size }),
clear: () => {
candidateCache.clear();
directoryCache.clear();
},
};
}
export function createAgentsMdCache(): AgentsMdCache {
const cache = new Map<string, readonly string[]>();
return {
get: (key) => cache.get(key),
set: (key, value) => cache.set(key, value),
clear: () => cache.clear(),
};
}
+33
View File
@@ -0,0 +1,33 @@
import type { RuleSource } from "./types";
export const PROJECT_MARKERS = [".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod", ".venv"] as const;
export const PROJECT_RULE_SUBDIRS = [
[".omo", "rules"],
[".sisyphus", "rules"],
[".claude", "rules"],
[".cursor", "rules"],
[".github", "instructions"],
] as const;
export const PROJECT_RULE_FILES = [".github/copilot-instructions.md"] as const;
export const OPENCODE_USER_RULE_DIRS = [".omo/rules", ".sisyphus/rules", ".opencode/rules"] as const;
export const USER_RULE_DIR = ".claude/rules";
export const RULE_EXTENSIONS = [".md", ".mdc"] as const;
export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/;
export const AGENTS_FILENAME = "AGENTS.md";
export const GLOBAL_DISTANCE = 9999;
export const EXCLUDED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", ".next", "coverage"]);
export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
[".omo/rules", 0],
[".sisyphus/rules", 1],
[".claude/rules", 2],
[".cursor/rules", 3],
[".github/instructions", 4],
[".github/copilot-instructions.md", 5],
["~/.omo/rules", 100],
["~/.sisyphus/rules", 101],
["~/.opencode/rules", 102],
["~/.claude/rules", 103],
]);
+25
View File
@@ -0,0 +1,25 @@
import { dirname, relative } from "node:path";
import { GLOBAL_DISTANCE } from "./constants";
export function calculateDistance(rulePath: string, currentFile: string, projectRoot: string | null): number {
if (!projectRoot) return GLOBAL_DISTANCE;
try {
const ruleRelative = relative(projectRoot, dirname(rulePath));
const currentRelative = relative(projectRoot, dirname(currentFile));
if (ruleRelative.startsWith("..") || currentRelative.startsWith("..")) return GLOBAL_DISTANCE;
const ruleParts = toParts(ruleRelative);
const currentParts = toParts(currentRelative);
let shared = 0;
for (let index = 0; index < Math.min(ruleParts.length, currentParts.length); index += 1) {
if (ruleParts[index] !== currentParts[index]) break;
shared += 1;
}
return currentParts.length - shared;
} catch {
return GLOBAL_DISTANCE;
}
}
function toParts(path: string): string[] {
return path.split(/[/\\]/).filter(Boolean);
}
+140
View File
@@ -0,0 +1,140 @@
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { GLOBAL_DISTANCE, OPENCODE_USER_RULE_DIRS, PROJECT_RULE_FILES, PROJECT_RULE_SUBDIRS, USER_RULE_DIR } from "./constants";
import { sortCandidates } from "./ordering";
import { findRuleFilesRecursive, safeRealpathSync } from "./scanner";
import type { DirectoryScanEntry, FindRuleFilesOptions, RuleFileCandidate, RuleScanCache, RuleSource } from "./types";
export function findRuleFiles(
projectRoot: string | null,
homeDir: string,
currentFile: string,
options?: FindRuleFilesOptions,
cache?: RuleScanCache,
): RuleFileCandidate[] {
const startDir = dirname(resolve(currentFile));
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
const cacheKey = [projectRoot ?? "", startDir, skipClaudeUserRules ? "1" : "0"].join("\0");
const cached = cache?.get(cacheKey);
if (cached) return [...cached];
const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>();
if (projectRoot) {
addProjectRuleCandidates(projectRoot, startDir, candidates, seenRealPaths, cache);
addProjectSingleFileCandidates(projectRoot, candidates, seenRealPaths);
}
addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache);
const sorted = sortCandidates(candidates);
cache?.set(cacheKey, sorted);
return sorted;
}
function addProjectRuleCandidates(
projectRoot: string,
startDir: string,
candidates: RuleFileCandidate[],
seenRealPaths: Set<string>,
cache: RuleScanCache | undefined,
): void {
let currentDir = startDir;
let distance = 0;
while (true) {
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const source = `${parent}/${subdir}` as RuleSource;
const ruleDir = join(currentDir, parent, subdir);
for (const entry of scanDirectoryWithCache(ruleDir, cache)) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
candidates.push({
path: entry.path,
realPath: entry.realPath,
source,
isGlobal: false,
distance,
relativePath: normalizePath(relative(projectRoot, entry.path)),
});
}
}
if (currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir || !isSameOrChildPath(parentDir, projectRoot)) break;
currentDir = parentDir;
distance += 1;
}
}
function addProjectSingleFileCandidates(
projectRoot: string,
candidates: RuleFileCandidate[],
seenRealPaths: Set<string>,
): void {
for (const ruleFile of PROJECT_RULE_FILES) {
const filePath = join(projectRoot, ruleFile);
const realPath = validFileRealPath(filePath);
if (realPath === null || seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
source: ruleFile as RuleSource,
isGlobal: false,
distance: 0,
isSingleFile: true,
relativePath: normalizePath(ruleFile),
});
}
}
function addUserRuleCandidates(
homeDir: string,
skipClaudeUserRules: boolean,
candidates: RuleFileCandidate[],
seenRealPaths: Set<string>,
cache: RuleScanCache | undefined,
): void {
const userRuleDirs: Array<readonly [string, RuleSource]> = OPENCODE_USER_RULE_DIRS.map((dir) => [join(homeDir, dir), `~/${dir}` as RuleSource]);
if (!skipClaudeUserRules) userRuleDirs.push([join(homeDir, USER_RULE_DIR), "~/.claude/rules"]);
for (const [userRuleDir, source] of userRuleDirs) {
for (const entry of scanDirectoryWithCache(userRuleDir, cache)) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
candidates.push({
path: entry.path,
realPath: entry.realPath,
source,
isGlobal: true,
distance: GLOBAL_DISTANCE,
relativePath: normalizePath(relative(homeDir, entry.path)),
});
}
}
}
function scanDirectoryWithCache(dir: string, cache: RuleScanCache | undefined): readonly DirectoryScanEntry[] {
const cached = cache?.getDirScan(dir);
if (cached) return cached;
const entries: DirectoryScanEntry[] = [];
findRuleFilesRecursive(dir, entries);
cache?.setDirScan(dir, entries);
return entries;
}
function validFileRealPath(filePath: string): string | null {
if (!existsSync(filePath)) return null;
try {
if (!statSync(filePath).isFile()) return null;
return safeRealpathSync(filePath);
} catch {
return null;
}
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const relativePath = relative(parentPath, childPath);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function normalizePath(path: string): string {
return path.replaceAll("\\", "/");
}
+133
View File
@@ -0,0 +1,133 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "bun:test";
import {
clearProjectRootCache,
createAgentsMdCache,
createRuleScanCache,
findAgentsMdUp,
findProjectRoot,
findRuleFiles,
parseRuleFrontmatter,
shouldApplyRule,
} from "./index";
let testRoot: string | null = null;
function createTestRoot(name: string): string {
testRoot = join(tmpdir(), `${name}-${Date.now()}-${Math.random()}`);
mkdirSync(testRoot, { recursive: true });
return testRoot;
}
afterEach(() => {
if (testRoot) {
rmSync(testRoot, { recursive: true, force: true });
testRoot = null;
}
clearProjectRootCache();
});
describe("rules-core", () => {
it("#given mixed rule sources #when finding rule files #then returns deterministic source-priority order", () => {
// given
const root = createTestRoot("rules-core-order");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(root, ".sisyphus", "rules"), { recursive: true });
mkdirSync(join(root, ".claude", "rules"), { recursive: true });
mkdirSync(join(root, ".cursor", "rules"), { recursive: true });
mkdirSync(join(root, ".github", "instructions"), { recursive: true });
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, ".github", "copilot-instructions.md"), "copilot");
writeFileSync(join(root, ".omo", "rules", "omo.md"), "omo");
writeFileSync(join(root, ".sisyphus", "rules", "sisyphus.md"), "sisyphus");
writeFileSync(join(root, ".claude", "rules", "claude.md"), "claude");
writeFileSync(join(root, ".cursor", "rules", "cursor.md"), "cursor");
writeFileSync(join(root, ".github", "instructions", "github.instructions.md"), "github");
// when
const found = findRuleFiles(root, root, join(root, "src", "index.ts"));
// then
expect(found.map((rule) => rule.relativePath)).toEqual([
".github/copilot-instructions.md",
".omo/rules/omo.md",
".sisyphus/rules/sisyphus.md",
".claude/rules/claude.md",
".cursor/rules/cursor.md",
".github/instructions/github.instructions.md",
]);
});
it("#given frontmatter aliases and negative glob #when matching #then honors applyTo paths and exclusions", () => {
// given
const { metadata } = parseRuleFrontmatter(`---\npaths: ["src/**/*.ts"]\napplyTo:\n - "!src/**/*.test.ts"\n---\nRule\n`);
// when
const sourceMatch = shouldApplyRule(metadata, "/repo/src/index.ts", "/repo");
const testMatch = shouldApplyRule(metadata, "/repo/src/index.test.ts", "/repo");
// then
expect(sourceMatch).toEqual({ applies: true, reason: "glob: src/**/*.ts" });
expect(testMatch).toEqual({ applies: false });
});
it("#given nested AGENTS.md files #when walking up with root skip #then returns parent-to-child non-root files", async () => {
// given
const root = createTestRoot("rules-core-agents");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, "packages", "app", "src"), { recursive: true });
writeFileSync(join(root, "AGENTS.md"), "root");
writeFileSync(join(root, "packages", "AGENTS.md"), "packages");
writeFileSync(join(root, "packages", "app", "AGENTS.md"), "app");
// when
const found = await findAgentsMdUp({
startDir: join(root, "packages", "app", "src"),
rootDir: root,
cache: createAgentsMdCache(),
});
// then
expect(found).toEqual([
join(root, "packages", "AGENTS.md"),
join(root, "packages", "app", "AGENTS.md"),
]);
});
it("#given repeated same-directory targets #when using scan caches #then reuses cached candidates", () => {
// given
const root = createTestRoot("rules-core-cache");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, ".omo", "rules", "typescript.md"), "typescript");
const cache = createRuleScanCache();
// when
const first = findRuleFiles(root, root, join(root, "src", "a.ts"), undefined, cache);
const second = findRuleFiles(root, root, join(root, "src", "b.ts"), undefined, cache);
// then
expect(first).toEqual(second);
expect(cache.stats()).toEqual({ candidateEntries: 1, directoryEntries: 11 });
});
it("#given nested project markers #when finding project root #then memoizes ancestor lookups", () => {
// given
const root = createTestRoot("rules-core-project-root");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, "a", "b", "c"), { recursive: true });
// when
const first = findProjectRoot(join(root, "a", "b", "c", "file.ts"));
const second = findProjectRoot(join(root, "a", "b", "other.ts"));
// then
expect(first).toBe(root);
expect(second).toBe(root);
});
});
+20
View File
@@ -0,0 +1,20 @@
export { createAgentsMdCache, createRuleScanCache } from "./cache";
export { findAgentsMdUp, type FindAgentsMdUpInput } from "./agents-md";
export { findRuleFiles } from "./finder";
export { parseRuleFrontmatter } from "./parser";
export { shouldApplyRule, createContentHash, isDuplicateByContentHash, isDuplicateByRealPath, resetMatcherCache, getMatcherCacheStats } from "./matcher";
export { findProjectRoot, clearProjectRootCache } from "./project-root";
export { calculateDistance } from "./distance";
export { findRuleFilesRecursive, safeRealpathSync } from "./scanner";
export type {
AgentsMdCache,
DirectoryScanEntry,
FindRuleFilesOptions,
MatchResult,
RuleFileCandidate,
RuleFrontmatterResult,
RuleMetadata,
RuleScanCache,
RuleScanCacheStats,
RuleSource,
} from "./types";
+77
View File
@@ -0,0 +1,77 @@
import { createHash } from "node:crypto";
import { basename, relative } from "node:path";
import picomatch from "picomatch";
import type { MatchResult, RuleMetadata } from "./types";
const matcherCache = new Map<string, (path: string) => boolean>();
const MAX_MATCHER_CACHE_ENTRIES = 256;
const PICOMATCH_OPTIONS = { dot: true, bash: true } as const;
export function resetMatcherCache(): void {
matcherCache.clear();
}
export function getMatcherCacheStats(): { readonly entries: number } {
return { entries: matcherCache.size };
}
export function shouldApplyRule(metadata: RuleMetadata, currentFilePath: string, projectRoot: string | null): MatchResult {
if (metadata.alwaysApply === true) return { applies: true, reason: "alwaysApply" };
const patterns = normalizeGlobs(metadata);
if (patterns.length === 0) return { applies: false };
const pathBases = [
toPosix(projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath),
toPosix(basename(currentFilePath)),
];
const negativeMatchers = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => matcherFor(pattern.slice(1)));
for (const pattern of patterns) {
if (pattern.startsWith("!")) continue;
const isMatch = matcherFor(pattern);
if (!pathBases.some((pathBase) => isMatch(pathBase))) continue;
if (pathBases.some((pathBase) => negativeMatchers.some((isExcluded) => isExcluded(pathBase)))) return { applies: false };
return { applies: true, reason: `glob: ${pattern}` };
}
return { applies: false };
}
export function createContentHash(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16);
}
export function isDuplicateByRealPath(realPath: string, cache: ReadonlySet<string>): boolean {
return cache.has(realPath);
}
export function isDuplicateByContentHash(hash: string, cache: ReadonlySet<string>): boolean {
return cache.has(hash);
}
function normalizeGlobs(metadata: RuleMetadata): string[] {
const patterns = [...normalizePatternList(metadata.globs), ...normalizePatternList(metadata.paths), ...normalizePatternList(metadata.applyTo)];
return [...new Set(patterns.map(toPosix))];
}
function normalizePatternList(patterns: string | readonly string[] | undefined): string[] {
if (patterns === undefined) return [];
return typeof patterns === "string" ? [patterns] : [...patterns];
}
function matcherFor(pattern: string): (path: string) => boolean {
const cached = matcherCache.get(pattern);
if (cached) {
matcherCache.delete(pattern);
matcherCache.set(pattern, cached);
return cached;
}
const matcher = picomatch(pattern, PICOMATCH_OPTIONS);
if (matcherCache.size >= MAX_MATCHER_CACHE_ENTRIES) {
const oldest = matcherCache.keys().next().value;
if (oldest !== undefined) matcherCache.delete(oldest);
}
matcherCache.set(pattern, matcher);
return matcher;
}
function toPosix(path: string): string {
return path.replaceAll("\\", "/");
}
+26
View File
@@ -0,0 +1,26 @@
import { SOURCE_PRIORITY } from "./constants";
import type { RuleFileCandidate } from "./types";
export function sortCandidates<T extends RuleFileCandidate>(candidates: readonly T[]): T[] {
return candidates
.map((candidate, index) => ({ candidate, index }))
.sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index)
.map(({ candidate }) => candidate);
}
function compareCandidates(left: RuleFileCandidate, right: RuleFileCandidate): number {
return (
Number(left.isGlobal) - Number(right.isGlobal) ||
left.distance - right.distance ||
(SOURCE_PRIORITY.get(left.source) ?? Number.POSITIVE_INFINITY) -
(SOURCE_PRIORITY.get(right.source) ?? Number.POSITIVE_INFINITY) ||
compareString(left.relativePath, right.relativePath) ||
compareString(left.realPath, right.realPath)
);
}
function compareString(left: string, right: string): number {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
+175
View File
@@ -0,0 +1,175 @@
import type { RuleFrontmatterResult, RuleMetadata } from "./types";
type GlobValue = string | readonly string[];
type ParsedGlobValue = {
readonly value: GlobValue;
readonly consumed: number;
};
export function parseRuleFrontmatter(content: string): RuleFrontmatterResult {
const normalized = stripBom(content);
const openingLength = openingDelimiterLength(normalized);
if (openingLength === 0) return { metadata: {}, body: normalized };
const closing = findClosingDelimiter(normalized, openingLength);
if (!closing) return { metadata: {}, body: normalized };
try {
return { metadata: parseYaml(normalized.slice(openingLength, closing.start)), body: normalized.slice(closing.bodyStart) };
} catch {
return { metadata: {}, body: normalized };
}
}
function parseYaml(yaml: string): RuleMetadata {
const lines = yaml.replace(/\r\n/g, "\n").split("\n");
const metadata: { description?: string; alwaysApply?: boolean; globs?: string | string[] } = {};
let index = 0;
while (index < lines.length) {
const line = stripComment(lines[index] ?? "").trim();
if (!line) {
index += 1;
continue;
}
const colon = line.indexOf(":");
if (colon === -1) {
index += 1;
continue;
}
const key = line.slice(0, colon).trim();
const rawValue = line.slice(colon + 1).trim();
if (key === "description") metadata.description = parseString(rawValue);
else if (key === "alwaysApply") metadata.alwaysApply = rawValue === "true";
else if (key === "globs" || key === "paths" || key === "applyTo") {
const parsed = parseGlobValue(rawValue, lines, index);
metadata.globs = mergeGlobs(metadata.globs, parsed.value);
index += parsed.consumed;
continue;
}
index += 1;
}
return metadata;
}
function parseGlobValue(rawValue: string, lines: readonly string[], currentIndex: number): ParsedGlobValue {
if (rawValue.startsWith("[")) return { value: parseInlineArray(rawValue), consumed: 1 };
if (!rawValue) {
const parsed = parseMultilineArray(lines, currentIndex);
return parsed.values.length > 0 ? { value: parsed.values, consumed: parsed.consumed } : { value: "", consumed: 1 };
}
const value = parseString(rawValue);
if (value.includes(",")) return { value: value.split(",").map((item) => item.trim()).filter(Boolean), consumed: 1 };
return { value, consumed: 1 };
}
function parseMultilineArray(lines: readonly string[], currentIndex: number): { readonly values: readonly string[]; readonly consumed: number } {
const values: string[] = [];
let consumed = 1;
for (let index = currentIndex + 1; index < lines.length; index += 1) {
const line = stripComment(lines[index] ?? "");
if (line.trim().length === 0) {
consumed += 1;
continue;
}
const item = line.match(/^\s+-\s*(.*)$/);
if (!item) break;
const value = parseString(item[1] ?? "");
if (value) values.push(value);
consumed += 1;
}
return { values, consumed };
}
function parseInlineArray(value: string): string[] {
const closing = value.lastIndexOf("]");
if (closing === -1) return [];
return splitCommaSeparated(value.slice(1, closing)).map(parseString).filter(Boolean);
}
function mergeGlobs(existing: string | string[] | undefined, next: GlobValue): string | string[] {
if (Array.isArray(next) && next.length === 0) return existing ?? [];
if (!Array.isArray(next) && next.length === 0) return existing ?? "";
if (existing === undefined) {
if (typeof next === "string") return next;
return [...next];
}
const existingValues = Array.isArray(existing) ? existing : [existing];
const nextValues = typeof next === "string" ? [next] : [...next];
return [...existingValues, ...nextValues];
}
function splitCommaSeparated(value: string): string[] {
const values: string[] = [];
let current = "";
let quote: string | null = null;
let escaped = false;
for (const character of value) {
if (escaped) {
current += character;
escaped = false;
continue;
}
if (quote && character === "\\") {
escaped = true;
continue;
}
if (character === '"' || character === "'") {
if (!quote) quote = character;
else if (quote === character) quote = null;
current += character;
continue;
}
if (!quote && character === ",") {
values.push(current.trim());
current = "";
continue;
}
current += character;
}
values.push(current.trim());
return values;
}
function parseString(value: string): string {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function stripComment(line: string): string {
let quote: string | null = null;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (character === '"' || character === "'") {
if (!quote) quote = character;
else if (quote === character) quote = null;
}
if (!quote && character === "#") return line.slice(0, index);
}
return line;
}
function stripBom(content: string): string {
return content.startsWith("\uFEFF") ? content.slice(1) : content;
}
function openingDelimiterLength(content: string): number {
if (content.startsWith("---\r\n")) return 5;
if (content.startsWith("---\n")) return 4;
return 0;
}
function findClosingDelimiter(content: string, openingLength: number): { readonly start: number; readonly bodyStart: number } | null {
let lineStart = openingLength;
while (lineStart <= content.length) {
const nextNewline = content.indexOf("\n", lineStart);
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
if (content.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") {
return { start: lineStart, bodyStart: nextNewline === -1 ? content.length : nextNewline + 1 };
}
if (nextNewline === -1) break;
lineStart = nextNewline + 1;
}
return null;
}
+55
View File
@@ -0,0 +1,55 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { PROJECT_MARKERS } from "./constants";
const projectRootCache = new Map<string, string | null>();
export function clearProjectRootCache(): void {
projectRootCache.clear();
}
export function findProjectRoot(startPath: string): string | null {
const cached = projectRootCache.get(startPath);
if (cached !== undefined) return cached;
const startDir = resolveStartDir(startPath);
const cachedStartDir = projectRootCache.get(startDir);
if (cachedStartDir !== undefined) {
projectRootCache.set(startPath, cachedStartDir);
return cachedStartDir;
}
const visited: string[] = [];
let current = startDir;
let resolved: string | null = null;
while (true) {
const cachedAncestor = projectRootCache.get(current);
if (cachedAncestor !== undefined) {
resolved = cachedAncestor;
break;
}
visited.push(current);
if (hasProjectMarker(current)) {
resolved = current;
break;
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
for (const directory of visited) projectRootCache.set(directory, resolved);
projectRootCache.set(startPath, resolved);
return resolved;
}
function resolveStartDir(startPath: string): string {
try {
return statSync(startPath).isDirectory() ? startPath : dirname(startPath);
} catch {
return dirname(startPath);
}
}
function hasProjectMarker(directory: string): boolean {
return PROJECT_MARKERS.some((marker) => existsSync(join(directory, marker)));
}
+44
View File
@@ -0,0 +1,44 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { EXCLUDED_DIRS, GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
import type { DirectoryScanEntry } from "./types";
function isGitHubInstructionsDir(dir: string): boolean {
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
}
function isRuleFile(fileName: string, dir: string): boolean {
if (isGitHubInstructionsDir(dir)) return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
return RULE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
}
export function safeRealpathSync(filePath: string): string {
try {
return realpathSync.native(filePath);
} catch {
return filePath;
}
}
export function findRuleFilesRecursive(dir: string, results: DirectoryScanEntry[], visited = new Set<string>()): void {
if (!existsSync(dir)) return;
const realDir = safeRealpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }).sort((left, right) => left.name.localeCompare(right.name));
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (!EXCLUDED_DIRS.has(entry.name)) findRuleFilesRecursive(fullPath, results, visited);
continue;
}
if (entry.isFile() && isRuleFile(entry.name, dir)) {
results.push({ path: fullPath, realPath: safeRealpathSync(fullPath), relativePath: entry.name });
}
}
}
+69
View File
@@ -0,0 +1,69 @@
export interface RuleMetadata {
readonly description?: string;
readonly globs?: string | readonly string[];
readonly paths?: string | readonly string[];
readonly applyTo?: string | readonly string[];
readonly alwaysApply?: boolean;
}
export interface RuleFrontmatterResult {
readonly metadata: RuleMetadata;
readonly body: string;
}
export interface RuleFileCandidate {
readonly path: string;
readonly realPath: string;
readonly isGlobal: boolean;
readonly distance: number;
readonly relativePath: string;
readonly source: RuleSource;
readonly isSingleFile?: boolean;
}
export type RuleSource =
| ".omo/rules"
| ".sisyphus/rules"
| ".claude/rules"
| ".cursor/rules"
| ".github/instructions"
| ".github/copilot-instructions.md"
| "~/.omo/rules"
| "~/.sisyphus/rules"
| "~/.opencode/rules"
| "~/.claude/rules";
export interface MatchResult {
readonly applies: boolean;
readonly reason?: string;
}
export interface DirectoryScanEntry {
readonly path: string;
readonly realPath: string;
readonly relativePath: string;
}
export interface RuleScanCacheStats {
readonly candidateEntries: number;
readonly directoryEntries: number;
}
export interface RuleScanCache {
get(key: string): readonly RuleFileCandidate[] | undefined;
set(key: string, value: readonly RuleFileCandidate[]): void;
getDirScan(dir: string): readonly DirectoryScanEntry[] | undefined;
setDirScan(dir: string, entries: readonly DirectoryScanEntry[]): void;
stats(): RuleScanCacheStats;
clear(): void;
}
export interface FindRuleFilesOptions {
readonly skipClaudeUserRules?: boolean;
}
export interface AgentsMdCache {
get(key: string): readonly string[] | undefined;
set(key: string, value: readonly string[]): void;
clear(): void;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}