diff --git a/src/lib/generate.test.ts b/src/lib/generate.test.ts index b4d0f6c59..cf2f8c25a 100644 --- a/src/lib/generate.test.ts +++ b/src/lib/generate.test.ts @@ -12,7 +12,12 @@ import { SkillsProcessor } from "../features/skills/skills-processor.js"; import { SubagentsProcessor } from "../features/subagents/subagents-processor.js"; import { createMockLogger } from "../test-utils/mock-logger.js"; import { fileExists, readFileContentOrNull } from "../utils/file.js"; -import { checkRulesyncDirExists, generate, resolveExecutionOrder } from "./generate.js"; +import { + checkRulesyncDirExists, + generate, + GENERATION_STEP_GRAPH, + resolveExecutionOrder, +} from "./generate.js"; const logger = createMockLogger(); @@ -1063,6 +1068,8 @@ describe("generate", () => { }); }); +const stubRun = async () => ({ count: 0, paths: [], hasDiff: false }); + const executionStep = ( id: string, opts: { writesSharedFile?: string[]; dependsOn?: string[] } = {}, @@ -1070,7 +1077,7 @@ const executionStep = ( ({ id, ...opts, - run: async () => ({ count: 0, paths: [], hasDiff: false }), + run: stubRun, }) as never; describe("resolveExecutionOrder", () => { @@ -1148,3 +1155,45 @@ describe("resolveExecutionOrder", () => { ).toThrow(/cyclic/); }); }); + +const asRunnableSteps = () => + GENERATION_STEP_GRAPH.map((meta) => ({ ...meta, run: stubRun }) as never); + +describe("GENERATION_STEP_GRAPH", () => { + it("is well-formed: every shared-file writer pair is ordered by dependsOn", () => { + expect(() => resolveExecutionOrder(asRunnableSteps())).not.toThrow(); + }); + + it("declares every dependsOn edge for a reason: an overlapping writesSharedFile token, or the known rules->skills value dependency", () => { + const byId = new Map(GENERATION_STEP_GRAPH.map((meta) => [meta.id, meta])); + const knownValueDependencies = new Set(["rules->skills"]); + + for (const step of GENERATION_STEP_GRAPH) { + for (const dep of step.dependsOn ?? []) { + const edgeKey = `${step.id}->${dep}`; + const depFiles = new Set(byId.get(dep)?.writesSharedFile ?? []); + const sharesFile = (step.writesSharedFile ?? []).some((file) => depFiles.has(file)); + + expect( + sharesFile || knownValueDependencies.has(edgeKey), + `dependsOn edge '${edgeKey}' has no overlapping writesSharedFile token and is not a ` + + `documented value dependency; either it's stale or the known-value-dependency list ` + + `needs updating`, + ).toBe(true); + } + } + }); + + it("pins the full execution order of the real generation step graph", () => { + expect(resolveExecutionOrder(asRunnableSteps()).map((s) => s.id)).toEqual([ + "ignore", + "commands", + "subagents", + "skills", + "mcp", + "hooks", + "permissions", + "rules", + ]); + }); +}); diff --git a/src/lib/generate.ts b/src/lib/generate.ts index c5c95895a..fa43997ae 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -201,9 +201,9 @@ type GenerationStepId = type GenerationStep = { id: GenerationStepId; /** Tokens for on-disk files this step read-modify-writes and shares with other steps. */ - writesSharedFile?: string[]; + writesSharedFile?: readonly string[]; /** Step ids that must run before this one (they write a shared file this step then reads). */ - dependsOn?: GenerationStepId[]; + dependsOn?: readonly GenerationStepId[]; run: () => Promise; }; @@ -300,6 +300,94 @@ export function resolveExecutionOrder(steps: GenerationStep[]): GenerationStep[] return ordered; } +type GenerationStepMeta = Readonly>; + +/** + * The static shape of the generation step graph: which steps write which shared + * (read-modify-write) config files, and the `dependsOn` edges that fix a safe order + * for those writers. Exported (separately from the `run` closures, which need a + * live `config`/`logger`) so `resolveExecutionOrder`'s ordering guarantee can be + * tested directly against the real graph rather than a hand-copied one. Readonly + * so a consumer can't mutate this module-level singleton and affect every + * subsequent `generate()` call in the process. + */ +export const GENERATION_STEP_GRAPH: readonly GenerationStepMeta[] = [ + { + id: "ignore", + writesSharedFile: ["claude-settings", "zed-settings"], + }, + { + id: "mcp", + writesSharedFile: [ + "kilo-opencode-config", + "zed-settings", + "qwencode-settings", + "augmentcode-settings", + "hermesagent-config", + "amp-settings", + "codexcli-config", + "grokcli-config", + "vibe-config", + "devin-config", + "reasonix-config", + ], + // Both ignore and mcp write zed-settings; ignore must run first so mcp's + // read-modify-write doesn't drop the ignore keys it wrote. + dependsOn: ["ignore"], + }, + { id: "commands" }, + { id: "subagents" }, + { id: "skills" }, + { + id: "hooks", + writesSharedFile: [ + "claude-settings", + "qwencode-settings", + "augmentcode-settings", + "hermesagent-config", + "kiro-agent-config", + "codexcli-config", + "vibe-config", + "devin-config", + ], + // Shares claude-settings with ignore, and shares qwencode-settings, + // augmentcode-settings, hermesagent-config, codexcli-config, vibe-config, + // and devin-config with mcp; both must run first so hooks' read-modify-write + // doesn't drop their keys. + dependsOn: ["ignore", "mcp"], + }, + { + id: "permissions", + writesSharedFile: [ + "claude-settings", + "kilo-opencode-config", + "zed-settings", + "qwencode-settings", + "augmentcode-settings", + "hermesagent-config", + "kiro-agent-config", + "amp-settings", + "codexcli-config", + "grokcli-config", + "vibe-config", + "devin-config", + "reasonix-config", + ], + // Shares claude-settings/zed-settings with ignore, every token hooks writes, + // and every token mcp writes; all three must run first so permissions' + // read-modify-write doesn't drop their keys. + dependsOn: ["ignore", "hooks", "mcp"], + }, + { + id: "rules", + writesSharedFile: ["kilo-opencode-config"], + // Shares kilo-opencode-config with mcp and permissions (both must run first), + // and reads the skills list the skills step produces (a value dependency, + // not a shared-file one). + dependsOn: ["mcp", "skills", "permissions"], + }, +]; + /** * Generate configuration files for AI tools. * @throws Error if generation fails @@ -313,81 +401,24 @@ export async function generate(params: { // Captured by the skills step so the rules step can read the generated skills. let skillsResult: Awaited> | undefined; - const steps: GenerationStep[] = [ - { - id: "ignore", - writesSharedFile: ["claude-settings", "zed-settings"], - run: () => generateIgnoreCore({ config, logger }), - }, - { - id: "mcp", - writesSharedFile: [ - "kilo-opencode-config", - "zed-settings", - "qwencode-settings", - "augmentcode-settings", - "hermesagent-config", - "amp-settings", - "codexcli-config", - "grokcli-config", - "vibe-config", - "devin-config", - "reasonix-config", - ], - dependsOn: ["ignore"], - run: () => generateMcpCore({ config, logger }), - }, - { id: "commands", run: () => generateCommandsCore({ config, logger }) }, - { id: "subagents", run: () => generateSubagentsCore({ config, logger }) }, - { - id: "skills", - run: async () => { - skillsResult = await generateSkillsCore({ config, logger }); - return skillsResult; - }, + const runners: Record Promise> = { + ignore: () => generateIgnoreCore({ config, logger }), + mcp: () => generateMcpCore({ config, logger }), + commands: () => generateCommandsCore({ config, logger }), + subagents: () => generateSubagentsCore({ config, logger }), + skills: async () => { + skillsResult = await generateSkillsCore({ config, logger }); + return skillsResult; }, - { - id: "hooks", - writesSharedFile: [ - "claude-settings", - "qwencode-settings", - "augmentcode-settings", - "hermesagent-config", - "kiro-agent-config", - "codexcli-config", - "vibe-config", - "devin-config", - ], - dependsOn: ["ignore", "mcp"], - run: () => generateHooksCore({ config, logger }), - }, - { - id: "permissions", - writesSharedFile: [ - "claude-settings", - "kilo-opencode-config", - "zed-settings", - "qwencode-settings", - "augmentcode-settings", - "hermesagent-config", - "kiro-agent-config", - "amp-settings", - "codexcli-config", - "grokcli-config", - "vibe-config", - "devin-config", - "reasonix-config", - ], - dependsOn: ["ignore", "hooks", "mcp"], - run: () => generatePermissionsCore({ config, logger }), - }, - { - id: "rules", - writesSharedFile: ["kilo-opencode-config"], - dependsOn: ["mcp", "skills", "permissions"], - run: () => generateRulesCore({ config, logger, skills: skillsResult?.skills }), - }, - ]; + hooks: () => generateHooksCore({ config, logger }), + permissions: () => generatePermissionsCore({ config, logger }), + rules: () => generateRulesCore({ config, logger, skills: skillsResult?.skills }), + }; + + const steps: GenerationStep[] = GENERATION_STEP_GRAPH.map((meta) => ({ + ...meta, + run: runners[meta.id], + })); const orderedSteps = resolveExecutionOrder(steps);