diff --git a/docs/PROFILES.md b/docs/PROFILES.md index e08e2a6..32a1c11 100644 --- a/docs/PROFILES.md +++ b/docs/PROFILES.md @@ -199,7 +199,19 @@ write phase, and two same-phase writers on one destination refuse; layered writers in distinct phases are legal (a passthrough prop beats a harvested one, deterministically). -**Repetition** collects repeated sub-structures into an array-valued prop: +**Repetition** collects repeated sub-structures into an array-valued prop. +T2's **item-mode** makes each matching descendant one record whose fields +resolve on the item's own node (`self.text`, `self.props.`, `self.id`) +— no joins, no sibling reads (a label paired by `htmlFor` is T3's declared +join): + +```jsonc +"collects": [ + { "of": ["select-item"], "into": "prop:options", "item": { "label": "self.text" } } +] +``` + +Table-mode expresses the three-level text shape: ```jsonc "collects": [ diff --git a/eval/build-eval-profile.mjs b/eval/build-eval-profile.mjs index c2fabf5..55fe655 100644 --- a/eval/build-eval-profile.mjs +++ b/eval/build-eval-profile.mjs @@ -38,7 +38,7 @@ import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const { scaffoldProfile } = await import(join(root, "dist/transform/scaffold.js")); const { shadcnProfile } = await import(join(root, "dist/transform/profiles.js")); -const { SHADCN_V2_SURFACES, SHADCN_V3_T1_SURFACES } = await import(join(root, "dist/transform/shadcn-v2-respelling.js")); +const { SHADCN_V2_SURFACES, SHADCN_V3_T1_SURFACES, SHADCN_V3_T2_PLANS } = await import(join(root, "dist/transform/shadcn-v2-respelling.js")); export const CONTRACT_SHA256 = "ea87346f85965937cb2b18e3998f19da7ba3cee41b41572638d657f20b3f5565"; export const CONTRACT_COMMIT = "48643ff (aestheticfunction/dspack, merged as b573637 / PR #35)"; @@ -77,6 +77,10 @@ export function buildEvalProfile() { const { propMap: _p, ...rest } = plan; return { ...rest, structural: {}, required: [], surface: structuredClone(t1) }; } + // T2 resolutions (proven in src/t2.test.ts): a collecting plan IS a + // catalog component — the whole receiving plan is committed evidence. + const t2 = SHADCN_V3_T2_PLANS[plan.dspackId]; + if (t2) return structuredClone(t2); return plan; }); @@ -87,7 +91,7 @@ export function buildEvalProfile() { "Downstream Studio consumes the pinned v2.3.0 contract with the shipped v1 profile; this fixture is measurement-only.", contract: { commit: CONTRACT_COMMIT, sha256: CONTRACT_SHA256 }, derivation: - "scaffoldProfile(v3) + the six shipped plans transplanted with their byte-proven v2 re-spelling + the T1 transparent-identity resolutions proven in src/t1.test.ts; zero casualties declared, zero fresh judgment.", + "scaffoldProfile(v3) + the six shipped plans transplanted with their byte-proven v2 re-spelling + the T1 transparent-identity resolutions (src/t1.test.ts) + the T2 item-mode collection plans (src/t2.test.ts); zero casualties declared, zero fresh judgment.", unresolvedAreDeliberate: "Every unresolved sub-component listed by the coverage report is a real, open representation decision — do not resolve them here to make a number look better.", }; diff --git a/eval/shadcn-v3.eval.profile.json b/eval/shadcn-v3.eval.profile.json index c844d04..3e5cc46 100644 --- a/eval/shadcn-v3.eval.profile.json +++ b/eval/shadcn-v3.eval.profile.json @@ -1394,74 +1394,52 @@ "ComponentCommon" ], "structural": { - "children": { + "options": { "schema": { - "$ref": "#/$defs/ChildList" + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } }, - "description": "Child component IDs, in order.", - "synthNote": "Scaffolded: observed with child nodes in worked example 'ex.expense-report-form'." + "description": "The selectable options, one record per item.", + "synthNote": "A2UI models repeated options as data on the group; dspack models them as repeated sub-components (T2 item-mode collection)." } }, - "required": [ - "children" - ], "propMap": { - "value": { - "a2ui": "value", - "kind": "string", - "description": "Controlled selected value. Must match the value of exactly one RadioGroupItem in the group." - }, - "defaultValue": { - "a2ui": "defaultValue", - "kind": "string", - "description": "Initially selected value for an uncontrolled group. Omit it when there is no safe default — an unselected group forces a deliberate choice." - }, - "orientation": { - "a2ui": "orientation", - "kind": "enum", - "targetEnum": [ - "vertical", - "horizontal" - ], - "description": "Sets aria-orientation and constrains arrow-key navigation to one axis. Unset by default, which lets both axes navigate; set it to match the visual arrangement whenever the layout is committed to a direction." - }, - "disabled": { - "a2ui": "disabled", - "kind": "boolean", - "description": "Disables every item in the group. To disable one option, set disabled on that RadioGroupItem instead." - }, - "required": { - "a2ui": "required", - "kind": "boolean", - "description": "Whether a selection is required before the form can be submitted. Sets aria-required on the group." - }, "name": { "a2ui": "name", - "kind": "string", - "description": "Form field name applied to the items' hidden native inputs, under which the selected value is submitted." - }, - "loop": { - "a2ui": "loop", - "kind": "boolean", - "description": "Whether arrow-key navigation wraps from the last item back to the first." + "kind": "string" }, - "dir": { - "a2ui": "dir", - "kind": "enum", - "targetEnum": [ - "ltr", - "rtl" - ], - "description": "Reading direction for arrow-key navigation. Inherited from an ambient direction provider when omitted." + "defaultValue": { + "a2ui": "defaultValue", + "kind": "string" } }, + "required": [ + "options" + ], "surface": { - "routes": [ + "collects": [ { - "from": [ - "children" + "of": [ + "radio-group-item" ], - "to": "slots:children" + "into": "prop:options", + "item": { + "value": "self.id" + } } ] } @@ -1473,72 +1451,61 @@ "ComponentCommon" ], "structural": { - "children": { + "options": { "schema": { - "$ref": "#/$defs/ChildList" + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + } }, - "description": "Child component IDs, in order.", - "synthNote": "Scaffolded: observed with child nodes in worked example 'ex.workspace-members-directory'." + "description": "The selectable options.", + "synthNote": "A2UI models repeated options as data on the control (T2 item-mode collection)." } }, - "required": [ - "children" - ], "propMap": { - "value": { - "a2ui": "value", - "kind": "string", - "description": "Controlled selected value; must match the value of one SelectItem. The empty string means nothing is selected and shows the SelectValue placeholder." - }, "defaultValue": { "a2ui": "defaultValue", - "kind": "string", - "description": "Initially selected value for an uncontrolled Select. Omit it to start on the placeholder." - }, - "open": { - "a2ui": "open", - "kind": "boolean", - "description": "Controlled open state of the listbox. Only needed when something outside the Select has to open or close it." - }, - "defaultOpen": { - "a2ui": "defaultOpen", - "kind": "boolean", - "description": "Whether the listbox starts open for an uncontrolled Select." - }, - "disabled": { - "a2ui": "disabled", - "kind": "boolean", - "description": "Whether the whole control is non-interactive. To disable one option, set disabled on that SelectItem instead." - }, - "required": { - "a2ui": "required", - "kind": "boolean", - "description": "Whether a value must be chosen before the form can be submitted. Sets aria-required on the trigger." - }, - "name": { - "a2ui": "name", - "kind": "string", - "description": "Form field name applied to the hidden native select, under which the value is submitted." - }, - "dir": { - "a2ui": "dir", - "kind": "enum", - "targetEnum": [ - "ltr", - "rtl" - ], - "description": "Reading direction. Inherited from an ambient direction provider when omitted." + "kind": "string" } }, + "required": [ + "options" + ], "surface": { - "routes": [ + "collects": [ { - "from": [ - "children" + "of": [ + "select-item" ], - "to": "slots:children" + "into": "prop:options", + "item": { + "label": "self.text" + } } - ] + ], + "subs": { + "select-trigger": { + "drop": "the trigger renders from the bound value, not authored content" + }, + "select-value": { + "drop": "the visible value is runtime state derived from the selection" + }, + "select-label": { + "drop": "group headings are not carried by the flat options shape" + }, + "select-separator": { + "drop": "visual grouping chrome with no data meaning" + } + } } }, { @@ -1974,7 +1941,7 @@ "commit": "48643ff (aestheticfunction/dspack, merged as b573637 / PR #35)", "sha256": "ea87346f85965937cb2b18e3998f19da7ba3cee41b41572638d657f20b3f5565" }, - "derivation": "scaffoldProfile(v3) + the six shipped plans transplanted with their byte-proven v2 re-spelling + the T1 transparent-identity resolutions proven in src/t1.test.ts; zero casualties declared, zero fresh judgment.", + "derivation": "scaffoldProfile(v3) + the six shipped plans transplanted with their byte-proven v2 re-spelling + the T1 transparent-identity resolutions (src/t1.test.ts) + the T2 item-mode collection plans (src/t2.test.ts); zero casualties declared, zero fresh judgment.", "unresolvedAreDeliberate": "Every unresolved sub-component listed by the coverage report is a real, open representation decision — do not resolve them here to make a number look better." } } diff --git a/eval/t1-rebaseline-matrix.json b/eval/t1-rebaseline-matrix.json new file mode 100644 index 0000000..b1f37fa --- /dev/null +++ b/eval/t1-rebaseline-matrix.json @@ -0,0 +1,220 @@ +{ + "model": "qwen3.6:35b", + "contract": "3.1.0 @ dspack 862ca95", + "gen": "0.2.2", + "emit": "0.5.0+T1 (main)", + "intents": [ + "destructive-action", + "record-collection", + "structured-input", + "preference-settings", + "record-detail", + "overlay-task" + ], + "runs": [ + { + "variant": "pre-T1", + "intent": "destructive-action", + "outcome": "failed-lint-exhausted", + "attempts": 3, + "firstAttemptGates": "", + "perAttempt": [ + "", + "", + "" + ], + "firstBlocker": "failed-lint-exhausted", + "zeroControlFinding": true, + "seconds": 482 + }, + { + "variant": "pre-T1", + "intent": "record-collection", + "outcome": "failed-lint-exhausted", + "attempts": 3, + "firstAttemptGates": "", + "perAttempt": [ + "", + "", + "" + ], + "firstBlocker": "failed-lint-exhausted", + "zeroControlFinding": false, + "seconds": 325 + }, + { + "variant": "pre-T1", + "intent": "structured-input", + "outcome": "passed", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": null, + "zeroControlFinding": false, + "seconds": 77 + }, + { + "variant": "pre-T1", + "intent": "preference-settings", + "outcome": "failed-gate", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": "emit refusal: unknown component 'form': not a mapped component of the 'shadcn/ui v3 \u2014 coverage-evaluation catalog (NOT production, NOT Studio's profile)' profile (sub-compone", + "zeroControlFinding": false, + "seconds": 144 + }, + { + "variant": "pre-T1", + "intent": "record-detail", + "outcome": "failed-gate", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": "emit refusal: unknown component 'form': not a mapped component of the 'shadcn/ui v3 \u2014 coverage-evaluation catalog (NOT production, NOT Studio's profile)' profile (sub-compone", + "zeroControlFinding": false, + "seconds": 107 + }, + { + "variant": "pre-T1", + "intent": "overlay-task", + "outcome": "failed-gate", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": "emit refusal: unknown component 'dialog': not a mapped component of the 'shadcn/ui v3 \u2014 coverage-evaluation catalog (NOT production, NOT Studio's profile)' profile (sub-compo", + "zeroControlFinding": false, + "seconds": 105 + }, + { + "variant": "post-T1", + "intent": "destructive-action", + "outcome": "failed-gate", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": "emit refusal: unknown component 'alert': not a mapped component of the 'shadcn/ui v3 \u2014 coverage-evaluation catalog (NOT production, NOT Studio's profile)' profile (sub-compon", + "zeroControlFinding": false, + "seconds": 68 + }, + { + "variant": "post-T1", + "intent": "record-collection", + "outcome": "failed-lint-exhausted", + "attempts": 3, + "firstAttemptGates": "", + "perAttempt": [ + "", + "", + "" + ], + "firstBlocker": "failed-lint-exhausted", + "zeroControlFinding": true, + "seconds": 342 + }, + { + "variant": "post-T1", + "intent": "structured-input", + "outcome": "failed-gate", + "attempts": 2, + "firstAttemptGates": "", + "perAttempt": [ + "", + "" + ], + "firstBlocker": "emit refusal: unknown component 'form-field': not a mapped component of the 'shadcn/ui v3 \u2014 coverage-evaluation catalog (NOT production, NOT Studio's profile)' profile (sub-c", + "zeroControlFinding": true, + "seconds": 156 + }, + { + "variant": "post-T1", + "intent": "preference-settings", + "outcome": "failed-adapter", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": "failed-adapter", + "zeroControlFinding": false, + "seconds": 598 + }, + { + "variant": "post-T1", + "intent": "record-detail", + "outcome": "failed-adapter", + "attempts": 2, + "firstAttemptGates": "", + "perAttempt": [ + "", + "" + ], + "firstBlocker": "failed-adapter", + "zeroControlFinding": true, + "seconds": 650 + }, + { + "variant": "post-T1", + "intent": "overlay-task", + "outcome": "failed-lint-exhausted", + "attempts": 3, + "firstAttemptGates": "", + "perAttempt": [ + "", + "", + "" + ], + "firstBlocker": "failed-lint-exhausted", + "zeroControlFinding": false, + "seconds": 234 + } + ], + "retries": [ + { + "variant": "post-T1", + "intent": "preference-settings", + "outcome": "failed-gate", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": "emit refusal: unknown component 'field-set': not a mapped component of the 'shadcn/ui v3 \u2014 coverage-evaluation catalog (NOT production, NOT Studio's profile)' profile (sub-co", + "zeroControlFinding": false, + "seconds": 83, + "note": "single retry of an infrastructure adapter timeout (originals preserved below)" + }, + { + "variant": "post-T1", + "intent": "record-detail", + "outcome": "failed-gate", + "attempts": 1, + "firstAttemptGates": "", + "perAttempt": [ + "" + ], + "firstBlocker": "emit refusal: unknown component 'form-label': not a mapped component of the 'shadcn/ui v3 \u2014 coverage-evaluation catalog (NOT production, NOT Studio's profile)' profile (sub-c", + "zeroControlFinding": false, + "seconds": 33, + "note": "single retry of an infrastructure adapter timeout (originals preserved below)" + } + ], + "method": { + "contract": "3.1.0 @ dspack 862ca95 (governance rule live)", + "gen": "0.2.2 (requiredCategories S3)", + "emit": "0.5.0+T1 @ 868721b", + "model": "qwen3.6:35b, maxRepairs 2, same six prompts as the T1 matrix", + "profiles": "same PRE/POST derivation as the T1 matrix" + }, + "finding": "THE GOVERNANCE HOLE IS CLOSED: zero-control forms are caught at S3 with repairable findings (zeroControlFinding true in 4 runs) and the 'donation boundary found 0' emit blocker is GONE from the entire post-T1 column \u2014 the T1-exposed defect now fails at the layer that owns it. Authoritative post-T1 baseline: pre-T1 1/6 passed (structured-input); post-T1 0/6 with all failures at honest frontiers: unmapped components (alert, field-set, dialog), stray subs outside their compound (form-field, form-label \u2014 correctly refused), and lint-exhausted repairs where the model cannot satisfy the tightened governance within 2 repairs. Two adapter timeouts were retried once (originals preserved). T1's conversion effect on this model is limited by model quality, not representation: the named chain emits deterministically in the corpus tests; under generation, qwen3.6:35b's form output now dies at governance instead of sailing through as semantically empty \u2014 which is the system working." +} diff --git a/src/eval-fixture.test.ts b/src/eval-fixture.test.ts index 91dce0f..74fcc2a 100644 --- a/src/eval-fixture.test.ts +++ b/src/eval-fixture.test.ts @@ -6,14 +6,16 @@ * compounds, 1 unresolved"): that profile's nine extra compound plans carried * judgment that did not survive the reboot. This fixture transplants only * COMMITTED mapping evidence — the six shipped plans in their byte-proven v2 - * re-spelling, and now the T1 transparent-identity resolutions proven in - * src/t1.test.ts — onto a mechanical scaffold of the pinned v3 contract, - * declares zero casualties, and lets every open decision show as open: + * re-spelling, the T1 transparent-identity resolutions (src/t1.test.ts), and + * the T2 item-mode collection plans (src/t2.test.ts) — onto a mechanical + * scaffold of the pinned v3 contract, declares zero casualties, and lets + * every open decision show as open: * * 106 sub-components under 19 mapped compounds - * 26 resolved — the transplanted families (card 5, table 7, - * alert-dialog 8) plus T1's form family (6) - * 80 unresolved — real, deliberate, and the measure of the T2–T5 work + * 34 resolved — the transplanted families (card 5, table 7, + * alert-dialog 8), T1's form family (6), T2's + * radio-group (1) and select (7) + * 72 unresolved — real, deliberate, and the measure of the T3–T5 work * * The v2 contract gate refusing this fixture against the v3 contract is not a * failure; it is the fatal coverage gate doing on the production corpus @@ -50,19 +52,19 @@ describe("the production-v3 evaluation fixture", () => { expect(profile.casualtyComponents).toEqual([]); }); - it("the fatal coverage gate refuses it against the v3 contract: 80 unresolved decisions, each pathed", () => { + it("the fatal coverage gate refuses it against the v3 contract: 72 unresolved decisions, each pathed", () => { try { transformFromJson(contract, { profile: loadProfile(structuredClone(fixtureJson)) }); - expect.unreachable("80 open representation decisions must refuse, not emit"); + expect.unreachable("72 open representation decisions must refuse, not emit"); } catch (e) { expect(e).toBeInstanceOf(ProfileContractError); const issues = (e as ProfileContractError).issues; - expect(issues).toHaveLength(80); + expect(issues).toHaveLength(72); expect(issues.every((i) => i.message.includes("unresolved"))).toBe(true); } }); - it("derived coverage: 106 subs, 26 resolved (transplants + T1 form), 80 open", () => { + it("derived coverage: 106 subs, 34 resolved (transplants + T1 + T2), 72 open", () => { const profile = loadProfile(structuredClone(fixtureJson)); const byId = new Map(profile.components.map((p: ComponentPlan) => [p.dspackId, p])); let resolved = 0; @@ -84,7 +86,7 @@ describe("the production-v3 evaluation fixture", () => { } } expect(resolved + unresolved).toBe(106); - expect(unresolved).toBe(80); - expect(Object.fromEntries(resolvedByCompound)).toEqual({ card: 5, table: 7, "alert-dialog": 8, form: 6 }); + expect(unresolved).toBe(72); + expect(Object.fromEntries(resolvedByCompound)).toEqual({ card: 5, table: 7, "alert-dialog": 8, form: 6, "radio-group": 1, select: 7 }); }); }); diff --git a/src/t2.test.ts b/src/t2.test.ts new file mode 100644 index 0000000..e350921 --- /dev/null +++ b/src/t2.test.ts @@ -0,0 +1,222 @@ +/** + * T2: homogeneous repeated sub-structure → data array (item-mode collection). + * + * Fail-first (captured before any code, /tmp/t2-failfirst.txt): the eval + * ledger carries radio-group-item and select's seven subs among its 80 + * unresolved; the item-mode spelling was refused by the v2 grammar; and + * ex.notification-preferences refused end-to-end. + * + * The measured shapes that scoped this milestone: + * - `select-item` carries its label as its OWN text — the pure homogeneous + * shape, collected completely by T2; + * - `radio-group-item` carries ONLY its id; its label is a SIBLING `label` + * joined by htmlFor→id — a DECLARED KEY JOIN, which is T3's mechanism. + * T2 collects what each item itself carries ({value: self.id}) and the + * sibling labels are dropped WITH RECORD — the measured T3 frontier, not + * a T2 defect. Position-based pairing is banned by the ratified model. + * - Neither item declares a `value` prop in the contract (the example + * encodes value into ids by convention) — a contract-modeling gap noted + * for the taxonomy trail, not papered over here. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { transformFromJson } from "./transform/index.js"; +import { emitSurface } from "./targets/a2ui/surface.js"; +import { loadProfile, ProfileLoadError } from "./transform/profile-load.js"; +import type { DspackDoc, DspackSurface } from "./types.js"; + +const repo = (p: string) => fileURLToPath(new URL(`../${p}`, import.meta.url)); +const contract = JSON.parse(readFileSync(repo("eval/shadcn-v3.dspack.json"), "utf8")) as DspackDoc & { + examples: Array<{ id: string; surface: DspackSurface }>; +}; +const fixture = JSON.parse(readFileSync(repo("eval/shadcn-v3.eval.profile.json"), "utf8")); + +const RG_T2_SURFACE = { + collects: [{ of: ["radio-group-item"], into: "prop:options", item: { value: "self.id" } }], +}; +const SELECT_T2_SURFACE = { + collects: [{ of: ["select-item"], into: "prop:options", item: { label: "self.text" } }], + subs: { + "select-trigger": { drop: "the trigger renders from the bound value, not authored content" }, + "select-value": { drop: "the visible value is runtime state derived from the selection" }, + "select-label": { drop: "group headings are not carried by the flat options shape" }, + "select-separator": { drop: "visual grouping chrome with no data meaning" }, + }, +}; + +/** Hermetic T2 profile: radio-group + select plans over the eval fixture base. */ +const t2Profile = (mutate?: (doc: Record) => void) => { + const base = structuredClone(fixture) as Record & { components: Array> }; + const pick = (id: string) => structuredClone(base.components.find((c) => c.dspackId === id)!); + const radioGroup = { + a2ui: "RadioGroup", + dspackId: "radio-group", + commons: ["ComponentCommon"], + structural: { + options: { + schema: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" } }, required: ["value"], additionalProperties: false } }, + description: "The selectable options, one record per item.", + synthNote: "A2UI models repeated options as data on the group; dspack models them as repeated sub-components.", + }, + }, + propMap: { name: { a2ui: "name", kind: "string" }, defaultValue: { a2ui: "defaultValue", kind: "string" } }, + required: ["options"], + surface: structuredClone(RG_T2_SURFACE), + }; + const select = { + a2ui: "Select", + dspackId: "select", + commons: ["ComponentCommon"], + structural: { + options: { + schema: { type: "array", items: { type: "object", properties: { label: { type: "string" } }, required: ["label"], additionalProperties: false } }, + description: "The selectable options.", + synthNote: "A2UI models repeated options as data on the control.", + }, + }, + propMap: { defaultValue: { a2ui: "defaultValue", kind: "string" } }, + required: ["options"], + surface: structuredClone(SELECT_T2_SURFACE), + }; + const doc: Record & { components: Array> } = { + ...base, + components: [pick("card"), radioGroup, select], + }; + delete (doc as Record)["x-scaffold"]; + mutate?.(doc); + return loadProfile(doc); +}; + +const surfaceOf = (root: unknown): DspackSurface => + ({ dspackSurface: "0.1", system: contract.name as string, intent: "preference-settings", root }) as DspackSurface; + +/** The production notification-preferences radio-group, verbatim shape. */ +const RG_SURFACE = { + component: "radio-group", + props: { name: "digest", defaultValue: "monday" }, + children: [ + { component: "radio-group-item", id: "digest-monday" }, + { component: "label", props: { htmlFor: "digest-monday" }, text: "Monday morning" }, + { component: "radio-group-item", id: "digest-friday" }, + { component: "label", props: { htmlFor: "digest-friday" }, text: "Friday afternoon" }, + ], +}; + +describe("T2 grammar and gates", () => { + it("the item-mode spelling loads (the fail-first refusal, inverted)", () => { + expect(t2Profile().language).toBe("v2"); + }); + + it("mixing item-mode and table-mode keys refuses", () => { + expect(() => + t2Profile((doc) => { + const rg = (doc.components as Array>).find((c) => c.dspackId === "radio-group")!; + rg.surface = { collects: [{ of: ["radio-group-item"], into: "prop:options", item: { value: "self.id" }, shape: "flat", row: ["x"], cells: ["y"] }] }; + }), + ).toThrowError(ProfileLoadError); + }); + + it("item fields are item-local: a sub() read refuses at the item field's path", () => { + // The schema's closed item-selector pattern fires first (self.text | + // self.id | self.props.); the parse layer carries the same rule + // with a T3-naming message as defense-in-depth behind it. + try { + t2Profile((doc) => { + const rg = (doc.components as Array>).find((c) => c.dspackId === "radio-group")!; + rg.surface = { collects: [{ of: ["radio-group-item"], into: "prop:options", item: { label: "sub(label).text" } }] }; + }); + expect.unreachable("a join read must refuse"); + } catch (e) { + const issues = (e as ProfileLoadError).issues; + expect(issues.some((i) => i.path.includes("/item/label") || i.path.includes("collects"))).toBe(true); + expect(issues.map((i) => i.message).join("\n")).toContain("pattern"); + } + }); +}); + +describe("T2 collects the measured shapes", () => { + it("select: the pure homogeneous shape collects completely", () => { + const s = surfaceOf({ + component: "select", + props: { defaultValue: "all" }, + children: [ + { + component: "select-content", + children: [ + { component: "select-item", text: "All roles" }, + { component: "select-item", text: "Admins" }, + { component: "select-item", text: "Editors" }, + ], + }, + ], + }); + const { messages } = emitSurface(s, contract, { profile: t2Profile() }); + const [sel] = (messages[1] as { updateComponents: { components: Array> } }) + .updateComponents.components; + expect(sel.component).toBe("Select"); + expect(sel.options).toEqual([{ label: "All roles" }, { label: "Admins" }, { label: "Editors" }]); + const check = transformFromJson(contract, { profile: t2Profile(), surface: { messages } }); + expect(check.validation.gates.find((g) => g.name === "instance")?.pass).toBe(true); + }); + + it("radio-group: items collect what they carry; sibling labels are the recorded T3 frontier", () => { + const { messages, warnings, fidelity } = emitSurface(surfaceOf(RG_SURFACE), contract, { profile: t2Profile() }); + const [rg] = (messages[1] as { updateComponents: { components: Array> } }) + .updateComponents.components; + expect(rg.component).toBe("RadioGroup"); + expect(rg.name).toBe("digest"); + expect(rg.options).toEqual([{ value: "digest-monday" }, { value: "digest-friday" }]); + + // The htmlFor-joined sibling labels are dropped WITH RECORD, naming T3. + const labelDrops = warnings.filter((w) => w.code === "surface-sub-dropped" && w.message.includes("'label'")); + expect(labelDrops).toHaveLength(2); + expect(labelDrops[0].message).toContain("T3"); + expect(fidelity.filter((f) => f.kind === "dropped" && f.source.includes("label"))).toHaveLength(2); + + // Each collected record is ledgered with its source item. + const collected = fidelity.filter((f) => f.kind === "moved" && f.note?.includes("item record")); + expect(collected).toHaveLength(2); + expect(collected[0].source).toContain("radio-group-item"); + + const check = transformFromJson(contract, { profile: t2Profile(), surface: { messages } }); + expect(check.validation.gates.find((g) => g.name === "instance")?.pass).toBe(true); + }); + + it("emission is deterministic across runs", () => { + const a = emitSurface(surfaceOf(RG_SURFACE), contract, { profile: t2Profile() }); + const b = emitSurface(surfaceOf(RG_SURFACE), contract, { profile: t2Profile() }); + expect(JSON.stringify([a.messages, a.warnings, a.fidelity])).toBe(JSON.stringify([b.messages, b.warnings, b.fidelity])); + }); + + it("an item missing its field omits it, and A3 arbitrates — never synthesis", () => { + const s = surfaceOf({ + component: "radio-group", + children: [{ component: "radio-group-item" }], // no id at all + }); + const { messages } = emitSurface(s, contract, { profile: t2Profile() }); + const [rg] = (messages[1] as { updateComponents: { components: Array> } }) + .updateComponents.components; + expect(rg.options).toEqual([{}]); + // The record schema requires `value`, so A3 refuses the emission. + const check = transformFromJson(contract, { profile: t2Profile(), surface: { messages } }); + expect(check.validation.gates.find((g) => g.name === "instance")?.pass).toBe(false); + }); + + it("derived coverage resolves both families through the collects", () => { + const out = (() => { + try { + return transformFromJson(contract, { profile: t2Profile() }); + } catch { + return undefined; + } + })(); + // The hermetic profile passes the contract gate (card + both T2 plans). + expect(out).toBeDefined(); + const unresolved = out!.mapping.coverage.filter( + (c) => c.disposition === "unclassified" && (c.id.startsWith("radio-group.") || c.id.startsWith("select.")), + ); + expect(unresolved).toEqual([]); + expect(out!.mapping.coverage.find((c) => c.id === "radio-group.radio-group-item")?.detail).toContain("collected"); + }); +}); diff --git a/src/targets/a2ui/surface.ts b/src/targets/a2ui/surface.ts index 5b94b38..7911b87 100644 --- a/src/targets/a2ui/surface.ts +++ b/src/targets/a2ui/surface.ts @@ -753,6 +753,56 @@ class SurfaceEmitter { ): void { void model; if (collect.as.kind === "inline") return; + + // T2 item-mode: each descendant matching `of` (document order) becomes + // one record whose fields resolve against THAT item's own node. Absent + // sources omit the field — gate A3 arbitrates required record shapes, + // never synthesis. Sibling content the collect does not claim follows + // the ordinary consumed-subtree accounting (closeCollects). + if (collect.fields) { + const records: Json[] = []; + const visitItems = (n: SurfaceNode, nPath: string): void => { + if (collect.of.includes(n.component)) { + const record: Json = {}; + for (const [fieldName, sel] of Object.entries(collect.fields!)) { + const value = + sel.kind === "self-text" ? n.text : sel.kind === "self-id" ? n.id : sel.kind === "self-prop" ? (n.props?.[sel.prop] as Json[keyof Json] | undefined) : undefined; + if (value !== undefined) record[fieldName] = value as Json[keyof Json]; + } + records.push(record); + this.diagnostics.pushFidelity( + { + source: `${nPath} (${n.component})`, + destination: collect.as.kind === "inline" ? "(inline)" : collect.as.name, + origin: collect.origin, + kind: "moved", + class: "maps-cleanly", + note: `item record { ${Object.keys(record).join(", ")} } collected`, + }, + treePath, + Band.BeforeChildren, + Phase.TableBody, + rule, + ); + return; // an item's subtree is the item's; nested items do not re-match + } + collectChildren(n).forEach((c, i) => visitItems(c.node, `${nPath}${c.suffix}[${i}]`)); + }; + collectChildren(node).forEach((c, i) => visitItems(c.node, `${path}${c.suffix}[${i}]`)); + if (records.length > 0) { + const landed = this.write(instance, collect.as.name, records as Json[keyof Json]); + if (!landed) { + this.diagnostics.pushFidelity( + { source: `${path} (${collect.of.join("|")} items)`, destination: "(discarded)", origin: collect.origin, kind: "dropped", class: "lossy", note: `'${collect.as.name}' was already written by an earlier phase; the collected records did not land` }, + treePath, + Band.BeforeChildren, + Phase.TableBody, + rule, + ); + } + } + return; + } const out: Json[] = []; const flat: string[] = []; const scalarField = Object.values(collect.item ?? {}).find((f) => isCollect(f) && f.scalar) as Collect | undefined; @@ -836,12 +886,15 @@ class SurfaceEmitter { if (claimedByCollect.has(c) || this.claimedByRoute(model, c)) continue; const childPath = `${path}${child.suffix}`; const reason = model.drops[c]; + const itemMode = model.collects.some((col) => col.fields); this.diagnostics.push( { code: "surface-sub-dropped", message: reason ? `${childPath}: '${c}' dropped: ${reason}.` - : `${childPath}: '${c}' has no slot in the synthesized table shape; dropped.`, + : itemMode + ? `${childPath}: '${c}' is not a collected item and has no disposition; dropped (sibling pairing is T3's declared join).` + : `${childPath}: '${c}' has no slot in the synthesized table shape; dropped.`, }, treePath, Band.BeforeChildren, @@ -862,10 +915,13 @@ class SurfaceEmitter { ); } + const itemsOnly = model.collects.every((col) => col.fields); this.diagnostics.push( { code: "surface-composition-flattened", - message: `${path}: compound '${node.component}' subtree consumed into the synthesized table shape (documented casualty; cell content beyond text is not carried).`, + message: itemsOnly + ? `${path}: compound '${node.component}' subtree consumed into collected item records (uncollected content is not carried).` + : `${path}: compound '${node.component}' subtree consumed into the synthesized table shape (documented casualty; cell content beyond text is not carried).`, }, treePath, Band.BeforeChildren, diff --git a/src/transform/model.ts b/src/transform/model.ts index 1d3174c..a86ff92 100644 --- a/src/transform/model.ts +++ b/src/transform/model.ts @@ -108,6 +108,12 @@ export type Selector = | { kind: "subtree-text"; subs: string[] } /** This node's ordered children, emitted as instances. */ | { kind: "children" } + /** + * T2: the node's own surface `id`. Item-local — only valid inside a + * collect's item fields, where each repeated item contributes the one + * datum it carries (the production radio-group-item carries nothing else). + */ + | { kind: "self-id" } /** No source: a declarative A2UI action synthesized from a deterministic slug. */ | { kind: "synthesized-action" }; @@ -182,6 +188,13 @@ export interface Route { export interface Collect { /** When this collect writes, relative to every other route and collect. */ order: WriteOrder; + /** + * T2 item-mode: each descendant matching `of` becomes ONE RECORD whose + * fields resolve against that item's own node (self.text, self.props.X, + * self.id — never joins, never siblings). Mutually exclusive with the + * table-mode `item`/`scalar` nesting. + */ + fields?: Record; /** * True when every repetition's cells concatenate into ONE flat list (v1's * header semantics: two header rows append into a single column list); @@ -266,6 +279,8 @@ export function describeSelector(s: Selector): string { return `sub(${s.subs.join("|")}).subtreeText`; case "children": return "children"; + case "self-id": + return "self.id"; case "synthesized-action": return "synthesized.action"; } diff --git a/src/transform/parse-v2.ts b/src/transform/parse-v2.ts index 2464b7e..abe55f1 100644 --- a/src/transform/parse-v2.ts +++ b/src/transform/parse-v2.ts @@ -65,18 +65,26 @@ export interface SurfaceV2Route { } export interface SurfaceV2Collect { - /** Sections: direct children that open the collection. */ + /** Sections (table-mode) or the repeated items themselves (item-mode). */ of: string[]; /** Array-valued destination (`prop:`). */ into: string; - /** `flat`: all rows concatenate into one list. `records`: one record per row. */ - shape: "flat" | "records"; - /** Record field carrying each row's cells; required when shape is `records`. */ + /** table-mode: `flat` concatenates all rows into one list; `records` keeps one record per row. */ + shape?: "flat" | "records"; + /** table-mode: record field carrying each row's cells; required when shape is `records`. */ field?: string; - /** Repetitions inside a section. */ - row: string[]; - /** Cells inside a repetition, flattened to their subtree text. */ - cells: string[]; + /** table-mode: repetitions inside a section. */ + row?: string[]; + /** table-mode: cells inside a repetition, flattened to their subtree text. */ + cells?: string[]; + /** + * T2 item-mode: each matching descendant becomes one record; every field + * resolves against THAT item's own node. Selectors: `self.text`, + * `self.props.`, `self.id`. No joins, no sibling reads — the + * htmlFor-paired sibling label the production radio-group carries is T3's + * declared join, measured and out of scope here. + */ + item?: Record; } export interface SurfaceV2Donate { @@ -112,7 +120,7 @@ export interface SurfaceV2 { const ID = "[a-z][a-z0-9-]*"; const PROP = "[A-Za-z][A-Za-z0-9]*"; const SELECTOR = new RegExp( - `^(self\\.text|self\\.props\\.(${PROP})|sub\\((${ID}(?:\\|${ID})*)\\)\\.(text|label|firstText|subtreeText)|children|synthesized\\.action)$`, + `^(self\\.text|self\\.id|self\\.props\\.(${PROP})|sub\\((${ID}(?:\\|${ID})*)\\)\\.(text|label|firstText|subtreeText)|children|synthesized\\.action)$`, ); const DESTINATION = new RegExp(`^(prop|textChild|slot|slots|action):(${PROP})$`); @@ -120,6 +128,7 @@ export function parseSelector(raw: string): Selector | null { const m = SELECTOR.exec(raw); if (!m) return null; if (m[1] === "self.text") return { kind: "self-text" }; + if (m[1] === "self.id") return { kind: "self-id" }; if (m[1] === "children") return { kind: "children" }; if (m[1] === "synthesized.action") return { kind: "synthesized-action" }; if (m[2]) return { kind: "self-prop", prop: m[2] }; @@ -171,6 +180,8 @@ function orderOfSelector(s: Selector, to: Destination): WriteOrder { return WriteOrder.CollectLead; case "self-text": return to.kind === "text-child" ? WriteOrder.SelfTextChild : WriteOrder.SelfText; + case "self-id": + return WriteOrder.SelfText; // unreachable in routes: `compatible` refuses it case "synthesized-action": return WriteOrder.Action; case "children": @@ -187,6 +198,8 @@ function compatible(s: Selector, to: Destination): boolean { return to.kind === "action"; case "self-text": return to.kind === "prop" || to.kind === "text-child"; + case "self-id": + return false; // item-field-only: a route may not read self.id default: return to.kind === "prop"; } @@ -385,6 +398,50 @@ export function parseSurfaceV2(surface: SurfaceV2, plan: ComponentPlan, path: st if (!declared.has(into.name)) { issues.push({ path: `${at}/into`, message: `destination '${into.name}' is not declared by this plan` }); } + + // T2 item-mode and table-mode are distinct shapes; mixing them is a + // contradiction, not a preference. + const itemMode = c.item !== undefined; + const tableKeys = [c.shape, c.field, c.row, c.cells].some((v) => v !== undefined); + if (itemMode && tableKeys) { + issues.push({ path: at, message: "a collect is either item-mode (item) or table-mode (shape/row/cells/field), never both" }); + continue; + } + if (itemMode) { + const fields: Record = {}; + let ok = true; + for (const [fieldName, raw] of Object.entries(c.item!)) { + const sel = parseSelector(raw); + const itemLocal = sel && (sel.kind === "self-text" || sel.kind === "self-prop" || sel.kind === "self-id"); + if (!itemLocal) { + issues.push({ + path: `${at}/item/${fieldName}`, + message: `'${raw}' is not an item field source (item fields resolve on the item's own node: self.text, self.props., self.id — sibling reads and joins are T3)`, + }); + ok = false; + continue; + } + fields[fieldName] = sel; + } + if (!ok || Object.keys(fields).length === 0) { + if (Object.keys(c.item!).length === 0) issues.push({ path: `${at}/item`, message: "an item-mode collect names at least one field" }); + continue; + } + claim(into.name, WriteOrder.Collect, `collects/${i}`, `${at}/into`); + model.collects.push({ + order: WriteOrder.Collect, + flatten: false, + of: c.of, + as: into, + fields, + origin: `v2:collects/${i}`, + }); + continue; + } + if (c.shape === undefined || c.row === undefined || c.cells === undefined) { + issues.push({ path: at, message: "a table-mode collect declares shape, row, and cells (or use item-mode with `item`)" }); + continue; + } if (c.shape === "records" && !c.field) { issues.push({ path: `${at}/field`, message: "a records collect names the field its rows' cells land in" }); continue; diff --git a/src/transform/profile-schema-v2.ts b/src/transform/profile-schema-v2.ts index 70688bf..c092dc4 100644 --- a/src/transform/profile-schema-v2.ts +++ b/src/transform/profile-schema-v2.ts @@ -370,55 +370,94 @@ export const profileSchemaV2: Record = { "collects": { "type": "array", "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "of", - "into", - "shape", - "row", - "cells" - ], - "properties": { - "of": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - } - }, - "into": { - "type": "string", - "pattern": "^prop:[A-Za-z][A-Za-z0-9]*$" - }, - "shape": { - "enum": [ - "flat", - "records" - ] - }, - "field": { - "type": "string", - "pattern": "^[A-Za-z][A-Za-z0-9]*$" - }, - "row": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "of", + "into", + "item" + ], + "properties": { + "of": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, + "into": { + "type": "string", + "pattern": "^prop:[A-Za-z][A-Za-z0-9]*$" + }, + "item": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Za-z][A-Za-z0-9]*$" + }, + "additionalProperties": { + "type": "string", + "pattern": "^(self\\.text|self\\.id|self\\.props\\.[A-Za-z][A-Za-z0-9]*)$" + }, + "description": "T2 item-mode: one record per matching descendant; fields resolve on the item's own node (self.text, self.props., self.id). Joins and sibling reads are T3." + } } }, - "cells": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" + { + "type": "object", + "additionalProperties": false, + "required": [ + "of", + "into", + "shape", + "row", + "cells" + ], + "properties": { + "of": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, + "into": { + "type": "string", + "pattern": "^prop:[A-Za-z][A-Za-z0-9]*$" + }, + "shape": { + "enum": [ + "flat", + "records" + ] + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]*$" + }, + "row": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, + "cells": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + } } } - } + ] } }, "subs": { diff --git a/src/transform/profile.v2.schema.json b/src/transform/profile.v2.schema.json index f6b0a65..45fadaa 100644 --- a/src/transform/profile.v2.schema.json +++ b/src/transform/profile.v2.schema.json @@ -362,55 +362,94 @@ "collects": { "type": "array", "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "of", - "into", - "shape", - "row", - "cells" - ], - "properties": { - "of": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - } - }, - "into": { - "type": "string", - "pattern": "^prop:[A-Za-z][A-Za-z0-9]*$" - }, - "shape": { - "enum": [ - "flat", - "records" - ] - }, - "field": { - "type": "string", - "pattern": "^[A-Za-z][A-Za-z0-9]*$" - }, - "row": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "of", + "into", + "item" + ], + "properties": { + "of": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, + "into": { + "type": "string", + "pattern": "^prop:[A-Za-z][A-Za-z0-9]*$" + }, + "item": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Za-z][A-Za-z0-9]*$" + }, + "additionalProperties": { + "type": "string", + "pattern": "^(self\\.text|self\\.id|self\\.props\\.[A-Za-z][A-Za-z0-9]*)$" + }, + "description": "T2 item-mode: one record per matching descendant; fields resolve on the item's own node (self.text, self.props., self.id). Joins and sibling reads are T3." + } } }, - "cells": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" + { + "type": "object", + "additionalProperties": false, + "required": [ + "of", + "into", + "shape", + "row", + "cells" + ], + "properties": { + "of": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, + "into": { + "type": "string", + "pattern": "^prop:[A-Za-z][A-Za-z0-9]*$" + }, + "shape": { + "enum": [ + "flat", + "records" + ] + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]*$" + }, + "row": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, + "cells": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + } } } - } + ] } }, "subs": { diff --git a/src/transform/shadcn-v2-respelling.ts b/src/transform/shadcn-v2-respelling.ts index 83e30f2..2acb6d2 100644 --- a/src/transform/shadcn-v2-respelling.ts +++ b/src/transform/shadcn-v2-respelling.ts @@ -79,3 +79,57 @@ export const SHADCN_V3_T1_SURFACES: Record = { }, }, }; + +/** + * T2 resolutions for the production v3 contract — item-mode collection + * spellings proven by src/t2.test.ts. `radio-group` collects what each item + * itself carries ({value: self.id}); its htmlFor-joined sibling labels are + * the measured T3 frontier, dropped with record until declared joins land. + * `select` is the pure homogeneous shape ({label: self.text}). + * + * Each entry carries the full receiving plan (structural options schema) — + * unlike T1's transparent plans, a collecting plan IS a catalog component. + */ +export const SHADCN_V3_T2_PLANS: Record> = { + "radio-group": { + a2ui: "RadioGroup", + dspackId: "radio-group", + commons: ["ComponentCommon"], + structural: { + options: { + schema: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" } }, required: ["value"], additionalProperties: false } }, + description: "The selectable options, one record per item.", + synthNote: "A2UI models repeated options as data on the group; dspack models them as repeated sub-components (T2 item-mode collection).", + }, + }, + propMap: { + name: { a2ui: "name", kind: "string" }, + defaultValue: { a2ui: "defaultValue", kind: "string" }, + }, + required: ["options"], + surface: { collects: [{ of: ["radio-group-item"], into: "prop:options", item: { value: "self.id" } }] }, + }, + select: { + a2ui: "Select", + dspackId: "select", + commons: ["ComponentCommon"], + structural: { + options: { + schema: { type: "array", items: { type: "object", properties: { label: { type: "string" } }, required: ["label"], additionalProperties: false } }, + description: "The selectable options.", + synthNote: "A2UI models repeated options as data on the control (T2 item-mode collection).", + }, + }, + propMap: { defaultValue: { a2ui: "defaultValue", kind: "string" } }, + required: ["options"], + surface: { + collects: [{ of: ["select-item"], into: "prop:options", item: { label: "self.text" } }], + subs: { + "select-trigger": { drop: "the trigger renders from the bound value, not authored content" }, + "select-value": { drop: "the visible value is runtime state derived from the selection" }, + "select-label": { drop: "group headings are not carried by the flat options shape" }, + "select-separator": { drop: "visual grouping chrome with no data meaning" }, + }, + }, + }, +};