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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ https://github.com/user-attachments/assets/510a781b-4214-49b3-b997-9cbecdc36961

| Path | What |
| --- | --- |
| `input/shadcn-ui.dspack.json` | Example input: a dspack v0.4 contract from a React+Tailwind system (shadcn/ui). |
| `input/shadcn-ui.dspack.json` | Example input: a dspack v0.4 contract from a React+Tailwind system (shadcn/ui). **Pinned to v2.3.0 (8 components) — not current shadcn coverage;** see [`docs/CONTRACT-PIN.md`](docs/CONTRACT-PIN.md). |
| `src/` | The transformer: source-agnostic engine + data-only profile + versioned emitter + validator. |
| `surface/settings-card.surface.json` | Hand-authored A2UI v0.9.1 surface instantiating the compiled components. |
| `out/catalog.v0_9_1.json`, `out/catalog.v1_0.json` | Generated catalogs (no external `$ref`, versioned `catalogId`). |
Expand Down
64 changes: 64 additions & 0 deletions docs/CONTRACT-PIN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# The shadcn contract pin

`input/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 package 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-emit#28 |

The pinned copy is byte-identical to that upstream commit. Nothing here is a fork: 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 package's catalogs, coverage tables or fidelity reports as a statement about shadcn/ui support. The canonical evidence corpus is the production contract on `dspack@main`.

## Why the pin exists

dspack#35 merged the production contract (a 4× vocabulary expansion) on 2026‑08‑05. Everything in this package that consumes that contract — the `shadcnProfile`, the shadcn renderers in dspack-studio, and the `profile-parity` invariants — was designed against v2.3.0.

Following `main` immediately would not have produced a bigger catalog; it would have produced a broken one, and would have merged the approved *foundation* milestone into the T1–T5 representation work that is explicitly paused.

## Measured gaps preventing honest migration

Taken by syncing the production contract locally and running the suite (2026‑08‑05):

| Gap | Measurement |
|---|---|
| Worked examples that refuse to emit | **12 of 14** (`ex.delete-project-confirmation`, `ex.workspace-members-directory`, `ex.expense-report-form`, `ex.notification-preferences`, `ex.order-detail-summary`, `ex.invite-teammates-dialog`, `ex.project-workspace-panels`, `ex.docs-article-trail`, `ex.usage-help-affordances`, `ex.customer-context-sheet`, `ex.import-run-status`, `ex.orders-table-loading`) |
| Components with no classification | **24 of 32** — `shadcnProfile` maps 6 and declares 2 casualties |
| Sub-components needing `subCoverage` | **106**, against 39 today |
| Renderers silently misrendering | **7 of 22** emitted instances across six shadcn renderers |
| Declared `checks` resolving to `{}` | **5 components** — the profile format has no `functions` path |

The parity suite fails **14 tests** on a bare sync. Critically, the two completeness invariants fail for a reason no amount of profile authoring fixes cleanly: `profile-parity.test.ts` asserts *every worked example emits*, which the production contract cannot satisfy until refusal-for-an-acknowledged-reason is a first-class outcome.

Closing the gap by declaring 24 new casualties was considered and rejected: those components are "not mapped yet", not "cannot represent", and overloading the casualty vocabulary would weaken the fail-closed gate that vocabulary exists to power.

## Removal condition

Replace the pin with the production contract **only after all of the following are implemented and measured**:

- [ ] profile v2 schema + explicit `profileVersion` dispatch
- [ ] v1 directive desugaring into the internal Identity/Route/Collect model
- [ ] load-time validation of selectors and destinations
- [ ] `EmitSurfaceResult.fidelity` + `--strict-surface`
- [ ] sub-component coverage derived and enforced from the internal model
- [ ] the six shadcn renderer drifts repaired, with props-level parity tests
- [ ] `functions` support on the profile/catalog path
- [ ] `profile-parity.test.ts` invariant 1 restated as *every worked example either emits, or refuses for a declared-casualty reason the contract itself acknowledges*

At that point: `node scripts/check-sync.mjs --write` after removing the `pin` block, regenerate the derived catalogs and json-render goldens, and commit them together.

## How the pin is enforced

`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 the pin can never quietly read as current.
73 changes: 68 additions & 5 deletions scripts/check-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,56 @@
* moved (or the copy was edited locally) — run with --write to re-sync,
* then regenerate anything derived (catalogs, json-render goldens) and commit both together.
*
* 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 package 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.
*/
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";

const RAW = "https://raw.githubusercontent.com/aestheticfunction/dspack";

const MANIFEST = [
{
local: "input/astryx.dspack.json",
source:
"https://raw.githubusercontent.com/aestheticfunction/dspack/main/examples/astryx.dspack.json",
source: `${RAW}/main/examples/astryx.dspack.json`,
note: "the Astryx transformer input — copy of the spec repo source of truth",
},
{
local: "input/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 transformer input — copy of the spec repo source of truth",
// A DELIBERATE PIN, not staleness. See docs/CONTRACT-PIN.md for the full
// record; the short version: dspack main now carries the 32-component
// production contract, but this package's profile, renderers and parity
// invariant were all built against the 8-component v2.3.0 contract, and
// migrating before the representation foundation lands would silently
// convert 12 of 14 worked examples into refusals.
pin: {
ref: "805732c154f0f214721c9934a450b0edb2656c99",
version: "2.3.0",
// Teeth: a pinned ref should be immutable. If the bytes behind it ever
// change (force-push, history rewrite, CDN mismatch), fail loudly rather
// than quietly re-syncing to something the profile was never designed for.
sha256: "ca19f8410a97f2004cf1d6f6dd2d7542abccfbb5430b756e0ccdc1ee954c7bb7",
// Never let the pin masquerade as current coverage: every run reports how
// far behind `main` it is.
tracks: `${RAW}/main/examples/shadcn-ui.dspack.json`,
removeWhen:
"profile v2 + v1 desugaring + load-time validation + surface fidelity/--strict-surface + " +
"renderer parity + functions support + the restated parity invariant are implemented and measured",
issue: "aestheticfunction/dspack-emit#28",
},
},
];

const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");

const write = process.argv.includes("--write");

async function fetchSource(url) {
Expand All @@ -44,17 +75,49 @@ 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.local} 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;
}
// The pin is never allowed to read as current coverage.
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.local} v${pin.version} @ ${pin.ref.slice(0, 7)} (sha256 verified)`);
console.log(` upstream drift: ${ahead}`);
console.log(` NOT current 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);
} catch {
local = null;
}
if (local && source.equals(local)) {
console.log(`in sync ${entry.local}`);
if (!entry.pin) console.log(`in sync ${entry.local}`);
continue;
}
if (write) {
Expand Down
63 changes: 63 additions & 0 deletions src/profile-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,69 @@ describe("casualty refusal cites the authored reason", () => {
});
});

describe("a declared casualty cannot be consumed into a parent", () => {
/**
* The plan-lookup gate refuses a casualty emitted as its OWN node. A
* consuming strategy (subText / subButtonText / subTable / subFlatten)
* walks the subtree directly, so without an equivalent gate an author
* could fold a casualty's text into a parent prop and the authored
* refusal would vanish behind a warning. Consumption is how compounds
* carry their parts — never an escape hatch around "cannot represent".
*/
it("refuses a casualty folded in by a parent's subText, citing the authored reason", () => {
const withFold = {
...shadcnProfile,
components: shadcnProfile.components.map((plan) =>
plan.dspackId === "card"
? {
...plan,
surfacePlan: {
...(plan.surfacePlan ?? {}),
subText: { ...(plan.surfacePlan?.subText ?? {}), "dropdown-menu": "title" },
},
}
: plan,
),
};
const surface: DspackSurface = {
dspackSurface: "0.1",
system: shadcnDoc.name as string,
intent: "record-collection",
root: {
component: "card",
children: [{ component: "dropdown-menu", text: "Row actions" }],
},
} as DspackSurface;
try {
emitSurface(surface, shadcnDoc, { profile: withFold });
expect.unreachable("a consumed casualty must refuse, not fold silently");
} catch (e) {
expect(e).toBeInstanceOf(EmitSurfaceError);
const msg = (e as EmitSurfaceError).message;
expect(msg).toContain("declared casualty");
expect(msg).toContain("cannot be consumed into");
expect(msg).toContain("dropdown menu"); // the authored reason survives
// The refusal points at the casualty itself, not at the parent that
// would have consumed it — same locating precision as direct emission.
expect((e as EmitSurfaceError).path).toBe("$.root.children[0]");
}
});

it("still consumes ordinary sub-components (the gate is casualty-scoped)", () => {
const surface: DspackSurface = {
dspackSurface: "0.1",
system: shadcnDoc.name as string,
intent: "record-collection",
root: {
component: "card",
children: [{ component: "card-header", children: [{ component: "card-title", text: "Members" }] }],
},
} as DspackSurface;
const result = emitSurface(surface, shadcnDoc, { profile: shadcnProfile });
expect(JSON.stringify(result.messages)).toContain("Members");
});
});

describe("scaffoldProfile", () => {
it("produces a loadProfile-valid profile from a bare contract", () => {
const { profile } = scaffoldProfile(astryxDoc, {
Expand Down
33 changes: 33 additions & 0 deletions src/targets/a2ui/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ class SurfaceEmitter {
if (value !== undefined) instance[key] = value as Json[keyof Json];
}
}
// A declared casualty is an authored refusal: it must never be laundered
// into a parent's text fold. The plan-lookup gate above only fires when a
// casualty is emitted as its OWN node; a consuming strategy (subText,
// subButtonText, subTable, subFlatten) walks the subtree directly and
// would otherwise fold the casualty's text in with a mere warning.
if (consumesSubtree || sp.subFlatten) this.refuseConsumedCasualty(node, path);

if (sp.subText || sp.subButtonText) this.applySubContent(node, sp.subText ?? {}, sp.subButtonText ?? {}, instance, path);
if (sp.subTable) this.applySubTable(node, sp.subTable, instance, path);
if (sp.textProp && node.text !== undefined) instance[sp.textProp] = node.text;
Expand Down Expand Up @@ -230,6 +237,32 @@ class SurfaceEmitter {
}
}

/**
* Refuse when a consumed subtree contains a component the profile declared a
* casualty. Consumption is how compounds fold their parts into props; it is
* NOT an escape hatch around an author's "this cannot be represented".
* Fail-closed and loud, with the authored reason, exactly like the direct
* emission path — including the offending node's own path, so the refusal
* points at the casualty rather than at the parent that would have eaten it.
*/
private refuseConsumedCasualty(node: SurfaceNode, path: string): void {
const walk = (n: SurfaceNode, nodePath: string): void => {
if (n !== node) {
const casualty = this.profile.casualtyComponents.find((c) => c.dspackId === n.component);
if (casualty && !this.byDspackId.has(n.component)) {
throw new EmitSurfaceError(
`component '${n.component}' is a declared casualty (${casualty.class}) of the ` +
`'${this.profile.catalogTitle}' profile and cannot be consumed into ` +
`'${node.component}': ${casualty.reason}`,
nodePath,
);
}
}
collectChildren(n).forEach((child, i) => walk(child.node, `${nodePath}${child.suffix}[${i}]`));
};
walk(node, path);
}

/**
* Compound flattening: pull text out of named sub-components anywhere in the
* subtree. The subtree is consumed — the documented composition casualty.
Expand Down
Loading