From 30f2af06b4b8e8d857012643522bfd29d2a2bb33 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Sat, 4 Jul 2026 00:50:24 -0400 Subject: [PATCH 1/2] =?UTF-8?q?spec:=20v0.4=20draft=20amendment=20?= =?UTF-8?q?=E2=80=94=20textScope,=20=E2=88=83-within,=20rule=20re-anchor?= =?UTF-8?q?=20(ADR-M3-1=20amendment)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On PR-15 evidence (dspack-gen#24: 216 runs; corrected decomposition 67 projectable-today / 20 liftable / 117 no-text-anywhere): - required-props requiredText gains textScope: self|subtree (default self; dependentRequired on requiredText); 'within' scoping is now ∃-quantified. Amendment note in §4.1 states the principle the evidence taught: a rule SHOULD state exactly the precondition of the projection it protects. - rule.trigger-carries-label re-anchored on alert-dialog-trigger with textScope subtree — 'an accessible label exists somewhere under the trigger' — matching the audited lift landing in dspack-emit. Contract 2.1.0 -> 2.2.0. - migration doc amendment note; negative fixture for bad textScope. validate + 17 negative fixtures green. Maintainer-directed amendment (2026-07-04); v0.4 is a draft — semantics freeze at release, not before. Co-Authored-By: Claude Fable 5 --- examples/shadcn-ui.dspack.json | 8 +-- ...e-required-props-bad-textscope.dspack.json | 36 +++++++++++++ schema/dspack.v0.4.schema.json | 14 +++++ spec/dspack-v0.4.md | 52 ++++++++++++------- spec/migration-v0.3-to-v0.4.md | 12 +++-- 5 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 fixtures/negative/rule-required-props-bad-textscope.dspack.json 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..0260f61 100644 --- a/schema/dspack.v0.4.schema.json +++ b/schema/dspack.v0.4.schema.json @@ -1223,6 +1223,15 @@ "const": true, "description": "The matching node MUST carry non-empty direct text (its own `text` field, never text in descendants)." }, + "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", "description": "Props that MUST be present directly on the matching node's `props`. Unlike required-composition's requiredProps, entries have no `on` (the rule's component IS the target) and `oneOf` is optional (presence-only when absent).", @@ -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..762c9d2 100644 --- a/spec/dspack-v0.4.md +++ b/spec/dspack-v0.4.md @@ -109,43 +109,57 @@ 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. +- When `within` is absent: every node in the surface matching `component`; **every** + node in the checked set MUST satisfy the constraints (one finding per violating + node, located at it). +- When `within` is present: for every node matching `within`, at least one descendant + matching `component` MUST exist (one finding per `within` node with none, located at + that node), and **at least one such descendant MUST satisfy the constraints** + (∃-quantified; one finding per `within` node whose matching descendants all violate, + located at the `within` node). The existence clause mirrors v0.3's + `requiredProps.on` semantics; the ∃ quantifier is the 2026-07-04 amendment above. + +Constraints, per checked node: + +- `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: From 1cf8a0cbdc6b1a225d79a3da913dbd2eff4c0a43 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Sat, 4 Jul 2026 01:01:11 -0400 Subject: [PATCH 2/2] =?UTF-8?q?review:=20address=20Copilot=20on=20the=20am?= =?UTF-8?q?endment=20PR=20=E2=80=94=20schema=20description=20+=20evaluatio?= =?UTF-8?q?n-unit=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requiredText schema description no longer claims descendant text never satisfies (contradicted textScope: subtree); points at textScope. - spec §4.1 semantics reworded: constraints are ALWAYS evaluated against candidate nodes matching component, never the within node; within changes candidacy, quantification, and finding location. The two modes and the constraint definition now name their evaluation unit explicitly. Co-Authored-By: Claude Fable 5 --- schema/dspack.v0.4.schema.json | 2 +- spec/dspack-v0.4.md | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/schema/dspack.v0.4.schema.json b/schema/dspack.v0.4.schema.json index 0260f61..e288828 100644 --- a/schema/dspack.v0.4.schema.json +++ b/schema/dspack.v0.4.schema.json @@ -1221,7 +1221,7 @@ }, "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", diff --git a/spec/dspack-v0.4.md b/spec/dspack-v0.4.md index 762c9d2..6a4358f 100644 --- a/spec/dspack-v0.4.md +++ b/spec/dspack-v0.4.md @@ -139,20 +139,23 @@ Fields: | `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`; **every** - node in the checked set MUST satisfy the constraints (one finding per violating - node, located at it). -- When `within` is present: for every node matching `within`, at least one descendant - matching `component` MUST exist (one finding per `within` node with none, located at - that node), and **at least one such descendant MUST satisfy the constraints** - (∃-quantified; one finding per `within` node whose matching descendants all violate, - located at the `within` node). The existence clause mirrors v0.3's +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. -Constraints, per checked node: +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