feat(rules): add shared rules-core package
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./src/index";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@oh-my-opencode/rules-core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "Pure TypeScript rule discovery, matching, and nested AGENTS.md context utilities for oh-my-opencode.",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||||
|
"test": "bun test src/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { existsSync, statSync } from "node:fs";
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||||
|
import { AGENTS_FILENAME } from "./constants";
|
||||||
|
import type { AgentsMdCache } from "./types";
|
||||||
|
|
||||||
|
export interface FindAgentsMdUpInput {
|
||||||
|
readonly startDir: string;
|
||||||
|
readonly rootDir: string;
|
||||||
|
readonly skipRoot?: boolean;
|
||||||
|
readonly cache?: AgentsMdCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findAgentsMdUp(input: FindAgentsMdUpInput): Promise<string[]> {
|
||||||
|
const startDir = resolve(input.startDir);
|
||||||
|
const rootDir = resolve(input.rootDir);
|
||||||
|
const skipRoot = input.skipRoot ?? true;
|
||||||
|
const cacheKey = [startDir, rootDir, skipRoot ? "1" : "0"].join("\0");
|
||||||
|
const cached = input.cache?.get(cacheKey);
|
||||||
|
if (cached) return [...cached];
|
||||||
|
const found: string[] = [];
|
||||||
|
let current = startDir;
|
||||||
|
while (true) {
|
||||||
|
const isRootDir = current === rootDir;
|
||||||
|
if (!(skipRoot && isRootDir)) {
|
||||||
|
const agentsPath = join(current, AGENTS_FILENAME);
|
||||||
|
if (isFile(agentsPath)) found.push(agentsPath);
|
||||||
|
}
|
||||||
|
if (isRootDir) break;
|
||||||
|
const parent = dirname(current);
|
||||||
|
if (parent === current || !isSameOrChildPath(parent, rootDir)) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
const result = found.reverse();
|
||||||
|
input.cache?.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFile(path: string): boolean {
|
||||||
|
if (!existsSync(path)) return false;
|
||||||
|
try {
|
||||||
|
return statSync(path).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||||
|
const relativePath = relative(parentPath, childPath);
|
||||||
|
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { AgentsMdCache, DirectoryScanEntry, RuleFileCandidate, RuleScanCache, RuleScanCacheStats } from "./types";
|
||||||
|
|
||||||
|
export function createRuleScanCache(): RuleScanCache {
|
||||||
|
const candidateCache = new Map<string, readonly RuleFileCandidate[]>();
|
||||||
|
const directoryCache = new Map<string, readonly DirectoryScanEntry[]>();
|
||||||
|
return {
|
||||||
|
get: (key) => candidateCache.get(key),
|
||||||
|
set: (key, value) => candidateCache.set(key, value),
|
||||||
|
getDirScan: (dir) => directoryCache.get(dir),
|
||||||
|
setDirScan: (dir, entries) => directoryCache.set(dir, entries),
|
||||||
|
stats: (): RuleScanCacheStats => ({ candidateEntries: candidateCache.size, directoryEntries: directoryCache.size }),
|
||||||
|
clear: () => {
|
||||||
|
candidateCache.clear();
|
||||||
|
directoryCache.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAgentsMdCache(): AgentsMdCache {
|
||||||
|
const cache = new Map<string, readonly string[]>();
|
||||||
|
return {
|
||||||
|
get: (key) => cache.get(key),
|
||||||
|
set: (key, value) => cache.set(key, value),
|
||||||
|
clear: () => cache.clear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { RuleSource } from "./types";
|
||||||
|
|
||||||
|
export const PROJECT_MARKERS = [".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod", ".venv"] as const;
|
||||||
|
|
||||||
|
export const PROJECT_RULE_SUBDIRS = [
|
||||||
|
[".omo", "rules"],
|
||||||
|
[".sisyphus", "rules"],
|
||||||
|
[".claude", "rules"],
|
||||||
|
[".cursor", "rules"],
|
||||||
|
[".github", "instructions"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PROJECT_RULE_FILES = [".github/copilot-instructions.md"] as const;
|
||||||
|
export const OPENCODE_USER_RULE_DIRS = [".omo/rules", ".sisyphus/rules", ".opencode/rules"] as const;
|
||||||
|
export const USER_RULE_DIR = ".claude/rules";
|
||||||
|
export const RULE_EXTENSIONS = [".md", ".mdc"] as const;
|
||||||
|
export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/;
|
||||||
|
export const AGENTS_FILENAME = "AGENTS.md";
|
||||||
|
export const GLOBAL_DISTANCE = 9999;
|
||||||
|
export const EXCLUDED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", ".next", "coverage"]);
|
||||||
|
|
||||||
|
export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
|
||||||
|
[".omo/rules", 0],
|
||||||
|
[".sisyphus/rules", 1],
|
||||||
|
[".claude/rules", 2],
|
||||||
|
[".cursor/rules", 3],
|
||||||
|
[".github/instructions", 4],
|
||||||
|
[".github/copilot-instructions.md", 5],
|
||||||
|
["~/.omo/rules", 100],
|
||||||
|
["~/.sisyphus/rules", 101],
|
||||||
|
["~/.opencode/rules", 102],
|
||||||
|
["~/.claude/rules", 103],
|
||||||
|
]);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { dirname, relative } from "node:path";
|
||||||
|
import { GLOBAL_DISTANCE } from "./constants";
|
||||||
|
|
||||||
|
export function calculateDistance(rulePath: string, currentFile: string, projectRoot: string | null): number {
|
||||||
|
if (!projectRoot) return GLOBAL_DISTANCE;
|
||||||
|
try {
|
||||||
|
const ruleRelative = relative(projectRoot, dirname(rulePath));
|
||||||
|
const currentRelative = relative(projectRoot, dirname(currentFile));
|
||||||
|
if (ruleRelative.startsWith("..") || currentRelative.startsWith("..")) return GLOBAL_DISTANCE;
|
||||||
|
const ruleParts = toParts(ruleRelative);
|
||||||
|
const currentParts = toParts(currentRelative);
|
||||||
|
let shared = 0;
|
||||||
|
for (let index = 0; index < Math.min(ruleParts.length, currentParts.length); index += 1) {
|
||||||
|
if (ruleParts[index] !== currentParts[index]) break;
|
||||||
|
shared += 1;
|
||||||
|
}
|
||||||
|
return currentParts.length - shared;
|
||||||
|
} catch {
|
||||||
|
return GLOBAL_DISTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toParts(path: string): string[] {
|
||||||
|
return path.split(/[/\\]/).filter(Boolean);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { existsSync, statSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||||
|
import { GLOBAL_DISTANCE, OPENCODE_USER_RULE_DIRS, PROJECT_RULE_FILES, PROJECT_RULE_SUBDIRS, USER_RULE_DIR } from "./constants";
|
||||||
|
import { sortCandidates } from "./ordering";
|
||||||
|
import { findRuleFilesRecursive, safeRealpathSync } from "./scanner";
|
||||||
|
import type { DirectoryScanEntry, FindRuleFilesOptions, RuleFileCandidate, RuleScanCache, RuleSource } from "./types";
|
||||||
|
|
||||||
|
export function findRuleFiles(
|
||||||
|
projectRoot: string | null,
|
||||||
|
homeDir: string,
|
||||||
|
currentFile: string,
|
||||||
|
options?: FindRuleFilesOptions,
|
||||||
|
cache?: RuleScanCache,
|
||||||
|
): RuleFileCandidate[] {
|
||||||
|
const startDir = dirname(resolve(currentFile));
|
||||||
|
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
|
||||||
|
const cacheKey = [projectRoot ?? "", startDir, skipClaudeUserRules ? "1" : "0"].join("\0");
|
||||||
|
const cached = cache?.get(cacheKey);
|
||||||
|
if (cached) return [...cached];
|
||||||
|
const candidates: RuleFileCandidate[] = [];
|
||||||
|
const seenRealPaths = new Set<string>();
|
||||||
|
if (projectRoot) {
|
||||||
|
addProjectRuleCandidates(projectRoot, startDir, candidates, seenRealPaths, cache);
|
||||||
|
addProjectSingleFileCandidates(projectRoot, candidates, seenRealPaths);
|
||||||
|
}
|
||||||
|
addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache);
|
||||||
|
const sorted = sortCandidates(candidates);
|
||||||
|
cache?.set(cacheKey, sorted);
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProjectRuleCandidates(
|
||||||
|
projectRoot: string,
|
||||||
|
startDir: string,
|
||||||
|
candidates: RuleFileCandidate[],
|
||||||
|
seenRealPaths: Set<string>,
|
||||||
|
cache: RuleScanCache | undefined,
|
||||||
|
): void {
|
||||||
|
let currentDir = startDir;
|
||||||
|
let distance = 0;
|
||||||
|
while (true) {
|
||||||
|
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||||
|
const source = `${parent}/${subdir}` as RuleSource;
|
||||||
|
const ruleDir = join(currentDir, parent, subdir);
|
||||||
|
for (const entry of scanDirectoryWithCache(ruleDir, cache)) {
|
||||||
|
if (seenRealPaths.has(entry.realPath)) continue;
|
||||||
|
seenRealPaths.add(entry.realPath);
|
||||||
|
candidates.push({
|
||||||
|
path: entry.path,
|
||||||
|
realPath: entry.realPath,
|
||||||
|
source,
|
||||||
|
isGlobal: false,
|
||||||
|
distance,
|
||||||
|
relativePath: normalizePath(relative(projectRoot, entry.path)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentDir === projectRoot) break;
|
||||||
|
const parentDir = dirname(currentDir);
|
||||||
|
if (parentDir === currentDir || !isSameOrChildPath(parentDir, projectRoot)) break;
|
||||||
|
currentDir = parentDir;
|
||||||
|
distance += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProjectSingleFileCandidates(
|
||||||
|
projectRoot: string,
|
||||||
|
candidates: RuleFileCandidate[],
|
||||||
|
seenRealPaths: Set<string>,
|
||||||
|
): void {
|
||||||
|
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||||
|
const filePath = join(projectRoot, ruleFile);
|
||||||
|
const realPath = validFileRealPath(filePath);
|
||||||
|
if (realPath === null || seenRealPaths.has(realPath)) continue;
|
||||||
|
seenRealPaths.add(realPath);
|
||||||
|
candidates.push({
|
||||||
|
path: filePath,
|
||||||
|
realPath,
|
||||||
|
source: ruleFile as RuleSource,
|
||||||
|
isGlobal: false,
|
||||||
|
distance: 0,
|
||||||
|
isSingleFile: true,
|
||||||
|
relativePath: normalizePath(ruleFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUserRuleCandidates(
|
||||||
|
homeDir: string,
|
||||||
|
skipClaudeUserRules: boolean,
|
||||||
|
candidates: RuleFileCandidate[],
|
||||||
|
seenRealPaths: Set<string>,
|
||||||
|
cache: RuleScanCache | undefined,
|
||||||
|
): void {
|
||||||
|
const userRuleDirs: Array<readonly [string, RuleSource]> = OPENCODE_USER_RULE_DIRS.map((dir) => [join(homeDir, dir), `~/${dir}` as RuleSource]);
|
||||||
|
if (!skipClaudeUserRules) userRuleDirs.push([join(homeDir, USER_RULE_DIR), "~/.claude/rules"]);
|
||||||
|
for (const [userRuleDir, source] of userRuleDirs) {
|
||||||
|
for (const entry of scanDirectoryWithCache(userRuleDir, cache)) {
|
||||||
|
if (seenRealPaths.has(entry.realPath)) continue;
|
||||||
|
seenRealPaths.add(entry.realPath);
|
||||||
|
candidates.push({
|
||||||
|
path: entry.path,
|
||||||
|
realPath: entry.realPath,
|
||||||
|
source,
|
||||||
|
isGlobal: true,
|
||||||
|
distance: GLOBAL_DISTANCE,
|
||||||
|
relativePath: normalizePath(relative(homeDir, entry.path)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanDirectoryWithCache(dir: string, cache: RuleScanCache | undefined): readonly DirectoryScanEntry[] {
|
||||||
|
const cached = cache?.getDirScan(dir);
|
||||||
|
if (cached) return cached;
|
||||||
|
const entries: DirectoryScanEntry[] = [];
|
||||||
|
findRuleFilesRecursive(dir, entries);
|
||||||
|
cache?.setDirScan(dir, entries);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validFileRealPath(filePath: string): string | null {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
try {
|
||||||
|
if (!statSync(filePath).isFile()) return null;
|
||||||
|
return safeRealpathSync(filePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||||
|
const relativePath = relative(parentPath, childPath);
|
||||||
|
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.replaceAll("\\", "/");
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearProjectRootCache,
|
||||||
|
createAgentsMdCache,
|
||||||
|
createRuleScanCache,
|
||||||
|
findAgentsMdUp,
|
||||||
|
findProjectRoot,
|
||||||
|
findRuleFiles,
|
||||||
|
parseRuleFrontmatter,
|
||||||
|
shouldApplyRule,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
|
let testRoot: string | null = null;
|
||||||
|
|
||||||
|
function createTestRoot(name: string): string {
|
||||||
|
testRoot = join(tmpdir(), `${name}-${Date.now()}-${Math.random()}`);
|
||||||
|
mkdirSync(testRoot, { recursive: true });
|
||||||
|
return testRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (testRoot) {
|
||||||
|
rmSync(testRoot, { recursive: true, force: true });
|
||||||
|
testRoot = null;
|
||||||
|
}
|
||||||
|
clearProjectRootCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rules-core", () => {
|
||||||
|
it("#given mixed rule sources #when finding rule files #then returns deterministic source-priority order", () => {
|
||||||
|
// given
|
||||||
|
const root = createTestRoot("rules-core-order");
|
||||||
|
mkdirSync(join(root, ".git"));
|
||||||
|
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
|
||||||
|
mkdirSync(join(root, ".sisyphus", "rules"), { recursive: true });
|
||||||
|
mkdirSync(join(root, ".claude", "rules"), { recursive: true });
|
||||||
|
mkdirSync(join(root, ".cursor", "rules"), { recursive: true });
|
||||||
|
mkdirSync(join(root, ".github", "instructions"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "src"), { recursive: true });
|
||||||
|
writeFileSync(join(root, ".github", "copilot-instructions.md"), "copilot");
|
||||||
|
writeFileSync(join(root, ".omo", "rules", "omo.md"), "omo");
|
||||||
|
writeFileSync(join(root, ".sisyphus", "rules", "sisyphus.md"), "sisyphus");
|
||||||
|
writeFileSync(join(root, ".claude", "rules", "claude.md"), "claude");
|
||||||
|
writeFileSync(join(root, ".cursor", "rules", "cursor.md"), "cursor");
|
||||||
|
writeFileSync(join(root, ".github", "instructions", "github.instructions.md"), "github");
|
||||||
|
|
||||||
|
// when
|
||||||
|
const found = findRuleFiles(root, root, join(root, "src", "index.ts"));
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(found.map((rule) => rule.relativePath)).toEqual([
|
||||||
|
".github/copilot-instructions.md",
|
||||||
|
".omo/rules/omo.md",
|
||||||
|
".sisyphus/rules/sisyphus.md",
|
||||||
|
".claude/rules/claude.md",
|
||||||
|
".cursor/rules/cursor.md",
|
||||||
|
".github/instructions/github.instructions.md",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given frontmatter aliases and negative glob #when matching #then honors applyTo paths and exclusions", () => {
|
||||||
|
// given
|
||||||
|
const { metadata } = parseRuleFrontmatter(`---\npaths: ["src/**/*.ts"]\napplyTo:\n - "!src/**/*.test.ts"\n---\nRule\n`);
|
||||||
|
|
||||||
|
// when
|
||||||
|
const sourceMatch = shouldApplyRule(metadata, "/repo/src/index.ts", "/repo");
|
||||||
|
const testMatch = shouldApplyRule(metadata, "/repo/src/index.test.ts", "/repo");
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(sourceMatch).toEqual({ applies: true, reason: "glob: src/**/*.ts" });
|
||||||
|
expect(testMatch).toEqual({ applies: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given nested AGENTS.md files #when walking up with root skip #then returns parent-to-child non-root files", async () => {
|
||||||
|
// given
|
||||||
|
const root = createTestRoot("rules-core-agents");
|
||||||
|
mkdirSync(join(root, ".git"));
|
||||||
|
mkdirSync(join(root, "packages", "app", "src"), { recursive: true });
|
||||||
|
writeFileSync(join(root, "AGENTS.md"), "root");
|
||||||
|
writeFileSync(join(root, "packages", "AGENTS.md"), "packages");
|
||||||
|
writeFileSync(join(root, "packages", "app", "AGENTS.md"), "app");
|
||||||
|
|
||||||
|
// when
|
||||||
|
const found = await findAgentsMdUp({
|
||||||
|
startDir: join(root, "packages", "app", "src"),
|
||||||
|
rootDir: root,
|
||||||
|
cache: createAgentsMdCache(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(found).toEqual([
|
||||||
|
join(root, "packages", "AGENTS.md"),
|
||||||
|
join(root, "packages", "app", "AGENTS.md"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given repeated same-directory targets #when using scan caches #then reuses cached candidates", () => {
|
||||||
|
// given
|
||||||
|
const root = createTestRoot("rules-core-cache");
|
||||||
|
mkdirSync(join(root, ".git"));
|
||||||
|
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "src"), { recursive: true });
|
||||||
|
writeFileSync(join(root, ".omo", "rules", "typescript.md"), "typescript");
|
||||||
|
const cache = createRuleScanCache();
|
||||||
|
|
||||||
|
// when
|
||||||
|
const first = findRuleFiles(root, root, join(root, "src", "a.ts"), undefined, cache);
|
||||||
|
const second = findRuleFiles(root, root, join(root, "src", "b.ts"), undefined, cache);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(cache.stats()).toEqual({ candidateEntries: 1, directoryEntries: 11 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given nested project markers #when finding project root #then memoizes ancestor lookups", () => {
|
||||||
|
// given
|
||||||
|
const root = createTestRoot("rules-core-project-root");
|
||||||
|
mkdirSync(join(root, ".git"));
|
||||||
|
mkdirSync(join(root, "a", "b", "c"), { recursive: true });
|
||||||
|
|
||||||
|
// when
|
||||||
|
const first = findProjectRoot(join(root, "a", "b", "c", "file.ts"));
|
||||||
|
const second = findProjectRoot(join(root, "a", "b", "other.ts"));
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(first).toBe(root);
|
||||||
|
expect(second).toBe(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export { createAgentsMdCache, createRuleScanCache } from "./cache";
|
||||||
|
export { findAgentsMdUp, type FindAgentsMdUpInput } from "./agents-md";
|
||||||
|
export { findRuleFiles } from "./finder";
|
||||||
|
export { parseRuleFrontmatter } from "./parser";
|
||||||
|
export { shouldApplyRule, createContentHash, isDuplicateByContentHash, isDuplicateByRealPath, resetMatcherCache, getMatcherCacheStats } from "./matcher";
|
||||||
|
export { findProjectRoot, clearProjectRootCache } from "./project-root";
|
||||||
|
export { calculateDistance } from "./distance";
|
||||||
|
export { findRuleFilesRecursive, safeRealpathSync } from "./scanner";
|
||||||
|
export type {
|
||||||
|
AgentsMdCache,
|
||||||
|
DirectoryScanEntry,
|
||||||
|
FindRuleFilesOptions,
|
||||||
|
MatchResult,
|
||||||
|
RuleFileCandidate,
|
||||||
|
RuleFrontmatterResult,
|
||||||
|
RuleMetadata,
|
||||||
|
RuleScanCache,
|
||||||
|
RuleScanCacheStats,
|
||||||
|
RuleSource,
|
||||||
|
} from "./types";
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { basename, relative } from "node:path";
|
||||||
|
import picomatch from "picomatch";
|
||||||
|
import type { MatchResult, RuleMetadata } from "./types";
|
||||||
|
|
||||||
|
const matcherCache = new Map<string, (path: string) => boolean>();
|
||||||
|
const MAX_MATCHER_CACHE_ENTRIES = 256;
|
||||||
|
const PICOMATCH_OPTIONS = { dot: true, bash: true } as const;
|
||||||
|
|
||||||
|
export function resetMatcherCache(): void {
|
||||||
|
matcherCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatcherCacheStats(): { readonly entries: number } {
|
||||||
|
return { entries: matcherCache.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldApplyRule(metadata: RuleMetadata, currentFilePath: string, projectRoot: string | null): MatchResult {
|
||||||
|
if (metadata.alwaysApply === true) return { applies: true, reason: "alwaysApply" };
|
||||||
|
const patterns = normalizeGlobs(metadata);
|
||||||
|
if (patterns.length === 0) return { applies: false };
|
||||||
|
const pathBases = [
|
||||||
|
toPosix(projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath),
|
||||||
|
toPosix(basename(currentFilePath)),
|
||||||
|
];
|
||||||
|
const negativeMatchers = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => matcherFor(pattern.slice(1)));
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.startsWith("!")) continue;
|
||||||
|
const isMatch = matcherFor(pattern);
|
||||||
|
if (!pathBases.some((pathBase) => isMatch(pathBase))) continue;
|
||||||
|
if (pathBases.some((pathBase) => negativeMatchers.some((isExcluded) => isExcluded(pathBase)))) return { applies: false };
|
||||||
|
return { applies: true, reason: `glob: ${pattern}` };
|
||||||
|
}
|
||||||
|
return { applies: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentHash(content: string): string {
|
||||||
|
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDuplicateByRealPath(realPath: string, cache: ReadonlySet<string>): boolean {
|
||||||
|
return cache.has(realPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDuplicateByContentHash(hash: string, cache: ReadonlySet<string>): boolean {
|
||||||
|
return cache.has(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobs(metadata: RuleMetadata): string[] {
|
||||||
|
const patterns = [...normalizePatternList(metadata.globs), ...normalizePatternList(metadata.paths), ...normalizePatternList(metadata.applyTo)];
|
||||||
|
return [...new Set(patterns.map(toPosix))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePatternList(patterns: string | readonly string[] | undefined): string[] {
|
||||||
|
if (patterns === undefined) return [];
|
||||||
|
return typeof patterns === "string" ? [patterns] : [...patterns];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matcherFor(pattern: string): (path: string) => boolean {
|
||||||
|
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 oldest = matcherCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) matcherCache.delete(oldest);
|
||||||
|
}
|
||||||
|
matcherCache.set(pattern, matcher);
|
||||||
|
return matcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPosix(path: string): string {
|
||||||
|
return path.replaceAll("\\", "/");
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { SOURCE_PRIORITY } from "./constants";
|
||||||
|
import type { RuleFileCandidate } from "./types";
|
||||||
|
|
||||||
|
export function sortCandidates<T extends RuleFileCandidate>(candidates: readonly T[]): T[] {
|
||||||
|
return candidates
|
||||||
|
.map((candidate, index) => ({ candidate, index }))
|
||||||
|
.sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index)
|
||||||
|
.map(({ candidate }) => candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCandidates(left: RuleFileCandidate, right: RuleFileCandidate): number {
|
||||||
|
return (
|
||||||
|
Number(left.isGlobal) - Number(right.isGlobal) ||
|
||||||
|
left.distance - right.distance ||
|
||||||
|
(SOURCE_PRIORITY.get(left.source) ?? Number.POSITIVE_INFINITY) -
|
||||||
|
(SOURCE_PRIORITY.get(right.source) ?? Number.POSITIVE_INFINITY) ||
|
||||||
|
compareString(left.relativePath, right.relativePath) ||
|
||||||
|
compareString(left.realPath, right.realPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareString(left: string, right: string): number {
|
||||||
|
if (left < right) return -1;
|
||||||
|
if (left > right) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { RuleFrontmatterResult, RuleMetadata } from "./types";
|
||||||
|
|
||||||
|
type GlobValue = string | readonly string[];
|
||||||
|
|
||||||
|
type ParsedGlobValue = {
|
||||||
|
readonly value: GlobValue;
|
||||||
|
readonly consumed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseRuleFrontmatter(content: string): RuleFrontmatterResult {
|
||||||
|
const normalized = stripBom(content);
|
||||||
|
const openingLength = openingDelimiterLength(normalized);
|
||||||
|
if (openingLength === 0) return { metadata: {}, body: normalized };
|
||||||
|
const closing = findClosingDelimiter(normalized, openingLength);
|
||||||
|
if (!closing) return { metadata: {}, body: normalized };
|
||||||
|
try {
|
||||||
|
return { metadata: parseYaml(normalized.slice(openingLength, closing.start)), body: normalized.slice(closing.bodyStart) };
|
||||||
|
} catch {
|
||||||
|
return { metadata: {}, body: normalized };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYaml(yaml: string): RuleMetadata {
|
||||||
|
const lines = yaml.replace(/\r\n/g, "\n").split("\n");
|
||||||
|
const metadata: { description?: string; alwaysApply?: boolean; globs?: string | string[] } = {};
|
||||||
|
let index = 0;
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = stripComment(lines[index] ?? "").trim();
|
||||||
|
if (!line) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const colon = line.indexOf(":");
|
||||||
|
if (colon === -1) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const rawValue = line.slice(colon + 1).trim();
|
||||||
|
if (key === "description") metadata.description = parseString(rawValue);
|
||||||
|
else if (key === "alwaysApply") metadata.alwaysApply = rawValue === "true";
|
||||||
|
else if (key === "globs" || key === "paths" || key === "applyTo") {
|
||||||
|
const parsed = parseGlobValue(rawValue, lines, index);
|
||||||
|
metadata.globs = mergeGlobs(metadata.globs, parsed.value);
|
||||||
|
index += parsed.consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGlobValue(rawValue: string, lines: readonly string[], currentIndex: number): ParsedGlobValue {
|
||||||
|
if (rawValue.startsWith("[")) return { value: parseInlineArray(rawValue), consumed: 1 };
|
||||||
|
if (!rawValue) {
|
||||||
|
const parsed = parseMultilineArray(lines, currentIndex);
|
||||||
|
return parsed.values.length > 0 ? { value: parsed.values, consumed: parsed.consumed } : { value: "", consumed: 1 };
|
||||||
|
}
|
||||||
|
const value = parseString(rawValue);
|
||||||
|
if (value.includes(",")) return { value: value.split(",").map((item) => item.trim()).filter(Boolean), consumed: 1 };
|
||||||
|
return { value, consumed: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMultilineArray(lines: readonly string[], currentIndex: number): { readonly values: readonly string[]; readonly consumed: number } {
|
||||||
|
const values: string[] = [];
|
||||||
|
let consumed = 1;
|
||||||
|
for (let index = currentIndex + 1; index < lines.length; index += 1) {
|
||||||
|
const line = stripComment(lines[index] ?? "");
|
||||||
|
if (line.trim().length === 0) {
|
||||||
|
consumed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const item = line.match(/^\s+-\s*(.*)$/);
|
||||||
|
if (!item) break;
|
||||||
|
const value = parseString(item[1] ?? "");
|
||||||
|
if (value) values.push(value);
|
||||||
|
consumed += 1;
|
||||||
|
}
|
||||||
|
return { values, consumed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineArray(value: string): string[] {
|
||||||
|
const closing = value.lastIndexOf("]");
|
||||||
|
if (closing === -1) return [];
|
||||||
|
return splitCommaSeparated(value.slice(1, closing)).map(parseString).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeGlobs(existing: string | string[] | undefined, next: GlobValue): string | string[] {
|
||||||
|
if (Array.isArray(next) && next.length === 0) return existing ?? [];
|
||||||
|
if (!Array.isArray(next) && next.length === 0) return existing ?? "";
|
||||||
|
if (existing === undefined) {
|
||||||
|
if (typeof next === "string") return next;
|
||||||
|
return [...next];
|
||||||
|
}
|
||||||
|
const existingValues = Array.isArray(existing) ? existing : [existing];
|
||||||
|
const nextValues = typeof next === "string" ? [next] : [...next];
|
||||||
|
return [...existingValues, ...nextValues];
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCommaSeparated(value: string): string[] {
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let quote: string | null = null;
|
||||||
|
let escaped = false;
|
||||||
|
for (const character of value) {
|
||||||
|
if (escaped) {
|
||||||
|
current += character;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote && character === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '"' || character === "'") {
|
||||||
|
if (!quote) quote = character;
|
||||||
|
else if (quote === character) quote = null;
|
||||||
|
current += character;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!quote && character === ",") {
|
||||||
|
values.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += character;
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseString(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripComment(line: string): string {
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const character = line[index];
|
||||||
|
if (character === '"' || character === "'") {
|
||||||
|
if (!quote) quote = character;
|
||||||
|
else if (quote === character) quote = null;
|
||||||
|
}
|
||||||
|
if (!quote && character === "#") return line.slice(0, index);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripBom(content: string): string {
|
||||||
|
return content.startsWith("\uFEFF") ? content.slice(1) : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openingDelimiterLength(content: string): number {
|
||||||
|
if (content.startsWith("---\r\n")) return 5;
|
||||||
|
if (content.startsWith("---\n")) return 4;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosingDelimiter(content: string, openingLength: number): { readonly start: number; readonly bodyStart: number } | null {
|
||||||
|
let lineStart = openingLength;
|
||||||
|
while (lineStart <= content.length) {
|
||||||
|
const nextNewline = content.indexOf("\n", lineStart);
|
||||||
|
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
||||||
|
if (content.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") {
|
||||||
|
return { start: lineStart, bodyStart: nextNewline === -1 ? content.length : nextNewline + 1 };
|
||||||
|
}
|
||||||
|
if (nextNewline === -1) break;
|
||||||
|
lineStart = nextNewline + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findProjectRoot(startPath: string): string | null {
|
||||||
|
const cached = projectRootCache.get(startPath);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const startDir = resolveStartDir(startPath);
|
||||||
|
const cachedStartDir = projectRootCache.get(startDir);
|
||||||
|
if (cachedStartDir !== undefined) {
|
||||||
|
projectRootCache.set(startPath, cachedStartDir);
|
||||||
|
return cachedStartDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of visited) projectRootCache.set(directory, resolved);
|
||||||
|
projectRootCache.set(startPath, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStartDir(startPath: string): string {
|
||||||
|
try {
|
||||||
|
return statSync(startPath).isDirectory() ? startPath : dirname(startPath);
|
||||||
|
} catch {
|
||||||
|
return dirname(startPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProjectMarker(directory: string): boolean {
|
||||||
|
return PROJECT_MARKERS.some((marker) => existsSync(join(directory, marker)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { EXCLUDED_DIRS, GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
|
||||||
|
import type { DirectoryScanEntry } from "./types";
|
||||||
|
|
||||||
|
function isGitHubInstructionsDir(dir: string): boolean {
|
||||||
|
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRuleFile(fileName: string, dir: string): boolean {
|
||||||
|
if (isGitHubInstructionsDir(dir)) return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
|
||||||
|
return RULE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeRealpathSync(filePath: string): string {
|
||||||
|
try {
|
||||||
|
return realpathSync.native(filePath);
|
||||||
|
} catch {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRuleFilesRecursive(dir: string, results: DirectoryScanEntry[], visited = new Set<string>()): void {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
const realDir = safeRealpathSync(dir);
|
||||||
|
if (visited.has(realDir)) return;
|
||||||
|
visited.add(realDir);
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }).sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!EXCLUDED_DIRS.has(entry.name)) findRuleFilesRecursive(fullPath, results, visited);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isFile() && isRuleFile(entry.name, dir)) {
|
||||||
|
results.push({ path: fullPath, realPath: safeRealpathSync(fullPath), relativePath: entry.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export interface RuleMetadata {
|
||||||
|
readonly description?: string;
|
||||||
|
readonly globs?: string | readonly string[];
|
||||||
|
readonly paths?: string | readonly string[];
|
||||||
|
readonly applyTo?: string | readonly string[];
|
||||||
|
readonly alwaysApply?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleFrontmatterResult {
|
||||||
|
readonly metadata: RuleMetadata;
|
||||||
|
readonly body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleFileCandidate {
|
||||||
|
readonly path: string;
|
||||||
|
readonly realPath: string;
|
||||||
|
readonly isGlobal: boolean;
|
||||||
|
readonly distance: number;
|
||||||
|
readonly relativePath: string;
|
||||||
|
readonly source: RuleSource;
|
||||||
|
readonly isSingleFile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleSource =
|
||||||
|
| ".omo/rules"
|
||||||
|
| ".sisyphus/rules"
|
||||||
|
| ".claude/rules"
|
||||||
|
| ".cursor/rules"
|
||||||
|
| ".github/instructions"
|
||||||
|
| ".github/copilot-instructions.md"
|
||||||
|
| "~/.omo/rules"
|
||||||
|
| "~/.sisyphus/rules"
|
||||||
|
| "~/.opencode/rules"
|
||||||
|
| "~/.claude/rules";
|
||||||
|
|
||||||
|
export interface MatchResult {
|
||||||
|
readonly applies: boolean;
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryScanEntry {
|
||||||
|
readonly path: string;
|
||||||
|
readonly realPath: string;
|
||||||
|
readonly relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleScanCacheStats {
|
||||||
|
readonly candidateEntries: number;
|
||||||
|
readonly directoryEntries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleScanCache {
|
||||||
|
get(key: string): readonly RuleFileCandidate[] | undefined;
|
||||||
|
set(key: string, value: readonly RuleFileCandidate[]): void;
|
||||||
|
getDirScan(dir: string): readonly DirectoryScanEntry[] | undefined;
|
||||||
|
setDirScan(dir: string, entries: readonly DirectoryScanEntry[]): void;
|
||||||
|
stats(): RuleScanCacheStats;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindRuleFilesOptions {
|
||||||
|
readonly skipClaudeUserRules?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentsMdCache {
|
||||||
|
get(key: string): readonly string[] | undefined;
|
||||||
|
set(key: string, value: readonly string[]): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user