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:
Vendored
+1
@@ -0,0 +1 @@
|
||||
export * from "./src/index";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("\\", "/");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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("\\", "/");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user