Merge pull request #4196 from code-yeongyu/fix/rules-core-fallback-to-workspace-when-no-project-root

fix(rules-core): fall back to workspace directory when no project root marker is found
This commit is contained in:
YeonGyu-Kim
2026-05-20 13:21:45 +09:00
committed by GitHub
5 changed files with 50 additions and 6 deletions
+21 -5
View File
@@ -15,21 +15,37 @@ export function findRuleFiles(
): RuleFileCandidate[] { ): RuleFileCandidate[] {
const startDir = dirname(resolve(currentFile)); const startDir = dirname(resolve(currentFile));
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false; 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); const cached = cache?.get(cacheKey);
if (cached) return [...cached]; if (cached) return [...cached];
const candidates: RuleFileCandidate[] = []; const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>(); const seenRealPaths = new Set<string>();
if (projectRoot) { addProjectRuleCandidates(effectiveProjectRoot, startDir, candidates, seenRealPaths, cache);
addProjectRuleCandidates(projectRoot, startDir, candidates, seenRealPaths, cache); addProjectSingleFileCandidates(effectiveProjectRoot, candidates, seenRealPaths);
addProjectSingleFileCandidates(projectRoot, candidates, seenRealPaths);
}
addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache); addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache);
const sorted = sortCandidates(candidates); const sorted = sortCandidates(candidates);
cache?.set(cacheKey, sorted); cache?.set(cacheKey, sorted);
return 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( function addProjectRuleCandidates(
projectRoot: string, projectRoot: string,
startDir: string, startDir: string,
+23
View File
@@ -62,6 +62,29 @@ describe("rules-core", () => {
expect(found.map((rule) => rule.relativePath)).not.toContain(".sisyphus/rules/sisyphus.md"); 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", () => { it("#given frontmatter aliases and negative glob #when matching #then honors applyTo paths and exclusions", () => {
// given // given
const { metadata } = parseRuleFrontmatter(`---\npaths: ["src/**/*.ts"]\napplyTo:\n - "!src/**/*.test.ts"\n---\nRule\n`); const { metadata } = parseRuleFrontmatter(`---\npaths: ["src/**/*.ts"]\napplyTo:\n - "!src/**/*.test.ts"\n---\nRule\n`);
+1
View File
@@ -58,6 +58,7 @@ export interface RuleScanCache {
export interface FindRuleFilesOptions { export interface FindRuleFilesOptions {
readonly skipClaudeUserRules?: boolean; readonly skipClaudeUserRules?: boolean;
readonly workspaceDirectory?: string;
} }
export interface AgentsMdCache { export interface AgentsMdCache {
@@ -339,6 +339,7 @@ describe("createRuleInjectionProcessor", () => {
contentHashes: new Set<string>(), contentHashes: new Set<string>(),
realPaths: new Set<string>([ruleRealPath]), realPaths: new Set<string>([ruleRealPath]),
}), }),
homedir: () => homeRoot,
}); });
// when // when
+4 -1
View File
@@ -137,6 +137,9 @@ export function createRuleInjectionProcessor(deps: {
} = deps; } = deps;
const matchDecisionCache: MatchDecisionCache = new Map(); const matchDecisionCache: MatchDecisionCache = new Map();
const finderOptions: FindRuleFilesOptions = ruleFinderOptions
? { ...ruleFinderOptions, workspaceDirectory }
: { workspaceDirectory };
function getParsedRule(filePath: string, realPath: string): ParsedRule { function getParsedRule(filePath: string, realPath: string): ParsedRule {
try { try {
@@ -189,7 +192,7 @@ export function createRuleInjectionProcessor(deps: {
projectRoot, projectRoot,
home, home,
resolved, resolved,
ruleFinderOptions, finderOptions,
ruleScanCache, ruleScanCache,
); );
const toInject: RuleToInject[] = []; const toInject: RuleToInject[] = [];