diff --git a/packages/rules-core/src/finder.ts b/packages/rules-core/src/finder.ts index ebf6e7ab5..8c7d58191 100644 --- a/packages/rules-core/src/finder.ts +++ b/packages/rules-core/src/finder.ts @@ -15,21 +15,37 @@ export function findRuleFiles( ): RuleFileCandidate[] { const startDir = dirname(resolve(currentFile)); const skipClaudeUserRules = options?.skipClaudeUserRules ?? false; - const cacheKey = [projectRoot ?? "", startDir, skipClaudeUserRules ? "1" : "0"].join("\0"); + const effectiveProjectRoot = resolveEffectiveProjectRoot( + projectRoot, + options?.workspaceDirectory, + startDir, + ); + const cacheKey = [projectRoot ?? "", effectiveProjectRoot, startDir, skipClaudeUserRules ? "1" : "0"].join( + "\0", + ); const cached = cache?.get(cacheKey); if (cached) return [...cached]; const candidates: RuleFileCandidate[] = []; const seenRealPaths = new Set(); - if (projectRoot) { - addProjectRuleCandidates(projectRoot, startDir, candidates, seenRealPaths, cache); - addProjectSingleFileCandidates(projectRoot, candidates, seenRealPaths); - } + addProjectRuleCandidates(effectiveProjectRoot, startDir, candidates, seenRealPaths, cache); + addProjectSingleFileCandidates(effectiveProjectRoot, candidates, seenRealPaths); addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache); const sorted = sortCandidates(candidates); cache?.set(cacheKey, sorted); return sorted; } +function resolveEffectiveProjectRoot( + projectRoot: string | null, + workspaceDirectory: string | undefined, + startDir: string, +): string { + if (projectRoot) return projectRoot; + if (!workspaceDirectory) return startDir; + const workspaceRoot = resolve(workspaceDirectory); + return isSameOrChildPath(startDir, workspaceRoot) ? workspaceRoot : startDir; +} + function addProjectRuleCandidates( projectRoot: string, startDir: string, diff --git a/packages/rules-core/src/index.test.ts b/packages/rules-core/src/index.test.ts index 30bb05981..c7b9f770c 100644 --- a/packages/rules-core/src/index.test.ts +++ b/packages/rules-core/src/index.test.ts @@ -62,6 +62,29 @@ describe("rules-core", () => { expect(found.map((rule) => rule.relativePath)).not.toContain(".sisyphus/rules/sisyphus.md"); }); + it("#given a workspace directory has no project marker (no .git, no package.json, etc.) AND contains .omo/rules/ #when findRuleFiles is called #then the .omo/rules/ files are still discovered", () => { + // given + const root = createTestRoot("rules-core-markerless-workspace"); + const homeDir = join(root, "home"); + const sourceDir = join(root, "src"); + const ruleFile = join(root, ".omo", "rules", "test-rule.md"); + const currentFile = join(sourceDir, "index.ts"); + mkdirSync(join(root, ".omo", "rules"), { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(ruleFile, "markerless workspace rule"); + writeFileSync(currentFile, "export {};"); + const projectRoot = findProjectRoot(currentFile); + const options = { skipClaudeUserRules: false, workspaceDirectory: root }; + + // when + const found = findRuleFiles(projectRoot, homeDir, currentFile, options); + + // then + expect(projectRoot).toBeNull(); + expect(found.map((rule) => rule.path)).toContain(ruleFile); + }); + 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`); diff --git a/packages/rules-core/src/types.ts b/packages/rules-core/src/types.ts index a6d44c1da..3be7f2baa 100644 --- a/packages/rules-core/src/types.ts +++ b/packages/rules-core/src/types.ts @@ -58,6 +58,7 @@ export interface RuleScanCache { export interface FindRuleFilesOptions { readonly skipClaudeUserRules?: boolean; + readonly workspaceDirectory?: string; } export interface AgentsMdCache { diff --git a/src/hooks/rules-injector/injector.test.ts b/src/hooks/rules-injector/injector.test.ts index 67fa2b9b9..b7dd8acc2 100644 --- a/src/hooks/rules-injector/injector.test.ts +++ b/src/hooks/rules-injector/injector.test.ts @@ -339,6 +339,7 @@ describe("createRuleInjectionProcessor", () => { contentHashes: new Set(), realPaths: new Set([ruleRealPath]), }), + homedir: () => homeRoot, }); // when diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts index cd23195d3..365da972c 100644 --- a/src/hooks/rules-injector/injector.ts +++ b/src/hooks/rules-injector/injector.ts @@ -137,6 +137,9 @@ export function createRuleInjectionProcessor(deps: { } = deps; const matchDecisionCache: MatchDecisionCache = new Map(); + const finderOptions: FindRuleFilesOptions = ruleFinderOptions + ? { ...ruleFinderOptions, workspaceDirectory } + : { workspaceDirectory }; function getParsedRule(filePath: string, realPath: string): ParsedRule { try { @@ -189,7 +192,7 @@ export function createRuleInjectionProcessor(deps: { projectRoot, home, resolved, - ruleFinderOptions, + finderOptions, ruleScanCache, ); const toInject: RuleToInject[] = [];