diff --git a/.rulesync/rules/overview.md b/.rulesync/rules/overview.md index 7317dfe1d..0299493e4 100644 --- a/.rulesync/rules/overview.md +++ b/.rulesync/rules/overview.md @@ -1,5 +1,5 @@ --- -root: true # true that is less than or equal to one file for overview such as AGENTS.md, false for details such as .agents/memories/*.md +root: true # true for overview fragments such as AGENTS.md; multiple root files can be composed for the same target. false for details such as .agents/memories/*.md targets: ["*"] # * = all, or specific tools description: "rulesync project overview and development guidelines for unified AI rules management CLI tool" globs: ["**/*"] # file patterns to match (e.g., ["*.md", "*.txt"]) diff --git a/docs/guide/global-mode.md b/docs/guide/global-mode.md index 44730f930..2308992f9 100644 --- a/docs/guide/global-mode.md +++ b/docs/guide/global-mode.md @@ -48,5 +48,5 @@ Currently, supports rules generation for Claude Code, GitHub Copilot, and OpenCo > Currently, when in the directory enabled global mode: > > - `rulesync.jsonc` only supports `global`, `features`, `delete` and `verbose`. `Features` can be set `"rules"` and `"commands"`. Other parameters are ignored. -> - Tools support only a single `root: true` file in global mode as a target, e.g. you can't have 2 root files targeting Claude. +> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or plain-Markdown single-file outputs are combined with a blank line between files. Local rules are composed first in lexicographic source file path order, followed by non-overridden `.curated/` rules in lexicographic order, so filename prefixes control composition order within each set. Distinct native paths remain separate. Explicitly supported plain-Markdown modular path collisions are combined (fragments whose generated output carries its own frontmatter block stay separate), unsafe collisions involving a source root rule fail, and other exact or case-insensitive modular collisions warn that the last write wins wherever their paths collide. > - Only Claude Code is supported for global mode commands. diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index dc9dbc007..0ffceb924 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -14,7 +14,7 @@ Example: ```md --- -root: true # true that is less than or equal to one file for overview such as `AGENTS.md`, false for details such as `.agents/memories/*.md` +root: true # true for root-level rules, false for details such as `.agents/memories/*.md` localRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI) and Roo Code: AGENTS.local.md; Others: append to root file targets: ["*"] # * = all, or specific tools description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool" @@ -61,6 +61,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ... ``` +Multiple files can set `root: true` for the same target in project and global modes. Rulesync renders each file through the target adapter, then combines compatible root or plain-Markdown single-file outputs in deterministic source-discovery order with one blank line between fragments. Local rules are ordered lexicographically by source file path and composed before non-overridden `.curated/` rules, which are also ordered lexicographically; filename prefixes such as `10-` and `20-` control composition order within each set. Targets that map source rules to distinct native paths keep those files separate. Explicitly supported plain-Markdown modular rules that normalize to the same output path are combined; a fragment whose generated output carries its own frontmatter block (such as Amp's `globs:` gate) is never composed and stays a separate file instead. Unsafe collisions involving a source `root: true` rule fail. Other exact or case-insensitive modular collisions remain separate and produce a warning that the last write wins wherever the filesystem treats their paths as the same. + > **AGENTS.md standard note (`agentsmd`):** Nested `AGENTS.md` files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from `agentsmd.subprojectPath` and, on **import**, discovers them by scanning the project for `**/AGENTS.md`. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are `node_modules/` and `__pycache__/`. Build, vendoring and scratch directories (`vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`) are skipped **at the project root only**, because a top-level `build/` is a build directory while `packages/build/` is a real subproject. Beyond those names, the scan honors your `.gitignore`: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled `.rulesync/rules/` would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the `.gitignore` files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the _directories_ above each file, not the file itself, so the `**/AGENTS.md` entry that `rulesync gitignore` writes for its own output does not disable the scan; the flip side is that ignoring one individual `AGENTS.md` no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled `.rulesync/`. The scan is import-only: a nested file rulesync did not write is never removed by `--delete`. That means deleting the rulesync rule stops the reference from being listed in the root `AGENTS.md`, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. A subproject that would claim the reserved `overview.md` name gets an `-agents` suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites `.rulesync/rules/`, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See . > **Kiro note:** Kiro reads steering files from `.kiro/steering/*.md` and uses an `inclusion` frontmatter block to decide when each is loaded (`always`, `fileMatch` with a `fileMatchPattern`, `manual`, or `auto` — which auto-includes the file when a request matches its companion `description`, keyed by `name`). Rulesync derives this for non-root steering files: an explicit `kiro.inclusion` block round-trips as-is (carrying `name`/`description` through for `auto`); otherwise specific (non-wildcard) `globs` map to `inclusion: fileMatch` (a single glob is written as a string and multiple as a YAML array, both of which Kiro accepts), so the rule applies only to matching files instead of always; otherwise the file stays always-on and is written without a frontmatter block (Kiro's no-frontmatter default). The root overview index is always written plain so Kiro always loads it. In **global** mode (`--global`), steering is written to `~/.kiro/steering/` with the root rule as `~/.kiro/steering/product.md` (Kiro does not read `~/AGENTS.md`, so the project-scope root `AGENTS.md` is not used at the home level), and global MCP is written to `~/.kiro/settings/mcp.json`. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 48773e69f..6a0fa93d5 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -14,7 +14,7 @@ Example: ```md --- -root: true # true that is less than or equal to one file for overview such as `AGENTS.md`, false for details such as `.agents/memories/*.md` +root: true # true for root-level rules, false for details such as `.agents/memories/*.md` localRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI) and Roo Code: AGENTS.local.md; Others: append to root file targets: ["*"] # * = all, or specific tools description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool" @@ -61,6 +61,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ... ``` +Multiple files can set `root: true` for the same target in project and global modes. Rulesync renders each file through the target adapter, then combines compatible root or plain-Markdown single-file outputs in deterministic source-discovery order with one blank line between fragments. Local rules are ordered lexicographically by source file path and composed before non-overridden `.curated/` rules, which are also ordered lexicographically; filename prefixes such as `10-` and `20-` control composition order within each set. Targets that map source rules to distinct native paths keep those files separate. Explicitly supported plain-Markdown modular rules that normalize to the same output path are combined; a fragment whose generated output carries its own frontmatter block (such as Amp's `globs:` gate) is never composed and stays a separate file instead. Unsafe collisions involving a source `root: true` rule fail. Other exact or case-insensitive modular collisions remain separate and produce a warning that the last write wins wherever the filesystem treats their paths as the same. + > **AGENTS.md standard note (`agentsmd`):** Nested `AGENTS.md` files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from `agentsmd.subprojectPath` and, on **import**, discovers them by scanning the project for `**/AGENTS.md`. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are `node_modules/` and `__pycache__/`. Build, vendoring and scratch directories (`vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`) are skipped **at the project root only**, because a top-level `build/` is a build directory while `packages/build/` is a real subproject. Beyond those names, the scan honors your `.gitignore`: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled `.rulesync/rules/` would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the `.gitignore` files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the _directories_ above each file, not the file itself, so the `**/AGENTS.md` entry that `rulesync gitignore` writes for its own output does not disable the scan; the flip side is that ignoring one individual `AGENTS.md` no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled `.rulesync/`. The scan is import-only: a nested file rulesync did not write is never removed by `--delete`. That means deleting the rulesync rule stops the reference from being listed in the root `AGENTS.md`, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. A subproject that would claim the reserved `overview.md` name gets an `-agents` suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites `.rulesync/rules/`, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See . > **Kiro note:** Kiro reads steering files from `.kiro/steering/*.md` and uses an `inclusion` frontmatter block to decide when each is loaded (`always`, `fileMatch` with a `fileMatchPattern`, `manual`, or `auto` — which auto-includes the file when a request matches its companion `description`, keyed by `name`). Rulesync derives this for non-root steering files: an explicit `kiro.inclusion` block round-trips as-is (carrying `name`/`description` through for `auto`); otherwise specific (non-wildcard) `globs` map to `inclusion: fileMatch` (a single glob is written as a string and multiple as a YAML array, both of which Kiro accepts), so the rule applies only to matching files instead of always; otherwise the file stays always-on and is written without a frontmatter block (Kiro's no-frontmatter default). The root overview index is always written plain so Kiro always loads it. In **global** mode (`--global`), steering is written to `~/.kiro/steering/` with the root rule as `~/.kiro/steering/product.md` (Kiro does not read `~/AGENTS.md`, so the project-scope root `AGENTS.md` is not used at the home level), and global MCP is written to `~/.kiro/settings/mcp.json`. diff --git a/skills/rulesync/global-mode.md b/skills/rulesync/global-mode.md index 44730f930..2308992f9 100644 --- a/skills/rulesync/global-mode.md +++ b/skills/rulesync/global-mode.md @@ -48,5 +48,5 @@ Currently, supports rules generation for Claude Code, GitHub Copilot, and OpenCo > Currently, when in the directory enabled global mode: > > - `rulesync.jsonc` only supports `global`, `features`, `delete` and `verbose`. `Features` can be set `"rules"` and `"commands"`. Other parameters are ignored. -> - Tools support only a single `root: true` file in global mode as a target, e.g. you can't have 2 root files targeting Claude. +> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or plain-Markdown single-file outputs are combined with a blank line between files. Local rules are composed first in lexicographic source file path order, followed by non-overridden `.curated/` rules in lexicographic order, so filename prefixes control composition order within each set. Distinct native paths remain separate. Explicitly supported plain-Markdown modular path collisions are combined (fragments whose generated output carries its own frontmatter block stay separate), unsafe collisions involving a source root rule fail, and other exact or case-insensitive modular collisions warn that the last write wins wherever their paths collide. > - Only Claude Code is supported for global mode commands. diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 88052fd09..d75bcca61 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -132,6 +132,59 @@ This is a test rule for E2E testing. }, ); + it.each([ + { + target: "claudecode", + outputPaths: ["CLAUDE.md"], + }, + { + target: "cursor", + outputPaths: [ + join(".cursor", "rules", "overview.mdc"), + join(".cursor", "rules", "additional-project-rule.mdc"), + ], + }, + ] as const)( + "should preserve multiple project root rules for $target", + async ({ target, outputPaths }) => { + const testDir = getTestDir(); + const rootRuleContent = `--- +root: true +targets: ["*"] +description: "Project root rule" +--- + +# Project Root Fragment +`; + const additionalRuleContent = `--- +root: true +targets: ["*"] +description: "Additional project root rule" +--- + +# Additional Project Root Fragment +`; + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), + rootRuleContent, + ); + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "additional-project-rule.md"), + additionalRuleContent, + ); + + await runGenerate({ target, features: "rules" }); + + const generatedContent = ( + await Promise.all( + outputPaths.map((outputPath) => readFileContent(join(testDir, outputPath))), + ) + ).join("\n"); + expect(generatedContent.split("# Project Root Fragment")).toHaveLength(2); + expect(generatedContent.split("# Additional Project Root Fragment")).toHaveLength(2); + }, + ); + it("should fold pi non-root rules into the root AGENTS.md", async () => { const testDir = getTestDir(); @@ -892,6 +945,72 @@ This is a global test rule for E2E testing. }, ); + it.each([ + { + target: "claudecode", + outputPaths: [join(".claude", "CLAUDE.md")], + }, + { + target: "augmentcode", + outputPaths: [ + join(".augment", "rules", "overview.md"), + join(".augment", "rules", "additional-global-rule.md"), + ], + }, + { + target: "takt", + outputPaths: [ + join(".takt", "facets", "policies", "overview.md"), + join(".takt", "facets", "policies", "additional-global-rule.md"), + ], + }, + ] as const)( + "should preserve multiple global root rules for $target", + async ({ target, outputPaths }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + const rootRuleContent = `--- +root: true +targets: ["*"] +description: "Global root rule" +--- + +# Global Root Fragment +`; + const additionalRuleContent = `--- +root: true +targets: ["*"] +description: "Additional global root rule" +--- + +# Additional Global Root Fragment +`; + await writeFileContent( + join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), + rootRuleContent, + ); + await writeFileContent( + join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "additional-global-rule.md"), + additionalRuleContent, + ); + + await runGenerate({ + target, + features: "rules", + global: true, + env: { HOME_DIR: homeDir }, + }); + + const generatedContent = ( + await Promise.all( + outputPaths.map((outputPath) => readFileContent(join(homeDir, outputPath))), + ) + ).join("\n"); + expect(generatedContent.split("# Global Root Fragment")).toHaveLength(2); + expect(generatedContent.split("# Additional Global Root Fragment")).toHaveLength(2); + }, + ); + it("should ignore non-root rules in global mode", async () => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index a0e3089fb..dd3223602 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -26,7 +26,7 @@ import { WarpRule } from "./warp-rule.js"; const logger = createMockLogger(); const globalFoldTargets = RulesProcessor.getToolTargets({ global: true }).filter( - (target) => RulesProcessor.getFactory(target)?.meta.foldsNonRootIntoRoot === true, + (target) => RulesProcessor.getFactory(target)?.meta.collisionPolicy === "fold", ); describe("RulesProcessor", () => { @@ -288,6 +288,37 @@ describe("RulesProcessor", () => { expect(appendRule?.getRelativeDirPath()).toBe(".pi"); }); + it("should trim singleton pi output groups", async () => { + const processor = new RulesProcessor({ logger, toolTarget: "pi" }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: ["pi"] }, + body: "# RootA\n\n\n", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "append.md", + frontmatter: { targets: ["pi"], pi: { systemPrompt: "append" } }, + body: "# Appended\n\n\n", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const rootRule = result.find( + (rule) => rule instanceof PiRule && rule.getRelativeFilePath() === "AGENTS.md", + ); + const appendRule = result.find( + (rule) => rule instanceof PiRule && rule.getRelativeFilePath() === "APPEND_SYSTEM.md", + ); + + expect(rootRule?.getFileContent()).toBe("# RootA"); + expect(appendRule?.getFileContent()).toBe("# Appended"); + }); + it("should not list APPEND_SYSTEM.md in the pi references section in explicit discovery mode", async () => { const processor = new RulesProcessor({ logger, @@ -1332,6 +1363,399 @@ Content that would fail parsing`; expect(result[0]?.getRelativeFilePath()).toBe("CLAUDE.md"); }); + it("should merge multiple global root rules that resolve to the same path", async () => { + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: "claudecode", + global: true, + }); + + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "10-overview.md", + frontmatter: { + root: true, + targets: ["*"], + }, + body: "# Global Overview", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "20-personal-assistant.md", + frontmatter: { + root: true, + targets: ["claudecode"], + }, + body: "# Personal Assistant", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(ClaudecodeRule); + expect(result[0]?.getRelativeDirPath()).toBe(".claude"); + expect(result[0]?.getRelativeFilePath()).toBe("CLAUDE.md"); + expect(result[0]?.getFileContent()).toBe("# Global Overview\n\n# Personal Assistant"); + }); + + it.each([false, true])( + "should preserve multiple root fragments across every target with global=%s", + async (global) => { + for (const toolTarget of RulesProcessor.getToolTargets({ global })) { + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: toolTarget as RulesProcessorToolTarget, + global, + }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "10-first-root.md", + frontmatter: { + root: true, + targets: ["*"], + description: "First root", + globs: ["**/*"], + }, + body: "# First Root Fragment", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "20-second-root.md", + frontmatter: { + root: true, + targets: ["*"], + description: "Second root", + globs: ["**/*"], + }, + body: "# Second Root Fragment", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const outputPaths = result.map((rule) => + join(rule.getRelativeDirPath(), rule.getRelativeFilePath()), + ); + const generatedContent = result.map((rule) => rule.getFileContent()).join("\n"); + + expect(new Set(outputPaths).size, toolTarget).toBe(outputPaths.length); + expect(generatedContent, toolTarget).toContain("# First Root Fragment"); + expect(generatedContent, toolTarget).toContain("# Second Root Fragment"); + expect( + result.every( + (rule) => rule.getFileContent().split("# First Root Fragment").length <= 2, + ), + toolTarget, + ).toBe(true); + expect( + result.every( + (rule) => rule.getFileContent().split("# Second Root Fragment").length <= 2, + ), + toolTarget, + ).toBe(true); + } + }, + ); + }); + }); + + describe("convertRulesyncFilesToToolFiles collision handling", () => { + it.each(["devin", "antigravity-ide"] as const)( + "should warn for metadata-bearing project rules normalized to the same %s path", + async (toolTarget) => { + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget, + }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "CodingGuidelines.md", + frontmatter: { + root: false, + targets: [toolTarget], + description: "First normalized rule", + globs: ["**/*"], + }, + body: "# First Normalized Rule", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "coding_guidelines.md", + frontmatter: { + root: false, + targets: [toolTarget], + description: "Second normalized rule", + globs: ["src/**/*"], + }, + body: "# Second Normalized Rule", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + + expect(result).toHaveLength(2); + // Both sources normalize to the exact same path, so the warning names + // that path once, without the case-insensitivity clause. + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("; the last one wins wherever they collide."), + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining("compared case-insensitively"), + ); + }, + ); + + it("should reject root collisions with metadata-bearing modular rules", async () => { + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: "kiro", + global: true, + }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: ["kiro"] }, + body: "# Root Body", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "product.md", + frontmatter: { + root: false, + targets: ["kiro"], + globs: ["src/**/*.ts"], + }, + body: "# Non Root Body", + }), + ]; + + await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( + `Multiple generated rules resolve to output path '${join(".kiro", "steering", "product.md")}' for target 'kiro', but this target cannot safely compose a collision involving a root rule. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "overview.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "product.md")}`, + ); + }); + + it.each(["agentsmd", "amp", "factorydroid", "kilo", "opencode"] as const)( + "should compose plain Markdown %s modular rules with the same output path", + async (toolTarget) => { + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget, + }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "first.md", + frontmatter: { + root: false, + targets: [toolTarget], + agentsmd: { subprojectPath: "packages/app" }, + }, + body: "# First Subproject Rule", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "second.md", + frontmatter: { + root: false, + targets: [toolTarget], + agentsmd: { subprojectPath: "packages/app" }, + }, + body: "# Second Subproject Rule", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const composedRules = result.filter( + (file) => file.getRelativeDirPath() === join("packages", "app"), + ); + + expect(composedRules).toHaveLength(1); + expect(composedRules[0]?.getFileContent()).toBe( + "# First Subproject Rule\n\n# Second Subproject Rule", + ); + }, + ); + + it("should not compose amp fragments that carry a globs frontmatter gate", async () => { + // Amp gates non-root files on a leading `globs:` frontmatter block + // (issue #2410). Concatenating two gated fragments would bury the second + // block mid-body where Amp never reads it, so the group falls back to + // preserve-and-warn instead of composing. + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: "amp", + }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "first.md", + frontmatter: { + root: false, + targets: ["amp"], + globs: ["packages/app/**/*.ts"], + agentsmd: { subprojectPath: "packages/app" }, + }, + body: "# First Gated Rule", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "second.md", + frontmatter: { + root: false, + targets: ["amp"], + globs: ["packages/app/**/*.tsx"], + agentsmd: { subprojectPath: "packages/app" }, + }, + body: "# Second Gated Rule", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const gatedRules = result.filter( + (file) => file.getRelativeDirPath() === join("packages", "app"), + ); + + expect(gatedRules).toHaveLength(2); + for (const rule of gatedRules) { + // Each file keeps exactly one frontmatter block, at the top. + expect(rule.getFileContent().startsWith("---\n")).toBe(true); + expect(rule.getFileContent()).not.toMatch(/\n---\nglobs:/); + } + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("; the last one wins wherever they collide."), + ); + }); + + it("should reject Takt rules with the same overridden output name", async () => { + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: "takt", + }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "first.md", + frontmatter: { + root: true, + targets: ["takt"], + takt: { name: "same", extends: "base-one" }, + }, + body: "# First Takt Rule", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "second.md", + frontmatter: { + root: true, + targets: ["takt"], + takt: { name: "same", extends: "base-two" }, + }, + body: "# Second Takt Rule", + }), + ]; + + await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( + `Multiple generated rules resolve to output path '${join(".takt", "facets", "policies", "same.md")}' for target 'takt', but this target cannot safely compose a collision involving a root rule. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "first.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "second.md")}`, + ); + }); + + it.each([ + { + toolTarget: "cursor", + expectedPaths: [ + join(".cursor", "rules", "overview.mdc"), + join(".cursor", "rules", "API.mdc"), + join(".cursor", "rules", "api.mdc"), + ], + }, + { + toolTarget: "claudecode", + expectedPaths: [ + "CLAUDE.md", + join(".claude", "rules", "API.md"), + join(".claude", "rules", "api.md"), + ], + }, + ] as const)( + "should preserve unrelated $toolTarget output paths that differ only by case", + async ({ toolTarget, expectedPaths }) => { + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: [toolTarget] }, + body: "# Overview", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "API.md", + frontmatter: { + root: false, + targets: [toolTarget], + description: "Uppercase API rule", + globs: ["**/*"], + }, + body: "# Uppercase API", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "api.md", + frontmatter: { + root: false, + targets: [toolTarget], + description: "Lowercase API rule", + globs: ["src/**/*"], + }, + body: "# Lowercase API", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const outputPaths = result.map((rule) => + join(rule.getRelativeDirPath(), rule.getRelativeFilePath()), + ); + + expect(outputPaths).toEqual(expectedPaths); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + "(compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.", + ), + ); + }, + ); + }); + + describe("RulesProcessor with global flag", () => { + describe("convertRulesyncFilesToToolFiles in global mode", () => { it("should convert using global paths when global=true for codexcli", async () => { const processor = new RulesProcessor({ logger, @@ -2051,6 +2475,30 @@ targets: ["opencode", "agentsmd"] }); describe("loadRulesyncFiles with curated rules", () => { + it("should compose local root fragments before curated root fragments", async () => { + const frontmatter = "---\nroot: true\ntargets:\n - claudecode\n---\n"; + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "20-local.md"), + `${frontmatter}# Local Root`, + ); + await writeFileContent( + join(testDir, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH, "05-curated.md"), + `${frontmatter}# Curated Root`, + ); + const processor = new RulesProcessor({ + logger, + inputRoot: testDir, + outputRoot: testDir, + toolTarget: "claudecode", + }); + + const rulesyncFiles = await processor.loadRulesyncFiles(); + const [toolRule] = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); + const content = toolRule?.getFileContent() ?? ""; + + expect(content.indexOf("# Local Root")).toBeLessThan(content.indexOf("# Curated Root")); + }); + it("should load curated rules while preferring a same-path local rule", async () => { const frontmatter = "---\ntargets:\n - '*'\n---\n"; await writeFileContent( @@ -2210,7 +2658,7 @@ targets: ["opencode"] expect((rootRules[0] as RulesyncRule).getFrontmatter().targets).toEqual(["claudecode"]); }); - it("should throw when two root rules target the same tool", async () => { + it("should allow two root rules targeting the same tool", async () => { await ensureDir(join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH)); await writeFileContent( join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "root1.md"), @@ -2235,12 +2683,17 @@ targets: ["claudecode"] toolTarget: "claudecode", }); - await expect(processor.loadRulesyncFiles()).rejects.toThrow( - "Multiple root rulesync rules found for target 'claudecode'", + const result = await processor.loadRulesyncFiles(); + const rootRules = result.filter( + (rule): rule is RulesyncRule => + rule instanceof RulesyncRule && rule.getFrontmatter().root === true, ); + + expect(rootRules).toHaveLength(2); + expect(rootRules.map((rule) => rule.getBody())).toEqual(["# Root 1", "# Root 2"]); }); - it("should throw when wildcard and specific target both match", async () => { + it("should allow wildcard and specific root rules when both match", async () => { await ensureDir(join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH)); await writeFileContent( join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "wildcard-root.md"), @@ -2265,9 +2718,14 @@ targets: ["claudecode"] toolTarget: "claudecode", }); - await expect(processor.loadRulesyncFiles()).rejects.toThrow( - "Multiple root rulesync rules found for target 'claudecode'", + const result = await processor.loadRulesyncFiles(); + const rootRules = result.filter( + (rule): rule is RulesyncRule => + rule instanceof RulesyncRule && rule.getFrontmatter().root === true, ); + + expect(rootRules).toHaveLength(2); + expect(rootRules.map((rule) => rule.getBody())).toEqual(["# Claude Root", "# Wildcard Root"]); }); it("should allow wildcard root when queried for non-overlapping target", async () => { @@ -2333,6 +2791,40 @@ targets: ["opencode"] expect((result[0] as RulesyncRule).getFrontmatter().targets).toEqual(["claudecode"]); }); + it("should retain multiple matching root rules in global mode", async () => { + await ensureDir(join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH)); + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "10-overview.md"), + `--- +root: true +targets: ["*"] +--- +# Global Overview`, + ); + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "20-personal-assistant.md"), + `--- +root: true +targets: ["claudecode"] +--- +# Personal Assistant`, + ); + + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: "claudecode", + global: true, + }); + + const result = await processor.loadRulesyncFiles(); + expect(result).toHaveLength(2); + expect(result.map((rule) => (rule as RulesyncRule).getBody())).toEqual([ + "# Global Overview", + "# Personal Assistant", + ]); + }); + it("should warn with target name when no root matches specific target", async () => { await ensureDir(join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH)); await writeFileContent( diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 3290e4bf7..f7c14b708 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -206,6 +206,11 @@ type McpInstructionsRegistrar = { }; type LocalRootMode = "separate-local-file" | "append-to-root"; +type RuleCollisionPolicy = "compose" | "fold" | "preserve"; +type RuleConversion = { + toolRule: ToolRule; + rulesyncRule: RulesyncRule; +}; /** * Factory entry for each tool rule class. @@ -272,11 +277,8 @@ type ToolRuleFactory = { additionalConventions?: AdditionalConventionsConfig; /** Whether to create a separate rule file for additional conventions instead of prepending to root */ createsSeparateConventionsRule?: boolean; - /** - * Fold every non-root rule body into the single root rule file, for tools that - * read only one root `AGENTS.md` and never scan a non-root directory. - */ - foldsNonRootIntoRoot?: boolean; + /** How rules that resolve to the same output path are handled. */ + collisionPolicy?: RuleCollisionPolicy; /** * MCP feature that registers non-root rule paths into its shared config's * `instructions` key (project scope only); set when the tool does not @@ -303,6 +305,7 @@ export const toolRuleFactories = new Map/AGENTS.md`. supportsGlobal: true, ruleDiscoveryMode: "auto", - foldsNonRootIntoRoot: true, + collisionPolicy: "fold", }, }, ], @@ -508,6 +512,7 @@ export const toolRuleFactories = new Map { if (!factory.class.isTargetedByRulesyncRule(rulesyncRule)) { return null; } - return factory.class.fromRulesyncRule({ + const toolRule = factory.class.fromRulesyncRule({ outputRoot: this.outputRoot, rulesyncRule, validate: true, global: this.global, }); + return { toolRule, rulesyncRule }; }) - .filter((rule): rule is ToolRule => rule !== null); + .filter((rule): rule is RuleConversion => rule !== null); - if (meta.foldsNonRootIntoRoot) { - this.foldNonRootRulesIntoRootRule(toolRules); - } + this.mergeRulesByOutputPath({ + convertedRules, + collisionPolicy: meta.collisionPolicy ?? "preserve", + }); + const toolRules = convertedRules.map(({ toolRule }) => toolRule); this.applyLocalRootRules({ toolRules, localRootRules, factory }); @@ -948,7 +957,9 @@ export class RulesProcessor extends FeatureProcessor { this.applyRootRuleSections({ toolRules, factory }); - return [...toolRules, ...extraFiles]; + const outputFiles = [...toolRules, ...extraFiles]; + this.warnForOutputPathCollisions({ outputFiles, convertedRules }); + return outputFiles; } /** @@ -1061,6 +1072,9 @@ export class RulesProcessor extends FeatureProcessor { factory: ToolRuleFactory; }): void { const { meta } = factory; + // Fixed-root targets were collapsed by mergeRulesByOutputPath. Targets that + // keep multiple native paths emit those ToolRules as non-root, so at most + // one root rule can survive here. const rootRule = toolRules.find((rule) => rule.isRoot()); if (!rootRule) { return; @@ -1118,21 +1132,32 @@ export class RulesProcessor extends FeatureProcessor { } /** - * Fold every non-root rule body into the single root rule file. + * Reconcile rules that resolve to the same output path. * - * Used for tools whose rules engine reads only one root `AGENTS.md` and neither - * scans a `memories/` directory nor follows references (deepagents' dcode reads - * `.deepagents/AGENTS.md`; Warp reads root/subdir `AGENTS.md` but never - * `.warp/memories/`). Those rule classes emit both root and non-root rules to - * the same root path, so all bodies must be merged into one instance to avoid - * colliding on that path (last-writer-wins would silently drop content). + * Multiple root fragments are composed for tools that emit a fixed root file. + * The `fold` policy is for tools whose rules engine reads only one root file and + * neither scans a modular rules directory nor follows references. For example, + * dcode reads `.deepagents/AGENTS.md`, while Warp reads root or subdirectory + * `AGENTS.md` files but never `.warp/memories/`. Those adapters must fold every + * body into one instance because last-writer-wins would silently drop content. + * Plain-Markdown adapters can opt into `compose` for colliding modular outputs. * - * The root rule (if any) becomes the merge target and leads the merged content; - * otherwise the first rule is used so a rule set without a root overview still - * produces a single, complete file. Mutates `toolRules` in place. + * A generated root rule becomes the merge target when present. A `fold` group + * without one uses its first rule. A group only composes when every rendered + * fragment is plain Markdown — a fragment carrying its own frontmatter block + * (e.g. Amp's `globs:` gate) would end up mid-body where the tool ignores it. + * Root-involved collisions that cannot be composed safely fail; other + * collisions remain separate and are reported by the final output-path check. + * Mutates `convertedRules` in place. */ - private foldNonRootRulesIntoRootRule(toolRules: ToolRule[]): void { - if (toolRules.length <= 1) { + private mergeRulesByOutputPath({ + convertedRules, + collisionPolicy, + }: { + convertedRules: RuleConversion[]; + collisionPolicy: RuleCollisionPolicy; + }): void { + if (convertedRules.length <= 1) { return; } @@ -1141,40 +1166,117 @@ export class RulesProcessor extends FeatureProcessor { // Pi additionally routes `pi.systemPrompt: append` rules to a separate // `APPEND_SYSTEM.md`, so those must concatenate among themselves rather than // into the root file. Insertion order is preserved so source order is kept. - const groups = new Map(); - for (const rule of toolRules) { - const path = join(rule.getRelativeDirPath(), rule.getRelativeFilePath()); + const groups = new Map(); + for (const conversion of convertedRules) { + const path = join( + conversion.toolRule.getRelativeDirPath(), + conversion.toolRule.getRelativeFilePath(), + ); const group = groups.get(path); if (group) { - group.push(rule); + group.push(conversion); } else { - groups.set(path, [rule]); + groups.set(path, [conversion]); } } - const survivors = new Set(); - for (const group of groups.values()) { - // The root-path group prefers the root rule as its merge target; other - // groups fold into their first rule in source order. - const target = group.find((rule) => rule.isRoot()) ?? group[0]; + const survivors = new Set(); + for (const [path, group] of groups) { + if (group.length === 1) { + const conversion = group[0]; + if (conversion) { + if (collisionPolicy === "fold") { + conversion.toolRule.setFileContent(conversion.toolRule.getFileContent().trim()); + } + survivors.add(conversion); + } + continue; + } + + const rootConversion = group.find(({ toolRule }) => toolRule.isRoot()); + const allGeneratedRulesAreRoots = group.every(({ toolRule }) => toolRule.isRoot()); + const hasSourceRoot = group.some( + ({ rulesyncRule }) => rulesyncRule.getFrontmatter().root === true, + ); + // Composition is only structure-preserving when every fragment is plain + // Markdown. An adapter may prepend a frontmatter block to some outputs + // (Amp gates non-root files on a leading `globs:` block); concatenating + // such a fragment would bury its block mid-body where the tool no longer + // reads it, so those groups fall through to preserve-or-reject instead. + const allFragmentsArePlain = group.every( + ({ toolRule }) => !/^---\r?\n/.test(toolRule.getFileContent()), + ); + const shouldCompose = + (collisionPolicy === "fold" || + collisionPolicy === "compose" || + allGeneratedRulesAreRoots) && + allFragmentsArePlain; + + if (!shouldCompose && hasSourceRoot) { + throw new Error( + `Multiple generated rules resolve to output path '${path}' for target '${this.toolTarget}', but this target cannot safely compose a collision involving a root rule. Source rules: ${formatRulePaths(group.map(({ rulesyncRule }) => rulesyncRule))}`, + ); + } + + if (!shouldCompose) { + for (const conversion of group) { + survivors.add(conversion); + } + continue; + } + + const target = rootConversion ?? group[0]; if (!target) { continue; } const ordered = [target, ...group.filter((rule) => rule !== target)]; const mergedContent = ordered - .map((rule) => rule.getFileContent().trim()) + .map(({ toolRule }) => toolRule.getFileContent().trim()) .filter((content) => content.length > 0) .join("\n\n"); - target.setFileContent(mergedContent); + target.toolRule.setFileContent(mergedContent); survivors.add(target); } // Keep only each group's merge target; the others are now folded in. - for (let i = toolRules.length - 1; i >= 0; i--) { - const rule = toolRules[i]; - if (rule && !survivors.has(rule)) { - toolRules.splice(i, 1); + for (let i = convertedRules.length - 1; i >= 0; i--) { + const conversion = convertedRules[i]; + if (conversion && !survivors.has(conversion)) { + convertedRules.splice(i, 1); + } + } + } + + private warnForOutputPathCollisions({ + outputFiles, + convertedRules, + }: { + outputFiles: ToolFile[]; + convertedRules: RuleConversion[]; + }): void { + const seen = new Map(); + const describeSource = (file: ToolFile): string => { + const source = convertedRules.find(({ toolRule }) => toolRule === file)?.rulesyncRule; + return source + ? formatRulePaths([source]) + : join(file.getRelativeDirPath(), file.getRelativeFilePath()); + }; + + for (const file of outputFiles) { + const path = join(file.getRelativeDirPath(), file.getRelativeFilePath()); + const key = path.toLowerCase(); + const previous = seen.get(key); + if (previous) { + const previousPath = join(previous.getRelativeDirPath(), previous.getRelativeFilePath()); + const pathDescription = + previousPath === path + ? `'${path}'` + : `'${previousPath}' and '${path}' (compared case-insensitively, as on macOS and Windows)`; + this.logger.warn( + `Both ${describeSource(previous)} and ${describeSource(file)} generate to ${pathDescription}; the last one wins wherever they collide.`, + ); } + seen.set(key, file); } } @@ -1438,12 +1540,6 @@ As this project's AI coding tool, you must follow the additional conventions bel factory.class.isTargetedByRulesyncRule(rule), ); - if (targetedRootRules.length > 1) { - throw new Error( - `Multiple root rulesync rules found for target '${this.toolTarget}': ${formatRulePaths(targetedRootRules)}`, - ); - } - if (targetedRootRules.length === 0 && rulesyncRules.length > 0) { this.logger.warn( `No root rulesync rule file found for target '${this.toolTarget}'. Consider adding 'root: true' to one of your rule files in ${RULESYNC_RULES_RELATIVE_DIR_PATH}.`, @@ -1473,7 +1569,7 @@ As this project's AI coding tool, you must follow the additional conventions bel const globalPaths = factory.class.getSettablePaths({ global: true }); const supportsGlobalNonRoot = ("nonRoot" in globalPaths && globalPaths.nonRoot !== null) || - (factory.meta.supportsGlobal && factory.meta.foldsNonRootIntoRoot === true); + (factory.meta.supportsGlobal && factory.meta.collisionPolicy === "fold"); const nonRootRules = rulesyncRules.filter( (rule) =>