From 4ea29e2c9474011f59b5aa4dca9ebae9b7d1178e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 21:19:06 +0900 Subject: [PATCH] refactor(rules): delegate injectors to rules-core Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/directory-agents-injector/finder.ts | 39 +--- src/hooks/directory-agents-injector/hook.ts | 11 +- .../directory-agents-injector/injector.ts | 39 ++-- src/hooks/rules-injector/matcher.ts | 107 +-------- src/hooks/rules-injector/parser.ts | 213 +----------------- .../rules-injector/project-root-finder.ts | 86 +------ src/hooks/rules-injector/rule-distance.ts | 54 +---- src/hooks/rules-injector/rule-file-finder.ts | 150 +----------- src/hooks/rules-injector/rule-file-scanner.ts | 59 +---- src/hooks/rules-injector/rule-scan-cache.ts | 40 +--- src/hooks/rules-injector/types.ts | 42 +--- 11 files changed, 65 insertions(+), 775 deletions(-) diff --git a/src/hooks/directory-agents-injector/finder.ts b/src/hooks/directory-agents-injector/finder.ts index e04cfab74..976f0379a 100644 --- a/src/hooks/directory-agents-injector/finder.ts +++ b/src/hooks/directory-agents-injector/finder.ts @@ -1,7 +1,6 @@ -import { constants, promises as fsPromises } from "node:fs"; -import { dirname, isAbsolute, join, resolve } from "node:path"; - -import { AGENTS_FILENAME } from "./constants"; +import { findAgentsMdUp as findAgentsMdUpCore } from "@oh-my-opencode/rules-core"; +import type { AgentsMdCache } from "@oh-my-opencode/rules-core"; +import { isAbsolute, resolve } from "node:path"; export function resolveFilePath(rootDirectory: string, path: string): string | null { if (!path) return null; @@ -10,33 +9,9 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n } export async function findAgentsMdUp(input: { - startDir: string; - rootDir: string; + readonly startDir: string; + readonly rootDir: string; + readonly cache?: AgentsMdCache; }): Promise { - const found: string[] = []; - let current = input.startDir; - - while (true) { - // Skip root AGENTS.md - OpenCode's system.ts already loads it via custom() - // See: https://github.com/code-yeongyu/oh-my-openagent/issues/379 - const isRootDir = current === input.rootDir; - if (!isRootDir) { - const agentsPath = join(current, AGENTS_FILENAME); - const exists = await fsPromises - .access(agentsPath, constants.F_OK) - .then(() => true) - .catch(() => false); - if (exists) { - found.push(agentsPath); - } - } - - if (isRootDir) break; - const parent = dirname(current); - if (parent === current) break; - if (!parent.startsWith(input.rootDir)) break; - current = parent; - } - - return found.reverse(); + return findAgentsMdUpCore({ startDir: input.startDir, rootDir: input.rootDir, cache: input.cache }); } diff --git a/src/hooks/directory-agents-injector/hook.ts b/src/hooks/directory-agents-injector/hook.ts index 58279fd3d..2dac87234 100644 --- a/src/hooks/directory-agents-injector/hook.ts +++ b/src/hooks/directory-agents-injector/hook.ts @@ -1,3 +1,4 @@ +import { createAgentsMdCache } from "@oh-my-opencode/rules-core"; import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; @@ -35,6 +36,7 @@ export function createDirectoryAgentsInjectorHook( modelCacheState?: { anthropicContext1MEnabled: boolean }, ): DirectoryAgentsInjectorHook { const sessionCaches = new Map>(); + const agentsMdCache = createAgentsMdCache(); const truncator = createDynamicTruncator(ctx, modelCacheState); const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => { @@ -45,6 +47,7 @@ export function createDirectoryAgentsInjectorHook( ctx, truncator, sessionCaches, + agentsMdCache, filePath: output.title, sessionID: input.sessionID, output, @@ -54,21 +57,21 @@ export function createDirectoryAgentsInjectorHook( }; const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - if (event.type === "session.deleted") { - const sessionID = resolveSessionEventID(props); + const sessionID = resolveSessionEventID(event.properties); if (sessionID) { sessionCaches.delete(sessionID); clearInjectedPaths(sessionID); + agentsMdCache.clear(); } } if (event.type === "session.compacted") { - const sessionID = resolveSessionEventID(props); + const sessionID = resolveSessionEventID(event.properties); if (sessionID) { sessionCaches.delete(sessionID); clearInjectedPaths(sessionID); + agentsMdCache.clear(); } } }; diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts index f05dc276f..ce3bf405b 100644 --- a/src/hooks/directory-agents-injector/injector.ts +++ b/src/hooks/directory-agents-injector/injector.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin"; +import type { AgentsMdCache } from "@oh-my-opencode/rules-core"; import { promises as fsPromises } from "node:fs"; import { dirname } from "node:path"; @@ -15,13 +16,18 @@ function getSessionCache( if (!sessionCaches.has(sessionID)) { sessionCaches.set(sessionID, loadInjectedPaths(sessionID)); } - return sessionCaches.get(sessionID)!; + const cache = sessionCaches.get(sessionID); + if (cache) return cache; + const loaded = loadInjectedPaths(sessionID); + sessionCaches.set(sessionID, loaded); + return loaded; } export async function processFilePathForAgentsInjection(input: { ctx: PluginInput; truncator: DynamicTruncator; sessionCaches: Map>; + agentsMdCache?: AgentsMdCache; filePath: string; sessionID: string; output: { title: string; output: string; metadata: unknown }; @@ -35,26 +41,29 @@ export async function processFilePathForAgentsInjection(input: { const dir = dirname(resolved); const cache = getSessionCache(input.sessionCaches, input.sessionID); - const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory }); + const agentsPaths = await findAgentsMdUp({ + startDir: dir, + rootDir: input.ctx.directory, + cache: input.agentsMdCache, + }); let dirty = false; for (const agentsPath of agentsPaths) { const agentsDir = dirname(agentsPath); if (cache.has(agentsDir)) continue; - try { - const content = await fsPromises.readFile(agentsPath, "utf-8"); - cache.add(agentsDir); - const { result, truncated } = await input.truncator.truncate( - input.sessionID, - content, - ); - const truncationNotice = truncated - ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]` - : ""; - input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`; - dirty = true; - } catch {} + const content = await fsPromises.readFile(agentsPath, "utf-8").catch(() => null); + if (content === null) continue; + cache.add(agentsDir); + const { result, truncated } = await input.truncator.truncate( + input.sessionID, + content, + ); + const truncationNotice = truncated + ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]` + : ""; + input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`; + dirty = true; } if (dirty) { diff --git a/src/hooks/rules-injector/matcher.ts b/src/hooks/rules-injector/matcher.ts index cd6995f6e..4a395e25f 100644 --- a/src/hooks/rules-injector/matcher.ts +++ b/src/hooks/rules-injector/matcher.ts @@ -1,100 +1,13 @@ -import { createHash } from "crypto" -import { relative } from "node:path" -import picomatch from "picomatch" -import type { RuleMetadata } from "./types" - -type PathMatcher = (path: string) => boolean - -export interface MatchResult { - applies: boolean - reason?: string -} +export { + createContentHash, + getMatcherCacheStats, + isDuplicateByContentHash, + isDuplicateByRealPath, + resetMatcherCache, + shouldApplyRule, +} from "@oh-my-opencode/rules-core"; +export type { MatchResult } from "@oh-my-opencode/rules-core"; export interface MatcherCacheStats { - entries: number -} - -const PICOMATCH_OPTIONS = { dot: true, bash: true } as const -const MAX_MATCHER_CACHE_ENTRIES = 256 -const matcherCache = new Map() - -function matcherFor(pattern: string): PathMatcher { - 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 oldestPattern = matcherCache.keys().next().value - if (oldestPattern !== undefined) { - matcherCache.delete(oldestPattern) - } - } - matcherCache.set(pattern, matcher) - return matcher -} - -export function resetMatcherCache(): void { - matcherCache.clear() -} - -export function getMatcherCacheStats(): MatcherCacheStats { - return { entries: matcherCache.size } -} - -/** - * Check if a rule should apply to the current file based on metadata - */ -export function shouldApplyRule( - metadata: RuleMetadata, - currentFilePath: string, - projectRoot: string | null -): MatchResult { - if (metadata.alwaysApply === true) { - return { applies: true, reason: "alwaysApply" } - } - - const globs = metadata.globs - if (!globs) { - return { applies: false } - } - - const patterns = Array.isArray(globs) ? globs : [globs] - if (patterns.length === 0) { - return { applies: false } - } - - const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath - - for (const pattern of patterns) { - if (matcherFor(pattern)(relativePath)) { - return { applies: true, reason: `glob: ${pattern}` } - } - } - - return { applies: false } -} - -/** - * Check if realPath already exists in cache (symlink deduplication) - */ -export function isDuplicateByRealPath(realPath: string, cache: Set): boolean { - return cache.has(realPath) -} - -/** - * Create SHA-256 hash of content, truncated to 16 chars - */ -export function createContentHash(content: string): string { - return createHash("sha256").update(content).digest("hex").slice(0, 16) -} - -/** - * Check if content hash already exists in cache - */ -export function isDuplicateByContentHash(hash: string, cache: Set): boolean { - return cache.has(hash) + readonly entries: number; } diff --git a/src/hooks/rules-injector/parser.ts b/src/hooks/rules-injector/parser.ts index 12d41edae..df7fec523 100644 --- a/src/hooks/rules-injector/parser.ts +++ b/src/hooks/rules-injector/parser.ts @@ -1,211 +1,2 @@ -import type { RuleMetadata } from "./types"; - -export interface RuleFrontmatterResult { - metadata: RuleMetadata; - body: string; -} - -/** - * Parse YAML frontmatter from rule file content - * Supports: - * - Single string: globs: "**\/*.py" - * - Inline array: globs: ["**\/*.py", "src/**\/*.ts"] - * - Multi-line array: - * globs: - * - "**\/*.py" - * - "src/**\/*.ts" - * - Comma-separated: globs: "**\/*.py, src/**\/*.ts" - * - Claude Code 'paths' field (alias for globs) - */ -export function parseRuleFrontmatter(content: string): RuleFrontmatterResult { - const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; - const match = content.match(frontmatterRegex); - - if (!match) { - return { metadata: {}, body: content }; - } - - const yamlContent = match[1]; - const body = match[2]; - - try { - const metadata = parseYamlContent(yamlContent); - return { metadata, body }; - } catch { - return { metadata: {}, body: content }; - } -} - -/** - * Parse YAML content without external library - */ -function parseYamlContent(yamlContent: string): RuleMetadata { - const lines = yamlContent.split("\n"); - const metadata: RuleMetadata = {}; - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - const colonIndex = line.indexOf(":"); - - if (colonIndex === -1) { - i++; - continue; - } - - const key = line.slice(0, colonIndex).trim(); - const rawValue = line.slice(colonIndex + 1).trim(); - - if (key === "description") { - metadata.description = parseStringValue(rawValue); - } else if (key === "alwaysApply") { - metadata.alwaysApply = rawValue === "true"; - } else if (key === "globs" || key === "paths" || key === "applyTo") { - const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i); - // Merge paths into globs (Claude Code compatibility) - if (key === "paths") { - metadata.globs = mergeGlobs(metadata.globs, value); - } else { - metadata.globs = mergeGlobs(metadata.globs, value); - } - i += consumed; - continue; - } - - i++; - } - - return metadata; -} - -/** - * Parse a string value, removing surrounding quotes - */ -function parseStringValue(value: string): string { - if (!value) return ""; - - // Remove surrounding quotes - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return value.slice(1, -1); - } - - return value; -} - -/** - * Parse array or string value from YAML - * Returns the parsed value and number of lines consumed - */ -function parseArrayOrStringValue( - rawValue: string, - lines: string[], - currentIndex: number -): { value: string | string[]; consumed: number } { - // Case 1: Inline array ["a", "b", "c"] - if (rawValue.startsWith("[")) { - return { value: parseInlineArray(rawValue), consumed: 1 }; - } - - // Case 2: Multi-line array (value is empty, next lines start with " - ") - if (!rawValue || rawValue === "") { - const arrayItems: string[] = []; - let consumed = 1; - - for (let j = currentIndex + 1; j < lines.length; j++) { - const nextLine = lines[j]; - - // Check if this is an array item (starts with whitespace + dash) - const arrayMatch = nextLine.match(/^\s+-\s*(.*)$/); - if (arrayMatch) { - const itemValue = parseStringValue(arrayMatch[1].trim()); - if (itemValue) { - arrayItems.push(itemValue); - } - consumed++; - } else if (nextLine.trim() === "") { - // Skip empty lines within array - consumed++; - } else { - // Not an array item, stop - break; - } - } - - if (arrayItems.length > 0) { - return { value: arrayItems, consumed }; - } - } - - // Case 3: Comma-separated patterns in single string - const stringValue = parseStringValue(rawValue); - if (stringValue.includes(",")) { - const items = stringValue - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - return { value: items, consumed: 1 }; - } - - // Case 4: Single string value - return { value: stringValue, consumed: 1 }; -} - -/** - * Parse inline JSON-like array: ["a", "b", "c"] - */ -function parseInlineArray(value: string): string[] { - // Remove brackets - const content = value.slice(1, value.lastIndexOf("]")).trim(); - if (!content) return []; - - const items: string[] = []; - let current = ""; - let inQuote = false; - let quoteChar = ""; - - for (let i = 0; i < content.length; i++) { - const char = content[i]; - - if (!inQuote && (char === '"' || char === "'")) { - inQuote = true; - quoteChar = char; - } else if (inQuote && char === quoteChar) { - inQuote = false; - quoteChar = ""; - } else if (!inQuote && char === ",") { - const trimmed = current.trim(); - if (trimmed) { - items.push(parseStringValue(trimmed)); - } - current = ""; - } else { - current += char; - } - } - - // Don't forget the last item - const trimmed = current.trim(); - if (trimmed) { - items.push(parseStringValue(trimmed)); - } - - return items; -} - -/** - * Merge two globs values (for combining paths and globs) - */ -function mergeGlobs( - existing: string | string[] | undefined, - newValue: string | string[] -): string | string[] { - if (!existing) return newValue; - - const existingArray = Array.isArray(existing) ? existing : [existing]; - const newArray = Array.isArray(newValue) ? newValue : [newValue]; - - return [...existingArray, ...newArray]; -} +export { parseRuleFrontmatter } from "@oh-my-opencode/rules-core"; +export type { RuleFrontmatterResult } from "@oh-my-opencode/rules-core"; diff --git a/src/hooks/rules-injector/project-root-finder.ts b/src/hooks/rules-injector/project-root-finder.ts index 613083c95..0f2499ec7 100644 --- a/src/hooks/rules-injector/project-root-finder.ts +++ b/src/hooks/rules-injector/project-root-finder.ts @@ -1,85 +1 @@ -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(); -} - -/** - * Find project root by walking up from startPath. - * Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.) - * - * Memoizes every directory visited during the walk so subsequent lookups for - * any descendant path resolve in O(1) without re-running marker existsSync - * probes. - * - * @param startPath - Starting path to search from (file or directory) - * @returns Project root path or null if not found - */ -export function findProjectRoot(startPath: string): string | null { - const cached = projectRootCache.get(startPath); - if (cached !== undefined) { - return cached; - } - - const startDir = resolveStartDir(startPath); - const cachedFromStartDir = projectRootCache.get(startDir); - if (cachedFromStartDir !== undefined) { - projectRootCache.set(startPath, cachedFromStartDir); - return cachedFromStartDir; - } - - 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) { - resolved = null; - break; - } - current = parent; - } - - for (const dir of visited) { - projectRootCache.set(dir, resolved); - } - projectRootCache.set(startPath, resolved); - - return resolved; -} - -function resolveStartDir(startPath: string): string { - try { - const stat = statSync(startPath); - return stat.isDirectory() ? startPath : dirname(startPath); - } catch { - return dirname(startPath); - } -} - -function hasProjectMarker(dir: string): boolean { - for (const marker of PROJECT_MARKERS) { - if (existsSync(join(dir, marker))) { - return true; - } - } - return false; -} +export { clearProjectRootCache, findProjectRoot } from "@oh-my-opencode/rules-core"; diff --git a/src/hooks/rules-injector/rule-distance.ts b/src/hooks/rules-injector/rule-distance.ts index 4cee64be3..47b003d12 100644 --- a/src/hooks/rules-injector/rule-distance.ts +++ b/src/hooks/rules-injector/rule-distance.ts @@ -1,53 +1 @@ -import { dirname, relative } from "node:path"; - -/** - * Calculate directory distance between a rule file and current file. - * Distance is based on common ancestor within project root. - * - * @param rulePath - Path to the rule file - * @param currentFile - Path to the current file being edited - * @param projectRoot - Project root for relative path calculation - * @returns Distance (0 = same directory, higher = further) - */ -export function calculateDistance( - rulePath: string, - currentFile: string, - projectRoot: string | null, -): number { - if (!projectRoot) { - return 9999; - } - - try { - const ruleDir = dirname(rulePath); - const currentDir = dirname(currentFile); - - const ruleRel = relative(projectRoot, ruleDir); - const currentRel = relative(projectRoot, currentDir); - - // Handle paths outside project root - if (ruleRel.startsWith("..") || currentRel.startsWith("..")) { - return 9999; - } - - // Split by both forward and back slashes for cross-platform compatibility - // path.relative() returns OS-native separators (backslashes on Windows) - const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : []; - const currentParts = currentRel ? currentRel.split(/[/\\]/) : []; - - // Find common prefix length - let common = 0; - for (let i = 0; i < Math.min(ruleParts.length, currentParts.length); i++) { - if (ruleParts[i] === currentParts[i]) { - common++; - } else { - break; - } - } - - // Distance is how many directories up from current file to common ancestor - return currentParts.length - common; - } catch { - return 9999; - } -} +export { calculateDistance } from "@oh-my-opencode/rules-core"; diff --git a/src/hooks/rules-injector/rule-file-finder.ts b/src/hooks/rules-injector/rule-file-finder.ts index c2e60e68d..64c50d937 100644 --- a/src/hooks/rules-injector/rule-file-finder.ts +++ b/src/hooks/rules-injector/rule-file-finder.ts @@ -1,148 +1,2 @@ -import { existsSync, statSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { - OPENCODE_USER_RULE_DIRS, - PROJECT_RULE_FILES, - PROJECT_RULE_SUBDIRS, - USER_RULE_DIR, -} from "./constants"; -import type { DirectoryScanEntry, RuleScanCache } from "./rule-scan-cache"; -import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner"; -import type { RuleFileCandidate } from "./types"; - -export interface FindRuleFilesOptions { - skipClaudeUserRules?: boolean; -} - -function scanDirectoryWithCache( - dir: string, - cache: RuleScanCache | undefined, -): DirectoryScanEntry[] { - const cached = cache?.getDirScan(dir); - if (cached) { - return cached; - } - - const files: string[] = []; - findRuleFilesRecursive(dir, files); - const entries: DirectoryScanEntry[] = files.map((filePath) => ({ - path: filePath, - realPath: safeRealpathSync(filePath), - })); - - cache?.setDirScan(dir, entries); - return entries; -} - -function getUserRuleDirs(homeDir: string, skipClaudeUserRules: boolean): string[] { - const userRuleDirs = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir)); - if (!skipClaudeUserRules) { - userRuleDirs.push(join(homeDir, USER_RULE_DIR)); - } - return userRuleDirs; -} - -function createCacheKey( - projectRoot: string | null, - startDir: string, - skipClaudeUserRules: boolean, -): string { - return `${projectRoot ?? ""}|${startDir}|${skipClaudeUserRules ? "1" : "0"}`; -} - -export function findRuleFiles( - projectRoot: string | null, - homeDir: string, - currentFile: string, - options?: FindRuleFilesOptions, - cache?: RuleScanCache, -): RuleFileCandidate[] { - const startDir = dirname(currentFile); - const skipClaudeUserRules = options?.skipClaudeUserRules ?? false; - const userRuleDirs = getUserRuleDirs(homeDir, skipClaudeUserRules); - const cacheKey = createCacheKey(projectRoot, startDir, skipClaudeUserRules); - const cachedCandidates = cache?.get(cacheKey); - - if (cachedCandidates) { - return cachedCandidates; - } - - const candidates: RuleFileCandidate[] = []; - const seenRealPaths = new Set(); - let currentDir = startDir; - let distance = 0; - - while (true) { - for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) { - const ruleDir = join(currentDir, parent, subdir); - const entries = scanDirectoryWithCache(ruleDir, cache); - - for (const entry of entries) { - if (seenRealPaths.has(entry.realPath)) continue; - seenRealPaths.add(entry.realPath); - candidates.push({ - path: entry.path, - realPath: entry.realPath, - isGlobal: false, - distance, - }); - } - } - - if (projectRoot && currentDir === projectRoot) break; - const parentDir = dirname(currentDir); - if (parentDir === currentDir) break; - currentDir = parentDir; - distance += 1; - } - - if (projectRoot) { - for (const ruleFile of PROJECT_RULE_FILES) { - const filePath = join(projectRoot, ruleFile); - if (!existsSync(filePath)) continue; - - try { - const stat = statSync(filePath); - if (!stat.isFile()) continue; - const realPath = safeRealpathSync(filePath); - if (seenRealPaths.has(realPath)) continue; - seenRealPaths.add(realPath); - candidates.push({ - path: filePath, - realPath, - isGlobal: false, - distance: 0, - isSingleFile: true, - }); - } catch { - continue; - } - } - } - - for (const userRuleDir of userRuleDirs) { - const entries = scanDirectoryWithCache(userRuleDir, cache); - - for (const entry of entries) { - if (seenRealPaths.has(entry.realPath)) continue; - seenRealPaths.add(entry.realPath); - candidates.push({ - path: entry.path, - realPath: entry.realPath, - isGlobal: true, - distance: 9999, - }); - } - } - - candidates.sort((left, right) => { - if (left.isGlobal !== right.isGlobal) { - return left.isGlobal ? 1 : -1; - } - return left.distance - right.distance; - }); - - cache?.set(cacheKey, candidates); - - return candidates; -} +export { findRuleFiles } from "@oh-my-opencode/rules-core"; +export type { FindRuleFilesOptions } from "@oh-my-opencode/rules-core"; diff --git a/src/hooks/rules-injector/rule-file-scanner.ts b/src/hooks/rules-injector/rule-file-scanner.ts index 2cd853d07..3cefabf3b 100644 --- a/src/hooks/rules-injector/rule-file-scanner.ts +++ b/src/hooks/rules-injector/rule-file-scanner.ts @@ -1,57 +1,10 @@ -import { existsSync, readdirSync, realpathSync } from "node:fs"; -import { join } from "node:path"; -import { EXCLUDED_DIRS } from "../../shared"; -import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants"; +import { findRuleFilesRecursive as findRuleFileEntriesRecursive, safeRealpathSync } from "@oh-my-opencode/rules-core"; +import type { DirectoryScanEntry } from "@oh-my-opencode/rules-core"; -function isGitHubInstructionsDir(dir: string): boolean { - return dir.includes(".github/instructions") || dir.endsWith(".github/instructions"); -} +export { safeRealpathSync }; -function isValidRuleFile(fileName: string, dir: string): boolean { - if (isGitHubInstructionsDir(dir)) { - return GITHUB_INSTRUCTIONS_PATTERN.test(fileName); - } - return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext)); -} - -/** - * Recursively find all rule files (*.md, *.mdc) in a directory - * - * @param dir - Directory to search - * @param results - Array to accumulate results - */ export function findRuleFilesRecursive(dir: string, results: string[]): void { - if (!existsSync(dir)) return; - - try { - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - - if (entry.isDirectory()) { - if (EXCLUDED_DIRS.has(entry.name)) continue; - findRuleFilesRecursive(fullPath, results); - } else if (entry.isFile()) { - if (isValidRuleFile(entry.name, dir)) { - results.push(fullPath); - } - } - } - } catch { - // Permission denied or other errors - silently skip - } -} - -/** - * Resolve symlinks safely with fallback to original path - * - * @param filePath - Path to resolve - * @returns Real path or original path if resolution fails - */ -export function safeRealpathSync(filePath: string): string { - try { - return realpathSync(filePath); - } catch { - return filePath; - } + const entries: DirectoryScanEntry[] = []; + findRuleFileEntriesRecursive(dir, entries); + results.push(...entries.map((entry) => entry.path)); } diff --git a/src/hooks/rules-injector/rule-scan-cache.ts b/src/hooks/rules-injector/rule-scan-cache.ts index fb69e46d6..4c597e0b2 100644 --- a/src/hooks/rules-injector/rule-scan-cache.ts +++ b/src/hooks/rules-injector/rule-scan-cache.ts @@ -1,38 +1,2 @@ -import type { RuleFileCandidate } from "./types"; - -export type DirectoryScanEntry = { - path: string; - realPath: string; -}; - -export type RuleScanCache = { - get: (key: string) => RuleFileCandidate[] | undefined; - set: (key: string, value: RuleFileCandidate[]) => void; - getDirScan: (dir: string) => DirectoryScanEntry[] | undefined; - setDirScan: (dir: string, entries: DirectoryScanEntry[]) => void; - clear: () => void; -}; - -export function createRuleScanCache(): RuleScanCache { - const finalResultCache = new Map(); - const directoryScanCache = new Map(); - - return { - get(key: string): RuleFileCandidate[] | undefined { - return finalResultCache.get(key); - }, - set(key: string, value: RuleFileCandidate[]): void { - finalResultCache.set(key, value); - }, - getDirScan(dir: string): DirectoryScanEntry[] | undefined { - return directoryScanCache.get(dir); - }, - setDirScan(dir: string, entries: DirectoryScanEntry[]): void { - directoryScanCache.set(dir, entries); - }, - clear(): void { - finalResultCache.clear(); - directoryScanCache.clear(); - }, - }; -} +export { createRuleScanCache } from "@oh-my-opencode/rules-core"; +export type { DirectoryScanEntry, RuleScanCache } from "@oh-my-opencode/rules-core"; diff --git a/src/hooks/rules-injector/types.ts b/src/hooks/rules-injector/types.ts index 63bf9f680..eab530f3a 100644 --- a/src/hooks/rules-injector/types.ts +++ b/src/hooks/rules-injector/types.ts @@ -1,57 +1,21 @@ -/** - * Rule file metadata (Claude Code style frontmatter) - * Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo) - * @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files - * @see https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot - */ -export interface RuleMetadata { - description?: string; - globs?: string | string[]; - alwaysApply?: boolean; -} +import type { RuleFileCandidate, RuleMetadata } from "@oh-my-opencode/rules-core"; + +export type { RuleFileCandidate, RuleMetadata }; -/** - * Rule information with path context and content - */ export interface RuleInfo { - /** Absolute path to the rule file */ path: string; - /** Path relative to project root */ relativePath: string; - /** Directory distance from target file (0 = same dir) */ distance: number; - /** Rule file content (without frontmatter) */ content: string; - /** SHA-256 hash of content for deduplication */ contentHash: string; - /** Parsed frontmatter metadata */ metadata: RuleMetadata; - /** Why this rule matched (e.g., "alwaysApply", "glob: *.ts", "path match") */ matchReason: string; - /** Real path after symlink resolution (for duplicate detection) */ realPath: string; } -/** - * Rule file candidate with discovery context - */ -export interface RuleFileCandidate { - path: string; - realPath: string; - isGlobal: boolean; - distance: number; - /** Single-file rules (e.g., .github/copilot-instructions.md) always apply without frontmatter */ - isSingleFile?: boolean; -} - -/** - * Session storage for injected rules tracking - */ export interface InjectedRulesData { sessionID: string; - /** Content hashes of already injected rules */ injectedHashes: string[]; - /** Real paths of already injected rules (for symlink deduplication) */ injectedRealPaths: string[]; updatedAt: number; }