From c4e1fc3137dc59a649337491c3d1bae92a130bcb Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 22 Jul 2026 15:53:47 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20DX-3=20bootstrap=20boundary=20?= =?UTF-8?q?=E2=80=94=20v0.4=20emission,=20x-bootstrap=20ledger,=20refusal?= =?UTF-8?q?=20table=20(0.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements dspack rfc/dx3-bootstrap-design.md: - Snapshots declare the current spec version (0.4) via SPEC_VERSION in the new src/emit/bootstrap.ts; the adoption journey's manual relabel step disappears. Populated sections unchanged — no governance content is ever emitted. - Every snapshot carries the non-semantic metadata["x-bootstrap"] ledger: spec version, content hash per generated section, awaitingAuthorship list. Deleting the ledger graduates the document to human-owned. - Regeneration refusal table: generate refuses on no-ledger, human-authored sections, or edited generated sections — naming what it found — and never destroys human-authored content. No force flag. --out writes to an explicit path. - validate checks against a vendored byte copy of the dspack v0.4 schema (shape gate only), guarded by scripts/check-sync.mjs. - CI test.yml and tag-triggered OIDC release.yml (ds-mcp pattern); package is publishable (private flag removed, files field, 0.3.0). Fail-first: src/tests/bootstrap.test.ts failed on the pre-boundary code (module absent, "Test Files 1 failed"); 13/13 after implementation. Full suite 106/106 including the ds-mcp round-trip; the four golden fixtures regenerate with exactly the header/ledger diff and untouched content sections. Refusal table exercised end to end via the real CLI. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 48 + .github/workflows/test.yml | 21 + .gitignore | 1 + CHANGELOG.md | 32 + README.md | 40 +- .../dtcg-tokens-demo.dspack.json | 28 +- fixtures/shadcn-demo/shadcn-demo.dspack.json | 28 +- .../shadcn-v4-demo/shadcn-v4-demo.dspack.json | 28 +- .../vuetify-demo/vuetify-demo.dspack.json | 26 +- package.json | 16 +- scripts/check-sync.mjs | 59 + src/cli.ts | 38 +- src/emit/assemble.ts | 9 +- src/emit/bootstrap.ts | 127 ++ src/emit/schema/dspack.v0.4.schema.json | 1554 +++++++++++++++++ src/emit/validate.ts | 15 +- src/generate.ts | 2 +- src/tests/bootstrap.test.ts | 140 ++ 18 files changed, 2160 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 scripts/check-sync.mjs create mode 100644 src/emit/bootstrap.ts create mode 100644 src/emit/schema/dspack.v0.4.schema.json create mode 100644 src/tests/bootstrap.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6313b3e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: release + +# Publishes @aestheticfunction/dspack-export to npm when a version tag is +# pushed. Mirrors ds-mcp's release workflow (which mirrors dspack-gen's). +# +# 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-export, workflow release.yml. +# Provenance attestations are generated automatically for trusted publishes. +# +# Guards: +# - tag must equal the package.json version (prevents accidental mismatches) +# - full test suite + schema drift check run 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 build + - run: npm test + - run: npm run check:sync + - name: Publish (public, with provenance) + run: npm publish --access public diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b8805e3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + - run: npm test + - name: Vendored-schema drift check + run: npm run check:sync diff --git a/.gitignore b/.gitignore index 3c25e1e..dd6e803 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.log +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..803e0f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## 0.3.0 + +The DX-3 bootstrap boundary (dspack `rfc/dx3-bootstrap-design.md`), and the +first npm-published release. + +- Snapshots declare the current spec version (**dspack 0.4**) instead of the + hard-coded 0.2; the manual relabel step disappears from the adoption + journey. Populated sections are unchanged — this tool still writes no + governance content, ever. +- Every snapshot carries a **non-semantic `metadata["x-bootstrap"]` ledger**: + the spec version, a content hash per generated section, and the list of + governance surfaces awaiting human authorship. Deleting the ledger marks a + document fully human-owned. +- **Regeneration refusal table**: `generate` refuses to overwrite any file it + cannot prove it owns — no ledger, human-authored sections present, or a + generated section edited by hand — and every refusal explains why. There + is deliberately no force flag. A pure untouched snapshot still + regenerates in place. +- `generate --out ` writes the snapshot to an explicit path. +- `validate` now checks against a vendored byte copy of the dspack **v0.4** + schema, guarded by `scripts/check-sync.mjs` (shape gate only; full + contract validation lives in the dspack repository's harness). +- CI (`test.yml`) and tag-triggered OIDC trusted publishing (`release.yml`), + mirroring ds-mcp/dspack-gen. + +## 0.2.0-alpha.0 + +Unpublished baseline: v0.2 snapshot generation (React + Tailwind/shadcn and +Vue 3 + Vuetify 3 adapters, DTCG token import, deterministic output, +round-trip gate against ds-mcp). diff --git a/README.md b/README.md index 90bb322..0bc4d85 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # dspack-export -Generate [dspack](https://github.com/aestheticfunction/dspack) v0.2 design-system -snapshots from component codebases — **React + Tailwind/shadcn** and **Vue 3 + +Bootstrap current-spec [dspack](https://github.com/aestheticfunction/dspack) +design-system snapshots from component codebases — **React + Tailwind/shadcn** and **Vue 3 + Vuetify 3** — ready to serve to AI agents via [ds-mcp](https://github.com/aestheticfunction/ds-mcp). > Part of the [dspack ecosystem](https://github.com/aestheticfunction) — the organization profile has the full map of how the repositories fit together. > -> **Kind:** snapshot tool (CLI, experimental, not yet on npm — install from source) · **Audience:** teams with an existing component codebase who want a starting dspack file · **Neighbors:** produces [dspack](https://github.com/aestheticfunction/dspack) documents; serves them via [ds-mcp](https://github.com/aestheticfunction/ds-mcp); see the governed end-to-end chain in [dspack-studio](https://github.com/aestheticfunction/dspack-studio) ([hosted replay](https://studio.aesthetic-function.com)) +> **Kind:** snapshot tool (CLI, experimental, npm `@aestheticfunction/dspack-export`) · **Audience:** teams with an existing component codebase who want a starting dspack file · **Neighbors:** produces [dspack](https://github.com/aestheticfunction/dspack) documents; serves them via [ds-mcp](https://github.com/aestheticfunction/ds-mcp); see the governed end-to-end chain in [dspack-studio](https://github.com/aestheticfunction/dspack-studio) ([hosted replay](https://studio.aesthetic-function.com)) > > This tool is **step 1** of the adoption journey; what to do with the snapshot it produces — review, governance authoring, validation, serving — is the [adoption guide](https://github.com/aestheticfunction/dspack/blob/main/ADOPTING.md). @@ -18,17 +18,33 @@ across frameworks. The active adapter is chosen by an optional `framework` confi field, or inferred from component file extensions (`.tsx/.jsx` → React, `.vue` → Vue) with a hard error on ambiguous input. -**Status: experimental** (`0.2.0-alpha.0`). Config format and output details -may still change between versions. Not yet published to npm — install from -source (below). The [handbook](docs/handbook.md) covers the supported stack, -known limitations, and troubleshooting. +**Status: experimental** (`0.3.0`). Config format and output details may +still change between versions. Published to npm as +`@aestheticfunction/dspack-export`. The [handbook](docs/handbook.md) covers +the supported stack, known limitations, and troubleshooting. -The snapshot targets the dspack v0.2 shape. The spec has since added the -governance revisions (v0.3 and v0.4); their rules, intents, and examples are -hand-authored downstream, not extracted, and this tool remains a snapshot -generator. +The snapshot declares the current spec version (v0.4) and populates the +machine-discoverable sections only. The governance layer (categories, +intents, rules, examples, patterns, anti-patterns, and prose guidance) is +hand-authored downstream, never extracted — the output's +`metadata["x-bootstrap"]` ledger records exactly which sections this tool +generated (with content hashes) and which surfaces await authorship. The +ledger is non-semantic: it exists only so regeneration can make safe +decisions, and deleting it marks the document fully human-owned. -## Install (from source) +**Regeneration never destroys human-authored content.** If the output file +contains anything this tool does not own — governance blocks, edited +sections, or no ledger at all — `generate` refuses, explains why, and +points at `--out` for writing a fresh snapshot elsewhere. There is no +force flag. + +## Install + +```bash +npm install -g @aestheticfunction/dspack-export +``` + +Or from source: ```bash git clone https://github.com/aestheticfunction/dspack-export diff --git a/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json b/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json index 2408579..f8169e5 100644 --- a/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json +++ b/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json @@ -1,13 +1,35 @@ { - "dspack": "0.2", + "dspack": "0.4", "name": "DTCG Tokens Demo", "description": "Fixture exercising DTCG design-token-file import alongside CSS token extraction.", "version": "1.0.0", "metadata": { - "generatedBy": "@aestheticfunction/dspack-export@0.2.0-alpha.0", + "generatedBy": "@aestheticfunction/dspack-export@0.3.0", "generatedAt": "2026-06-10T00:00:00.000Z", "source": "fixtures/dtcg-tokens-demo", - "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated and will be overwritten on regeneration." + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "spec": "0.4", + "generated": { + "tokens": "6ff5c3fd63f4ba56337320d79583982bfce66919a3552f4938e95cbd1005326c", + "components": "0c49b4db41e59c849995be828fa8df10a4e10aa39384213f6ddb3801da0e9444", + "frameworkBindings": "9ee9adecbbe7a9a144145507791bebde19e6f4ebda6a47a29beec1497a9faf12", + "themes": "8fec47475f975ca9da6b547d5bd2d8ab5ac0f1c126ce98bb0614c01f39a66dad", + "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" + }, + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } }, "tokens": { "color": { diff --git a/fixtures/shadcn-demo/shadcn-demo.dspack.json b/fixtures/shadcn-demo/shadcn-demo.dspack.json index 2c6c9a6..2faabaa 100644 --- a/fixtures/shadcn-demo/shadcn-demo.dspack.json +++ b/fixtures/shadcn-demo/shadcn-demo.dspack.json @@ -1,13 +1,35 @@ { - "dspack": "0.2", + "dspack": "0.4", "name": "Shadcn Demo", "description": "Demo shadcn-style design system used as the dspack-export golden fixture.", "version": "1.0.0", "metadata": { - "generatedBy": "@aestheticfunction/dspack-export@0.2.0-alpha.0", + "generatedBy": "@aestheticfunction/dspack-export@0.3.0", "generatedAt": "2026-06-10T00:00:00.000Z", "source": "fixtures/shadcn-demo", - "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated and will be overwritten on regeneration." + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "spec": "0.4", + "generated": { + "tokens": "d031a79c0a10556c38b099145be82d26eed263713c60922efab56338aca69f15", + "components": "452923cc85bea76d7cd784299cf28dcd79b853961a0c65aa7d31cd186c9f74cd", + "frameworkBindings": "9dea8ada96c2e74adcbf96dc3234d13994b88c6a3a96f12a7e68ed354d89bd1e", + "themes": "46510dd813828a275bd601d0f0348934b9cd1b8b29b4cc525538472f3958173f", + "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" + }, + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } }, "tokens": { "color": { diff --git a/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json b/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json index 6975af5..9449eb5 100644 --- a/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json +++ b/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json @@ -1,13 +1,35 @@ { - "dspack": "0.2", + "dspack": "0.4", "name": "Shadcn V4 Demo", "description": "Tailwind v4 / @theme-style fixture for dspack-export.", "version": "1.0.0", "metadata": { - "generatedBy": "@aestheticfunction/dspack-export@0.2.0-alpha.0", + "generatedBy": "@aestheticfunction/dspack-export@0.3.0", "generatedAt": "2026-06-10T00:00:00.000Z", "source": "fixtures/shadcn-v4-demo", - "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated and will be overwritten on regeneration." + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "spec": "0.4", + "generated": { + "tokens": "a6f0f842d11f89823929bb7e3df4d40c19357c119dd42a687e0adbd1fa8e58d0", + "components": "84e0bc713f0fd87239cc60a9981d59d49e5c6689874f025a7773b24502ac1976", + "frameworkBindings": "9ee9adecbbe7a9a144145507791bebde19e6f4ebda6a47a29beec1497a9faf12", + "themes": "fafbb44d5be2b3a7954c6cb4fb1590a669e8aa1c84eecb3ad15b421e14a0042b", + "layout": "0b92b1df3e314dcb730718fd0c087a680750c7a3a33f16f2f1c0a6d885c4c585" + }, + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } }, "tokens": { "color": { diff --git a/fixtures/vuetify-demo/vuetify-demo.dspack.json b/fixtures/vuetify-demo/vuetify-demo.dspack.json index b034704..fa2c576 100644 --- a/fixtures/vuetify-demo/vuetify-demo.dspack.json +++ b/fixtures/vuetify-demo/vuetify-demo.dspack.json @@ -1,13 +1,33 @@ { - "dspack": "0.2", + "dspack": "0.4", "name": "Vuetify Demo", "description": "Demo Vue 3 + Vuetify 3 design system used as the dspack-export Vue golden fixture.", "version": "1.0.0", "metadata": { - "generatedBy": "@aestheticfunction/dspack-export@0.2.0-alpha.0", + "generatedBy": "@aestheticfunction/dspack-export@0.3.0", "generatedAt": "2026-06-10T00:00:00.000Z", "source": "fixtures/vuetify-demo", - "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated and will be overwritten on regeneration." + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "spec": "0.4", + "generated": { + "tokens": "411ade9c82231a3fcb56a281e79f25d4eb3f8e10001e2c7d06b7ff8dff9bec66", + "components": "7aa04b6cb24b0dd8d580503f82e2b7fc5f9962f2e94bea402f4252ff1f02fb77", + "frameworkBindings": "3d6893c966874d773c2c3ebcfbedbb481b4947ec86f986aa30855ccda7ef970c" + }, + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } }, "tokens": { "color": { diff --git a/package.json b/package.json index 41ccae6..f456764 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,7 @@ { "name": "@aestheticfunction/dspack-export", - "version": "0.2.0-alpha.0", - "private": true, - "description": "Generate dspack v0.2 design-system snapshots from component codebases (React + Tailwind/shadcn, Vue 3 + Vuetify 3) through a framework-adapter layer", + "version": "0.3.0", + "description": "Bootstrap a current-spec dspack design-system snapshot from a component codebase (React + Tailwind/shadcn, Vue 3 + Vuetify 3) through a framework-adapter layer", "keywords": [ "dspack", "design-system", @@ -37,7 +36,8 @@ "build": "tsc", "test": "vitest run", "generate:fixture": "npm run build && SOURCE_DATE_EPOCH=1781049600 node dist/cli.js generate --config fixtures/shadcn-demo/dspack-export.config.json", - "generate:fixture:vue": "npm run build && SOURCE_DATE_EPOCH=1781049600 node dist/cli.js generate --config fixtures/vuetify-demo/dspack-export.config.json" + "generate:fixture:vue": "npm run build && SOURCE_DATE_EPOCH=1781049600 node dist/cli.js generate --config fixtures/vuetify-demo/dspack-export.config.json", + "check:sync": "node scripts/check-sync.mjs" }, "dependencies": { "@babel/parser": "^7.28.5", @@ -61,5 +61,11 @@ "tailwind-merge": "^2.6.0", "vitest": "^3.0.0", "vue": "^3.5.38" - } + }, + "files": [ + "dist", + "src/emit/schema", + "README.md", + "LICENSE" + ] } diff --git a/scripts/check-sync.mjs b/scripts/check-sync.mjs new file mode 100644 index 0000000..aa90a12 --- /dev/null +++ b/scripts/check-sync.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Copy drift check (dspack-gen#7, ecosystem-wide). + * + * dspack-export vendors a byte copy of the dspack v0.4 JSON Schema (source + * of truth: the dspack spec repo) as its output-shape gate. The price of a + * copy is silent drift — exactly how the tool once shipped a stale v0.2 + * emitter; this script makes drift loud: the copy must match its source + * BYTE-FOR-BYTE. On a red check, run with --write to re-sync and commit. + * + * Boring by design: node builtins + global fetch, one retry, no deps. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +const MANIFEST = [ + { + local: "src/emit/schema/dspack.v0.4.schema.json", + source: + "https://raw.githubusercontent.com/aestheticfunction/dspack/main/schema/dspack.v0.4.schema.json", + }, +]; + +const write = process.argv.includes("--write"); + +async function fetchSource(url) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.text(); + } catch (err) { + if (attempt === 1) throw err; + } + } +} + +let failed = 0; +for (const { local, source } of MANIFEST) { + const remote = await fetchSource(source); + let current; + try { + current = readFileSync(local, "utf-8"); + } catch { + current = null; + } + if (current === remote) { + console.log(`in sync ${local}`); + } else if (write) { + writeFileSync(local, remote); + console.log(`re-synced ${local}`); + } else { + console.error(`DRIFT ${local} differs from ${source}`); + failed++; + } +} +if (failed > 0) { + console.error(`\n${failed} cop(ies) out of sync. Run: node scripts/check-sync.mjs --write`); + process.exit(1); +} diff --git a/src/cli.ts b/src/cli.ts index ecb74db..5f5ecb3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,20 +1,25 @@ #!/usr/bin/env node /** - * dspack-export — generate dspack v0.2 snapshots from component codebases - * (React + Tailwind/shadcn, Vue 3 + Vuetify 3). + * dspack-export — bootstrap a current-spec dspack snapshot from a component + * codebase (React + Tailwind/shadcn, Vue 3 + Vuetify 3). Step 1 of the + * adoption journey (ADOPTING.md in the dspack repository). * * Commands: - * dspack-export generate --config Generate a .dspack.json snapshot - * dspack-export validate Validate any dspack file against the v0.2 schema + * dspack-export generate --config [--out ] Generate a .dspack.json snapshot + * dspack-export validate Schema-check a dspack file (shape only — + * full contract validation lives in the dspack repo) * * Snapshot generator only: no watching, no diffing, no write-back. + * Regeneration never destroys human-authored content — the refusal table in + * src/emit/bootstrap.ts decides, and every refusal explains why. */ -import { writeFileSync, readFileSync } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync } from 'node:fs'; import { relative } from 'node:path'; import minimist from 'minimist'; import { loadConfig } from './config.js'; import { generateDocument, generatedAtFromEnv } from './generate.js'; import { validateDspack } from './emit/validate.js'; +import { decideRegeneration } from './emit/bootstrap.js'; import { initConfig } from './init.js'; function usage(): never { @@ -22,15 +27,16 @@ function usage(): never { [ 'Usage:', ' dspack-export init [--force]', - ' dspack-export generate --config ', + ' dspack-export generate --config [--out ]', ' dspack-export validate ', ].join('\n'), ); process.exit(1); } -function runGenerate(configPath: string): void { +function runGenerate(configPath: string, outOverride: string | undefined): void { const config = loadConfig(configPath); + const outputPath = outOverride ?? config.outputPath; // SOURCE_DATE_EPOCH (reproducible-builds convention) pins generatedAt for // deterministic output, e.g. golden-file tests and reviewable diffs. @@ -40,12 +46,18 @@ function runGenerate(configPath: string): void { const validation = validateDspack(document); if (!validation.valid) { - console.error('Generated document failed dspack v0.2 schema validation:'); + console.error('Generated document failed dspack v0.4 schema validation:'); for (const err of validation.errors) console.error(` ${err}`); process.exit(1); } - writeFileSync(config.outputPath, JSON.stringify(document, null, 2) + '\n', 'utf-8'); + const decision = decideRegeneration(existsSync(outputPath) ? readFileSync(outputPath, 'utf-8') : null); + if (!decision.allow) { + console.error(`Refusing to write ${relative(process.cwd(), outputPath)}: ${decision.reason}`); + process.exit(1); + } + + writeFileSync(outputPath, JSON.stringify(document, null, 2) + '\n', 'utf-8'); const componentCount = Object.keys(document.components ?? {}).length; const tokenCount = Object.values(document.tokens ?? {}).reduce( @@ -53,7 +65,7 @@ function runGenerate(configPath: string): void { 0, ); const themeCount = Object.keys(document.themes ?? {}).length; - console.log(`✓ Wrote ${relative(process.cwd(), config.outputPath)}`); + console.log(`✓ Wrote ${relative(process.cwd(), outputPath)}`); console.log(` components: ${componentCount}, tokens: ${tokenCount}, themes: ${themeCount}`); if (warnings.length > 0) { console.log(` warnings (${warnings.length}):`); @@ -65,9 +77,9 @@ function runValidate(filePath: string): void { const doc = JSON.parse(readFileSync(filePath, 'utf-8')) as unknown; const result = validateDspack(doc); if (result.valid) { - console.log(`✓ ${filePath} is a valid dspack v0.2 document`); + console.log(`✓ ${filePath} matches the dspack v0.4 schema (shape only — run the dspack repo's validator for full contract checks)`); } else { - console.error(`✗ ${filePath} failed dspack v0.2 schema validation:`); + console.error(`✗ ${filePath} failed dspack v0.4 schema validation:`); for (const err of result.errors) console.error(` ${err}`); process.exit(1); } @@ -84,7 +96,7 @@ try { for (const note of notes) console.log(` note: ${note}`); } else if (command === 'generate') { const configPath = (argv.config as string | undefined) ?? 'dspack-export.config.json'; - runGenerate(configPath); + runGenerate(configPath, argv.out as string | undefined); } else if (command === 'validate') { if (!positional) usage(); runValidate(positional); diff --git a/src/emit/assemble.ts b/src/emit/assemble.ts index fe1a733..eb3ffc3 100644 --- a/src/emit/assemble.ts +++ b/src/emit/assemble.ts @@ -1,11 +1,13 @@ /** - * Merge SourceFragments into a dspack v0.2 document. + * Merge SourceFragments into a dspack document at the current emitted spec + * version (SPEC_VERSION in bootstrap.ts). * * Merge rules: fragments are processed lowest precedence first, so higher * precedence overwrites per key. Component entries merge per field (a * higher-precedence fragment's props replace, lower fills gaps). Token * categories merge per token name. Themes merge per override key. */ +import { SPEC_VERSION, buildLedger } from './bootstrap.js'; import type { SourceFragment } from '../fragment.js'; import type { DspackDocument, @@ -127,7 +129,7 @@ export function assemble(fragments: SourceFragment[], options: AssembleOptions): } const document: DspackDocument = { - dspack: '0.2', + dspack: SPEC_VERSION, name: options.name, ...(options.description ? { description: options.description } : {}), ...(options.version ? { version: options.version } : {}), @@ -135,7 +137,7 @@ export function assemble(fragments: SourceFragment[], options: AssembleOptions): generatedBy: `@aestheticfunction/dspack-export@${options.generatorVersion}`, generatedAt: options.generatedAt ?? new Date().toISOString(), ...(options.source ? { source: options.source } : {}), - note: 'Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated and will be overwritten on regeneration.', + note: 'Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata["x-bootstrap"]).', }, ...(Object.keys(tokens).length > 0 ? { tokens } : {}), ...(Object.keys(components).length > 0 ? { components } : {}), @@ -143,6 +145,7 @@ export function assemble(fragments: SourceFragment[], options: AssembleOptions): ...(Object.keys(themes).length > 0 ? { themes } : {}), ...(layout ? { layout } : {}), }; + (document.metadata as Record)['x-bootstrap'] = buildLedger(document); return { document, warnings }; } diff --git a/src/emit/bootstrap.ts b/src/emit/bootstrap.ts new file mode 100644 index 0000000..de469a2 --- /dev/null +++ b/src/emit/bootstrap.ts @@ -0,0 +1,127 @@ +/** + * The DX-3 bootstrap boundary (dspack rfc/dx3-bootstrap-design.md): + * current-spec emission, the metadata `x-bootstrap` ledger, and the + * regeneration refusal table. + * + * The ledger is NON-SEMANTIC: it exists solely so this tool can make + * regeneration decisions. Its presence or absence does not affect the + * meaning or validity of a dspack contract, and deleting it is how a team + * graduates a document to fully human-owned (after which this tool refuses + * to touch the file, always). + */ +import { createHash } from 'node:crypto'; +import type { DspackDocument } from '../types.js'; + +/** The spec version this release of the tool emits. */ +export const SPEC_VERSION = '0.4'; + +/** The sections this tool generates and (while their hashes match) owns. */ +export const GENERATED_SECTIONS = [ + 'tokens', + 'components', + 'frameworkBindings', + 'themes', + 'layout', +] as const; + +/** + * Governance surfaces a snapshot leaves for humans to author. A visible + * checklist for people — tools never police it, and an empty governance + * block is deliberately NOT emitted in its place ("unauthored" and + * "deliberately none" must stay distinguishable). + */ +export const AWAITING_AUTHORSHIP = [ + 'categories', + 'intents', + 'rules', + 'examples', + 'patterns', + 'antiPatterns', + 'components.*.whenToUse', + 'components.*.accessibility', + 'components.*.composition', + 'components.*.constraints', +] as const; + +/** Top-level keys that are neither generated sections nor human governance. */ +const BASE_KEYS = new Set(['dspack', 'name', 'description', 'version', 'metadata']); + +export interface BootstrapLedger { + spec: string; + generated: Record; + awaitingAuthorship: string[]; +} + +/** Content hash of one generated section (canonical = the deterministic emitted JSON). */ +export function sectionHash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +/** Build the ledger for a freshly assembled document (hashes of present sections only). */ +export function buildLedger(document: DspackDocument): BootstrapLedger { + const generated: Record = {}; + for (const section of GENERATED_SECTIONS) { + const value = (document as Record)[section]; + if (value !== undefined) generated[section] = sectionHash(value); + } + return { spec: SPEC_VERSION, generated, awaitingAuthorship: [...AWAITING_AUTHORSHIP] }; +} + +export type RegenerationDecision = { allow: true } | { allow: false; reason: string }; + +const WORKFLOW_HINT = + 'The documented workflow is snapshot -> commit -> enrich; regenerate to a fresh path with --out and compare by hand. ' + + 'See ADOPTING.md in the dspack repository.'; + +/** + * The refusal table (rfc/dx3-bootstrap-design.md section 3). Invariant: + * regeneration never destroys human-authored content; when in doubt it + * refuses and says why. There is deliberately no force override. + */ +export function decideRegeneration(existingContent: string | null): RegenerationDecision { + if (existingContent === null) return { allow: true }; + + let existing: Record; + try { + existing = JSON.parse(existingContent) as Record; + } catch { + return { + allow: false, + reason: `the existing output file is not valid JSON, so ownership cannot be established. ${WORKFLOW_HINT}`, + }; + } + + const metadata = (existing.metadata ?? {}) as Record; + const ledger = metadata['x-bootstrap'] as BootstrapLedger | undefined; + if (!ledger || typeof ledger !== 'object' || typeof ledger.generated !== 'object') { + return { + allow: false, + reason: `the existing document carries no bootstrap ledger (metadata["x-bootstrap"]), so it is human-owned and this tool will not touch it. ${WORKFLOW_HINT}`, + }; + } + + const humanSections = Object.keys(existing).filter( + (key) => !BASE_KEYS.has(key) && !(key in ledger.generated), + ); + if (humanSections.length > 0) { + return { + allow: false, + reason: `the existing document contains human-authored sections (${humanSections.join(', ')}); regenerating would sit alongside content this tool does not own. ${WORKFLOW_HINT}`, + }; + } + + const edited = Object.entries(ledger.generated) + .filter(([section, recorded]) => { + const value = existing[section]; + return value === undefined || sectionHash(value) !== recorded; + }) + .map(([section]) => section); + if (edited.length > 0) { + return { + allow: false, + reason: `generated section(s) were edited by hand since the snapshot was written (${edited.join(', ')}); regenerating would destroy those edits. ${WORKFLOW_HINT}`, + }; + } + + return { allow: true }; +} diff --git a/src/emit/schema/dspack.v0.4.schema.json b/src/emit/schema/dspack.v0.4.schema.json new file mode 100644 index 0000000..e288828 --- /dev/null +++ b/src/emit/schema/dspack.v0.4.schema.json @@ -0,0 +1,1554 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/aestheticfunction/dspack/blob/main/schema/dspack.v0.4.schema.json", + "title": "dspack v0.4", + "description": "Schema for dspack v0.4 — a JSON format for representing design system corpora. Backward-compatible with v0.1–v0.3 documents; adds component categories (a contract-defined category registry referenced by component metadata and rules) and the required-props rule type.", + "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.4", + "description": "Specification version. MUST be \"0.4\" 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" + } + }, + "categories": { + "type": "object", + "description": "Contract-defined component category registry. Categories are the contract's own taxonomy (dspack bakes none in); component/sub-component metadata and category-based rule fields reference these ids. Referenced ids MUST be registered here.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/categoryEntry" + } + }, + "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 — 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 (the three original types) and the v0.4 specification (required-props, forbiddenCategories).", + "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" + } + }, + "categories": { + "type": "array", + "description": "Category ids this entry belongs to; each MUST be registered in the top-level categories registry.", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "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." + }, + "categories": { + "type": "array", + "description": "Category ids this entry belongs to; each MUST be registered in the top-level categories registry.", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "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/v0.4 specs). Linters MUST fail loudly on unknown types — never skip silently. Contract severity uses RFC 2119 terms; tools map must→error and should→warn.", + "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", + "required-props" + ], + "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→error (triggers repair/failure) and should→warn (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" + ] + }, + { + "required": [ + "forbiddenCategories" + ] + } + ], + "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 + }, + "forbiddenCategories": { + "type": "array", + "description": "Category ids: no descendant of a matching node may belong to any of these categories. Each id MUST be registered in the top-level categories registry.", + "items": { + "type": "string" + }, + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "required-props" + } + } + }, + "then": { + "required": [ + "component" + ], + "anyOf": [ + { + "required": [ + "requiredText" + ] + }, + { + "required": [ + "requiredProps" + ] + } + ], + "properties": { + "component": { + "type": "string", + "description": "Component or sub-component ID whose instances are checked (unlike other rule types, sub-component ids are accepted here)." + }, + "within": { + "type": "string", + "description": "Optional scope: a component or sub-component ID. When present, only nodes with an ancestor matching this id are checked, and every node matching `within` MUST contain at least one node matching `component`." + }, + "requiredText": { + "const": true, + "description": "The matching node MUST carry non-empty text. Where the text may live is set by `textScope`: its own `text` field only (the default, \"self\") or anywhere in its subtree (\"subtree\")." + }, + "textScope": { + "type": "string", + "enum": [ + "self", + "subtree" + ], + "default": "self", + "description": "Where requiredText looks for the text: \"self\" (the node's own `text` field — the default) or \"subtree\" (direct text on the node or any descendant; for compound wrappers whose documented projections lift a label from within). Amendment 2026-07-04, on PR-15 evidence." + }, + "requiredProps": { + "type": "array", + "description": "Props that MUST be present directly on the matching node's `props`. Unlike required-composition's requiredProps, entries have no `on` (the rule's component IS the target) and `oneOf` is optional (presence-only when absent).", + "items": { + "type": "object", + "required": [ + "prop" + ], + "properties": { + "prop": { + "type": "string", + "description": "Prop name that must be present on the node itself." + }, + "oneOf": { + "type": "array", + "minItems": 1, + "description": "Optional allowed values; when present the prop value MUST be a member." + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "minItems": 1 + } + }, + "dependentRequired": { + "textScope": [ + "requiredText" + ] + } + } + } + ] + }, + "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 + }, + "categoryEntry": { + "type": "object", + "description": "A component category: a named role components/sub-components can declare and rules can select by.", + "required": [ + "description" + ], + "properties": { + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "What membership in this category means; written for maintainers and reviewers." + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + } + } +} diff --git a/src/emit/validate.ts b/src/emit/validate.ts index b9a7595..2addec7 100644 --- a/src/emit/validate.ts +++ b/src/emit/validate.ts @@ -1,5 +1,8 @@ /** - * Validate an assembled document against the bundled dspack v0.2 JSON Schema. + * Validate an assembled document against the bundled dspack v0.4 JSON Schema + * (a byte copy of the spec repo's schema, guarded by scripts/check-sync.mjs). + * Schema-shape gate on this tool's own output only — contract validation + * semantics live upstream in the dspack repository's harness. * AJV setup mirrors ds-mcp's loader (the reference consumer), so anything we * emit is guaranteed loadable by ds-mcp. */ @@ -16,12 +19,12 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Read from src/ at runtime (tsc does not copy JSON) — same pattern as ds-mcp's loader. -const schemaV02 = JSON.parse( - readFileSync(join(__dirname, '..', '..', 'src', 'emit', 'schema', 'dspack.v0.2.schema.json'), 'utf-8'), +const schemaV04 = JSON.parse( + readFileSync(join(__dirname, '..', '..', 'src', 'emit', 'schema', 'dspack.v0.4.schema.json'), 'utf-8'), ); const ajv = new Ajv2020({ allErrors: true, validateFormats: false }); -const validateV02 = ajv.compile(schemaV02); +const validateV04 = ajv.compile(schemaV04); export interface ValidationResult { valid: boolean; @@ -29,10 +32,10 @@ export interface ValidationResult { } export function validateDspack(doc: unknown): ValidationResult { - if (validateV02(doc)) { + if (validateV04(doc)) { return { valid: true, errors: [] }; } - const errors = (validateV02.errors as Array<{ instancePath?: string; message?: string }>).map( + const errors = (validateV04.errors as Array<{ instancePath?: string; message?: string }>).map( (e) => `${e.instancePath || '/'}: ${e.message}`, ); return { valid: false, errors }; diff --git a/src/generate.ts b/src/generate.ts index 59755bf..6eec60b 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -17,7 +17,7 @@ import type { DspackDocument } from './types.js'; export { dropOrphanCvaComponents } from './adapters/react/index.js'; // Keep in sync with package.json version (embedded in metadata.generatedBy). -export const GENERATOR_VERSION = '0.2.0-alpha.0'; +export const GENERATOR_VERSION = '0.3.0'; export interface GenerateOptions { /** ISO 8601 timestamp override for deterministic output. */ diff --git a/src/tests/bootstrap.test.ts b/src/tests/bootstrap.test.ts new file mode 100644 index 0000000..be770ea --- /dev/null +++ b/src/tests/bootstrap.test.ts @@ -0,0 +1,140 @@ +/** + * DX-3 bootstrap boundary (rfc/dx3-bootstrap-design.md in the dspack repo): + * current-spec emission, the metadata x-bootstrap ledger, and the + * regeneration refusal table. Written fail-first against the pre-boundary + * generator. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { loadConfig } from '../config.js'; +import { generateDocument } from '../generate.js'; +import { + SPEC_VERSION, + GENERATED_SECTIONS, + sectionHash, + decideRegeneration, +} from '../emit/bootstrap.js'; +import type { DspackDocument } from '../types.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const FIXTURE_CONFIG = join(REPO_ROOT, 'fixtures', 'shadcn-demo', 'dspack-export.config.json'); +const GENERATED_AT = '2026-06-10T00:00:00.000Z'; + +function generateFixture(): DspackDocument { + const config = loadConfig(FIXTURE_CONFIG); + const { document } = generateDocument(config, { generatedAt: GENERATED_AT }); + return document; +} + +type Ledger = { + spec: string; + generated: Record; + awaitingAuthorship: string[]; +}; + +function ledgerOf(doc: DspackDocument): Ledger { + return (doc.metadata as Record)['x-bootstrap'] as Ledger; +} + +describe('current-spec emission', () => { + const doc = generateFixture(); + + it('declares the current spec version, not 0.2', () => { + expect(doc.dspack).toBe(SPEC_VERSION); + expect(SPEC_VERSION).toBe('0.4'); + }); + + it('states the refusal guarantee in the metadata note, not the overwrite warning', () => { + const note = String((doc.metadata as Record).note); + expect(note).not.toContain('will be overwritten'); + expect(note).toContain('refuse'); + }); +}); + +describe('the x-bootstrap ledger', () => { + const doc = generateFixture(); + const ledger = ledgerOf(doc); + + it('is present, spec-stamped, and non-semantic metadata', () => { + expect(ledger).toBeDefined(); + expect(ledger.spec).toBe(SPEC_VERSION); + }); + + it('records a content hash for every generated section present in the document', () => { + for (const section of GENERATED_SECTIONS) { + const value = (doc as Record)[section]; + if (value === undefined) { + expect(ledger.generated[section]).toBeUndefined(); + } else { + expect(ledger.generated[section]).toBe(sectionHash(value)); + } + } + // Nothing beyond the generated set is ever claimed by the tool. + for (const key of Object.keys(ledger.generated)) { + expect(GENERATED_SECTIONS).toContain(key); + } + }); + + it('names the governance surfaces awaiting authorship', () => { + expect(ledger.awaitingAuthorship).toContain('rules'); + expect(ledger.awaitingAuthorship).toContain('intents'); + expect(ledger.awaitingAuthorship).toContain('examples'); + expect(ledger.awaitingAuthorship).toContain('categories'); + }); + + it('emits no empty governance blocks', () => { + for (const block of ['categories', 'intents', 'rules', 'examples', 'patterns', 'antiPatterns']) { + expect((doc as Record)[block]).toBeUndefined(); + } + }); + + it('is deterministic (ledger hashes included)', () => { + expect(JSON.stringify(generateFixture())).toBe(JSON.stringify(doc)); + }); +}); + +describe('regeneration refusal table', () => { + const doc = generateFixture(); + const raw = JSON.stringify(doc, null, 2) + '\n'; + + it('allows when no file exists', () => { + expect(decideRegeneration(null)).toEqual({ allow: true }); + }); + + it('refuses an unparseable file', () => { + const d = decideRegeneration('not json {'); + expect(d.allow).toBe(false); + if (!d.allow) expect(d.reason).toMatch(/JSON/i); + }); + + it('refuses, always, when the ledger is absent (human-owned document)', () => { + const owned = JSON.parse(raw) as DspackDocument; + delete (owned.metadata as Record)['x-bootstrap']; + const d = decideRegeneration(JSON.stringify(owned)); + expect(d.allow).toBe(false); + if (!d.allow) expect(d.reason).toMatch(/human-owned|no bootstrap ledger/i); + }); + + it('allows a pure untouched snapshot', () => { + expect(decideRegeneration(raw)).toEqual({ allow: true }); + }); + + it('refuses when human-authored sections are present, even with matching hashes', () => { + const enriched = JSON.parse(raw) as Record; + enriched.intents = [{ id: 'signup-form', description: 'x' }]; + const d = decideRegeneration(JSON.stringify(enriched)); + expect(d.allow).toBe(false); + if (!d.allow) expect(d.reason).toContain('intents'); + }); + + it('refuses when a generated section was edited, naming the section', () => { + const edited = JSON.parse(raw) as DspackDocument; + (edited.components as Record)['button'].whenToUse = 'enriched'; + const d = decideRegeneration(JSON.stringify(edited)); + expect(d.allow).toBe(false); + if (!d.allow) expect(d.reason).toContain('components'); + }); +}); From 3b0bb3a783feb0ae8922172824192e8121174c8a Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 22 Jul 2026 15:58:54 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20node=2022=20floor=20=E2=80=94=20?= =?UTF-8?q?fs.globSync=20(config.ts:84)=20does=20not=20exist=20on=20node?= =?UTF-8?q?=2020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo claimed engines >=20 but was never CI-tested; the first CI run caught it. Declare the real floor (engines >=22, CI node 22). Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8805e3..1a2037b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - run: npm ci - run: npm run build diff --git a/package.json b/package.json index f456764..3093f03 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dspack-export": "dist/cli.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "author": { "name": "Ryan Dombrowski"