Skip to content
Merged
22 changes: 22 additions & 0 deletions apps/agent/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,28 @@ describe("accepting a build result (/project/save-example, fail-closed)", () =>
expect(doc.examples.some((e: any) => e.id === "ex.chat-bad")).toBe(false);
});

it("rejects a surface the EMITTER refuses, even though every S-gate passes", async () => {
// A root info-card with no children breaks no authored rule — S1, S2 and
// S3 all pass — and the emitter still refuses it: the mapped Card's
// `child` prop is required and fed by children. The server gate stopped at
// lint, so this saved cleanly and then blocked the project's own emit from
// inside the contract. Refuse it here, in the emitter's own words.
const surface = { ...structuredClone(freshExample().surface), root: { component: "info-card", id: "root" } };
const { status, payload } = await call("save-example", {
path: root,
example: { id: "ex.empty-card", intent: "status-report", prompt: "a card with nothing in it", surface },
});
expect(status).toBe(422);
expect(payload.ok).toBe(false);
const refusal = payload.findings.find((f: any) => f.code === "emit-surface");
expect(refusal.severity).toBe("error");
expect(refusal.target).toBe("ex.empty-card");
expect(refusal.message).toContain("required prop 'child' has no value");
// Nothing was written.
const doc = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8"));
expect(doc.examples.some((e: any) => e.id === "ex.empty-card")).toBe(false);
});

it("rejects unknown intents and malformed ids", async () => {
const surface = freshExample().surface;
expect((await call("save-example", { path: root, example: { id: "ex.x", intent: "not-an-intent", prompt: "p", surface } })).status).toBe(422);
Expand Down
18 changes: 18 additions & 0 deletions apps/agent/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,24 @@ async function saveExample(ctx: ProjectContext, body: Record<string, unknown>) {
}
if (findings.length > 0) return { status: 422, payload: { ok: false, findings } };

// The EMIT gate. The lint gate above answers "does this obey the rules the
// owner authored"; it does not answer "can the design system draw it". A
// tree can pass every S-gate and still be refused by the emitter (a Card
// whose required `child` is fed by children it does not have), and accepting
// that writes a surface the project's own /project/emit then refuses from
// inside the contract — the same hole the browser's Surfaces editor had.
// Refuse it here too, in the emitter's own words. Skipped when the project
// has no profile yet: there is nothing to emit against, and discovery is the
// step that is missing, not this one.
if (existsSync(ctx.profilePath)) {
const profileJson = readJson(ctx.profilePath) as Record<string, unknown>;
const emitted = projectEmit(contract, profileJson, [{ name: id, surface: raw.surface }]);
const refusal = emitted.surfaces.find((s) => s.name === id)?.error;
if (refusal) {
return { status: 422, payload: { ok: false, findings: [finding("A3", "emit-surface", "error", id, refusal)] } };
}
}

const entry = {
id,
intent,
Expand Down
2 changes: 1 addition & 1 deletion apps/composer/app/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function Shell() {
)}
{hasProject && (
<div className="af-page" style={{ paddingTop: "clamp(20px,3vw,32px)" }}>
{view === "build" && <BuildView />}
{view === "build" && <BuildView onNavigate={(v) => setView(v)} />}
{view === "preview" && <PreviewView />}
{view === "flows" && <PreviewView focus="flows" />}
{view === "inventory" && <InventoryView onOpen={() => setView("component")} />}
Expand Down
106 changes: 106 additions & 0 deletions apps/composer/app/contract-enums.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import contract from "../shadcn-v3-project/shadcn-ui.dspack.json";
import { enumLabel, enumMembers, parseEnumValues } from "./contract-enums";

/**
* dspack v0.4 allows an enum prop's `values` to be bare values OR value
* descriptor objects (`{ value, description }`), and BOTH are spec-valid. The
* shipped shadcn contract uses the rich form throughout — ten occurrences on
* Button alone — so any reader that assumes strings prints "[object Object]"
* at the user, and any AUTHORING path that writes strings makes the catalog
* hold two shapes for the same idea.
*
* One reader, one writer, both pinned here. This mirrors dspack-gen's
* `enumValues()` (the canonical unwrap) and keeps the description, which that
* reader deliberately discards.
*/
describe("enumMembers — one reader for both spec-valid enum shapes", () => {
it("reads the flat form: bare strings", () => {
expect(enumMembers({ type: "enum", values: ["sm", "md", "lg"] })).toEqual([
{ value: "sm" },
{ value: "md" },
{ value: "lg" },
]);
});

it("reads the rich form and keeps each value's description", () => {
expect(
enumMembers({
type: "enum",
values: [
{ value: "default", description: "Standard button for primary page actions." },
{ value: "destructive", description: "For irreversible actions like delete." },
],
}),
).toEqual([
{ value: "default", description: "Standard button for primary page actions." },
{ value: "destructive", description: "For irreversible actions like delete." },
]);
});

it("reads a mixed list, and non-string values, without stringifying an object", () => {
const members = enumMembers({ type: "enum", values: ["plain", { value: "rich", description: "why" }, 3, true] });
expect(members.map((m) => m.value)).toEqual(["plain", "rich", "3", "true"]);
expect(members.some((m) => m.value.includes("[object"))).toBe(false);
});

it("is empty-safe: missing, empty, malformed, and non-enum props", () => {
expect(enumMembers({ type: "enum" })).toEqual([]);
expect(enumMembers({ type: "enum", values: [] })).toEqual([]);
expect(enumMembers({ type: "string", values: ["a"] })).toEqual([]);
expect(enumMembers({ type: "enum", values: "sm,md" })).toEqual([]);
expect(enumMembers(undefined)).toEqual([]);
expect(enumMembers(null)).toEqual([]);
// A descriptor with no value is not a value — it is skipped, not rendered.
expect(enumMembers({ type: "enum", values: [{ description: "orphan" }, { value: "kept" }] })).toEqual([{ value: "kept" }]);
});

it("reads the SHIPPED contract's Button props without producing [object Object]", () => {
const props = (contract as unknown as Record<string, any>).components.button.props as Record<string, any>;
const enums = Object.entries(props).filter(([, p]) => p.type === "enum");
expect(enums.length).toBeGreaterThan(0);
for (const [, p] of enums) {
const members = enumMembers(p);
expect(members.length).toBe(p.values.length);
for (const m of members) {
expect(m.value).not.toContain("[object");
expect(typeof m.value).toBe("string");
}
}
const variant = enumMembers(props.variant);
expect(variant.map((m) => m.value)).toContain("destructive");
expect(variant.find((m) => m.value === "destructive")?.description).toContain("irreversible");
});

it("the defect it replaces: `values.join(', ')` over the shipped contract prints [object Object]", () => {
const values = (contract as unknown as Record<string, any>).components.button.props.variant.values as unknown[];
// What component-view.tsx renders today, against the contract we ship.
expect(values.join(", ")).toContain("[object Object]");
expect(enumMembers({ type: "enum", values }).map((m) => m.value).join(", ")).not.toContain("[object Object]");
});

it("enumLabel reads one member of either shape", () => {
expect(enumLabel("ghost")).toBe("ghost");
expect(enumLabel({ value: "ghost", description: "Minimal" })).toBe("ghost");
expect(enumLabel(7)).toBe("7");
});
});

describe("parseEnumValues — authoring writes the shape the contract already uses", () => {
it("writes value descriptors, not bare strings", () => {
expect(parseEnumValues("sm, md , lg")).toEqual([{ value: "sm" }, { value: "md" }, { value: "lg" }]);
});

it("drops blanks and duplicates, so an authored enum is never malformed", () => {
expect(parseEnumValues("sm,,md, ,sm")).toEqual([{ value: "sm" }, { value: "md" }]);
expect(parseEnumValues(" ")).toEqual([]);
expect(parseEnumValues("")).toEqual([]);
});

it("round-trips through the reader — what is authored is what is displayed", () => {
expect(enumMembers({ type: "enum", values: parseEnumValues("ghost, link") }).map((m) => m.value)).toEqual([
"ghost",
"link",
]);
});
});
69 changes: 69 additions & 0 deletions apps/composer/app/contract-enums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Enum values, read once.
*
* A dspack v0.4 enum prop's `values` may be bare values (`["sm","md"]`) or
* value descriptor objects (`[{ value: "sm", description: "…" }]`). BOTH are
* spec-valid, and the shipped shadcn contract uses the rich form throughout —
* so every reader that assumes strings prints "[object Object]" at the user,
* and every writer that emits strings puts a second shape for the same idea
* into the same catalog.
*
* `enumMembers` is that one reader. It mirrors dspack-gen's `enumValues()`
* (the canonical unwrap: `v.value` when the member is an object, the member
* otherwise) and keeps the per-value description, which that reader
* deliberately discards because generation has no use for it and a UI does.
*
* `parseEnumValues` is the matching writer: authoring produces the descriptor
* form the contract already uses. Neither the contract nor the schema changes
* — value descriptors require only `value` (dspack.v0.4 §valueDescriptor).
*/

/** One allowed value of an enum prop, in the shape a UI can render. */
export interface EnumMember {
value: string;
/** When to choose this value, when the contract says. */
description?: string;
}

/** One member's text, whichever shape it arrived in. */
export function enumLabel(member: unknown): string {
if (member && typeof member === "object" && "value" in member) return String((member as { value: unknown }).value);
return String(member);
}

/**
* The allowed values of a prop descriptor, or [] for anything that is not a
* populated enum (a missing `values`, a non-array, a non-enum prop, a
* descriptor carrying no value). Empty-safe by construction: a catalog page
* renders whatever the contract holds, including a half-authored entry.
*/
export function enumMembers(prop: unknown): EnumMember[] {
if (!prop || typeof prop !== "object") return [];
const { type, values } = prop as { type?: unknown; values?: unknown };
if (type !== "enum" || !Array.isArray(values)) return [];
const members: EnumMember[] = [];
for (const member of values) {
if (member && typeof member === "object") {
const { value, description } = member as { value?: unknown; description?: unknown };
if (value === undefined || value === null) continue; // a descriptor with no value is not a value
members.push({ value: String(value), ...(typeof description === "string" && description ? { description } : {}) });
continue;
}
if (member === undefined || member === null) continue;
members.push({ value: String(member) });
}
return members;
}

/** The values authored in the catalog's comma-separated field, as descriptors. */
export function parseEnumValues(input: string): EnumMember[] {
const seen = new Set<string>();
const members: EnumMember[] = [];
for (const raw of input.split(",")) {
const value = raw.trim();
if (!value || seen.has(value)) continue;
seen.add(value);
members.push({ value });
}
return members;
}
2 changes: 1 addition & 1 deletion apps/composer/app/hosted-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export function streamHostedBuild(
const example = examples.filter((e) => e.intent === intent).at(-1);
if (!example) {
handlers.onError(
`Scripted mode replays this intent's own worked example, and '${intent || "(none)"}' has none. ` +
`Scripted mode replays this governed context's own surface, and '${intent || "(none)"}' has none yet. ` +
"Pick a governed context that already has a surface, or connect the local agent to generate from the contract without few-shot context.",
);
handlers.onComplete();
Expand Down
62 changes: 60 additions & 2 deletions apps/composer/app/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@
* delta; other edits are session-only, stated plainly per view.
* Files (or the delta store) are the source of truth; this state is a view.
*/
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import {
addTombstone,
applyFreshFact,
Expand All @@ -25,6 +36,7 @@ import {
type BuildReadiness,
type BuildTurnProgress,
type ComposerFinding,
type FlowPlan,
type FreshFact,
type GoalPlan,
type LedgerStatus,
Expand Down Expand Up @@ -97,6 +109,40 @@ import {

export type Mode = "demo" | "agent";

/**
* "Build a flow" composition state, held HERE rather than in the Build view.
*
* The view unmounts on every navigation (`{view === "build" && <BuildView/>}`),
* and the product itself sends people away mid-composition — a pending step
* reads "build it from Build and accept into this step". Held in the view, the
* plan and its drive died on that round trip, per-step rebuild became
* unreachable, and re-planning minted a SECOND flow beside the one already
* created. This is in-progress WORKING state, not project data: it lives for
* the session, is never persisted, and is cleared with the build thread when
* the project changes.
*/
export interface FlowBuildState {
flowId: string;
/** Minted step ids, index-aligned with the frozen plan's steps. */
stepIds: string[];
running: boolean;
/** The step the sequential driver is on, or null when it is not driving. */
at: number | null;
}

export interface FlowComposition {
/** "surface" = the ordinary single-surface composer; "flow" = plan a flow. */
mode: "surface" | "flow";
/** The whole-journey goal the planner decomposes. */
goal: string;
/** The proposed (and editable) outline, or null before planning. */
plan: FlowPlan | null;
/** The accepted plan's created flow + drive position, or null before accept. */
build: FlowBuildState | null;
}

export const EMPTY_FLOW_COMPOSITION: FlowComposition = { mode: "surface", goal: "", plan: null, build: null };

/** One chat turn in the Build thread: the ask, its run, and its result. */
export interface BuildTurn {
id: number;
Expand Down Expand Up @@ -240,6 +286,10 @@ export interface ComposerState {
* a STALE binding never fails the accept, it is reported in the notice. */
acceptBuildTurn: (turnId: number, exampleId?: string, intoFlowStep?: StepBinding) => Promise<void>;
clearBuildThread: () => void;
/** "Build a flow" composition, held above the view so navigating away and
* back does not destroy an in-progress plan (see FlowComposition). */
flowComposition: FlowComposition;
setFlowComposition: Dispatch<SetStateAction<FlowComposition>>;
}

const Ctx = createContext<ComposerState | null>(null);
Expand Down Expand Up @@ -951,6 +1001,9 @@ export function ComposerProvider({ children }: { children: ReactNode }) {

/* ---------------- Build (chat-driven creation) ---------------- */
const [buildTurns, setBuildTurns] = useState<BuildTurn[]>([]);
// In-progress flow composition, lifted out of BuildView so the view can
// unmount without taking the plan with it (see FlowComposition).
const [flowComposition, setFlowComposition] = useState<FlowComposition>(EMPTY_FLOW_COMPOSITION);
const [buildBusy, setBuildBusy] = useState(false);
const [buildModels, setBuildModels] = useState<string[]>(["scripted"]);
// The auto-select correction below must NOT run against the ["scripted"]
Expand Down Expand Up @@ -1060,6 +1113,9 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
buildStream.current = null;
setBuildTurns([]);
setBuildBusy(false);
// A composition belongs to the project it was planned in — every caller of
// this is a project transition (open, close, connect, import).
setFlowComposition(EMPTY_FLOW_COMPOSITION);
}, []);

/**
Expand Down Expand Up @@ -1460,8 +1516,10 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
runBuild,
acceptBuildTurn,
clearBuildThread,
flowComposition,
setFlowComposition,
}),
[mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread],
[mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition],
);

return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
Expand Down
Loading
Loading