diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7565007..f024b2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,15 +27,15 @@ jobs: - name: Astryx vocabulary drift check (report-only, local CLI) run: pnpm --filter @dspack-studio/contracts drift-check - name: Unit tests - run: | - pnpm --filter @dspack-studio/a2ui-ingest test - pnpm --filter @dspack-studio/agui-bridge test - pnpm --filter @dspack-studio/replay test - pnpm --filter @dspack-studio/scenarios test - pnpm --filter @dspack-studio/shadcn-renderers test - pnpm --filter agent test - pnpm --filter web test - pnpm --filter composer test + # THE INVARIANT: CI runs EVERY workspace test script, never a + # hand-maintained filter list. The list this replaced had silently + # omitted @dspack-studio/composer-core (89 tests — both planners, the + # ledger, findings, the flow schema), @dspack-studio/wireframe-renderers + # (4) and @dspack-studio/contracts (2): a package could be added, hold + # product logic, go red, and CI stayed green because its name was never + # typed here. `pnpm test` is the root script, so what a contributor runs + # locally and what CI enforces are the same command by construction. + run: pnpm test - name: Type checks run: pnpm -r typecheck - name: Static export (the deploy artifact) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 936735f..27712c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ Ollama if present, and "scripted" mode is fully deterministic. ## Tests ```sh -pnpm test # package unit tests +pnpm test # EVERY workspace test script (this is what CI runs) pnpm -r typecheck pnpm e2e # builds the static export, runs Playwright against it ``` diff --git a/README.md b/README.md index 0c12689..f890543 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Published dependencies doing the heavy lifting: `@aestheticfunction/dspack-gen` ```sh pnpm typecheck # whole repo -pnpm test # package unit tests +pnpm test # EVERY workspace test script (the exact command CI runs) pnpm --filter composer test # composer unit tests pnpm --filter agent test # agent unit tests pnpm --filter composer build # static export → apps/composer/out diff --git a/apps/agent/src/project.test.ts b/apps/agent/src/project.test.ts index 8075959..1e58495 100644 --- a/apps/agent/src/project.test.ts +++ b/apps/agent/src/project.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ServerResponse } from "node:http"; +import { A2UI_VERSIONS, projectEmit } from "@dspack-studio/composer-core"; import { handleProjectRoute } from "./project.js"; const demoProject = fileURLToPath(new URL("../../composer/demo-project", import.meta.url)); @@ -90,6 +91,48 @@ describe("emit", () => { // ok is false because one surface refused? No: ok reflects catalog gates. expect(payload.ok).toBe(true); }); + + /** + * A3/A4 EQUIVALENCE, agent half. The route must add FILE WRITING to the + * shared emit seam and nothing else — no second opinion about gates, no + * different A2UI version list. (The browser half is proven where the browser + * lives: apps/composer/app/validation.test.ts asserts browserEmit is the same + * seam plus surface selection. Together the two halves make the agent and the + * browser equal by construction rather than by coincidence.) + */ + it("is the shared emit seam plus file writing: same verdict, same findings, same A2UI versions", async () => { + const { payload } = await call("emit", { path: root }); + + // The one DOCUMENTED difference between the two doors: the agent also + // emits the surfaces in the project's surfacesDir, which a browser-backed + // project does not have. Feed the seam exactly the same list. + const contract = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + const profileJson = JSON.parse(readFileSync(join(root, "acme.profile.json"), "utf8")); + const surfaces = [ + ...((contract.examples ?? []) as Array<{ id?: string; surface?: unknown }>) + .filter((e) => e.surface) + .map((e) => ({ name: e.id ?? "example", surface: e.surface })), + { + name: "uses-casualty", + surface: JSON.parse(readFileSync(join(root, "surfaces", "uses-casualty.dsurface.json"), "utf8")), + }, + ]; + const seam = projectEmit(contract, profileJson, surfaces); + + expect(payload.ok).toBe(seam.ok); + expect(payload.findings).toEqual(seam.findings); + expect(payload.catalog).toEqual(seam.catalog); + expect(payload.surfaces.map((s: any) => s.name)).toEqual(seam.surfaces.map((s) => s.name)); + // Both versions, named on the wire — the divergence this milestone closed + // was invisible precisely because nothing said which versions ran. + expect(payload.a2uiVersions).toEqual([...A2UI_VERSIONS]); + expect(seam.runs.map((r) => r.version)).toEqual([...A2UI_VERSIONS]); + // ...and the file writing that is the route's actual added value. + for (const seg of ["v0_9_1", "v1_0"]) { + expect(JSON.parse(readFileSync(join(root, "out", `catalog.${seg}.json`), "utf8")).components).toBeTruthy(); + expect(JSON.parse(readFileSync(join(root, "out", `report.${seg}.json`), "utf8"))).toBeTruthy(); + } + }); }); describe("acknowledged casualties (#30)", () => { diff --git a/apps/agent/src/project.ts b/apps/agent/src/project.ts index e283c7d..5f36027 100644 --- a/apps/agent/src/project.ts +++ b/apps/agent/src/project.ts @@ -5,7 +5,8 @@ * * POST /project/connect { path } -> manifest + ledger + inventory * POST /project/discover { path } -> dspack-export CLI (bootstrap / refusal verbatim) - * POST /project/emit { path } -> loadProfile + transformFromJson + emitSurface -> out/ + * POST /project/emit { path } -> composer-core projectEmit (the seam the browser + * also runs) -> catalogs, reports and surfaces written to out/ * POST /project/validate { path } -> dspack-validate CLI + dspack-gen/core lintSurface * POST /project/save { path, kind, document } -> shape-gated, ledger-preserving atomic write * POST /project/save-flow { path, flows } -> schema-gated atomic write of the MANIFEST's @@ -26,15 +27,7 @@ import { createRequire } from "node:module"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { promisify } from "node:util"; import type { ServerResponse } from "node:http"; -import { - loadProfile, - transformFromJson, - emitSurface, - EmitSurfaceError, - ProfileLoadError, - type Profile, - type A2uiVersion, -} from "@aestheticfunction/dspack-emit"; +import { loadProfile, ProfileLoadError, type Profile } from "@aestheticfunction/dspack-emit"; import { lintSurface } from "@aestheticfunction/dspack-gen/core"; import { runPipeline, ScriptedAdapter, adapterFor, OllamaAdapter } from "@aestheticfunction/dspack-gen"; import { adapterForProvider } from "./providers.js"; @@ -47,10 +40,10 @@ import { parseProjectManifest, preservesLedger, finding, - catalogGateFindings, - classifySurfaceRefusal, + projectEmit, type ComposerFinding, type ProjectManifest, + type SurfaceToEmit, } from "@dspack-studio/composer-core"; import { createPipelineEventMapper, @@ -154,8 +147,14 @@ function atomicWriteJson(path: string, value: unknown): void { } /** Surfaces available for emit/preview: contract examples + surfacesDir files. */ -function projectSurfaces(ctx: ProjectContext, contract: Record): Array<{ name: string; surface: unknown }> { - const out: Array<{ name: string; surface: unknown }> = []; +/** + * The surfaces a REPOSITORY-backed project has: the contract's worked examples + * plus everything in its `surfacesDir`. That directory is the one documented + * asymmetry with the browser, which has no filesystem to read it from + * (apps/composer/app/validation.ts contractSurfaces stops at the examples). + */ +function projectSurfaces(ctx: ProjectContext, contract: Record): SurfaceToEmit[] { + const out: SurfaceToEmit[] = []; for (const example of (contract.examples as Array<{ id?: string; surface?: unknown }> | undefined) ?? []) { if (example.surface) out.push({ name: example.id ?? "example", surface: example.surface }); } @@ -270,99 +269,47 @@ async function rediscover(ctx: ProjectContext, body: Record) { return { ok: true, contract, ledger: await ledgerStatus(contract), report: result.report }; } +/** + * The project's emit, on disk. This route is the SHARED SEAM plus FILE + * WRITING: the emit loop, the per-version catalog gates, coverage, fidelity + * and the casualty classification all live in composer-core's `projectEmit`, + * which the browser's validation.ts calls too. It used to be a copy, and the + * copies had drifted — this side validated A2UI 0.9.1 AND 1.0, the browser + * only 0.9.1, so the same governed project got a different verdict depending + * on whether it was repository- or browser-backed. What is genuinely the + * agent's own job, and stays here, is that the agent owns the filesystem: + * surface selection includes `surfacesDir`, and the results land in out/. + */ function emit(ctx: ProjectContext) { const contract = readJson(ctx.contractPath) as Record; // The profile as authored (JSON): casualty declarations and their written // reasons are read from this, never from emitted message text. const profileJson = readJson(ctx.profilePath) as Record; - let profile: Profile; - try { - profile = loadProfile(profileJson); - } catch (e) { - if (e instanceof ProfileLoadError) { - return { - ok: false as const, - findings: e.issues.map((i) => finding("profile", "schema", "error", i.path, i.message)), - }; - } - throw e; - } - - const surfaces = projectSurfaces(ctx, contract); - const emitted: Array<{ name: string; messages?: unknown[]; warnings: Array<{ code: string; message: string }>; error?: string }> = []; - const allMessages: unknown[] = []; - for (const { name, surface } of surfaces) { - try { - const result = emitSurface(surface as Parameters[0], contract as Parameters[1], { profile }); - emitted.push({ name, messages: result.messages, warnings: result.warnings as Array<{ code: string; message: string }> }); - allMessages.push(...result.messages); - } catch (e) { - if (e instanceof EmitSurfaceError) { - emitted.push({ name, warnings: [], error: e.message }); - continue; - } - throw e; - } + const result = projectEmit(contract, profileJson, projectSurfaces(ctx, contract)); + if (result.runs.length === 0) { + // The profile did not load; nothing was emitted and nothing is written. + return { ok: false as const, findings: result.findings }; } mkdirSync(ctx.outDir, { recursive: true }); - const versions: A2uiVersion[] = ["0.9.1", "1.0"]; - const runs = versions.map((version) => { - const out = transformFromJson(contract as Parameters[0], { a2uiVersion: version, surface: { messages: allMessages }, profile }); - const seg = version === "0.9.1" ? "v0_9_1" : "v1_0"; - atomicWriteJson(join(ctx.outDir, `catalog.${seg}.json`), out.catalog); - atomicWriteJson(join(ctx.outDir, `report.${seg}.json`), out.report.json); - return { version, out }; - }); - for (const { name, messages } of emitted) { - if (messages) atomicWriteJson(join(ctx.outDir, `${name}.surface.json`), { messages }); - } - - const primary = runs[0].out; - const findings: ComposerFinding[] = []; - for (const { version, out } of runs) { - for (const gate of out.validation.gates) { - if (!gate.pass) { - const gateId = gate.name.startsWith("schema-compile") ? "A1" : gate.name === "catalog-shape" ? "A2" : "A3"; - // Per-instance findings with honest Component#id targets when the - // emitter reports structured errorDetails (dspack-emit >= 0.7, - // feature-detected); otherwise one capped finding whose `evidence` - // keeps every raw error string. Twin: apps/composer/app/validation.ts. - findings.push(...catalogGateFindings(gateId as "A1", gate, `a2ui@${version}`)); - } - } - } - for (const c of primary.mapping.coverage) { - if (c.disposition === "unclassified") { - findings.push(finding("coverage", "unclassified", "error", c.id, "component is neither mapped, adapted, omitted, nor a declared casualty")); - } + for (const run of result.runs) { + const seg = run.version === "0.9.1" ? "v0_9_1" : "v1_0"; + atomicWriteJson(join(ctx.outDir, `catalog.${seg}.json`), run.catalog); + atomicWriteJson(join(ctx.outDir, `report.${seg}.json`), run.report); } - for (const f of primary.mapping.fidelity) { - if (f.class === "lossy" || f.class === "cannot-represent") { - findings.push(finding("fidelity", f.class, "warn", f.source, f.note)); - } - } - for (const { name, warnings, error } of emitted) { - if (error) { - // An emit refusal caused solely by components the profile author - // declared casualties (with a written reason) is an acknowledged - // decision, not unresolved work. The finding keeps its severity, - // code, target, and verbatim message; the classification is - // structured evidence attached alongside. - const base = finding("A3", "emit-surface", "error", name, error); - const surface = surfaces.find((s) => s.name === name)?.surface; - const acknowledged = classifySurfaceRefusal(surface, contract, profileJson); - findings.push(acknowledged ? { ...base, acknowledged } : base); - } - for (const w of warnings) findings.push(finding("A3", w.code, "info", name, w.message)); + for (const { name, messages } of result.surfaces) { + if (messages) atomicWriteJson(join(ctx.outDir, `${name}.surface.json`), { messages }); } return { - ok: runs.every((r) => r.out.validation.pass), - catalog: runs[0].out.catalog, - report: primary.report.json, - surfaces: emitted, - findings, + ok: result.ok, + catalog: result.catalog, + report: result.report, + // The versions this verdict actually came from, on the wire: the + // divergence this replaced was invisible precisely because nothing said. + a2uiVersions: result.runs.map((r) => r.version), + surfaces: result.surfaces, + findings: result.findings, }; } diff --git a/apps/composer/app/required-prop-consumption.test.ts b/apps/composer/app/required-prop-consumption.test.ts new file mode 100644 index 0000000..ba611bf --- /dev/null +++ b/apps/composer/app/required-prop-consumption.test.ts @@ -0,0 +1,263 @@ +/** + * THE STRUCTURAL GUARD: a REQUIRED catalog prop must not be silently ignored + * by its native renderer. + * + * The failure this exists to make impossible was shipped, twice over. The + * shadcn Table renderer read its body rows from `props.data` — the Astryx + * catalog's name — while the production shadcn/ui v3 catalog declares + * `rows` (required) and no `data` at all. Every gate stayed green: A3 + * validates the INSTANCE against the catalog, and an instance is perfectly + * valid when the renderer that draws it reads a prop the catalog never + * declared. The parity suites in packages/shadcn-renderers could not see it + * either, because their corpus is emitted from the ASTRYX contract, where + * `data` is the real name. So a renderer could ignore a required prop of a + * catalog it serves, and nothing in the repo would say a word. + * + * This is deliberately a BEHAVIOR-level check, not a source grep for prop + * names: it builds an instance per component with every required prop + * populated by distinctive SENTINEL values, renders it through the real + * registry, and asserts the sentinels come out the other side. A renderer + * that reads the right prop passes however it is written; a renderer that + * reads the wrong one fails however plausible its source looks. + * + * It runs against BOTH governed catalogs and BOTH native registries — the + * bug was a cross-catalog confusion, so a single-catalog guard would have + * missed it. + */ +import { createElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { Registry } from "@dspack-studio/a2ui-ingest"; +import { nativeRegistryFor, NATIVE_REGISTRIES } from "./registries"; +import shadcnEmit from "./demo/generated/emit.shadcn.json"; +import astryxEmit from "./demo/generated/emit.astryx.json"; + +type Catalog = { components: Record; $defs?: Record }; + +const CATALOGS: Record = { + shadcn: (shadcnEmit as { catalog: Catalog }).catalog, + astryx: (astryxEmit as { catalog: Catalog }).catalog, +}; + +/** + * PER-PROP ALLOWLIST. Every entry is a claim that a prop is consumed + * STRUCTURALLY rather than displayed, so no sentinel it carries could ever + * appear in static markup. Each one needs a reason someone can argue with; + * the list is meant to stay this short. + * + * `child` and `children` are deliberately NOT here: they carry ComponentId + * references, the marker `buildChild` below renders the id verbatim, and so a + * slot that is never built is caught like any other dropped sentinel. + */ +const STRUCTURAL_PROPS: Record = { + action: "A2UI Action — a handler the binder turns into a callback and the renderer wires to onClick. It is dispatch behavior; it has no visible text to observe in static markup, and its consumption is covered by the interaction e2e suites.", +}; + +/** + * Content-bearing keys probed when a catalog declares an array item as an + * OPAQUE `{ type: "object" }` with no properties (Table.rows and + * MetadataList.items today). The catalog does not say what a record holds, so + * the guard cannot demand every field be shown — it populates the keys the + * catalogs' own descriptions name ("each { cells: string[] }", "each + * { label, value }") and asserts the prop is not wholly ignored. + */ +const OPAQUE_RECORD_KEYS = ["cells", "label", "value", "text"] as const; + +/* --------------------------------------------------------------- schema */ + +function deref(catalog: Catalog, node: any): any { + if (node && typeof node.$ref === "string" && node.$ref.startsWith("#/")) { + let target: any = catalog; + for (const seg of node.$ref.replace(/^#\//, "").split("/")) target = target?.[seg]; + return target; + } + return node; +} + +function refName(node: any): string | undefined { + return typeof node?.$ref === "string" ? node.$ref.split("/").pop() : undefined; +} + +/** Flattened properties + required names for one component (allOf/$ref resolved). */ +function componentSchema(catalog: Catalog, name: string): { props: Record; required: string[] } { + const props: Record = {}; + const required = new Set(); + const walk = (node: any) => { + if (!node || typeof node !== "object") return; + if (typeof node.$ref === "string") walk(deref(catalog, node)); + if (Array.isArray(node.allOf)) node.allOf.forEach(walk); + if (node.properties) Object.assign(props, node.properties); + if (Array.isArray(node.required)) node.required.forEach((r: string) => required.add(r)); + }; + walk(catalog.components[name]); + required.delete("component"); + required.delete("id"); + return { props, required: [...required] }; +} + +/* ------------------------------------------------------------- sentinels */ + +interface Sentinel { + value: unknown; + /** Strings that must be observable in the markup. */ + strings: string[]; + /** Opaque records: the prop must show at least one, not all. */ + anyOf: boolean; +} + +/** + * Build a sentinel value from a prop's SCHEMA — never from a guess about the + * renderer. Unhandled shapes throw rather than pass: a required prop the + * guard cannot reason about must fail loudly and be given a rule (or an + * allowlist entry with a reason), not slip through as a silent success. + */ +function sentinelFor(catalog: Catalog, schema: any, path: string): Sentinel { + const ref = refName(schema); + if (ref === "ComponentId") { + const id = `sentinel-${path}`; + return { value: id, strings: [id], anyOf: false }; + } + if (ref === "ChildList") { + const ids = [`sentinel-${path}-0`, `sentinel-${path}-1`]; + return { value: ids, strings: ids, anyOf: false }; + } + const node = ref ? deref(catalog, schema) : schema; + // DynamicString is `string | binding | functionCall`; the plain-string + // branch is what an emitted literal uses. + if (Array.isArray(node?.oneOf) && node.oneOf.some((b: any) => b.type === "string")) { + const s = `SENTINEL~${path}`; + return { value: s, strings: [s], anyOf: false }; + } + if (Array.isArray(node?.enum)) { + throw new Error(`required enum prop '${path}' cannot carry a sentinel — give it a rule or an allowlist entry`); + } + if (node?.type === "string") { + const s = `SENTINEL~${path}`; + return { value: s, strings: [s], anyOf: false }; + } + if (node?.type === "number" || node?.type === "integer") { + return { value: 424242, strings: ["424242"], anyOf: false }; + } + if (node?.type === "array") { + const items = node.items ?? {}; + const built = [0, 1].map((i) => sentinelFor(catalog, items, `${path}.${i}`)); + return { + value: built.map((b) => b.value), + strings: built.flatMap((b) => b.strings), + anyOf: built.some((b) => b.anyOf), + }; + } + if (node?.type === "object") { + const declared = Object.entries(node.properties ?? {}); + if (declared.length > 0) { + const value: Record = {}; + const strings: string[] = []; + let anyOf = false; + for (const [key, sub] of declared) { + const built = sentinelFor(catalog, sub, `${path}.${key}`); + value[key] = built.value; + strings.push(...built.strings); + anyOf ||= built.anyOf; + } + return { value, strings, anyOf }; + } + // Opaque record: probe the documented content keys, demand at least one. + const value: Record = {}; + const strings: string[] = []; + for (const key of OPAQUE_RECORD_KEYS) { + const s = `SENTINEL~${path}.${key}`; + value[key] = key === "cells" ? [s] : s; + strings.push(s); + } + return { value, strings, anyOf: true }; + } + throw new Error(`no sentinel rule for required prop '${path}' (schema ${JSON.stringify(schema).slice(0, 120)})`); +} + +/* ---------------------------------------------------------------- render */ + +/** Marker child: a built slot renders its ComponentId verbatim, so a dropped + * slot is a dropped sentinel like any other. */ +const buildChild = (id: string): ReactNode => createElement("i", { key: id }, `[child:${id}]`); + +function render(registry: Registry, name: string, props: Record): string { + const Visual = (registry.custom as Record)[name]; + return renderToStaticMarkup( + createElement(Visual, { + props, + buildChild, + context: { componentModel: { id: "node" }, dataContext: { path: "/" } }, + }), + ); +} + +/** The instance a component's required props alone produce, plus what must show. */ +function requiredInstance(catalog: Catalog, name: string) { + const { props: schemas, required } = componentSchema(catalog, name); + const props: Record = {}; + const expect: Array<{ prop: string; strings: string[]; anyOf: boolean }> = []; + for (const prop of required) { + if (prop in STRUCTURAL_PROPS) { + props[prop] = () => {}; + continue; + } + const built = sentinelFor(catalog, schemas[prop], prop); + props[prop] = built.value; + expect.push({ prop, strings: built.strings, anyOf: built.anyOf }); + } + return { props, expect }; +} + +/** Every required prop of `name` that this registry's visual fails to show. */ +function ignoredRequiredProps(catalog: Catalog, registry: Registry, name: string): string[] { + const { props, expect: expected } = requiredInstance(catalog, name); + const html = render(registry, name, props); + const out: string[] = []; + for (const { prop, strings, anyOf } of expected) { + const seen = strings.filter((s) => html.includes(s)); + if (anyOf ? seen.length === 0 : seen.length !== strings.length) { + const missing = strings.filter((s) => !html.includes(s)); + out.push(`${name}.${prop} ignored (no sentinel in output: ${JSON.stringify(missing)})`); + } + } + return out; +} + +/** Catalog names this design system draws natively (wireframe fallback excluded: + * the universal visual consumes anything, so it cannot witness this property). */ +function nativeNames(catalog: Catalog, registry: Registry): string[] { + return Object.keys(catalog.components).filter((n) => Boolean((registry.custom as Record)[n])); +} + +/* ----------------------------------------------------------------- suite */ + +describe("required catalog props are observably consumed by their native renderer", () => { + for (const id of NATIVE_REGISTRIES) { + it(`${id}: every required prop of every natively-drawn component reaches the output`, () => { + const catalog = CATALOGS[id]; + const registry = nativeRegistryFor(id)!; + const names = nativeNames(catalog, registry); + // Guard on the guard: an empty or collapsed name set would pass vacuously. + expect(names.length).toBeGreaterThanOrEqual(10); + const ignored = names.flatMap((n) => ignoredRequiredProps(catalog, registry, n)); + expect(ignored).toEqual([]); + }); + } + + it("the allowlist stays a short list of justified structural props", () => { + // An entry here is a claim someone must defend, so make growing it visible. + expect(Object.keys(STRUCTURAL_PROPS)).toEqual(["action"]); + for (const reason of Object.values(STRUCTURAL_PROPS)) expect(reason.length).toBeGreaterThan(40); + }); + + it("rejects a visual that ignores a required prop, rather than passing it silently", () => { + // The detector turned on a deliberately broken visual: without this, a + // registry whose renderers all returned null would look perfect. + const blind: Registry = { reuseBasic: new Set(), custom: { Badge: () => null } }; + expect(ignoredRequiredProps(CATALOGS.astryx, blind, "Badge")).toEqual([ + 'Badge.label ignored (no sentinel in output: ["SENTINEL~label"])', + ]); + // ...and stays quiet on one that consumes it. + expect(ignoredRequiredProps(CATALOGS.astryx, nativeRegistryFor("astryx")!, "Badge")).toEqual([]); + }); +}); diff --git a/apps/composer/app/validation.test.ts b/apps/composer/app/validation.test.ts new file mode 100644 index 0000000..1d34b29 --- /dev/null +++ b/apps/composer/app/validation.test.ts @@ -0,0 +1,96 @@ +/** + * A3/A4 EQUIVALENCE, browser half. + * + * The measured divergence this closes: the browser validated the emitted + * surface against A2UI 0.9.1 ONLY, while the agent validated 0.9.1 AND 1.0. + * The same governed project therefore got a different validation truth + * depending on whether it was browser-backed or repository-backed — and + * nothing in the result said which versions had run, so nobody could tell. + * + * The canonical answer is BOTH. dspack-gen's `runPipeline` — the generator + * behind both the agent's and the hosted browser's BUILD — defaults to + * `a2uiVersions: ["0.9.1", "1.0"]`; the agent's emit matched it; so does the + * composer's build-time reference bake. The browser's emit was the only + * dissenter, so the browser is what moved. No version was deleted to make + * outputs agree. + * + * This half asserts browserEmit is the shared seam plus SURFACE SELECTION and + * nothing else. The agent half (apps/agent/src/project.test.ts) asserts the + * route is the same seam plus FILE WRITING. Chained through the seam, the two + * doors are equal by construction rather than by coincidence. + */ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { A2UI_VERSIONS, projectEmit } from "@dspack-studio/composer-core"; +import { browserEmit, contractSurfaces } from "./validation"; + +const read = (rel: string) => JSON.parse(readFileSync(new URL(rel, import.meta.url), "utf8")); + +/** The shipped demo project — a real bootstrapped-and-enriched contract with a + * JSON profile, the same material the agent's route tests run against. */ +const contract = read("../demo-project/acme-ui.dspack.json") as Record; +const profileJson = read("../demo-project/acme.profile.json") as Record; + +describe("browserEmit — the same emit seam the agent runs", () => { + it("validates BOTH canonical A2UI versions, and says which ones it ran", () => { + const result = browserEmit(contract, profileJson, contractSurfaces(contract)); + expect(result.runs.map((r) => r.version)).toEqual([...A2UI_VERSIONS]); + expect(result.ok).toBe(result.runs.every((r) => r.pass)); + // Not just a label: the 1.0 catalog is really compiled and gated. The two + // A2UI versions are distinguishable in the catalog shape itself — 0.9.1 + // requires `$defs.theme` and forbids `$defs.surfaceProperties`; 1.0 is the + // exact inverse — so this is proof the second run happened, not a claim. + const v0_9_1 = result.runs.find((r) => r.version === "0.9.1")!; + const v1_0 = result.runs.find((r) => r.version === "1.0")!; + expect(v0_9_1.catalog.$defs.theme).toBeTruthy(); + expect(v0_9_1.catalog.$defs.surfaceProperties).toBeUndefined(); + expect(v1_0.catalog.$defs.surfaceProperties).toBeTruthy(); + expect(v1_0.catalog.$defs.theme).toBeUndefined(); + }); + + it("is projectEmit plus surface selection: same verdict, same finding set, same catalog", () => { + const surfaces = contractSurfaces(contract); + const browser = browserEmit(contract, profileJson, surfaces); + const seam = projectEmit(contract, profileJson, surfaces); + expect(browser.ok).toBe(seam.ok); + expect(browser.findings).toEqual(seam.findings); + expect(browser.catalog).toEqual(seam.catalog); + expect(browser.surfaces).toEqual(seam.surfaces); + }); + + it("selects the contract's worked examples — the surfaces a browser-backed project has", () => { + // The documented, justified asymmetry with the agent: a repository-backed + // project also emits its surfacesDir, which the browser has no access to. + const names = contractSurfaces(contract).map((s) => s.name); + expect(names).toEqual( + ((contract.examples as Array<{ id?: string; surface?: unknown }>) ?? []) + .filter((e) => e.surface) + .map((e) => e.id ?? "example"), + ); + expect(names.length).toBeGreaterThan(0); + }); + + it("reports every packaged reference project identically under both versions", () => { + // The honest impact statement for this change, asserted rather than + // claimed: on the material the composer actually ships, 1.0 surfaces no + // finding that 0.9.1 did not. Users see no new noise — they gain the + // guarantee that a 1.0 failure would now be shown rather than silently + // passed over. + for (const dir of ["shadcn-v3-project", "astryx-project"]) { + const manifest = read(`../${dir}/project.json`) as { contractPath: string; profilePath: string }; + const doc = read(`../${dir}/${manifest.contractPath}`) as Record; + const profile = read(`../${dir}/${manifest.profilePath}`) as Record; + const result = browserEmit(doc, profile, contractSurfaces(doc)); + expect(result.runs.map((r) => r.version)).toEqual([...A2UI_VERSIONS]); + const perVersion = new Map(); + for (const run of result.runs) { + perVersion.set( + run.version, + result.findings.filter((f) => f.target === `a2ui@${run.version}`).map((f) => `${f.gate}/${f.code}: ${f.message}`), + ); + } + expect(perVersion.get("1.0")).toEqual(perVersion.get("0.9.1")); + expect(perVersion.get("1.0")).toEqual([]); + } + }); +}); diff --git a/apps/composer/app/validation.ts b/apps/composer/app/validation.ts index 9928297..3537461 100644 --- a/apps/composer/app/validation.ts +++ b/apps/composer/app/validation.ts @@ -5,10 +5,13 @@ * * document gate + consistency @aestheticfunction/dspack-spec lib * S1/S2/S3 surface lint @aestheticfunction/dspack-gen/core - * emit + A-gates + fidelity @aestheticfunction/dspack-emit (browser-safe) + * emit + A-gates + fidelity composer-core's projectEmit, over + * @aestheticfunction/dspack-emit (browser-safe) * * Everything here is synchronous and pure; the agent stays the file-writing - * authority, but every EDIT gets instant, gate-identical feedback. + * authority, but every EDIT gets instant, gate-identical feedback. "Gate + * identical" is now structural rather than aspirational: the emit loop itself + * is the shared seam both doors call, not a copy of it (see browserEmit). */ import { compileSchemaSet, @@ -19,15 +22,7 @@ import dspackV04 from "@aestheticfunction/dspack-spec/schema/dspack.v0.4.schema. import dspackV03 from "@aestheticfunction/dspack-spec/schema/dspack.v0.3.schema.json"; import surfaceV01 from "@aestheticfunction/dspack-spec/schema/dspack.surface.v0_1.schema.json"; import { lintSurface } from "@aestheticfunction/dspack-gen/core"; -import { - transformFromJson, - emitSurface, - loadProfile, - EmitSurfaceError, - ProfileLoadError, - type Profile, -} from "@aestheticfunction/dspack-emit"; -import { finding, catalogGateFindings, classifySurfaceRefusal, type ComposerFinding } from "@dspack-studio/composer-core"; +import { finding, projectEmit, type ComposerFinding, type ProjectEmitResult, type SurfaceToEmit } from "@dspack-studio/composer-core"; let compiled: ValidatorMap | undefined; function validators(): ValidatorMap { @@ -68,107 +63,39 @@ export function lintOneSurface(name: string, surface: unknown, contract: Record< return findings; } -export interface BrowserEmitResult { - ok: boolean; - catalog?: Record; - surfaces: Array<{ name: string; messages?: unknown[]; warnings: Array<{ code: string; message: string }>; error?: string }>; - findings: ComposerFinding[]; -} +/** The emit result the browser hands the views — the shared seam's result. */ +export type BrowserEmitResult = ProjectEmitResult; /** * The full emit loop in the browser: profile load, per-surface emission, - * catalog gates for 0.9.1, coverage + fidelity findings. The agent's - * /project/emit remains the twin that also WRITES out/; this one powers - * instant feedback on unsaved edits and full demo-mode function. - */ -/** - * The A3 refusal finding, classified. An emit refusal caused solely by - * components the profile author declared casualties (with a written reason) - * is an acknowledged decision — the finding keeps its severity, code, - * target, and verbatim message, and gains structured evidence of the - * acknowledgement. See composer-core's classifySurfaceRefusal for the rule. + * catalog gates for EVERY canonical A2UI version, coverage + fidelity + * findings. It powers instant feedback on unsaved edits and full demo-mode + * function. + * + * It is the SHARED SEAM plus surface selection, and nothing else. The loop + * itself lives in composer-core (`projectEmit`) because the agent's + * /project/emit runs the identical loop and the two copies had already + * drifted: this side validated A2UI 0.9.1 only while the agent validated + * 0.9.1 and 1.0, so the same governed project got a different verdict + * depending on which door it came through. Equivalence is now structural, and + * asserted from both sides (validation.test.ts here, project.test.ts there). */ -function refusalFinding( - name: string, - error: string, - surfaces: Array<{ name: string; surface: unknown }>, - contract: Record, - profileJson: Record, -): ComposerFinding { - const base = finding("A3", "emit-surface", "error", name, error); - const surface = surfaces.find((s) => s.name === name)?.surface; - const acknowledged = classifySurfaceRefusal(surface, contract, profileJson as Record); - return acknowledged ? { ...base, acknowledged } : base; -} - export function browserEmit( contract: Record, profileJson: Record, - surfaces: Array<{ name: string; surface: unknown }>, + surfaces: SurfaceToEmit[], ): BrowserEmitResult { - let profile: Profile; - try { - profile = loadProfile(profileJson); - } catch (e) { - if (e instanceof ProfileLoadError) { - return { - ok: false, - surfaces: [], - findings: e.issues.map((i) => finding("profile", "schema", "error", i.path, i.message)), - }; - } - throw e; - } - - const emitted: BrowserEmitResult["surfaces"] = []; - const allMessages: unknown[] = []; - for (const { name, surface } of surfaces) { - try { - const r = emitSurface(surface as never, contract as never, { profile }); - emitted.push({ name, messages: r.messages, warnings: r.warnings as Array<{ code: string; message: string }> }); - allMessages.push(...r.messages); - } catch (e) { - if (e instanceof EmitSurfaceError) { - emitted.push({ name, warnings: [], error: e.message }); - continue; - } - throw e; - } - } - - const findings: ComposerFinding[] = []; - const out = transformFromJson(contract as never, { a2uiVersion: "0.9.1", surface: { messages: allMessages }, profile }); - for (const gate of out.validation.gates) { - if (!gate.pass) { - const gateId = gate.name.startsWith("schema-compile") ? "A1" : gate.name === "catalog-shape" ? "A2" : "A3"; - // Per-instance findings with honest Component#id targets when the - // emitter reports structured errorDetails (dspack-emit >= 0.7, - // feature-detected); otherwise one capped finding whose `evidence` - // keeps every raw error string. Twin: apps/agent/src/project.ts emit(). - findings.push(...catalogGateFindings(gateId as "A1", gate, "a2ui@0.9.1")); - } - } - for (const c of out.mapping.coverage) { - if (c.disposition === "unclassified") { - findings.push(finding("coverage", "unclassified", "error", c.id, "component is neither mapped, adapted, omitted, nor a declared casualty")); - } - } - for (const f of out.mapping.fidelity) { - if (f.class === "lossy" || f.class === "cannot-represent") { - findings.push(finding("fidelity", f.class, "warn", f.source, f.note)); - } - } - for (const { name, warnings, error } of emitted) { - if (error) findings.push(refusalFinding(name, error, surfaces, contract, profileJson)); - for (const w of warnings) findings.push(finding("A3", w.code, "info", name, w.message)); - } - - return { ok: out.validation.pass, catalog: out.catalog as Record, surfaces: emitted, findings }; + return projectEmit(contract, profileJson, surfaces); } -/** Contract examples + any extra named surfaces, the emit/preview corpus. */ -export function contractSurfaces(contract: Record): Array<{ name: string; surface: unknown }> { - const out: Array<{ name: string; surface: unknown }> = []; +/** + * The surfaces a BROWSER-backed project has: the contract's worked examples. + * This is the documented asymmetry with the agent, which additionally emits + * the project's `surfacesDir` — a directory a browser-backed project has no + * access to. Everything downstream of this selection is identical. + */ +export function contractSurfaces(contract: Record): SurfaceToEmit[] { + const out: SurfaceToEmit[] = []; for (const example of (contract.examples as Array<{ id?: string; surface?: unknown }> | undefined) ?? []) { if (example.surface) out.push({ name: example.id ?? "example", surface: example.surface }); } diff --git a/package.json b/package.json index 956ec9e..1b10c7f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "packageManager": "pnpm@10.25.0", "scripts": { "build": "pnpm -r --filter './packages/**' build", - "test": "pnpm -r --filter './packages/**' test", + "test": "pnpm build:contracts && pnpm -r --no-bail test", "build:contracts": "pnpm --filter @dspack-studio/contracts build:catalogs", "dev": "pnpm --filter web dev", "e2e": "pnpm --filter web build && playwright test", diff --git a/packages/composer-core/package.json b/packages/composer-core/package.json index fb2c144..e6b7d99 100644 --- a/packages/composer-core/package.json +++ b/packages/composer-core/package.json @@ -2,7 +2,7 @@ "name": "@dspack-studio/composer-core", "version": "0.1.0", "private": true, - "description": "Pure data layer of the catalog composer: the project manifest schema, x-bootstrap ledger reading (WebCrypto, isomorphic), the normalized finding shape, and the adapter manifests binding the three existing seams (discovery / mapping / rendering) by reference. No protocol dependencies, no React, no Node-only APIs.", + "description": "Pure data layer of the catalog composer: the project manifest schema, x-bootstrap ledger reading (WebCrypto, isomorphic), the normalized finding shape, the shared emit seam (one emit loop for the agent and the browser), and the adapter manifests binding the three existing seams (discovery / mapping / rendering) by reference. No protocol dependencies, no React, no Node-only APIs.", "license": "Apache-2.0", "type": "module", "exports": { @@ -13,6 +13,7 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { + "@aestheticfunction/dspack-emit": "^0.7.0", "zod": "3.25.76" }, "devDependencies": { diff --git a/packages/composer-core/src/emit.test.ts b/packages/composer-core/src/emit.test.ts new file mode 100644 index 0000000..43be6b3 --- /dev/null +++ b/packages/composer-core/src/emit.test.ts @@ -0,0 +1,91 @@ +/** + * THE EMIT SEAM, and the divergence it exists to end. + * + * The same governed project used to get a different validation truth depending + * on which door it came through. `apps/agent/src/project.ts` emit() validated + * the emitted surface against A2UI 0.9.1 AND 1.0; the browser's + * `apps/composer/app/validation.ts` browserEmit() validated 0.9.1 only. The + * canonical answer is BOTH, and it is not a matter of taste: dspack-gen's + * `runPipeline` — the generator both the agent and the hosted browser BUILD + * run — defaults to `a2uiVersions: ["0.9.1", "1.0"]`, the agent's emit matched + * it, and so does the composer's build-time reference bake + * (apps/composer/scripts/demo-assets.mjs). Three of the four twins already + * said both; the browser's emit was the outlier, so it is the one that moved. + * + * These tests pin the seam itself. The equivalence of the two CALL SITES is + * proven where each one lives: apps/agent/src/project.test.ts (the agent route + * adds file writing and nothing else) and apps/composer/app/validation.test.ts + * (the browser adds surface selection and nothing else). + */ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { A2UI_VERSIONS, projectEmit } from "./emit"; + +const read = (rel: string) => JSON.parse(readFileSync(new URL(rel, import.meta.url), "utf8")); + +/** The shipped demo project: a REAL non-canonical contract bootstrapped by + * dspack-export and human-enriched, with a JSON profile and a surface whose + * emit refuses on an authored casualty. */ +const contract = read("../../../apps/composer/demo-project/acme-ui.dspack.json") as Record; +const profileJson = read("../../../apps/composer/demo-project/acme.profile.json") as Record; +const surfaces = ((contract.examples as Array<{ id?: string; surface?: unknown }>) ?? []) + .filter((e) => e.surface) + .map((e) => ({ name: e.id ?? "example", surface: e.surface })); + +describe("projectEmit — one emit loop, one validation truth", () => { + it("validates every canonical A2UI version, not just the first", () => { + // The divergence, made observable: the result names the versions it + // actually validated, so "which versions did this verdict come from" is + // answerable rather than assumed. + expect(A2UI_VERSIONS).toEqual(["0.9.1", "1.0"]); + const result = projectEmit(contract, profileJson, surfaces); + expect(result.runs.map((r) => r.version)).toEqual(["0.9.1", "1.0"]); + for (const run of result.runs) { + expect(run.catalog).toBeTruthy(); + expect(run.report).toBeTruthy(); + } + }); + + it("`ok` is the conjunction over versions, not the first version's verdict", () => { + const result = projectEmit(contract, profileJson, surfaces); + expect(result.ok).toBe(result.runs.every((r) => r.pass)); + // The shipped demo passes both; asserting the CONJUNCTION rather than the + // value is what stops a one-version verdict from creeping back in. + expect(result.runs.every((r) => r.pass)).toBe(true); + }); + + it("reports the primary catalog and the emitted surfaces alongside the findings", () => { + const result = projectEmit(contract, profileJson, surfaces); + expect(result.catalog).toBe(result.runs[0].catalog); + expect(result.surfaces.map((s) => s.name)).toEqual(surfaces.map((s) => s.name)); + for (const surface of result.surfaces) { + expect(surface.error === undefined ? Array.isArray(surface.messages) : true).toBe(true); + } + }); + + it("refuses a schema-invalid profile with pathed findings and emits nothing", () => { + const result = projectEmit(contract, { ...profileJson, components: "not an array" }, surfaces); + expect(result.ok).toBe(false); + expect(result.runs).toEqual([]); + expect(result.surfaces).toEqual([]); + expect(result.findings.length).toBeGreaterThan(0); + for (const f of result.findings) expect(f.gate).toBe("profile"); + }); + + it("classifies an authored casualty refusal instead of reporting it as unfinished work", () => { + // The demo project's `uses-casualty` surface refuses because the profile + // declares mini-stepper a casualty WITH a written reason. The seam must + // carry that classification, or the agent and browser would disagree about + // whether a project is done. + const casualtySurface = read("../../../apps/composer/demo-project/surfaces/uses-casualty.dsurface.json"); + const result = projectEmit(contract, profileJson, [ + ...surfaces, + { name: "uses-casualty", surface: casualtySurface }, + ]); + const refusal = result.findings.find((f) => f.gate === "A3" && f.code === "emit-surface"); + expect(refusal?.target).toBe("uses-casualty"); + expect(refusal?.severity).toBe("error"); + expect(refusal?.acknowledged?.componentId).toBe("mini-stepper"); + expect(refusal?.acknowledged?.reason).toContain("steps is an array prop"); + }); +}); diff --git a/packages/composer-core/src/emit.ts b/packages/composer-core/src/emit.ts new file mode 100644 index 0000000..7176d44 --- /dev/null +++ b/packages/composer-core/src/emit.ts @@ -0,0 +1,209 @@ +/** + * THE EMIT SEAM: one emit loop, one validation truth. + * + * Two doors led to the same governed project — the local agent's + * `/project/emit` (repository-backed) and the composer's in-browser + * `browserEmit` (browser-backed) — and each carried its own copy of the emit + * loop. The copies drifted where copies always drift, on the detail nobody + * re-reads: the agent validated the emitted surface against A2UI 0.9.1 AND + * 1.0, the browser against 0.9.1 alone. The same project, two truths, and + * nothing in either result said which versions had run. + * + * WHY BOTH IS THE CANONICAL ANSWER, and not simply the majority vote: + * dspack-gen's `runPipeline` — the generator behind BUILD on both doors — + * defaults to `a2uiVersions: ["0.9.1", "1.0"]` and neither caller overrides + * it, so generation already gated both versions on both doors. The agent's + * emit matched generation; so does the composer's build-time reference bake + * (apps/composer/scripts/demo-assets.mjs). The browser's emit was the lone + * dissenter, and validating FEWER versions than the generator that produced + * the surface is the one reading that cannot be right. + * + * SCOPE. This is the smallest seam that stops the known divergence: profile + * load, per-surface emission, per-version catalog gates, coverage, fidelity, + * refusal classification, warnings — everything the two copies said + * identically, plus the one thing they said differently. It deliberately does + * NOT absorb what is genuinely different about each door: the agent writes + * `out/` (it owns the filesystem) and the browser selects which surfaces exist + * (it has no `surfacesDir`). Those stay at their call sites, which is why they + * can be asserted as "the seam plus exactly one thing" from either side. + * + * The other known twins between these two files — id minting, the accept gate, + * the scripted adapter — are NOT extracted here. They have not been measured + * to diverge, and a refactor is not a correctness fix. + */ +import { + loadProfile, + transformFromJson, + emitSurface, + EmitSurfaceError, + ProfileLoadError, + type A2uiVersion, + type Profile, +} from "@aestheticfunction/dspack-emit"; +import { + finding, + catalogGateFindings, + classifySurfaceRefusal, + type ComposerFinding, +} from "./findings"; + +/** + * The A2UI versions every governed emit in this repo validates against. + * + * Pinned to dspack-gen's `runPipeline` default (`["0.9.1", "1.0"]`, see + * dist/run/orchestrator.js): the generator that produced the surface decides + * which versions it is claiming to satisfy, and emit validates exactly that + * set. If dspack-gen's default ever changes, this constant is the ONE place + * the studio follows it. + */ +export const A2UI_VERSIONS: readonly A2uiVersion[] = ["0.9.1", "1.0"]; + +/** A surface to emit, named the way its door names it. */ +export interface SurfaceToEmit { + name: string; + surface: unknown; +} + +/** One surface's emission outcome — messages, warnings, or a verbatim refusal. */ +export interface EmittedSurface { + name: string; + messages?: unknown[]; + warnings: Array<{ code: string; message: string }>; + error?: string; +} + +/** One A2UI version's compiled catalog and its gate verdict. */ +export interface EmitVersionRun { + version: A2uiVersion; + pass: boolean; + catalog: Record; + report: unknown; +} + +export interface ProjectEmitResult { + /** Every version's catalog gates passed. */ + ok: boolean; + /** The primary (first version's) catalog — what previews render. */ + catalog?: Record; + /** The primary version's fidelity/coverage report JSON. */ + report?: unknown; + /** One entry per validated A2UI version, in `A2UI_VERSIONS` order. */ + runs: EmitVersionRun[]; + surfaces: EmittedSurface[]; + findings: ComposerFinding[]; +} + +/** + * Emit a governed project's surfaces and validate the result, exactly once, + * for every door. Pure: no filesystem, no network, no globals — the agent + * hands it parsed documents and writes the returned runs to disk itself. + */ +export function projectEmit( + contract: Record, + profileJson: Record, + surfaces: SurfaceToEmit[], +): ProjectEmitResult { + let profile: Profile; + try { + profile = loadProfile(profileJson); + } catch (e) { + if (e instanceof ProfileLoadError) { + return { + ok: false, + runs: [], + surfaces: [], + findings: e.issues.map((i) => finding("profile", "schema", "error", i.path, i.message)), + }; + } + throw e; + } + + const emitted: EmittedSurface[] = []; + const allMessages: unknown[] = []; + for (const { name, surface } of surfaces) { + try { + const r = emitSurface(surface as never, contract as never, { profile }); + emitted.push({ name, messages: r.messages, warnings: r.warnings as EmittedSurface["warnings"] }); + allMessages.push(...r.messages); + } catch (e) { + if (e instanceof EmitSurfaceError) { + emitted.push({ name, warnings: [], error: e.message }); + continue; + } + throw e; + } + } + + // One transform per version. The emitter's gate and mapping handles stay + // local — the published result is data, not an emitter session. + const transformed = A2UI_VERSIONS.map((version) => ({ + version, + out: transformFromJson(contract as never, { + a2uiVersion: version, + surface: { messages: allMessages }, + profile, + }), + })); + const runs: EmitVersionRun[] = transformed.map(({ version, out }) => ({ + version, + pass: out.validation.pass, + catalog: out.catalog as Record, + report: out.report.json, + })); + + const findings: ComposerFinding[] = []; + // CATALOG GATES: reported per version, because a gate that fails under one + // A2UI version and passes under another is exactly the information the + // single-version reading destroyed. The `a2ui@` target is what + // distinguishes the rows; catalogGateFindings still explodes structured + // errorDetails into honest Component#id targets when the emitter supplies + // them (dspack-emit >= 0.7, feature-detected). + for (const { version, out } of transformed) { + for (const gate of out.validation.gates) { + if (gate.pass) continue; + const gateId = gate.name.startsWith("schema-compile") ? "A1" : gate.name === "catalog-shape" ? "A2" : "A3"; + findings.push(...catalogGateFindings(gateId as "A1", gate, `a2ui@${version}`)); + } + } + + // COVERAGE + FIDELITY come from the primary run: they describe how the + // CONTRACT projects onto A2UI, which is version-independent. + const primary = transformed[0].out; + for (const c of primary.mapping.coverage) { + if (c.disposition === "unclassified") { + findings.push( + finding("coverage", "unclassified", "error", c.id, "component is neither mapped, adapted, omitted, nor a declared casualty"), + ); + } + } + for (const f of primary.mapping.fidelity) { + if (f.class === "lossy" || f.class === "cannot-represent") { + findings.push(finding("fidelity", f.class, "warn", f.source, f.note)); + } + } + + for (const { name, warnings, error } of emitted) { + if (error) { + // An emit refusal caused solely by components the profile author + // declared casualties (with a written reason) is an acknowledged + // decision, not unresolved work. The finding keeps its severity, code, + // target, and verbatim message; the classification rides alongside. + const base = finding("A3", "emit-surface", "error", name, error); + const surface = surfaces.find((s) => s.name === name)?.surface; + const acknowledged = classifySurfaceRefusal(surface, contract as Record, profileJson as Record); + findings.push(acknowledged ? { ...base, acknowledged } : base); + } + for (const w of warnings) findings.push(finding("A3", w.code, "info", name, w.message)); + } + + return { + // Every version, not the first one: a project is only clean when the whole + // set of versions it claims to satisfy actually gates clean. + ok: runs.every((r) => r.pass), + catalog: runs[0]?.catalog, + report: runs[0]?.report, + runs, + surfaces: emitted, + findings, + }; +} diff --git a/packages/composer-core/src/index.ts b/packages/composer-core/src/index.ts index 2cb32d9..04b617b 100644 --- a/packages/composer-core/src/index.ts +++ b/packages/composer-core/src/index.ts @@ -49,6 +49,14 @@ export { type GateErrorDetail, type GateErrorDetailError, } from "./findings"; +export { + A2UI_VERSIONS, + projectEmit, + type SurfaceToEmit, + type EmittedSurface, + type EmitVersionRun, + type ProjectEmitResult, +} from "./emit"; export { COMPOSER_ADAPTERS, composerAdapter, diff --git a/packages/shadcn-renderers/package.json b/packages/shadcn-renderers/package.json index 65b46b7..a89b691 100644 --- a/packages/shadcn-renderers/package.json +++ b/packages/shadcn-renderers/package.json @@ -24,6 +24,7 @@ "react": ">=19.0.0" }, "devDependencies": { + "@aestheticfunction/dspack-emit": "^0.7.0", "@tailwindcss/cli": "^4.1.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/shadcn-renderers/src/components/AlertDialogRender.tsx b/packages/shadcn-renderers/src/components/AlertDialogRender.tsx index a6fcfbb..e96684e 100644 --- a/packages/shadcn-renderers/src/components/AlertDialogRender.tsx +++ b/packages/shadcn-renderers/src/components/AlertDialogRender.tsx @@ -5,6 +5,17 @@ * is visible evidence, not hidden behind a modal. The markup and classes are * shadcn's alert-dialog content; the portal/overlay behavior is deliberately * not used, so no Radix dependency enters the bundle. + * + * TWO CATALOGS, TWO NAMES FOR THE CONFIRM — and one extra part. The + * Astryx/neutral catalog names the confirm action `actionLabel` (required) + * and stops at the panel. shadcn/ui v3 names it `confirmLabel` and also + * declares `triggerLabel` (REQUIRED) — shadcn's AlertDialog is Trigger + + * Content, and the trigger is what tells a reader what the dialog is FOR. + * Reading only `actionLabel` left every shadcn/ui v3 confirmation with a blank + * confirm button and no opener at all: "Delete project and all data" and + * "Delete project" both vanished from the flagship destructive surface. The + * trigger renders only when the catalog declares one, so the neutral catalog's + * panel-only rendering is unchanged. */ import { useId, type FC } from "react"; import { cva } from "class-variance-authority"; @@ -33,8 +44,28 @@ const actionVariants = cva( export const AlertDialogRender: FC = ({ props }) => { const titleId = useId(); const descriptionId = useId(); + const panelId = useId(); + // shadcn/ui v3 names the confirm action `confirmLabel`; the neutral catalog + // names it `actionLabel`. An instance carries exactly one. + const confirmLabel = props.confirmLabel ?? props.actionLabel; + const hasTrigger = props.triggerLabel != null; + // A Fragment, not a wrapper: a catalog without `triggerLabel` (the neutral + // one) must render the panel exactly as it did before this renderer learned + // about triggers — same element, same attributes, no new box. return ( + <> + {hasTrigger && ( + + )}
= ({ props }) => { className={cn(actionVariants({ variant: (props.actionVariant as any) ?? "primary" }))} onClick={() => props.action?.()} > - {String(props.actionLabel ?? "")} + {String(confirmLabel ?? "")}
+ ); }; diff --git a/packages/shadcn-renderers/src/components/ButtonRender.tsx b/packages/shadcn-renderers/src/components/ButtonRender.tsx index 3d3c9ec..5322768 100644 --- a/packages/shadcn-renderers/src/components/ButtonRender.tsx +++ b/packages/shadcn-renderers/src/components/ButtonRender.tsx @@ -3,6 +3,14 @@ * style). The catalog's Astryx-flavored variant vocabulary projects onto * shadcn's nearest treatment — the same inverse-mapping duty the Astryx * TextRender performs for typography. + * + * TWO CATALOGS, TWO ANATOMIES FOR THE CONTENT. The Astryx/neutral catalog + * carries the button's words in a required `label` string. shadcn/ui v3 + * carries them the way shadcn's own Button does — a required `child` + * ComponentId pointing at a Text the surface defines separately. Reading only + * `label` rendered every shadcn/ui v3 button with no text at all, including + * the "Cancel" on the flagship delete-confirmation surface. Both are honored: + * a literal label when the catalog gives one, the built child otherwise. */ import type { FC } from "react"; import { cva } from "class-variance-authority"; @@ -37,7 +45,7 @@ const VARIANT: Record }; const SIZE: Record = { sm: "sm", md: "default", lg: "lg" }; -export const ButtonRender: FC = ({ props }) => ( +export const ButtonRender: FC = ({ props, buildChild }) => ( ); diff --git a/packages/shadcn-renderers/src/components/TableRender.tsx b/packages/shadcn-renderers/src/components/TableRender.tsx index 5ae0dcc..a1318e4 100644 --- a/packages/shadcn-renderers/src/components/TableRender.tsx +++ b/packages/shadcn-renderers/src/components/TableRender.tsx @@ -1,13 +1,26 @@ /** * Catalog `Table` -> shadcn/ui Table markup. Both modes of the catalog shape - * (data-driven columns/data and nested children) render, mirroring the + * (data-driven columns/rows and nested children) render, mirroring the * Astryx renderer's chunking of flat children into rows of one cell per * column; rows with a status get a trailing Badge cell. * + * TWO CATALOGS, TWO NAMES FOR THE ROWS. This registry serves both governed + * catalogs (see registry-parity.test.ts), and they do not agree: shadcn/ui v3 + * declares `rows` (REQUIRED, alongside `columns`, with no `data` and no + * `children`), while the Astryx/neutral catalog declares `data` (with + * `children` as its alternative). Reading only `data` — which is what this + * renderer did — meant no code path could ever render a shadcn table row: + * every shipped shadcn table drew its headers over an empty . Both + * names are honored here, each named after the catalog that declares it, so + * neither design system is served by accident. + * * The presentation props the contract carries — `density`, `dividers`, * `isStriped` — are projected onto shadcn's table utilities rather than * dropped: a table emitted as compact-and-striped must read as compact and - * striped here, or the emitted surface is being misrepresented. + * striped here, or the emitted surface is being misrepresented. (Those three + * are Astryx-only vocabulary; under shadcn/ui v3 they arrive undefined and the + * catalog defaults apply, which is the correct reading of a catalog that does + * not declare them.) */ import type { FC, ReactNode } from "react"; import { childIds } from "@dspack-studio/a2ui-ingest"; @@ -66,7 +79,9 @@ export const TableRender: FC = ({ props, buildChild }) => { bodyRows.push(nested.slice(i, i + width).map((id) => buildChild(id))); } } else { - const rows: Row[] = Array.isArray(props.data) ? props.data : []; + // `rows` is the shadcn/ui v3 catalog's required name; `data` is the + // Astryx/neutral catalog's. A given instance carries exactly one. + const rows: Row[] = Array.isArray(props.rows) ? props.rows : Array.isArray(props.data) ? props.data : []; anyStatus = rows.some((r) => r.status); bodyRows = rows.map((r) => { const cells: ReactNode[] = (r.cells ?? []).map((c) => String(c)); diff --git a/packages/shadcn-renderers/src/shadcn-v3-surface.test.tsx b/packages/shadcn-renderers/src/shadcn-v3-surface.test.tsx new file mode 100644 index 0000000..06fe7b7 --- /dev/null +++ b/packages/shadcn-renderers/src/shadcn-v3-surface.test.tsx @@ -0,0 +1,153 @@ +/** + * THE PRODUCTION CATALOG, RENDERED. Every other suite in this package reads a + * corpus emitted from the ASTRYX contract — where a Table's body rows live in + * `data`, a Button carries a `label`, and an AlertDialog's confirm label is + * `actionLabel`. The catalog the hosted composer actually ships is + * shadcn/ui v3, and it names those things `rows`, `child` and `confirmLabel`. + * A renderer can therefore satisfy every Astryx-fed suite in this repo and + * still draw an empty table, a wordless button and a blank confirm — which is + * exactly what shipped. + * + * So this suite renders REAL SHIPPED MATERIAL under the REAL production + * contract: the shadcn/ui v3 dspack document and profile in packages/contracts + * (the drift-guarded copies of the composer's reference project), through the + * real `emitSurface`, through the real registry, and asserts the text a user + * is supposed to read comes out. Nothing here is a hand-written fixture; if + * the contract's examples change, this suite renders whatever they now say. + */ +import { readFileSync } from "node:fs"; +import { createElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { emitSurface, loadProfile } from "@aestheticfunction/dspack-emit"; +import { shadcnRegistry } from "./registry"; + +const read = (rel: string) => JSON.parse(readFileSync(new URL(rel, import.meta.url), "utf8")); + +/** The shipped production contract + profile (byte-copies of the composer's + * shadcn-v3 reference project; the contracts build gates them on every run). */ +const contract = read("../../contracts/shadcn-v3.dspack.json") as { + examples: Array<{ id: string; surface?: unknown }>; +}; +const profile = loadProfile(read("../../contracts/shadcn-v3.profile.json")); + +/** Emit one shipped example and index its A2UI components by id. */ +function emitExample(id: string): { byId: Map; rootId: string } { + const example = contract.examples.find((e) => e.id === id); + if (!example?.surface) throw new Error(`shipped example '${id}' not found in the shadcn/ui v3 contract`); + const { messages } = emitSurface(example.surface as never, contract as never, { profile }); + const byId = new Map(); + let rootId = ""; + for (const message of messages as Array>) { + for (const component of message.updateComponents?.components ?? []) { + byId.set(component.id, component); + if (!rootId) rootId = component.id; + } + } + if (byId.size === 0) throw new Error(`example '${id}' emitted no components`); + return { byId, rootId }; +} + +/** + * Render an emitted surface through the registry the composer uses, resolving + * ComponentId references the way A2UI's binder does: `buildChild` renders the + * referenced component, recursively. Actions arrive as callables. + */ +function renderSurface(byId: Map, rootId: string): string { + const build = (id: string): ReactNode => { + const component = byId.get(id); + if (!component) return null; + const Visual = (shadcnRegistry.custom as Record)[component.component]; + if (!Visual) return createElement("div", { key: id }, `[unimplemented:${component.component}]`); + const props: Record = {}; + for (const [key, value] of Object.entries(component)) { + if (key === "id" || key === "component") continue; + props[key] = key === "action" ? () => {} : value; + } + return createElement(Visual, { + key: id, + props, + buildChild: build, + context: { componentModel: { id }, dataContext: { path: "/" } }, + }); + }; + return renderToStaticMarkup(createElement("div", null, build(rootId))); +} + +const renderExample = (id: string) => { + const { byId, rootId } = emitExample(id); + return renderSurface(byId, rootId); +}; + +const bodyRows = (html: string): string[] => { + const start = html.indexOf(""); + const end = html.indexOf(""); + if (start < 0 || end < 0) return []; + const body = html.slice(start + "".length, end); + return body.split(" html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + +/** The shipped examples this suite renders. */ +const EXAMPLES = ["ex.support-ticket-queue", "ex.orders-table-loading", "ex.delete-project-confirmation"]; + +describe("shipped shadcn/ui v3 examples render their real content", () => { + it("ex.support-ticket-queue: one body row per emitted row, with every cell", () => { + const html = renderExample("ex.support-ticket-queue"); + // The contract's example carries three tickets across four columns. + expect(bodyRows(html)).toHaveLength(3); + const visible = text(html); + for (const header of ["Ticket", "Subject", "Status", "Priority"]) expect(visible).toContain(header); + for (const cell of [ + "#4812", "Cannot export billing statement", "Urgent", "P1", + "#4809", "Two-factor codes arrive late", "Waiting on customer", "P2", + "#4801", "Dark mode contrast on invoices", "Open", "P3", + ]) { + expect(visible).toContain(cell); + } + }); + + it("ex.orders-table-loading: the loading placeholder keeps its row structure", () => { + // Every cell flattens to empty text (the Skeletons are a documented + // casualty of the synthesized table shape), so ROW COUNT is the whole + // property: a placeholder that renders no rows does not hold the shape of + // what is coming, which is the only job a loading table has. + const html = renderExample("ex.orders-table-loading"); + expect(bodyRows(html)).toHaveLength(3); + for (const header of ["Order", "Customer", "Status", "Total"]) expect(text(html)).toContain(header); + }); + + it("ex.delete-project-confirmation: the buttons say what they do", () => { + // The composer's flagship destructive-action surface. Its Button carries + // its text as a `child` ComponentId (shadcn/ui v3's anatomy), and its + // AlertDialog names the confirm action in `confirmLabel` and the opener in + // `triggerLabel`. A blank button on a delete confirmation is the worst + // possible place for dropped content. + const visible = text(renderExample("ex.delete-project-confirmation")); + expect(visible).toContain("Cancel"); + expect(visible).toContain("Delete project and all data"); + expect(visible).toContain("Keep project"); + expect(visible).toContain("Delete Northwind Checkout?"); + }); + + it("draws the components under test natively, and names the one it does not", () => { + // A guard on the guards above: if a component quietly lost its native + // visual, the assertions would be measuring a placeholder. This registry + // is a first-class PARTIAL cover of the production catalog, so the honest + // statement is which name falls back, not that none does — in the + // composer, wireframe fills that gap (apps/composer/app/registries.ts). + const fallbacks = new Set(); + for (const id of EXAMPLES) { + for (const component of emitExample(id).byId.values()) { + if (!(shadcnRegistry.custom as Record)[component.component]) { + fallbacks.add(component.component); + } + } + } + expect([...fallbacks]).toEqual(["Spinner"]); + for (const name of ["Table", "Button", "AlertDialog", "Card", "Column", "Text", "Alert", "TextField"]) { + expect(shadcnRegistry.custom[name]).toBeTypeOf("function"); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9488f7a..094ea72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: packages/composer-core: dependencies: + '@aestheticfunction/dspack-emit': + specifier: ^0.7.0 + version: 0.7.0 zod: specifier: 3.25.76 version: 3.25.76 @@ -365,6 +368,9 @@ importers: specifier: ^3.0.2 version: 3.6.0 devDependencies: + '@aestheticfunction/dspack-emit': + specifier: ^0.7.0 + version: 0.7.0 '@tailwindcss/cli': specifier: ^4.1.0 version: 4.3.2