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 34b21ee..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 { @@ -31,7 +29,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"); @@ -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) => { @@ -165,8 +160,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.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..8d15527 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,64 @@ 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; + 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({ + 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 +112,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 +182,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..b6d0ae3 --- /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" }, +}; + +export const HARNESS_NAMES = Object.keys(HARNESS_REGISTRY) as HarnessName[]; 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 6adfa9b..3156e90 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", () => ({ @@ -20,7 +21,7 @@ describe("sync-global", () => { ]; beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it("returns no writes when no global patterns configured", async () => { @@ -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,213 @@ 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: [] }); + + // 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: ["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..e643808 100644 --- a/src/core/sync-global.ts +++ b/src/core/sync-global.ts @@ -1,53 +1,142 @@ 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, GlobResult } 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. + * Returns the override glob result so callers can reuse it (avoids double-globbing). + */ +async function detectOverlap( + rulesSource: string, + globalPaths: string[], + overridePatterns: string[], + harnessName: HarnessName, +): 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(", "); + 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.`, + ); + } + + return overrideResult; } /** - * 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[] = []; + let sharedResult: GlobResult | undefined; + if (hasGlobal) { + if (hasOverrides) { + sharedResult = await globRulePaths(config.rulesSource, globalPatterns); + } + const result = await loadRules( + config.rulesSource, + globalPatterns, + sharedResult, + ); + sharedRules = result.rules; + sharedUnmatched = result.unmatchedPatterns; + } + + // 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 }]; + }); + 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 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, + overrideGlobResults.get(harnessName), + ); + 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 }; }