From 9873648dfa4b668ce2cf78205baddbeac62dd699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Mon, 6 Apr 2026 16:34:52 +0200 Subject: [PATCH 1/5] feat: add per-harness global overrides Replace flat BUILT_IN_GLOBAL_TARGETS with a typed harness registry mapping harness names (claude, gemini, opencode, codex) to target paths. Add globalOverrides config field with closed-world key validation so each harness can receive tailored global rules on top of the shared global content. Key changes: - New harness registry in src/core/harness-registry.ts - globalOverrides field in Zod config schema with per-key validation - projects field is now optional (globals-only mode) - Per-harness content composition: shared global + override rules - Same-rule overlap detection within a harness (fails loudly) - Cross-harness rule reuse is allowed - Empty targets silently skipped (no auto-delete) - Unmatched override globs reported as warnings - Full backward compatibility with existing configs Closes #31 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/run-sync-command.ts | 2 +- src/config/config.test.ts | 134 ++++++++++++++++++++-- src/config/config.ts | 93 ++++++++++++--- src/core/harness-registry.ts | 21 ++++ src/core/sync-global.test.ts | 216 ++++++++++++++++++++++++++++++++++- src/core/sync-global.ts | 126 +++++++++++++++----- 6 files changed, 536 insertions(+), 56 deletions(-) create mode 100644 src/core/harness-registry.ts diff --git a/src/cli/run-sync-command.ts b/src/cli/run-sync-command.ts index 34b21ee..668bff1 100644 --- a/src/cli/run-sync-command.ts +++ b/src/cli/run-sync-command.ts @@ -31,7 +31,7 @@ export async function runSyncCommand( const { configPath, verbose, dryRun, porcelain, json } = options; const config = await loadConfig(configPath || DEFAULT_CONFIG_PATH); - const projectsToSync: Project[] = config.projects; + const projectsToSync: Project[] = config.projects ?? []; const { syncProject } = await import("../core/sync.js"); const { syncGlobal } = await import("../core/sync-global.js"); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 38045f5..48aba15 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -38,9 +38,10 @@ describe("config", () => { const config = parseConfig(json); expect(config.projects).toHaveLength(1); + const projects = config.projects ?? []; // normalizePath will expand ~ to full home directory - expect(config.projects[0]?.path).toMatch(/\/Developer\/project$/u); - expect(config.projects[0]?.rules).toEqual(["python.md"]); + expect(projects[0]?.path).toMatch(/\/Developer\/project$/u); + expect(projects[0]?.rules).toEqual(["python.md"]); }); it("parses multiple projects", () => { @@ -60,7 +61,8 @@ describe("config", () => { const config = parseConfig(json); expect(config.projects).toHaveLength(2); - expect(config.projects[1]?.rules).toEqual(["frontend/**/*.md"]); + const projects = config.projects ?? []; + expect(projects[1]?.rules).toEqual(["frontend/**/*.md"]); }); it("accepts positive and negative POSIX globs in 'rules'", () => { @@ -75,7 +77,8 @@ describe("config", () => { }); const config = parseConfig(json); - expect(config.projects[0]?.rules).toEqual([ + const projects = config.projects ?? []; + expect(projects[0]?.rules).toEqual([ "**/*.md", "frontend/**", "!test/**", @@ -90,8 +93,72 @@ describe("config", () => { expectTypeOf(config.rulesSource).toBeString(); expect(config.rulesSource).toMatch(/sync-rules[/\\]rules$/u); expect(config.projects).toHaveLength(1); - expect(config.projects[0]?.path).toMatch(/\//u); - expect(config.projects[0]?.rules).toEqual(["test.md"]); + const projects = config.projects ?? []; + expect(projects[0]?.path).toMatch(/\//u); + expect(projects[0]?.rules).toEqual(["test.md"]); + }); + + it("accepts config with only global (no projects)", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + global: ["global-rules/*.md"], + }); + const config = parseConfig(json); + expect(config.global).toEqual(["global-rules/*.md"]); + expect(config.projects).toBeUndefined(); + }); + + it("accepts config with only globalOverrides (no projects, no global)", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + globalOverrides: { + claude: ["claude-specific/*.md"], + }, + }); + const config = parseConfig(json); + expect(config.globalOverrides).toEqual({ + claude: ["claude-specific/*.md"], + }); + expect(config.projects).toBeUndefined(); + expect(config.global).toBeUndefined(); + }); + + it("accepts config with global, globalOverrides, and projects", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + global: ["shared/*.md"], + globalOverrides: { + gemini: ["gemini/*.md"], + codex: ["codex/*.md"], + }, + projects: [{ path: "./app", rules: ["**/*.md"] }], + }); + const config = parseConfig(json); + expect(config.global).toEqual(["shared/*.md"]); + expect(config.globalOverrides).toEqual({ + gemini: ["gemini/*.md"], + codex: ["codex/*.md"], + }); + expect(config.projects).toHaveLength(1); + }); + + it("accepts all valid harness names in globalOverrides", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + globalOverrides: { + claude: ["claude/*.md"], + gemini: ["gemini/*.md"], + opencode: ["opencode/*.md"], + codex: ["codex/*.md"], + }, + }); + const config = parseConfig(json); + expect(Object.keys(config.globalOverrides ?? {})).toEqual([ + "claude", + "gemini", + "opencode", + "codex", + ]); }); }); @@ -171,6 +238,56 @@ describe("config", () => { }); // Root-level validation errors are covered by the table-driven tests + + it("rejects unknown harness names in globalOverrides", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + globalOverrides: { + unknown_harness: ["some/*.md"], + }, + }); + expect(() => parseConfig(json)).toThrowError(z.ZodError); + + let zodError: z.ZodError | undefined; + try { + parseConfig(json); + } catch (error) { + zodError = error as z.ZodError; + } + expect(zodError).toBeDefined(); + expect( + zodError?.issues.some((issue) => + issue.message.includes('Unknown harness "unknown_harness"'), + ), + ).toBe(true); + }); + + it("rejects empty glob array in globalOverrides", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + globalOverrides: { + claude: [], + }, + }); + expect(() => parseConfig(json)).toThrowError(z.ZodError); + }); + + it("rejects globalOverrides with only negative globs", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + globalOverrides: { + gemini: ["!exclude/*.md"], + }, + }); + expect(() => parseConfig(json)).toThrowError(z.ZodError); + }); + + it("rejects config with no global, no globalOverrides, and no projects", () => { + const json = JSON.stringify({ + rulesSource: "/path/to/rules", + }); + expect(() => parseConfig(json)).toThrowError(z.ZodError); + }); }); // Edge case with many projects removed - adds time with little signal @@ -442,8 +559,9 @@ describe("config", () => { const config = await loadConfig("/path/to/config.json"); // Path should be normalized (~ expanded) - expect(config.projects[0]?.path).toMatch(/\/project$/u); - expect(config.projects[0]?.path).not.toContain("~"); + const projects = config.projects ?? []; + expect(projects[0]?.path).toMatch(/\/project$/u); + expect(projects[0]?.path).not.toContain("~"); }); }); }); diff --git a/src/config/config.ts b/src/config/config.ts index b2d4258..81a6cff 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import path from "node:path"; import { normalizePath } from "../utils/paths.js"; import { DEFAULT_RULES_SOURCE } from "./constants.js"; +import { HARNESS_NAMES } from "../core/harness-registry.js"; +import type { HarnessName } from "../core/harness-registry.js"; /** * Project configuration schema. @@ -33,6 +35,56 @@ export const Project = z }) .strip(); +/** + * Validates that a glob pattern array (when provided) contains at least one + * non-empty, non-negation pattern. + */ +function hasPositiveGlob(patterns: string[] | undefined): boolean { + return ( + patterns === undefined || + patterns.some((p) => { + const t = p.trim(); + return t !== "" && !t.startsWith("!"); + }) + ); +} + +const GlobalOverrides = z + .record(z.string(), z.array(z.string())) + .optional() + .superRefine((overrides, context) => { + if (overrides === undefined) return; + for (const key of Object.keys(overrides)) { + if (!HARNESS_NAMES.includes(key as HarnessName)) { + context.addIssue({ + code: "custom", + message: `Unknown harness "${key}". Valid harness names: ${HARNESS_NAMES.join(", ")}`, + path: [key], + }); + continue; + } + const patterns = overrides[key]; + if (!patterns || patterns.length === 0) { + context.addIssue({ + code: "custom", + message: `globalOverrides.${key} cannot be empty when provided`, + path: [key], + }); + continue; + } + if (!hasPositiveGlob(patterns)) { + context.addIssue({ + code: "custom", + message: `globalOverrides.${key} must include at least one positive glob pattern`, + path: [key], + }); + } + } + }) + .describe( + "Optional per-harness override globs. Keys must be valid harness names.", + ); + /** * Main configuration schema. * Defines the central rules directory and all projects to sync. @@ -52,26 +104,36 @@ export const Config = z .refine((patterns) => patterns === undefined || patterns.length > 0, { message: "global cannot be empty when provided", }) - .refine( - (patterns) => - patterns === undefined || - patterns.some((p) => { - const t = p.trim(); - return t !== "" && !t.startsWith("!"); - }), - { - message: - 'global must include at least one positive glob pattern (e.g., "global-rules/*.md") when provided', - }, - ) + .refine((patterns) => hasPositiveGlob(patterns), { + message: + 'global must include at least one positive glob pattern (e.g., "global-rules/*.md") when provided', + }) .describe( "Optional POSIX-style globs for global rules to sync to tool-specific global files.", ), + globalOverrides: GlobalOverrides, projects: z .array(Project) - .nonempty("At least one project must be specified"), + .optional() + .refine((projects) => projects === undefined || projects.length > 0, { + message: + "projects cannot be empty when provided; omit the field instead", + }), }) - .strip(); + .strip() + .refine( + (config) => { + const hasGlobal = config.global !== undefined; + const hasOverrides = config.globalOverrides !== undefined; + const hasProjects = + config.projects !== undefined && config.projects.length > 0; + return hasGlobal || hasOverrides || hasProjects; + }, + { + message: + "Config must specify at least one of: global, globalOverrides, or projects", + }, + ); /** * Inferred types from Zod schemas @@ -112,9 +174,10 @@ export function findProjectForPath( config: Config, ): Project | undefined { const normalizedTarget = normalizePath(currentPath); + const projects = config.projects ?? []; // Find all matching projects with proper boundary checking - const matches = config.projects.filter((project) => { + const matches = projects.filter((project) => { // project.path is already normalized by Zod schema // Check if target is inside project (or is the project root itself) const relative_ = path.relative(project.path, normalizedTarget); diff --git a/src/core/harness-registry.ts b/src/core/harness-registry.ts new file mode 100644 index 0000000..d310d54 --- /dev/null +++ b/src/core/harness-registry.ts @@ -0,0 +1,21 @@ +/** + * Registry of supported AI coding assistant harnesses and their global config targets. + * + * Each harness maps to the absolute path where sync-rules writes global rules. + * The registry is the single source of truth for valid harness names and target paths. + */ + +export type HarnessName = "claude" | "gemini" | "opencode" | "codex"; + +type HarnessEntry = { + readonly target: string; +}; + +export const HARNESS_REGISTRY: Record = { + claude: { target: "~/.claude/CLAUDE.md" }, + gemini: { target: "~/.gemini/AGENTS.md" }, + opencode: { target: "~/.config/opencode/AGENTS.md" }, + codex: { target: "~/.codex/AGENTS.md" }, +} as const; + +export const HARNESS_NAMES = Object.keys(HARNESS_REGISTRY) as HarnessName[]; diff --git a/src/core/sync-global.test.ts b/src/core/sync-global.test.ts index 6adfa9b..bd20e49 100644 --- a/src/core/sync-global.test.ts +++ b/src/core/sync-global.test.ts @@ -7,6 +7,7 @@ import type { WriteAction } from "./execution.js"; vi.mock("./rules-fs.js", () => ({ loadRules: vi.fn(), + globRulePaths: vi.fn(), })); vi.mock("./execution.js", () => ({ @@ -28,7 +29,6 @@ describe("sync-global", () => { { dryRun: false }, { rulesSource: "/rules", - projects: [], }, ); expect(result.written).toEqual([]); @@ -44,7 +44,6 @@ describe("sync-global", () => { { rulesSource: "/rules", global: ["global-rules/*.md"], - projects: [], }, ); expect(filesystemModule.loadRules).toHaveBeenCalled(); @@ -53,13 +52,11 @@ describe("sync-global", () => { }); it("writes combined content to all global targets", async () => { - // Mock rules vi.mocked(filesystemModule.loadRules).mockResolvedValue({ rules: mockRules, unmatchedPatterns: [], }); - // Mock executeActions vi.mocked(executionModule.executeActions).mockResolvedValue({ written: ["/home/user/.claude/CLAUDE.md", "/home/user/.codex/AGENTS.md"], skipped: [], @@ -70,7 +67,6 @@ describe("sync-global", () => { { rulesSource: "/rules", global: ["global-rules/*.md"], - projects: [], }, ); @@ -88,4 +84,214 @@ describe("sync-global", () => { ).toBe(true); expect(result.written).toHaveLength(2); }); + + describe("per-harness overrides", () => { + it("composes shared global + per-harness override content", async () => { + const sharedRules: Rule[] = [ + { path: "shared.md", content: "# Shared\nContent" }, + ]; + const overrideRules: Rule[] = [ + { path: "claude-extra.md", content: "# Claude Extra\nStuff" }, + ]; + + // First call: loadRules for shared global + // Second call: loadRules for claude override + vi.mocked(filesystemModule.loadRules) + .mockResolvedValueOnce({ rules: sharedRules, unmatchedPatterns: [] }) + .mockResolvedValueOnce({ rules: overrideRules, unmatchedPatterns: [] }) + .mockResolvedValue({ rules: [], unmatchedPatterns: [] }); + + // No overlap + vi.mocked(filesystemModule.globRulePaths) + .mockResolvedValueOnce({ paths: ["shared.md"], unmatchedPatterns: [] }) + .mockResolvedValueOnce({ + paths: ["claude-extra.md"], + unmatchedPatterns: [], + }); + + vi.mocked(executionModule.executeActions).mockResolvedValue({ + written: [], + skipped: [], + }); + + await syncGlobal( + { dryRun: false }, + { + rulesSource: "/rules", + global: ["shared/*.md"], + globalOverrides: { + claude: ["claude/*.md"], + }, + }, + ); + + expect(executionModule.executeActions).toHaveBeenCalledTimes(1); + const actionsArgument: WriteAction[] = + vi.mocked(executionModule.executeActions).mock.calls[0]?.[0] ?? []; + + // Claude should have composed content (shared + override) + const claudeAction = actionsArgument.find((a) => + a.path.endsWith("CLAUDE.md"), + ); + expect(claudeAction).toBeDefined(); + expect(claudeAction?.content).toBe( + "# Shared\nContent\n\n# Claude Extra\nStuff", + ); + + // Other harnesses should have only shared content + const geminiAction = actionsArgument.find((a) => + a.path.includes("gemini"), + ); + expect(geminiAction).toBeDefined(); + expect(geminiAction?.content).toBe("# Shared\nContent"); + }); + + it("writes only to harnesses with override content when no global patterns", async () => { + const overrideRules: Rule[] = [ + { path: "codex-only.md", content: "# Codex Only" }, + ]; + + vi.mocked(filesystemModule.loadRules).mockResolvedValueOnce({ + rules: overrideRules, + unmatchedPatterns: [], + }); + + vi.mocked(executionModule.executeActions).mockResolvedValue({ + written: [], + skipped: [], + }); + + await syncGlobal( + { dryRun: false }, + { + rulesSource: "/rules", + globalOverrides: { + codex: ["codex/*.md"], + }, + }, + ); + + expect(executionModule.executeActions).toHaveBeenCalledTimes(1); + const actionsArgument: WriteAction[] = + vi.mocked(executionModule.executeActions).mock.calls[0]?.[0] ?? []; + + // Only codex should have content + expect(actionsArgument).toHaveLength(1); + expect(actionsArgument[0]?.path).toMatch(/\.codex/u); + expect(actionsArgument[0]?.content).toBe("# Codex Only"); + }); + + it("throws on rule overlap between global and override for same harness", async () => { + vi.mocked(filesystemModule.loadRules).mockResolvedValue({ + rules: mockRules, + unmatchedPatterns: [], + }); + + // Simulate overlap: same file in both global and claude override + vi.mocked(filesystemModule.globRulePaths) + .mockResolvedValueOnce({ + paths: ["shared.md", "overlap.md"], + unmatchedPatterns: [], + }) + .mockResolvedValueOnce({ + paths: ["overlap.md", "claude-only.md"], + unmatchedPatterns: [], + }); + + await expect( + syncGlobal( + { dryRun: false }, + { + rulesSource: "/rules", + global: ["shared/*.md"], + globalOverrides: { + claude: ["claude/*.md"], + }, + }, + ), + ).rejects.toThrowError(/Rule overlap for harness "claude"/u); + }); + + it("allows same rule file across different harnesses", async () => { + const sharedRules: Rule[] = [{ path: "shared.md", content: "# Shared" }]; + const overrideRules: Rule[] = [{ path: "extra.md", content: "# Extra" }]; + + vi.mocked(filesystemModule.loadRules) + .mockResolvedValueOnce({ rules: sharedRules, unmatchedPatterns: [] }) + .mockResolvedValueOnce({ rules: overrideRules, unmatchedPatterns: [] }) + .mockResolvedValueOnce({ rules: overrideRules, unmatchedPatterns: [] }) + .mockResolvedValue({ rules: [], unmatchedPatterns: [] }); + + // No overlap within any single harness + vi.mocked(filesystemModule.globRulePaths) + .mockResolvedValueOnce({ paths: ["shared.md"], unmatchedPatterns: [] }) + .mockResolvedValueOnce({ paths: ["extra.md"], unmatchedPatterns: [] }) + .mockResolvedValueOnce({ paths: ["shared.md"], unmatchedPatterns: [] }) + .mockResolvedValueOnce({ paths: ["extra.md"], unmatchedPatterns: [] }); + + vi.mocked(executionModule.executeActions).mockResolvedValue({ + written: [], + skipped: [], + }); + + // Should not throw - same file in different harnesses is fine + await syncGlobal( + { dryRun: false }, + { + rulesSource: "/rules", + global: ["shared/*.md"], + globalOverrides: { + claude: ["extra/*.md"], + gemini: ["extra/*.md"], + }, + }, + ); + + expect(executionModule.executeActions).toHaveBeenCalledTimes(1); + }); + + it("reports unmatched override patterns", async () => { + vi.mocked(filesystemModule.loadRules).mockResolvedValue({ + rules: [], + unmatchedPatterns: ["nonexistent/*.md"], + }); + + const result = await syncGlobal( + { dryRun: false }, + { + rulesSource: "/rules", + globalOverrides: { + claude: ["nonexistent/*.md"], + }, + }, + ); + + expect(result.unmatchedPatterns).toContainEqual( + "globalOverrides.claude: nonexistent/*.md", + ); + }); + + it("skips harnesses with no content", async () => { + vi.mocked(filesystemModule.loadRules).mockResolvedValue({ + rules: [], + unmatchedPatterns: ["missing/*.md"], + }); + + vi.mocked(executionModule.executeActions).mockResolvedValue({ + written: [], + skipped: [], + }); + + const result = await syncGlobal( + { dryRun: false }, + { + rulesSource: "/rules", + global: ["missing/*.md"], + }, + ); + + // No writes when all rules are empty + expect(result.written).toEqual([]); + }); + }); }); diff --git a/src/core/sync-global.ts b/src/core/sync-global.ts index 62220ff..6c69755 100644 --- a/src/core/sync-global.ts +++ b/src/core/sync-global.ts @@ -1,53 +1,125 @@ import type { Config } from "../config/config.js"; -import { loadRules } from "./rules-fs.js"; +import { loadRules, globRulePaths } from "./rules-fs.js"; import { concatenateRules } from "./concatenate-rules.js"; import { executeActions } from "./execution.js"; import type { RunFlags, ExecutionReport, WriteAction } from "./execution.js"; import { normalizePath } from "../utils/paths.js"; +import { HARNESS_REGISTRY, HARNESS_NAMES } from "./harness-registry.js"; +import type { HarnessName } from "./harness-registry.js"; +import type { Rule } from "./rules-fs.js"; interface GlobalSyncResult extends ExecutionReport { unmatchedPatterns: string[]; } -// Built-in global target files for supported tools -const BUILT_IN_GLOBAL_TARGETS = [ - "~/.claude/CLAUDE.md", // Claude Code - "~/.gemini/AGENTS.md", // Gemini CLI - "~/.config/opencode/AGENTS.md", // OpenCode - "~/.codex/AGENTS.md", // Codex CLI -] as const; - -function getGlobalTargetPaths(): string[] { - // Normalize so paths stay consistent across platforms - const targets = BUILT_IN_GLOBAL_TARGETS.map((p) => normalizePath(p)); - return targets; +/** + * Detect rule file overlap between global patterns and per-harness override patterns. + * Throws if the same rule file would be included twice for a single harness. + */ +async function detectOverlap( + rulesSource: string, + globalPatterns: string[], + overridePatterns: string[], + harnessName: HarnessName, +): Promise { + const [globalResult, overrideResult] = await Promise.all([ + globRulePaths(rulesSource, globalPatterns), + globRulePaths(rulesSource, overridePatterns), + ]); + + const globalPaths = new Set(globalResult.paths); + const overlapping = overrideResult.paths.filter((p) => globalPaths.has(p)); + + if (overlapping.length > 0) { + const fileList = overlapping.join(", "); + throw new Error( + `Rule overlap for harness "${harnessName}": the following files appear in both "global" and "globalOverrides.${harnessName}": ${fileList}. Remove duplicates from one or the other.`, + ); + } } /** - * Synchronize global rules to the built-in absolute target paths. - * Combines all selected global rule files into one content and writes it to each target path. + * Synchronize global rules to harness-specific target paths. + * + * For each harness in the registry: + * 1. Start with shared `global` rules content (if any) + * 2. Append per-harness override content (if any) + * 3. Write the combined content to the harness target path + * 4. Skip harnesses with no content (no writes, no errors) */ export async function syncGlobal( flags: RunFlags, config: Config, ): Promise { - const patterns = config.global; - if (!patterns || patterns.length === 0) { + const globalPatterns = config.global; + const overrides = config.globalOverrides; + const hasGlobal = globalPatterns !== undefined && globalPatterns.length > 0; + const hasOverrides = + overrides !== undefined && Object.keys(overrides).length > 0; + + if (!hasGlobal && !hasOverrides) { return { written: [], skipped: [], unmatchedPatterns: [] }; } - const { rules, unmatchedPatterns } = await loadRules( - config.rulesSource, - patterns, - ); - if (rules.length === 0) { - return { written: [], skipped: [], unmatchedPatterns }; + // Load shared global rules once + let sharedRules: Rule[] = []; + let sharedUnmatched: string[] = []; + if (hasGlobal) { + const result = await loadRules(config.rulesSource, globalPatterns); + sharedRules = result.rules; + sharedUnmatched = result.unmatchedPatterns; + } + + // Detect overlaps for each harness that has overrides + if (hasGlobal && hasOverrides) { + await Promise.all( + HARNESS_NAMES.filter((name) => overrides[name] !== undefined).map( + (name) => { + const patterns = overrides[name]; + if (!patterns) return Promise.resolve(); + return detectOverlap( + config.rulesSource, + globalPatterns, + patterns, + name, + ); + }, + ), + ); } - const content = concatenateRules(rules); - const targets = getGlobalTargetPaths(); - const actions: WriteAction[] = targets.map((path) => ({ path, content })); + const actions: WriteAction[] = []; + const allUnmatched = [...sharedUnmatched]; + + for (const harnessName of HARNESS_NAMES) { + const entry = HARNESS_REGISTRY[harnessName]; + const targetPath = normalizePath(entry.target); + const overridePatterns = overrides?.[harnessName]; + + let overrideRules: Rule[] = []; + if (overridePatterns !== undefined && overridePatterns.length > 0) { + const result = await loadRules(config.rulesSource, overridePatterns); + overrideRules = result.rules; + if (result.unmatchedPatterns.length > 0) { + allUnmatched.push( + ...result.unmatchedPatterns.map( + (p) => `globalOverrides.${harnessName}: ${p}`, + ), + ); + } + } + + const combinedRules = [...sharedRules, ...overrideRules]; + if (combinedRules.length === 0) continue; + + const content = concatenateRules(combinedRules); + actions.push({ path: targetPath, content }); + } + + if (actions.length === 0) { + return { written: [], skipped: [], unmatchedPatterns: allUnmatched }; + } const report = await executeActions(actions, flags); - return { ...report, unmatchedPatterns }; + return { ...report, unmatchedPatterns: allUnmatched }; } From 493bcb4f79b667ff241422bdfb2cf5d96d312d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Mon, 6 Apr 2026 17:25:34 +0200 Subject: [PATCH 2/5] fix: reject empty globalOverrides and fix verbose message for globals-only mode - Reject `globalOverrides: {}` in config validation, matching the existing behavior for `global: []` and `projects: []`. - Fix misleading 'No projects configured; nothing to do.' verbose message when only global rules are synced (no projects configured). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/run-sync-command.ts | 5 ++++- src/config/config.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cli/run-sync-command.ts b/src/cli/run-sync-command.ts index 668bff1..dc93b30 100644 --- a/src/cli/run-sync-command.ts +++ b/src/cli/run-sync-command.ts @@ -165,8 +165,11 @@ export async function runSyncCommand( const totalWrites = allWritten.length; - if (projectsToSync.length === 0) { + if (projectsToSync.length === 0 && totalWrites === 0) { console.error("No projects configured; nothing to do."); + } else if (projectsToSync.length === 0) { + const action = dryRun ? "Would write" : "Wrote"; + console.error(`${action} ${String(totalWrites)} global file(s).`); } else if (totalWrites === 0) { console.error("No changes. Rules matched no files or files up to date."); } else { diff --git a/src/config/config.ts b/src/config/config.ts index 81a6cff..8d15527 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -54,6 +54,14 @@ const GlobalOverrides = z .optional() .superRefine((overrides, context) => { if (overrides === undefined) return; + if (Object.keys(overrides).length === 0) { + context.addIssue({ + code: "custom", + message: + "globalOverrides cannot be empty when provided; omit the field instead", + }); + return; + } for (const key of Object.keys(overrides)) { if (!HARNESS_NAMES.includes(key as HarnessName)) { context.addIssue({ From 853c24daaa1b2e1ad2dee84d2eecd0e3dcaac1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Mon, 6 Apr 2026 17:40:33 +0200 Subject: [PATCH 3/5] fix: document globalOverrides, optimize glob paths, and fix warning labels - Document globalOverrides in README with config example and field descriptions - Note that projects is optional (globals-only config is valid) - Optimize syncGlobal to pre-glob shared global paths once and pass resolved paths to detectOverlap instead of re-globbing per harness - Accept pre-computed GlobResult in loadRules to avoid redundant globbing - Extract pattern warning logic into collect-pattern-warnings module - Parse globalOverrides prefix in unmatched pattern warnings to assign distinct source labels (e.g. 'in globalOverrides.claude' not 'in global') - Fix test mock sequence to match optimized glob call order - Use resetAllMocks in beforeEach to prevent mock leakage between tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 +++- src/cli/collect-pattern-warnings.ts | 41 +++++++++++++++++ src/cli/run-sync-command.ts | 19 +++----- src/core/rules-fs.ts | 12 ++--- src/core/sync-global.test.ts | 5 +- src/core/sync-global.ts | 71 ++++++++++++++++++----------- 6 files changed, 107 insertions(+), 50 deletions(-) create mode 100644 src/cli/collect-pattern-warnings.ts diff --git a/README.md b/README.md index 738634e..866ba70 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ Edit the `config.json` file to define your setup. { "rulesSource": "/path/to/my/central/rules/repository", "global": ["global-rules/*.md"], + "globalOverrides": { + "claude": ["claude-specific/*.md"], + "codex": ["codex-specific/*.md"] + }, "projects": [ { "path": "~/Developer/my-backend-api", @@ -76,10 +80,13 @@ Edit the `config.json` file to define your setup. - `rulesSource`: The central directory where you store your rule files (e.g., Markdown files). If omitted, it defaults to the system's data directory. - `global`: Optional POSIX globs for rules that are combined and written to built-in global target files for supported tools (e.g., `~/.claude/CLAUDE.md`, `~/.gemini/AGENTS.md`, `~/.config/opencode/AGENTS.md`, `~/.codex/AGENTS.md`). -- `projects`: An array defining each project. +- `globalOverrides`: Optional per-harness override globs. Each key is a harness name (`claude`, `gemini`, `opencode`, `codex`) with its own glob patterns. Override rules are appended after the shared `global` rules for that harness only. A rule file must not appear in both `global` and an override for the same harness. +- `projects`: Optional array defining each project. Can be omitted for a globals-only config. - `path`: The root directory of the project (supports `~` for home directory). - `rules`: POSIX-style glob patterns to select files from `rulesSource`. Supports negation (`!`). +Config must specify at least one of `global`, `globalOverrides`, or `projects`. + ### 3\. Synchronize Rules To synchronize the rules for all configured projects, run the default command: diff --git a/src/cli/collect-pattern-warnings.ts b/src/cli/collect-pattern-warnings.ts new file mode 100644 index 0000000..1455036 --- /dev/null +++ b/src/cli/collect-pattern-warnings.ts @@ -0,0 +1,41 @@ +export type PatternWarning = { + source: string; + patterns: string[]; +}; + +const GLOBAL_OVERRIDE_PATTERN = /^globalOverrides\.([^:]+): (.+)$/u; + +function addPatternWarning( + warnings: PatternWarning[], + source: string, + pattern: string, +): void { + const existing = warnings.find((warning) => warning.source === source); + if (existing) { + existing.patterns.push(pattern); + return; + } + warnings.push({ source, patterns: [pattern] }); +} + +/** + * Convert raw unmatched patterns from syncGlobal into structured warnings. + * + * Patterns prefixed with `globalOverrides.:` are grouped under + * the corresponding override source; everything else is grouped under + * `"global"`. + */ +export function collectGlobalPatternWarnings( + unmatchedPatterns: string[], +): PatternWarning[] { + const warnings: PatternWarning[] = []; + for (const pattern of unmatchedPatterns) { + const match = GLOBAL_OVERRIDE_PATTERN.exec(pattern); + if (match?.[1] && match[2]) { + addPatternWarning(warnings, `globalOverrides.${match[1]}`, match[2]); + continue; + } + addPatternWarning(warnings, "global", pattern); + } + return warnings; +} diff --git a/src/cli/run-sync-command.ts b/src/cli/run-sync-command.ts index dc93b30..924ab94 100644 --- a/src/cli/run-sync-command.ts +++ b/src/cli/run-sync-command.ts @@ -6,6 +6,9 @@ import type { SyncResult } from "../core/sync.js"; import type { SkippedEntry } from "../core/execution.js"; import { formatSyncFailureMessage } from "./format-sync-failures.js"; +import { collectGlobalPatternWarnings } from "./collect-pattern-warnings.js"; +import type { PatternWarning } from "./collect-pattern-warnings.js"; + interface SyncCommandOptions { configPath: string; verbose: boolean; @@ -14,11 +17,6 @@ interface SyncCommandOptions { json: boolean; } -type PatternWarning = { - source: string; - patterns: string[]; -}; - function isFulfilled( s: PromiseSettledResult, ): s is PromiseFulfilledResult { @@ -39,13 +37,10 @@ export async function runSyncCommand( const globalResult = await syncGlobal({ dryRun }, config); // Track warnings for unmatched patterns - const patternWarnings: PatternWarning[] = []; - if (globalResult.unmatchedPatterns.length > 0) { - patternWarnings.push({ - source: "global", - patterns: globalResult.unmatchedPatterns, - }); - } + const patternWarnings: PatternWarning[] = + globalResult.unmatchedPatterns.length > 0 + ? collectGlobalPatternWarnings(globalResult.unmatchedPatterns) + : []; const settlements = await Promise.allSettled( projectsToSync.map(async (project: Project) => { diff --git a/src/core/rules-fs.ts b/src/core/rules-fs.ts index 6b65cb7..03d1377 100644 --- a/src/core/rules-fs.ts +++ b/src/core/rules-fs.ts @@ -7,7 +7,7 @@ import { SyncError, ensureError } from "../utils/errors.js"; /** * Result of glob matching with tracking of unmatched patterns. */ -type GlobResult = { +export type GlobResult = { paths: string[]; unmatchedPatterns: string[]; }; @@ -136,11 +136,9 @@ type LoadRulesResult = { export async function loadRules( rulesDirectory: string, patterns: string[], + globResult?: GlobResult, ): Promise { - const { paths, unmatchedPatterns } = await globRulePaths( - rulesDirectory, - patterns, - ); - const rules = await readRuleContents(rulesDirectory, paths); - return { rules, unmatchedPatterns }; + const result = globResult ?? (await globRulePaths(rulesDirectory, patterns)); + const rules = await readRuleContents(rulesDirectory, result.paths); + return { rules, unmatchedPatterns: result.unmatchedPatterns }; } diff --git a/src/core/sync-global.test.ts b/src/core/sync-global.test.ts index bd20e49..3156e90 100644 --- a/src/core/sync-global.test.ts +++ b/src/core/sync-global.test.ts @@ -21,7 +21,7 @@ describe("sync-global", () => { ]; beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it("returns no writes when no global patterns configured", async () => { @@ -222,11 +222,10 @@ describe("sync-global", () => { .mockResolvedValueOnce({ rules: overrideRules, unmatchedPatterns: [] }) .mockResolvedValue({ rules: [], unmatchedPatterns: [] }); - // No overlap within any single harness + // Pre-glob shared paths once, then one overlap check per harness vi.mocked(filesystemModule.globRulePaths) .mockResolvedValueOnce({ paths: ["shared.md"], unmatchedPatterns: [] }) .mockResolvedValueOnce({ paths: ["extra.md"], unmatchedPatterns: [] }) - .mockResolvedValueOnce({ paths: ["shared.md"], unmatchedPatterns: [] }) .mockResolvedValueOnce({ paths: ["extra.md"], unmatchedPatterns: [] }); vi.mocked(executionModule.executeActions).mockResolvedValue({ diff --git a/src/core/sync-global.ts b/src/core/sync-global.ts index 6c69755..17b0ace 100644 --- a/src/core/sync-global.ts +++ b/src/core/sync-global.ts @@ -6,7 +6,7 @@ import type { RunFlags, ExecutionReport, WriteAction } from "./execution.js"; import { normalizePath } from "../utils/paths.js"; import { HARNESS_REGISTRY, HARNESS_NAMES } from "./harness-registry.js"; import type { HarnessName } from "./harness-registry.js"; -import type { Rule } from "./rules-fs.js"; +import type { Rule, GlobResult } from "./rules-fs.js"; interface GlobalSyncResult extends ExecutionReport { unmatchedPatterns: string[]; @@ -15,20 +15,17 @@ interface GlobalSyncResult extends ExecutionReport { /** * Detect rule file overlap between global patterns and per-harness override patterns. * Throws if the same rule file would be included twice for a single harness. + * Returns the override glob result so callers can reuse it (avoids double-globbing). */ async function detectOverlap( rulesSource: string, - globalPatterns: string[], + globalPaths: string[], overridePatterns: string[], harnessName: HarnessName, -): Promise { - const [globalResult, overrideResult] = await Promise.all([ - globRulePaths(rulesSource, globalPatterns), - globRulePaths(rulesSource, overridePatterns), - ]); - - const globalPaths = new Set(globalResult.paths); - const overlapping = overrideResult.paths.filter((p) => globalPaths.has(p)); +): Promise { + const overrideResult = await globRulePaths(rulesSource, overridePatterns); + const globalPathSet = new Set(globalPaths); + const overlapping = overrideResult.paths.filter((p) => globalPathSet.has(p)); if (overlapping.length > 0) { const fileList = overlapping.join(", "); @@ -36,6 +33,8 @@ async function detectOverlap( `Rule overlap for harness "${harnessName}": the following files appear in both "global" and "globalOverrides.${harnessName}": ${fileList}. Remove duplicates from one or the other.`, ); } + + return overrideResult; } /** @@ -64,28 +63,42 @@ export async function syncGlobal( // Load shared global rules once let sharedRules: Rule[] = []; let sharedUnmatched: string[] = []; + let sharedPaths: string[] = []; if (hasGlobal) { - const result = await loadRules(config.rulesSource, globalPatterns); + const sharedResult = hasOverrides + ? await globRulePaths(config.rulesSource, globalPatterns) + : undefined; + const result = await loadRules( + config.rulesSource, + globalPatterns, + sharedResult, + ); sharedRules = result.rules; sharedUnmatched = result.unmatchedPatterns; + sharedPaths = sharedResult?.paths ?? []; } - // Detect overlaps for each harness that has overrides - if (hasGlobal && hasOverrides) { - await Promise.all( - HARNESS_NAMES.filter((name) => overrides[name] !== undefined).map( - (name) => { - const patterns = overrides[name]; - if (!patterns) return Promise.resolve(); - return detectOverlap( - config.rulesSource, - globalPatterns, - patterns, - name, - ); - }, - ), + // Detect overlaps and pre-glob overrides for each harness + const overrideGlobResults = new Map(); + if (hasOverrides) { + const overrideEntries = HARNESS_NAMES.flatMap((name) => { + const patterns = overrides[name]; + return patterns === undefined ? [] : [{ name, patterns }]; + }); + const results = await Promise.all( + overrideEntries.map(({ name, patterns }) => { + if (hasGlobal) { + return detectOverlap(config.rulesSource, sharedPaths, patterns, name); + } + return globRulePaths(config.rulesSource, patterns); + }), ); + for (const [index, { name }] of overrideEntries.entries()) { + const result = results[index]; + if (result) { + overrideGlobResults.set(name, result); + } + } } const actions: WriteAction[] = []; @@ -98,7 +111,11 @@ export async function syncGlobal( let overrideRules: Rule[] = []; if (overridePatterns !== undefined && overridePatterns.length > 0) { - const result = await loadRules(config.rulesSource, overridePatterns); + const result = await loadRules( + config.rulesSource, + overridePatterns, + overrideGlobResults.get(harnessName), + ); overrideRules = result.rules; if (result.unmatchedPatterns.length > 0) { allUnmatched.push( From a509cbf5519f6271046cc5967bcc605d89357634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Mon, 6 Apr 2026 17:59:15 +0200 Subject: [PATCH 4/5] refactor: scope sharedPaths to override detection block Move sharedPaths variable inside the if(hasOverrides) block where it is actually consumed. Hoists sharedResult to function scope so both the global loading and override detection blocks can access it. This makes the coupling between global glob results and overlap detection explicit, preventing future misuse of a silently-empty sharedPaths array. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/sync-global.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/sync-global.ts b/src/core/sync-global.ts index 17b0ace..e643808 100644 --- a/src/core/sync-global.ts +++ b/src/core/sync-global.ts @@ -63,11 +63,11 @@ export async function syncGlobal( // Load shared global rules once let sharedRules: Rule[] = []; let sharedUnmatched: string[] = []; - let sharedPaths: string[] = []; + let sharedResult: GlobResult | undefined; if (hasGlobal) { - const sharedResult = hasOverrides - ? await globRulePaths(config.rulesSource, globalPatterns) - : undefined; + if (hasOverrides) { + sharedResult = await globRulePaths(config.rulesSource, globalPatterns); + } const result = await loadRules( config.rulesSource, globalPatterns, @@ -75,12 +75,12 @@ export async function syncGlobal( ); sharedRules = result.rules; sharedUnmatched = result.unmatchedPatterns; - sharedPaths = sharedResult?.paths ?? []; } // Detect overlaps and pre-glob overrides for each harness const overrideGlobResults = new Map(); if (hasOverrides) { + const sharedPaths = sharedResult?.paths ?? []; const overrideEntries = HARNESS_NAMES.flatMap((name) => { const patterns = overrides[name]; return patterns === undefined ? [] : [{ name, patterns }]; From 5dcdd44b47378ee641772ca2f0e66f7d3868032a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Mon, 6 Apr 2026 18:04:43 +0200 Subject: [PATCH 5/5] fix: remove redundant registry assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/harness-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/harness-registry.ts b/src/core/harness-registry.ts index d310d54..b6d0ae3 100644 --- a/src/core/harness-registry.ts +++ b/src/core/harness-registry.ts @@ -16,6 +16,6 @@ export const HARNESS_REGISTRY: Record = { gemini: { target: "~/.gemini/AGENTS.md" }, opencode: { target: "~/.config/opencode/AGENTS.md" }, codex: { target: "~/.codex/AGENTS.md" }, -} as const; +}; export const HARNESS_NAMES = Object.keys(HARNESS_REGISTRY) as HarnessName[];