From 8bb7cab8bb0ba93d453a77978347589318184736 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Tue, 11 Aug 2026 05:32:59 -0400 Subject: [PATCH 1/2] =?UTF-8?q?test:=20P3a=20fail-first=20=E2=80=94=20comp?= =?UTF-8?q?osition=20notes=20in=20the=20prompt,=20join-id-required=20schem?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence-tied to the Gateway corpus Phase-2 baseline: every field-donation failure used the un-prescribed field-label/field-content idiom while the contract's own composition.notes state the correct nesting verbatim and never reach the prompt; every join failure omitted or prefix-mismatched ids that the schema leaves optional while the profile's collects join on self.id. Failing run on pre-change code: × composition notes reach the generation system prompt (P3a) > carries the noted component's first two sentences on its vocabulary line 6ms × composition notes reach the generation system prompt (P3a) > hard ceiling: a single giant sentence is word-truncated near 360 chars with an ellipsis 1ms × join-participating sub-components require id in the generation schema (P3a) > joinIdComponents derives exactly the self.id-keyed participants 4ms × join-participating sub-components require id in the generation schema (P3a) > requireJoinIds makes id required on every unroll level for participants, and only them 1ms × join-participating sub-components require id in the generation schema (P3a) > without a profile the schema is returned unchanged by reference 0ms × join-participating sub-components require id in the generation schema (P3a) > the pipeline hands adapters the tightened schema when an emit profile is active 7ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯ Tests 6 failed | 2 passed (8) Co-Authored-By: Claude Fable 5 --- src/core/composition-notes.test.ts | 79 ++++++++++++ src/run/join-id-view.test.ts | 187 +++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 src/core/composition-notes.test.ts create mode 100644 src/run/join-id-view.test.ts diff --git a/src/core/composition-notes.test.ts b/src/core/composition-notes.test.ts new file mode 100644 index 0000000..98946ff --- /dev/null +++ b/src/core/composition-notes.test.ts @@ -0,0 +1,79 @@ +/** + * P3a fail-first: the system prompt must carry each component's authored + * composition semantics (contract `composition.notes`, capped), because the + * Gateway corpus showed the model composing compounds "reasonably but + * emit-fatally" precisely where the vocabulary line lists sub-components + * without their nesting semantics. The contract already states the correct + * idioms ("inside a Field: FieldLabel, the control, …"; "Every TabsTrigger + * value must match exactly one TabsContent value") — generation just never + * saw them. Notes are capped at two sentences / 360 chars so the addition + * removes ambiguity without prompt bloat. + */ +import { describe, expect, it } from "vitest"; +import { compileContext } from "./compiler.js"; +import type { Contract } from "./contract.js"; + +const LONG_NOTES = + "Inside a Wrapper place the WrapperLabel, then the control, then the WrapperHint — in that reading order. " + + "A Wrapper holds exactly one control and never nests another Wrapper. " + + "This third sentence is deliberate overflow that the cap must exclude from the prompt."; + +const contract: Contract = { + dspack: "0.4", + name: "notes-kit", + version: "1.0.0", + components: { + wrapper: { + description: "A labeled wrapper for one control.", + props: {}, + composition: { + subComponents: [{ id: "wrapper-label" }, { id: "wrapper-hint" }], + notes: LONG_NOTES, + }, + }, + plain: { + description: "A plain component with no composition knowledge.", + props: {}, + }, + }, + intents: [{ id: "demo", description: "Demo intent." }], + rules: [], + examples: [], +} as unknown as Contract; + +describe("composition notes reach the generation system prompt (P3a)", () => { + const system = compileContext(contract, "demo").system; + + it("carries the noted component's first two sentences on its vocabulary line", () => { + expect(system).toContain("Composition: Inside a Wrapper place the WrapperLabel, then the control"); + expect(system).toContain("never nests another Wrapper."); + }); + + it("caps at two sentences — overflow prose never reaches the prompt", () => { + expect(system).not.toContain("deliberate overflow"); + }); + + it("components without notes gain nothing", () => { + const plainLine = system.split("\n").find((l) => l.startsWith("- plain")); + expect(plainLine).toBeDefined(); + expect(plainLine).not.toContain("Composition:"); + }); + + it("hard ceiling: a single giant sentence is word-truncated near 360 chars with an ellipsis", () => { + const giant = { + ...contract, + components: { + big: { + description: "Big.", + props: {}, + composition: { notes: `${"alpha bravo charlie delta ".repeat(30)}end.` }, + }, + }, + } as unknown as Contract; + const sys = compileContext(giant, "demo").system; + const line = sys.split("\n").find((l) => l.startsWith("- big"))!; + const notes = line.slice(line.indexOf("Composition:")); + expect(notes.length).toBeLessThanOrEqual(400); + expect(notes).toMatch(/…$/); + }); +}); diff --git a/src/run/join-id-view.test.ts b/src/run/join-id-view.test.ts new file mode 100644 index 0000000..4be797e --- /dev/null +++ b/src/run/join-id-view.test.ts @@ -0,0 +1,187 @@ +/** + * P3a fail-first: sub-components that participate in a profile-declared + * Collect JOIN must be REQUIRED to carry `id` in the generation schema. + * Evidence (Gateway corpus, Phase-2 baseline): keyless `tabs-trigger` / + * `radio-group-item` and prefix-mismatched trigger/content pairs are the + * dominant join failure — ids are schema-optional today, so the model omits + * or free-styles them and the emitter's join refuses after generation ends. + * Profile-derived (the run layer owns profile knowledge — core stays + * profile-free, same layering as casualtyFreeView). + */ +import { describe, expect, it } from "vitest"; +import { loadProfile } from "@aestheticfunction/dspack-emit"; +import { buildGenerationSchema } from "../core/generation-schema.js"; +import { compileContext } from "../core/compiler.js"; +import type { Contract } from "../core/contract.js"; +import { ScriptedAdapter } from "../adapters/fake.js"; +import { runPipeline } from "./orchestrator.js"; + +const contract: Contract = { + dspack: "0.4", + name: "join-kit", + version: "1.0.0", + components: { + panes: { + description: "A paned compound joined by id.", + props: {}, + composition: { + subComponents: [{ id: "mini-trigger" }, { id: "mini-content" }], + }, + }, + chooser: { + description: "A chooser whose items key labels by htmlFor.", + props: {}, + composition: { subComponents: [{ id: "mini-item" }] }, + }, + picker: { + description: "A picker whose items collect WITHOUT a join.", + props: {}, + composition: { subComponents: [{ id: "mini-option" }] }, + }, + "mini-label": { description: "Standalone label.", props: { htmlFor: { type: "string" } } }, + text: { description: "Text.", props: {} }, + }, + intents: [{ id: "demo", description: "Demo intent." }], + rules: [], + examples: [ + { + id: "ex.panes", + intent: "demo", + prompt: "panes", + surface: { + dspackSurface: "0.1", + system: "join-kit", + intent: "demo", + root: { component: "panes", id: "p", children: [] }, + }, + }, + ], +} as unknown as Contract; + +const profile = loadProfile({ + profileVersion: "2", + catalogTitle: "Join-kit profile", + catalogDescription: "Minimal v2 profile for join-id schema tests.", + catalogIdBase: "https://example.invalid/catalogs/join-kit", + instructions: "Demo.", + primaryColorToken: { category: "color", name: "primary" }, + components: [ + { + a2ui: "Panes", + dspackId: "panes", + commons: ["ComponentCommon"], + structural: { + sections: { + schema: { type: "array", items: { type: "object", properties: { title: { type: "string" }, value: { type: "string" }, child: { $ref: "#/$defs/ComponentId" } }, required: ["title"], additionalProperties: false } }, + description: "Paired trigger/panel records.", + synthNote: "Declared join keyed on ids.", + }, + }, + propMap: {}, + required: ["sections"], + surface: { + collects: [ + { + of: ["mini-trigger"], + into: "prop:sections", + item: { title: "self.text", value: "self.id" }, + join: { with: ["mini-content"], on: { left: "self.id", right: "self.id" }, fields: { child: "children" } }, + }, + ], + }, + }, + { + a2ui: "Chooser", + dspackId: "chooser", + commons: ["ComponentCommon"], + structural: { + options: { + schema: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" } }, required: ["value"], additionalProperties: false } }, + description: "Item records keyed by id, labels joined via htmlFor.", + synthNote: "Declared join; the with-side keys on htmlFor.", + }, + }, + propMap: {}, + required: ["options"], + surface: { + collects: [ + { + of: ["mini-item"], + into: "prop:options", + item: { value: "self.id" }, + join: { with: ["mini-label"], on: { left: "self.id", right: "self.props.htmlFor" }, fields: { label: "self.text" } }, + }, + ], + }, + }, + { + a2ui: "Picker", + dspackId: "picker", + commons: ["ComponentCommon"], + structural: { + options: { + schema: { type: "array", items: { type: "object", properties: { label: { type: "string" } }, additionalProperties: false } }, + description: "Collected option records (no join).", + synthNote: "Flat collect.", + }, + }, + propMap: {}, + required: ["options"], + surface: { + collects: [{ of: ["mini-option"], into: "prop:options", item: { label: "self.text" } }], + }, + }, + { a2ui: "Text", dspackId: "text", commons: ["ComponentCommon"], structural: {}, propMap: {}, required: [], surface: {} }, + ], + synthesized: [], + casualtyComponents: [], + surfaceSynthesis: { textComponent: "Text", textProp: "text", wrapComponent: "Column", wrapChildrenProp: "children" }, +}); + +function branchesFor(schema: Record, component: string): Array<{ required?: string[] }> { + const defs = (schema as { $defs?: Record }> }).$defs ?? {}; + const out: Array<{ required?: string[] }> = []; + for (const def of Object.values(defs)) { + for (const b of def.anyOf ?? []) if (b.properties?.component?.const === component) out.push(b); + } + return out; +} + +describe("join-participating sub-components require id in the generation schema (P3a)", () => { + it("joinIdComponents derives exactly the self.id-keyed participants", async () => { + const mod = await import("./join-id-view.js"); + const ids = mod.joinIdComponents(profile); + expect([...ids].sort()).toEqual(["mini-content", "mini-item", "mini-trigger"]); + }); + + it("requireJoinIds makes id required on every unroll level for participants, and only them", async () => { + const mod = await import("./join-id-view.js"); + const schema = buildGenerationSchema(contract, "demo"); + const tightened = mod.requireJoinIds(schema, profile) as Record; + for (const comp of ["mini-trigger", "mini-content", "mini-item"]) { + const branches = branchesFor(tightened, comp); + expect(branches.length).toBeGreaterThan(0); + for (const b of branches) expect(b.required).toContain("id"); + } + for (const comp of ["mini-option", "mini-label", "text"]) { + for (const b of branchesFor(tightened, comp)) expect(b.required ?? []).not.toContain("id"); + } + }); + + it("without a profile the schema is returned unchanged by reference", async () => { + const mod = await import("./join-id-view.js"); + const schema = buildGenerationSchema(contract, "demo"); + expect(mod.requireJoinIds(schema, undefined)).toBe(schema); + }); + + it("the pipeline hands adapters the tightened schema when an emit profile is active", async () => { + const surface = contract.examples![0]!.surface; + const adapter = new ScriptedAdapter([{ output: surface }]); + await runPipeline({ contract, intent: "demo", prompt: "panes", adapter, maxRepairs: 0, emitProfile: profile }).catch(() => undefined); + const seen = adapter.requests.at(0)?.jsonSchema as Record; + expect(seen).toBeDefined(); + const branches = branchesFor(seen, "mini-trigger"); + expect(branches.length).toBeGreaterThan(0); + for (const b of branches) expect(b.required).toContain("id"); + }); +}); From aaf9f95be2599451f28aaf5b349ce8216bea01e7 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Tue, 11 Aug 2026 05:41:02 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20P3a=20generation=20quality=20?= =?UTF-8?q?=E2=80=94=20composition=20notes=20in=20the=20prompt,=20join=20i?= =?UTF-8?q?ds=20required=20(0.5.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both mechanisms feed the model knowledge the stack already holds: - core/compiler.ts renders each component's own composition.notes (2-sentence/ 360-char cap) on its vocabulary line. The Gateway corpus showed every field donation failure using an idiom the contract's prose explicitly rules out ('inside a Field: FieldLabel, the control, …') and every tab key mismatch contradicting 'must match exactly one'. - run/join-id-view.ts derives the sub-components whose profile collect/join keys on self.id and requires `id` on their generation-schema branches at every unroll level. Generation-only, same layering as casualtyFreeView. Stack alignment: emit dep pinned to published 0.7.0; p05 eval scripts extended through the repair loop and the pipeline emitter-gate test now pins the 0.7 invariant (missing-required refuses at emission, terminal at zero budget, no validations array); compiler + eval goldens regenerated deliberately. 157/157 green. src/core changes: compiler.ts only (prompt rendering — ds-mcp inherits at its next deliberate re-pin). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++ eval/matrix.fake.json | 6 ++ .../context/shadcn.destructive-action.json | 2 +- fixtures/golden/eval/results.fake.json | 10 +-- package-lock.json | 12 +-- package.json | 4 +- src/core/compiler.ts | 23 ++++++ src/run/join-id-view.ts | 76 +++++++++++++++++++ src/run/orchestrator.ts | 7 +- src/run/pipeline.test.ts | 16 ++-- 10 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 src/run/join-id-view.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff671ac..f7b3aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.5.0 — 2026-08-11 + +Generation-quality release (P3a): the pipeline teaches the model what the emitter will demand, from knowledge the contract already carries. + +- **Composition notes reach the system prompt.** Each component vocabulary line now carries the contract's own `composition.notes` (capped at two sentences / 360 chars). Measured on the Gateway corpus: every field-donation failure and tab/radio key mismatch contradicted prose the contract already states verbatim — generation just never saw it. +- **Join-participating sub-components require `id` in the generation schema** (`run/join-id-view.ts`, profile-derived, generation-only — S-gates and the contract untouched). Keyless and prefix-mismatched join items were the dominant residual join failure. +- Dev/CI stack aligned to dspack-emit 0.7 (lockfile): the p05 eval cell and the pipeline emitter-gate test recalibrated to 0.7 semantics (missing-required-prop surfaces now REFUSE at emission and ride the repair loop; the eval golden regenerated accordingly — refusals terminate as failed-gate, never script-exhaustion errors). + # Changelog ## 0.4.0 diff --git a/eval/matrix.fake.json b/eval/matrix.fake.json index 97200de..82ac678 100644 --- a/eval/matrix.fake.json +++ b/eval/matrix.fake.json @@ -91,6 +91,12 @@ } ], "p05-s3-clean-gate-fail": [ + { + "fixture": "../fixtures/eval/text-placement-gate-fail.dsurface.json" + }, + { + "fixture": "../fixtures/eval/text-placement-gate-fail.dsurface.json" + }, { "fixture": "../fixtures/eval/text-placement-gate-fail.dsurface.json" } diff --git a/fixtures/golden/context/shadcn.destructive-action.json b/fixtures/golden/context/shadcn.destructive-action.json index eb088b3..07964f4 100644 --- a/fixtures/golden/context/shadcn.destructive-action.json +++ b/fixtures/golden/context/shadcn.destructive-action.json @@ -1,5 +1,5 @@ { - "system": "You generate user interface surfaces for the \"shadcn/ui\" design system. You must respond\nwith a single JSON object conforming to the provided schema — a dspack surface document.\n\n## Component vocabulary\nYou may use only these components (with the listed props and allowed values):\n- button — An interactive element that triggers an action when activated. Built on a native button element with support for Radix UI Slot composition via asChild. Props: variant ∈ {default, destructive, outline, secondary, ghost, link}; size ∈ {default, sm, lg, icon}; disabled; asChild.\n- alert-dialog — A modal dialog that interrupts the user with important content and expects a response. Built on Radix UI AlertDialog. Renders with a required action and a cancel option. The user cannot dismiss it by clicking the overlay or pressing Escape — they must choose an explicit action. Props: open; onOpenChange. Sub-components (used as children): alert-dialog-trigger, alert-dialog-content, alert-dialog-header, alert-dialog-title, alert-dialog-description, alert-dialog-footer, alert-dialog-action, alert-dialog-cancel.\n- dialog — A modal window that appears over the page content. Built on Radix UI Dialog. Can be dismissed by clicking the overlay, pressing Escape, or activating a close button. Props: open; onOpenChange; modal. Sub-components (used as children): dialog-trigger, dialog-content, dialog-header, dialog-title, dialog-description, dialog-footer, dialog-close.\n- card — A container for grouping related content and actions. Provides visual separation through a bordered surface. Props: className. Sub-components (used as children): card-header, card-title, card-description, card-content, card-footer.\n- input — A single-line text input field built on the native element. Supports all standard HTML input types. Props: type; placeholder; disabled.\n- badge — A small label for categorization, status indication, or metadata. Renders as an inline element. Props: variant ∈ {default, secondary, outline, destructive}.\n- dropdown-menu — A menu that appears on activation of a trigger element. Built on Radix UI DropdownMenu. Supports items, checkboxes, radio groups, sub-menus, separators, and keyboard navigation. Props: open; onOpenChange; modal. Sub-components (used as children): dropdown-menu-trigger, dropdown-menu-content, dropdown-menu-item, dropdown-menu-checkbox-item, dropdown-menu-radio-group, dropdown-menu-radio-item, dropdown-menu-label, dropdown-menu-separator, dropdown-menu-group, dropdown-menu-sub, dropdown-menu-sub-trigger, dropdown-menu-sub-content.\n- table — A set of primitives for presenting tabular data with a meaningful row-and-column relationship. These are thin, presentational wrappers over the native table elements — Table renders a inside a horizontally scrollable container, and the sub-components render thead, tbody, tfoot, tr, th, td, and caption. There is no built-in sorting, filtering, pagination, or selection; those are composed on top. Sub-components (used as children): table-header, table-body, table-footer, table-row, table-head, table-cell, table-caption.\n\n## Governance rules in effect (intent: destructive-action)\nThese are hard requirements. Surfaces violating them will be rejected:\n1. [rule.destructive-requires-alertdialog / must] Use alert-dialog for this surface; dialog is forbidden. Why: Dialog can be dismissed by clicking the overlay or pressing Escape, so a user can bypass a destructive confirmation without making a conscious choice. AlertDialog forces an explicit confirm/cancel decision and is announced with greater urgency by screen readers.\n2. [rule.alertdialog-requires-cancel / must] Every alert-dialog must contain alert-dialog-cancel and alert-dialog-title. Why: A confirmation without an explicit cancel action and a title naming the consequence funnels the user toward the destructive action; the title is also required for aria-labelledby.\n3. [rule.button-no-interactive-descendants / must] Never place button or input inside a button. Why: Nested interactive elements create ambiguous click targets and are an accessibility violation: screen readers cannot determine intent and click handling varies across browsers.\n4. [rule.trigger-carries-label / must] Every alert-dialog-trigger must contain non-empty text (its own `text` field or a descendant's). Why: The trigger must present an accessible label: non-empty text somewhere under the trigger. Protocol projections lift the label from the trigger's subtree (preferring a label-bearing button; lifts are audited) — a trigger with no label text anywhere yields a control with no accessible name and an instance downstream emitters must refuse.\n5. [rule.alertdialog-no-nested-overlays / must] never place overlay-category components (alert-dialog, dialog, dropdown-menu) inside alert-dialog. Why: An alert dialog is a single focused interruption. Stacking another overlay (dialog, dropdown menu, another alert dialog) inside it breaks focus containment and dismiss semantics and buries the confirmation decision under a second layer.\n\n## Design intent\nIntent \"destructive-action\": The requested UI performs an irreversible or high-consequence operation: deleting records or accounts, revoking access, removing members.\nRelated pattern \"Destructive Action Confirmation\": Use AlertDialog, not Dialog, for destructive confirmations. The trigger should clearly indicate the destructive nature of the action. Inside the AlertDialog, provide a clear title stating what will happen, a description of the consequences, and two actions: a cancel option and a confirm option. The confirm button MUST use the destructive variant. Place the cancel action before the confirm action in the footer. The description should state specifically what will be affected (e.g., 'This will permanently delete 3 projects and all associated data').\n\nOutput only the JSON object. No commentary.", + "system": "You generate user interface surfaces for the \"shadcn/ui\" design system. You must respond\nwith a single JSON object conforming to the provided schema — a dspack surface document.\n\n## Component vocabulary\nYou may use only these components (with the listed props and allowed values):\n- button — An interactive element that triggers an action when activated. Built on a native button element with support for Radix UI Slot composition via asChild. Props: variant ∈ {default, destructive, outline, secondary, ghost, link}; size ∈ {default, sm, lg, icon}; disabled; asChild.\n- alert-dialog — A modal dialog that interrupts the user with important content and expects a response. Built on Radix UI AlertDialog. Renders with a required action and a cancel option. The user cannot dismiss it by clicking the overlay or pressing Escape — they must choose an explicit action. Props: open; onOpenChange. Sub-components (used as children): alert-dialog-trigger, alert-dialog-content, alert-dialog-header, alert-dialog-title, alert-dialog-description, alert-dialog-footer, alert-dialog-action, alert-dialog-cancel. Composition: AlertDialogContent must contain AlertDialogTitle and AlertDialogDescription for accessibility. AlertDialogAction and AlertDialogCancel must appear in AlertDialogFooter.\n- dialog — A modal window that appears over the page content. Built on Radix UI Dialog. Can be dismissed by clicking the overlay, pressing Escape, or activating a close button. Props: open; onOpenChange; modal. Sub-components (used as children): dialog-trigger, dialog-content, dialog-header, dialog-title, dialog-description, dialog-footer, dialog-close.\n- card — A container for grouping related content and actions. Provides visual separation through a bordered surface. Props: className. Sub-components (used as children): card-header, card-title, card-description, card-content, card-footer.\n- input — A single-line text input field built on the native element. Supports all standard HTML input types. Props: type; placeholder; disabled.\n- badge — A small label for categorization, status indication, or metadata. Renders as an inline element. Props: variant ∈ {default, secondary, outline, destructive}.\n- dropdown-menu — A menu that appears on activation of a trigger element. Built on Radix UI DropdownMenu. Supports items, checkboxes, radio groups, sub-menus, separators, and keyboard navigation. Props: open; onOpenChange; modal. Sub-components (used as children): dropdown-menu-trigger, dropdown-menu-content, dropdown-menu-item, dropdown-menu-checkbox-item, dropdown-menu-radio-group, dropdown-menu-radio-item, dropdown-menu-label, dropdown-menu-separator, dropdown-menu-group, dropdown-menu-sub, dropdown-menu-sub-trigger, dropdown-menu-sub-content.\n- table — A set of primitives for presenting tabular data with a meaningful row-and-column relationship. These are thin, presentational wrappers over the native table elements — Table renders a
inside a horizontally scrollable container, and the sub-components render thead, tbody, tfoot, tr, th, td, and caption. There is no built-in sorting, filtering, pagination, or selection; those are composed on top. Sub-components (used as children): table-header, table-body, table-footer, table-row, table-head, table-cell, table-caption. Composition: TableRow must appear inside TableHeader, TableBody, or TableFooter — not directly under Table. Header cells use TableHead (th); data cells use TableCell (td).\n\n## Governance rules in effect (intent: destructive-action)\nThese are hard requirements. Surfaces violating them will be rejected:\n1. [rule.destructive-requires-alertdialog / must] Use alert-dialog for this surface; dialog is forbidden. Why: Dialog can be dismissed by clicking the overlay or pressing Escape, so a user can bypass a destructive confirmation without making a conscious choice. AlertDialog forces an explicit confirm/cancel decision and is announced with greater urgency by screen readers.\n2. [rule.alertdialog-requires-cancel / must] Every alert-dialog must contain alert-dialog-cancel and alert-dialog-title. Why: A confirmation without an explicit cancel action and a title naming the consequence funnels the user toward the destructive action; the title is also required for aria-labelledby.\n3. [rule.button-no-interactive-descendants / must] Never place button or input inside a button. Why: Nested interactive elements create ambiguous click targets and are an accessibility violation: screen readers cannot determine intent and click handling varies across browsers.\n4. [rule.trigger-carries-label / must] Every alert-dialog-trigger must contain non-empty text (its own `text` field or a descendant's). Why: The trigger must present an accessible label: non-empty text somewhere under the trigger. Protocol projections lift the label from the trigger's subtree (preferring a label-bearing button; lifts are audited) — a trigger with no label text anywhere yields a control with no accessible name and an instance downstream emitters must refuse.\n5. [rule.alertdialog-no-nested-overlays / must] never place overlay-category components (alert-dialog, dialog, dropdown-menu) inside alert-dialog. Why: An alert dialog is a single focused interruption. Stacking another overlay (dialog, dropdown menu, another alert dialog) inside it breaks focus containment and dismiss semantics and buries the confirmation decision under a second layer.\n\n## Design intent\nIntent \"destructive-action\": The requested UI performs an irreversible or high-consequence operation: deleting records or accounts, revoking access, removing members.\nRelated pattern \"Destructive Action Confirmation\": Use AlertDialog, not Dialog, for destructive confirmations. The trigger should clearly indicate the destructive nature of the action. Inside the AlertDialog, provide a clear title stating what will happen, a description of the consequences, and two actions: a cancel option and a confirm option. The confirm button MUST use the destructive variant. Place the cancel action before the confirm action in the footer. The description should state specifically what will be affected (e.g., 'This will permanently delete 3 projects and all associated data').\n\nOutput only the JSON object. No commentary.", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/fixtures/golden/eval/results.fake.json b/fixtures/golden/eval/results.fake.json index 0c153e0..51c7fd0 100644 --- a/fixtures/golden/eval/results.fake.json +++ b/fixtures/golden/eval/results.fake.json @@ -1,5 +1,5 @@ { - "matrixSha256": "f2aed86eeed5acd8f862fa7c4eeaa29f1a61480189a04b52e81cef65337e30cf", + "matrixSha256": "4b042d8b0e04dca3fdb61246b72172e02178e6f5d3acbfe66f81c2e8a3c85e7e", "contract": { "name": "shadcn/ui", "dspack": "0.4", @@ -374,7 +374,7 @@ "run": 1, "outcome": "failed-gate", "exitCode": 3, - "attempts": 1, + "attempts": 3, "firstAttemptSchemaValid": true, "firstAttemptViolated": false, "firstAttemptRuleIds": [], @@ -385,7 +385,7 @@ "run": 2, "outcome": "failed-gate", "exitCode": 3, - "attempts": 1, + "attempts": 3, "firstAttemptSchemaValid": true, "firstAttemptViolated": false, "firstAttemptRuleIds": [], @@ -415,7 +415,7 @@ "run": 1, "outcome": "failed-gate", "exitCode": 3, - "attempts": 1, + "attempts": 3, "firstAttemptSchemaValid": true, "firstAttemptViolated": false, "firstAttemptRuleIds": [], @@ -426,7 +426,7 @@ "run": 2, "outcome": "failed-gate", "exitCode": 3, - "attempts": 1, + "attempts": 3, "firstAttemptSchemaValid": true, "firstAttemptViolated": false, "firstAttemptRuleIds": [], diff --git a/package-lock.json b/package-lock.json index d31b667..a48c5f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@aestheticfunction/dspack-gen", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aestheticfunction/dspack-gen", - "version": "0.4.0", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { - "@aestheticfunction/dspack-emit": "^0.3.1 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0", + "@aestheticfunction/dspack-emit": "^0.7.0", "@anthropic-ai/sdk": "^0.109.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -30,9 +30,9 @@ } }, "node_modules/@aestheticfunction/dspack-emit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@aestheticfunction/dspack-emit/-/dspack-emit-0.6.0.tgz", - "integrity": "sha512-tgAQy6XPUf7R+YMco8ZYqkP3xMrxzgE8BjgmW5kIpF1n31bQRbr0WjFRJiPEYMWbjrcsTVxme34uBbg3VCgwnw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@aestheticfunction/dspack-emit/-/dspack-emit-0.7.0.tgz", + "integrity": "sha512-uD61qcAo/kNeYP5OMVKDfV6gI79cP9HNz4B0aQJ5cSDcsMdDDT2GZv8syLFeYfYH9ED14ZbC2i7p52S03NJeDw==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 033bde9..7bae6e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aestheticfunction/dspack-gen", - "version": "0.4.0", + "version": "0.5.0", "description": "Generation + governance pipeline for dspack contracts: prompt/context compiler, surface gates S1–S3, bounded repair, protocol emission, audit reports.", "type": "module", "license": "Apache-2.0", @@ -66,7 +66,7 @@ "test:pack": "bash scripts/pack-test.sh" }, "dependencies": { - "@aestheticfunction/dspack-emit": "^0.3.1 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0", + "@aestheticfunction/dspack-emit": "^0.7.0", "@anthropic-ai/sdk": "^0.109.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", diff --git a/src/core/compiler.ts b/src/core/compiler.ts index ffe8505..515d053 100644 --- a/src/core/compiler.ts +++ b/src/core/compiler.ts @@ -67,6 +67,21 @@ export function compileContext( }; } +/** + * First two sentences of a component's composition notes, word-truncated at + * 360 chars. Two sentences because the audited contracts state placement in + * the first and pairing/exclusion rules in the second (tabs' exact-match + * rule, radio's htmlFor pairing); the ceiling keeps a runaway sentence from + * bloating the vocabulary block. + */ +function capCompositionNotes(notes: unknown): string | null { + if (typeof notes !== "string" || !notes.trim()) return null; + const twoSentences = notes.match(/^(?:[^.!?]*[.!?]){1,2}/); + let out = (twoSentences ? twoSentences[0] : notes).trim(); + if (out.length > 360) out = `${out.slice(0, 357).replace(/\s+\S*$/, "")}…`; + return out; +} + function fewshotPair(example: ExampleEntry): FewshotMessage[] { return [ { role: "user", content: example.prompt ?? example.description ?? example.id }, @@ -99,6 +114,14 @@ function renderSystemPrompt( let line = `- ${id} — ${component.description}`; if (props) line += ` Props: ${props}.`; if (subs) line += ` Sub-components (used as children): ${subs}.`; + // P3a: the contract's own composition semantics ride the vocabulary line. + // A sub-component LIST without its nesting rules invites plausible-but- + // unprojectable compositions (measured on the Gateway corpus: every field + // donation failure and join key mismatch contradicted prose the contract + // already carries). Capped at two sentences / 360 chars — ambiguity + // removal, not prompt growth. + const notes = capCompositionNotes(component.composition?.notes); + if (notes) line += ` Composition: ${notes}`; lines.push(line); } diff --git a/src/run/join-id-view.ts b/src/run/join-id-view.ts new file mode 100644 index 0000000..04408a2 --- /dev/null +++ b/src/run/join-id-view.ts @@ -0,0 +1,76 @@ +/** + * Join-id schema view (P3a generation-quality mechanism, sibling of + * casualtyFreeView). + * + * A profile-declared Collect JOIN pairs sub-component families by id + * (`on: {left: self.id, right: self.id}` — tabs-trigger/tabs-content — or an + * item id referenced by a counterpart key). The generation schema leaves `id` + * optional on every node, so a model can omit the keys (keyless items) or + * free-style a prefix convention (`trigger-x` / `content-x`) that the exact- + * match join must refuse — both measured as the dominant join failure on the + * Gateway corpus after Phase 2. This view makes `id` REQUIRED in the + * generation schema for exactly the components whose join participation keys + * on `self.id`, derived from the active profile. + * + * Run-layer on purpose: core stays profile-free. Only GENERATION sees the + * tightened schema — S-gates and the contract are untouched (requiring a key + * the emitter will demand is ambiguity removal, not governance change). + */ +import type { Profile } from "@aestheticfunction/dspack-emit"; + +type Json = Record; + +/** Component names whose collect/join participation keys on `self.id`. */ +export function joinIdComponents(profile?: Profile): Set { + const out = new Set(); + if (!profile) return out; + const components = (profile as unknown as { components?: Array }).components ?? []; + for (const plan of components) { + const surface = (plan.surface ?? {}) as Json; + const collects = (surface.collects ?? []) as Array; + for (const collect of collects) { + const join = collect.join as Json | undefined; + if (!join) continue; + const on = (join.on ?? {}) as { left?: string; right?: string }; + const item = (collect.item ?? {}) as Record; + const itemKeysOnId = on.left === "self.id" || Object.values(item).includes("self.id"); + if (itemKeysOnId) for (const name of (collect.of ?? []) as string[]) out.add(name); + if (on.right === "self.id") for (const name of (join.with ?? []) as string[]) out.add(name); + } + } + return out; +} + +/** + * The generation schema with `id` required on every unroll level's branch for + * join-participating components. Returns the SAME schema object when the + * profile declares no id-keyed joins (identity — digests and callers relying + * on reference equality are unaffected). + */ +export function requireJoinIds(schema: Json, profile?: Profile): Json { + const idComponents = joinIdComponents(profile); + if (idComponents.size === 0) return schema; + + const defs = schema.$defs as Record | undefined; + if (!defs) return schema; + + let changed = false; + const nextDefs: Record = {}; + for (const [name, def] of Object.entries(defs)) { + const anyOf = def.anyOf as Array | undefined; + if (!anyOf) { + nextDefs[name] = def; + continue; + } + const nextAnyOf = anyOf.map((branch) => { + const componentConst = ((branch.properties as Json | undefined)?.component as Json | undefined)?.const; + if (typeof componentConst !== "string" || !idComponents.has(componentConst)) return branch; + const required = (branch.required as string[] | undefined) ?? []; + if (required.includes("id")) return branch; + changed = true; + return { ...branch, required: [...required, "id"] }; + }); + nextDefs[name] = nextAnyOf.some((b, i) => b !== anyOf[i]) ? { ...def, anyOf: nextAnyOf } : def; + } + return changed ? { ...schema, $defs: nextDefs } : schema; +} diff --git a/src/run/orchestrator.ts b/src/run/orchestrator.ts index f4b7b7f..de0884a 100644 --- a/src/run/orchestrator.ts +++ b/src/run/orchestrator.ts @@ -31,6 +31,7 @@ import { import type { Contract } from "../core/contract.js"; import { applicableRules, compileContext, type CompileOptions } from "../core/compiler.js"; import { casualtyFreeView } from "./casualty-view.js"; +import { requireJoinIds } from "./join-id-view.js"; import { lintSurface, type Finding, type GateReport } from "../core/lint/index.js"; import { AdapterOutputError, type GenerateMessage, type GenerationAdapter } from "../adapters/types.js"; import { renderRepairMessage, type RepairTemplate } from "../repair/render.js"; @@ -165,7 +166,11 @@ export async function runPipeline(options: RunOptions): Promise { // and so does contractDigest (report identity must not vary with the // emit profile). const generationContract = casualtyFreeView(contract, options.emitProfile); - const context = compileContext(generationContract, intent, options.compile); + const compiled = compileContext(generationContract, intent, options.compile); + // P3a: sub-components the profile joins by id must CARRY an id — the schema + // requires it for exactly those components (join-id-view.ts). Same layering + // as the casualty view: generation-only, profile-derived, gates untouched. + const context = { ...compiled, schema: requireJoinIds(compiled.schema as Record, options.emitProfile) }; const conversation: GenerateMessage[] = [ ...context.fewshot, ...(options.conversation ?? []), diff --git a/src/run/pipeline.test.ts b/src/run/pipeline.test.ts index d943fb2..0a95919 100644 --- a/src/run/pipeline.test.ts +++ b/src/run/pipeline.test.ts @@ -120,9 +120,14 @@ describe("failure paths are first-class artifacts", () => { expect(validateReport(JSON.parse(JSON.stringify(result.report)))).toBe(true); }); - it("emitter-gate failure: lint-clean surface that fails A3 → failed-gate, exit 3", async () => { + it("emitter-gate failure: lint-clean surface the catalog would reject → refusal rides the loop, terminal failed-gate exit 3", async () => { // Governed (alert-dialog present & complete) but the input node has no - // text, so the emitted TextField lacks its required label — A3 fails. + // text, so the emitted TextField would lack its required label. Under + // dspack-emit ≥0.7 that is REFUSED at emission (the emitter never ships + // an instance its own catalog rejects — the class that used to explode + // at A3 downstream), and since Phase 2 the refusal is a REPAIRABLE + // signal. With zero repair budget it stays exactly the old terminal + // shape: failed-gate, exit 3, refusal recorded, no validations array. const gateBreaker: Surface = { dspackSurface: "0.1", system: "shadcn/ui", @@ -136,11 +141,12 @@ describe("failure paths are first-class artifacts", () => { }, }; const adapter = new ScriptedAdapter([{ output: gateBreaker }]); - const result = await runPipeline({ ...baseOptions, adapter }); + const result = await runPipeline({ ...baseOptions, adapter, maxRepairs: 0 }); expect(result.report.outcome).toBe("failed-gate"); expect(result.exitCode).toBe(3); - const gates = result.report.emitted!.validations[0].gates; - expect(gates.find((g) => g.gate === "A3")!.pass).toBe(false); + expect(result.report.emitted!.refusal).toMatch(/would not validate against the emitted catalog/); + expect(result.report.attempts[0]!.representability).toEqual({ pass: false, refusal: result.report.emitted!.refusal }); + expect(result.report.emitted!.validations).toEqual([]); expect(validateReport(JSON.parse(JSON.stringify(result.report)))).toBe(true); });