Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions src/lib/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -1063,14 +1068,16 @@ describe("generate", () => {
});
});

const stubRun = async () => ({ count: 0, paths: [], hasDiff: false });

const executionStep = (
id: string,
opts: { writesSharedFile?: string[]; dependsOn?: string[] } = {},
) =>
({
id,
...opts,
run: async () => ({ count: 0, paths: [], hasDiff: false }),
run: stubRun,
}) as never;

describe("resolveExecutionOrder", () => {
Expand Down Expand Up @@ -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",
]);
});
});
183 changes: 107 additions & 76 deletions src/lib/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeatureGenerateResult>;
};

Expand Down Expand Up @@ -300,6 +300,94 @@ export function resolveExecutionOrder(steps: GenerationStep[]): GenerationStep[]
return ordered;
}

type GenerationStepMeta = Readonly<Omit<GenerationStep, "run">>;

/**
* 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
Expand All @@ -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<ReturnType<typeof generateSkillsCore>> | 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<GenerationStepId, () => Promise<FeatureGenerateResult>> = {
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);

Expand Down
Loading