2026-02-08 16:22:33 +09:00
|
|
|
import { existsSync, statSync } from "node:fs";
|
2026-05-17 01:53:24 +09:00
|
|
|
import { dirname, join } from "node:path";
|
2026-02-08 16:22:33 +09:00
|
|
|
import {
|
2026-04-18 14:13:51 +09:00
|
|
|
OPENCODE_USER_RULE_DIRS,
|
2026-02-08 16:22:33 +09:00
|
|
|
PROJECT_RULE_FILES,
|
|
|
|
|
PROJECT_RULE_SUBDIRS,
|
|
|
|
|
USER_RULE_DIR,
|
|
|
|
|
} from "./constants";
|
2026-05-17 01:53:24 +09:00
|
|
|
import type { DirectoryScanEntry, RuleScanCache } from "./rule-scan-cache";
|
2026-02-08 16:22:33 +09:00
|
|
|
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
|
2026-04-18 14:13:51 +09:00
|
|
|
import type { RuleFileCandidate } from "./types";
|
2026-02-08 16:22:33 +09:00
|
|
|
|
2026-03-29 10:13:55 +07:00
|
|
|
export interface FindRuleFilesOptions {
|
|
|
|
|
skipClaudeUserRules?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 01:53:24 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 14:13:51 +09:00
|
|
|
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"}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 16:22:33 +09:00
|
|
|
export function findRuleFiles(
|
|
|
|
|
projectRoot: string | null,
|
|
|
|
|
homeDir: string,
|
|
|
|
|
currentFile: string,
|
2026-03-29 10:13:55 +07:00
|
|
|
options?: FindRuleFilesOptions,
|
2026-04-18 14:13:51 +09:00
|
|
|
cache?: RuleScanCache,
|
2026-02-08 16:22:33 +09:00
|
|
|
): RuleFileCandidate[] {
|
2026-04-18 14:13:51 +09:00
|
|
|
const startDir = dirname(currentFile);
|
|
|
|
|
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
|
|
|
|
|
const userRuleDirs = getUserRuleDirs(homeDir, skipClaudeUserRules);
|
|
|
|
|
const cacheKey = createCacheKey(projectRoot, startDir, skipClaudeUserRules);
|
2026-05-17 01:53:24 +09:00
|
|
|
const cachedCandidates = cache?.get(cacheKey);
|
2026-04-18 14:13:51 +09:00
|
|
|
|
2026-05-17 01:53:24 +09:00
|
|
|
if (cachedCandidates) {
|
|
|
|
|
return cachedCandidates;
|
2026-04-18 14:13:51 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 16:22:33 +09:00
|
|
|
const candidates: RuleFileCandidate[] = [];
|
|
|
|
|
const seenRealPaths = new Set<string>();
|
2026-04-18 14:13:51 +09:00
|
|
|
let currentDir = startDir;
|
2026-02-08 16:22:33 +09:00
|
|
|
let distance = 0;
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
|
|
|
|
const ruleDir = join(currentDir, parent, subdir);
|
2026-05-17 01:53:24 +09:00
|
|
|
const entries = scanDirectoryWithCache(ruleDir, cache);
|
2026-02-08 16:22:33 +09:00
|
|
|
|
2026-05-17 01:53:24 +09:00
|
|
|
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,
|
|
|
|
|
});
|
2026-02-08 16:22:33 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (projectRoot && currentDir === projectRoot) break;
|
|
|
|
|
const parentDir = dirname(currentDir);
|
|
|
|
|
if (parentDir === currentDir) break;
|
|
|
|
|
currentDir = parentDir;
|
2026-04-18 14:13:51 +09:00
|
|
|
distance += 1;
|
2026-02-08 16:22:33 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (projectRoot) {
|
|
|
|
|
for (const ruleFile of PROJECT_RULE_FILES) {
|
|
|
|
|
const filePath = join(projectRoot, ruleFile);
|
2026-04-18 14:13:51 +09:00
|
|
|
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;
|
2026-02-08 16:22:33 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 10:13:55 +07:00
|
|
|
for (const userRuleDir of userRuleDirs) {
|
2026-05-17 01:53:24 +09:00
|
|
|
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,
|
|
|
|
|
});
|
2026-03-29 10:13:55 +07:00
|
|
|
}
|
2026-02-08 16:22:33 +09:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 14:13:51 +09:00
|
|
|
candidates.sort((left, right) => {
|
|
|
|
|
if (left.isGlobal !== right.isGlobal) {
|
|
|
|
|
return left.isGlobal ? 1 : -1;
|
2026-02-08 16:22:33 +09:00
|
|
|
}
|
2026-04-18 14:13:51 +09:00
|
|
|
return left.distance - right.distance;
|
2026-02-08 16:22:33 +09:00
|
|
|
});
|
|
|
|
|
|
2026-05-17 01:53:24 +09:00
|
|
|
cache?.set(cacheKey, candidates);
|
2026-04-18 14:13:51 +09:00
|
|
|
|
2026-02-08 16:22:33 +09:00
|
|
|
return candidates;
|
|
|
|
|
}
|