Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions examples/shadcn-ui.dspack.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
},
Expand Down
36 changes: 36 additions & 0 deletions fixtures/negative/rule-required-props-bad-textscope.dspack.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
16 changes: 15 additions & 1 deletion schema/dspack.v0.4.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1249,6 +1258,11 @@
},
"minItems": 1
}
},
"dependentRequired": {
"textScope": [
"requiredText"
]
}
}
}
Expand Down
61 changes: 39 additions & 22 deletions spec/dspack-v0.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 9 additions & 3 deletions spec/migration-v0.3-to-v0.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading