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:
YeonGyu-Kim
2026-05-18 21:19:06 +09:00
parent fb7d47f1b7
commit 4ea29e2c94
11 changed files with 65 additions and 775 deletions
+7 -32
View File
@@ -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 });
}
+7 -4
View File
@@ -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();
}
}
};
+24 -15
View File
@@ -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) {
+10 -97
View File
@@ -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;
}
+2 -211
View File
@@ -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
View File
@@ -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";
+2 -148
View File
@@ -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";
+6 -53
View File
@@ -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));
}
+2 -38
View File
@@ -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";
+3 -39
View File
@@ -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;
}