fix(rules-injector): cache directory scan results per session
This commit is contained in:
@@ -1,51 +1,108 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname, join, sep } from "node:path";
|
||||
import {
|
||||
OPENCODE_USER_RULE_DIRS,
|
||||
PROJECT_RULE_FILES,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
USER_RULE_DIR,
|
||||
OPENCODE_USER_RULE_DIRS,
|
||||
} from "./constants";
|
||||
import type { RuleFileCandidate } from "./types";
|
||||
import type { RuleScanCache } from "./rule-scan-cache";
|
||||
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
|
||||
import type { RuleFileCandidate } from "./types";
|
||||
|
||||
export interface FindRuleFilesOptions {
|
||||
/**
|
||||
* When true, skip loading rules from ~/.claude/rules/.
|
||||
* Use when claude_code integration is disabled to prevent
|
||||
* Claude Code-specific instructions from leaking into non-Claude agents.
|
||||
*/
|
||||
skipClaudeUserRules?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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"}`;
|
||||
}
|
||||
|
||||
function createCachedCandidate(
|
||||
filePath: string,
|
||||
projectRoot: string | null,
|
||||
startDir: string,
|
||||
userRuleDirs: string[],
|
||||
): RuleFileCandidate | undefined {
|
||||
const realPath = safeRealpathSync(filePath);
|
||||
|
||||
for (const userRuleDir of userRuleDirs) {
|
||||
if (filePath.startsWith(`${userRuleDir}${sep}`)) {
|
||||
return { path: filePath, realPath, isGlobal: true, distance: 9999 };
|
||||
}
|
||||
}
|
||||
|
||||
if (projectRoot) {
|
||||
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||
if (filePath === join(projectRoot, ruleFile)) {
|
||||
return {
|
||||
path: filePath,
|
||||
realPath,
|
||||
isGlobal: false,
|
||||
distance: 0,
|
||||
isSingleFile: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let currentDir = startDir;
|
||||
let distance = 0;
|
||||
while (true) {
|
||||
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||
const ruleDir = join(currentDir, parent, subdir);
|
||||
if (filePath.startsWith(`${ruleDir}${sep}`)) {
|
||||
return { path: filePath, realPath, isGlobal: false, distance };
|
||||
}
|
||||
}
|
||||
|
||||
if (projectRoot && currentDir === projectRoot) break;
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 cachedPaths = cache?.get(cacheKey);
|
||||
|
||||
if (cachedPaths) {
|
||||
return cachedPaths
|
||||
.map((filePath) => createCachedCandidate(filePath, projectRoot, startDir, userRuleDirs))
|
||||
.filter((candidate): candidate is RuleFileCandidate => candidate !== undefined);
|
||||
}
|
||||
|
||||
const candidates: RuleFileCandidate[] = [];
|
||||
const seenRealPaths = new Set<string>();
|
||||
|
||||
// Search from current file's directory up to project root
|
||||
let currentDir = dirname(currentFile);
|
||||
let currentDir = startDir;
|
||||
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[] = [];
|
||||
@@ -55,60 +112,41 @@ export function findRuleFiles(
|
||||
const realPath = safeRealpathSync(filePath);
|
||||
if (seenRealPaths.has(realPath)) continue;
|
||||
seenRealPaths.add(realPath);
|
||||
|
||||
candidates.push({
|
||||
path: filePath,
|
||||
realPath,
|
||||
isGlobal: false,
|
||||
distance,
|
||||
});
|
||||
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++;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search user-level rule directories
|
||||
// Always search OpenCode-native dirs (~/.sisyphus/rules, ~/.opencode/rules)
|
||||
const userRuleDirs: string[] = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
|
||||
|
||||
// Only search ~/.claude/rules when claude_code integration is not disabled
|
||||
if (!options?.skipClaudeUserRules) {
|
||||
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
|
||||
}
|
||||
|
||||
for (const userRuleDir of userRuleDirs) {
|
||||
const userFiles: string[] = [];
|
||||
findRuleFilesRecursive(userRuleDir, userFiles);
|
||||
@@ -117,23 +155,21 @@ export function findRuleFiles(
|
||||
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
|
||||
});
|
||||
candidates.push({ path: filePath, realPath, isGlobal: true, distance: 9999 });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance (closest first, then global rules last)
|
||||
candidates.sort((a, b) => {
|
||||
if (a.isGlobal !== b.isGlobal) {
|
||||
return a.isGlobal ? 1 : -1;
|
||||
candidates.sort((left, right) => {
|
||||
if (left.isGlobal !== right.isGlobal) {
|
||||
return left.isGlobal ? 1 : -1;
|
||||
}
|
||||
return a.distance - b.distance;
|
||||
return left.distance - right.distance;
|
||||
});
|
||||
|
||||
cache?.set(
|
||||
cacheKey,
|
||||
candidates.map((candidate) => candidate.path),
|
||||
);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user