diff --git a/docs/CONTRACT-PIN.md b/docs/CONTRACT-PIN.md new file mode 100644 index 0000000..3651b3b --- /dev/null +++ b/docs/CONTRACT-PIN.md @@ -0,0 +1,51 @@ +# The shadcn contract pin + +`packages/contracts/shadcn-ui.dspack.json` is **pinned to one upstream commit** instead of tracking `dspack@main`. This file is the record of that decision. + +A pin is not staleness. Staleness is silent and is discovered when something breaks; a pin names the exact bytes this repo was built against, verifies them by hash on every CI run, reports how far behind `main` it sits, and states what has to be true before it is removed. + +## The pin + +| | | +|---|---| +| **Pinned ref** | [`805732c154f0f214721c9934a450b0edb2656c99`](https://github.com/aestheticfunction/dspack/commit/805732c154f0f214721c9934a450b0edb2656c99) — *feat(shadcn): record-collection intent (v2.3.0)*, 2026‑07‑22 | +| **Pinned contract** | shadcn/ui **v2.3.0** — 8 components, 39 sub-components, 2 intents, 8 rules, 2 worked examples, 70 040 bytes | +| **sha256** | `ca19f8410a97f2004cf1d6f6dd2d7542abccfbb5430b756e0ccdc1ee954c7bb7` | +| **Current upstream** | shadcn/ui **v3.0.0** at [`48643ff`](https://github.com/aestheticfunction/dspack/commit/48643ff) (merged as [`b573637`](https://github.com/aestheticfunction/dspack/commit/b573637), dspack#35) — 32 components, 106 sub-components, 11 intents, 48 rules, 14 worked examples, 460 066 bytes | +| **Tracking issue** | aestheticfunction/dspack-studio#48 | + +The pinned copy is byte-identical to that upstream commit — the same artifact `dspack-emit` pins, verified by the same sha256. Nothing is forked: no contract content was copied, edited, or re-authored to make the check pass. + +> **The pinned contract is not current shadcn/ui coverage.** It describes 8 of the 32 components the design system's contract now governs. Do not cite this repo's catalogs, scenario surfaces, or renderer coverage as a statement about shadcn/ui support. + +## Why the pin exists + +dspack#35 merged the production contract (a 4× vocabulary expansion) on 2026‑08‑05. The shadcn renderers, the emit profile, and the scenario surfaces here were all built against v2.3.0. + +The sync check runs in CI **before** the unit tests, so once upstream moved, every downstream step — unit tests, type checks, Playwright — was skipped rather than run. That is the concrete harm: not a warning, but a repo whose test evidence silently stopped being produced. + +Following `main` would not have widened coverage; it would have broken catalog builds and scenario surfaces, and coupled unrelated work to a migration that cannot land yet. + +## Removal condition + +Replace the pin with the production contract only after **the dspack-emit representation foundation lands and the profile migration completes** — the emitter must be able to represent the production catalog before this repo consumes it. Tracked in aestheticfunction/dspack-emit#28, whose checklist is the gating work: + +- [ ] profile v2 schema + explicit `profileVersion` dispatch +- [ ] v1 directive desugaring into the internal Identity/Route/Collect model +- [ ] load-time validation of selectors and destinations +- [ ] surface fidelity reporting + `--strict-surface` +- [ ] sub-component coverage derived and enforced +- [ ] `functions` support on the profile/catalog path +- [ ] the restated parity invariant + +Then: remove the `pin` block, `node scripts/check-sync.mjs --write`, rebuild catalogs (`build:catalogs`), re-record scenario surfaces, and commit them together. + +## How the pin is enforced + +`packages/contracts/scripts/check-sync.mjs` runs in CI on every push and PR. For a pinned entry it: + +1. fetches the artifact at the pinned commit and **fails if its sha256 differs from the recorded hash** — a pinned ref must be immutable, so a change means a force-push, history rewrite or CDN mismatch, never a routine update; +2. fails if the local copy drifts from those exact bytes, as before; +3. fetches the tracked branch and **always prints how far behind the pin sits**, so it can never quietly read as current. + +Verified: clean pin exits 0; a mutated expected hash exits 1 with `TAMPERED`; a one-byte local edit exits 1. diff --git a/packages/contracts/scripts/check-sync.mjs b/packages/contracts/scripts/check-sync.mjs index cbfab53..1690a9b 100644 --- a/packages/contracts/scripts/check-sync.mjs +++ b/packages/contracts/scripts/check-sync.mjs @@ -16,15 +16,27 @@ * change lands in dspack first; the consuming dspack-studio PR carries * the byte-copied file; sync checks are green in both repositories. * + * An entry may carry a `pin`: a deliberate hold at one upstream commit rather + * than following a branch. A pin is a STRONGER claim than tracking, not a + * weaker one — it asserts the exact bytes this repo was built against, is + * verified by sha256 on every run, always reports how far behind the tracked + * branch it sits, and carries an explicit removal condition. Staleness hides; + * a pin announces itself. See docs/CONTRACT-PIN.md. + * * Boring by design: node builtins + global fetch, one retry, no deps. * `--write` re-syncs local copies from canonical. */ +import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const RAW = "https://raw.githubusercontent.com/aestheticfunction/dspack"; + +const sha256 = (buf) => createHash("sha256").update(buf).digest("hex"); + const MANIFEST = [ { local: join(root, "astryx.dspack.json"), @@ -36,9 +48,27 @@ const MANIFEST = [ { local: join(root, "shadcn-ui.dspack.json"), label: "shadcn-ui.dspack.json", - source: - "https://raw.githubusercontent.com/aestheticfunction/dspack/main/examples/shadcn-ui.dspack.json", + source: `${RAW}/805732c154f0f214721c9934a450b0edb2656c99/examples/shadcn-ui.dspack.json`, note: "the shadcn contract — copy of the spec repo's source of truth", + // A DELIBERATE PIN, not staleness. dspack main now carries the + // 32-component production contract, but the shadcn renderers, the emit + // profile and the scenario surfaces here were all built against the + // 8-component v2.3.0 contract. Migrating before the emitter can represent + // the production catalog would break catalog builds and scenario surfaces + // rather than widen coverage. See docs/CONTRACT-PIN.md. + pin: { + ref: "805732c154f0f214721c9934a450b0edb2656c99", + version: "2.3.0", + // Teeth: a pinned ref must be immutable. If the bytes behind it change + // (force-push, history rewrite, CDN mismatch), fail loudly rather than + // quietly re-syncing to something this repo was never built for. + sha256: "ca19f8410a97f2004cf1d6f6dd2d7542abccfbb5430b756e0ccdc1ee954c7bb7", + tracks: `${RAW}/main/examples/shadcn-ui.dspack.json`, + removeWhen: + "the dspack-emit representation foundation lands and the profile migration completes — " + + "the emitter must be able to represent the production catalog first", + issue: "aestheticfunction/dspack-studio#48", + }, }, ]; @@ -57,9 +87,40 @@ async function fetchSource(url) { } } +/** A pinned ref must be immutable; report how far behind the tracked branch it sits. */ +async function reportPin(entry, source) { + const { pin } = entry; + const actual = sha256(source); + if (actual !== pin.sha256) { + console.error(`TAMPERED ${entry.label} the PINNED artifact itself changed`); + console.error(` ref ${pin.ref}`); + console.error(` expected sha256 ${pin.sha256}`); + console.error(` actual sha256 ${actual}`); + console.error(` a pinned commit must be immutable — investigate before re-syncing.`); + return false; + } + let ahead = "unavailable"; + try { + const head = await fetchSource(pin.tracks); + ahead = head.equals(source) + ? "none — main matches the pin; the pin can be lifted" + : `main has moved (v${JSON.parse(head.toString()).version}, ${head.length} bytes vs pinned ${source.length})`; + } catch { + /* offline: the pin still verifies against its own hash */ + } + console.log(`PINNED ${entry.label} v${pin.version} @ ${pin.ref.slice(0, 7)} (sha256 verified)`); + console.log(` upstream drift: ${ahead}`); + console.log(` NOT current production shadcn coverage — see docs/CONTRACT-PIN.md (${pin.issue})`); + return true; +} + let drifted = 0; for (const entry of MANIFEST) { const source = await fetchSource(entry.source); + if (entry.pin && !(await reportPin(entry, source))) { + drifted++; + continue; + } let local; try { local = readFileSync(entry.local); @@ -67,7 +128,7 @@ for (const entry of MANIFEST) { local = null; } if (local && source.equals(local)) { - console.log(`in sync ${entry.label}`); + if (!entry.pin) console.log(`in sync ${entry.label}`); continue; } if (write) {