diff --git a/examples/shadcn-ui.dspack.json b/examples/shadcn-ui.dspack.json index 2de7105..c897b04 100644 --- a/examples/shadcn-ui.dspack.json +++ b/examples/shadcn-ui.dspack.json @@ -3,7 +3,7 @@ "dspack": "0.4", "name": "shadcn/ui", "description": "A collection of reusable components built with Radix UI and Tailwind CSS. Components are copied into your project, not installed as a dependency.", - "version": "2.1.0", + "version": "2.2.0", "metadata": { "source": "https://ui.shadcn.com", "license": "MIT" @@ -1079,10 +1079,10 @@ "id": "rule.trigger-carries-label", "type": "required-props", "severity": "must", - "component": "button", - "within": "alert-dialog-trigger", + "component": "alert-dialog-trigger", "requiredText": true, - "rationale": "The trigger button must present its label as its own text. Assistive technology reads the control's accessible name from the element itself, and protocol projections lift a trigger label from the label-bearing component's direct text — a label nested in a child (e.g. a badge) or absent entirely produces a control with no accessible name and an instance downstream emitters must refuse.", + "textScope": "subtree", + "rationale": "The trigger must present an accessible label: non-empty text somewhere under the trigger. Protocol projections lift the label from the trigger's subtree (preferring a label-bearing button; lifts are audited) — a trigger with no label text anywhere yields a control with no accessible name and an instance downstream emitters must refuse.", "examples": ["ex.delete-account-confirmation"], "tags": ["accessibility", "projection"] }, diff --git a/fixtures/negative/rule-required-props-bad-textscope.dspack.json b/fixtures/negative/rule-required-props-bad-textscope.dspack.json new file mode 100644 index 0000000..3bc8344 --- /dev/null +++ b/fixtures/negative/rule-required-props-bad-textscope.dspack.json @@ -0,0 +1,36 @@ +{ + "dspack": "0.4", + "name": "negative-fixture", + "_comment": "SCHEMA: textScope must be 'self' or 'subtree' (and requires requiredText via dependentRequired).", + "components": { + "alert-dialog": { + "name": "AlertDialog", + "description": "A confirmation dialog.", + "composition": { + "subComponents": [ + { + "id": "alert-dialog-trigger", + "name": "AlertDialogTrigger" + } + ] + } + } + }, + "intents": [ + { + "id": "destructive-action", + "description": "Irreversible operations." + } + ], + "rules": [ + { + "id": "rule.fixture", + "type": "required-props", + "severity": "must", + "rationale": "Fixture rationale.", + "component": "alert-dialog-trigger", + "requiredText": true, + "textScope": "descendants" + } + ] +} diff --git a/schema/dspack.v0.4.schema.json b/schema/dspack.v0.4.schema.json index 4f65bbc..e288828 100644 --- a/schema/dspack.v0.4.schema.json +++ b/schema/dspack.v0.4.schema.json @@ -1221,7 +1221,16 @@ }, "requiredText": { "const": true, - "description": "The matching node MUST carry non-empty direct text (its own `text` field, never text in descendants)." + "description": "The matching node MUST carry non-empty text. Where the text may live is set by `textScope`: its own `text` field only (the default, \"self\") or anywhere in its subtree (\"subtree\")." + }, + "textScope": { + "type": "string", + "enum": [ + "self", + "subtree" + ], + "default": "self", + "description": "Where requiredText looks for the text: \"self\" (the node's own `text` field — the default) or \"subtree\" (direct text on the node or any descendant; for compound wrappers whose documented projections lift a label from within). Amendment 2026-07-04, on PR-15 evidence." }, "requiredProps": { "type": "array", @@ -1249,6 +1258,11 @@ }, "minItems": 1 } + }, + "dependentRequired": { + "textScope": [ + "requiredText" + ] } } } diff --git a/spec/dspack-v0.4.md b/spec/dspack-v0.4.md index bfb4e9e..6a4358f 100644 --- a/spec/dspack-v0.4.md +++ b/spec/dspack-v0.4.md @@ -109,43 +109,60 @@ fields' semantics. "id": "rule.trigger-carries-label", "type": "required-props", "severity": "must", - "component": "button", - "within": "alert-dialog-trigger", + "component": "alert-dialog-trigger", "requiredText": true, - "rationale": "The trigger button must present its label as its own text…", + "textScope": "subtree", + "rationale": "The trigger must present an accessible label…", "examples": ["ex.delete-account-confirmation"] } ``` +> **Draft amendment (2026-07-04), on measured evidence.** The first live run of this +> rule type (dspack-gen PR-15, 216 runs) decomposed its findings and showed the +> original for-every-node `within` semantics rejected 67 surfaces whose emission the +> A2UI target accepts (a labeled bearer existed; a textless *sibling* tripped the +> rule). Two changes, while v0.4 is a draft: `requiredText` gains **`textScope`** +> (`self` | `subtree`, default `self`), and `within` scoping is now **∃-quantified** +> (at least one matching node per scope satisfies). Rules SHOULD state exactly the +> precondition of the projection they protect — no stricter, no looser; stricter +> requirements (e.g. "no unlabeled buttons anywhere") are their own rules with their +> own rationales. + Fields: | Field | Type | Required | Description | | --- | --- | --- | --- | | `component` | string | yes | Component **or sub-component** id whose instances are checked. This is the one rule type whose `component` accepts a sub-component id. | | `within` | string | no | Component or sub-component id scoping the check (see below). | -| `requiredText` | `true` | one of these two | The node MUST carry non-empty **direct** text (its own `text` field). | +| `requiredText` | `true` | one of these two | The node MUST carry non-empty text — its own `text` field by default; see `textScope`. | +| `textScope` | `self` \| `subtree` | no (default `self`) | Where `requiredText` looks: `self` = the node's own `text` field only; `subtree` = direct text on the node **or any of its descendants** — for compound wrappers whose documented projections lift a label from within. Only meaningful with `requiredText`. | | `requiredProps` | `{prop, oneOf?}[]` | one of these two | Props that MUST be present **directly on the node's `props`**; when `oneOf` is given the value MUST be a member. | **Normative evaluation semantics.** Terms as in v0.3 §5.3 ("descendants", "matches"). - -The **checked set**: - -- When `within` is absent: every node in the surface matching `component`. -- When `within` is present: every node matching `component` that has an ancestor - matching `within`. Additionally, **every node matching `within` MUST contain at least - one descendant matching `component`** (one finding per `within` node with none, - located at that node). This existence clause mirrors v0.3's `requiredProps.on` - semantics ("at least one such descendant MUST exist") and closes the hole where a - scope carries no label-bearing node at all. - -For each node in the checked set: - -- `requiredText: true` — the node MUST have a `text` field that is a non-empty string. - Text carried by descendants does not satisfy the requirement; that is the point of - the rule type. One finding per violating node, located at it. +Two evaluation modes, distinguished by `within`; in both, the **constraints** (defined +below) are always evaluated against individual nodes matching `component` — never +against the `within` node itself. `within` changes only which nodes are candidates, +how many must satisfy, and where findings land. + +- **`within` absent — every instance.** Every node in the surface matching + `component` is evaluated; each one MUST satisfy the constraints. One finding per + violating node, located at that node. +- **`within` present — per scope, at least one (∃).** For every node matching + `within` (a *scope*): at least one descendant matching `component` MUST exist (one + finding per scope with none, located at the scope node), and at least one of those + descendants MUST satisfy the constraints (one finding per scope in which every + candidate violates, located at the scope node). Candidates that violate while a + sibling satisfies produce no findings. The existence clause mirrors v0.3's + `requiredProps.on` semantics; the ∃ quantifier is the 2026-07-04 amendment above. + +The constraints, evaluated against a candidate node matching `component`: + +- `requiredText: true` with `textScope: "self"` (the default) — the node MUST have a + `text` field that is a non-empty string; text carried by descendants does not + satisfy it. With `textScope: "subtree"` — the node or at least one of its + descendants MUST carry a non-empty `text` field. - Each `requiredProps` entry — the node's own `props[prop]` MUST be present; when - `oneOf` is present, its value MUST be a member. One finding per violated entry, - located at the node. + `oneOf` is present, its value MUST be a member. **Distinction from `required-composition.requiredProps`** (v0.3 §5.3): that field is `on`-scoped (checks descendants of the anchoring component) and requires `oneOf` diff --git a/spec/migration-v0.3-to-v0.4.md b/spec/migration-v0.3-to-v0.4.md index c631a1a..a09e53e 100644 --- a/spec/migration-v0.3-to-v0.4.md +++ b/spec/migration-v0.3-to-v0.4.md @@ -60,13 +60,19 @@ repairable finding: "id": "rule.trigger-carries-label", "type": "required-props", "severity": "must", - "component": "button", - "within": "alert-dialog-trigger", + "component": "alert-dialog-trigger", "requiredText": true, - "rationale": "The trigger button must present its label as its own text…" + "textScope": "subtree", + "rationale": "The trigger must present an accessible label…" } ``` +*(Amended 2026-07-04 while v0.4 is a draft: the rule originally anchored on `button` +`within` the trigger with for-every-button semantics; the first live run showed that +form rejected surfaces whose emission succeeds — a labeled bearer plus a textless +sibling. The amended form states exactly the projection's precondition: label text +somewhere under the trigger. See spec §4.1's amendment note.)* + **A category-based `forbidden-composition` rule** that would otherwise enumerate ids and silently rot as the vocabulary grows: