Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
204 changes: 204 additions & 0 deletions src/core/lint/containment.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { composition?: { subComponents: Array<{ id: string; description: string }> } }>;
};
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<string, unknown> };
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");
});
});
50 changes: 45 additions & 5 deletions src/core/lint/vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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<string, string[]>();
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<string>): 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;
}
33 changes: 29 additions & 4 deletions src/repair/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/run/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,10 @@ export async function runPipeline(options: RunOptions): Promise<RunResult> {
}

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 });
Expand Down
23 changes: 14 additions & 9 deletions src/run/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -164,15 +169,15 @@ 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" },
],
},
};
const adapter = new ScriptedAdapter([{ output: refusalBreaker }]);
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);
Expand Down
Loading