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 = new Set([".sisyphus/rules", "~/.sisyphus/rules"]); const warnedSisyphusRuleDirectories = new Set(); 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(); 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, 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, ): 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, 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); 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(), 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("\\", "/"); }