diff --git a/fixtures/negative/rule-required-subcomponent-unknown-ref.dspack.json b/fixtures/negative/rule-required-subcomponent-unknown-ref.dspack.json new file mode 100644 index 0000000..c22d760 --- /dev/null +++ b/fixtures/negative/rule-required-subcomponent-unknown-ref.dspack.json @@ -0,0 +1,62 @@ +{ + "dspack": "0.3", + "name": "negative-fixture", + "components": { + "button": { + "name": "Button", + "description": "A button.", + "props": { + "variant": { + "type": "enum", + "values": [ + "default", + "destructive" + ] + } + } + }, + "alert-dialog": { + "name": "AlertDialog", + "description": "A confirmation dialog.", + "composition": { + "subComponents": [ + { + "id": "alert-dialog-title", + "name": "AlertDialogTitle" + }, + { + "id": "alert-dialog-cancel", + "name": "AlertDialogCancel" + } + ] + } + } + }, + "intents": [ + { + "id": "destructive-action", + "description": "Irreversible operations." + } + ], + "_comment": "CONSISTENCY: requiredSubComponents references 'ghost-panel', which is declared neither as a top-level component nor as a composition sub-component. (Ids declared as EITHER kind resolve — descendants match by node component id; see rule.review-offers-alternatives in examples/astryx.dspack.json for the top-level-component case and rule.alertdialog-requires-cancel in examples/shadcn-ui.dspack.json for the sub-component case.)", + "rules": [ + { + "id": "rule.fixture", + "severity": "must", + "rationale": "Fixture rationale.", + "type": "required-composition", + "appliesTo": { + "intents": [ + "destructive-action" + ] + }, + "component": "alert-dialog", + "requiredSubComponents": [ + { + "id": "ghost-panel", + "min": 1 + } + ] + } + ] +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs index e4ae91b..7e56e79 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -243,12 +243,18 @@ function checkGovernance(doc, validateSurface) { for (const { kind, id } of ruleComponentRefs(rule)) { const resolvesToComponent = vocab.components.has(id); const resolvesToSub = vocab.subComponents.has(id); + // `requiredSubComponents` entries match descendant NODES by component id + // at lint time (spec §5), so the id may be declared as a top-level + // component or as a composition sub-component. Resolution here only + // guards the vocabulary; satisfaction (matching descendants beneath each + // governed node) is the S3 gate's concern, not this harness's. + // `on` remains sub-component-only (spec §5: "the sub-component id `on`"). const ok = - kind === "requiredSubComponents" || kind === "on" + kind === "on" ? resolvesToSub : kind === "component" ? resolvesToComponent - : resolvesToComponent || resolvesToSub; // componentOrSub, require, forbid, forbiddenDescendants + : resolvesToComponent || resolvesToSub; // requiredSubComponents, componentOrSub, require, forbid, forbiddenDescendants if (!ok) errors.push(`${rule.id}: ${kind} reference '${id}' does not resolve in the contract`); } for (const ex of rule.examples ?? []) { diff --git a/spec/dspack-v0.3.md b/spec/dspack-v0.3.md index 3b4daf4..ce6d17b 100644 --- a/spec/dspack-v0.3.md +++ b/spec/dspack-v0.3.md @@ -175,6 +175,11 @@ present and equal to a member of `oneOf`; when `on` is given, **every** descenda the sub-component id `on` MUST satisfy the prop constraint, and at least one such descendant MUST exist. +A `requiredSubComponents` entry's `id` MUST be declared in the contract — either as a +top-level component or as a composition sub-component; descendants match by node component +id in both cases. Declaration alone never satisfies the entry: satisfaction requires the +matching descendants described above, beneath each governed node. + **`forbidden-composition`** — structure and values no instance of a component may contain. Fields: `component: string`, `forbiddenDescendants?: string[]`, `forbiddenProps?: {on?, prop, values}[]` (at least one of the two present).