From 8d2e6bea620908fa4f0602f3563545df273da38e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 14:54:20 +0900 Subject: [PATCH] feat(omo-codex): add PLUGIN_BUNDLED rule source to codex-rules engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../plugin/components/rules/src/config.ts | 54 ++++- .../components/rules/src/rules/constants.ts | 6 + .../rules/src/rules/finder-cache.ts | 73 ++++++ .../rules/src/rules/finder-paths.ts | 47 ++++ .../rules/src/rules/finder-sources.ts | 50 ++++ .../components/rules/src/rules/finder.ts | 217 ++++-------------- .../components/rules/src/rules/formatter.ts | 34 ++- .../components/rules/src/rules/plugin-root.ts | 55 +++++ .../components/rules/src/rules/types.ts | 1 + .../rules/test/bundled-rules-priority.test.ts | 107 +++++++++ .../rules/test/bundled-rules.test.ts | 215 +++++++++++++++++ .../components/rules/test/finder.test.ts | 10 +- 12 files changed, 688 insertions(+), 181 deletions(-) create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts create mode 100644 packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts create mode 100644 packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts diff --git a/packages/omo-codex/plugin/components/rules/src/config.ts b/packages/omo-codex/plugin/components/rules/src/config.ts index 1ca7a6c77..146ba964c 100644 --- a/packages/omo-codex/plugin/components/rules/src/config.ts +++ b/packages/omo-codex/plugin/components/rules/src/config.ts @@ -2,10 +2,9 @@ import { SOURCE_PRIORITY } from "./rules/constants.js"; import { defaultConfig } from "./rules/engine.js"; import type { PiRulesConfig, RuleSource } from "./rules/types.js"; -const MODE_VALUES = new Set(["static", "dynamic", "both", "off"]); - export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig { const config = defaultConfig(); + const disableBundledRules = isTruthy(firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED")); config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED")); config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode; config.maxRuleChars = @@ -16,6 +15,7 @@ export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiR config.maxResultChars; config.enabledSources = parseEnabledSources( firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"), + disableBundledRules, ); return config; } @@ -38,7 +38,15 @@ function isTruthy(value: string | undefined): boolean { function parseMode(value: string | undefined): PiRulesConfig["mode"] | undefined { if (value === undefined) return undefined; const normalized = value.trim().toLowerCase(); - return MODE_VALUES.has(normalized as PiRulesConfig["mode"]) ? (normalized as PiRulesConfig["mode"]) : undefined; + switch (normalized) { + case "static": + case "dynamic": + case "both": + case "off": + return normalized; + default: + return undefined; + } } function parsePositiveInteger(value: string | undefined): number | undefined { @@ -47,19 +55,45 @@ function parsePositiveInteger(value: string | undefined): number | undefined { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; } -function parseEnabledSources(value: string | undefined): RuleSource[] | "auto" { +function parseEnabledSources(value: string | undefined, disableBundledRules: boolean): RuleSource[] | "auto" { if (value === undefined || value.trim().toLowerCase() === "auto") { - return "auto"; + return disableBundledRules ? sourcesWithoutBundledRules() : "auto"; } - const validSources = new Set(SOURCE_PRIORITY.keys()); const sources: RuleSource[] = []; for (const rawSource of value.split(",")) { - const source = rawSource.trim(); - if (!validSources.has(source as RuleSource)) { + const source = toRuleSource(rawSource.trim()); + if (source === null) { continue; } - sources.push(source as RuleSource); + sources.push(source); + } + const enabledSources = disableBundledRules ? sources.filter((source) => source !== "plugin-bundled") : sources; + return enabledSources.length > 0 || sources.length > 0 ? enabledSources : "auto"; +} + +function sourcesWithoutBundledRules(): RuleSource[] { + return [...SOURCE_PRIORITY.keys()].filter((source) => source !== "plugin-bundled"); +} + +function toRuleSource(value: string): RuleSource | null { + switch (value) { + case ".omo/rules": + case ".claude/rules": + case ".cursor/rules": + case ".github/instructions": + case ".github/copilot-instructions.md": + case "AGENTS.md": + case "CLAUDE.md": + case "CONTEXT.md": + case "plugin-bundled": + case "~/.omo/rules": + case "~/.opencode/rules": + case "~/.claude/rules": + case "~/.config/opencode/AGENTS.md": + case "~/.claude/CLAUDE.md": + return value; + default: + return null; } - return sources.length > 0 ? sources : "auto"; } diff --git a/packages/omo-codex/plugin/components/rules/src/rules/constants.ts b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts index d8eb3f047..38baef529 100644 --- a/packages/omo-codex/plugin/components/rules/src/rules/constants.ts +++ b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts @@ -45,6 +45,11 @@ export const USER_HOME_RULE_SUBDIRS: readonly string[] = [".omo/rules", ".openco */ export const USER_HOME_SINGLE_FILES: readonly string[] = [".config/opencode/AGENTS.md", ".claude/CLAUDE.md"]; +/** + * Bundled plugin rule directory relative to the rules component root. + */ +export const BUNDLED_RULE_SUBDIR = "bundled-rules"; + /** * File extensions accepted as rule files in scanned directories. */ @@ -67,6 +72,7 @@ export const SOURCE_PRIORITY: ReadonlyMap = new Map([ ["~/.claude/rules", 102], ["~/.config/opencode/AGENTS.md", 103], ["~/.claude/CLAUDE.md", 104], + ["plugin-bundled", 200], ]); /** diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts new file mode 100644 index 000000000..f9fe6af30 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder-cache.ts @@ -0,0 +1,73 @@ +import { existsSync, realpathSync, statSync } from "node:fs"; + +import { scanRuleFiles } from "./scanner.js"; + +type ScannedRuleFiles = ReturnType; + +interface SingleFileInfo { + readonly path: string; + readonly realPath: string; +} + +export interface RuleDiscoveryCache { + readonly scannedRuleFiles: Map; + readonly singleFileInfo: Map; +} + +export function createRuleDiscoveryCache(): RuleDiscoveryCache { + return { scannedRuleFiles: new Map(), singleFileInfo: new Map() }; +} + +export function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ScannedRuleFiles { + if (cache === undefined) { + return scanRuleFiles({ rootDir }); + } + + const cached = cache.scannedRuleFiles.get(rootDir); + if (cached !== undefined) { + return cached; + } + + const scannedFiles = scanRuleFiles({ rootDir }); + cache.scannedRuleFiles.set(rootDir, scannedFiles); + return scannedFiles; +} + +export function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null { + if (cache === undefined) { + return readSingleFileInfo(filePath); + } + + const cached = cache.singleFileInfo.get(filePath); + if (cached !== undefined) { + return cached; + } + + const fileInfo = readSingleFileInfo(filePath); + cache.singleFileInfo.set(filePath, fileInfo); + return fileInfo; +} + +function readSingleFileInfo(filePath: string): SingleFileInfo | null { + if (!existsSync(filePath)) { + return null; + } + + try { + if (!statSync(filePath).isFile()) { + return null; + } + + return { path: filePath, realPath: resolveRealPath(filePath) }; + } catch { + return null; + } +} + +function resolveRealPath(filePath: string): string { + try { + return realpathSync.native(filePath); + } catch { + return filePath; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts new file mode 100644 index 000000000..c1c54ff38 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder-paths.ts @@ -0,0 +1,47 @@ +import { dirname, posix, relative, resolve } from "node:path"; + +export interface WalkDirectory { + readonly directory: string; + readonly distance: number; +} + +export function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] { + if (targetFile === null) { + return [{ directory: projectRoot, distance: 0 }]; + } + + const startDirectory = dirname(resolve(targetFile)); + if (!isSameOrChildPath(startDirectory, projectRoot)) { + return [{ directory: projectRoot, distance: 0 }]; + } + + const walkDirectories: WalkDirectory[] = []; + let currentDirectory = startDirectory; + let distance = 0; + + while (true) { + walkDirectories.push({ directory: currentDirectory, distance }); + if (currentDirectory === projectRoot) { + break; + } + + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + break; + } + + currentDirectory = parentDirectory; + distance += 1; + } + + return walkDirectories; +} + +export function toRelativePath(rootDirectory: string, filePath: string): string { + return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/")); +} + +function isSameOrChildPath(childPath: string, parentPath: string): boolean { + const childRelativePath = relative(parentPath, childPath); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/")); +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts new file mode 100644 index 000000000..5277323bd --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder-sources.ts @@ -0,0 +1,50 @@ +import { UnsupportedRuleSourceError } from "./errors.js"; +import type { RuleSource } from "./types.js"; + +export function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource { + const source = `${parentDirectory}/${subDirectory}`; + switch (source) { + case ".omo/rules": + case ".claude/rules": + case ".cursor/rules": + case ".github/instructions": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`); + } +} + +export function toProjectSingleFileSource(ruleFile: string): RuleSource { + switch (ruleFile) { + case ".github/copilot-instructions.md": + case "AGENTS.md": + case "CLAUDE.md": + case "CONTEXT.md": + return ruleFile; + default: + throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`); + } +} + +export function toUserHomeRuleSource(ruleSubdir: string): RuleSource { + const source = `~/${ruleSubdir}`; + switch (source) { + case "~/.omo/rules": + case "~/.opencode/rules": + case "~/.claude/rules": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`); + } +} + +export function toUserHomeSingleFileSource(ruleFile: string): RuleSource { + const source = `~/${ruleFile}`; + switch (source) { + case "~/.config/opencode/AGENTS.md": + case "~/.claude/CLAUDE.md": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`); + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder.ts index ebd2f8363..b729751d1 100644 --- a/packages/omo-codex/plugin/components/rules/src/rules/finder.ts +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder.ts @@ -1,27 +1,27 @@ -import { existsSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, posix, relative, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { GLOBAL_DISTANCE, + BUNDLED_RULE_SUBDIR, PROJECT_RULE_SUBDIRS, PROJECT_SINGLE_FILES, USER_HOME_RULE_SUBDIRS, USER_HOME_SINGLE_FILES, } from "./constants.js"; -import { UnsupportedRuleSourceError } from "./errors.js"; -import { scanRuleFiles } from "./scanner.js"; -import type { RuleCandidate, RuleSource } from "./types.js"; +import { type RuleDiscoveryCache, scanRuleFilesCached, singleFileInfoCached } from "./finder-cache.js"; +import { getWalkDirectories, toRelativePath } from "./finder-paths.js"; +import { + toProjectRuleSource, + toProjectSingleFileSource, + toUserHomeRuleSource, + toUserHomeSingleFileSource, +} from "./finder-sources.js"; +import { resolvePluginRulesRoot } from "./plugin-root.js"; +import type { RuleCandidate } from "./types.js"; -interface SingleFileInfo { - path: string; - realPath: string; -} - -export interface RuleDiscoveryCache { - scannedRuleFiles: Map>; - singleFileInfo: Map; -} +export type { RuleDiscoveryCache } from "./finder-cache.js"; +export { createRuleDiscoveryCache } from "./finder-cache.js"; export interface FinderOptions { /** Project root absolute path (use findProjectRoot to get this). */ @@ -34,24 +34,19 @@ export interface FinderOptions { disabledSources?: ReadonlySet; /** Whether to skip user-home rules. Default: false. */ skipUserHome?: boolean; + /** Plugin root directory. Defaults to PLUGIN_ROOT env or this package root. */ + pluginRoot?: string; cache?: RuleDiscoveryCache; } -interface WalkDirectory { - directory: string; - distance: number; -} - -export function createRuleDiscoveryCache(): RuleDiscoveryCache { - return { scannedRuleFiles: new Map(), singleFileInfo: new Map() }; +interface PluginBundledFinderOptions { + readonly disabledSources?: ReadonlySet; + readonly cache?: RuleDiscoveryCache; + readonly pluginRoot?: string; } export function findRuleCandidates(options: FinderOptions): RuleCandidate[] { const skipUserHome = options.skipUserHome ?? false; - if (options.projectRoot === null && skipUserHome) { - return []; - } - const disabledSources = options.disabledSources ?? new Set(); const candidates: RuleCandidate[] = []; const homeDirectory = resolve(options.homeDir ?? homedir()); @@ -62,6 +57,13 @@ export function findRuleCandidates(options: FinderOptions): RuleCandidate[] { ); } + const pluginBundledOptions: PluginBundledFinderOptions = { + disabledSources, + ...(options.cache === undefined ? {} : { cache: options.cache }), + ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), + }; + candidates.push(...findPluginBundledCandidates(pluginBundledOptions)); + if (!skipUserHome) { candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache)); } @@ -69,6 +71,28 @@ export function findRuleCandidates(options: FinderOptions): RuleCandidate[] { return candidates; } +export function findPluginBundledCandidates(options: PluginBundledFinderOptions = {}): RuleCandidate[] { + if (options.disabledSources?.has("plugin-bundled") === true) { + return []; + } + + const pluginRoot = resolvePluginRulesRoot(options.pluginRoot); + const ruleDirectory = join(pluginRoot, BUNDLED_RULE_SUBDIR); + const candidates: RuleCandidate[] = []; + for (const scannedFile of scanRuleFilesCached(ruleDirectory, options.cache)) { + candidates.push({ + path: scannedFile.path, + realPath: scannedFile.realPath, + source: "plugin-bundled", + distance: GLOBAL_DISTANCE, + isGlobal: true, + isSingleFile: false, + relativePath: toRelativePath(pluginRoot, scannedFile.path), + }); + } + return candidates; +} + function findProjectCandidates( projectRoot: string, targetFile: string | null, @@ -181,146 +205,3 @@ function findUserHomeCandidates( return candidates; } - -function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ReturnType { - if (cache === undefined) { - return scanRuleFiles({ rootDir }); - } - - const cached = cache.scannedRuleFiles.get(rootDir); - if (cached !== undefined) { - return cached; - } - - const scannedFiles = scanRuleFiles({ rootDir }); - cache.scannedRuleFiles.set(rootDir, scannedFiles); - return scannedFiles; -} - -function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null { - if (cache === undefined) { - return readSingleFileInfo(filePath); - } - - const cached = cache.singleFileInfo.get(filePath); - if (cached !== undefined) { - return cached; - } - - const fileInfo = readSingleFileInfo(filePath); - cache.singleFileInfo.set(filePath, fileInfo); - return fileInfo; -} - -function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] { - if (targetFile === null) { - return [{ directory: projectRoot, distance: 0 }]; - } - - const startDirectory = dirname(resolve(targetFile)); - if (!isSameOrChildPath(startDirectory, projectRoot)) { - return [{ directory: projectRoot, distance: 0 }]; - } - - const walkDirectories: WalkDirectory[] = []; - let currentDirectory = startDirectory; - let distance = 0; - - while (true) { - walkDirectories.push({ directory: currentDirectory, distance }); - if (currentDirectory === projectRoot) { - break; - } - - const parentDirectory = dirname(currentDirectory); - if (parentDirectory === currentDirectory) { - break; - } - - currentDirectory = parentDirectory; - distance += 1; - } - - return walkDirectories; -} - -function isSameOrChildPath(childPath: string, parentPath: string): boolean { - const childRelativePath = relative(parentPath, childPath); - return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/")); -} - -function readSingleFileInfo(filePath: string): SingleFileInfo | null { - if (!existsSync(filePath)) { - return null; - } - - try { - if (!statSync(filePath).isFile()) { - return null; - } - - return { path: filePath, realPath: resolveRealPath(filePath) }; - } catch { - return null; - } -} - -function resolveRealPath(filePath: string): string { - try { - return realpathSync.native(filePath); - } catch { - return filePath; - } -} - -function toRelativePath(rootDirectory: string, filePath: string): string { - return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/")); -} - -function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource { - const source = `${parentDirectory}/${subDirectory}`; - switch (source) { - case ".omo/rules": - case ".claude/rules": - case ".cursor/rules": - case ".github/instructions": - return source; - default: - throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`); - } -} - -function toProjectSingleFileSource(ruleFile: string): RuleSource { - switch (ruleFile) { - case ".github/copilot-instructions.md": - case "AGENTS.md": - case "CLAUDE.md": - case "CONTEXT.md": - return ruleFile; - default: - throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`); - } -} - -function toUserHomeRuleSource(ruleSubdir: string): RuleSource { - const source = `~/${ruleSubdir}`; - switch (source) { - case "~/.omo/rules": - case "~/.opencode/rules": - case "~/.claude/rules": - return source; - default: - throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`); - } -} - -function toUserHomeSingleFileSource(ruleFile: string): RuleSource { - const source = `~/${ruleFile}`; - switch (source) { - case "~/.config/opencode/AGENTS.md": - case "~/.claude/CLAUDE.md": - return source; - default: - throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`); - } -} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts b/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts index a05aee826..240722054 100644 --- a/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts +++ b/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts @@ -50,7 +50,39 @@ export function formatStaticBlock(rules: ReadonlyArray, options: For return ""; } - return `\n\n## Project Instructions\n${truncateRules(rules, options).map(formatRule).join("\n\n")}`; + return `\n\n## Project Instructions\n${truncateRules(staticDisplayRules(rules), options).map(formatRule).join("\n\n")}`; +} + +function staticDisplayRules(rules: ReadonlyArray): LoadedRule[] { + const uniqueRules = uniqueRulesByBody(rules); + return [ + ...uniqueRules.filter((rule) => rule.source === "plugin-bundled"), + ...uniqueRules.filter((rule) => rule.source !== "plugin-bundled"), + ]; +} + +function uniqueRulesByBody(rules: ReadonlyArray): LoadedRule[] { + const uniqueRules: LoadedRule[] = []; + const seenBodies = new Set(); + const userDescriptions = new Set(); + for (const rule of rules) { + const descriptionKey = rule.frontmatter.description?.trim(); + if (rule.source === "plugin-bundled" && descriptionKey !== undefined && userDescriptions.has(descriptionKey)) { + continue; + } + + const bodyKey = rule.body.trim(); + if (seenBodies.has(bodyKey)) { + continue; + } + + seenBodies.add(bodyKey); + if (descriptionKey !== undefined && rule.source !== "plugin-bundled") { + userDescriptions.add(descriptionKey); + } + uniqueRules.push(rule); + } + return uniqueRules; } export function formatDynamicBlock( diff --git a/packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts b/packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts new file mode 100644 index 000000000..c3ee49c34 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/plugin-root.ts @@ -0,0 +1,55 @@ +import { statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json"); + +export function resolvePluginRulesRoot(pluginRoot: string | undefined, moduleUrl = import.meta.url): string { + const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"]; + if (configuredRoot !== undefined && configuredRoot.trim().length > 0) { + return resolveRulesComponentRoot(resolve(configuredRoot)); + } + + const discoveredRoot = findNearestPluginRoot(dirname(fileURLToPath(moduleUrl))); + if (discoveredRoot !== null) { + return resolveRulesComponentRoot(discoveredRoot); + } + + return fileURLToPath(new URL("../../..", moduleUrl)); +} + +function findNearestPluginRoot(startDirectory: string): string | null { + let currentDirectory = resolve(startDirectory); + while (true) { + if (isFile(join(currentDirectory, PLUGIN_MANIFEST_PATH))) { + return currentDirectory; + } + + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } +} + +function resolveRulesComponentRoot(pluginRoot: string): string { + const componentRoot = join(pluginRoot, "components", "rules"); + return isDirectory(componentRoot) ? componentRoot : pluginRoot; +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/types.ts b/packages/omo-codex/plugin/components/rules/src/rules/types.ts index 245ed52a7..f24f046d9 100644 --- a/packages/omo-codex/plugin/components/rules/src/rules/types.ts +++ b/packages/omo-codex/plugin/components/rules/src/rules/types.ts @@ -84,6 +84,7 @@ export type RuleSource = | "AGENTS.md" | "CLAUDE.md" | "CONTEXT.md" + | "plugin-bundled" | "~/.omo/rules" | "~/.opencode/rules" | "~/.claude/rules" diff --git a/packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts b/packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts new file mode 100644 index 000000000..ee749f9ad --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/bundled-rules-priority.test.ts @@ -0,0 +1,107 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { configFromEnvironment } from "../src/config.js"; +import { SOURCE_PRIORITY } from "../src/rules/constants.js"; +import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js"; +import { resolvePluginRulesRoot } from "../src/rules/plugin-root.js"; +import type { RuleCandidate } from "../src/rules/types.js"; + +const projectRoot = "/tmp/codex-rules-bundled-priority"; +const bundledPath = join(projectRoot, "bundled-rules", "hephaestus.md"); +const homePath = join(projectRoot, "home", ".opencode", "rules", "hephaestus.md"); +const bundledBody = "Bundled baseline discipline."; +const homeBody = "Home baseline discipline override."; +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function globalCandidate(source: "plugin-bundled" | "~/.opencode/rules", path: string): RuleCandidate { + return { + path, + realPath: path, + source, + distance: 9999, + isGlobal: true, + isSingleFile: false, + relativePath: source === "plugin-bundled" ? "bundled-rules/hephaestus.md" : ".opencode/rules/hephaestus.md", + }; +} + +function ruleMarkdown(body: string): string { + return [ + "---", + "description: OMO Hephaestus baseline discipline for Codex", + "alwaysApply: true", + "---", + "", + body, + ].join("\n"); +} + +describe("plugin bundled rule priority", () => { + it("#given bundled source explicitly enabled then disabled #when parsing env #then no sources remain enabled", () => { + // given / when + const config = configFromEnvironment({ + CODEX_RULES_ENABLED_SOURCES: "plugin-bundled", + CODEX_RULES_DISABLE_BUNDLED: "1", + }); + + // then + expect(config.enabledSources).toEqual([]); + }); + + it("#given source priorities #when comparing user-home and bundled rules #then bundled has lower priority", () => { + // given / when / then + expect(SOURCE_PRIORITY.get("~/.opencode/rules")).toBe(101); + expect(SOURCE_PRIORITY.get("plugin-bundled")).toBe(200); + }); + + it("#given user-home and bundled rules share a description #when formatting static rules #then user-home wins", () => { + // given + const bundledCandidate = globalCandidate("plugin-bundled", bundledPath); + const homeCandidate = globalCandidate("~/.opencode/rules", homePath); + const deps = { + findProjectRoot: () => projectRoot, + findCandidates: () => [bundledCandidate, homeCandidate], + readFile: (path: string) => { + if (path === bundledPath) return ruleMarkdown(bundledBody); + if (path === homePath) return ruleMarkdown(homeBody); + return null; + }, + } satisfies EngineDeps; + const engine = createEngine(defaultConfig(), deps); + + // when + const loaded = engine.loadStaticRules(projectRoot); + const formatted = engine.formatStatic(loaded.rules); + + // then + expect(formatted).toContain(homePath); + expect(formatted).toContain(homeBody); + expect(formatted).not.toContain(bundledPath); + expect(formatted).not.toContain(bundledBody); + }); + + it("#given aggregate plugin root #when resolving rules root #then components rules directory is selected", () => { + // given + const aggregateRoot = mkdtempSync(join(tmpdir(), "codex-rules-aggregate-plugin-")); + const componentRoot = join(aggregateRoot, "components", "rules"); + tempDirectories.push(aggregateRoot); + mkdirSync(join(aggregateRoot, ".codex-plugin"), { recursive: true }); + mkdirSync(componentRoot, { recursive: true }); + writeFileSync(join(aggregateRoot, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "omo" })); + + // when + const resolvedRoot = resolvePluginRulesRoot(aggregateRoot); + + // then + expect(resolvedRoot).toBe(componentRoot); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts b/packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts new file mode 100644 index 000000000..b91f71c34 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/bundled-rules.test.ts @@ -0,0 +1,215 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + type CodexPostCompactInput, + type CodexSessionStartInput, + runPostCompactHook, + runSessionStartHook, + runUserPromptSubmitHook, +} from "../src/codex-hook.js"; +import { createRuleDiscoveryCache, findRuleCandidates } from "../src/rules/finder.js"; + +interface FixtureOptions { + readonly writeProjectDuplicate?: boolean; +} + +interface Fixture { + readonly root: string; + readonly pluginRoot: string; + readonly pluginData: string; + readonly bundledRulePath: string; + readonly projectRulePath: string; +} + +const BUNDLED_ONLY_ENV = { + CODEX_RULES_ENABLED_SOURCES: "plugin-bundled", +}; + +const PROJECT_AND_BUNDLED_ENV = { + CODEX_RULES_ENABLED_SOURCES: ".omo/rules,plugin-bundled", +}; + +const DISABLED_BUNDLED_ENV = { + CODEX_RULES_ENABLED_SOURCES: "plugin-bundled", + CODEX_RULES_DISABLE_BUNDLED: "1", +}; + +const BUNDLED_BODY = "Bundled craftsman baseline."; +const SHARED_BODY = "Always choose the smallest correct change."; + +const tempDirectories: string[] = []; +let originalPluginRoot: string | undefined; + +beforeEach(() => { + originalPluginRoot = process.env["PLUGIN_ROOT"]; +}); + +afterEach(() => { + restoreEnv("PLUGIN_ROOT", originalPluginRoot); + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeFixture(options: FixtureOptions = {}): Fixture { + const root = mkdtempSync(join(tmpdir(), "codex-rules-bundled-project-")); + const pluginRoot = mkdtempSync(join(tmpdir(), "codex-rules-bundled-plugin-")); + const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-bundled-data-")); + tempDirectories.push(root, pluginRoot, pluginData); + + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture" })); + mkdirSync(join(root, ".omo", "rules"), { recursive: true }); + mkdirSync(join(pluginRoot, "bundled-rules"), { recursive: true }); + + const bundledRulePath = join(pluginRoot, "bundled-rules", "hephaestus.md"); + const bundledBody = options.writeProjectDuplicate === true ? SHARED_BODY : BUNDLED_BODY; + writeFileSync(bundledRulePath, ruleMarkdown(bundledBody)); + + const projectRulePath = join(root, ".omo", "rules", "hephaestus.md"); + if (options.writeProjectDuplicate === true) { + writeFileSync(projectRulePath, ruleMarkdown(SHARED_BODY)); + } + + process.env["PLUGIN_ROOT"] = pluginRoot; + return { root, pluginRoot, pluginData, bundledRulePath, projectRulePath }; +} + +function ruleMarkdown(body: string): string { + return ["---", "description: Fixture", "alwaysApply: true", "---", "", body].join("\n"); +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + + process.env[name] = value; +} + +function sessionStartInput(root: string): CodexSessionStartInput { + return { + session_id: "session-1", + transcript_path: null, + cwd: root, + hook_event_name: "SessionStart", + model: "gpt-5.5", + permission_mode: "default", + source: "startup", + }; +} + +function postCompactInput(root: string): CodexPostCompactInput { + return { + session_id: "session-1", + turn_id: "turn-compact", + transcript_path: null, + cwd: root, + hook_event_name: "PostCompact", + model: "gpt-5.5", + trigger: "manual", + }; +} + +function userPromptSubmitInput(root: string): Parameters[0] { + return { + session_id: "session-1", + turn_id: "turn-1", + transcript_path: null, + cwd: root, + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "continue", + }; +} + +function occurrenceCount(value: string, search: string): number { + return value.split(search).length - 1; +} + +describe("plugin bundled rules", () => { + it("#given PLUGIN_ROOT with bundled markdown #when finding candidates #then plugin-bundled source is cached", () => { + // given + const { pluginRoot } = makeFixture(); + const cache = createRuleDiscoveryCache(); + + // when + const candidates = findRuleCandidates({ projectRoot: null, targetFile: null, skipUserHome: true, cache }); + + // then + expect(candidates.map((candidate) => `${candidate.source}:${candidate.relativePath}`)).toEqual([ + "plugin-bundled:bundled-rules/hephaestus.md", + ]); + expect(cache.scannedRuleFiles.has(join(pluginRoot, "bundled-rules"))).toBe(true); + }); + + it("#given alwaysApply bundled rule #when SessionStart runs #then static context includes it", async () => { + // given + const { root, pluginData } = makeFixture(); + + // when + const output = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: BUNDLED_ONLY_ENV, + }); + + // then + expect(output).toContain('"hookEventName":"SessionStart"'); + expect(output).toContain(BUNDLED_BODY); + }); + + it("#given same project and bundled body #when SessionStart runs #then project rule wins", async () => { + // given + const { root, pluginData, bundledRulePath, projectRulePath } = makeFixture({ writeProjectDuplicate: true }); + + // when + const output = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_AND_BUNDLED_ENV, + }); + + // then + expect(occurrenceCount(output, SHARED_BODY)).toBe(1); + expect(output).toContain(projectRulePath); + expect(output).not.toContain(bundledRulePath); + }); + + it("#given bundled rules disabled #when SessionStart runs #then bundled context is suppressed", async () => { + // given + const { root, pluginData } = makeFixture(); + + // when + const output = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: DISABLED_BUNDLED_ENV, + }); + + // then + expect(output).toBe(""); + }); + + it("#given PostCompact pending flag #when UserPromptSubmit runs #then bundled static context re-injects", async () => { + // given + const { root, pluginData } = makeFixture(); + const firstOutput = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: BUNDLED_ONLY_ENV, + }); + expect(firstOutput).toContain(BUNDLED_BODY); + + // when + const compactOutput = await runPostCompactHook(postCompactInput(root), { pluginDataRoot: pluginData }); + const output = await runUserPromptSubmitHook(userPromptSubmitInput(root), { + pluginDataRoot: pluginData, + env: BUNDLED_ONLY_ENV, + }); + + // then + expect(compactOutput).toBe(""); + expect(output).toContain(BUNDLED_BODY); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/finder.test.ts b/packages/omo-codex/plugin/components/rules/test/finder.test.ts index 7b7fbe7b8..3f10ca7f7 100644 --- a/packages/omo-codex/plugin/components/rules/test/finder.test.ts +++ b/packages/omo-codex/plugin/components/rules/test/finder.test.ts @@ -43,7 +43,12 @@ describe("findRuleCandidates", () => { const { projectRoot, homeRoot, targetPath } = makeProject(); // when - const candidates = findRuleCandidates({ projectRoot, targetFile: targetPath, homeDir: homeRoot }); + const candidates = findRuleCandidates({ + projectRoot, + targetFile: targetPath, + homeDir: homeRoot, + disabledSources: new Set(["plugin-bundled"]), + }); // then expect(candidates.map(candidateSummary)).toEqual([ @@ -64,7 +69,7 @@ describe("findRuleCandidates", () => { projectRoot, targetFile: targetPath, homeDir: homeRoot, - disabledSources: new Set([".omo/rules", "~/.opencode/rules"]), + disabledSources: new Set([".omo/rules", "~/.opencode/rules", "plugin-bundled"]), }); // then @@ -84,6 +89,7 @@ describe("findRuleCandidates", () => { targetFile: targetPath, homeDir: homeRoot, skipUserHome: true, + disabledSources: new Set(["plugin-bundled"]), }); // then