From 2d22a54b550ec99ec53b7c5a95863d848b738bd3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:33 +0900 Subject: [PATCH] refactor(rules-injector): split finder.ts into rule discovery modules Extract rule finding logic: - project-root-finder.ts: project root detection - rule-file-finder.ts: rule file discovery - rule-file-scanner.ts: filesystem scanning for rules - rule-distance.ts: rule-to-file distance calculation --- src/hooks/rules-injector/finder.ts | 266 +----------------- src/hooks/rules-injector/index.ts | 1 + .../rules-injector/project-root-finder.ts | 36 +++ src/hooks/rules-injector/rule-distance.ts | 53 ++++ src/hooks/rules-injector/rule-file-finder.ts | 119 ++++++++ src/hooks/rules-injector/rule-file-scanner.ts | 55 ++++ 6 files changed, 267 insertions(+), 263 deletions(-) create mode 100644 src/hooks/rules-injector/project-root-finder.ts create mode 100644 src/hooks/rules-injector/rule-distance.ts create mode 100644 src/hooks/rules-injector/rule-file-finder.ts create mode 100644 src/hooks/rules-injector/rule-file-scanner.ts diff --git a/src/hooks/rules-injector/finder.ts b/src/hooks/rules-injector/finder.ts index 3bf29394..4cd19fb3 100644 --- a/src/hooks/rules-injector/finder.ts +++ b/src/hooks/rules-injector/finder.ts @@ -1,263 +1,3 @@ -import { - existsSync, - readdirSync, - realpathSync, - statSync, -} from "node:fs"; -import { dirname, join, relative } from "node:path"; -import { - GITHUB_INSTRUCTIONS_PATTERN, - PROJECT_MARKERS, - PROJECT_RULE_FILES, - PROJECT_RULE_SUBDIRS, - RULE_EXTENSIONS, - USER_RULE_DIR, -} from "./constants"; -import type { RuleFileCandidate } from "./types"; - -function isGitHubInstructionsDir(dir: string): boolean { - return dir.includes(".github/instructions") || dir.endsWith(".github/instructions"); -} - -function isValidRuleFile(fileName: string, dir: string): boolean { - if (isGitHubInstructionsDir(dir)) { - return GITHUB_INSTRUCTIONS_PATTERN.test(fileName); - } - return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext)); -} - -/** - * Find project root by walking up from startPath. - * Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.) - * - * @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 { - let current: string; - - try { - const stat = statSync(startPath); - current = stat.isDirectory() ? startPath : dirname(startPath); - } catch { - current = dirname(startPath); - } - - while (true) { - for (const marker of PROJECT_MARKERS) { - const markerPath = join(current, marker); - if (existsSync(markerPath)) { - return current; - } - } - - const parent = dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -/** - * Recursively find all rule files (*.md, *.mdc) in a directory - * - * @param dir - Directory to search - * @param results - Array to accumulate results - */ -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()) { - 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 - */ -function safeRealpathSync(filePath: string): string { - try { - return realpathSync(filePath); - } catch { - return filePath; - } -} - -/** - * 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; - } -} - -/** - * Find all rule files for a given context. - * Searches from currentFile upward to projectRoot for rule directories, - * then user-level directory (~/.claude/rules). - * - * IMPORTANT: This searches EVERY directory from file to project root. - * Not just the project root itself. - * - * @param projectRoot - Project root path (or null if outside any project) - * @param homeDir - User home directory - * @param currentFile - Current file being edited (for distance calculation) - * @returns Array of rule file candidates sorted by distance - */ -export function findRuleFiles( - projectRoot: string | null, - homeDir: string, - currentFile: string, -): RuleFileCandidate[] { - const candidates: RuleFileCandidate[] = []; - const seenRealPaths = new Set(); - - // Search from current file's directory up to project root - let currentDir = dirname(currentFile); - let distance = 0; - - while (true) { - // Search rule directories in current directory - for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) { - const ruleDir = join(currentDir, parent, subdir); - const files: string[] = []; - findRuleFilesRecursive(ruleDir, files); - - for (const filePath of files) { - const realPath = safeRealpathSync(filePath); - if (seenRealPaths.has(realPath)) continue; - seenRealPaths.add(realPath); - - candidates.push({ - path: filePath, - realPath, - isGlobal: false, - distance, - }); - } - } - - // Stop at project root or filesystem root - if (projectRoot && currentDir === projectRoot) break; - const parentDir = dirname(currentDir); - if (parentDir === currentDir) break; - currentDir = parentDir; - distance++; - } - - // Check for single-file rules at project root (e.g., .github/copilot-instructions.md) - if (projectRoot) { - for (const ruleFile of PROJECT_RULE_FILES) { - const filePath = join(projectRoot, ruleFile); - if (existsSync(filePath)) { - try { - const stat = statSync(filePath); - if (stat.isFile()) { - const realPath = safeRealpathSync(filePath); - if (!seenRealPaths.has(realPath)) { - seenRealPaths.add(realPath); - candidates.push({ - path: filePath, - realPath, - isGlobal: false, - distance: 0, - isSingleFile: true, - }); - } - } - } catch { - // Skip if file can't be read - } - } - } - } - - // Search user-level rule directory (~/.claude/rules) - const userRuleDir = join(homeDir, USER_RULE_DIR); - const userFiles: string[] = []; - findRuleFilesRecursive(userRuleDir, userFiles); - - for (const filePath of userFiles) { - const realPath = safeRealpathSync(filePath); - if (seenRealPaths.has(realPath)) continue; - seenRealPaths.add(realPath); - - candidates.push({ - path: filePath, - realPath, - isGlobal: true, - distance: 9999, // Global rules always have max distance - }); - } - - // Sort by distance (closest first, then global rules last) - candidates.sort((a, b) => { - if (a.isGlobal !== b.isGlobal) { - return a.isGlobal ? 1 : -1; - } - return a.distance - b.distance; - }); - - return candidates; -} +export { findProjectRoot } from "./project-root-finder"; +export { calculateDistance } from "./rule-distance"; +export { findRuleFiles } from "./rule-file-finder"; diff --git a/src/hooks/rules-injector/index.ts b/src/hooks/rules-injector/index.ts index 8bcd0bb0..181729ca 100644 --- a/src/hooks/rules-injector/index.ts +++ b/src/hooks/rules-injector/index.ts @@ -1 +1,2 @@ export { createRulesInjectorHook } from "./hook"; +export { calculateDistance, findProjectRoot, findRuleFiles } from "./finder"; diff --git a/src/hooks/rules-injector/project-root-finder.ts b/src/hooks/rules-injector/project-root-finder.ts new file mode 100644 index 00000000..da697f0d --- /dev/null +++ b/src/hooks/rules-injector/project-root-finder.ts @@ -0,0 +1,36 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { PROJECT_MARKERS } from "./constants"; + +/** + * Find project root by walking up from startPath. + * Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.) + * + * @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 { + let current: string; + + try { + const stat = statSync(startPath); + current = stat.isDirectory() ? startPath : dirname(startPath); + } catch { + current = dirname(startPath); + } + + while (true) { + for (const marker of PROJECT_MARKERS) { + const markerPath = join(current, marker); + if (existsSync(markerPath)) { + return current; + } + } + + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} diff --git a/src/hooks/rules-injector/rule-distance.ts b/src/hooks/rules-injector/rule-distance.ts new file mode 100644 index 00000000..4cee64be --- /dev/null +++ b/src/hooks/rules-injector/rule-distance.ts @@ -0,0 +1,53 @@ +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; + } +} diff --git a/src/hooks/rules-injector/rule-file-finder.ts b/src/hooks/rules-injector/rule-file-finder.ts new file mode 100644 index 00000000..a08b12a1 --- /dev/null +++ b/src/hooks/rules-injector/rule-file-finder.ts @@ -0,0 +1,119 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + PROJECT_RULE_FILES, + PROJECT_RULE_SUBDIRS, + USER_RULE_DIR, +} from "./constants"; +import type { RuleFileCandidate } from "./types"; +import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner"; + +/** + * Find all rule files for a given context. + * Searches from currentFile upward to projectRoot for rule directories, + * then user-level directory (~/.claude/rules). + * + * IMPORTANT: This searches EVERY directory from file to project root. + * Not just the project root itself. + * + * @param projectRoot - Project root path (or null if outside any project) + * @param homeDir - User home directory + * @param currentFile - Current file being edited (for distance calculation) + * @returns Array of rule file candidates sorted by distance + */ +export function findRuleFiles( + projectRoot: string | null, + homeDir: string, + currentFile: string, +): RuleFileCandidate[] { + const candidates: RuleFileCandidate[] = []; + const seenRealPaths = new Set(); + + // Search from current file's directory up to project root + let currentDir = dirname(currentFile); + let distance = 0; + + while (true) { + // Search rule directories in current directory + for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) { + const ruleDir = join(currentDir, parent, subdir); + const files: string[] = []; + findRuleFilesRecursive(ruleDir, files); + + for (const filePath of files) { + const realPath = safeRealpathSync(filePath); + if (seenRealPaths.has(realPath)) continue; + seenRealPaths.add(realPath); + + candidates.push({ + path: filePath, + realPath, + isGlobal: false, + distance, + }); + } + } + + // Stop at project root or filesystem root + if (projectRoot && currentDir === projectRoot) break; + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + distance++; + } + + // Check for single-file rules at project root (e.g., .github/copilot-instructions.md) + if (projectRoot) { + for (const ruleFile of PROJECT_RULE_FILES) { + const filePath = join(projectRoot, ruleFile); + if (existsSync(filePath)) { + try { + const stat = statSync(filePath); + if (stat.isFile()) { + const realPath = safeRealpathSync(filePath); + if (!seenRealPaths.has(realPath)) { + seenRealPaths.add(realPath); + candidates.push({ + path: filePath, + realPath, + isGlobal: false, + distance: 0, + isSingleFile: true, + }); + } + } + } catch { + // Skip if file can't be read + } + } + } + } + + // Search user-level rule directory (~/.claude/rules) + const userRuleDir = join(homeDir, USER_RULE_DIR); + const userFiles: string[] = []; + findRuleFilesRecursive(userRuleDir, userFiles); + + for (const filePath of userFiles) { + const realPath = safeRealpathSync(filePath); + if (seenRealPaths.has(realPath)) continue; + seenRealPaths.add(realPath); + + candidates.push({ + path: filePath, + realPath, + isGlobal: true, + distance: 9999, // Global rules always have max distance + }); + } + + // Sort by distance (closest first, then global rules last) + candidates.sort((a, b) => { + if (a.isGlobal !== b.isGlobal) { + return a.isGlobal ? 1 : -1; + } + return a.distance - b.distance; + }); + + return candidates; +} diff --git a/src/hooks/rules-injector/rule-file-scanner.ts b/src/hooks/rules-injector/rule-file-scanner.ts new file mode 100644 index 00000000..ffd87d8a --- /dev/null +++ b/src/hooks/rules-injector/rule-file-scanner.ts @@ -0,0 +1,55 @@ +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants"; + +function isGitHubInstructionsDir(dir: string): boolean { + return dir.includes(".github/instructions") || dir.endsWith(".github/instructions"); +} + +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()) { + 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; + } +}