refactor(packages): rename rules-core to rules-engine

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-21 11:10:51 +09:00
parent 7c7aa28160
commit 4bbf1d9388
32 changed files with 28 additions and 28 deletions
+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"],
[".claude", "rules"],
[".cursor", "rules"],
[".github", "instructions"],
[".sisyphus", "rules"],
] as const;
export const PROJECT_RULE_FILES = [".github/copilot-instructions.md"] as const;
export const OPENCODE_USER_RULE_DIRS = [".omo/rules", ".opencode/rules", ".sisyphus/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],
[".claude/rules", 1],
[".cursor/rules", 2],
[".github/instructions", 3],
[".github/copilot-instructions.md", 4],
[".sisyphus/rules", 5],
["~/.omo/rules", 100],
["~/.opencode/rules", 101],
["~/.claude/rules", 102],
["~/.sisyphus/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);
}
+198
View File
@@ -0,0 +1,198 @@
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 type SisyphusRuleDeprecationLogger = (
message: string,
meta: { event: string; path: string },
) => void;
const noopSisyphusRuleDeprecationLogger: SisyphusRuleDeprecationLogger = () => {};
const SISYPHUS_DEPRECATION_MESSAGE = "[rules] .sisyphus/rules is deprecated and will be removed in v4.3.0; migrate to .omo/rules";
const SISYPHUS_LEGACY_RULE_SOURCES: ReadonlySet<RuleSource> = new Set([".sisyphus/rules", "~/.sisyphus/rules"]);
const warnedSisyphusRuleDirectories = new Set<string>();
let logSisyphusRuleDeprecation: SisyphusRuleDeprecationLogger = noopSisyphusRuleDeprecationLogger;
export function setSisyphusRuleDeprecationLogger(logger: SisyphusRuleDeprecationLogger): void {
logSisyphusRuleDeprecation = logger;
}
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 effectiveProjectRoot = resolveEffectiveProjectRoot(
projectRoot,
options?.workspaceDirectory,
startDir,
);
const cacheKey = [projectRoot ?? "", effectiveProjectRoot, startDir, skipClaudeUserRules ? "1" : "0"].join(
"\0",
);
const cached = cache?.get(cacheKey);
if (cached) return [...cached];
const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>();
addProjectRuleCandidates(effectiveProjectRoot, startDir, candidates, seenRealPaths, cache);
addProjectSingleFileCandidates(effectiveProjectRoot, candidates, seenRealPaths);
addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache);
const sorted = sortCandidates(candidates);
cache?.set(cacheKey, sorted);
return sorted;
}
function resolveEffectiveProjectRoot(
projectRoot: string | null,
workspaceDirectory: string | undefined,
startDir: string,
): string {
if (projectRoot) return projectRoot;
if (!workspaceDirectory) return startDir;
const workspaceRoot = resolve(workspaceDirectory);
return isSameOrChildPath(startDir, workspaceRoot) ? workspaceRoot : startDir;
}
function addProjectRuleCandidates(
projectRoot: string,
startDir: string,
candidates: RuleFileCandidate[],
seenRealPaths: Set<string>,
cache: RuleScanCache | undefined,
): void {
const projectRootRealPath = safeRealpathSync(projectRoot);
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, projectRootRealPath)) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
warnSisyphusRuleDeprecation(source, entry.path);
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 {
const projectRootRealPath = safeRealpathSync(projectRoot);
for (const ruleFile of PROJECT_RULE_FILES) {
const filePath = join(projectRoot, ruleFile);
const realPath = validFileRealPath(filePath, projectRootRealPath);
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);
warnSisyphusRuleDeprecation(source, entry.path);
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, boundaryRealPath?: string): readonly DirectoryScanEntry[] {
const cached = cache?.getDirScan(dir);
if (cached) return cached;
const entries: DirectoryScanEntry[] = [];
findRuleFilesRecursive(dir, entries, new Set<string>(), boundaryRealPath);
cache?.setDirScan(dir, entries);
return entries;
}
function warnSisyphusRuleDeprecation(source: RuleSource, path: string): void {
if (!SISYPHUS_LEGACY_RULE_SOURCES.has(source)) return;
const warningKey = dirname(path);
if (warnedSisyphusRuleDirectories.has(warningKey)) return;
warnedSisyphusRuleDirectories.add(warningKey);
logSisyphusRuleDeprecation(SISYPHUS_DEPRECATION_MESSAGE, {
event: "rules-sisyphus-deprecated",
path,
});
}
export function _setSisyphusRuleDeprecationLoggerForTesting(logger: SisyphusRuleDeprecationLogger): void {
logSisyphusRuleDeprecation = logger;
}
export function _resetSisyphusRuleDeprecationWarningStateForTesting(): void {
warnedSisyphusRuleDirectories.clear();
logSisyphusRuleDeprecation = noopSisyphusRuleDeprecationLogger;
}
function validFileRealPath(filePath: string, boundaryRealPath?: string): string | null {
if (!existsSync(filePath)) return null;
try {
if (!statSync(filePath).isFile()) return null;
const realPath = safeRealpathSync(filePath);
if (boundaryRealPath !== undefined && !isSameOrChildPath(realPath, boundaryRealPath)) return null;
return realPath;
} 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("\\", "/");
}
+213
View File
@@ -0,0 +1,213 @@
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";
import { _resetSisyphusRuleDeprecationWarningStateForTesting, _setSisyphusRuleDeprecationLoggerForTesting } from "./finder";
let testRoot: string | null = null;
const SISYPHUS_DEPRECATION_MESSAGE = "[rules] .sisyphus/rules is deprecated and will be removed in v4.3.0; migrate to .omo/rules";
function createTestRoot(name: string): string {
testRoot = join(tmpdir(), `${name}-${Date.now()}-${Math.random()}`);
mkdirSync(testRoot, { recursive: true });
return testRoot;
}
afterEach(() => {
_resetSisyphusRuleDeprecationWarningStateForTesting();
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",
".claude/rules/claude.md",
".cursor/rules/cursor.md",
".github/instructions/github.instructions.md",
".sisyphus/rules/sisyphus.md",
]);
});
it("#given a workspace with .sisyphus/rules/*.md #when findRuleFiles is called #then those files are discovered with lowest priority among project sources", () => {
// given
const root = createTestRoot("rules-core-sisyphus-restored");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(root, ".sisyphus", "rules"), { recursive: true });
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, ".omo", "rules", "shared.md"), "omo");
writeFileSync(join(root, ".sisyphus", "rules", "shared.md"), "legacy");
writeFileSync(join(root, ".sisyphus", "rules", "legacy.md"), "legacy");
// when
const found = findRuleFiles(root, root, join(root, "src", "index.ts"));
const relativePaths = found.map((rule) => rule.relativePath);
const omoSharedIndex = relativePaths.indexOf(".omo/rules/shared.md");
const sisyphusSharedIndex = relativePaths.indexOf(".sisyphus/rules/shared.md");
// then
expect(relativePaths).toContain(".sisyphus/rules/legacy.md");
expect(omoSharedIndex).toBeGreaterThanOrEqual(0);
expect(sisyphusSharedIndex).toBeGreaterThan(omoSharedIndex);
});
it("#given .sisyphus/rules is discovered #when the finder runs #then a deprecation warning is logged exactly once", () => {
// given
const root = createTestRoot("rules-core-sisyphus-warning");
const legacyRulePath = join(root, ".sisyphus", "rules", "legacy.md");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, ".sisyphus", "rules"), { recursive: true });
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(legacyRulePath, "legacy");
const warnings: Array<{ readonly message: string; readonly data: unknown }> = [];
_setSisyphusRuleDeprecationLoggerForTesting((message, data) => {
warnings.push({ message, data });
});
// when
findRuleFiles(root, root, join(root, "src", "index.ts"));
findRuleFiles(root, root, join(root, "src", "index.ts"));
const deprecationWarnings = warnings.filter(
({ message, data }) => message === SISYPHUS_DEPRECATION_MESSAGE && isSisyphusDeprecationData(data, legacyRulePath),
);
// then
expect(deprecationWarnings).toHaveLength(1);
});
it("#given a workspace directory has no project marker (no .git, no package.json, etc.) AND contains .omo/rules/ #when findRuleFiles is called #then the .omo/rules/ files are still discovered", () => {
// given
const root = createTestRoot("rules-core-markerless-workspace");
const homeDir = join(root, "home");
const sourceDir = join(root, "src");
const ruleFile = join(root, ".omo", "rules", "test-rule.md");
const currentFile = join(sourceDir, "index.ts");
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(homeDir, { recursive: true });
mkdirSync(sourceDir, { recursive: true });
writeFileSync(ruleFile, "markerless workspace rule");
writeFileSync(currentFile, "export {};");
const projectRoot = findProjectRoot(currentFile);
const options = { skipClaudeUserRules: false, workspaceDirectory: root };
// when
const found = findRuleFiles(projectRoot, homeDir, currentFile, options);
// then
expect(projectRoot).toBeNull();
expect(found.map((rule) => rule.path)).toContain(ruleFile);
});
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);
});
});
function isSisyphusDeprecationData(data: unknown, path: string): boolean {
if (typeof data !== "object" || data === null) return false;
if (!("event" in data) || !("path" in data)) return false;
return data.event === "rules-sisyphus-deprecated" && data.path === path;
}
+20
View File
@@ -0,0 +1,20 @@
export { createAgentsMdCache, createRuleScanCache } from "./cache";
export { findAgentsMdUp, type FindAgentsMdUpInput } from "./agents-md";
export { findRuleFiles, setSisyphusRuleDeprecationLogger, type SisyphusRuleDeprecationLogger } 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)));
}
+58
View File
@@ -0,0 +1,58 @@
import { existsSync, readdirSync, realpathSync, type Dirent } from "node:fs";
import { isAbsolute, join, relative } 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;
}
}
function isPathWithinRoot(candidate: string, root: string): boolean {
const rel = relative(root, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
export function findRuleFilesRecursive(
dir: string,
results: DirectoryScanEntry[],
visited = new Set<string>(),
boundaryRoot?: string,
): void {
if (!existsSync(dir)) return;
const realDir = safeRealpathSync(dir);
const effectiveBoundary = boundaryRoot ?? realDir;
if (!isPathWithinRoot(realDir, effectiveBoundary)) return;
if (visited.has(realDir)) return;
visited.add(realDir);
let entries: Dirent<string>[] = [];
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, effectiveBoundary);
continue;
}
if (entry.isFile() && isRuleFile(entry.name, dir)) {
const realPath = safeRealpathSync(fullPath);
if (!isPathWithinRoot(realPath, effectiveBoundary)) continue;
results.push({ path: fullPath, realPath, relativePath: entry.name });
}
}
}
@@ -0,0 +1,78 @@
/// <reference path="../../../bun-test.d.ts" />
import { mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "bun:test";
import { clearProjectRootCache, findRuleFiles } from "./index";
import { _resetSisyphusRuleDeprecationWarningStateForTesting } from "./finder";
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(() => {
_resetSisyphusRuleDeprecationWarningStateForTesting();
if (testRoot) {
rmSync(testRoot, { recursive: true, force: true });
testRoot = null;
}
clearProjectRootCache();
});
describe("rules-core security boundary", () => {
it("#given a project .omo/rules directory symlink escapes the workspace #when finding rule files #then escaped rules are rejected", () => {
// given
const root = createTestRoot("rules-core-project-dir-symlink-escape");
const projectRoot = join(root, "repo");
const homeDir = join(root, "home");
const outsideDir = join(root, "outside-rules");
const currentFile = join(projectRoot, "src", "index.ts");
const escapedRule = join(outsideDir, "leak.md");
mkdirSync(projectRoot, { recursive: true });
mkdirSync(join(projectRoot, ".git"));
mkdirSync(join(projectRoot, ".omo"), { recursive: true });
mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
writeFileSync(currentFile, "export {};");
writeFileSync(escapedRule, "do not inject this external project rule");
symlinkSync(outsideDir, join(projectRoot, ".omo", "rules"), "dir");
// when
const found = findRuleFiles(projectRoot, homeDir, currentFile);
// then
expect(found.some((rule) => rule.realPath === realpathSync.native(escapedRule))).toBe(false);
});
it("#given a project .github/instructions directory symlink escapes the workspace #when finding rule files #then escaped instructions are rejected", () => {
// given
const root = createTestRoot("rules-core-github-dir-symlink-escape");
const projectRoot = join(root, "repo");
const homeDir = join(root, "home");
const outsideDir = join(root, "outside-instructions");
const currentFile = join(projectRoot, "src", "index.ts");
const escapedInstruction = join(outsideDir, "leak.instructions.md");
mkdirSync(projectRoot, { recursive: true });
mkdirSync(join(projectRoot, ".git"));
mkdirSync(join(projectRoot, ".github"), { recursive: true });
mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
writeFileSync(currentFile, "export {};");
writeFileSync(escapedInstruction, "do not inject this external github instruction");
symlinkSync(outsideDir, join(projectRoot, ".github", "instructions"), "dir");
// when
const found = findRuleFiles(projectRoot, homeDir, currentFile);
// then
expect(found.some((rule) => rule.realPath === realpathSync.native(escapedInstruction))).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
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"
| ".claude/rules"
| ".cursor/rules"
| ".github/instructions"
| ".github/copilot-instructions.md"
| ".sisyphus/rules"
| "~/.omo/rules"
| "~/.opencode/rules"
| "~/.claude/rules"
| "~/.sisyphus/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;
readonly workspaceDirectory?: string;
}
export interface AgentsMdCache {
get(key: string): readonly string[] | undefined;
set(key: string, value: readonly string[]): void;
clear(): void;
}