refactor(rules): delegate injectors to rules-core
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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<string[]> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<string, Set<string>>();
|
||||
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<string, unknown> | 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<string, Set<string>>;
|
||||
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) {
|
||||
|
||||
@@ -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<string, PathMatcher>()
|
||||
|
||||
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<string>): 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<string>): boolean {
|
||||
return cache.has(hash)
|
||||
readonly entries: number;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,85 +1 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { PROJECT_MARKERS } from "./constants";
|
||||
|
||||
const projectRootCache = new Map<string, string | null>();
|
||||
|
||||
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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string>();
|
||||
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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<string, RuleFileCandidate[]>();
|
||||
const directoryScanCache = new Map<string, DirectoryScanEntry[]>();
|
||||
|
||||
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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user