Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/cli/src/commands/conventions.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("architecture and practices facets integration", () => {
.mockResolvedValueOnce("tanstack"); // architecture
vi.mocked(clack.multiselect)
.mockResolvedValueOnce([]) // practices: none
.mockResolvedValueOnce([]) // backpressure: none
.mockResolvedValueOnce([]) // docsets: deselect all
.mockResolvedValueOnce(["claude-code"]); // harnesses

Expand Down Expand Up @@ -228,6 +229,7 @@ describe("architecture and practices facets integration", () => {
.mockResolvedValueOnce("tanstack"); // architecture
vi.mocked(clack.multiselect)
.mockResolvedValueOnce(["tdd-london", "conventional-commits"]) // practices
.mockResolvedValueOnce([]) // backpressure: none
.mockResolvedValueOnce([]) // docsets: deselect all
.mockResolvedValueOnce(["claude-code"]); // harnesses

Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/install.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const mockLogical: LogicalConfig = {
instructions: ["test instruction"],
cli_actions: [],
knowledge_sources: [],
skills: []
skills: [],
git_hooks: [],
setup_notes: []
};

vi.mock("@ade/core", async (importOriginal) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/knowledge.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("knowledge integration", () => {

vi.mocked(clack.multiselect)
.mockResolvedValueOnce([]) // practices: none
.mockResolvedValueOnce([]) // backpressure: none
.mockResolvedValueOnce([
"tanstack-router-docs",
"tanstack-query-docs",
Expand Down Expand Up @@ -94,6 +95,7 @@ describe("knowledge integration", () => {

vi.mocked(clack.multiselect)
.mockResolvedValueOnce([]) // practices: none
.mockResolvedValueOnce([]) // backpressure: none
.mockResolvedValueOnce(["tanstack-router-docs", "tanstack-query-docs"])
.mockResolvedValueOnce(["claude-code"]); // harnesses

Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/commands/setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ vi.mock("@ade/core", async (importOriginal) => {
instructions: [],
cli_actions: [],
knowledge_sources: [],
skills: []
skills: [],
git_hooks: [],
setup_notes: []
} satisfies LogicalConfig),
collectDocsets: actual.collectDocsets
};
Expand Down Expand Up @@ -176,7 +178,9 @@ describe("runSetup", () => {
instructions: ["do stuff"],
cli_actions: [],
knowledge_sources: [],
skills: []
skills: [],
git_hooks: [],
setup_notes: []
};
vi.mocked(resolve).mockResolvedValueOnce(mockLogical);
vi.mocked(clack.select)
Expand Down Expand Up @@ -311,6 +315,30 @@ describe("runSetup", () => {
expect(clack.outro).toHaveBeenCalled();
});

it("displays each setup note via clack.log.info", async () => {
const mockLogical: LogicalConfig = {
mcp_servers: [],
instructions: [],
cli_actions: [],
knowledge_sources: [],
skills: [],
git_hooks: [],
setup_notes: ["Add lint script to package.json", "Run npm install"]
};
vi.mocked(resolve).mockResolvedValueOnce(mockLogical);
vi.mocked(clack.select)
.mockResolvedValueOnce("workflow-a")
.mockResolvedValueOnce("vitest");
vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);

await runSetup("/tmp/test-project", testCatalog);

expect(clack.log.info).toHaveBeenCalledWith(
"Add lint script to package.json"
);
expect(clack.log.info).toHaveBeenCalledWith("Run npm install");
});

describe("re-run with existing config", () => {
it("passes existing single-select choice as initialValue", async () => {
vi.mocked(readUserConfig).mockResolvedValueOnce({
Expand Down
21 changes: 17 additions & 4 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
collectDocsets,
createDefaultRegistry,
getFacet,
getOption
getOption,
sortFacets,
getVisibleOptions
} from "@ade/core";
import {
allHarnessWriters,
Expand Down Expand Up @@ -45,9 +47,16 @@ export async function runSetup(

const choices: Record<string, string | string[]> = {};

for (const facet of catalog.facets) {
const sortedFacets = sortFacets(catalog);

for (const facet of sortedFacets) {
const visibleOptions = getVisibleOptions(facet, choices, catalog);
if (visibleOptions.length === 0) continue;

const visibleFacet = { ...facet, options: visibleOptions };

if (facet.multiSelect) {
const selected = await promptMultiSelect(facet, existingChoices);
const selected = await promptMultiSelect(visibleFacet, existingChoices);
if (typeof selected === "symbol") {
clack.cancel("Setup cancelled.");
return;
Expand All @@ -56,7 +65,7 @@ export async function runSetup(
choices[facet.id] = selected;
}
} else {
const selected = await promptSelect(facet, existingChoices);
const selected = await promptSelect(visibleFacet, existingChoices);
if (typeof selected === "symbol") {
clack.cancel("Setup cancelled.");
return;
Expand Down Expand Up @@ -161,6 +170,10 @@ export async function runSetup(
);
}

for (const note of logicalConfig.setup_notes) {
clack.log.info(note);
}

clack.outro("Setup complete!");
}

Expand Down
241 changes: 240 additions & 1 deletion packages/core/src/catalog/catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { getDefaultCatalog, getFacet, getOption } from "./index.js";
import {
getDefaultCatalog,
getFacet,
getOption,
sortFacets,
getVisibleOptions
} from "./index.js";
import { createDefaultRegistry, getProvisionWriter } from "../registry.js";

describe("catalog", () => {
Expand Down Expand Up @@ -272,6 +278,239 @@ describe("catalog", () => {
});
});

describe("backpressure facet", () => {
it("exists in the default catalog", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure");
expect(backpressure).toBeDefined();
expect(backpressure!.required).toBe(false);
});

it("is multi-select", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;
expect(backpressure.multiSelect).toBe(true);
});

it("depends on architecture facet", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;
expect(backpressure.dependsOn).toContain("architecture");
});

it("has per-architecture lint-build-precommit options with git-hooks provisions", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) {
const option = getOption(
backpressure,
`lint-build-precommit-${archId}`
);
expect(option, `lint-build-precommit-${archId} missing`).toBeDefined();
expect(option!.recipe.some((p) => p.writer === "git-hooks")).toBe(true);

const gitHooksProvision = option!.recipe.find(
(p) => p.writer === "git-hooks"
)!;
const hooks = (
gitHooksProvision.config as { hooks: { phase: string }[] }
).hooks;
expect(hooks.some((h) => h.phase === "pre-commit")).toBe(true);
}
});

it("lint-build-precommit options have an instruction provision for WIP commits", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) {
const option = getOption(
backpressure,
`lint-build-precommit-${archId}`
)!;
expect(option.recipe.some((p) => p.writer === "instruction")).toBe(
true
);
}
});

it("lint-build-precommit options have a setup-note provision", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) {
const option = getOption(
backpressure,
`lint-build-precommit-${archId}`
)!;
const note = option.recipe.find((p) => p.writer === "setup-note");
expect(
note,
`lint-build-precommit-${archId} missing setup-note`
).toBeDefined();
expect((note!.config as { text: string }).text).toBeTruthy();
}
});

it("has per-architecture unit-test-prepush options with git-hooks provisions", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) {
const option = getOption(backpressure, `unit-test-prepush-${archId}`);
expect(option, `unit-test-prepush-${archId} missing`).toBeDefined();
expect(option!.recipe.some((p) => p.writer === "git-hooks")).toBe(true);

const gitHooksProvision = option!.recipe.find(
(p) => p.writer === "git-hooks"
)!;
const hooks = (
gitHooksProvision.config as { hooks: { phase: string }[] }
).hooks;
expect(hooks.some((h) => h.phase === "pre-push")).toBe(true);
}
});

it("hook scripts contain the swallow-on-success pattern", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

for (const option of backpressure.options) {
const gitHooksProvision = option.recipe.find(
(p) => p.writer === "git-hooks"
)!;
const hooks = (
gitHooksProvision.config as { hooks: { script: string }[] }
).hooks;
for (const hook of hooks) {
expect(hook.script).toContain("✓");
expect(hook.script).toContain("exit_code");
}
}
});

it("all options have an available() function", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

for (const option of backpressure.options) {
expect(
typeof option.available,
`option ${option.id} missing available()`
).toBe("function");
}
});
});

describe("backpressure facet — available()", () => {
it("tanstack options are visible when architecture=tanstack", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;
const architectureFacet = getFacet(catalog, "architecture")!;
const tanstackOption = getOption(architectureFacet, "tanstack")!;

const visible = getVisibleOptions(
backpressure,
{ architecture: "tanstack" },
catalog
);
const ids = visible.map((o) => o.id);
expect(ids).toContain("lint-build-precommit-tanstack");
expect(ids).toContain("unit-test-prepush-tanstack");
expect(ids).not.toContain("lint-build-precommit-java-backend");
expect(tanstackOption).toBeDefined(); // guard
});

it("java-backend options are visible when architecture=java-backend", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

const visible = getVisibleOptions(
backpressure,
{ architecture: "java-backend" },
catalog
);
const ids = visible.map((o) => o.id);
expect(ids).toContain("lint-build-precommit-java-backend");
expect(ids).toContain("unit-test-prepush-java-backend");
expect(ids).not.toContain("lint-build-precommit-tanstack");
});

it("no options visible when architecture is not selected", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

const visible = getVisibleOptions(backpressure, {}, catalog);
expect(visible).toHaveLength(0);
});

it("only the two matching options are visible per architecture", () => {
const catalog = getDefaultCatalog();
const backpressure = getFacet(catalog, "backpressure")!;

for (const archId of ["tanstack", "nodejs-backend", "java-backend"]) {
const visible = getVisibleOptions(
backpressure,
{ architecture: archId },
catalog
);
expect(visible, `expected 2 options for ${archId}`).toHaveLength(2);
expect(visible.every((o) => o.id.endsWith(`-${archId}`))).toBe(true);
}
});
});

describe("sortFacets", () => {
it("returns all facets", () => {
const catalog = getDefaultCatalog();
const sorted = sortFacets(catalog);
expect(sorted).toHaveLength(catalog.facets.length);
});

it("places backpressure after architecture", () => {
const catalog = getDefaultCatalog();
const sorted = sortFacets(catalog);
const archIdx = sorted.findIndex((f) => f.id === "architecture");
const bpIdx = sorted.findIndex((f) => f.id === "backpressure");
expect(archIdx).toBeLessThan(bpIdx);
});

it("facets without dependsOn are not placed after their dependents", () => {
const catalog = getDefaultCatalog();
const sorted = sortFacets(catalog);
for (const facet of sorted) {
const facetIdx = sorted.findIndex((f) => f.id === facet.id);
for (const depId of facet.dependsOn ?? []) {
const depIdx = sorted.findIndex((f) => f.id === depId);
expect(depIdx, `${depId} must come before ${facet.id}`).toBeLessThan(
facetIdx
);
}
}
});
});

describe("getVisibleOptions", () => {
it("returns all options when none have available()", () => {
const catalog = getDefaultCatalog();
const architecture = getFacet(catalog, "architecture")!;
const visible = getVisibleOptions(architecture, {}, catalog);
expect(visible).toHaveLength(architecture.options.length);
});

it("returns all options when available() returns true for all", () => {
const catalog = getDefaultCatalog();
const architecture = getFacet(catalog, "architecture")!;
const visible = getVisibleOptions(
architecture,
{ architecture: "tanstack" },
catalog
);
expect(visible).toHaveLength(architecture.options.length);
});
});

describe("catalog + registry integration", () => {
it("every recipe provision references a writer that exists in the default registry", () => {
const catalog = getDefaultCatalog();
Expand Down
Loading