diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..36c115d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +## 0.7.0 — 2026-08-10 + +Two ratified behavior changes. The emitted catalog artifact is untouched +(`$defs.anyComponent` and every component schema stay byte-identical — the +19 byte-neutrality pins and the v2 keystone byte-identity gate prove it); +what changes is how invalid *instances* are reported and when they are +refused. + +### Gate A3 errors are branch-scoped, with structured `errorDetails` + +Gate 3 used to validate every instance against `#/$defs/anyComponent` — a +flat `oneOf` over ALL catalog components — with `allErrors`, so one missing +prop produced an error per *other* branch (their +`const`/`required`/`unevaluatedProperties` failures), all prefixed with the +instance's own `component#id`. A single invalid TextField reported ~50 +errors naming other components' props. + +Now each instance is validated against its own `#/components/` branch +(manual discrimination — semantically equivalent, since every branch pins +`component: {const: }`), `allErrors` *within* the branch: + +- an unknown component name yields exactly one error: + `` `#: component '' is not in this catalog (N components admitted)` ``; +- error strings are enriched from ajv params: enum violations append + `(allowed: …)`, const violations `(expected: …)`, and + unevaluated/additional-property violations name the offending property; +- `GateResult` gains an optional `errorDetails` field — per-instance + structured evidence (`instance`, `component`, `id`, and each ajv error's + `instancePath`/`schemaPath`/`keyword`/`params`/`message`) for downstream + UIs. The strings in `errors` remain the primary user-facing form. + +Gate name (`"instance"`), pass/fail semantics, and rigor are unchanged. + +### `emitSurface` refuses catalog-invalid instances + +The emitter could produce instances that cannot validate against the very +catalog they name: an authored prop with no propMap dropped silently at +projection, leaving a *required* catalog prop unset (e.g. `props.title` on +`alert`, whose `title` comes from the `alert-title` sub-component), and — +when a propMap declares a `targetEnum` but no `valueMap` — an +off-vocabulary value passed through verbatim. Both only failed downstream +at gate A3. + +`emitSurface` now runs a final guard over every emitted instance against +its ComponentPlan (required-prop presence + targetEnum membership) and, on +any violation, throws `EmitSurfaceError` with one aggregated message +listing each violation and its cause, e.g.: + +``` +Alert#root: required prop 'title' has no value after emission — authored +'props.title' on 'alert' has no A2UI projection (title comes from the +'alert-title' sub-component) +``` + +Full-schema rigor stays gate A3's job (defense in depth). Warnings and +fidelity recording are untouched: a surface that emits, emits +byte-identically to 0.6.0, and no shipped example changes behavior. + +## 0.6.0 — the representation program + +See [RELEASE-0.6.0.md](./RELEASE-0.6.0.md). diff --git a/package.json b/package.json index 6f2f537..c3d5c9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aestheticfunction/dspack-emit", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "description": "dspack emitters: compile a dspack design-system contract and dspack surfaces into protocol targets \u2014 an A2UI catalog + surface messages (gates A1-A3) and json-render catalog/registry modules + specs (gates J1-J3).", "license": "Apache-2.0", diff --git a/src/engine-boundary.test.ts b/src/engine-boundary.test.ts index c8ead19..7deb0e0 100644 --- a/src/engine-boundary.test.ts +++ b/src/engine-boundary.test.ts @@ -143,10 +143,14 @@ describe("transformations keep their meaning", () => { }); it("first-write-wins: the first matching sub claims the destination", () => { + // The trigger keeps the AlertDialog instance catalog-valid + // (`triggerLabel` is required; emitSurface now refuses invalid + // instances) — the behaviour under test is the duplicated title. const [dialog] = instances( { component: "alert-dialog", children: [ + { component: "alert-dialog-trigger", children: [{ component: "button", text: "Open" }] }, { component: "alert-dialog-content", children: [ { component: "alert-dialog-title", text: "First" }, { component: "alert-dialog-title", text: "Second" }, @@ -173,6 +177,9 @@ describe("transformations keep their meaning", () => { }); it("collection: two header rows concatenate into one column list (v1 behaviour)", () => { + // A body row keeps the Table instance catalog-valid (`rows` is required; + // emitSurface now refuses invalid instances) — the behaviour under test + // is the header concatenation, which is unaffected. const [table] = instances({ component: "table", children: [ @@ -180,6 +187,9 @@ describe("transformations keep their meaning", () => { { component: "table-row", children: [{ component: "table-head", text: "A" }] }, { component: "table-row", children: [{ component: "table-head", text: "B" }] }, ] }, + { component: "table-body", children: [ + { component: "table-row", children: [{ component: "table-cell", text: "1" }] }, + ] }, ], }); expect(table.columns).toEqual(["A", "B"]); diff --git a/src/instance-diagnostics.test.ts b/src/instance-diagnostics.test.ts new file mode 100644 index 0000000..bc6ac6f --- /dev/null +++ b/src/instance-diagnostics.test.ts @@ -0,0 +1,194 @@ +/** + * Instance diagnostics — two ratified fixes, proven fail-first: + * + * Fix 1 (gate A3, src/validate/ajv.ts): gate 3 used to validate every + * instance against `#/$defs/anyComponent` — a flat oneOf over ALL catalog + * components — with allErrors, so ONE missing prop yielded an error per + * OTHER branch (their const/required/unevaluatedProperties failures), all + * prefixed with the instance's own component#id. Branch-aware validation + * reports only the instance's own branch (semantically equivalent: every + * branch pins `component: {const: }`), enriches messages from ajv + * params (enum/const/unevaluated), and carries a structured `errorDetails` + * channel preserving keyword/params for downstream UIs. + * + * Fix 2 (emitSurface, src/targets/a2ui/surface.ts): the emitter could + * produce instances that cannot validate against the very catalog they + * name — a required prop silently dropped at projection (authored + * `props.title` with no propMap), or an enum value passed through verbatim + * when no valueMap exists. emitSurface now refuses with one aggregated, + * causal message instead of emitting a catalog-invalid surface. Full-schema + * rigor stays gate A3's job (defense in depth); this guard covers + * required-presence and enum-membership, the observed defect class. + * + * The fixture is a minimal inline contract + v2 profile with three mapped + * components carrying DISTINCT required props (label / title / caption) and + * one enum prop (tone) — so cross-branch noise, if any, is detectable by + * name. + */ +import { describe, expect, it } from "vitest"; +import { transformFromJson } from "./transform/index.js"; +import { loadProfile } from "./transform/profile-load.js"; +import { emitSurface, EmitSurfaceError } from "./targets/a2ui/surface.js"; +import type { DspackDoc, DspackSurface } from "./types.js"; + +const contract: DspackDoc = { + dspack: "0.4", + name: "mini-kit", + description: "Minimal inline contract for instance diagnostics.", + tokens: { color: { values: { primary: { value: "#3366ff", type: "color" } } } }, + components: { + "text-field": { name: "TextField", description: "Single-line text input." }, + alert: { + name: "Alert", + description: "Callout whose title comes from its alert-title sub-component.", + composition: { subComponents: [{ id: "alert-title" }] }, + }, + chip: { + name: "Chip", + description: "Small status marker with a tone.", + props: { tone: { type: "enum", values: ["info", "warning"], description: "Visual tone." } }, + }, + }, +}; + +/** A v2 profile document mapping all three components. */ +const profileDoc = (): Record => ({ + profileVersion: "2", + catalogTitle: "mini-kit — A2UI catalog", + catalogDescription: "Minimal catalog exercising branch-aware instance diagnostics.", + catalogIdBase: "https://example.test/catalogs/mini-kit", + instructions: "", + primaryColorToken: { category: "color", name: "primary" }, + surfaceSynthesis: { textComponent: "Text", textProp: "text", wrapComponent: "Column", wrapChildrenProp: "children" }, + synthesized: [], + casualtyComponents: [], + components: [ + { + a2ui: "TextField", + dspackId: "text-field", + commons: ["ComponentCommon"], + structural: { + label: { schema: { type: "string" }, description: "The field label.", synthNote: "synthesized from node text" }, + }, + required: ["label"], + surface: { routes: [{ from: ["self.text"], to: "prop:label" }] }, + }, + { + a2ui: "Alert", + dspackId: "alert", + commons: ["ComponentCommon"], + structural: { + title: { schema: { type: "string" }, description: "The alert title.", synthNote: "from the alert-title sub-component" }, + }, + required: ["title"], + surface: { routes: [{ from: ["sub(alert-title).text"], to: "prop:title" }] }, + }, + { + a2ui: "Chip", + dspackId: "chip", + commons: ["ComponentCommon"], + structural: { + caption: { schema: { type: "string" }, description: "The chip caption.", synthNote: "synthesized from node text" }, + }, + // Deliberately NO valueMap: an authored tone passes through verbatim today. + propMap: { tone: { a2ui: "tone", kind: "enum", targetEnum: ["info", "warning"], description: "Visual tone." } }, + required: ["caption"], + surface: { routes: [{ from: ["self.text"], to: "prop:caption" }] }, + }, + ], +}); + +const profile = () => loadProfile(profileDoc()); + +const surfaceOf = (root: unknown, intent = "diagnostics"): DspackSurface => + ({ dspackSurface: "0.1", system: "mini-kit", intent, root }) as DspackSurface; + +const messagesWith = (...instances: Array>) => ({ + messages: [{ version: "v0.9", updateComponents: { surfaceId: "s", components: instances } }], +}); + +const instanceGate = (surface: unknown) => { + const out = transformFromJson(contract, { profile: profile(), surface }); + return out.validation.gates.find((g) => g.name === "instance")!; +}; + +describe("gate A3 reports branch-scoped errors (fix 1)", () => { + it("an instance missing its ONE required prop gets own-branch errors only, not every other branch's noise", () => { + const gate = instanceGate(messagesWith({ id: "x", component: "TextField" })); + expect(gate.pass).toBe(false); + const errors = gate.errors ?? []; + // Branch-scoped: a handful of errors, not one per unrelated branch. + expect(errors.length).toBeGreaterThan(0); + expect(errors.length).toBeLessThanOrEqual(4); + for (const e of errors) { + // Every error names the instance itself… + expect(e).toContain("TextField#x"); + // …and NONE leak the other components' branches: their names or their + // required props must not appear in a report about a TextField. + expect(e).not.toMatch(/\bAlert\b|\bChip\b|'title'|'caption'|"title"|"caption"/); + } + // The genuine error is reported: label is missing. + expect(errors.join("\n")).toMatch(/label/); + // Structured evidence rides alongside, preserving ajv keyword/params. + expect(gate.errorDetails).toBeDefined(); + const detail = gate.errorDetails!.find((d) => d.instance === "TextField#x")!; + expect(detail).toBeDefined(); + expect(detail.component).toBe("TextField"); + expect(detail.id).toBe("x"); + const required = detail.errors.find((e) => e.keyword === "required"); + expect(required).toBeDefined(); + expect((required!.params as { missingProperty?: string }).missingProperty).toBe("label"); + }); + + it("an unknown component yields exactly ONE error naming it and the admitted count", () => { + const gate = instanceGate(messagesWith({ id: "z", component: "Zorp" })); + expect(gate.pass).toBe(false); + expect(gate.errors).toHaveLength(1); + expect(gate.errors![0]).toBe("Zorp#z: component 'Zorp' is not in this catalog (3 components admitted)"); + }); + + it("an enum violation names the allowed values (enriched from ajv params)", () => { + const gate = instanceGate( + messagesWith({ id: "c", component: "Chip", caption: "Beta", tone: "loud" }), + ); + expect(gate.pass).toBe(false); + const joined = (gate.errors ?? []).join("\n"); + expect(joined).toMatch(/allowed: info, warning/); + }); +}); + +describe("emitSurface refuses catalog-invalid instances (fix 2)", () => { + it("a required prop left unset by a dropped authored prop refuses — with the cause", () => { + // Authored `props.title` has no propMap on 'alert' (title comes from the + // alert-title sub-component), so today the prop drops with a warning and + // the emitted Alert instance is missing its required `title`. + const s = surfaceOf({ component: "alert", props: { title: "Heads up" } }); + expect(() => emitSurface(s, contract, { profile: profile() })).toThrowError(EmitSurfaceError); + expect(() => emitSurface(s, contract, { profile: profile() })).toThrowError(/required prop 'title'/); + // The refusal explains the cause: the authored prop had no projection, + // and the value was expected from the sub-component. + expect(() => emitSurface(s, contract, { profile: profile() })).toThrowError(/props\.title/); + expect(() => emitSurface(s, contract, { profile: profile() })).toThrowError(/alert-title/); + }); + + it("an enum value outside the targetEnum refuses, naming the allowed values", () => { + // No valueMap on tone: the raw value would land verbatim on the instance + // and fail gate A3 downstream. Refuse at emission instead. + const s = surfaceOf({ component: "chip", text: "Beta", props: { tone: "loud" } }); + expect(() => emitSurface(s, contract, { profile: profile() })).toThrowError(EmitSurfaceError); + expect(() => emitSurface(s, contract, { profile: profile() })).toThrowError(/tone/); + expect(() => emitSurface(s, contract, { profile: profile() })).toThrowError(/allowed: info, warning/); + }); + + it("fixture sanity: valid surfaces emit and pass every gate (true before and after the fixes)", () => { + for (const root of [ + { component: "text-field", text: "Email" }, + { component: "alert", children: [{ component: "alert-title", text: "Heads up" }] }, + { component: "chip", text: "Beta", props: { tone: "info" } }, + ]) { + const { messages } = emitSurface(surfaceOf(root), contract, { profile: profile() }); + const out = transformFromJson(contract, { profile: profile(), surface: { messages } }); + expect(out.validation.pass, `gates for ${root.component}`).toBe(true); + } + }); +}); diff --git a/src/review-findings.test.ts b/src/review-findings.test.ts index b597a05..6985fe1 100644 --- a/src/review-findings.test.ts +++ b/src/review-findings.test.ts @@ -223,10 +223,13 @@ describe("dead and contradictory spellings refuse at load (findings 4, 5, 13)", describe("the ledger never reports a discarded value as landed (findings 7, 11, 12)", () => { it("a first-wins-skipped harvest ledgers as dropped/lossy, not moved", () => { // The shipped v1 Table: authored caption prop beats the harvested one. + // columns/rows ride the same structuralPassthrough so the instance stays + // catalog-valid (both are required; emitSurface now refuses otherwise) — + // the first-wins dynamics under test concern only the caption. const { fidelity } = emitSurface( surface({ component: "table", - props: { caption: "Authored" }, + props: { caption: "Authored", columns: ["A"], rows: [{ cells: ["1"] }] }, children: [{ component: "table-caption", text: "Harvested" }], }), doc, diff --git a/src/surface-emit.test.ts b/src/surface-emit.test.ts index 208956a..12b3ea9 100644 --- a/src/surface-emit.test.ts +++ b/src/surface-emit.test.ts @@ -8,8 +8,9 @@ * - Compound flattening lands the documented projection (title/description/ * cancelLabel/confirmLabel/triggerLabel + synthesized action). * - Unknown components fail with a typed error (never silently dropped). - * - A CSR violating the emitted component's required props FAILS gate A3 — - * the gate is proven non-vacuous. + * - A CSR that would emit an instance missing a required prop REFUSES at + * emitSurface (with the cause); gate A3 stays non-vacuous for + * hand-authored surfaces — defense in depth, proven separately. * - Emission is deterministic. */ import { readFileSync } from "node:fs"; @@ -111,16 +112,19 @@ describe("emitSurface: worked example (ex.delete-account-confirmation)", () => { } }); - it("lift is relocation, never synthesis: a trigger with no text anywhere still fails A3", () => { + it("lift is relocation, never synthesis: a trigger with no text anywhere refuses at emission", () => { + // Nothing exists to lift, so nothing is synthesized — and instead of + // emitting an AlertDialog missing its required `triggerLabel` for gate A3 + // to refuse downstream, emitSurface now refuses the emission itself, + // naming the unfilled requirement and where the value should have come + // from. (Gate A3 keeps full-schema rigor for hand-authored surfaces — see + // the non-vacuity test below.) const bare: DspackSurface = structuredClone(workedExample.surface); const trigger = bare.root.children![0].children![0]; trigger.children = [{ component: "button", props: { variant: "destructive" }, children: [{ component: "badge" }] }]; - const { messages: bareMessages, warnings: bareWarnings } = emitSurface(bare, doc); - const dialog = componentsOf(bareMessages).find((c) => c.component === "AlertDialog")!; - expect(dialog.triggerLabel).toBeUndefined(); - expect(bareWarnings.map((w) => w.code)).not.toContain("surface-label-lifted"); - const { validation } = transform(doc, "0.9.1", { messages: bareMessages }); - expect(validation.gates.find((g) => g.name === "instance")!.pass).toBe(false); // A3 refuses, as before + expect(() => emitSurface(bare, doc)).toThrowError(EmitSurfaceError); + expect(() => emitSurface(bare, doc)).toThrowError(/required prop 'triggerLabel' has no value after emission/); + expect(() => emitSurface(bare, doc)).toThrowError(/alert-dialog-trigger/); }); }); @@ -184,7 +188,7 @@ describe("emitSurface: failure modes", () => { expect(() => emitSurface(bad, doc)).toThrowError(/does not match contract name/); }); - it("gate A3 is non-vacuous: an AlertDialog missing its title fails instance validation", () => { + it("emitSurface refuses an AlertDialog missing its title, with the sub-component cause", () => { const missingTitle: DspackSurface = { dspackSurface: "0.1", system: "shadcn/ui", @@ -205,10 +209,38 @@ describe("emitSurface: failure modes", () => { ], }, }; - const { messages } = emitSurface(missingTitle, doc); + expect(() => emitSurface(missingTitle, doc)).toThrowError(EmitSurfaceError); + expect(() => emitSurface(missingTitle, doc)).toThrowError(/required prop 'title' has no value after emission/); + expect(() => emitSurface(missingTitle, doc)).toThrowError(/alert-dialog-title/); + }); + + it("gate A3 is non-vacuous: a hand-authored AlertDialog missing its title fails instance validation", () => { + // Defense in depth: the emitter's guard refuses upstream, but gate A3 + // must still catch invalid instances that arrive without going through + // emitSurface at all — and its (now branch-scoped) errors must name the + // genuine problem. + const messages = [ + { + version: "v0.9", + updateComponents: { + surfaceId: "s", + components: [ + { + id: "root", + component: "AlertDialog", + triggerLabel: "Delete", + cancelLabel: "Cancel", + action: { event: { name: "delete", context: {} } }, + }, + ], + }, + }, + ]; const { validation } = transform(doc, "0.9.1", { messages }); const instance = validation.gates.find((g) => g.name === "instance")!; expect(instance.pass).toBe(false); expect((instance.errors ?? []).join("\n")).toMatch(/title/); + // And branch-scoped: no other component's branch leaks into the report. + for (const e of instance.errors ?? []) expect(e).toContain("AlertDialog#root"); }); }); diff --git a/src/surface-fidelity.test.ts b/src/surface-fidelity.test.ts index 9b52b94..3f76134 100644 --- a/src/surface-fidelity.test.ts +++ b/src/surface-fidelity.test.ts @@ -78,10 +78,22 @@ describe("every transformation reports itself", () => { }); it("collection records the gathered rows, per-cell flattening losses, and drops", () => { + // The header row keeps the Table instance catalog-valid (`columns` is + // required; emitSurface now refuses invalid instances) — the ledger + // behaviour under test lives in the body/footer handling. const entries = fidelityOf({ component: "table", children: [ { component: "table-caption", text: "Orders" }, + { + component: "table-header", + children: [ + { + component: "table-row", + children: [{ component: "table-head", text: "Status" }, { component: "table-head", text: "Order" }], + }, + ], + }, { component: "table-body", children: [ diff --git a/src/t1.test.ts b/src/t1.test.ts index 8e77078..dd6cb1f 100644 --- a/src/t1.test.ts +++ b/src/t1.test.ts @@ -331,7 +331,11 @@ describe("T1 fail-closed boundaries", () => { expect(lost).toBeDefined(); }); - it("nothing to donate leaves the destination to gate A3 — relocation, never synthesis", () => { + it("nothing to donate leaves the destination unfilled and emission refuses — relocation, never synthesis", () => { + // No form-label exists, so no label is donated and NOTHING is synthesized + // in its place. The shipped TextField requires `label`, so instead of + // emitting an instance gate A3 would refuse downstream, emitSurface now + // refuses the emission itself, naming the unfilled requirement. const s = surfaceOf({ component: "card", children: [ @@ -346,13 +350,10 @@ describe("T1 fail-closed boundaries", () => { }, ], }); - const { messages } = emitSurface(s, contract, { profile: t1Profile() }); - const field = (messages[1] as { updateComponents: { components: Array> } }) - .updateComponents.components.find((c) => c.component === "TextField")!; - expect(field.label).toBeUndefined(); - // The shipped TextField requires `label`, so A3 refuses the omission. - const check = transformFromJson(contract, { profile: t1Profile(), surface: { messages } }); - expect(check.validation.gates.find((g) => g.name === "instance")?.pass).toBe(false); + expect(() => emitSurface(s, contract, { profile: t1Profile() })).toThrowError(EmitSurfaceError); + expect(() => emitSurface(s, contract, { profile: t1Profile() })).toThrowError( + /required prop 'label' has no value after emission/, + ); }); it("a transparent plan emits no catalog entry, and coverage says so", () => { diff --git a/src/targets/a2ui/surface.ts b/src/targets/a2ui/surface.ts index 047bbc0..a8397f8 100644 --- a/src/targets/a2ui/surface.ts +++ b/src/targets/a2ui/surface.ts @@ -118,6 +118,12 @@ export function emitSurface( ? emitter.emitTransparentRoot(surface.root, "$.root") : emitter.emitNode(surface.root, "$.root"); + // Every instance is assembled; refuse the whole emission if any of them + // could not validate against the catalog this surface names (required + // presence + enum membership — the observed defect class; gate A3 keeps + // full-schema rigor downstream). + emitter.refuseCatalogInvalid(); + const surfaceId = options.surfaceId ?? slug(surface.intent); const theme: Json = { agentDisplayName: `${doc.name} via dspack` }; const primaryHex = primaryColor(doc, profile); @@ -177,6 +183,17 @@ class SurfaceEmitter { readonly components: Json[] = []; readonly diagnostics = new Diagnostics(); private readonly usedIds = new Set(); + /** + * Evidence channel for the final catalog-validity guard: authored props + * dropped per EMITTED instance — either the prop had no A2UI projection at + * all, or its value had no projection and no default. Keyed by instance + * object so a later missing-required refusal can cite its cause. This is + * bookkeeping alongside the (byte-frozen) warnings, never a new warning. + */ + private readonly droppedProps = new Map< + Json, + Array<{ prop: string; dest?: string; raw: unknown; component: string; path: string; reason: "no-projection" | "no-value" }> + >(); constructor( private readonly profile: Profile, @@ -591,8 +608,9 @@ class SurfaceEmitter { case "sub-text-lift": { // Audited lift: relocation of text that exists, never synthesis. If - // nothing exists to lift the destination stays missing and gate A3 - // refuses the instance, exactly as before. + // nothing exists to lift the destination stays missing — refused at + // emission by the final catalog-validity guard when it is required, + // arbitrated by gate A3 otherwise. Either way, no fabrication. const lift = (n: SurfaceNode, inside: boolean): { text: string; component: string } | undefined => { const here = inside || selector.subs.includes(n.component); if (here && n.text !== undefined && n.text !== "") return { text: n.text, component: n.component }; @@ -656,6 +674,7 @@ class SurfaceEmitter { if (routedVerbatim.has(prop)) continue; const pp = plan.propMap?.[prop]; if (!pp) { + this.recordDrop(instance, { prop, raw, component: node.component, path, reason: "no-projection" }); this.diagnostics.push( { code: "surface-prop-dropped", @@ -676,6 +695,7 @@ class SurfaceEmitter { const mapped = pp.valueMap ? pp.valueMap[String(raw)] : undefined; const value = pp.valueMap ? (mapped ?? pp.default) : raw; if (value === undefined) { + this.recordDrop(instance, { prop, dest: pp.a2ui, raw, component: node.component, path, reason: "no-value" }); this.diagnostics.push( { code: "surface-prop-value-dropped", @@ -710,6 +730,104 @@ class SurfaceEmitter { } } + private recordDrop( + instance: Json, + entry: { prop: string; dest?: string; raw: unknown; component: string; path: string; reason: "no-projection" | "no-value" }, + ): void { + const list = this.droppedProps.get(instance); + if (list) list.push(entry); + else this.droppedProps.set(instance, [entry]); + } + + /** + * Final guard (ratified 2026-08-10): never return a surface whose instances + * the emitted catalog itself refuses. Two checks per instance, against its + * own ComponentPlan: + * + * - every catalog-required prop carries a value (`plan.required` — the + * same list mapping.ts emits as `required: ["component", ...]`); + * - every propMap-projected prop with a `targetEnum` carries a member of + * that enum (without a valueMap the authored value passes through + * verbatim, so an off-vocabulary value used to land silently). + * + * On any violation the whole emission refuses with ONE aggregated message + * citing each violation and — where the node's recorded drop diagnostics + * recover it — the cause. This covers the observed defect class + * (required-presence + enum-membership); full-schema rigor stays gate A3's + * job, downstream, as defense in depth. Warnings and fidelity recording are + * untouched: a surface that emits, emits byte-identically to before. + */ + refuseCatalogInvalid(): void { + const byA2ui = new Map(); + for (const plan of [...this.profile.components, ...this.profile.synthesized]) { + // Transparent plans dissolve at emission — no instance ever carries them. + if (!surfaceModelOf(plan).transparent) byA2ui.set(plan.a2ui, plan); + } + + const violations: string[] = []; + for (const inst of this.components) { + const plan = byA2ui.get(String(inst.component)); + if (!plan) continue; // no plan to check against: gate A3 arbitrates downstream + const where = `${inst.component}#${inst.id}`; + const drops = this.droppedProps.get(inst) ?? []; + + for (const req of plan.required) { + if (inst[req] !== undefined) continue; + violations.push(`${where}: required prop '${req}' has no value after emission${this.missingCause(plan, req, drops)}`); + } + + for (const [src, pp] of Object.entries(plan.propMap ?? {})) { + if (!pp.targetEnum) continue; + const value = inst[pp.a2ui]; + if (value === undefined) continue; + if (typeof value === "string" && pp.targetEnum.includes(value)) continue; + const cause = pp.valueMap ? "" : ` — authored 'props.${src}' passed through verbatim (this propMap declares no valueMap)`; + violations.push( + `${where}: prop '${pp.a2ui}' value ${JSON.stringify(value)} is outside the catalog's enum (allowed: ${pp.targetEnum.join(", ")})${cause}`, + ); + } + } + + if (violations.length > 0) { + throw new EmitSurfaceError( + `refusing to emit: ${violations.length} instance value(s) would not validate against the emitted catalog ` + + `(gate A3 would refuse this surface downstream):\n` + + violations.map((v) => ` - ${v}`).join("\n"), + "$", + ); + } + } + + /** + * The recoverable cause for a required prop with no value: the authored + * prop this node DID carry that was dropped (recorded at PropMap time), + * and/or where the plan's own routes expect the value to come from. + */ + private missingCause( + plan: ComponentPlan, + req: string, + drops: Array<{ prop: string; dest?: string; raw: unknown; component: string; path: string; reason: "no-projection" | "no-value" }>, + ): string { + const model = surfaceModelOf(plan); + const feeders = model.routes.filter((r) => r.to.name === req); + const subs = [ + ...new Set(feeders.flatMap((r) => r.from.flatMap((s) => ("subs" in s ? [...(s.subs as string[])] : [])))), + ]; + const source = + subs.length > 0 + ? ` (${req} comes from the ${subs.map((s) => `'${s}'`).join(" / ")} sub-component${subs.length > 1 ? "s" : ""})` + : feeders.length > 0 + ? ` (${req} is fed by ${feeders.map((r) => r.from.map(describeSelector).join(" | ")).join(", ")})` + : ""; + const drop = drops.find((d) => d.prop === req || d.dest === req); + if (!drop) return source; + const why = + drop.reason === "no-projection" + ? `authored 'props.${drop.prop}' on '${drop.component}' has no A2UI projection` + : `authored 'props.${drop.prop}'=${JSON.stringify(drop.raw)} on '${drop.component}' has no projection for that value and no default`; + return ` — ${why}${source}`; + } + /** * Refuse when a consumed subtree contains a component the profile declared a * casualty. Consumption is how compounds fold their parts into props; it is @@ -1546,8 +1664,10 @@ class SurfaceEmitter { if (value !== undefined) { pending.push({ prop: d.to.name, value, origin: d.origin, donorPath: `${path}${suffix}`, donorComponent }); } - // Nothing to donate is not an error: the destination stays absent and - // gate A3 arbitrates — relocation, never synthesis (the lift rule). + // Nothing to donate is not an error: the destination stays absent — + // refused at emission by the final catalog-validity guard when it is + // required, arbitrated by gate A3 otherwise. Relocation, never + // synthesis (the lift rule). } // The boundary's own text rises as body text unless a self.text diff --git a/src/validate/ajv.ts b/src/validate/ajv.ts index baaff99..5751885 100644 --- a/src/validate/ajv.ts +++ b/src/validate/ajv.ts @@ -12,7 +12,10 @@ * a2ui-catalog.meta..json (the literal "catalog schema" check; this is what * makes v0.9.1 vs v1.0 conformance distinct — theme vs surfaceProperties). * 3. instance: every component instance in the hand-authored surface validates - * against the catalog's own #/$defs/anyComponent. + * against the catalog's own #/$defs/anyComponent — checked by manual + * discrimination (each instance against its own #/components/ + * branch), which is semantically equivalent and keeps every reported + * error inside the instance's own branch. See the gate 3 body. */ import Ajv2020 from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; @@ -24,6 +27,25 @@ export interface GateResult { pass: boolean; detail: string; errors?: string[]; + /** + * Structured per-instance evidence for gate 3 failures (advanced channel + * for downstream UIs). The strings in `errors` remain the primary + * user-facing form; this preserves ajv's keyword/params/schemaPath, which + * the strings deliberately compress. + */ + errorDetails?: Array<{ + /** `component#id` — same prefix the string form carries. */ + instance: string; + component: string; + id: string; + errors: Array<{ + instancePath?: string; + schemaPath?: string; + keyword?: string; + params?: unknown; + message?: string; + }>; + }>; } export interface ValidationReport { @@ -171,20 +193,64 @@ export function validateCatalog( // Belt and braces: gate 1 has already proven every ref resolves, but a // raw ajv throw must never escape this function regardless. const failures: string[] = []; + const errorDetails: NonNullable = []; let instances: Json[] = []; try { const ajv = newAjv(); ajv.addSchema(catalog as unknown as Json, catalog.$id); - const validateAny = ajv.getSchema(`${catalog.$id}#/$defs/anyComponent`); instances = extractInstances(surface); - if (!validateAny) { - failures.push("Could not resolve #/$defs/anyComponent from the catalog."); - } else { - for (const inst of instances) { - if (!validateAny(inst)) { - const where = `${inst.component}#${inst.id}`; - for (const e of validateAny.errors ?? []) failures.push(`${where}: ${fmtErr(e)}`); - } + // Manual discrimination instead of the flat `#/$defs/anyComponent` + // oneOf. Semantically equivalent: every branch of the emitted + // anyComponent is `{$ref: "#/components/"}` and each component + // schema pins `component: {const: }` inside its allOf (and + // requires it), so an instance tagged component X can only ever match + // branch X — validating the instance against ITS OWN branch accepts + // and rejects exactly the same instances the oneOf does. What changes + // is the report: allErrors WITHIN the matching branch (rigor + // preserved, every genuine error surfaced) without echoing every + // other branch's const/required/unevaluatedProperties noise. + const admitted = Object.keys(catalog.components ?? {}); + for (const inst of instances) { + const comp = String(inst.component); + const id = String(inst.id); + const where = `${comp}#${id}`; + if (!admitted.includes(comp)) { + failures.push( + `${where}: component '${comp}' is not in this catalog (${admitted.length} components admitted)`, + ); + errorDetails.push({ + instance: where, + component: comp, + id, + errors: [ + { + message: `component '${comp}' is not in this catalog`, + params: { admittedComponents: admitted }, + }, + ], + }); + continue; + } + const validateBranch = ajv.getSchema(`${catalog.$id}#/components/${comp}`); + if (!validateBranch) { + failures.push(`${where}: could not resolve #/components/${comp} from the catalog.`); + continue; + } + if (!validateBranch(inst)) { + const errs = validateBranch.errors ?? []; + for (const e of errs) failures.push(`${where}: ${fmtErr(e)}`); + errorDetails.push({ + instance: where, + component: comp, + id, + errors: errs.map((e) => ({ + instancePath: e.instancePath, + schemaPath: e.schemaPath, + keyword: e.keyword, + params: e.params as unknown, + message: e.message, + })), + }); } } } catch (e) { @@ -198,6 +264,7 @@ export function validateCatalog( ? `All ${instances.length} surface component instance(s) validate against #/$defs/anyComponent.` : `${failures.length} instance validation error(s).`, errors: failures.length ? failures : undefined, + ...(errorDetails.length ? { errorDetails } : {}), }); } } @@ -205,6 +272,23 @@ export function validateCatalog( return { version, pass: gates.every((g) => g.pass), gates }; } -function fmtErr(e: { instancePath?: string; message?: string }): string { - return `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim(); +/** + * One ajv error as a user-facing line: instancePath + message, enriched from + * the error's params where they carry the actionable detail ajv's message + * omits — the allowed enum values, the expected const, or the name of the + * offending unevaluated/additional property. + */ +function fmtErr(e: { instancePath?: string; keyword?: string; params?: unknown; message?: string }): string { + const params = (e.params ?? {}) as Record; + let out = `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim(); + if (e.keyword === "enum" && Array.isArray(params.allowedValues)) { + out += ` (allowed: ${(params.allowedValues as unknown[]).map((v) => String(v)).join(", ")})`; + } else if (e.keyword === "const" && "allowedValue" in params) { + out += ` (expected: ${JSON.stringify(params.allowedValue)})`; + } else if (e.keyword === "unevaluatedProperties" && typeof params.unevaluatedProperty === "string") { + out += ` ('${params.unevaluatedProperty}')`; + } else if (e.keyword === "additionalProperties" && typeof params.additionalProperty === "string") { + out += ` ('${params.additionalProperty}')`; + } + return out; }