From e4002b7f1f8969e63d8e66725158bb6a638a4d1a Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Fri, 7 Aug 2026 12:25:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(s3):=20evaluate=20requiredCategories=20?= =?UTF-8?q?=E2=80=94=20spec=20v0.4=20=C2=A74.3=20(0.2.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The category-OR the T1 Build evidence demanded: for every node matching the rule's component, each requiredCategories entry must have >= min descendants belonging to the category, membership resolved through the contract's categories declarations exactly like forbiddenCategories, LOCAL to the matching node's descendants — a member elsewhere in the surface satisfies nothing, and the finding message says so explicitly. Ten ratified pins in required-categories.test.ts: text-only form-control FAILs carrying the owner-approved rationale verbatim; empty FAILs; input / textarea / select each PASS; locality (interactive elsewhere does not satisfy); min 2 with one found FAILs naming both numbers; multiple entries keep AND semantics; requiredSubComponents unchanged; forbidden-composition unchanged alongside. 129/129 green. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- src/core/contract.ts | 7 + src/core/lint/required-categories.test.ts | 189 ++++++++++++++++++++++ src/core/lint/rules.ts | 19 ++- 4 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/core/lint/required-categories.test.ts diff --git a/package.json b/package.json index f67b16f..8315f74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aestheticfunction/dspack-gen", - "version": "0.2.1", + "version": "0.2.2", "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/contract.ts b/src/core/contract.ts index ab9f091..50eb8b8 100644 --- a/src/core/contract.ts +++ b/src/core/contract.ts @@ -93,6 +93,13 @@ export interface ComponentChoiceRule extends RuleBase { } export interface RequiredCompositionRule extends RuleBase { + /** + * spec v0.4 §4.3 (2026-08-07 amendment, lifted from the §6 ceiling on the + * T1 Build evidence): categories from which at least `min` descendants must + * appear beneath each matching node. AND across entries, OR within a + * category's membership; LOCAL to the matching node's descendants. + */ + requiredCategories?: Array<{ id: string; min?: number }>; type: "required-composition"; component: string; requiredSubComponents?: Array<{ id: string; min?: number }>; diff --git a/src/core/lint/required-categories.test.ts b/src/core/lint/required-categories.test.ts new file mode 100644 index 0000000..cc66972 --- /dev/null +++ b/src/core/lint/required-categories.test.ts @@ -0,0 +1,189 @@ +/** + * S3 `requiredCategories` (spec v0.4 §4.3, the 2026-08-07 amendment) — the + * ten ratified pins. The invariant that motivated it, verbatim from the + * owner-approved rationale: + * + * A form-control represents the location of the user-editable control in a + * field. It must contain an approved interactive control; text-only or + * empty form controls are not meaningful form composition. + * + * The fail-first record: the T1 Build evaluation produced exactly these + * zero-control forms, lint-clean under every gate (dspack-emit + * eval/t1-build-matrix*.json), and no existing rule type could express the + * category-OR without enumerating ids. + */ +import { describe, expect, it } from "vitest"; +import { lintSurface } from "./index.js"; +import type { Contract } from "../contract.js"; +import type { Surface } from "../surface-schema.js"; + +const RATIONALE = + "A form-control represents the location of the user-editable control in a field. It must contain an approved interactive control; text-only or empty form controls are not meaningful form composition."; + +/** A hermetic v0.4 contract fragment: a form family + three interactive controls. */ +const contract: Contract = { + dspack: "0.4", + name: "required-categories-fixture", + description: "S3 requiredCategories pins", + version: "0.0.1", + components: { + input: { name: "Input", description: "Text entry.", props: {}, categories: ["interactive"] }, + textarea: { name: "Textarea", description: "Long text entry.", props: {}, categories: ["interactive"] }, + select: { name: "Select", description: "Choose one.", props: {}, categories: ["interactive"] }, + badge: { name: "Badge", description: "A label.", props: {}, categories: ["content"] }, + form: { + name: "Form", + description: "A form.", + props: {}, + categories: ["form"], + composition: { + subComponents: [ + { id: "form-item", description: "One field." }, + { id: "form-label", description: "The field label.", acceptsChildren: "text" }, + { id: "form-control", description: "Where the control lives." }, + ], + }, + }, + }, + categories: { + interactive: { name: "Interactive", description: "Receives activation." }, + content: { name: "Content", description: "Static content." }, + form: { name: "Form", description: "Form structure." }, + }, + intents: [{ id: "data-entry", name: "Data entry", description: "Collect input." }], + rules: [ + { + id: "rule.form-control-carries-control", + type: "required-composition", + severity: "must", + component: "form-control", + requiredCategories: [{ id: "interactive", min: 1 }], + rationale: RATIONALE, + }, + ], +} as unknown as Contract; + +const surface = (root: unknown): Surface => + ({ dspackSurface: "0.1", system: "required-categories-fixture", intent: "data-entry", root }) as Surface; + +const govern = (root: unknown, c: Contract = contract) => { + const report = lintSurface(surface(root), c); + const gate = report.gates.find((g) => g.name === "governance")!; + return { status: gate.status, findings: report.findings }; +}; + +const field = (control: unknown) => ({ + component: "form", + children: [ + { + component: "form-item", + children: [{ component: "form-label", text: "L" }, ...(control === undefined ? [] : [control as object])], + }, + ], +}); + +describe("requiredCategories — the ten ratified pins", () => { + it("1. form-control with literal text only → FAIL, carrying rule id and rationale", () => { + const { status, findings } = govern(field({ component: "form-control", text: "e.g., Client Dinner" })); + expect(status).toBe("FAIL"); + const f = findings.find((x) => x.ruleId === "rule.form-control-carries-control")!; + expect(f.rationale).toBe(RATIONALE); + expect(f.message).toContain("'interactive'"); + expect(f.message).toContain("found 0"); + }); + + it("2. empty form-control → FAIL", () => { + const { status } = govern(field({ component: "form-control" })); + expect(status).toBe("FAIL"); + }); + + it("3. form-control > input → PASS", () => { + const { status } = govern(field({ component: "form-control", children: [{ component: "input" }] })); + expect(status).toBe("PASS"); + }); + + it("4. form-control > textarea → PASS", () => { + const { status } = govern(field({ component: "form-control", children: [{ component: "textarea" }] })); + expect(status).toBe("PASS"); + }); + + it("5. form-control > select → PASS (select is an interactive member)", () => { + const { status } = govern(field({ component: "form-control", children: [{ component: "select" }] })); + expect(status).toBe("PASS"); + }); + + it("6. an interactive control ELSEWHERE does not satisfy an empty form-control — locality", () => { + const { status, findings } = govern({ + component: "form", + children: [ + { component: "input" }, // interactive, but outside the form-control + { component: "form-item", children: [{ component: "form-control", text: "just text" }] }, + ], + }); + expect(status).toBe("FAIL"); + expect(findings.some((f) => f.message.includes("members elsewhere in the surface do not satisfy"))).toBe(true); + }); + + it("7. min: 2 with one matching descendant → FAIL", () => { + const strict = structuredClone(contract) as Contract & { rules: Array<{ requiredCategories?: Array<{ id: string; min?: number }> }> }; + strict.rules[0].requiredCategories = [{ id: "interactive", min: 2 }]; + const { status, findings } = govern(field({ component: "form-control", children: [{ component: "input" }] }), strict as Contract); + expect(status).toBe("FAIL"); + expect(findings[0].message).toContain("min 2"); + expect(findings[0].message).toContain("found 1"); + }); + + it("8. multiple entries keep AND semantics: each category independently required", () => { + const both = structuredClone(contract) as Contract & { rules: Array<{ requiredCategories?: Array<{ id: string }> }> }; + both.rules[0].requiredCategories = [{ id: "interactive" }, { id: "content" }]; + // interactive satisfied, content missing → FAIL on the content entry. + const failing = govern(field({ component: "form-control", children: [{ component: "input" }] }), both as Contract); + expect(failing.status).toBe("FAIL"); + expect(failing.findings.some((f) => f.message.includes("'content'"))).toBe(true); + // Both satisfied → PASS. + const ok = govern(field({ component: "form-control", children: [{ component: "input" }, { component: "badge", text: "b" }] }), both as Contract); + expect(ok.status).toBe("PASS"); + }); + + it("9. requiredSubComponents behaviour is unchanged alongside the new field", () => { + const mixed = structuredClone(contract) as Contract & { rules: unknown[] }; + (mixed.rules as Array>)[0].requiredSubComponents = [{ id: "form-label", min: 1 }]; + // form-control > input, but the rule's node (form-control) has no + // form-label DESCENDANT (the label is a sibling) → the id requirement + // fails exactly as it always did, independent of the category pass. + const { status, findings } = govern(field({ component: "form-control", children: [{ component: "input" }] }), mixed as Contract); + expect(status).toBe("FAIL"); + expect(findings.some((f) => f.message.includes("'form-label'"))).toBe(true); + }); + + it("10. forbidden-category behaviour is unchanged: the two sides coexist", () => { + const withForbid = structuredClone(contract) as Contract & { rules: unknown[] }; + (withForbid.rules as Array>).push({ + id: "rule.label-holds-no-controls", + type: "forbidden-composition", + severity: "must", + component: "form-label", + forbiddenCategories: ["interactive"], + rationale: "A label labels; it does not contain the control it labels.", + }); + expect(govern(field({ component: "form-control", children: [{ component: "input" }] }), withForbid as Contract).status).toBe("PASS"); + const bad = lintSurface( + surface({ + component: "form", + children: [ + { + component: "form-item", + children: [ + { component: "form-label", text: "L", children: [{ component: "input" }] }, + { component: "form-control", children: [{ component: "input" }] }, + ], + }, + ], + }), + withForbid as Contract, + ); + const gate = bad.gates.find((g) => g.name === "governance")!; + expect(gate.status).toBe("FAIL"); + expect(bad.findings.some((f) => f.ruleId === "rule.label-holds-no-controls")).toBe(true); + }); +}); diff --git a/src/core/lint/rules.ts b/src/core/lint/rules.ts index e6c887a..93124aa 100644 --- a/src/core/lint/rules.ts +++ b/src/core/lint/rules.ts @@ -115,13 +115,30 @@ function evaluateComponentChoice(entry: RuleEntry, surface: Surface): Finding[] * requiredProps entry MUST hold (on the node itself, or on every descendant * matching `on` — of which at least one must exist). */ -function evaluateRequiredComposition(entry: RuleEntry, surface: Surface): Finding[] { +function evaluateRequiredComposition(entry: RuleEntry, surface: Surface, contract: Contract): Finding[] { const rule = entry as RequiredCompositionRule; const findings: Finding[] = []; + // Resolved lazily, exactly like forbiddenCategories: membership comes from + // the contract's categories declarations at lint time (spec v0.4 §4.2/§4.3). + const categories = rule.requiredCategories?.length ? categoryIndex(contract) : undefined; for (const visited of walkSurface(surface).filter((v) => v.node.component === rule.component)) { const descendants = descendantsOf(visited); + for (const requirement of rule.requiredCategories ?? []) { + const min = requirement.min ?? 1; + const found = descendants.filter((d) => (categories!.get(d.node.component) ?? []).includes(requirement.id)).length; + if (found < min) { + findings.push( + finding( + rule, + `Required category '${requirement.id}' (min ${min}) not found among descendants (found ${found}) — no descendant of this node is a '${requirement.id}' member; members elsewhere in the surface do not satisfy this rule.`, + locationOf(visited), + ), + ); + } + } + for (const requirement of rule.requiredSubComponents ?? []) { const min = requirement.min ?? 1; const found = descendants.filter((d) => d.node.component === requirement.id).length;