From 93d99e17b65e4545d21f2d39f55a3febafe6a86e Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 10:39:53 -0400 Subject: [PATCH 1/5] ci: run every workspace test script, not a hand-typed filter list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1 of the pre-1.0 correctness milestone. `.github/workflows/ci.yml` hand-listed eight pnpm filters and silently omitted three packages that hold product logic: @dspack-studio/composer-core 89 tests (both planners, ledger, findings, flow schema) @dspack-studio/wireframe-renderers 4 tests @dspack-studio/contracts 2 tests The root `pnpm test` script would have caught them (it did not, either: it was scoped `--filter './packages/**'`, so it missed all three apps) and CI never invoked it. Two independent lists, neither complete, neither checked. FAIL-FIRST PROOF 1 — CI as it stands reports success while a deliberately failing composer-core test never executes. A temporary probe was added at packages/composer-core/src/ci-omission-proof.test.ts: it("PROOF: composer-core tests do not run in CI", () => { expect("composer-core never executed").toBe("this assertion must fail"); }); then the EXACT command sequence of the workflow's "Unit tests" step was run: $ pnpm --filter @dspack-studio/a2ui-ingest test Test Files 1 passed (1) Tests 3 passed (3) $ pnpm --filter @dspack-studio/agui-bridge test Test Files 2 passed (2) Tests 6 passed (6) $ pnpm --filter @dspack-studio/replay test Test Files 5 passed (5) Tests 21 passed (21) $ pnpm --filter @dspack-studio/scenarios test Test Files 1 passed (1) Tests 5 passed (5) $ pnpm --filter @dspack-studio/shadcn-renderers test Test Files 3 passed (3) Tests 21 passed (21) $ pnpm --filter agent test Test Files 4 passed (4) Tests 48 passed (48) $ pnpm --filter web test Test Files 1 passed (1) Tests 6 passed (6) $ pnpm --filter composer test Test Files 7 passed (7) Tests 68 passed (68) EXIT CODE OF CI'S 'Unit tests' STEP: 0 $ grep -c composer-core 0 Green, and the string "composer-core" does not appear once in the entire step output. The failing test was never collected, let alone run. FAIL-FIRST PROOF 2 — the new configuration fails on the same probe: $ pnpm test Scope: 12 of 13 workspace projects packages/composer-core test: ❯ src/ci-omission-proof.test.ts (1 test | 1 failed) 6ms packages/composer-core test: × PROOF: composer-core tests do not run in CI 5ms packages/composer-core test: → expected 'composer-core never executed' to be 'this assertion must fail' // Object.is equality packages/composer-core test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ packages/composer-core test: FAIL src/ci-omission-proof.test.ts > PROOF: composer-core tests do not run in CI packages/composer-core test: AssertionError: expected 'composer-core never executed' to be 'this assertion must fail' // Object.is equality packages/composer-core test: Expected: "this assertion must fail" packages/composer-core test: Received: "composer-core never executed" packages/composer-core test: ❯ src/ci-omission-proof.test.ts:9:42 packages/composer-core test: Test Files 1 failed | 5 passed (6) packages/composer-core test: Tests 1 failed | 89 passed (90) ERR_PNPM_RECURSIVE_FAIL A test failed in "@dspack-studio/composer-core" EXIT CODE OF CI'S NEW 'Unit tests' STEP: 1 The probe is deleted in this commit; it exists only in these two outputs. THE FIX is a workspace-level strategy, not another list: root package.json "test": "pnpm build:contracts && pnpm -r --no-bail test" ci.yml "Unit tests" step: `pnpm test` so the command a contributor runs locally and the command CI enforces are the same string by construction. A package added tomorrow with a `test` script is covered without anyone editing a workflow. Two details keep the workspace run self-sufficient rather than reintroducing a list. `pnpm build:contracts` (0.6s) runs first because the shadcn-renderers and contracts suites read packages/contracts/out/, which is gitignored and only exists after the catalog build — CI already gates catalogs in an earlier step, so this is idempotent, and it makes `pnpm test` work from a bare checkout. `--no-bail` reports every failing package in one run instead of stopping at the first; pnpm still exits non-zero (proof 2 above ran all 11 packages and still exited 1). The composer's own `test` script already invokes demo-assets.mjs, so its build-time reference bake needs nothing from the root. CI unit coverage, before -> after: 8 packages, 24 test files, 178 tests 11 packages, 30 test files, 273 tests packages/astryx-renderers stays outside the run: it declares no `test` script because it has no tests. That is now the ONLY way a package can escape CI, and it is visible in package.json rather than in a workflow file nobody reads. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 18 +++++++++--------- CONTRIBUTING.md | 2 +- README.md | 2 +- package.json | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) 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/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", From 28c755a70cc8cce38d4c1288401b892c28abd06e Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 10:46:37 -0400 Subject: [PATCH 2/5] test(shadcn): fail first on the required props the renderers ignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2 of the pre-1.0 correctness milestone, tests only. Both files are RED at this commit by design; the fix is the next one. Two new suites, aimed at one invariant: A REQUIRED CATALOG PROP MUST NOT BE SILENTLY IGNORED BY ITS NATIVE RENDERER. 1. packages/shadcn-renderers/src/shadcn-v3-surface.test.tsx — real shipped material, end to end. The shipped shadcn/ui v3 dspack document and profile in packages/contracts, through the real `emitSurface`, through the real registry, asserting the text a user is meant to read. No hand-written fixture: if the contract's examples change, the suite renders whatever they now say. (Adds @aestheticfunction/dspack-emit as a devDependency of shadcn-renderers — the same published emitter every other twin uses.) 2. apps/composer/app/required-prop-consumption.test.ts — the structural guard, behavior-level rather than a source grep. For every component in each catalog, it builds an instance whose required props carry distinctive SENTINEL values, renders it through the real registry, and asserts the sentinels come out. A renderer that reads the right prop passes however it is written; one that reads the wrong name fails however plausible its source looks. It runs against BOTH governed catalogs and BOTH native registries, because the bug being caught is a cross-catalog confusion. Sentinels are derived from the SCHEMA, and an unhandled schema shape throws rather than passing — the guard fails when it cannot reason. The per-prop allowlist has exactly one entry, `action`, with its reason written out: an A2UI Action becomes a callback, so no sentinel it carries could appear in static markup. `child`/`children` are deliberately NOT allowlisted — they carry ComponentIds and the marker `buildChild` renders the id verbatim, so an unbuilt slot is a dropped sentinel like any other. FAIL-FIRST OUTPUT 1 — the structural guard, against current code. It found THREE required props ignored, not the one the milestone named: $ npx vitest run app/required-prop-consumption.test.ts ❯ app/required-prop-consumption.test.ts (4 tests | 1 failed) 20ms × required catalog props are observably consumed by their native renderer > shadcn: every required prop of every natively-drawn component reaches the output 13ms → expected [ …(3) ] to deeply equal [] ✓ required catalog props are observably consumed by their native renderer > astryx: every required prop of every natively-drawn component reaches the output 6ms ✓ required catalog props are observably consumed by their native renderer > the allowlist stays a short list of justified structural props 0ms ✓ required catalog props are observably consumed by their native renderer > rejects a visual that ignores a required prop, rather than passing it silently 0ms AssertionError: expected [ …(3) ] to deeply equal [] - [] + [ + "Button.child ignored (no sentinel in output: [\"sentinel-child\"])", + "AlertDialog.triggerLabel ignored (no sentinel in output: [\"SENTINEL~triggerLabel\"])", + "Table.rows ignored (no sentinel in output: [\"SENTINEL~rows.0.cells\",\"SENTINEL~rows.0.label\",\"SENTINEL~rows.0.value\",\"SENTINEL~rows.0.text\",\"SENTINEL~rows.1.cells\",\"SENTINEL~rows.1.label\",\"SENTINEL~rows.1.value\",\"SENTINEL~rows.1.text\"])", + ] Astryx passes untouched, which is the point: these are three instances of ONE mistake — a renderer reading the Astryx catalog's prop names while serving the shadcn/ui v3 catalog. prop the shadcn/ui v3 catalog declares the renderer reads result today Table.rows (required) props.data empty Button.child (required, ComponentId) props.label wordless button AlertDialog.triggerLabel (required) — nothing — no opener AlertDialog.confirmLabel props.actionLabel blank confirm FAIL-FIRST OUTPUT 2 — the shipped examples, against current code: $ npx vitest run src/shadcn-v3-surface.test.tsx ❯ src/shadcn-v3-surface.test.tsx (4 tests | 3 failed) 20ms × shipped shadcn/ui v3 examples render their real content > ex.support-ticket-queue: one body row per emitted row, with every cell 13ms → expected [] to have a length of 3 but got +0 × shipped shadcn/ui v3 examples render their real content > ex.orders-table-loading: the loading placeholder keeps its row structure 2ms → expected [] to have a length of 3 but got +0 × shipped shadcn/ui v3 examples render their real content > ex.delete-project-confirmation: the buttons say what they do 4ms → expected 'Danger zone Deleting a project remove…' to contain 'Cancel' ✓ shipped shadcn/ui v3 examples render their real content > draws the components under test natively, and names the one it does not 1ms Test Files 1 failed (1) Tests 3 failed | 1 passed (4) and the third failure is worth reading in full, because it is the composer's flagship destructive-action surface rendered exactly as a visitor sees it: Expected: "Cancel" Received: "Danger zone Deleting a project removes its environments, deploys, and logs for everyone in the workspace. Deleting Northwind Checkout is permanent 3 environments, 412 deploys, and 18 GB of build logs are destroyed immediately. There is no backup to restore from. Type the project name to confirm The name must match exactly, including capitalisation. Delete Northwind Checkout? The project, its three environments, and every deploy log are deleted immediately. Anyone with a link to a preview deploy will get a 404. Keep project" Every governed word survives except the words ON THE BUTTONS. "Cancel" (the Button's `child` Text) is gone, "Delete project and all data" (`confirmLabel`) is gone, "Delete project" (`triggerLabel`) is gone. A delete-confirmation whose confirm button is blank is the worst possible place for dropped content, and every gate in the stack was green over it: A3 validates the INSTANCE against the catalog, and an instance stays perfectly valid when the renderer drawing it reads a prop the catalog never declared. Co-Authored-By: Claude Fable 5 --- .../app/required-prop-consumption.test.ts | 263 ++++++++++++++++++ packages/shadcn-renderers/package.json | 1 + .../src/shadcn-v3-surface.test.tsx | 153 ++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 420 insertions(+) create mode 100644 apps/composer/app/required-prop-consumption.test.ts create mode 100644 packages/shadcn-renderers/src/shadcn-v3-surface.test.tsx 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/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/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..6b1630d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,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 From 6af8551185ec18c72f5e82f528273c6f9535cdf9 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 10:50:30 -0400 Subject: [PATCH 3/5] fix(shadcn): read the props the production catalog actually declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2 of the pre-1.0 correctness milestone. Turns the previous commit's two suites green by fixing the three renderers they measured, using the ACTIVE shadcn contract as the source of truth (packages/contracts/out/ catalog.shadcn-v3.v1_0.json — read, not assumed). One mistake, three instances of it. This registry serves BOTH governed catalogs (see the header of registry-parity.test.ts), and the two do not agree on names. Each renderer had been written against the Astryx/neutral catalog and then pointed at the production shadcn/ui v3 catalog without anyone re-reading it: what shadcn/ui v3 declares read before read now Table.rows (REQUIRED) props.data props.rows ?? props.data Button.child (REQUIRED, id) props.label props.label ?? child slot AlertDialog.triggerLabel (REQUIRED) — nothing — rendered as the trigger AlertDialog.confirmLabel props.actionLabel confirmLabel ?? actionLabel Table had no path to a row at all: shadcn/ui v3's Table declares `columns` and `rows` and neither `data` nor `children`, so the nested-children branch could never fire either. Every shipped shadcn table drew headers over an empty . Both row sources are now read, each commented with the catalog that declares it; the nested-children branch, the density/dividers/striped projections and the status -> Badge behavior are untouched. Button now falls back to building its `child` ComponentId when the instance carries no literal `label`. AlertDialog renders shadcn's actual anatomy — Trigger + Content — with the trigger emitted only when the catalog declares one, and takes the confirm label from `confirmLabel` before `actionLabel`. ASTRYX IS UNCHANGED, MEASURED RATHER THAN ASSERTED. Every A2UI instance the Astryx contract emits in this repo (the contracts build's surfaces plus the recorded replay fixtures — 172 of them) was rendered through the shadcn registry before and after this commit and diffed: $ npx tsx neutral-render.tmp.mjs > after.txt # 172 neutral instances $ git stash push -- packages/shadcn-renderers/src/components $ npx tsx neutral-render.tmp.mjs > before.txt # 172 neutral instances $ git stash pop && diff before.txt after.txt IDENTICAL — neutral-catalog rendering through the shadcn registry is byte-for-byte unchanged That is why AlertDialog's trigger sits in a Fragment rather than a wrapper div and why the panel's `id` is conditional: a catalog with no `triggerLabel` must render the same element with the same attributes it did before this renderer learned that triggers exist. packages/astryx-renderers is not touched by this branch at all (`git diff main -- packages/astryx-renderers` is empty). Green: packages/shadcn-renderers 25 tests, apps/composer 72, `pnpm test` 273 across 11 packages, `pnpm -r typecheck` clean. Co-Authored-By: Claude Fable 5 --- .../src/components/AlertDialogRender.tsx | 34 ++++++++++++++++++- .../src/components/ButtonRender.tsx | 16 +++++++-- .../src/components/TableRender.tsx | 21 ++++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) 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)); From fde23e2d9d7f4206a578f6df2f55920d9148d5e7 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 10:56:39 -0400 Subject: [PATCH 4/5] test(composer): fail first on the browser/agent validation divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A3/A4 of the pre-1.0 correctness milestone, tests only. All three files are RED at this commit by design; the seam that turns them green is the next one. THE CANONICAL BEHAVIOR, investigated rather than assumed. The same governed project got 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, while apps/composer/app/validation.ts browserEmit() validated 0.9.1 only. Reading the stack rather than picking a side: @aestheticfunction/dspack-gen 0.5.0, dist/run/orchestrator.js:231 for (const version of options.a2uiVersions ?? ["0.9.1", "1.0"]) { neither caller overrides it — apps/agent/src/project.ts:568,633 and apps/composer/app/hosted-build.ts:233 both call runPipeline without `a2uiVersions`, so BUILD already validates both versions on both doors. apps/agent/src/project.ts emit(): ["0.9.1", "1.0"] apps/composer/scripts/demo-assets.mjs bake(): ["0.9.1", "1.0"] apps/composer/app/validation.ts browserEmit(): "0.9.1" <- outlier Three of the four twins already said both, and the fourth is the one users see least — the browser's instant-feedback emit. So the browser moves. No version is deleted to make outputs agree. FAIL-FIRST OUTPUT — the browser half, against current code: $ npx vitest run app/validation.test.ts ❯ app/validation.test.ts (4 tests | 3 failed) 115ms × browserEmit — the same emit seam the agent runs > validates BOTH canonical A2UI versions, and says which ones it ran 46ms → Cannot read properties of undefined (reading 'map') × browserEmit — the same emit seam the agent runs > is projectEmit plus surface selection: same verdict, same finding set, same catalog 19ms → (0 , projectEmit) is not a function ✓ browserEmit — the same emit seam the agent runs > selects the contract's worked examples — the surfaces a browser-backed project has 1ms × browserEmit — the same emit seam the agent runs > reports every packaged reference project identically under both versions 49ms → Cannot read properties of undefined (reading 'map') TypeError: Cannot read properties of undefined (reading 'map') ❯ app/validation.test.ts:37:24 36| const result = browserEmit(contract, profileJson, contractSurfaces… 37| expect(result.runs.map((r) => r.version)).toEqual([...A2UI_VERSION… | ^ A missing computation can only be observed as a missing output, so the failure reads as an absent `runs`: the browser's result had no second run to report. The assertion behind it is not a label check — 0.9.1 and 1.0 catalogs are structurally distinguishable (0.9.1 requires `$defs.theme` and forbids `$defs.surfaceProperties`; 1.0 is the exact inverse), and the test asserts both shapes, so it can only pass if the second catalog was really compiled and gated. The same run against the agent half and the seam: $ npx vitest run src/project.test.ts -t "shared emit seam" FAIL src/project.test.ts > emit > is the shared emit seam plus file writing: same verdict, same findings, same A2UI versions TypeError: (0 , projectEmit) is not a function $ npx vitest run src/emit.test.ts FAIL src/emit.test.ts [ src/emit.test.ts ] Error: Cannot find module './emit' imported from '…/packages/composer-core/src/emit.test.ts' THE EQUIVALENCE ARGUMENT the three files make together. Rather than compare two apps that cannot import each other, each half is proven against the shared seam where it lives: agent route == projectEmit + file writing (apps/agent) browserEmit == projectEmit + surface selection (apps/composer) projectEmit == both A2UI versions, one verdict (packages/composer-core) so the two doors are equal by construction, not by coincidence. The one asymmetry is documented and asserted rather than hidden: a repository-backed project also emits the surfaces in its `surfacesDir`, which a browser-backed project does not have, so the agent test feeds the seam that same list explicitly. Co-Authored-By: Claude Fable 5 --- apps/agent/src/project.test.ts | 43 +++++++++++ apps/composer/app/validation.test.ts | 96 +++++++++++++++++++++++++ packages/composer-core/src/emit.test.ts | 91 +++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 apps/composer/app/validation.test.ts create mode 100644 packages/composer-core/src/emit.test.ts 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/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/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"); + }); +}); From 18c928541e949466b2dc632397a3adddb1acefc9 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 11:04:14 -0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(composer):=20one=20emit=20loop=20for=20?= =?UTF-8?q?both=20doors=20=E2=80=94=20the=20browser=20now=20validates=201.?= =?UTF-8?q?0=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A3/A4 of the pre-1.0 correctness milestone. Turns the previous commit's three suites green by extracting the smallest seam that stops the measured divergence, and moving the browser onto the canonical two-version reading. THE DIVERGENCE, and which side was wrong. apps/agent/src/project.ts emit() validated the emitted surface against A2UI 0.9.1 AND 1.0; apps/composer/app/ validation.ts browserEmit() validated 0.9.1 only. 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 everywhere. The agent's emit matched generation; so does the composer's build-time reference bake. Validating FEWER versions than the generator that produced the surface is the one reading that cannot be right, so the browser moved. No version was deleted to make the two outputs agree. THE SEAM: packages/composer-core/src/emit.ts, `projectEmit(contract, profileJson, surfaces)` — profile load, per-surface emission, per-version catalog gates, coverage, fidelity, casualty classification, warnings. Pure: no filesystem, no network. Plus `A2UI_VERSIONS`, pinned to dspack-gen's default with the reason written down, so there is ONE place the studio follows it. WHY THAT BOUNDARY. Everything the two copies said IDENTICALLY moved in; everything genuinely different about each door stayed out, which is what makes each side assertable as "the seam plus exactly one thing": agent route == projectEmit + FILE WRITING (it owns the filesystem) browserEmit == projectEmit + SURFACE SELECTION (it has no surfacesDir) Both are asserted, from both sides. The other known twins between these two files — id minting, the accept gate, the scripted adapter — are deliberately NOT extracted: they have not been measured to diverge, and a refactor is not a correctness fix. Recorded as follow-ups instead. apps/composer/app/state.tsx is untouched. IMPACT, MEASURED AND HONEST. No shipped reference example and no packaged reference project surfaces a NEW finding under 1.0 that browser users did not already see: shadcn-v3-project a2ui@0.9.1 pass, 0 failing gates a2ui@1.0 pass, 0 failing gates -> 1.0-only: none astryx-project a2ui@0.9.1 pass, 0 failing gates a2ui@1.0 pass, 0 failing gates -> 1.0-only: none and validation.test.ts asserts exactly that for both packaged references, so the claim stays true or the suite goes red. Users therefore see no new noise; what they gain is that a 1.0 failure would now be SHOWN rather than silently passed over. The Checks presentation needed no change: per-version findings are distinguished by their `a2ui@` target, and the agent has been feeding that same view two versions' worth of findings since it added 1.0 — this makes the browser's finding set match rather than introducing a new shape. The build path's cross-version dedup (composer-core buildFailure, "identical catalog-gate errors under both A2UI versions render ONCE") is likewise already in place. The emit result now also carries `runs` — one entry per validated version, with its compiled catalog and verdict — and the agent route reports `a2uiVersions` on the wire. The divergence was invisible for years precisely because nothing in either result said which versions had run. Green: pnpm test 291 tests across 11 packages; pnpm -r typecheck clean; playwright composer-smoke 13/13, composer-agent 44/44, studio e2e 106 passed. Co-Authored-By: Claude Fable 5 --- apps/agent/src/project.ts | 137 ++++++------------ apps/composer/app/validation.ts | 131 ++++------------- packages/composer-core/package.json | 3 +- packages/composer-core/src/emit.ts | 209 ++++++++++++++++++++++++++++ packages/composer-core/src/index.ts | 8 ++ pnpm-lock.yaml | 3 + 6 files changed, 293 insertions(+), 198 deletions(-) create mode 100644 packages/composer-core/src/emit.ts 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/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/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.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/pnpm-lock.yaml b/pnpm-lock.yaml index 6b1630d..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