diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..59cf523 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,21 @@ +name: validate + +on: + push: + branches: [main] + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Schemas + examples (positive) + run: npm run validate + - name: Negative fixtures (must all be rejected) + run: npm run validate -- --fixtures negative diff --git a/README.md b/README.md index d22f936..a317a72 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Think of it as OpenAPI for design systems. --- -> **Status: v0.2 draft available.** +> **Status: v0.3 draft available.** > -> The current draft is [`spec/dspack-v0.2.md`](./spec/dspack-v0.2.md), with a matching [JSON Schema](./schema/dspack.v0.2.schema.json) and a [shadcn/ui reference example](./examples/shadcn-ui.dspack.json). The [v0.1 spec](./spec/dspack-v0.1.md) and [schema](./schema/dspack.v0.1.schema.json) are preserved for reference. v0.2 is fully backward-compatible — a valid v0.1 document with `"dspack": "0.2"` validates against the v0.2 schema. This is a draft — breaking changes may occur before v1.0. Contributions to the design are welcome at any stage. +> The current draft is [`spec/dspack-v0.3.md`](./spec/dspack-v0.3.md) (written as a delta over [v0.2](./spec/dspack-v0.2.md)), with a matching [JSON Schema](./schema/dspack.v0.3.schema.json), a companion [surface schema](./schema/dspack.surface.v0_1.schema.json), and a [shadcn/ui reference example](./examples/shadcn-ui.dspack.json). v0.3 adds the machine-checkable **governance blocks** — `intents`, `rules`, `examples` — and is strictly additive: a valid v0.2 document with `"dspack": "0.3"` validates against the v0.3 schema (see the [migration guide](./spec/migration-v0.2-to-v0.3.md)). Earlier specs and schemas ([v0.2](./spec/dspack-v0.2.md), [v0.1](./spec/dspack-v0.1.md)) are preserved for reference. This is a draft — breaking changes may occur before v1.0. Contributions to the design are welcome at any stage. --- @@ -120,6 +120,7 @@ The following milestones represent the current intended direction. They are not | **v0.1 spec draft** | First complete draft of the dspack specification — _[available](./spec/dspack-v0.1.md)_ | | **shadcn/ui example dspack** | A reference dspack file for the [shadcn/ui](https://ui.shadcn.com) component library — _[available](./examples/shadcn-ui.dspack.json)_ | | **v0.2 spec draft** | Adds structured generation constraints: lifecycle status, accessibility, composition rules, contextual constraints, variant semantics, token hierarchy, themes, layout primitives, and anti-pattern severity — _[available](./spec/dspack-v0.2.md)_ | +| **v0.3 spec draft** | Adds the machine-checkable governance blocks: named intents, typed deterministic rules with rationales, compilable examples, and the companion dspack surface format — _[available](./spec/dspack-v0.3.md)_ | | **ds-mcp v0 release** | First release of the reference implementation, validated against the v0.2 spec — _[available](https://github.com/aestheticfunction/ds-mcp)_ | | **Community RFCs** | Open RFC process for proposing additions and changes to the spec | | **v1.0 spec stabilization** | First stable, versioned release of the specification; breaking changes require a formal process after this point | diff --git a/examples/shadcn-ui.dspack.json b/examples/shadcn-ui.dspack.json index d149c10..9a0d925 100644 --- a/examples/shadcn-ui.dspack.json +++ b/examples/shadcn-ui.dspack.json @@ -1,6 +1,6 @@ { - "$schema": "../schema/dspack.v0.2.schema.json", - "dspack": "0.2", + "$schema": "../schema/dspack.v0.3.schema.json", + "dspack": "0.3", "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.0.0", @@ -875,6 +875,101 @@ "tags": ["accessibility", "semantics", "component-misuse"] } ], + "intents": [ + { + "id": "destructive-action", + "name": "Destructive action", + "description": "The requested UI performs an irreversible or high-consequence operation: deleting records or accounts, revoking access, removing members.", + "relatedPatterns": ["destructive-action-confirmation"] + } + ], + "rules": [ + { + "id": "rule.destructive-requires-alertdialog", + "type": "component-choice", + "severity": "must", + "appliesTo": { "intents": ["destructive-action"] }, + "require": ["alert-dialog"], + "forbid": ["dialog"], + "rationale": "Dialog can be dismissed by clicking the overlay or pressing Escape, so a user can bypass a destructive confirmation without making a conscious choice. AlertDialog forces an explicit confirm/cancel decision and is announced with greater urgency by screen readers.", + "examples": ["ex.delete-account-confirmation"], + "tags": ["safety", "accessibility"] + }, + { + "id": "rule.alertdialog-requires-cancel", + "type": "required-composition", + "severity": "must", + "component": "alert-dialog", + "requiredSubComponents": [ + { "id": "alert-dialog-cancel", "min": 1 }, + { "id": "alert-dialog-title", "min": 1 } + ], + "rationale": "A confirmation without an explicit cancel action and a title naming the consequence funnels the user toward the destructive action; the title is also required for aria-labelledby.", + "examples": ["ex.delete-account-confirmation"], + "tags": ["safety", "accessibility"] + }, + { + "id": "rule.button-no-interactive-descendants", + "type": "forbidden-composition", + "severity": "must", + "component": "button", + "forbiddenDescendants": ["button", "input"], + "rationale": "Nested interactive elements create ambiguous click targets and are an accessibility violation: screen readers cannot determine intent and click handling varies across browsers.", + "examples": ["ex.delete-account-confirmation"], + "tags": ["accessibility", "interaction"] + } + ], + "examples": [ + { + "id": "ex.delete-account-confirmation", + "intent": "destructive-action", + "name": "Delete account confirmation", + "prompt": "a screen to delete my account", + "description": "Card with a destructive entry point; AlertDialog confirmation with explicit consequence text, cancel-before-confirm, destructive confirm variant.", + "surface": { + "dspackSurface": "0.1", + "system": "shadcn/ui", + "intent": "destructive-action", + "root": { + "component": "card", + "children": [ + { + "component": "alert-dialog", + "children": [ + { + "component": "alert-dialog-trigger", + "children": [ + { + "component": "button", + "props": { "variant": "destructive" }, + "text": "Delete account" + } + ] + }, + { + "component": "alert-dialog-content", + "children": [ + { "component": "alert-dialog-title", "text": "Delete your account?" }, + { + "component": "alert-dialog-description", + "text": "This will permanently delete your account and all associated data. This action cannot be undone." + }, + { + "component": "alert-dialog-footer", + "children": [ + { "component": "alert-dialog-cancel", "text": "Cancel" }, + { "component": "alert-dialog-action", "text": "Delete account" } + ] + } + ] + } + ] + } + ] + } + } + } + ], "frameworkBindings": { "react": { "name": "React", diff --git a/fixtures/negative/duplicate-subcomponent-id.dspack.json b/fixtures/negative/duplicate-subcomponent-id.dspack.json new file mode 100644 index 0000000..1455088 --- /dev/null +++ b/fixtures/negative/duplicate-subcomponent-id.dspack.json @@ -0,0 +1,37 @@ +{ + "_comment": "CONSISTENCY: 'shared-part' is declared as a sub-component by two different components; ambiguous vocabulary must be rejected, not resolved by iteration order.", + "dspack": "0.3", + "name": "negative-fixture", + "components": { + "widget-a": { + "name": "WidgetA", + "description": "First parent.", + "composition": { + "subComponents": [ + { + "id": "shared-part", + "name": "SharedPart" + } + ] + } + }, + "widget-b": { + "name": "WidgetB", + "description": "Second parent.", + "composition": { + "subComponents": [ + { + "id": "shared-part", + "name": "SharedPart" + } + ] + } + } + }, + "intents": [ + { + "id": "destructive-action", + "description": "Irreversible operations." + } + ] +} diff --git a/fixtures/negative/example-missing-intent.dspack.json b/fixtures/negative/example-missing-intent.dspack.json new file mode 100644 index 0000000..d5381ca --- /dev/null +++ b/fixtures/negative/example-missing-intent.dspack.json @@ -0,0 +1,56 @@ +{ + "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": "SCHEMA: example missing the mandatory intent.", + "examples": [ + { + "id": "ex.fixture", + "surface": { + "dspackSurface": "0.1", + "system": "negative-fixture", + "intent": "destructive-action", + "root": { + "component": "button", + "text": "Go" + } + } + } + ] +} diff --git a/fixtures/negative/example-surface-bad-enum.dspack.json b/fixtures/negative/example-surface-bad-enum.dspack.json new file mode 100644 index 0000000..906c857 --- /dev/null +++ b/fixtures/negative/example-surface-bad-enum.dspack.json @@ -0,0 +1,60 @@ +{ + "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": "S2: button variant 'danger' is not an allowed enum value (default | destructive).", + "examples": [ + { + "id": "ex.fixture", + "surface": { + "dspackSurface": "0.1", + "system": "negative-fixture", + "intent": "destructive-action", + "root": { + "component": "button", + "props": { + "variant": "danger" + }, + "text": "Delete" + } + }, + "intent": "destructive-action" + } + ] +} diff --git a/fixtures/negative/example-surface-invalid.dspack.json b/fixtures/negative/example-surface-invalid.dspack.json new file mode 100644 index 0000000..a84c39e --- /dev/null +++ b/fixtures/negative/example-surface-invalid.dspack.json @@ -0,0 +1,56 @@ +{ + "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": "S1: example surface root node lacks the required 'component' field.", + "examples": [ + { + "id": "ex.fixture", + "surface": { + "dspackSurface": "0.1", + "system": "negative-fixture", + "intent": "destructive-action", + "root": { + "text": "orphan text node" + } + }, + "intent": "destructive-action" + } + ] +} diff --git a/fixtures/negative/example-surface-out-of-vocabulary.dspack.json b/fixtures/negative/example-surface-out-of-vocabulary.dspack.json new file mode 100644 index 0000000..73452e2 --- /dev/null +++ b/fixtures/negative/example-surface-out-of-vocabulary.dspack.json @@ -0,0 +1,56 @@ +{ + "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": "S2: surface uses component 'carousel' which is not in the contract vocabulary.", + "examples": [ + { + "id": "ex.fixture", + "surface": { + "dspackSurface": "0.1", + "system": "negative-fixture", + "intent": "destructive-action", + "root": { + "component": "carousel" + } + }, + "intent": "destructive-action" + } + ] +} diff --git a/fixtures/negative/intent-non-x-unknown-key.dspack.json b/fixtures/negative/intent-non-x-unknown-key.dspack.json new file mode 100644 index 0000000..356e7eb --- /dev/null +++ b/fixtures/negative/intent-non-x-unknown-key.dspack.json @@ -0,0 +1,19 @@ +{ + "_comment": "SCHEMA: non-x unknown key 'unknownField' on an intent entry must be rejected (x-* extension keys are the only permitted additions).", + "dspack": "0.3", + "name": "negative-fixture", + "components": { + "button": { + "name": "Button", + "description": "A button." + } + }, + "intents": [ + { + "id": "destructive-action", + "description": "Irreversible operations.", + "x-owner": "design-systems-guild", + "unknownField": true + } + ] +} diff --git a/fixtures/negative/rule-component-choice-empty.dspack.json b/fixtures/negative/rule-component-choice-empty.dspack.json new file mode 100644 index 0000000..a5a0e10 --- /dev/null +++ b/fixtures/negative/rule-component-choice-empty.dspack.json @@ -0,0 +1,55 @@ +{ + "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": "SCHEMA: component-choice rule with neither require nor forbid.", + "rules": [ + { + "id": "rule.fixture", + "severity": "must", + "rationale": "Fixture rationale.", + "type": "component-choice", + "appliesTo": { + "intents": [ + "destructive-action" + ] + } + } + ] +} diff --git a/fixtures/negative/rule-missing-rationale.dspack.json b/fixtures/negative/rule-missing-rationale.dspack.json new file mode 100644 index 0000000..7bb006a --- /dev/null +++ b/fixtures/negative/rule-missing-rationale.dspack.json @@ -0,0 +1,52 @@ +{ + "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": "SCHEMA: rule missing the mandatory rationale.", + "rules": [ + { + "id": "rule.fixture", + "severity": "must", + "type": "component-choice", + "require": [ + "alert-dialog" + ] + } + ] +} diff --git a/fixtures/negative/rule-unknown-component-ref.dspack.json b/fixtures/negative/rule-unknown-component-ref.dspack.json new file mode 100644 index 0000000..91078c5 --- /dev/null +++ b/fixtures/negative/rule-unknown-component-ref.dspack.json @@ -0,0 +1,58 @@ +{ + "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: rule requires component 'wizard' which the contract does not define.", + "rules": [ + { + "id": "rule.fixture", + "severity": "must", + "rationale": "Fixture rationale.", + "type": "component-choice", + "appliesTo": { + "intents": [ + "destructive-action" + ] + }, + "require": [ + "wizard" + ] + } + ] +} diff --git a/fixtures/negative/rule-unknown-example-ref.dspack.json b/fixtures/negative/rule-unknown-example-ref.dspack.json new file mode 100644 index 0000000..b74d00c --- /dev/null +++ b/fixtures/negative/rule-unknown-example-ref.dspack.json @@ -0,0 +1,61 @@ +{ + "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: rule references example 'ex.missing' which does not exist.", + "rules": [ + { + "id": "rule.fixture", + "severity": "must", + "rationale": "Fixture rationale.", + "type": "component-choice", + "appliesTo": { + "intents": [ + "destructive-action" + ] + }, + "require": [ + "alert-dialog" + ], + "examples": [ + "ex.missing" + ] + } + ] +} diff --git a/fixtures/negative/rule-unknown-type.dspack.json b/fixtures/negative/rule-unknown-type.dspack.json new file mode 100644 index 0000000..5645580 --- /dev/null +++ b/fixtures/negative/rule-unknown-type.dspack.json @@ -0,0 +1,53 @@ +{ + "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": "SCHEMA: rule type 'layout-order' is not a v0.3 rule type (unknown types must be rejected, never skipped).", + "rules": [ + { + "id": "rule.fixture", + "severity": "must", + "rationale": "Fixture rationale.", + "type": "layout-order", + "require": [ + "button" + ] + } + ] +} diff --git a/fixtures/negative/rule-unregistered-intent.dspack.json b/fixtures/negative/rule-unregistered-intent.dspack.json new file mode 100644 index 0000000..7915302 --- /dev/null +++ b/fixtures/negative/rule-unregistered-intent.dspack.json @@ -0,0 +1,58 @@ +{ + "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: rule appliesTo intent 'bulk-edit' is not registered in intents[].", + "rules": [ + { + "id": "rule.fixture", + "severity": "must", + "rationale": "Fixture rationale.", + "type": "component-choice", + "appliesTo": { + "intents": [ + "bulk-edit" + ] + }, + "require": [ + "alert-dialog" + ] + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..01a47fe --- /dev/null +++ b/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "@aestheticfunction/dspack-spec", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aestheticfunction/dspack-spec", + "version": "0.3.0", + "license": "Apache-2.0", + "devDependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..97ca3fc --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "@aestheticfunction/dspack-spec", + "version": "0.3.0", + "private": true, + "description": "Validation harness for the dspack specification artifacts: schemas, examples, and negative fixtures.", + "type": "module", + "license": "Apache-2.0", + "scripts": { + "validate": "node scripts/validate.mjs" + }, + "devDependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/schema/README.md b/schema/README.md index 7863dd8..84e3a65 100644 --- a/schema/README.md +++ b/schema/README.md @@ -4,6 +4,25 @@ JSON Schema and related validation artifacts for dspack live in this directory. ## Current schemas +- [`dspack.v0.3.schema.json`](./dspack.v0.3.schema.json) — JSON Schema for dspack v0.3 + (current draft; adds the governance blocks: `intents`, `rules`, `examples`) +- [`dspack.surface.v0_1.schema.json`](./dspack.surface.v0_1.schema.json) — JSON Schema for + the dspack **surface** document (v0.1), the protocol-neutral component tree that governance + rules are evaluated against and that `examples[].surface` embeds +- [`dspack.v0.2.schema.json`](./dspack.v0.2.schema.json) — JSON Schema for dspack v0.2 - [`dspack.v0.1.schema.json`](./dspack.v0.1.schema.json) — JSON Schema for dspack v0.1 -The schema validates the structure defined in the [v0.1 specification](../spec/dspack-v0.1.md). It enforces required fields, type constraints, and ID naming conventions. It does not validate cross-references between sections (e.g., whether a component ID referenced in a pattern exists in the `components` object). +Each dspack schema validates the structure defined in the matching specification under +[`spec/`](../spec). Schemas enforce required fields, type constraints, and ID naming +conventions; they do **not** validate cross-references between sections (e.g., whether a +component ID referenced in a rule exists in `components`). + +Cross-reference consistency — plus validation of every `examples[].surface` against the +surface schema (gate S1) and the contract vocabulary (gate S2) — is checked by the +repository's validation harness: + +```bash +npm ci +npm run validate # schemas compile; examples valid; consistency + S1/S2 +npm run validate -- --fixtures negative # every fixtures/negative/* must be rejected +``` diff --git a/schema/dspack.surface.v0_1.schema.json b/schema/dspack.surface.v0_1.schema.json new file mode 100644 index 0000000..b646bac --- /dev/null +++ b/schema/dspack.surface.v0_1.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/aestheticfunction/dspack/blob/main/schema/dspack.surface.v0_1.schema.json", + "title": "dspack surface v0.1", + "description": "Schema for a dspack surface document — a protocol-neutral, nested component tree expressed in a dspack contract's vocabulary (component IDs, sub-component IDs, props, slots, text leaves) plus a declared generation intent. A surface is the pipeline's intermediate representation: never rendered, never transported, always compiled to a protocol by an emitter. This generic schema is the portable validation floor (gate S1); checking the tree against a specific contract's vocabulary is a separate gate (S2), and governance rules are a third (S3). The schema answers whether the object can exist; the linter answers whether it is correct; a renderer answers whether its compiled form can render. These layers must never collapse.", + "type": "object", + "required": ["dspackSurface", "system", "intent", "root"], + "properties": { + "dspackSurface": { + "type": "string", + "const": "0.1", + "description": "Surface format version. MUST be \"0.1\" for documents conforming to this version." + }, + "system": { + "type": "string", + "minLength": 1, + "description": "Name of the design system contract this surface is expressed against (the contract's top-level name)." + }, + "intent": { + "type": "string", + "description": "The declared generation intent (an intent ID registered in the bound contract). Declared by the caller, carried in the artifact, and used by the linter to activate intent-scoped rules." + }, + "root": { + "$ref": "#/$defs/node", + "description": "The root component node of the surface tree." + } + }, + "additionalProperties": false, + "$defs": { + "node": { + "type": "object", + "description": "A component instance in the surface tree. 'component' is a component ID or sub-component ID from the bound contract's vocabulary.", + "required": ["component"], + "properties": { + "component": { + "type": "string", + "description": "Component ID or sub-component ID from the bound contract." + }, + "id": { + "type": "string", + "description": "Optional stable identifier for this node, used in lint finding locations and emitted artifacts." + }, + "props": { + "type": "object", + "description": "Prop values for this node. Prop names and enum values are validated against the bound contract (gate S2), not by this schema." + }, + "text": { + "type": "string", + "description": "Text content for leaf nodes (components or sub-components that accept text children)." + }, + "children": { + "type": "array", + "description": "Ordered child nodes.", + "items": { + "$ref": "#/$defs/node" + } + }, + "slots": { + "type": "object", + "description": "Named slot contents, keyed by slot name declared on the parent's sub-components in the bound contract.", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/node" + } + } + } + }, + "additionalProperties": false + } + } +} diff --git a/schema/dspack.v0.3.schema.json b/schema/dspack.v0.3.schema.json new file mode 100644 index 0000000..a743414 --- /dev/null +++ b/schema/dspack.v0.3.schema.json @@ -0,0 +1,1213 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/aestheticfunction/dspack/blob/main/schema/dspack.v0.3.schema.json", + "title": "dspack v0.3", + "description": "Schema for dspack v0.3 — a JSON format for representing design system corpora. Backward-compatible with v0.1 and v0.2 documents; adds the machine-checkable governance blocks (intents, rules, examples).", + "type": "object", + "required": ["dspack", "name"], + "properties": { + "$schema": { + "type": "string", + "description": "URI reference to this JSON Schema. Optional; consumers MUST NOT require this property." + }, + "dspack": { + "type": "string", + "const": "0.3", + "description": "Specification version. MUST be \"0.3\" for documents conforming to this version." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name of the design system." + }, + "description": { + "type": "string", + "description": "Brief description of the design system's purpose and scope." + }, + "version": { + "type": "string", + "description": "Version of the design system content (not the spec version)." + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "tokens": { + "type": "object", + "description": "Token definitions organized by category.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/tokenCategory" + } + }, + "components": { + "type": "object", + "description": "Component definitions keyed by component ID.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/componentEntry" + } + }, + "patterns": { + "type": "array", + "description": "Pattern entries describing preferred ways of combining components.", + "items": { + "$ref": "#/$defs/patternEntry" + } + }, + "antiPatterns": { + "type": "array", + "description": "Anti-pattern entries describing approaches that are deliberately ruled out.", + "items": { + "$ref": "#/$defs/antiPatternEntry" + } + }, + "intents": { + "type": "array", + "description": "Named generation intents \u2014 the vocabulary that scopes governance rules and examples. Referenced by rules[].appliesTo.intents and examples[].intent, and declared by callers when requesting generation.", + "items": { + "$ref": "#/$defs/intentEntry" + } + }, + "rules": { + "type": "array", + "description": "Machine-checkable governance rules, evaluated deterministically over dspack surface documents. Each rule is a typed, structured predicate plus a human-readable rationale. Evaluation semantics per type are normative in the v0.3 specification.", + "items": { + "$ref": "#/$defs/ruleEntry" + } + }, + "examples": { + "type": "array", + "description": "Compilable example surfaces tied to named intents. Examples serve double duty as documentation and few-shot exemplars; each surface must validate against the dspack surface schema and the contract vocabulary.", + "items": { + "$ref": "#/$defs/exampleEntry" + } + }, + "frameworkBindings": { + "type": "object", + "description": "Framework-specific information keyed by framework identifier.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/frameworkBinding" + } + }, + "themes": { + "type": "object", + "description": "Named sets of token overrides representing alternative visual modes.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/themeEntry" + } + }, + "layout": { + "$ref": "#/$defs/layoutPrimitives" + } + }, + "additionalProperties": true, + "$defs": { + "metadata": { + "type": "object", + "description": "Extensible metadata about the dspack file.", + "properties": { + "generatedBy": { + "type": "string", + "description": "Tool or process that created this file." + }, + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when the file was generated." + }, + "source": { + "type": "string", + "description": "URL or description of the upstream source." + }, + "license": { + "type": "string", + "description": "SPDX license identifier or freeform description." + } + }, + "additionalProperties": true + }, + "tokenCategory": { + "type": "object", + "description": "A category of tokens.", + "required": ["values"], + "properties": { + "description": { + "type": "string", + "description": "What this category covers." + }, + "tier": { + "type": "string", + "enum": ["primitive", "semantic", "component"], + "description": "Default abstraction level for tokens in this category." + }, + "values": { + "type": "object", + "description": "Map of token name to token entry.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/tokenEntry" + } + } + }, + "additionalProperties": true + }, + "tokenEntry": { + "type": "object", + "description": "A single token definition.", + "required": ["value"], + "properties": { + "value": { + "type": "string", + "description": "The resolved value of the token." + }, + "description": { + "type": "string", + "description": "Semantic meaning of the token." + }, + "type": { + "type": "string", + "description": "The value type (e.g., color, dimension, fontFamily)." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this token is deprecated.", + "default": false + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Other names by which this token is known." + }, + "status": { + "oneOf": [ + { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"] + }, + { + "$ref": "#/$defs/statusObject" + } + ], + "description": "Lifecycle stage. String for uniform status, object for per-platform granularity." + }, + "aliasOf": { + "oneOf": [ + { + "type": "string", + "description": "Token name that this token aliases." + }, + { + "$ref": "#/$defs/aliasReference" + } + ], + "description": "Token that this token aliases. String for unambiguous names, object for cross-category disambiguation." + }, + "tier": { + "type": "string", + "enum": ["primitive", "semantic", "component"], + "description": "Abstraction level of this token, overriding the category default." + } + }, + "additionalProperties": true + }, + "statusObject": { + "type": "object", + "description": "Lifecycle status with per-platform granularity.", + "required": ["default"], + "properties": { + "default": { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"], + "description": "Default lifecycle stage when no platform-specific override applies." + }, + "platforms": { + "type": "object", + "description": "Map of platform/framework ID to platform status object.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/platformStatus" + } + } + }, + "additionalProperties": true + }, + "platformStatus": { + "type": "object", + "description": "Lifecycle status for a specific platform.", + "required": ["stage"], + "properties": { + "stage": { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"], + "description": "Lifecycle stage for this platform." + }, + "since": { + "type": "string", + "description": "Version of the design system content at which this stage took effect." + }, + "migrateTo": { + "type": "string", + "description": "Component ID or token name of the recommended replacement." + }, + "note": { + "type": "string", + "description": "Prose migration guidance or context for this platform's status." + } + }, + "additionalProperties": true + }, + "aliasReference": { + "type": "object", + "description": "Structured token alias reference for cross-category disambiguation.", + "required": ["category", "token"], + "properties": { + "category": { + "type": "string", + "description": "Token category name containing the referenced token." + }, + "token": { + "type": "string", + "description": "Token name within that category." + } + }, + "additionalProperties": true + }, + "componentEntry": { + "type": "object", + "description": "A component definition.", + "required": ["name", "description"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "What the component is for." + }, + "whenToUse": { + "type": "string", + "description": "Guidance on when to use this component." + }, + "whenNotToUse": { + "type": "string", + "description": "Guidance on when to choose a different component." + }, + "props": { + "type": "object", + "description": "Map of prop name to prop descriptor.", + "additionalProperties": { + "$ref": "#/$defs/propDescriptor" + } + }, + "tokens": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Token names this component depends on." + }, + "relatedComponents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs of related components." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this component is deprecated.", + "default": false + }, + "deprecatedMessage": { + "type": "string", + "description": "What to use instead of this component." + }, + "status": { + "oneOf": [ + { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"] + }, + { + "$ref": "#/$defs/statusObject" + } + ], + "description": "Lifecycle stage. String for uniform status, object for per-platform granularity." + }, + "accessibility": { + "$ref": "#/$defs/accessibilityConstraints" + }, + "composition": { + "$ref": "#/$defs/compositionRules" + }, + "constraints": { + "type": "array", + "description": "Structured usage constraints.", + "items": { + "$ref": "#/$defs/constraintEntry" + } + } + }, + "additionalProperties": true + }, + "propDescriptor": { + "type": "object", + "description": "Describes a single component prop.", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "The value type of the prop (e.g., string, number, boolean, enum)." + }, + "description": { + "type": "string", + "description": "What this prop controls." + }, + "values": { + "type": "array", + "description": "For enum type, the allowed values. Items may be bare values or value descriptor objects.", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "$ref": "#/$defs/valueDescriptor" } + ] + } + }, + "default": { + "description": "Default value of the prop." + }, + "required": { + "type": "boolean", + "description": "Whether this prop must be provided.", + "default": false + }, + "propRole": { + "type": "string", + "enum": ["flag", "dimension", "choice", "slot", "handler", "content", "state"], + "description": "Semantic role of this prop." + } + }, + "additionalProperties": true + }, + "valueDescriptor": { + "type": "object", + "description": "Describes a single allowed value for an enum prop.", + "required": ["value"], + "properties": { + "value": { + "description": "The actual enum value." + }, + "description": { + "type": "string", + "description": "When to choose this value." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this specific value is deprecated.", + "default": false + } + }, + "additionalProperties": true + }, + "accessibilityConstraints": { + "type": "object", + "description": "Accessibility constraints and expectations for a component.", + "properties": { + "role": { + "type": "string", + "description": "WAI-ARIA role this component fulfills." + }, + "requiredAttributes": { + "type": "array", + "description": "HTML or ARIA attributes that must be present for correct accessible usage.", + "items": { + "$ref": "#/$defs/attributeDescriptor" + } + }, + "keyboardInteractions": { + "type": "array", + "description": "Expected keyboard behaviors.", + "items": { + "$ref": "#/$defs/keyboardInteraction" + } + }, + "contrastRequirement": { + "type": "string", + "description": "Minimum contrast ratio or WCAG level." + }, + "focusManagement": { + "type": "string", + "description": "Prose description of focus behavior expectations." + }, + "labelRequirement": { + "type": "string", + "enum": ["required-visible", "required-accessible-name", "required-aria", "optional", "none"], + "description": "How the component must be labeled." + }, + "notes": { + "type": "string", + "description": "Additional accessibility guidance in prose." + } + }, + "additionalProperties": true + }, + "attributeDescriptor": { + "type": "object", + "description": "Describes a required HTML or ARIA attribute for accessible usage.", + "required": ["attribute"], + "properties": { + "attribute": { + "type": "string", + "description": "The attribute name (e.g., aria-label, aria-describedby, id, type)." + }, + "description": { + "type": "string", + "description": "When and how to provide this attribute." + }, + "condition": { + "type": "string", + "description": "Condition under which this attribute is required." + } + }, + "additionalProperties": true + }, + "keyboardInteraction": { + "type": "object", + "description": "Describes an expected keyboard behavior.", + "required": ["key", "description"], + "properties": { + "key": { + "type": "string", + "description": "The key or key combination." + }, + "description": { + "type": "string", + "description": "What this key does in the context of this component." + } + }, + "additionalProperties": true + }, + "compositionRules": { + "type": "object", + "description": "Rules governing how a component composes with other components.", + "properties": { + "subComponents": { + "type": "array", + "description": "Sub-components that belong to this compound component.", + "items": { + "$ref": "#/$defs/subComponentDescriptor" + } + }, + "requiredChildren": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that must appear as descendants." + }, + "allowedChildren": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that may appear as direct children." + }, + "requiredParent": { + "type": "string", + "description": "Component ID or sub-component ID that must be an ancestor." + }, + "allowedParents": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that may be the parent." + }, + "requiredSiblings": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that must also be present among siblings." + }, + "notes": { + "type": "string", + "description": "Prose description of composition constraints not captured by structured fields." + } + }, + "additionalProperties": true + }, + "subComponentDescriptor": { + "type": "object", + "description": "Describes a sub-component of a compound component.", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Identifier for the sub-component. Should be parent-prefixed." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "What this sub-component is for." + }, + "required": { + "type": "boolean", + "description": "Whether this sub-component must be present when the parent is used.", + "default": false + }, + "slot": { + "type": "string", + "description": "Named slot this sub-component fills." + }, + "acceptsChildren": { + "type": "string", + "enum": ["any", "text", "components", "none"], + "description": "What this sub-component expects as children." + } + }, + "additionalProperties": true + }, + "constraintEntry": { + "type": "object", + "description": "A structured usage constraint.", + "required": ["context", "rule", "severity"], + "properties": { + "context": { + "type": "string", + "description": "The situation or condition this constraint applies to." + }, + "rule": { + "type": "string", + "description": "What to do or not do." + }, + "severity": { + "type": "string", + "enum": ["must", "should", "should-not", "must-not"], + "description": "RFC 2119 strength of the constraint." + } + }, + "additionalProperties": true + }, + "patternEntry": { + "type": "object", + "description": "A pattern describing a preferred way of combining components.", + "required": ["id", "name", "description"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique identifier for this pattern." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name." + }, + "description": { + "type": "string", + "description": "What problem this pattern addresses." + }, + "intent": { + "type": "string", + "description": "The underlying design goal or UX objective." + }, + "context": { + "type": "string", + "description": "When this pattern applies." + }, + "components": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs involved in this pattern." + }, + "guidance": { + "type": "string", + "description": "Prose guidance on how to apply the pattern correctly." + }, + "relatedPatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pattern IDs of related patterns." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + } + }, + "additionalProperties": true + }, + "antiPatternEntry": { + "type": "object", + "description": "An anti-pattern describing an approach that is deliberately ruled out.", + "required": ["id", "name", "description", "reason"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique identifier for this anti-pattern." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name describing what not to do." + }, + "description": { + "type": "string", + "description": "What this anti-pattern is." + }, + "reason": { + "type": "string", + "description": "Why this approach is ruled out." + }, + "severity": { + "type": "string", + "enum": ["must-not", "should-not", "discouraged"], + "description": "Strength of the prohibition. Defaults to should-not.", + "default": "should-not" + }, + "insteadUse": { + "type": "string", + "description": "Pattern ID of the preferred approach." + }, + "components": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs involved in this anti-pattern." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + } + }, + "additionalProperties": true + }, + "intentEntry": { + "type": "object", + "description": "A named generation intent: what kind of UI is being requested. Intents scope governance rules and select examples.", + "required": ["id", "description"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique identifier for this intent." + }, + "name": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "What kind of requested UI this intent covers. Written for both humans and generation prompts." + }, + "relatedPatterns": { + "type": "array", + "description": "IDs of patterns that document how to satisfy this intent.", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "description": "Free-form tags for grouping and search.", + "items": { + "type": "string" + } + } + }, + "patternProperties": { "^x-": {} }, + "additionalProperties": false + }, + "ruleEntry": { + "type": "object", + "description": "A machine-checkable governance rule. The type field selects the evaluation algorithm (normative semantics in the v0.3 spec). Linters MUST fail loudly on unknown types \u2014 never skip silently. Contract severity uses RFC 2119 terms; tools map must\u2192error and should\u2192warn.", + "required": ["id", "type", "severity", "rationale"], + "properties": { + "id": { + "type": "string", + "pattern": "^rule\\.[a-z0-9.-]+$", + "description": "Unique, stable identifier for this rule (rule.* namespace)." + }, + "type": { + "type": "string", + "enum": ["component-choice", "required-composition", "forbidden-composition"], + "description": "The rule type, selecting the evaluation algorithm. New types are added in future spec versions only (additive)." + }, + "severity": { + "type": "string", + "enum": ["must", "should"], + "description": "RFC 2119 strength. Tools map must\u2192error (triggers repair/failure) and should\u2192warn (reported only)." + }, + "rationale": { + "type": "string", + "minLength": 1, + "description": "Why this rule exists. Shown verbatim in lint findings, repair feedback, and audit reports." + }, + "appliesTo": { + "type": "object", + "description": "Scope of the rule. Absent means universal (fires for every surface).", + "properties": { + "intents": { + "type": "array", + "description": "Intent IDs this rule fires for.", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "patternProperties": { "^x-": {} }, + "additionalProperties": false + }, + "examples": { + "type": "array", + "description": "IDs of examples demonstrating compliance; included in repair feedback as corrected references.", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "description": "Free-form tags for grouping and search.", + "items": { + "type": "string" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "component-choice" + } + } + }, + "then": { + "anyOf": [{ "required": ["require"] }, { "required": ["forbid"] }], + "properties": { + "require": { + "type": "array", + "description": "Component IDs that MUST each appear at least once in the surface.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "forbid": { + "type": "array", + "description": "Component IDs that MUST NOT appear in the surface.", + "items": { + "type": "string" + }, + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "required-composition" + } + } + }, + "then": { + "required": ["component"], + "anyOf": [{ "required": ["requiredSubComponents"] }, { "required": ["requiredProps"] }], + "properties": { + "component": { + "type": "string", + "description": "Component ID every instance of which is checked." + }, + "requiredSubComponents": { + "type": "array", + "description": "Sub-components that MUST appear among each matching node's descendants.", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "Sub-component ID that must be present." + }, + "min": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Minimum number of occurrences among descendants." + } + }, + "patternProperties": { "^x-": {} }, + "additionalProperties": false + }, + "minItems": 1 + }, + "requiredProps": { + "type": "array", + "description": "Prop constraints that MUST hold on each matching node (or on descendant sub-component nodes when 'on' is given).", + "items": { + "type": "object", + "required": ["prop", "oneOf"], + "properties": { + "on": { + "type": "string", + "description": "Sub-component ID the constraint applies to; absent means the matching component node itself." + }, + "prop": { + "type": "string", + "description": "Prop name the constraint applies to." + }, + "oneOf": { + "type": "array", + "description": "Allowed values; the prop MUST be present and take one of these.", + "minItems": 1 + } + }, + "patternProperties": { "^x-": {} }, + "additionalProperties": false + }, + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "forbidden-composition" + } + } + }, + "then": { + "required": ["component"], + "anyOf": [{ "required": ["forbiddenDescendants"] }, { "required": ["forbiddenProps"] }], + "properties": { + "component": { + "type": "string", + "description": "Component ID every instance of which is checked." + }, + "forbiddenDescendants": { + "type": "array", + "description": "Component or sub-component IDs that MUST NOT appear among a matching node's descendants.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "forbiddenProps": { + "type": "array", + "description": "Prop values that MUST NOT be used on matching nodes (or on descendant sub-component nodes when 'on' is given).", + "items": { + "type": "object", + "required": ["prop", "values"], + "properties": { + "on": { + "type": "string", + "description": "Sub-component ID the constraint applies to; absent means the matching component node itself." + }, + "prop": { + "type": "string", + "description": "Prop name the constraint applies to." + }, + "values": { + "type": "array", + "description": "Forbidden values for the prop.", + "minItems": 1 + } + }, + "patternProperties": { "^x-": {} }, + "additionalProperties": false + }, + "minItems": 1 + } + } + } + } + ] + }, + "exampleEntry": { + "type": "object", + "description": "A compilable example surface tied to a named intent. Serves as documentation and as a few-shot exemplar for generation.", + "required": ["id", "intent", "surface"], + "properties": { + "id": { + "type": "string", + "pattern": "^ex\\.[a-z0-9.-]+$", + "description": "Unique identifier for this example (ex.* namespace)." + }, + "intent": { + "type": "string", + "description": "Intent ID this example demonstrates." + }, + "name": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "What the example shows and why it is correct." + }, + "prompt": { + "type": "string", + "description": "A representative user request this example answers; used as the user turn in few-shot blocks." + }, + "surface": { + "type": "object", + "description": "A dspack surface document. Validated against dspack.surface.v0_1.schema.json plus the contract vocabulary by tooling; kept loose here to avoid a cross-file $ref." + } + }, + "patternProperties": { "^x-": {} }, + "additionalProperties": false + }, + "frameworkBinding": { + "type": "object", + "description": "Framework-specific information for the design system.", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable framework name." + }, + "package": { + "type": "string", + "description": "Primary package name." + }, + "installCommand": { + "type": "string", + "description": "How to install the framework binding." + }, + "description": { + "type": "string", + "description": "What this binding provides." + }, + "guidance": { + "type": "string", + "description": "Framework-wide guidance." + }, + "components": { + "type": "object", + "description": "Per-component framework details keyed by component ID.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/componentBinding" + } + } + }, + "additionalProperties": true + }, + "componentBinding": { + "type": "object", + "description": "Framework-specific details for a single component.", + "properties": { + "importPath": { + "type": "string", + "description": "Where to import the component." + }, + "installCommand": { + "type": "string", + "description": "Component-specific install command." + }, + "exportName": { + "type": "string", + "description": "Named export if different from the component name." + }, + "guidance": { + "type": "string", + "description": "Framework-specific usage guidance for this component." + }, + "subComponents": { + "type": "object", + "description": "Map of sub-component ID to sub-component binding.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/subComponentBinding" + } + } + }, + "additionalProperties": true + }, + "subComponentBinding": { + "type": "object", + "description": "Framework-specific details for a sub-component.", + "properties": { + "exportName": { + "type": "string", + "description": "Named export for this sub-component." + }, + "importPath": { + "type": "string", + "description": "Import path if different from the parent component's import path." + } + }, + "additionalProperties": true + }, + "themeEntry": { + "type": "object", + "description": "A named set of token overrides representing an alternative visual mode.", + "required": ["name", "overrides"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable theme name." + }, + "description": { + "type": "string", + "description": "What this theme is for." + }, + "overrides": { + "type": "object", + "description": "Map of token reference (category.tokenName) to overridden resolved value.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": true + }, + "layoutPrimitives": { + "type": "object", + "description": "Layout system primitives: breakpoints, grid, containers, and spacing scale.", + "properties": { + "breakpoints": { + "type": "object", + "description": "Named responsive breakpoints.", + "additionalProperties": { + "$ref": "#/$defs/breakpointEntry" + } + }, + "grid": { + "$ref": "#/$defs/gridConfig" + }, + "containers": { + "type": "object", + "description": "Named container width configurations.", + "additionalProperties": { + "$ref": "#/$defs/containerEntry" + } + }, + "spacingScale": { + "$ref": "#/$defs/spacingScaleConfig" + } + }, + "additionalProperties": true + }, + "breakpointEntry": { + "type": "object", + "description": "A responsive breakpoint definition.", + "required": ["minWidth"], + "properties": { + "minWidth": { + "type": "string", + "description": "Minimum viewport width for this breakpoint." + }, + "description": { + "type": "string", + "description": "What this breakpoint targets." + } + }, + "additionalProperties": true + }, + "gridConfig": { + "type": "object", + "description": "Grid system parameters.", + "properties": { + "columns": { + "type": "number", + "description": "Number of columns in the grid system." + }, + "gutter": { + "type": "string", + "description": "Default gutter width between columns." + }, + "margin": { + "type": "string", + "description": "Default outer margin of the grid container." + }, + "description": { + "type": "string", + "description": "Guidance on grid usage." + } + }, + "additionalProperties": true + }, + "containerEntry": { + "type": "object", + "description": "A container width configuration.", + "required": ["maxWidth"], + "properties": { + "maxWidth": { + "type": "string", + "description": "Maximum width of this container." + }, + "description": { + "type": "string", + "description": "When to use this container size." + } + }, + "additionalProperties": true + }, + "spacingScaleConfig": { + "type": "object", + "description": "Spacing scale system description.", + "properties": { + "baseUnit": { + "type": "string", + "description": "The fundamental unit of the spacing scale." + }, + "description": { + "type": "string", + "description": "How the scale is constructed." + } + }, + "additionalProperties": true + } + } +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..5c8b715 --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +/** + * Validation harness for the dspack specification repository. + * + * Default mode (`npm run validate`): + * 1. schema-compile — every schema in schema/ compiles as a draft 2020-12 + * JSON Schema under ajv (strict: false, the toolchain convention shared + * with dspack-to-a2ui). + * 2. examples — every examples/*.dspack.json validates against the schema + * matching its declared `dspack` version. + * 3. back-compat — for v0.3 documents, the document with the governance + * blocks (intents/rules/examples) removed still validates against the + * v0.3 schema (the "v0.2 shape + dspack: 0.3 is valid" guarantee). + * 4. governance consistency — for v0.3 documents: unique IDs, intent + * references resolve, rule component references resolve, rule example + * references resolve, and every examples[].surface passes: + * S1 — the generic dspack surface schema, and + * S2 — the contract vocabulary (component/sub-component IDs, prop + * names, enum prop values, declared slot names). + * S2 here checks exactly what the v0.3 spec defines for the gate; it + * does not check acceptsChildren semantics or non-enum prop types. + * + * Negative mode (`npm run validate -- --fixtures negative`): + * Runs the same full validation over fixtures/negative/*.dspack.json and + * exits 0 iff every fixture is rejected (each must fail schema validation + * or a consistency check). A fixture that unexpectedly passes is a harness + * defect and fails the run. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SCHEMA_DIR = join(ROOT, "schema"); +const EXAMPLES_DIR = join(ROOT, "examples"); +const NEGATIVE_DIR = join(ROOT, "fixtures", "negative"); + +const DSPACK_SCHEMAS = { + "0.1": "dspack.v0.1.schema.json", + "0.2": "dspack.v0.2.schema.json", + "0.3": "dspack.v0.3.schema.json", +}; +const SURFACE_SCHEMA = "dspack.surface.v0_1.schema.json"; + +function newAjv() { + const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: true }); + addFormats(ajv); + return ajv; +} + +const loadJson = (path) => JSON.parse(readFileSync(path, "utf8")); +const fmtErr = (e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim(); + +/** Compile every schema; returns { validators, failures }. */ +function compileSchemas() { + const validators = new Map(); + const failures = []; + const files = readdirSync(SCHEMA_DIR).filter((f) => f.endsWith(".schema.json")); + for (const file of files) { + try { + validators.set(file, newAjv().compile(loadJson(join(SCHEMA_DIR, file)))); + } catch (e) { + failures.push(`${file}: ${e instanceof Error ? e.message : String(e)}`); + } + } + return { validators, failures }; +} + +/** + * Build the vocabulary of a contract: + * - components: Map componentId -> { props: Map propName -> descriptor, slots: Set slotName } + * - subComponents: Map subComponentId -> parent componentId + * - duplicateSubIds: sub-component IDs declared by more than one component. + * Duplicates would make S2 checks and rule reference resolution depend on + * object iteration order, so callers MUST surface them as consistency + * errors (spec §5: sub-component IDs must be unique document-wide). + */ +function buildVocabulary(doc) { + const components = new Map(); + const subComponents = new Map(); + const duplicateSubIds = new Set(); + for (const [id, entry] of Object.entries(doc.components ?? {})) { + const props = new Map(Object.entries(entry.props ?? {})); + const slots = new Set(); + for (const sub of entry.composition?.subComponents ?? []) { + if (sub.id) { + if (subComponents.has(sub.id) && subComponents.get(sub.id) !== id) duplicateSubIds.add(sub.id); + subComponents.set(sub.id, id); + } + if (sub.slot) slots.add(sub.slot); + } + components.set(id, { props, slots }); + } + return { components, subComponents, duplicateSubIds }; +} + +/** Allowed values for an enum prop descriptor (bare values or valueDescriptor objects). */ +function enumValues(descriptor) { + if (descriptor.type !== "enum" || !Array.isArray(descriptor.values)) return null; + return descriptor.values.map((v) => (v && typeof v === "object" ? v.value : v)); +} + +/** Gate S2: walk a surface tree against a contract vocabulary. Returns error strings. */ +function checkVocabulary(surface, vocab) { + const errors = []; + const walk = (node, path) => { + if (!node || typeof node !== "object") return; + const cid = node.component; + const isComponent = vocab.components.has(cid); + const isSub = vocab.subComponents.has(cid); + if (!isComponent && !isSub) { + errors.push(`${path}: component '${cid}' is not a component or sub-component of the contract`); + } + if (node.props && Object.keys(node.props).length > 0) { + if (isSub) { + errors.push(`${path}: sub-component '${cid}' does not declare props in this contract`); + } else if (isComponent) { + const { props } = vocab.components.get(cid); + for (const [name, value] of Object.entries(node.props)) { + const descriptor = props.get(name); + if (!descriptor) { + errors.push(`${path}: prop '${name}' is not declared on component '${cid}'`); + continue; + } + const allowed = enumValues(descriptor); + if (allowed && !allowed.includes(value)) { + errors.push( + `${path}: prop '${name}' on '${cid}' has value ${JSON.stringify(value)}; allowed: ${allowed.map((v) => JSON.stringify(v)).join(", ")}`, + ); + } + } + } + } + if (node.slots) { + const slots = isComponent ? vocab.components.get(cid).slots : new Set(); + for (const [slotName, children] of Object.entries(node.slots)) { + if (!slots.has(slotName)) { + errors.push(`${path}: slot '${slotName}' is not declared on component '${cid}'`); + } + children.forEach((child, i) => walk(child, `${path}.slots.${slotName}[${i}]`)); + } + } + (node.children ?? []).forEach((child, i) => walk(child, `${path}.children[${i}]`)); + }; + walk(surface.root, "$.root"); + return errors; +} + +/** Every component/sub-component reference inside a rule, for resolution checks. */ +function ruleComponentRefs(rule) { + const refs = []; + const push = (kind, ids) => { + for (const id of ids ?? []) refs.push({ kind, id }); + }; + push("require", rule.require); + push("forbid", rule.forbid); + if (rule.component) refs.push({ kind: "component", id: rule.component }); + push("forbiddenDescendants", rule.forbiddenDescendants); + push("requiredSubComponents", (rule.requiredSubComponents ?? []).map((s) => s.id)); + push("on", (rule.requiredProps ?? []).map((p) => p.on).filter(Boolean)); + push("on", (rule.forbiddenProps ?? []).map((p) => p.on).filter(Boolean)); + return refs; +} + +/** Governance consistency checks for a v0.3 document. Returns error strings. */ +function checkGovernance(doc, validateSurface) { + const errors = []; + // Spec §5 scopes governance consistency (incl. sub-component id uniqueness) + // to contracts that USE governance blocks — a pure v0.2-shaped document with + // "dspack": "0.3" must keep the strictly-additive guarantee. + if (!doc.intents && !doc.rules && !doc.examples) return errors; + const vocab = buildVocabulary(doc); + // Fail loudly on ambiguous vocabulary before any check that depends on it. + for (const id of vocab.duplicateSubIds) { + const parents = Object.entries(doc.components ?? {}) + .filter(([, entry]) => (entry.composition?.subComponents ?? []).some((s) => s.id === id)) + .map(([componentId]) => componentId); + errors.push( + `sub-component id '${id}' is declared by multiple components (${parents.join(", ")}); ` + + `sub-component ids must be unique document-wide for deterministic S2 and rule resolution`, + ); + } + const intents = new Set((doc.intents ?? []).map((i) => i.id)); + const exampleIds = new Set((doc.examples ?? []).map((e) => e.id)); + + const seen = new Set(); + for (const [block, key] of [ + ["intents", "id"], + ["rules", "id"], + ["examples", "id"], + ]) { + for (const entry of doc[block] ?? []) { + const tag = `${block}:${entry[key]}`; + if (seen.has(tag)) errors.push(`duplicate ${block} id '${entry[key]}'`); + seen.add(tag); + } + } + + for (const rule of doc.rules ?? []) { + for (const intent of rule.appliesTo?.intents ?? []) { + if (!intents.has(intent)) errors.push(`${rule.id}: appliesTo intent '${intent}' is not registered in intents[]`); + } + for (const { kind, id } of ruleComponentRefs(rule)) { + const resolvesToComponent = vocab.components.has(id); + const resolvesToSub = vocab.subComponents.has(id); + const ok = + kind === "requiredSubComponents" || kind === "on" + ? resolvesToSub + : kind === "component" + ? resolvesToComponent + : resolvesToComponent || resolvesToSub; + if (!ok) errors.push(`${rule.id}: ${kind} reference '${id}' does not resolve in the contract`); + } + for (const ex of rule.examples ?? []) { + if (!exampleIds.has(ex)) errors.push(`${rule.id}: example reference '${ex}' does not resolve`); + } + } + + for (const example of doc.examples ?? []) { + const where = example.id ?? "(example without id)"; + if (example.intent && !intents.has(example.intent)) { + errors.push(`${where}: intent '${example.intent}' is not registered in intents[]`); + } + const surface = example.surface; + if (!surface) continue; + // S1 — generic surface schema. + if (!validateSurface(surface)) { + for (const e of validateSurface.errors ?? []) errors.push(`${where}: S1 ${fmtErr(e)}`); + continue; // vocabulary walk needs a well-formed tree + } + if (surface.intent !== example.intent) { + errors.push(`${where}: surface.intent '${surface.intent}' does not match example intent '${example.intent}'`); + } + if (surface.system !== doc.name) { + errors.push(`${where}: surface.system '${surface.system}' does not match contract name '${doc.name}'`); + } + // S2 — contract vocabulary. + for (const e of checkVocabulary(surface, vocab)) errors.push(`${where}: S2 ${e}`); + } + + return errors; +} + +/** Fully validate one dspack document. Returns error strings (empty = valid). */ +function validateDocument(doc, validators) { + const errors = []; + const version = doc?.dspack; + const schemaFile = DSPACK_SCHEMAS[version]; + if (!schemaFile) return [`unknown or missing dspack version: ${JSON.stringify(version)}`]; + const validate = validators.get(schemaFile); + if (!validate) return [`schema ${schemaFile} did not compile`]; + if (!validate(doc)) { + for (const e of validate.errors ?? []) errors.push(`schema ${fmtErr(e)}`); + return errors; + } + if (version === "0.3") { + errors.push(...checkGovernance(doc, validators.get(SURFACE_SCHEMA))); + } + return errors; +} + +function listDocs(dir) { + try { + return readdirSync(dir) + .filter((f) => f.endsWith(".dspack.json")) + .map((f) => join(dir, f)); + } catch { + return []; + } +} + +function main() { + const args = process.argv.slice(2); + const negativeMode = args.includes("--fixtures") && args[args.indexOf("--fixtures") + 1] === "negative"; + + const { validators, failures } = compileSchemas(); + if (!validators.has(SURFACE_SCHEMA)) failures.push(`${SURFACE_SCHEMA}: missing`); + if (failures.length) { + console.error("schema-compile FAIL"); + for (const f of failures) console.error(` ✖ ${f}`); + process.exit(1); + } + console.log(`schema-compile PASS (${validators.size} schemas)`); + + if (negativeMode) { + const fixtures = listDocs(NEGATIVE_DIR); + if (fixtures.length === 0) { + console.error(`no negative fixtures found in ${NEGATIVE_DIR}`); + process.exit(1); + } + let unexpected = 0; + for (const path of fixtures) { + const errors = validateDocument(loadJson(path), validators); + if (errors.length === 0) { + console.error(` ✖ ${basename(path)}: expected to be rejected, but validated cleanly`); + unexpected++; + } else { + console.log(` ✔ ${basename(path)} rejected: ${errors[0]}`); + } + } + if (unexpected) { + console.error(`negative-fixtures FAIL (${unexpected} fixture(s) unexpectedly valid)`); + process.exit(1); + } + console.log(`negative-fixtures PASS (${fixtures.length} fixtures all rejected)`); + return; + } + + const docs = listDocs(EXAMPLES_DIR); + if (docs.length === 0) { + console.error(`no examples found in ${EXAMPLES_DIR}`); + process.exit(1); + } + let failed = 0; + for (const path of docs) { + const doc = loadJson(path); + const errors = validateDocument(doc, validators); + + // Back-compat guarantee: a v0.3 document minus governance blocks stays valid. + if (doc?.dspack === "0.3" && errors.length === 0) { + const stripped = { ...doc }; + delete stripped.intents; + delete stripped.rules; + delete stripped.examples; + const strippedErrors = validateDocument(stripped, validators); + for (const e of strippedErrors) errors.push(`back-compat (governance blocks removed): ${e}`); + } + + if (errors.length) { + failed++; + console.error(` ✖ ${basename(path)}`); + for (const e of errors) console.error(` ${e}`); + } else { + console.log(` ✔ ${basename(path)} (dspack ${doc.dspack})`); + } + } + if (failed) { + console.error(`examples FAIL (${failed} document(s) invalid)`); + process.exit(1); + } + console.log(`examples PASS (${docs.length} document(s))`); +} + +main(); diff --git a/spec/README.md b/spec/README.md index 972e2d5..1c69d73 100644 --- a/spec/README.md +++ b/spec/README.md @@ -4,6 +4,14 @@ Versioned specification documents for dspack live in this directory. ## Current drafts +- [`dspack-v0.3.md`](./dspack-v0.3.md) — v0.3 specification (current draft, written as a + delta over v0.2): the governance blocks (`intents`, `rules`, `examples`), normative rule + evaluation semantics, the severity mapping (must→error / should→warn), the dspack surface + format, and the S1/S2/S3 validation gates +- [`migration-v0.2-to-v0.3.md`](./migration-v0.2-to-v0.3.md) — migration guide (v0.3 is + strictly additive; includes a worked anti-pattern → rule conversion) +- [`dspack-v0.2.md`](./dspack-v0.2.md) — v0.2 specification (remains normative for everything + the v0.3 delta does not cover) - [`dspack-v0.1.md`](./dspack-v0.1.md) — v0.1 specification draft -The v0.1 draft defines the document structure, tokens, components, patterns, anti-patterns, framework bindings, extensibility model, and ID conventions. It is a draft and may change before stabilization at v1.0. +All drafts may change before stabilization at v1.0. diff --git a/spec/dspack-v0.3.md b/spec/dspack-v0.3.md new file mode 100644 index 0000000..3b4daf4 --- /dev/null +++ b/spec/dspack-v0.3.md @@ -0,0 +1,289 @@ +# dspack Specification — v0.3 (delta) + +**Status: draft.** + +This document specifies dspack v0.3 as a **delta over v0.2**. Everything in the +[v0.2 specification](./dspack-v0.2.md) remains normative and unchanged; v0.3 is strictly +additive. A valid v0.2 document with `"dspack": "0.3"` is a valid v0.3 document. + +v0.3 adds the **governance blocks**: three optional top-level properties — `intents`, `rules`, +and `examples` — that make a subset of a design system's institutional knowledge +machine-checkable. v0.2 already records governance as prose (`patterns`, `antiPatterns`, +`whenToUse`, `constraints`, `composition.notes`); v0.3 lets a contract shadow that prose with +deterministic predicates that a linter can evaluate and an agent pipeline can enforce. Prose +remains authoritative for humans; rules are authoritative for tools. + +The matching JSON Schema is [`schema/dspack.v0.3.schema.json`](../schema/dspack.v0.3.schema.json). +A companion schema, [`schema/dspack.surface.v0_1.schema.json`](../schema/dspack.surface.v0_1.schema.json), +defines the **dspack surface** document — the artifact rules are evaluated against (§7). + +## Table of Contents + +- [1. Conformance](#1-conformance) +- [2. File Identification](#2-file-identification) +- [3. The Three Layers](#3-the-three-layers) +- [4. Intents](#4-intents) +- [5. Rules](#5-rules) +- [6. Examples](#6-examples) +- [7. The dspack Surface Format](#7-the-dspack-surface-format) +- [8. Validation Gates](#8-validation-gates) +- [9. Deliberate Ceiling](#9-deliberate-ceiling) + +## 1. Conformance + +The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described +in RFC 2119. Conformance requirements for producers and consumers of v0.2 constructs are +unchanged. This document adds requirements for producers of governance blocks and for +**linters** — tools that evaluate `rules[]` against dspack surface documents. + +## 2. File Identification + +A v0.3 document declares `"dspack": "0.3"`. All three governance blocks are optional; a +document using none of them is still a v0.3 document. Consumers that understand only v0.2 MUST +ignore unknown top-level properties (per the v0.2 extensibility rules), so v0.3 documents +degrade safely. + +## 3. The Three Layers + +Three questions about a generated (or authored) UI object are answered by three different +layers, and these layers MUST never collapse: + +1. **The schema answers "can this object exist."** JSON Schema validation of shape and + vocabulary. Deterministic. +2. **The linter answers "is this object correct."** Governance rules evaluated over the + surface. Deterministic in v0.3 — every rule is a machine-checkable predicate plus a + human-readable rationale. +3. **The renderer answers "can this render."** A protocol emitter compiles the surface; the + target's own validation and renderer decide renderability. + +In particular: encoding governance into a generation schema (layer 1) would make violations +unobservable and any audit trail vacuous. Generation schemas MUST encode vocabulary and shape +only; correctness belongs to the linter. + +## 4. Intents + +```json +"intents": [ + { + "id": "destructive-action", + "name": "Destructive action", + "description": "The requested UI performs an irreversible or high-consequence operation.", + "relatedPatterns": ["destructive-action-confirmation"] + } +] +``` + +An **intent** names a kind of UI request. Intents are the scoping vocabulary for rules +(`rules[].appliesTo.intents`) and the selection key for examples (`examples[].intent`). The +intent for a given generation is **declared by the caller**, not inferred by a model; it is +carried in the surface document itself (§7) so downstream tools can evaluate intent-scoped +rules without out-of-band state. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | string (`^[a-z][a-z0-9-]*$`) | yes | Unique identifier. | +| `name` | string | no | Display name. | +| `description` | string | yes | What requests this intent covers; written for humans and prompts. | +| `relatedPatterns` | string[] | no | Pattern IDs documenting how to satisfy the intent. | +| `tags` | string[] | no | Free-form tags. | + +Rules and examples referencing an intent id that is not registered in `intents[]` make the +document **inconsistent**; validating tools MUST reject it. + +## 5. Rules + +```json +{ + "id": "rule.destructive-requires-alertdialog", + "type": "component-choice", + "severity": "must", + "appliesTo": { "intents": ["destructive-action"] }, + "require": ["alert-dialog"], + "forbid": ["dialog"], + "rationale": "Dialog can be dismissed by clicking the overlay or pressing Escape…", + "examples": ["ex.delete-account-confirmation"] +} +``` + +A **rule** is a typed, structured predicate over a dspack surface, plus a mandatory +`rationale`. There is deliberately **no expression language**: each rule `type` selects a +fully specified evaluation algorithm (§5.3), and its fields are plain identifiers and value +lists. This keeps rules deterministic, implementable in any language, authorable by +design-system maintainers, and portable as documents. + +Common fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | string (`^rule\.[a-z0-9.-]+$`) | yes | Unique, stable identifier. | +| `type` | `component-choice` \| `required-composition` \| `forbidden-composition` | yes | Selects the evaluation algorithm. | +| `severity` | `must` \| `should` | yes | RFC 2119 strength (§5.2). | +| `rationale` | string | yes | Why the rule exists. Surfaced verbatim in findings, repair feedback, and audit reports. | +| `appliesTo.intents` | string[] | no | Intent IDs the rule fires for. **Absent means universal.** | +| `examples` | string[] | no | Example IDs demonstrating compliance. | +| `tags` | string[] | no | Free-form tags. | + +All component and sub-component references inside a rule MUST resolve in the contract's +`components` map (including `composition.subComponents` ids); all example references MUST +resolve in `examples[]`. Otherwise the document is inconsistent and MUST be rejected. +Because rule references and gate S2 resolve sub-components by id alone, **sub-component ids +MUST be unique across the document** in contracts that use governance blocks (v0.2's +"should be parent-prefixed" convention makes this natural); a duplicate makes the document +inconsistent and MUST be rejected. Governance objects follow the global v0.2 extensibility +rule: `x-`-prefixed extension properties are permitted on `intents[]`, `rules[]`, +`examples[]` entries and their nested objects, and consumers MUST ignore them. + +### 5.1 Applicability + +A rule **fires** for a surface when `appliesTo` is absent (universal) or when the surface's +declared `intent` is a member of `appliesTo.intents`. Rules that do not fire produce no +findings of any kind. + +### 5.2 Severity: normative terms, tool levels + +Contract severity uses RFC 2119 terms — the vocabulary design-system maintainers already use +in `constraints` and `antiPatterns`. Tools map them to reporting levels: + +| Contract `severity` | Tool `level` | Effect | +| --- | --- | --- | +| `must` | `error` | Fails the lint; triggers repair in generation pipelines. | +| `should` | `warn` | Reported in findings and audit output; MUST NOT fail the lint or trigger repair in v0.3. | + +Findings objects MUST carry **both** fields (`requirement: "must"`, `level: "error"`), so the +contract-facing language and the tool-facing behavior stay distinguishable. Normative language +is contract-facing; severity levels are tool-facing. + +### 5.3 Rule Types — Normative Evaluation Semantics + +Evaluation operates on the surface tree (§7). "Descendants" means all nodes reachable through +`children` and `slots`, at any depth. A node "matches" a component id when its `component` +field equals that id. + +**`component-choice`** — component selection for an intent. +Fields: `require?: string[]`, `forbid?: string[]` (at least one present). +For each id in `require`: at least one node in the surface MUST match it (one finding per +missing id, located at the surface root). For each id in `forbid`: no node in the surface may +match it (one finding per matching node, located at that node). + +**`required-composition`** — structure every instance of a component must contain. +Fields: `component: string`, `requiredSubComponents?: {id, min=1}[]`, +`requiredProps?: {on?, prop, oneOf}[]` (at least one of the two present). +For **every** node matching `component`: each `requiredSubComponents` entry MUST have ≥ `min` +matching descendants (one finding per unsatisfied entry, located at the matching node); each +`requiredProps` entry MUST hold — when `on` is absent, the node itself MUST have `props[prop]` +present and equal to a member of `oneOf`; when `on` is given, **every** descendant matching +the sub-component id `on` MUST satisfy the prop constraint, and at least one such descendant +MUST exist. + +**`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). +For **every** node matching `component`: no descendant may match any id in +`forbiddenDescendants` (one finding per offending descendant, located at it); no +`forbiddenProps` entry may hold — when `on` is absent, the node's `props[prop]` MUST NOT be a +member of `values`; when `on` is given, the same check applies to every descendant matching +`on`. + +Findings MUST include: rule id, rule type, `requirement`, `level`, a message naming the +violated condition, the rule's `rationale` verbatim, and a location (path from the surface +root plus the offending node's `component` and, when present, `id`). + +### 5.4 Unknown Rule Types + +A linter encountering a rule whose `type` it does not implement MUST fail loudly — a distinct +error outcome (recommended process exit code: 4), never a silent skip and never a warning. +Skipping unknown rules would misreport a surface as governed when it was not. + +### 5.5 Forward Compatibility + +Future spec versions add rule types and optional fields **only**; existing types' semantics +are frozen once released. A v0.3 document remains a valid v0.4 document. A v0.4 document +using a new rule type is intentionally **invalid** under the v0.3 schema and MUST trigger the +unknown-rule-type failure in v0.3 linters. + +## 6. Examples + +```json +{ + "id": "ex.delete-account-confirmation", + "intent": "destructive-action", + "prompt": "a screen to delete my account", + "description": "Card with a destructive entry point; AlertDialog confirmation…", + "surface": { "dspackSurface": "0.1", "system": "shadcn/ui", "intent": "destructive-action", "root": { … } } +} +``` + +An **example** is a compilable dspack surface tied to a named intent. Examples serve double +duty: documentation of correct usage, and few-shot exemplars for generation (used verbatim — +the surface format is the generation format, so exemplars are exactly in-distribution). There +is no third example format. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | string (`^ex\.[a-z0-9.-]+$`) | yes | Unique identifier. | +| `intent` | string | yes | Intent ID this example demonstrates. | +| `name` / `description` | string | no | Display name / why the example is correct. | +| `prompt` | string | no | Representative user request; the user turn in few-shot blocks. | +| `surface` | object | yes | A dspack surface document (§7). | + +Every example's `surface` MUST validate against the surface schema (gate S1), MUST use only +the contract's vocabulary (gate S2), MUST declare the same `intent` as the example entry, and +MUST set `system` to the contract's `name`. Examples SHOULD satisfy the contract's own rules; +tooling MAY enforce this (gate S3 over examples). + +## 7. The dspack Surface Format + +A **dspack surface** (`.dsurface.json`, schema +[`dspack.surface.v0_1.schema.json`](../schema/dspack.surface.v0_1.schema.json), versioned +independently as `dspackSurface: "0.1"`) is a protocol-neutral, nested component tree in a +contract's vocabulary: + +- `system` — the contract's `name`. +- `intent` — the declared intent id (§4), carried in the artifact. +- `root` — a node tree. Each node has `component` (a component id or sub-component id), + optional `id`, `props`, `text` (for text leaves), ordered `children`, and named `slots`. + +The surface is an **intermediate representation**: it is never rendered, never transported, +and always compiled to a protocol (A2UI, json-render, …) by a deterministic emitter. It +deliberately preserves compound composition (sub-component structure) that individual +protocol projections may lose — governance evaluates *before* those documented casualties. +Renderer- or transport-facing features do not belong in this format; proposals to add them +are scope changes requiring an explicit design decision, not incremental additions. + +## 8. Validation Gates + +Named uniformly across tooling, lint output, and audit reports: + +| Gate | Question | Defined by | +| --- | --- | --- | +| **S1** | Is this a well-formed surface? | The generic surface schema. | +| **S2** | Does the surface use only the contract's vocabulary? Component/sub-component ids, prop names on components, enum prop values, and declared slot names. | This spec + the bound contract. | +| **S3** | Does the surface satisfy the contract's rules? | §5. | + +S1 and S2 are checks on **any** produced surface — model-generated, hand-authored, or fixture. +A generation pipeline MAY reuse its schema-constrained decoding to implement S2, but MUST +still report S1 and S2 as independent gates over the produced artifact. Emitter-side gates +(e.g. A2UI's schema-compile / catalog-shape / instance-validation checks) are downstream of +this spec and named by the emitter (A1/A2/A3 for the A2UI target). + +S2 is deliberately scoped: it does **not** check `acceptsChildren` semantics, non-enum prop +value types, or sub-component nesting order. Those either belong to S3 rules or are not yet +expressible (§9). + +## 9. Deliberate Ceiling + +v0.3's rule inventory is intentionally small. Known governance needs that the three types +**cannot** express, recorded here so the ceiling is explicit rather than discovered: + +- **Ordering constraints** — e.g. "cancel appears before confirm in reading order." Presence + is checkable (`required-composition`); order is not. +- **Category-based selection** — e.g. "no *interactive* descendants inside a button" as a + category predicate. v0.3 rules enumerate ids; a category form requires component metadata + the contract does not yet carry. +- **Cardinality beyond `min`** — no `max`, no exact counts. +- **Token-usage and layout rules.** +- **Soft/heuristic judgments** — out of scope for v0.3 entirely; every v0.3 rule is + deterministic. + +These are v0.4 candidates, to be added as new typed rules (additively, per §5.5) driven by +evidence from real contracts. diff --git a/spec/migration-v0.2-to-v0.3.md b/spec/migration-v0.2-to-v0.3.md new file mode 100644 index 0000000..d452282 --- /dev/null +++ b/spec/migration-v0.2-to-v0.3.md @@ -0,0 +1,100 @@ +# Migrating from dspack v0.2 to v0.3 + +**Short version: change `"dspack": "0.2"` to `"0.3"` and you are done.** v0.3 is strictly +additive; everything else in this guide is about what you can *now add*, not what you must +change. + +## What changes + +Nothing, structurally. v0.3 adds three optional top-level blocks: + +| Block | Purpose | +| --- | --- | +| `intents` | Named kinds of UI requests; the scoping vocabulary for rules and examples. | +| `rules` | Machine-checkable governance: typed, deterministic predicates + rationales. | +| `examples` | Compilable example surfaces per intent; documentation and few-shot exemplars in one artifact. | + +No existing field changes meaning, type, or required status. No field is removed. + +## Validity guarantees + +- **Any valid v0.2 document with `"dspack"` set to `"0.3"` validates against the v0.3 + schema.** This is the same guarantee v0.2 gave v0.1 documents. +- **v0.2 consumers ignore the new blocks.** Per the v0.2 extensibility rules, unknown + top-level properties are ignored, so a v0.3 file degrades safely in tools that only know + v0.2 (this repository's validation harness checks the stripped-document guarantee on every + run). +- **Forward:** a v0.3 document remains valid under future v0.4 schemas; new rule *types* + arrive additively, and a v0.3 linter encountering one fails loudly rather than skipping it + (spec §5.4–§5.5). + +## Also new in v0.3 (companion artifact, not part of the contract document) + +The **dspack surface** format (`schema/dspack.surface.v0_1.schema.json`, spec §7): a +protocol-neutral component tree used as the evaluation target for rules and the payload of +`examples[].surface`. Contracts embed surfaces only inside `examples[].surface`; standalone +`.dsurface.json` files are pipeline artifacts, not contract content. + +## What you gain by migrating + +Your contract likely already records governance as prose — `antiPatterns`, `constraints`, +`whenToUse`, `composition.notes`. v0.3 lets you shadow the enforceable subset with rules that +a linter can check and a generation pipeline can enforce, while the prose stays authoritative +for humans. + +### Worked conversion: anti-pattern → rule + +The shadcn/ui example ships this v0.2 anti-pattern (unchanged in v0.3): + +```json +{ + "id": "dialog-for-destructive-actions", + "name": "Using Dialog for Destructive Confirmations", + "description": "Using the dismissible Dialog component instead of AlertDialog when confirming a destructive or irreversible action.", + "reason": "Dialog can be dismissed by clicking the overlay or pressing Escape, which means a user can accidentally bypass the confirmation without making a conscious choice. …", + "severity": "must-not", + "insteadUse": "destructive-action-confirmation", + "components": ["dialog", "alert-dialog"] +} +``` + +Its machine-checkable shadow in v0.3 (now also in the example contract): + +```json +{ + "id": "rule.destructive-requires-alertdialog", + "type": "component-choice", + "severity": "must", + "appliesTo": { "intents": ["destructive-action"] }, + "require": ["alert-dialog"], + "forbid": ["dialog"], + "rationale": "Dialog can be dismissed by clicking the overlay or pressing Escape, so a user can bypass a destructive confirmation without making a conscious choice. AlertDialog forces an explicit confirm/cancel decision and is announced with greater urgency by screen readers.", + "examples": ["ex.delete-account-confirmation"] +} +``` + +What moved where: + +| Anti-pattern (prose) | Rule (predicate) | +| --- | --- | +| `description` — the mistake | `forbid: ["dialog"]` + `require: ["alert-dialog"]`, scoped by `appliesTo.intents` | +| `reason` | `rationale` (verbatim in lint findings and repair feedback) | +| `severity: "must-not"` | `severity: "must"` — polarity lives in require/forbid arms, so only `must`/`should` exist on rules | +| `insteadUse` (pattern id) | intent's `relatedPatterns` + the rule's `examples` (a compilable corrected reference) | + +The anti-pattern stays in the document: it explains the *why* at reading time; the rule +enforces the *what* at lint time. + +### Migration recipe + +1. Bump `"dspack"` to `"0.3"` (and `$schema`, if you point at the schema file). +2. Identify the intents your governance actually scopes to (start with one; the example + contract starts with `destructive-action`). +3. For each `must`/`must-not`-grade constraint or anti-pattern that is *structurally + checkable* (component presence, required/forbidden composition, prop values), write the + rule and link a compilable example. Leave the prose in place. +4. Constraints that are not structurally checkable in v0.3 (ordering, category-based, + token usage — spec §9) stay prose for now; they are v0.4 rule-type candidates. +5. Validate: `npm ci && npm run validate` in this repository checks schema validity, the + stripped-document back-compat guarantee, cross-reference consistency, and every + `examples[].surface` against gates S1 and S2.