diff --git a/package.json b/package.json index 8315f74..382e27a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aestheticfunction/dspack-gen", - "version": "0.2.2", + "version": "0.3.0", "description": "Generation + governance pipeline for dspack contracts: prompt/context compiler, surface gates S1\u2013S3, bounded repair, protocol emission, audit reports.", "type": "module", "license": "Apache-2.0", diff --git a/src/core/lint/containment.test.ts b/src/core/lint/containment.test.ts new file mode 100644 index 0000000..6f4ee92 --- /dev/null +++ b/src/core/lint/containment.test.ts @@ -0,0 +1,204 @@ +/** + * S2 sub-component containment (spec v0.4 §5.1, the 2026-08-07 amendment). + * + * The ratified invariant, verbatim: + * + * A component declared as a sub-component of a compound may appear only + * within the subtree of a valid owning compound, unless the contract + * explicitly declares that component as independently usable. + * + * Fail-first (captured before implementation): the four production shapes + * below — root-level `form-label`, `select-trigger` beside its `select`, + * bare `alert-dialog-content` duplicated next to a nested one, and the + * field-trio exemplar corruption (dspack#40) — all PASSED S2 as membership, + * were inexpressible in every S3 rule type, and died terminally at the + * emitter after the repair loop had already run (post-T3 Build matrix: + * 2/6 scenarios; probe: 19% of generated nodes). + * + * Ownership comes ONLY from composition.subComponents — never names, + * prefixes, adjacency, or examples. + */ +import { describe, expect, it } from "vitest"; +import { lintSurface } from "./index.js"; +import { renderRepairMessage } from "../../repair/render.js"; +import type { Contract } from "../contract.js"; +import type { Surface } from "../surface-schema.js"; + +/** Hermetic contract mirroring the four measured production shapes. */ +const contract: Contract = { + dspack: "0.4", + name: "containment-fixture", + description: "S2 containment pins", + version: "0.0.1", + components: { + card: { + name: "Card", description: "A container.", props: {}, + composition: { subComponents: [{ id: "card-header", description: "Header region." }] }, + }, + form: { + name: "Form", description: "A form.", props: {}, + composition: { subComponents: [{ id: "form-item", description: "One field.", slot: "body" }, { id: "form-label", description: "The field label.", acceptsChildren: "text" }] }, + }, + select: { + name: "Select", description: "Choose one.", props: {}, + composition: { subComponents: [{ id: "select-trigger", description: "Opens the listbox." }, { id: "select-item", description: "One option." }] }, + }, + "alert-dialog": { + name: "AlertDialog", description: "Confirm dialog.", props: {}, + composition: { subComponents: [{ id: "alert-dialog-content", description: "The dialog body." }] }, + }, + // The dspack#40 shape as currently (mis)declared: the wrapper trio as subs of field. + field: { + name: "Field", description: "One labelled field.", props: {}, + composition: { + subComponents: [ + { id: "field-set", description: "Groups controls answering one question." }, + { id: "field-legend", description: "Names a FieldSet." }, + { id: "field-group", description: "Stacks Fields." }, + { id: "field-label", description: "The label." }, + ], + }, + }, + input: { name: "Input", description: "Text entry.", props: {} }, + }, + intents: [{ id: "data-entry", name: "Data entry", description: "Collect input." }], +} as unknown as Contract; + +/** The corrected dspack#40 model: the trio promoted per the real shadcn API. */ +const corrected: Contract = (() => { + const c = structuredClone(contract) as unknown as { + components: Record } }>; + }; + c.components.field.composition = { subComponents: [{ id: "field-label", description: "The label." }] }; + c.components["field-set"] = { + composition: { subComponents: [{ id: "field-legend", description: "Names the set." }] }, + ...( { name: "FieldSet", description: "Groups fields answering one question.", props: {} } as object), + } as never; + c.components["field-group"] = { ...( { name: "FieldGroup", description: "Stacks fields.", props: {} } as object) } as never; + return c as unknown as Contract; +})(); + +const surface = (root: unknown): Surface => + ({ dspackSurface: "0.1", system: "containment-fixture", intent: "data-entry", root }) as Surface; + +const s2 = (root: unknown, c: Contract = contract) => { + const report = lintSurface(surface(root), c); + const gate = report.gates.find((g) => g.gate === "S2")!; + return { status: gate.status, errors: gate.errors ?? [] }; +}; + +describe("S2 containment — the four measured production shapes refuse", () => { + it("1. root-level form-label (no owner anywhere) fails with a pathed finding naming sub and owner", () => { + const { status, errors } = s2({ component: "form-label", text: "Merchant" }); + expect(status).toBe("FAIL"); + const finding = errors.find((e) => e.includes("form-label"))!; + expect(finding).toContain("$.root"); + expect(finding).toContain("'form'"); + }); + + it("2. select-trigger beside rather than beneath its select fails; properly nested passes", () => { + const sibling = s2({ + component: "card", + children: [ + { component: "select" }, + { component: "select-trigger", text: "Role" }, + ], + }); + expect(sibling.status).toBe("FAIL"); + expect(sibling.errors.some((e) => e.includes("select-trigger") && e.includes("'select'"))).toBe(true); + + const nested = s2({ + component: "card", + children: [{ component: "select", children: [{ component: "select-trigger", text: "Role" }] }], + }); + expect(nested.status).toBe("PASS"); + }); + + it("3. a bare alert-dialog-content duplicated beside a correctly nested one flags ONLY the bare path", () => { + const { status, errors } = s2({ + component: "form", + children: [ + { component: "alert-dialog", children: [{ component: "alert-dialog-content", text: "Are you sure?" }] }, + { component: "alert-dialog-content", text: "Are you sure?" }, + ], + }); + expect(status).toBe("FAIL"); + const hits = errors.filter((e) => e.includes("alert-dialog-content")); + expect(hits).toHaveLength(1); + expect(hits[0]).toContain("$.root.children[1]"); + }); + + it("4. the dspack#40 exemplar shape fails under the inverted declaration and passes under the corrected one", () => { + const exemplar = { + component: "card", + children: [ + { + component: "field-set", + children: [ + { component: "field-legend", text: "Digest" }, + { component: "field-group", children: [{ component: "field", children: [{ component: "field-label", text: "Cadence" }, { component: "input" }] }] }, + ], + }, + ], + }; + const inverted = s2(exemplar); + expect(inverted.status).toBe("FAIL"); + expect(inverted.errors.some((e) => e.includes("field-set") && e.includes("'field'"))).toBe(true); + + // The containment amendment and the field correction converge: the SAME + // surface is green once ownership is declared the way the family is used. + expect(s2(exemplar, corrected).status).toBe("PASS"); + }); +}); + +describe("S2 containment — semantics", () => { + it("valid nested use with arbitrary intermediate structure stays green (owner is an ancestor, not the parent)", () => { + const { status } = s2({ + component: "form", + children: [ + { + component: "card", // intermediate structural node between owner and sub + children: [{ component: "form-item", children: [{ component: "form-label", text: "A" }, { component: "input" }] }], + }, + ], + }); + expect(status).toBe("PASS"); + }); + + it("a sub reached through an ancestor's slots is contained (walk parity with S3)", () => { + const { status } = s2({ + component: "form", + slots: { body: [{ component: "form-label", text: "A" }] }, + }); + expect(status).toBe("PASS"); + }); + + it("an id declared BOTH top-level and as a sub is a component everywhere — containment does not apply", () => { + const both = structuredClone(contract) as unknown as { components: Record }; + both.components["form-label"] = { name: "FormLabel", description: "Also independently usable.", props: {} }; + const { status } = s2({ component: "form-label", text: "standalone" }, both as unknown as Contract); + expect(status).toBe("PASS"); + }); + + it("ownership is never inferred: a name-alike inside a name-alike compound still refuses", () => { + // select-item inside CARD (prefix-similar sibling family present elsewhere): + // only the declared owner counts. + const { status, errors } = s2({ + component: "card", + children: [{ component: "select-item", text: "Admins" }], + }); + expect(status).toBe("FAIL"); + expect(errors.some((e) => e.includes("select-item") && e.includes("'select'"))).toBe(true); + }); +}); + +describe("the repair loop receives containment findings", () => { + it("renderRepairMessage carries S2 vocabulary errors so a repair round can relocate the sub", () => { + const report = lintSurface(surface({ component: "form-label", text: "Merchant" }), contract); + const gate = report.gates.find((g) => g.gate === "S2")!; + const message = renderRepairMessage(report.findings, contract, "standard", gate.errors ?? []); + expect(message).toContain("form-label"); + expect(message).toContain("'form'"); + expect(message).toContain("$.root"); + }); +}); diff --git a/src/core/lint/vocabulary.ts b/src/core/lint/vocabulary.ts index d22fc0c..2b971a5 100644 --- a/src/core/lint/vocabulary.ts +++ b/src/core/lint/vocabulary.ts @@ -4,12 +4,18 @@ * constrained: the S0 spike caught Ollama's mlx engine silently ignoring * `format`, which is why S2 is never assumed from generation. * - * Scope per spec §8: component/sub-component ids, prop names on components, - * enum prop values, declared slot names, plus surface-level consistency - * (registered intent, matching system name). Deliberately NOT checked: - * acceptsChildren semantics, non-enum prop types, ordering. + * Scope per spec v0.3 §8 + the v0.4 §5.1 amendment: component/sub-component + * ids, prop names on components, enum prop values, declared slot names, + * surface-level consistency (registered intent, matching system name), and + * sub-component CONTAINMENT — a sub-declared id may appear only within the + * subtree of an instance of a declaring compound, unless the contract also + * declares the id as a top-level component (independently usable). + * Ownership comes only from composition.subComponents; it is never inferred + * from names, prefixes, adjacency, or examples. Deliberately NOT checked: + * acceptsChildren semantics, non-enum prop types, ordering (containment is + * ownership, not order). */ -import { type Contract, type Surface, duplicateSubComponentIds, enumValues, subComponentIndex } from "../contract.js"; +import { type Contract, type Surface, type SurfaceNode, duplicateSubComponentIds, enumValues, subComponentIndex } from "../contract.js"; import { walkSurface } from "./walk.js"; export function checkVocabulary(surface: Surface, contract: Contract): string[] { @@ -76,5 +82,39 @@ export function checkVocabulary(surface: Surface, contract: Contract): string[] } } } + + // Containment (spec v0.4 §5.1): every sub-declared id needs a declaring + // compound among its ANCESTORS (any depth — intermediate structure is + // fine; parent-only would be a different, stricter rule). Owners are the + // full declaration set (exactly one today, since duplicate sub ids refuse + // above; the set form is the spec's, not a behavior). An id that is also + // a top-level component is a component everywhere and exempt. The + // ancestor-carrying walk mirrors walkSurface exactly (children + slots). + const owners = new Map(); + for (const [id, component] of Object.entries(components)) { + for (const sub of component.composition?.subComponents ?? []) { + owners.set(sub.id, [...(owners.get(sub.id) ?? []), id]); + } + } + const containment = (node: SurfaceNode, path: string, ancestors: ReadonlySet): void => { + const id = node.component; + const declaredBy = owners.get(id); + if (declaredBy && !(id in components) && !declaredBy.some((owner) => ancestors.has(owner))) { + const ownerList = declaredBy.map((o) => `'${o}'`).join(" or "); + errors.push( + `${path}: sub-component '${id}' of ${ownerList} appears outside any ${ownerList} subtree — ` + + `a declared sub-component is only valid within its declaring compound (declare '${id}' as a top-level ` + + `component if it is independently usable)`, + ); + } + const next = new Set(ancestors); + next.add(id); + (node.children ?? []).forEach((child, i) => containment(child, `${path}.children[${i}]`, next)); + for (const slot of Object.keys(node.slots ?? {}).sort()) { + node.slots![slot].forEach((child, i) => containment(child, `${path}.slots.${slot}[${i}]`, next)); + } + }; + containment(surface.root, "$.root", new Set()); + return errors; } diff --git a/src/repair/render.ts b/src/repair/render.ts index e30ac40..f64359f 100644 --- a/src/repair/render.ts +++ b/src/repair/render.ts @@ -29,12 +29,37 @@ export function renderRepairMessage( findings: Finding[], contract: Contract, template: RepairTemplate = "standard", + /** + * S2 vocabulary errors (pathed strings from the gate report), rendered in + * their own section BEFORE governance findings: spec v0.4 §5.1 requires + * containment defects to reach the repair loop, and vocabulary errors are + * more fundamental than rule findings (a rule cannot be judged on + * vocabulary the contract does not have). Empty/omitted keeps the message + * byte-identical to the pre-amendment rendering. + */ + vocabularyErrors: readonly string[] = [], ): string { const errors = findings.filter((f) => f.level === "error"); - const lines: string[] = [ - `Your surface violates ${errors.length} governance rule finding(s) of the "${contract.name}" design system:`, - "", - ]; + const lines: string[] = []; + if (vocabularyErrors.length > 0) { + lines.push( + `Your surface uses the "${contract.name}" vocabulary incorrectly in ${vocabularyErrors.length} place(s):`, + "", + ); + vocabularyErrors.forEach((error, index) => { + lines.push(`Vocabulary error ${index + 1}: ${error}`); + }); + lines.push(""); + } + // The governance header is unconditional when there are no vocabulary + // errors (byte-compatible with the pre-§5.1 rendering); with vocabulary + // errors present, a zero-findings governance section would be noise. + if (errors.length > 0 || vocabularyErrors.length === 0) { + lines.push( + `Your surface violates ${errors.length} governance rule finding(s) of the "${contract.name}" design system:`, + "", + ); + } errors.forEach((finding, index) => { const where = finding.location.nodeId diff --git a/src/run/orchestrator.ts b/src/run/orchestrator.ts index 10dfa3e..0e17373 100644 --- a/src/run/orchestrator.ts +++ b/src/run/orchestrator.ts @@ -276,7 +276,10 @@ export async function runPipeline(options: RunOptions): Promise { } if (index < maxRepairs) { - const repair = renderRepairMessage(lint.findings, contract, repairTemplate); + // S2 errors ride the repair message alongside governance findings + // (spec v0.4 §5.1: containment defects must be repairable in-loop). + const s2Errors = lint.gates.find((g) => g.gate === "S2")?.errors ?? []; + const repair = renderRepairMessage(lint.findings, contract, repairTemplate, s2Errors); repairMessages.push(repair); conversation.push({ role: "assistant", content: generated.raw }, { role: "user", content: repair }); emit({ type: "repair", index, message: repair }); diff --git a/src/run/pipeline.test.ts b/src/run/pipeline.test.ts index 904e559..4ef2874 100644 --- a/src/run/pipeline.test.ts +++ b/src/run/pipeline.test.ts @@ -146,16 +146,21 @@ describe("failure paths are first-class artifacts", () => { it("emitter REFUSAL: lint-clean surface the emitter cannot project at all → failed-gate, exit 3, refusal recorded", async () => { // The live-eval discovery (2026-07-03, qwen): a sub-component outside its - // compound parent is in-vocabulary (S2), ungoverned (S3), but the a2ui + // compound parent was in-vocabulary (S2), ungoverned (S3), but the a2ui // profile cannot emit it standalone — EmitSurfaceError. That is the // target-equivalent emitter-gate failure, never a crash. // - // Fixture note (2026-08-06): the stray sub is table-footer, not - // card-header. card-header INSIDE a card stopped being a refusal the day - // the card plan learned to dissolve its own sub-family (emit 0.3.2's - // subFlatten) — this test only stayed green because the lockfile froze - // emit 0.3.1. A table sub inside a CARD is outside its compound under - // every emitter version, which is what the discovery actually was. + // Fixture note (2026-08-06): the stray sub became table-footer, not + // card-header, after emit 0.3.2's subFlatten dissolved card's family. + // + // Fixture note (2026-08-07, spec v0.4 §5.1): the stray-sub shape is no + // longer lint-clean — S2 containment now catches it in-loop, which is + // the amendment's whole point (see containment.test.ts). The + // lint-clean-but-refused class this test pins is exercised through its + // other member: a declared CASUALTY component ('dropdown-menu' in the + // shipped profile; 'dialog' is intent-forbidden by S3 here) — + // in-vocabulary, ungoverned in this surface, and refused by the emitter + // with the casualty reason. const refusalBreaker: Surface = { dspackSurface: "0.1", system: "shadcn/ui", @@ -164,7 +169,7 @@ describe("failure paths are first-class artifacts", () => { component: "card", children: [ (workedExample.root.children![0] as Surface["root"]), - { component: "table-footer", text: "stray sub-component" }, + { component: "dropdown-menu", text: "stray casualty" }, ], }, }; @@ -172,7 +177,7 @@ describe("failure paths are first-class artifacts", () => { const result = await runPipeline({ ...baseOptions, adapter }); expect(result.report.outcome).toBe("failed-gate"); expect(result.exitCode).toBe(3); - expect(result.report.emitted!.refusal).toContain("table-footer"); + expect(result.report.emitted!.refusal).toContain("dropdown-menu"); expect(result.report.emitted!.validations).toEqual([]); expect(result.surfaceMessages).toBeUndefined(); expect(validateReport(JSON.parse(JSON.stringify(result.report)))).toBe(true);