From 1c5c1fae18c84f7d559f96eece79106f6c9f4e7d Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Sun, 26 Jul 2026 19:07:26 +0200 Subject: [PATCH 1/7] feat(rules): compose multiple global root rules --- docs/guide/global-mode.md | 2 +- skills/rulesync/global-mode.md | 2 +- src/e2e/e2e-rules.spec.ts | 20 ++++++ src/features/rules/rules-processor.test.ts | 74 ++++++++++++++++++++++ src/features/rules/rules-processor.ts | 25 ++++---- 5 files changed, 108 insertions(+), 15 deletions(-) diff --git a/docs/guide/global-mode.md b/docs/guide/global-mode.md index 44730f930..390f9b6e4 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. When they resolve to the same global output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files. > - Only Claude Code is supported for global mode commands. diff --git a/skills/rulesync/global-mode.md b/skills/rulesync/global-mode.md index 44730f930..390f9b6e4 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. When they resolve to the same global output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files. > - 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 6d05e3c94..c14f2b132 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -870,11 +870,25 @@ globs: ["**/*"] # Global Test Rule This is a global test rule for E2E testing. +`; + const additionalRuleContent = `--- +root: true +targets: ["*"] +description: "Additional global test rule" +--- + +# Additional Root Fragment + +This is an additional global test rule for E2E testing. `; await writeFileContent( join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), ruleContent, ); + await writeFileContent( + join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "additional-global-rule.md"), + additionalRuleContent, + ); await runGenerate({ target, @@ -885,6 +899,12 @@ This is a global test rule for E2E testing. const generatedContent = await readFileContent(join(homeDir, outputPath)); expect(generatedContent).toContain("Global Test Rule"); + expect(generatedContent).toContain("Additional Root Fragment"); + expect(generatedContent.split("Global Test Rule")).toHaveLength(2); + expect(generatedContent.split("Additional Root Fragment")).toHaveLength(2); + expect(generatedContent.indexOf("Additional Root Fragment")).toBeLessThan( + generatedContent.indexOf("Global Test Rule"), + ); }, ); diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index bdad4fba4..471ee7f52 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -1318,6 +1318,46 @@ 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("should convert using global paths when global=true for codexcli", async () => { const processor = new RulesProcessor({ logger, @@ -2288,6 +2328,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 09e2f1392..da250d910 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -927,8 +927,8 @@ export class RulesProcessor extends FeatureProcessor { }) .filter((rule): rule is ToolRule => rule !== null); - if (meta.foldsNonRootIntoRoot) { - this.foldNonRootRulesIntoRootRule(toolRules); + if (meta.foldsNonRootIntoRoot || this.global) { + this.mergeRulesByOutputPath(toolRules); } this.applyLocalRootRules({ toolRules, localRootRules, factory }); @@ -1109,20 +1109,19 @@ export class RulesProcessor extends FeatureProcessor { } /** - * Fold every non-root rule body into the single root rule file. + * Merge 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). + * Global mode can compose multiple root rules into one target file. This is + * also used for tools whose rules engine reads only one root file and therefore + * folds non-root rule bodies into it. Grouping by output path preserves tools + * that intentionally route rules to separate files, such as Pi's + * `APPEND_SYSTEM.md`. * * 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. + * otherwise the first rule is used. Source discovery order is preserved and + * fragments are separated by one blank line. Mutates `toolRules` in place. */ - private foldNonRootRulesIntoRootRule(toolRules: ToolRule[]): void { + private mergeRulesByOutputPath(toolRules: ToolRule[]): void { if (toolRules.length <= 1) { return; } @@ -1419,7 +1418,7 @@ As this project's AI coding tool, you must follow the additional conventions bel factory.class.isTargetedByRulesyncRule(rule), ); - if (targetedRootRules.length > 1) { + if (!this.global && targetedRootRules.length > 1) { throw new Error( `Multiple root rulesync rules found for target '${this.toolTarget}': ${formatRulePaths(targetedRootRules)}`, ); From a53a57a4ffc1b793cd44ac2ecd0f9fd23e95359b Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Sun, 26 Jul 2026 19:50:19 +0200 Subject: [PATCH 2/7] feat(rules): compose project root rules --- docs/guide/global-mode.md | 2 +- docs/reference/file-formats.md | 4 +- skills/rulesync/file-formats.md | 4 +- skills/rulesync/global-mode.md | 2 +- src/e2e/e2e-rules.spec.ts | 65 ++++++++++++++++- src/features/rules/rules-processor.test.ts | 84 ++++++++++++++++++++-- src/features/rules/rules-processor.ts | 14 +--- 7 files changed, 153 insertions(+), 22 deletions(-) diff --git a/docs/guide/global-mode.md b/docs/guide/global-mode.md index 390f9b6e4..5a09e933c 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. -> - Multiple `root: true` files can target the same tool. When they resolve to the same global output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files. +> - Multiple `root: true` files can target the same tool in project and global modes. When they resolve to the same output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files; distinct native paths remain separate. > - Only Claude Code is supported for global mode commands. diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 2d2067f76..a01750396 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): 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" @@ -58,6 +58,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 files that resolve to the same output path in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. + > **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 b9812b1da..314a76f51 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): 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" @@ -58,6 +58,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 files that resolve to the same output path in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. + > **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 390f9b6e4..5a09e933c 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. -> - Multiple `root: true` files can target the same tool. When they resolve to the same global output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files. +> - Multiple `root: true` files can target the same tool in project and global modes. When they resolve to the same output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files; distinct native paths remain separate. > - 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 c14f2b132..e5ae6c5c8 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -128,6 +128,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(); @@ -899,6 +952,16 @@ This is an additional global test rule for E2E testing. const generatedContent = await readFileContent(join(homeDir, outputPath)); expect(generatedContent).toContain("Global Test Rule"); + + if (target === "augmentcode" || target === "takt") { + const additionalGeneratedContent = await readFileContent( + join(homeDir, dirname(outputPath), "additional-global-rule.md"), + ); + expect(generatedContent).not.toContain("Additional Root Fragment"); + expect(additionalGeneratedContent).toContain("Additional Root Fragment"); + return; + } + expect(generatedContent).toContain("Additional Root Fragment"); expect(generatedContent.split("Global Test Rule")).toHaveLength(2); expect(generatedContent.split("Additional Root Fragment")).toHaveLength(2); diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 471ee7f52..1f0d09d90 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -1358,6 +1358,68 @@ Content that would fail parsing`; 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); + } + }, + ); + it("should convert using global paths when global=true for codexcli", async () => { const processor = new RulesProcessor({ logger, @@ -2205,7 +2267,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"), @@ -2230,12 +2292,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"), @@ -2260,9 +2327,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 () => { diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index da250d910..50ca87ab2 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -927,9 +927,7 @@ export class RulesProcessor extends FeatureProcessor { }) .filter((rule): rule is ToolRule => rule !== null); - if (meta.foldsNonRootIntoRoot || this.global) { - this.mergeRulesByOutputPath(toolRules); - } + this.mergeRulesByOutputPath(toolRules); this.applyLocalRootRules({ toolRules, localRootRules, factory }); @@ -1111,8 +1109,8 @@ export class RulesProcessor extends FeatureProcessor { /** * Merge rules that resolve to the same output path. * - * Global mode can compose multiple root rules into one target file. This is - * also used for tools whose rules engine reads only one root file and therefore + * Project and global modes can compose multiple root rules into one target + * file. This is also used for tools whose rules engine reads only one root file and therefore * folds non-root rule bodies into it. Grouping by output path preserves tools * that intentionally route rules to separate files, such as Pi's * `APPEND_SYSTEM.md`. @@ -1418,12 +1416,6 @@ As this project's AI coding tool, you must follow the additional conventions bel factory.class.isTargetedByRulesyncRule(rule), ); - if (!this.global && 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}.`, From 0f237b48fec605a08e31963b5f67c0d062b7a191 Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Sun, 26 Jul 2026 20:29:13 +0200 Subject: [PATCH 3/7] fix(rules): reject unsafe output collisions --- docs/guide/global-mode.md | 2 +- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- skills/rulesync/global-mode.md | 2 +- src/features/rules/rules-processor.test.ts | 113 +++++++++++++++++++++ src/features/rules/rules-processor.ts | 56 +++++++--- 6 files changed, 161 insertions(+), 16 deletions(-) diff --git a/docs/guide/global-mode.md b/docs/guide/global-mode.md index 5a09e933c..8669b8869 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. -> - Multiple `root: true` files can target the same tool in project and global modes. When they resolve to the same output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files; distinct native paths remain separate. +> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in deterministic source-discovery order with a blank line between files; distinct native paths remain separate, and unsafe modular or case-only path collisions fail explicitly. > - Only Claude Code is supported for global mode commands. diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index a01750396..62afa8b32 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -58,7 +58,7 @@ 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 files that resolve to the same output path in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. +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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, or if generated paths differ only by case, generation fails instead of emitting malformed or platform-dependent files. > **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 . diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 314a76f51..b8244e04b 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -58,7 +58,7 @@ 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 files that resolve to the same output path in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. +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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, or if generated paths differ only by case, generation fails instead of emitting malformed or platform-dependent files. > **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 . diff --git a/skills/rulesync/global-mode.md b/skills/rulesync/global-mode.md index 5a09e933c..8669b8869 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. -> - Multiple `root: true` files can target the same tool in project and global modes. When they resolve to the same output path, Rulesync combines their rendered content in deterministic source-discovery order with a blank line between files; distinct native paths remain separate. +> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in deterministic source-discovery order with a blank line between files; distinct native paths remain separate, and unsafe modular or case-only path collisions fail explicitly. > - Only Claude Code is supported for global mode commands. diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 1f0d09d90..967cd9ad8 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -1420,6 +1420,119 @@ Content that would fail parsing`; }, ); + it.each(["devin", "antigravity-ide"] as const)( + "should reject metadata-bearing project rules normalized to the same $toolTarget 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", + }), + ]; + + await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( + `Multiple generated rules resolve to output path`, + ); + }, + ); + + 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")}'`, + ); + }); + + it("should reject generated output paths that differ only by case", 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: "Policy" }, + }, + body: "# Uppercase Policy", + }), + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "second.md", + frontmatter: { + root: true, + targets: ["takt"], + takt: { name: "policy" }, + }, + body: "# Lowercase Policy", + }), + ]; + + await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( + "Generated rule output paths differ only by case", + ); + }); + it("should convert using global paths when global=true for codexcli", async () => { const processor = new RulesProcessor({ logger, diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 50ca87ab2..df1f80dd4 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -927,7 +927,9 @@ export class RulesProcessor extends FeatureProcessor { }) .filter((rule): rule is ToolRule => rule !== null); - this.mergeRulesByOutputPath(toolRules); + this.mergeRulesByOutputPath(toolRules, { + mergeNonRootRules: meta.foldsNonRootIntoRoot === true, + }); this.applyLocalRootRules({ toolRules, localRootRules, factory }); @@ -1111,15 +1113,19 @@ export class RulesProcessor extends FeatureProcessor { * * Project and global modes can compose multiple root rules into one target * file. This is also used for tools whose rules engine reads only one root file and therefore - * folds non-root rule bodies into it. Grouping by output path preserves tools - * that intentionally route rules to separate files, such as Pi's - * `APPEND_SYSTEM.md`. + * folds non-root rule bodies into it. Modular non-root files cannot be safely + * concatenated after rendering because their metadata formats may conflict, + * so collisions between those files are rejected. * - * The root rule (if any) becomes the merge target and leads the merged content; - * otherwise the first rule is used. Source discovery order is preserved and - * fragments are separated by one blank line. Mutates `toolRules` in place. + * The root rule becomes the merge target for root groups. Explicitly folding + * tools use the first rule when no root exists. Source discovery order is + * preserved and fragments are separated by one blank line. Mutates `toolRules` + * in place. */ - private mergeRulesByOutputPath(toolRules: ToolRule[]): void { + private mergeRulesByOutputPath( + toolRules: ToolRule[], + { mergeNonRootRules }: { mergeNonRootRules: boolean }, + ): void { if (toolRules.length <= 1) { return; } @@ -1140,11 +1146,37 @@ export class RulesProcessor extends FeatureProcessor { } } + const caseFoldedPaths = new Map(); + for (const path of groups.keys()) { + const caseFoldedPath = path.normalize("NFC").toLowerCase(); + const existingPath = caseFoldedPaths.get(caseFoldedPath); + if (existingPath && existingPath !== path) { + throw new Error( + `Generated rule output paths differ only by case for target '${this.toolTarget}': '${existingPath}', '${path}'`, + ); + } + caseFoldedPaths.set(caseFoldedPath, path); + } + 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]; + for (const [path, group] of groups) { + if (group.length === 1) { + const rule = group[0]; + if (rule) { + survivors.add(rule); + } + continue; + } + + // Root-path groups prefer the root rule as their merge target. Explicitly + // folding tools use their first rule when no root exists. + const rootRule = group.find((rule) => rule.isRoot()); + if (!rootRule && !mergeNonRootRules) { + throw new Error( + `Multiple generated rules resolve to output path '${path}' for target '${this.toolTarget}', but this target does not support composing modular rule files`, + ); + } + const target = rootRule ?? group[0]; if (!target) { continue; } From 2192d995fb93e260c1a9f5bbadfa12ef5a6d49e0 Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Mon, 27 Jul 2026 11:11:27 +0200 Subject: [PATCH 4/7] fix(rules): address multi-root review feedback --- docs/guide/global-mode.md | 2 +- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- skills/rulesync/global-mode.md | 2 +- src/e2e/e2e-rules.spec.ts | 84 +++++++++---- src/features/rules/rules-processor.test.ts | 131 +++++++++++++++------ src/features/rules/rules-processor.ts | 45 ++++--- 7 files changed, 185 insertions(+), 83 deletions(-) diff --git a/docs/guide/global-mode.md b/docs/guide/global-mode.md index 8669b8869..ef6f1b29b 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. -> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in deterministic source-discovery order with a blank line between files; distinct native paths remain separate, and unsafe modular or case-only path collisions fail explicitly. +> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in lexicographic source file path order with a blank line between files, so filename prefixes can control composition order. Distinct native paths remain separate, and exact modular path collisions fail explicitly. > - Only Claude Code is supported for global mode commands. diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 62afa8b32..b47b81b25 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -58,7 +58,7 @@ 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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, or if generated paths differ only by case, generation fails instead of emitting malformed or platform-dependent files. +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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Fragments are ordered lexicographically by source file path, so filename prefixes such as `10-` and `20-` control composition order. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, generation fails instead of emitting malformed files or silently overwriting content. > **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 . diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index b8244e04b..47709ce02 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -58,7 +58,7 @@ 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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, or if generated paths differ only by case, generation fails instead of emitting malformed or platform-dependent files. +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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Fragments are ordered lexicographically by source file path, so filename prefixes such as `10-` and `20-` control composition order. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, generation fails instead of emitting malformed files or silently overwriting content. > **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 . diff --git a/skills/rulesync/global-mode.md b/skills/rulesync/global-mode.md index 8669b8869..ef6f1b29b 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. -> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in deterministic source-discovery order with a blank line between files; distinct native paths remain separate, and unsafe modular or case-only path collisions fail explicitly. +> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in lexicographic source file path order with a blank line between files, so filename prefixes can control composition order. Distinct native paths remain separate, and exact modular path collisions fail explicitly. > - 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 e5ae6c5c8..8830282ca 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -1,4 +1,4 @@ -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -924,19 +924,66 @@ globs: ["**/*"] This is a global test rule for E2E testing. `; - const additionalRuleContent = `--- + await writeFileContent( + join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), + ruleContent, + ); + + await runGenerate({ + target, + features: "rules", + global: true, + env: { HOME_DIR: homeDir }, + }); + + const generatedContent = await readFileContent(join(homeDir, outputPath)); + expect(generatedContent).toContain("Global Test Rule"); + }, + ); + + 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: "Additional global test rule" +description: "Global root rule" --- -# Additional Root Fragment +# Global Root Fragment +`; + const additionalRuleContent = `--- +root: true +targets: ["*"] +description: "Additional global root rule" +--- -This is an additional global test rule for E2E testing. +# Additional Global Root Fragment `; await writeFileContent( join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), - ruleContent, + rootRuleContent, ); await writeFileContent( join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "additional-global-rule.md"), @@ -950,24 +997,13 @@ This is an additional global test rule for E2E testing. env: { HOME_DIR: homeDir }, }); - const generatedContent = await readFileContent(join(homeDir, outputPath)); - expect(generatedContent).toContain("Global Test Rule"); - - if (target === "augmentcode" || target === "takt") { - const additionalGeneratedContent = await readFileContent( - join(homeDir, dirname(outputPath), "additional-global-rule.md"), - ); - expect(generatedContent).not.toContain("Additional Root Fragment"); - expect(additionalGeneratedContent).toContain("Additional Root Fragment"); - return; - } - - expect(generatedContent).toContain("Additional Root Fragment"); - expect(generatedContent.split("Global Test Rule")).toHaveLength(2); - expect(generatedContent.split("Additional Root Fragment")).toHaveLength(2); - expect(generatedContent.indexOf("Additional Root Fragment")).toBeLessThan( - generatedContent.indexOf("Global Test Rule"), - ); + 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); }, ); diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 967cd9ad8..f21a7869d 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -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, @@ -1456,7 +1487,7 @@ Content that would fail parsing`; ]; await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( - `Multiple generated rules resolve to output path`, + `Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "CodingGuidelines.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "coding_guidelines.md")}`, ); }, ); @@ -1493,45 +1524,73 @@ Content that would fail parsing`; ]; await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( - `Multiple generated rules resolve to output path '${join(".takt", "facets", "policies", "same.md")}'`, + `Multiple generated rules resolve to output path '${join(".takt", "facets", "policies", "same.md")}' for target 'takt', but this target does not support composing modular rule files. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "first.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "second.md")}`, ); }); - it("should reject generated output paths that differ only by case", 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: "Policy" }, - }, - body: "# Uppercase Policy", - }), - new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, - relativeFilePath: "second.md", - frontmatter: { - root: true, - targets: ["takt"], - takt: { name: "policy" }, - }, - body: "# Lowercase Policy", - }), - ]; + 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", + }), + ]; - await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( - "Generated rule output paths differ only by case", - ); - }); + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const outputPaths = result.map((rule) => + join(rule.getRelativeDirPath(), rule.getRelativeFilePath()), + ); + + expect(outputPaths).toEqual(expectedPaths); + }, + ); it("should convert using global paths when global=true for codexcli", async () => { const processor = new RulesProcessor({ diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index df1f80dd4..83fa5e8b8 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -912,23 +912,28 @@ export class RulesProcessor extends FeatureProcessor { const factory = this.getFactory(this.toolTarget); const { meta } = factory; + const sourceRuleByToolRule = new Map(); const toolRules = nonLocalRootRules .map((rulesyncRule) => { 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, }); + sourceRuleByToolRule.set(toolRule, rulesyncRule); + return toolRule; }) .filter((rule): rule is ToolRule => rule !== null); - this.mergeRulesByOutputPath(toolRules, { + this.mergeRulesByOutputPath({ + toolRules, mergeNonRootRules: meta.foldsNonRootIntoRoot === true, + sourceRuleByToolRule, }); this.applyLocalRootRules({ toolRules, localRootRules, factory }); @@ -1052,6 +1057,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; @@ -1122,10 +1130,15 @@ export class RulesProcessor extends FeatureProcessor { * preserved and fragments are separated by one blank line. Mutates `toolRules` * in place. */ - private mergeRulesByOutputPath( - toolRules: ToolRule[], - { mergeNonRootRules }: { mergeNonRootRules: boolean }, - ): void { + private mergeRulesByOutputPath({ + toolRules, + mergeNonRootRules, + sourceRuleByToolRule, + }: { + toolRules: ToolRule[]; + mergeNonRootRules: boolean; + sourceRuleByToolRule: ReadonlyMap; + }): void { if (toolRules.length <= 1) { return; } @@ -1146,23 +1159,14 @@ export class RulesProcessor extends FeatureProcessor { } } - const caseFoldedPaths = new Map(); - for (const path of groups.keys()) { - const caseFoldedPath = path.normalize("NFC").toLowerCase(); - const existingPath = caseFoldedPaths.get(caseFoldedPath); - if (existingPath && existingPath !== path) { - throw new Error( - `Generated rule output paths differ only by case for target '${this.toolTarget}': '${existingPath}', '${path}'`, - ); - } - caseFoldedPaths.set(caseFoldedPath, path); - } - const survivors = new Set(); for (const [path, group] of groups) { if (group.length === 1) { const rule = group[0]; if (rule) { + if (mergeNonRootRules) { + rule.setFileContent(rule.getFileContent().trim()); + } survivors.add(rule); } continue; @@ -1172,8 +1176,11 @@ export class RulesProcessor extends FeatureProcessor { // folding tools use their first rule when no root exists. const rootRule = group.find((rule) => rule.isRoot()); if (!rootRule && !mergeNonRootRules) { + const sourceRules = group + .map((rule) => sourceRuleByToolRule.get(rule)) + .filter((rule): rule is RulesyncRule => rule !== undefined); throw new Error( - `Multiple generated rules resolve to output path '${path}' for target '${this.toolTarget}', but this target does not support composing modular rule files`, + `Multiple generated rules resolve to output path '${path}' for target '${this.toolTarget}', but this target does not support composing modular rule files. Source rules: ${formatRulePaths(sourceRules)}`, ); } const target = rootRule ?? group[0]; From 48a7d6365f705019ff8127c36dfd8814eb05d730 Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Mon, 27 Jul 2026 16:39:52 +0200 Subject: [PATCH 5/7] fix(rules): address collision review feedback --- docs/guide/global-mode.md | 2 +- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- skills/rulesync/global-mode.md | 2 +- src/features/rules/rules-processor.test.ts | 77 +++++++++++++++++++++- src/features/rules/rules-processor.ts | 25 +++++-- 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/docs/guide/global-mode.md b/docs/guide/global-mode.md index ef6f1b29b..64dc7561d 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. -> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in lexicographic source file path order with a blank line between files, so filename prefixes can control composition order. Distinct native paths remain separate, and exact modular path collisions fail explicitly. +> - 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; plain-Markdown modular path collisions are combined, while targets whose modular format carries metadata reject such collisions explicitly. > - Only Claude Code is supported for global mode commands. diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index b47b81b25..88158e797 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -58,7 +58,7 @@ 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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Fragments are ordered lexicographically by source file path, so filename prefixes such as `10-` and `20-` control composition order. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, generation fails instead of emitting malformed files or silently overwriting content. +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. Plain-Markdown modular rules that normalize to the same output path are combined, while targets whose modular format carries metadata reject such collisions instead of emitting malformed files or silently overwriting content. > **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 . diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 47709ce02..cc2bc5b61 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -58,7 +58,7 @@ 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 single-file outputs in deterministic source-discovery order with one blank line between fragments. Fragments are ordered lexicographically by source file path, so filename prefixes such as `10-` and `20-` control composition order. Targets that map source rules to distinct native paths keep those files separate. If modular rules normalize to the same output path, generation fails instead of emitting malformed files or silently overwriting content. +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. Plain-Markdown modular rules that normalize to the same output path are combined, while targets whose modular format carries metadata reject such collisions instead of emitting malformed files or silently overwriting content. > **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 . diff --git a/skills/rulesync/global-mode.md b/skills/rulesync/global-mode.md index ef6f1b29b..64dc7561d 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. -> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or single-file outputs are combined in lexicographic source file path order with a blank line between files, so filename prefixes can control composition order. Distinct native paths remain separate, and exact modular path collisions fail explicitly. +> - 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; plain-Markdown modular path collisions are combined, while targets whose modular format carries metadata reject such collisions explicitly. > - Only Claude Code is supported for global mode commands. diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index f21a7869d..61d6acae7 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -1492,6 +1492,81 @@ Content that would fail parsing`; }, ); + 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 colliding modular rule files. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "overview.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "product.md")}`, + ); + }); + + it("should compose plain Markdown modular rules with the same output path", async () => { + const processor = new RulesProcessor({ + logger, + outputRoot: testDir, + toolTarget: "agentsmd", + }); + const rulesyncRules = [ + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "first.md", + frontmatter: { + root: false, + targets: ["agentsmd"], + 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: ["agentsmd"], + agentsmd: { subprojectPath: "packages/app" }, + }, + body: "# Second Subproject Rule", + }), + ]; + + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(AgentsMdRule); + expect(result[0]?.getRelativeDirPath()).toBe(join("packages", "app")); + expect(result[0]?.getRelativeFilePath()).toBe("AGENTS.md"); + expect(result[0]?.getFileContent()).toBe( + "# First Subproject Rule\n\n# Second Subproject Rule", + ); + }); + it("should reject Takt rules with the same overridden output name", async () => { const processor = new RulesProcessor({ logger, @@ -1524,7 +1599,7 @@ Content that would fail parsing`; ]; 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 does not support composing modular rule files. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "first.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "second.md")}`, + `Multiple generated rules resolve to output path '${join(".takt", "facets", "policies", "same.md")}' for target 'takt', but this target cannot safely compose colliding modular rule files. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "first.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "second.md")}`, ); }); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 83fa5e8b8..6457d0e3d 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -277,6 +277,8 @@ type ToolRuleFactory = { * read only one root `AGENTS.md` and never scan a non-root directory. */ foldsNonRootIntoRoot?: boolean; + /** Whether colliding non-root outputs are plain Markdown that can be composed safely. */ + composesSamePathNonRootRules?: boolean; /** * 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; }): void { if (toolRules.length <= 1) { @@ -1173,14 +1183,17 @@ export class RulesProcessor extends FeatureProcessor { } // Root-path groups prefer the root rule as their merge target. Explicitly - // folding tools use their first rule when no root exists. + // folding tools use their first rule when no root exists. Root fragments are + // composable, while adapters must explicitly declare colliding non-root + // outputs safe because their metadata formats vary by target. const rootRule = group.find((rule) => rule.isRoot()); - if (!rootRule && !mergeNonRootRules) { + const hasNonRootRule = group.some((rule) => !rule.isRoot()); + if (!mergeNonRootRules && !composeSamePathNonRootRules && hasNonRootRule) { const sourceRules = group .map((rule) => sourceRuleByToolRule.get(rule)) .filter((rule): rule is RulesyncRule => rule !== undefined); throw new Error( - `Multiple generated rules resolve to output path '${path}' for target '${this.toolTarget}', but this target does not support composing modular rule files. Source rules: ${formatRulePaths(sourceRules)}`, + `Multiple generated rules resolve to output path '${path}' for target '${this.toolTarget}', but this target cannot safely compose colliding modular rule files. Source rules: ${formatRulePaths(sourceRules)}`, ); } const target = rootRule ?? group[0]; From ee1a42a93aef3954623b25e493c98a243143329f Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Tue, 28 Jul 2026 14:17:34 +0200 Subject: [PATCH 6/7] fix(rules): narrow output collision handling --- .rulesync/rules/overview.md | 2 +- docs/guide/global-mode.md | 2 +- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- skills/rulesync/global-mode.md | 2 +- src/features/rules/rules-processor.test.ts | 333 ++++++++++++--------- src/features/rules/rules-processor.ts | 197 +++++++----- 7 files changed, 309 insertions(+), 231 deletions(-) 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 64dc7561d..5994954b1 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. -> - 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; plain-Markdown modular path collisions are combined, while targets whose modular format carries metadata reject such collisions explicitly. +> - 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, 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 88158e797..73b3f959b 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -58,7 +58,7 @@ 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. Plain-Markdown modular rules that normalize to the same output path are combined, while targets whose modular format carries metadata reject such collisions instead of emitting malformed files or silently overwriting content. +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. 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 . diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index cc2bc5b61..ef4b3060a 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -58,7 +58,7 @@ 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. Plain-Markdown modular rules that normalize to the same output path are combined, while targets whose modular format carries metadata reject such collisions instead of emitting malformed files or silently overwriting content. +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. 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 . diff --git a/skills/rulesync/global-mode.md b/skills/rulesync/global-mode.md index 64dc7561d..5994954b1 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. -> - 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; plain-Markdown modular path collisions are combined, while targets whose modular format carries metadata reject such collisions explicitly. +> - 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, 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/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 61d6acae7..22bfa0093 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", () => { @@ -1450,86 +1450,96 @@ Content that would fail parsing`; } }, ); + }); + }); - it.each(["devin", "antigravity-ide"] as const)( - "should reject metadata-bearing project rules normalized to the same $toolTarget 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", - }), - ]; - - await expect(processor.convertRulesyncFilesToToolFiles(rulesyncRules)).rejects.toThrow( - `Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "CodingGuidelines.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "coding_guidelines.md")}`, - ); - }, - ); - - it("should reject root collisions with metadata-bearing modular rules", async () => { + 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: "kiro", - global: true, + toolTarget, }); const rulesyncRules = [ new RulesyncRule({ outputRoot: testDir, relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, - relativeFilePath: "overview.md", - frontmatter: { root: true, targets: ["kiro"] }, - body: "# Root Body", + 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: "product.md", + relativeFilePath: "coding_guidelines.md", frontmatter: { root: false, - targets: ["kiro"], - globs: ["src/**/*.ts"], + targets: [toolTarget], + description: "Second normalized rule", + globs: ["src/**/*"], }, - body: "# Non Root Body", + body: "# Second Normalized Rule", }), ]; - 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 colliding modular rule files. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "overview.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "product.md")}`, + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + + expect(result).toHaveLength(2); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + "(compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.", + ), ); + }, + ); + + 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", + }), + ]; - it("should compose plain Markdown modular rules with the same output path", async () => { + 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: "agentsmd", + toolTarget, }); const rulesyncRules = [ new RulesyncRule({ @@ -1538,7 +1548,7 @@ Content that would fail parsing`; relativeFilePath: "first.md", frontmatter: { root: false, - targets: ["agentsmd"], + targets: [toolTarget], agentsmd: { subprojectPath: "packages/app" }, }, body: "# First Subproject Rule", @@ -1549,7 +1559,7 @@ Content that would fail parsing`; relativeFilePath: "second.md", frontmatter: { root: false, - targets: ["agentsmd"], + targets: [toolTarget], agentsmd: { subprojectPath: "packages/app" }, }, body: "# Second Subproject Rule", @@ -1557,116 +1567,125 @@ Content that would fail parsing`; ]; const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const composedRules = result.filter( + (file) => file.getRelativeDirPath() === join("packages", "app"), + ); - expect(result).toHaveLength(1); - expect(result[0]).toBeInstanceOf(AgentsMdRule); - expect(result[0]?.getRelativeDirPath()).toBe(join("packages", "app")); - expect(result[0]?.getRelativeFilePath()).toBe("AGENTS.md"); - expect(result[0]?.getFileContent()).toBe( + expect(composedRules).toHaveLength(1); + expect(composedRules[0]?.getFileContent()).toBe( "# First Subproject Rule\n\n# Second Subproject Rule", ); - }); + }, + ); - it("should reject Takt rules with the same overridden output name", async () => { - const processor = new RulesProcessor({ - logger, + 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, - toolTarget: "takt", - }); + 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: "first.md", + 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: true, - targets: ["takt"], - takt: { name: "same", extends: "base-one" }, + root: false, + targets: [toolTarget], + description: "Uppercase API rule", + globs: ["**/*"], }, - body: "# First Takt Rule", + body: "# Uppercase API", }), new RulesyncRule({ outputRoot: testDir, relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, - relativeFilePath: "second.md", + relativeFilePath: "api.md", frontmatter: { - root: true, - targets: ["takt"], - takt: { name: "same", extends: "base-two" }, + root: false, + targets: [toolTarget], + description: "Lowercase API rule", + globs: ["src/**/*"], }, - body: "# Second Takt Rule", + body: "# Lowercase API", }), ]; - 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 colliding modular rule files. Source rules: ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "first.md")}, ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, "second.md")}`, + const result = await processor.convertRulesyncFilesToToolFiles(rulesyncRules); + const outputPaths = result.map((rule) => + join(rule.getRelativeDirPath(), rule.getRelativeFilePath()), ); - }); - 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(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, @@ -2355,6 +2374,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( diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 6457d0e3d..0e23bd386 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,13 +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; - /** Whether colliding non-root outputs are plain Markdown that can be composed safely. */ - composesSamePathNonRootRules?: 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 @@ -305,7 +305,7 @@ export const toolRuleFactories = new Map/AGENTS.md`. supportsGlobal: true, ruleDiscoveryMode: "auto", - foldsNonRootIntoRoot: true, + collisionPolicy: "fold", }, }, ], @@ -511,7 +511,7 @@ export const toolRuleFactories = new Map(); - - const toolRules = nonLocalRootRules + const convertedRules = nonLocalRootRules .map((rulesyncRule) => { if (!factory.class.isTargetedByRulesyncRule(rulesyncRule)) { return null; @@ -932,17 +930,15 @@ export class RulesProcessor extends FeatureProcessor { validate: true, global: this.global, }); - sourceRuleByToolRule.set(toolRule, rulesyncRule); - return toolRule; + return { toolRule, rulesyncRule }; }) - .filter((rule): rule is ToolRule => rule !== null); + .filter((rule): rule is RuleConversion => rule !== null); this.mergeRulesByOutputPath({ - toolRules, - mergeNonRootRules: meta.foldsNonRootIntoRoot === true, - composeSamePathNonRootRules: meta.composesSamePathNonRootRules === true, - sourceRuleByToolRule, + convertedRules, + collisionPolicy: meta.collisionPolicy ?? "preserve", }); + const toolRules = convertedRules.map(({ toolRule }) => toolRule); this.applyLocalRootRules({ toolRules, localRootRules, factory }); @@ -952,7 +948,9 @@ export class RulesProcessor extends FeatureProcessor { this.applyRootRuleSections({ toolRules, factory }); - return [...toolRules, ...extraFiles]; + const outputFiles = [...toolRules, ...extraFiles]; + this.warnForOutputPathCollisions({ outputFiles, convertedRules }); + return outputFiles; } /** @@ -1125,31 +1123,29 @@ export class RulesProcessor extends FeatureProcessor { } /** - * Merge rules that resolve to the same output path. + * Reconcile rules that resolve to the same output path. * - * Project and global modes can compose multiple root rules into one target - * file. This is also used for tools whose rules engine reads only one root file and therefore - * folds non-root rule bodies into it. Adapters whose colliding modular outputs - * are plain Markdown opt into composition; other modular collisions are rejected - * because their rendered metadata formats may conflict. + * 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 becomes the merge target for root groups. Explicitly folding - * tools use the first rule when no root exists. Source discovery order is - * preserved and fragments are separated by one blank line. Mutates `toolRules` - * in place. + * A generated root rule becomes the merge target when present. A `fold` group + * without one uses its first rule. Root-involved collisions that cannot be + * composed safely fail; pre-existing modular collisions remain separate and are + * reported by the final output-path check. Mutates `convertedRules` in place. */ private mergeRulesByOutputPath({ - toolRules, - mergeNonRootRules, - composeSamePathNonRootRules, - sourceRuleByToolRule, + convertedRules, + collisionPolicy, }: { - toolRules: ToolRule[]; - mergeNonRootRules: boolean; - composeSamePathNonRootRules: boolean; - sourceRuleByToolRule: ReadonlyMap; + convertedRules: RuleConversion[]; + collisionPolicy: RuleCollisionPolicy; }): void { - if (toolRules.length <= 1) { + if (convertedRules.length <= 1) { return; } @@ -1158,63 +1154,102 @@ 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(); + const survivors = new Set(); for (const [path, group] of groups) { if (group.length === 1) { - const rule = group[0]; - if (rule) { - if (mergeNonRootRules) { - rule.setFileContent(rule.getFileContent().trim()); + const conversion = group[0]; + if (conversion) { + if (collisionPolicy === "fold") { + conversion.toolRule.setFileContent(conversion.toolRule.getFileContent().trim()); } - survivors.add(rule); + survivors.add(conversion); } continue; } - // Root-path groups prefer the root rule as their merge target. Explicitly - // folding tools use their first rule when no root exists. Root fragments are - // composable, while adapters must explicitly declare colliding non-root - // outputs safe because their metadata formats vary by target. - const rootRule = group.find((rule) => rule.isRoot()); - const hasNonRootRule = group.some((rule) => !rule.isRoot()); - if (!mergeNonRootRules && !composeSamePathNonRootRules && hasNonRootRule) { - const sourceRules = group - .map((rule) => sourceRuleByToolRule.get(rule)) - .filter((rule): rule is RulesyncRule => rule !== undefined); + const rootConversion = group.find(({ toolRule }) => toolRule.isRoot()); + const allGeneratedRulesAreRoots = group.every(({ toolRule }) => toolRule.isRoot()); + const hasSourceRoot = group.some( + ({ rulesyncRule }) => rulesyncRule.getFrontmatter().root === true, + ); + const shouldCompose = + collisionPolicy === "fold" || collisionPolicy === "compose" || allGeneratedRulesAreRoots; + + if (!shouldCompose && hasSourceRoot) { throw new Error( - `Multiple generated rules resolve to output path '${path}' for target '${this.toolTarget}', but this target cannot safely compose colliding modular rule files. Source rules: ${formatRulePaths(sourceRules)}`, + `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))}`, ); } - const target = rootRule ?? group[0]; + + 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()); + this.logger.warn( + `Both ${describeSource(previous)} and ${describeSource(file)} generate to '${previousPath}' and '${path}' (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`, + ); } + seen.set(key, file); } } @@ -1497,7 +1532,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) => From 8be0cee6778887f6460aa7bcd9469fef908dbf9b Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Wed, 29 Jul 2026 00:13:10 -0700 Subject: [PATCH 7/7] fix(rules): never compose fragments that carry generated frontmatter Since #2410, Amp prepends a globs: frontmatter gate to non-root files, so composing two gated fragments buried the second gate mid-body where Amp never reads it. Composition safety is now decided by content: a colliding group only composes when every rendered fragment is plain Markdown; otherwise it falls back to preserve-and-warn, or fails when a source root rule is involved. Exact-path collision warnings also stop repeating the same path twice. Co-Authored-By: Claude Fable 5 --- docs/guide/global-mode.md | 2 +- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- skills/rulesync/global-mode.md | 2 +- src/features/rules/rules-processor.test.ts | 62 ++++++++++++++++++++-- src/features/rules/rules-processor.ts | 28 ++++++++-- 6 files changed, 86 insertions(+), 12 deletions(-) diff --git a/docs/guide/global-mode.md b/docs/guide/global-mode.md index 5994954b1..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. -> - 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, 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. +> - 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 675381bbf..0ffceb924 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -61,7 +61,7 @@ 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. 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. +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 . diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 3cd8ef434..6a0fa93d5 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -61,7 +61,7 @@ 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. 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. +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 . diff --git a/skills/rulesync/global-mode.md b/skills/rulesync/global-mode.md index 5994954b1..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. -> - 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, 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. +> - 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/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 0b1231234..dd3223602 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -1506,10 +1506,13 @@ Content that would fail parsing`; 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( - "(compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.", - ), + expect.stringContaining("; the last one wins wherever they collide."), + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining("compared case-insensitively"), ); }, ); @@ -1592,6 +1595,59 @@ Content that would fail parsing`; }, ); + 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, diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index f47e9581f..f7c14b708 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -1143,9 +1143,12 @@ export class RulesProcessor extends FeatureProcessor { * Plain-Markdown adapters can opt into `compose` for colliding modular outputs. * * A generated root rule becomes the merge target when present. A `fold` group - * without one uses its first rule. Root-involved collisions that cannot be - * composed safely fail; pre-existing modular collisions remain separate and are - * reported by the final output-path check. Mutates `convertedRules` in place. + * 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 mergeRulesByOutputPath({ convertedRules, @@ -1195,8 +1198,19 @@ export class RulesProcessor extends FeatureProcessor { 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; + (collisionPolicy === "fold" || + collisionPolicy === "compose" || + allGeneratedRulesAreRoots) && + allFragmentsArePlain; if (!shouldCompose && hasSourceRoot) { throw new Error( @@ -1254,8 +1268,12 @@ export class RulesProcessor extends FeatureProcessor { 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 '${previousPath}' and '${path}' (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`, + `Both ${describeSource(previous)} and ${describeSource(file)} generate to ${pathDescription}; the last one wins wherever they collide.`, ); } seen.set(key, file);