From 14a4c808b2af3c8009dedcc22bf5355341d7b4c7 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 22 Jul 2026 15:57:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(validate):=20--file=20mode=20+=20dspack-va?= =?UTF-8?q?lidate=20bin=20=E2=80=94=20the=20standalone=20surface=20(DX-3?= =?UTF-8?q?=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One validator, two front-ends. --file ... runs the identical validation (schema, back-compat, governance consistency, categories) over named documents; the examples/ and negative-fixture modes are untouched. The package becomes publishable (@aestheticfunction/ dspack-spec, bin dspack-validate = scripts/validate.mjs verbatim) with a tag-triggered OIDC release workflow; CI now guards file mode in both directions โ€” the pre---file harness silently ignored the flag and exited 0 pointing at a negative fixture (fail-first baseline). ADOPTING.md updated in the same change per the invariant: step 1 carries the refusal guarantee and ledger, step 2 is retired (snapshots declare the current spec version), step 10 uses the standalone surface. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 59 ++++++++++++++++++++++++++++ .github/workflows/validate.yml | 10 +++++ ADOPTING.md | 71 +++++++++++++++++----------------- README.md | 2 +- package.json | 17 ++++++-- scripts/validate.mjs | 26 +++++++++++-- 6 files changed, 142 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0508669 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: release + +# Publishes @aestheticfunction/dspack-spec (schemas, spec, reference +# contracts, and the dspack-validate bin) when a version tag is pushed. +# Mirrors ds-mcp's release workflow. The bin is a front-end over +# scripts/validate.mjs โ€” the single validator implementation +# (rfc/dx3-bootstrap-design.md ยง4); this workflow distributes it, never +# forks it. +# +# Auth: npm trusted publishing (OIDC) โ€” no NPM_TOKEN secret in the repo. +# One-time setup on npmjs.com (package settings -> Trusted publisher): +# repository aestheticfunction/dspack, workflow release.yml. +# +# Guards: +# - tag must equal the package.json version +# - full validation (positive + negative fixtures + --file both ways) +# runs before publish + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # OIDC for npm trusted publishing / provenance + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + # Node 24 bundles npm >= 11.5, the minimum for OIDC trusted + # publishing (see dspack-gen v0.1.1's E404 incident). + node-version: 24 + cache: npm + registry-url: https://registry.npmjs.org + - name: Verify tag matches package.json version + run: | + pkg="$(node -p "require('./package.json').version")" + tag="${GITHUB_REF_NAME#v}" + if [ "$pkg" != "$tag" ]; then + echo "tag v$tag does not match package.json version $pkg" >&2 + exit 1 + fi + - run: npm ci + - run: npm run validate + - run: npm run validate -- --fixtures negative + - name: File mode accepts a valid document + run: npm run validate -- --file examples/shadcn-ui.dspack.json + - name: File mode rejects an invalid document + run: | + if npm run validate -- --file fixtures/negative/rule-unknown-component-ref.dspack.json; then + echo "file mode unexpectedly accepted a negative fixture" >&2 + exit 1 + fi + - name: Publish (public, with provenance) + run: npm publish --access public diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 59cf523..59ff389 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,3 +19,13 @@ jobs: run: npm run validate - name: Negative fixtures (must all be rejected) run: npm run validate -- --fixtures negative + - name: File mode accepts a valid document + run: npm run validate -- --file examples/shadcn-ui.dspack.json + - name: File mode rejects an invalid document + # Guards the fail-first behavior: the pre---file harness silently + # ignored the flag and exited 0 on this exact invocation. + run: | + if npm run validate -- --file fixtures/negative/rule-unknown-component-ref.dspack.json; then + echo "file mode unexpectedly accepted a negative fixture" >&2 + exit 1 + fi diff --git a/ADOPTING.md b/ADOPTING.md index 8109336..5b14d30 100644 --- a/ADOPTING.md +++ b/ADOPTING.md @@ -28,7 +28,7 @@ there.) | # | Step | Mode | |---|------|------| | 1 | [Extract a snapshot from your codebase](#step-1--extract-a-snapshot) | ๐Ÿค– Automated | -| 2 | [Set the version to the current spec](#step-2--set-the-version) | ๐Ÿค– Automated | +| 2 | [~~Set the version to the current spec~~](#step-2--set-the-version) โ€” retired; snapshots declare it | ๐Ÿค– Automated | | 3 | [Review the snapshot against reality](#step-3--review-the-snapshot) | ๐Ÿ‘€ Human review | | 4 | [Author component guidance](#step-4--author-component-guidance) | โš–๏ธ Design decision | | 5 | [Define categories and tag components](#step-5--define-categories) | โš–๏ธ Design decision | @@ -40,7 +40,7 @@ there.) | 11 | [Serve it to agents](#step-11--serve-it-to-agents) | ๐Ÿค– Automated | | 12 | [Generate, lint, and render](#step-12--generate-lint-render) | ๐Ÿค– Automated (rendering profile: โš–๏ธ) | -Steps 1โ€“2 assume a React + Tailwind/shadcn or Vue 3 + Vuetify 3 codebase +Step 1 assumes a React + Tailwind/shadcn or Vue 3 + Vuetify 3 codebase (what [dspack-export](https://github.com/aestheticfunction/dspack-export) currently supports). For any other stack, skip to [Writing the snapshot by hand](#writing-the-snapshot-by-hand) โ€” a valid @@ -53,17 +53,17 @@ was written entirely by hand. ### Step 1 ยท Extract a snapshot โ€” ๐Ÿค– Automated -dspack-export is experimental and not yet on npm; install from source: - ```bash -git clone https://github.com/aestheticfunction/dspack-export -cd dspack-export && npm install && npm run build && npm link +npm install -g @aestheticfunction/dspack-export cd /path/to/your/design-system dspack-export init # detects conventions, writes dspack-export.config.json dspack-export generate --config dspack-export.config.json ``` +(Installing from a source clone โ€” `npm install && npm run build && npm link` +in a checkout โ€” works identically.) + You get a `.dspack.json` (named from your `package.json` name, or a placeholder if there isn't one) containing everything observable from source: `components` with props (cva variant enums and defaults on React; @@ -74,27 +74,26 @@ what generated it. If your tokens live in Figma or Tokens Studio, export them to a DTCG file and point the config's `tokens` field at it โ€” the import is file-based; no tool integration or network involved. -Two properties of the output matter later: +Three properties of the output matter later: - It is **deterministic** โ€” same input, same bytes โ€” so you can commit it and diff regenerations. -- Its metadata warns that hand-authored sections **will be overwritten on - regeneration**. Treat the snapshot as a one-shot starting point: commit it, - then enrich the committed copy. Do not regenerate over your enrichments. - -### Step 2 ยท Set the version to the current spec โ€” ๐Ÿค– Automated - -The snapshot declares `"dspack": "0.2"`. The spec's versions are strictly -additive โ€” a v0.2-shaped document is a valid v0.4 document โ€” so update the -label (and, if present, the `$schema` pointer) to the current version: - -```json -{ "dspack": "0.4", ... } -``` - -That is the whole step. Nothing else changes. The governance blocks you add -in Phase 2 (`categories`, `intents`, `rules`, `examples`) exist only from -v0.3/v0.4, which is why the label moves before you start writing them. +- It carries a non-semantic `metadata["x-bootstrap"]` **ledger** recording + which sections the tool generated (with content hashes) and which + governance surfaces await your authorship. It affects nothing downstream; + once your contract is fully yours, delete it โ€” after which the tool + refuses to touch the file at all. +- **Regeneration never destroys your work โ€” guaranteed, not advised.** The + workflow is: commit the snapshot, then enrich the committed copy. If you + run `generate` against a file containing anything you authored (or any + file without the ledger), it refuses, says why, and points you at + `--out` for writing a fresh snapshot elsewhere to compare by hand. + +### Step 2 ยท Set the version โ€” retired + +Nothing to do: snapshots declare the current spec version (`"dspack": +"0.4"`) directly. (This step existed when the tool emitted the older v0.2 +shape; the number is kept so later steps keep their names.) ### Step 3 ยท Review the snapshot โ€” ๐Ÿ‘€ Human review @@ -261,24 +260,24 @@ reference for what each block looks like fully populated. ### Step 10 ยท Validate โ€” ๐Ÿค– Automated -The validation harness lives in this repository and runs over -`examples/*.dspack.json`. Point it at your contract by dropping the file -into a clean checkout's `examples/` directory: +```bash +npx -p @aestheticfunction/dspack-spec dspack-validate --file your-system.dspack.json +``` + +Or, from a checkout of this repository: ```bash git clone https://github.com/aestheticfunction/dspack cd dspack && npm ci -cp /path/to/your-system.dspack.json examples/ -npm run validate +npm run validate -- --file /path/to/your-system.dspack.json ``` -You get, per document: schema validation for its declared version, -back-compat (the additive guarantee), governance consistency (unique ids; -intent, component, and example references resolve; every example surface -passes S1 + S2), and categories consistency. Fix what it reports; re-run -until green. (Your file stays local โ€” `examples/` in your checkout is just -where the harness looks. A standalone `validate --file` entry point is a -known gap on the roadmap.) +Both are the same validator โ€” the published bin is a front-end over this +repository's harness, never a fork. You get, per document: schema +validation for its declared version, back-compat (the additive guarantee), +governance consistency (unique ids; intent, component, and example +references resolve; every example surface passes S1 + S2), and categories +consistency. Fix what it reports; re-run until green. ### Step 11 ยท Serve it to agents โ€” ๐Ÿค– Automated diff --git a/README.md b/README.md index a2cc708..6776028 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Think of it as OpenAPI for design systems. > Part of the [dspack ecosystem](https://github.com/aestheticfunction) โ€” the organization profile has the full map of how the repositories fit together. > -> **Kind:** specification (spec, schemas, examples, validation harness โ€” not an npm package) ยท **Audience:** design-system practitioners and tool implementers ยท **Neighbors:** consumed by [ds-mcp](https://github.com/aestheticfunction/ds-mcp), [dspack-gen](https://github.com/aestheticfunction/dspack-gen), [dspack-emit](https://github.com/aestheticfunction/dspack-emit), and [dspack-studio](https://github.com/aestheticfunction/dspack-studio); bootstrapped from code by [dspack-export](https://github.com/aestheticfunction/dspack-export) +> **Kind:** specification (spec, schemas, examples, validation harness; npm `@aestheticfunction/dspack-spec` ships the `dspack-validate` bin) ยท **Audience:** design-system practitioners and tool implementers ยท **Neighbors:** consumed by [ds-mcp](https://github.com/aestheticfunction/ds-mcp), [dspack-gen](https://github.com/aestheticfunction/dspack-gen), [dspack-emit](https://github.com/aestheticfunction/dspack-emit), and [dspack-studio](https://github.com/aestheticfunction/dspack-studio); bootstrapped from code by [dspack-export](https://github.com/aestheticfunction/dspack-export) > > **Adopting dspack for an existing design system?** The complete journey โ€” snapshot, governance authoring, validation, serving โ€” is walked step by step in [ADOPTING.md](./ADOPTING.md). diff --git a/package.json b/package.json index 2d240cc..cf252ec 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,7 @@ { "name": "@aestheticfunction/dspack-spec", "version": "0.4.0", - "private": true, - "description": "Validation harness for the dspack specification artifacts: schemas, examples, and negative fixtures.", + "description": "The dspack specification: spec documents, JSON Schemas, reference example contracts, and the validation harness (bin: dspack-validate).", "type": "module", "license": "Apache-2.0", "scripts": { @@ -14,5 +13,17 @@ }, "engines": { "node": ">=20.0.0" - } + }, + "bin": { + "dspack-validate": "scripts/validate.mjs" + }, + "files": [ + "schema", + "scripts/validate.mjs", + "spec", + "examples/README.md", + "examples/shadcn-ui.dspack.json", + "examples/astryx.dspack.json", + "ADOPTING.md" + ] } diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 7e56e79..a31b920 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -30,6 +30,13 @@ * 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. + * + * File mode (`npm run validate -- --file [...]`, also the + * `dspack-validate` bin): + * Runs the identical validation (schema, back-compat, governance + * consistency, categories) over the named document(s) instead of + * examples/. This is the standalone surface from rfc/dx3-bootstrap-design + * ยง4 โ€” a front-end over the same function, never a second validator. */ import { readFileSync, readdirSync } from "node:fs"; import { join, dirname, basename } from "node:path"; @@ -321,6 +328,18 @@ function listDocs(dir) { function main() { const args = process.argv.slice(2); const negativeMode = args.includes("--fixtures") && args[args.indexOf("--fixtures") + 1] === "negative"; + // --file [...]: every argument after the flag that does not + // start with "-" names a document to validate. + let fileArgs = null; + const fileFlag = args.indexOf("--file"); + if (fileFlag !== -1) { + fileArgs = []; + for (let i = fileFlag + 1; i < args.length && !args[i].startsWith("-"); i++) fileArgs.push(args[i]); + if (fileArgs.length === 0) { + console.error("usage: validate --file [...]"); + process.exit(1); + } + } const { validators, failures } = compileSchemas(); if (!validators.has(SURFACE_SCHEMA)) failures.push(`${SURFACE_SCHEMA}: missing`); @@ -355,7 +374,7 @@ function main() { return; } - const docs = listDocs(EXAMPLES_DIR); + const docs = fileArgs ?? listDocs(EXAMPLES_DIR); if (docs.length === 0) { console.error(`no examples found in ${EXAMPLES_DIR}`); process.exit(1); @@ -404,11 +423,12 @@ function main() { console.log(` โœ” ${basename(path)} (dspack ${doc.dspack})`); } } + const label = fileArgs ? "documents" : "examples"; if (failed) { - console.error(`examples FAIL (${failed} document(s) invalid)`); + console.error(`${label} FAIL (${failed} document(s) invalid)`); process.exit(1); } - console.log(`examples PASS (${docs.length} document(s))`); + console.log(`${label} PASS (${docs.length} document(s))`); } main();