diff --git a/packages/rules-core/index.d.ts b/packages/rules-core/index.d.ts new file mode 100644 index 000000000..e910bb060 --- /dev/null +++ b/packages/rules-core/index.d.ts @@ -0,0 +1 @@ +export * from "./src/index"; diff --git a/packages/rules-core/package.json b/packages/rules-core/package.json new file mode 100644 index 000000000..ca4f75f3b --- /dev/null +++ b/packages/rules-core/package.json @@ -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" + } +} diff --git a/packages/rules-core/src/agents-md.ts b/packages/rules-core/src/agents-md.ts new file mode 100644 index 000000000..5620f66db --- /dev/null +++ b/packages/rules-core/src/agents-md.ts @@ -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 { + 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)); +} diff --git a/packages/rules-core/src/cache.ts b/packages/rules-core/src/cache.ts new file mode 100644 index 000000000..2ac85ec79 --- /dev/null +++ b/packages/rules-core/src/cache.ts @@ -0,0 +1,26 @@ +import type { AgentsMdCache, DirectoryScanEntry, RuleFileCandidate, RuleScanCache, RuleScanCacheStats } from "./types"; + +export function createRuleScanCache(): RuleScanCache { + const candidateCache = new Map(); + const directoryCache = new Map(); + 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(); + return { + get: (key) => cache.get(key), + set: (key, value) => cache.set(key, value), + clear: () => cache.clear(), + }; +} diff --git a/packages/rules-core/src/constants.ts b/packages/rules-core/src/constants.ts new file mode 100644 index 000000000..aafdb0088 --- /dev/null +++ b/packages/rules-core/src/constants.ts @@ -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 = 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], +]); diff --git a/packages/rules-core/src/distance.ts b/packages/rules-core/src/distance.ts new file mode 100644 index 000000000..3e553ea19 --- /dev/null +++ b/packages/rules-core/src/distance.ts @@ -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); +} diff --git a/packages/rules-core/src/finder.ts b/packages/rules-core/src/finder.ts new file mode 100644 index 000000000..ebf6e7ab5 --- /dev/null +++ b/packages/rules-core/src/finder.ts @@ -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(); + 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, + 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, +): 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, + cache: RuleScanCache | undefined, +): void { + const userRuleDirs: Array = 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("\\", "/"); +} diff --git a/packages/rules-core/src/index.test.ts b/packages/rules-core/src/index.test.ts new file mode 100644 index 000000000..bfb069291 --- /dev/null +++ b/packages/rules-core/src/index.test.ts @@ -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); + }); +}); diff --git a/packages/rules-core/src/index.ts b/packages/rules-core/src/index.ts new file mode 100644 index 000000000..e4f3c247e --- /dev/null +++ b/packages/rules-core/src/index.ts @@ -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"; diff --git a/packages/rules-core/src/matcher.ts b/packages/rules-core/src/matcher.ts new file mode 100644 index 000000000..a2d64eb29 --- /dev/null +++ b/packages/rules-core/src/matcher.ts @@ -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 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): boolean { + return cache.has(realPath); +} + +export function isDuplicateByContentHash(hash: string, cache: ReadonlySet): 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("\\", "/"); +} diff --git a/packages/rules-core/src/ordering.ts b/packages/rules-core/src/ordering.ts new file mode 100644 index 000000000..9ef97ccf7 --- /dev/null +++ b/packages/rules-core/src/ordering.ts @@ -0,0 +1,26 @@ +import { SOURCE_PRIORITY } from "./constants"; +import type { RuleFileCandidate } from "./types"; + +export function sortCandidates(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; +} diff --git a/packages/rules-core/src/parser.ts b/packages/rules-core/src/parser.ts new file mode 100644 index 000000000..a75c9f03a --- /dev/null +++ b/packages/rules-core/src/parser.ts @@ -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; +} diff --git a/packages/rules-core/src/project-root.ts b/packages/rules-core/src/project-root.ts new file mode 100644 index 000000000..4e1341a28 --- /dev/null +++ b/packages/rules-core/src/project-root.ts @@ -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(); + +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))); +} diff --git a/packages/rules-core/src/scanner.ts b/packages/rules-core/src/scanner.ts new file mode 100644 index 000000000..ff1fbce6c --- /dev/null +++ b/packages/rules-core/src/scanner.ts @@ -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()): 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 }); + } + } +} diff --git a/packages/rules-core/src/types.ts b/packages/rules-core/src/types.ts new file mode 100644 index 000000000..4bea6d989 --- /dev/null +++ b/packages/rules-core/src/types.ts @@ -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; +} diff --git a/packages/rules-core/tsconfig.json b/packages/rules-core/tsconfig.json new file mode 100644 index 000000000..7a69d1222 --- /dev/null +++ b/packages/rules-core/tsconfig.json @@ -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/**/*"] +}