From 5282284bb082efab201fe84abc826131e6cb8af0 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 22 Jul 2026 13:27:55 -0400 Subject: [PATCH] fix(validate): requiredSubComponents ids resolve to components or sub-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static governance-consistency check required requiredSubComponents ids to resolve strictly in the sub-component vocabulary, while the runtime linter (dspack-gen) and the spec's own wording match descendant nodes by component id — either kind. The shipped astryx governance (rule.review-offers-alternatives, rule.option-states-attributes) relies on the runtime interpretation, so the validator has failed main since the studio governance sync (#22). Align the static check with runtime semantics: the id must be declared as a top-level component or a composition sub-component; declaration alone never satisfies the rule at lint time. `on` references remain sub-component-only per spec. Adds a negative fixture for an id absent from both vocabularies and a spec §5 sentence stating the identifier-resolution rule. Runtime satisfaction (matching descendants beneath each governed node) is unchanged and covered by dspack-gen's lint tests and F3/F4/F9 goldens. Co-Authored-By: Claude Fable 5 --- ...uired-subcomponent-unknown-ref.dspack.json | 62 +++++++++++++++++++ scripts/validate.mjs | 10 ++- spec/dspack-v0.3.md | 5 ++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 fixtures/negative/rule-required-subcomponent-unknown-ref.dspack.json 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).