From 8b7303d06c8f18c6f4a1b56a4bad5dd7a176c72e Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Tue, 4 Aug 2026 08:14:27 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20ledger=20v2=20=E2=80=94=20per-compo?= =?UTF-8?q?nent=20ownership,=20deletion=20memory,=20tombstones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the two granularity limitations recorded in #13 by moving components-section ownership from whole-section to per-entry, inside the existing metadata["x-bootstrap"] ledger (exporter metadata; no spec change). - ledger: '2' + per-entry hashes (ledger.components, id -> sha256) - orphaned hash = deletion memory: rediscovery skips and asks (deletedAwaitingDecision), never silently restores - doNotRediscover tombstones (plain ids, authored data) - fail-closed write rule: generated.components omitted whenever any entry is human-owned or any tombstone/orphan exists, so shipped 0.4.0 paths refuse v2-active documents (byte-tested against the published package via the dspack-export-shipped devDep) - freshDelta on preserved enriched entries: fresh-side facts only, report-only, never persisted, never a merge - automatic migration, byte-stable outside x-bootstrap; malformed / partially-migrated ledgers refuse with pathed findings; no force flag - per-entry report classifications for composer UIs (added, refreshed, unchanged, readopted, preservedEnriched, removedWithSource, keptMissingInFresh, deletedAwaitingDecision, suppressed, suppressedButPresent, restoredConflict, entryHashRetired) Fail-first: the full src/tests/ledger-v2.test.ts battery (15 tests, including the #13 restructure case and the shipped-0.4.0 fail-closed pair) was written and run against the pre-change code first — 15/15 failed — before the implementation landed. Golden fixtures regenerated deterministically (SOURCE_DATE_EPOCH=1781049600) and now carry v2 ledgers; pack-and-install boundary test extended to exercise orphan skip-and-ask and tombstone suppression from the tarball. 126/127 tests green (1 pre-existing DS_MCP-gated skip). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 61 +++ README.md | 11 +- .../dtcg-tokens-demo.dspack.json | 5 + fixtures/shadcn-demo/shadcn-demo.dspack.json | 11 + .../shadcn-v4-demo/shadcn-v4-demo.dspack.json | 5 + .../vuetify-demo/vuetify-demo.dspack.json | 9 + package-lock.json | 31 +- package.json | 3 +- scripts/pack-test.sh | 45 ++- src/emit/bootstrap.ts | 48 ++- src/index.ts | 9 +- src/regenerate.ts | 361 +++++++++++++++--- src/tests/ledger-v2.test.ts | 301 +++++++++++++++ src/tests/regenerate.test.ts | 23 +- 14 files changed, 847 insertions(+), 76 deletions(-) create mode 100644 src/tests/ledger-v2.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ceeeed..8a48a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## 0.5.0 + +Ledger v2: per-component ownership inside `metadata["x-bootstrap"]` +(exporter-owned, non-semantic metadata — no dspack spec change). Resolves +the two granularity limitations recorded in #13. + +- **Per-entry hashes** (`ledger.components`, id → sha256): ownership of the + components section is now decided entry by entry. Tool-owned entries + refresh deterministically; a human-edited entry is preserved verbatim and + its stale recorded hash (or absence from the map) is the human-owned + signal. +- **Deletion memory (skip-and-ask)**: a ledger hash whose entry is absent + from the document is an orphan — rediscovery never silently restores the + component; it reports `deletedAwaitingDecision` and carries the hash + forward until a human resolves it. +- **`doNotRediscover` tombstones** (plain component ids, authored data): + rediscovery skips them unambiguously (`suppressed`); a tombstoned id that + is nonetheless present in the document reconciles as + `suppressedButPresent`. +- **Fail-closed against pre-v2 tooling**: `generated.components` (the v1 + whole-section signal) is written only when every entry is tool-owned and + no tombstone or orphan exists. Any v2-active document therefore refuses + under shipped 0.4.0's `decideRegeneration`/`regenerateSections` — byte- + tested against the published package (`dspack-export-shipped` devDep). + Ledger-v2 documents require this version or later (version floor; the + ratified alternative to patching 0.4.x). +- **`freshDelta`** on preserved human-owned entries: fresh-side facts only + (added props/variants/enum values, changed scalars, with values and + JSON-pointer-like paths), report-only, never persisted, never a merge. + Explicitly not claimed: detection of source removals inside enriched + entries (reconsidered only on real-project evidence — no telemetry). +- **Migration is automatic and byte-stable outside `x-bootstrap`**: a v1 + ledger with a tool-owned components section synthesizes per-entry hashes; + a human-owned section migrates with entries unattributed, and re-adoption + reclaims any entry still byte-identical to fresh discovery. Malformed, + contradictory, or partially migrated ledgers refuse with pathed findings. + There is still no force flag anywhere. +- Regeneration report gains per-entry classifications (`added`, `refreshed`, + `unchanged`, `readopted`, `preservedEnriched`, `removedWithSource`, + `keptMissingInFresh`, `deletedAwaitingDecision`, `suppressed`, + `suppressedButPresent`, `restoredConflict`, `entryHashRetired`) so callers + (the Studio composer) can present every decision explicitly. +- `LEDGER_VERSION`, `ComponentReport`, and `FreshFact` are exported; + golden fixtures regenerated with v2 ledgers; the pack-and-install + boundary test now exercises orphan skip-and-ask and tombstone suppression + from the tarball. + +## 0.4.0 + +(Entry backfilled with 0.5.0; released 2026-08-03 via PR #12.) + +- Programmatic library entry: `exportProject(configPath)` (the equivalent of + `generate --config`) plus exported primitives (`sectionHash`, + `buildLedger`, `decideRegeneration`, types). +- **Section-scoped `regenerateSections(existing, fresh)`**: refreshes only + hash-matching tool-owned sections, preserves human-owned sections and + governance verbatim, adds newly discovered components into a human-owned + components section as pure additions, and refuses ledger-less documents. + No force flag. +- Pack-and-install boundary test (`npm run test:pack`) wired into CI. + ## 0.3.0 The DX-3 bootstrap boundary (dspack `rfc/dx3-bootstrap-design.md`), and the diff --git a/README.md b/README.md index 0bc4d85..289017c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ 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.3.0`). Config format and output details may +**Status: experimental** (`0.5.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. @@ -32,6 +32,15 @@ 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. +Since 0.5.0 the ledger is **v2**: ownership of the components section is +tracked per entry (`ledger.components`, id → hash), so `regenerateSections` +can refresh untouched entries while preserving enriched ones verbatim. A +hash whose entry was hand-deleted is deletion memory — rediscovery skips it +and asks (`deletedAwaitingDecision`) instead of silently restoring it — and +ids listed in `ledger.doNotRediscover` are never re-added. Any document +using these v2 states omits the v1 whole-section signal, so older releases +refuse to touch it rather than mishandle it. + **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 diff --git a/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json b/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json index f8169e5..51c94f2 100644 --- a/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json +++ b/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json @@ -9,6 +9,7 @@ "source": "fixtures/dtcg-tokens-demo", "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": { + "ledger": "2", "spec": "0.4", "generated": { "tokens": "6ff5c3fd63f4ba56337320d79583982bfce66919a3552f4938e95cbd1005326c", @@ -17,6 +18,10 @@ "themes": "8fec47475f975ca9da6b547d5bd2d8ab5ac0f1c126ce98bb0614c01f39a66dad", "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" }, + "components": { + "button": "ec7d55f9e100fbe58ef0f9a407882592f681515d365aeba6849d797f847aad45" + }, + "doNotRediscover": [], "awaitingAuthorship": [ "categories", "intents", diff --git a/fixtures/shadcn-demo/shadcn-demo.dspack.json b/fixtures/shadcn-demo/shadcn-demo.dspack.json index 2faabaa..84a6439 100644 --- a/fixtures/shadcn-demo/shadcn-demo.dspack.json +++ b/fixtures/shadcn-demo/shadcn-demo.dspack.json @@ -9,6 +9,7 @@ "source": "fixtures/shadcn-demo", "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": { + "ledger": "2", "spec": "0.4", "generated": { "tokens": "d031a79c0a10556c38b099145be82d26eed263713c60922efab56338aca69f15", @@ -17,6 +18,16 @@ "themes": "46510dd813828a275bd601d0f0348934b9cd1b8b29b4cc525538472f3958173f", "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" }, + "components": { + "badge": "8441ce8aef1597b09e3e9ad14e71e1dba5b00e519a90b7e0cfd139263d4085f6", + "button": "115d3bed70bd9ceab5338e9f1e3e379ac61f3b0c35992ee83fe7dd59e6dfb573", + "card": "3c0e8da41c1130bd245a08bea4c9287812cfe449a5e468dd03e7c3c36c459644", + "card-header": "5467b6377f5a356856ab89ba2a45120b89b2e98a1f926b842d95339b5dadcc8a", + "card-title": "b475008cd40f61288baa5bd7af9c7a947bdd0a890756ee865025b2a83f51d479", + "card-content": "6464cf3e1ef6f041a2feb434c95bd74cd6a92ef023f900d4c7b5bf5f05a573df", + "input": "e591c2b2c642c2a169fdead4a85ccc4123a76db88d7cfb918a32f9aca6677a9c" + }, + "doNotRediscover": [], "awaitingAuthorship": [ "categories", "intents", diff --git a/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json b/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json index 9449eb5..8ba1106 100644 --- a/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json +++ b/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json @@ -9,6 +9,7 @@ "source": "fixtures/shadcn-v4-demo", "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": { + "ledger": "2", "spec": "0.4", "generated": { "tokens": "a6f0f842d11f89823929bb7e3df4d40c19357c119dd42a687e0adbd1fa8e58d0", @@ -17,6 +18,10 @@ "themes": "fafbb44d5be2b3a7954c6cb4fb1590a669e8aa1c84eecb3ad15b421e14a0042b", "layout": "0b92b1df3e314dcb730718fd0c087a680750c7a3a33f16f2f1c0a6d885c4c585" }, + "components": { + "button": "0af3dda0e80d46130863b999b31ebe5c30903789a8329755294cf1c3bb497cbf" + }, + "doNotRediscover": [], "awaitingAuthorship": [ "categories", "intents", diff --git a/fixtures/vuetify-demo/vuetify-demo.dspack.json b/fixtures/vuetify-demo/vuetify-demo.dspack.json index fa2c576..904f856 100644 --- a/fixtures/vuetify-demo/vuetify-demo.dspack.json +++ b/fixtures/vuetify-demo/vuetify-demo.dspack.json @@ -9,12 +9,21 @@ "source": "fixtures/vuetify-demo", "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": { + "ledger": "2", "spec": "0.4", "generated": { "tokens": "411ade9c82231a3fcb56a281e79f25d4eb3f8e10001e2c7d06b7ff8dff9bec66", "components": "7aa04b6cb24b0dd8d580503f82e2b7fc5f9962f2e94bea402f4252ff1f02fb77", "frameworkBindings": "3d6893c966874d773c2c3ebcfbedbb481b4947ec86f986aa30855ccda7ef970c" }, + "components": { + "app-button": "5f8b7e9c5cc1047b4412b73f2c2f02f1f4af5de5a5db92057f8bf21b167caa10", + "array-props": "915f61261be849baf83acf4d99b2f2c8fd1ce773aeb4532a8b1499534a57fab0", + "data-card": "7e50dfc649665d79778377450c54f1f101d7a868ef06549e7400b982c22251b0", + "form-field": "0e6d1a7d82a2ac74c612a2f8461cf3d363a6cc1135898285e5c1278f02cd995c", + "status-badge": "60cb70dd7d6bfbb788cd52bc3599e2fc4d2dfb55a889100fa35c78807f2c92bf" + }, + "doNotRediscover": [], "awaitingAuthorship": [ "categories", "intents", diff --git a/package-lock.json b/package-lock.json index 0936a71..6d8fad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aestheticfunction/dspack-export", - "version": "0.2.0-alpha.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aestheticfunction/dspack-export", - "version": "0.2.0-alpha.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "@babel/parser": "^7.28.5", @@ -29,13 +29,14 @@ "@types/react": "^18.3.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dspack-export-shipped": "npm:@aestheticfunction/dspack-export@^0.4.0", "react": "^18.3.1", "tailwind-merge": "^2.6.0", "vitest": "^3.0.0", "vue": "^3.5.38" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@babel/code-frame": { @@ -1440,6 +1441,30 @@ "node": ">=6" } }, + "node_modules/dspack-export-shipped": { + "name": "@aestheticfunction/dspack-export", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@aestheticfunction/dspack-export/-/dspack-export-0.4.0.tgz", + "integrity": "sha512-Y7q5ZcwnpxOXMWvnEBQ422LPBXSaluXKKr1xWZPv3xaq26DpB3WSU8rPrqpPhqZ0LLb4wUQ7I57I8t+QGlsFlg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@vue/compiler-sfc": "^3.5.38", + "ajv": "^8.17.0", + "minimist": "^1.2.8", + "react-docgen-typescript": "^2.4.0", + "typescript": "^5.5.0" + }, + "bin": { + "dspack-export": "dist/cli.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", diff --git a/package.json b/package.json index e151df2..695d058 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aestheticfunction/dspack-export", - "version": "0.4.0", + "version": "0.5.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", @@ -58,6 +58,7 @@ "@types/react": "^18.3.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dspack-export-shipped": "npm:@aestheticfunction/dspack-export@^0.4.0", "react": "^18.3.1", "tailwind-merge": "^2.6.0", "vitest": "^3.0.0", diff --git a/scripts/pack-test.sh b/scripts/pack-test.sh index 2eddaac..8c8c0b6 100755 --- a/scripts/pack-test.sh +++ b/scripts/pack-test.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Pack-and-install boundary test: the library entry must be consumable # exactly as published — exportProject and regenerateSections work from the -# tarball, the ownership invariants hold, and the bin still runs. +# tarball, the ownership invariants hold (now at ledger-v2 entry granularity: +# orphan skip-and-ask, tombstone suppression, the v1 fail-closed write rule), +# and the bin still runs. set -euo pipefail cd "$(dirname "$0")/.." @@ -17,13 +19,17 @@ npm init -y >/dev/null 2>&1 npm install --no-fund --no-audit "./$TARBALL" >/dev/null cat > smoke.mjs <<'SMOKE' -import { exportProject, regenerateSections, sectionHash, GENERATED_SECTIONS, decideRegeneration } from "@aestheticfunction/dspack-export"; +import { exportProject, regenerateSections, sectionHash, GENERATED_SECTIONS, decideRegeneration, LEDGER_VERSION } from "@aestheticfunction/dspack-export"; const configPath = `${process.env.REPO_ROOT}/fixtures/shadcn-demo/dspack-export.config.json`; const { document } = exportProject(configPath); -if (!document.metadata["x-bootstrap"]) throw new Error("exportProject lost the ledger"); +const ledger = document.metadata["x-bootstrap"]; +if (!ledger) throw new Error("exportProject lost the ledger"); +if (ledger.ledger !== LEDGER_VERSION) throw new Error("fresh ledger is not v2"); +if (Object.keys(ledger.components).length === 0) throw new Error("no per-entry hashes"); -// Enrich, then regenerate: human-owned preserved with its hash, governance verbatim. +// Enrich, then regenerate: human-owned entry preserved, governance verbatim, +// and the v2 write rule omits the legacy whole-section signal. const enriched = structuredClone(document); enriched.components.button.whenToUse = "Any user-initiated action."; enriched.intents = [{ id: "demo-intent", description: "A demo intent." }]; @@ -32,9 +38,30 @@ const merged = regenerateSections(enriched, fresh); if (!merged.ok) throw new Error("merge refused unexpectedly"); if (merged.document.components.button.whenToUse !== "Any user-initiated action.") throw new Error("enrichment lost"); if (merged.document.intents[0].id !== "demo-intent") throw new Error("governance lost"); -if (!merged.report.preservedHumanOwned.includes("components")) throw new Error("ownership split wrong"); -const recorded = merged.document.metadata["x-bootstrap"].generated.components; -if (sectionHash(merged.document.components) === recorded) throw new Error("human-owned hash signal lost"); +const outLedger = merged.document.metadata["x-bootstrap"]; +if (outLedger.generated.components !== undefined) { + throw new Error("legacy section signal present despite a human-owned entry (v1 paths would not fail closed)"); +} +if (outLedger.components.button === sectionHash(merged.document.components.button)) { + throw new Error("human-owned entry hash signal lost"); +} + +// Orphan skip-and-ask: a hand-deleted entry is never silently restored. +const editedDoc = structuredClone(merged.document); +delete editedDoc.components.badge; +const r1 = regenerateSections(editedDoc, exportProject(configPath).document); +if (!r1.ok) throw new Error("orphan merge refused"); +if (r1.document.components.badge !== undefined) throw new Error("orphan silently restored"); +if (!r1.report.components.deletedAwaitingDecision.includes("badge")) throw new Error("deletion not asked about"); + +// Tombstone suppression. +const tombstoned = structuredClone(r1.document); +tombstoned.metadata["x-bootstrap"].doNotRediscover = ["badge"]; +delete tombstoned.metadata["x-bootstrap"].components.badge; +const r2 = regenerateSections(tombstoned, exportProject(configPath).document); +if (!r2.ok) throw new Error("tombstoned merge refused"); +if (!r2.report.components.suppressed.includes("badge")) throw new Error("tombstone not honored"); +if (r2.document.components.badge !== undefined) throw new Error("tombstoned id restored"); // Ledger-less documents fail closed; there is no force override. const bare = structuredClone(document); @@ -42,9 +69,9 @@ delete bare.metadata["x-bootstrap"]; const refused = regenerateSections(bare, fresh); if (refused.ok || !refused.reason.includes("no force override")) throw new Error("ledger-less refusal broken"); if (typeof decideRegeneration !== "function" || GENERATED_SECTIONS.length === 0) throw new Error("primitives missing"); -console.log("pack-and-install smoke: OK (exportProject + regenerateSections; refusals fail closed)"); +console.log("pack-and-install smoke: OK (v2 ledger; enrichment + governance preserved; orphan skip-and-ask; tombstone suppression; refusals fail closed)"); SMOKE REPO_ROOT="$OLDPWD" node smoke.mjs ./node_modules/.bin/dspack-export --help 2>/dev/null | head -1 >/dev/null || node node_modules/.bin/dspack-export 2>/dev/null || true -node -e "const p=require('@aestheticfunction/dspack-export/package.json'); if(p.version!=='0.4.0') throw new Error('version mismatch: '+p.version); console.log('bin + version OK:', p.version)" +node -e "const p=require('@aestheticfunction/dspack-export/package.json'); if(p.version!=='0.5.0') throw new Error('version mismatch: '+p.version); console.log('bin + version OK:', p.version)" diff --git a/src/emit/bootstrap.ts b/src/emit/bootstrap.ts index de469a2..a100278 100644 --- a/src/emit/bootstrap.ts +++ b/src/emit/bootstrap.ts @@ -15,6 +15,18 @@ import type { DspackDocument } from '../types.js'; /** The spec version this release of the tool emits. */ export const SPEC_VERSION = '0.4'; +/** + * The ledger format version this release writes. v2 (the ratified + * per-component ownership decision, 2026-08-04) adds entry-level hashes for + * the components section, the `doNotRediscover` tombstone list, and the + * write rule that omits `generated.components` whenever any entry is + * human-owned, any tombstone exists, or any orphaned hash exists — which + * makes every pre-v2 mutation path fail closed on v2-active documents. + * Ledger-v2 documents require this exporter version or later (the ratified + * version floor; shipped 0.4.0's pure-addition path cannot honor tombstones). + */ +export const LEDGER_VERSION = '2'; + /** The sections this tool generates and (while their hashes match) owns. */ export const GENERATED_SECTIONS = [ 'tokens', @@ -47,8 +59,14 @@ export const AWAITING_AUTHORSHIP = [ const BASE_KEYS = new Set(['dspack', 'name', 'description', 'version', 'metadata']); export interface BootstrapLedger { + /** Ledger format version; absent on documents written before v2. */ + ledger?: string; spec: string; generated: Record; + /** v2: per-entry content hashes for the components section (emission order). */ + components?: Record; + /** v2: component ids rediscovery must never re-add (plain ids; authored data). */ + doNotRediscover?: string[]; awaitingAuthorship: string[]; } @@ -57,14 +75,31 @@ 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). */ +/** + * Build the ledger for a freshly assembled document (hashes of present + * sections only). Generation never reads an existing ledger — fresh output + * stays a pure function of source — so a fresh document is all tool-owned + * with no tombstones and no orphans, and `generated.components` is present + * by construction. + */ 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] }; + const componentEntries = ((document as Record).components ?? {}) as Record; + const components = Object.fromEntries( + Object.entries(componentEntries).map(([id, entry]) => [id, sectionHash(entry)]), + ); + return { + ledger: LEDGER_VERSION, + spec: SPEC_VERSION, + generated, + components, + doNotRediscover: [], + awaitingAuthorship: [...AWAITING_AUTHORSHIP], + }; } export type RegenerationDecision = { allow: true } | { allow: false; reason: string }; @@ -104,9 +139,16 @@ export function decideRegeneration(existingContent: string | null): Regeneration (key) => !BASE_KEYS.has(key) && !(key in ledger.generated), ); if (humanSections.length > 0) { + // A v2-active document (tombstones, orphans, or enriched entries) omits + // generated.components by design, so it lands here: point at the + // entry-level path instead of the whole-file one. + const v2Hint = + ledger.ledger === LEDGER_VERSION + ? ' This is a ledger-v2 document; iterate through rediscovery (regenerateSections / the composer), not in-place generate.' + : ''; 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}`, + reason: `the existing document contains human-authored sections (${humanSections.join(', ')}); regenerating would sit alongside content this tool does not own.${v2Hint} ${WORKFLOW_HINT}`, }; } diff --git a/src/index.ts b/src/index.ts index a395edf..1d4b743 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ export { generateDocument, generatedAtFromEnv, GENERATOR_VERSION, type GenerateR export { GENERATED_SECTIONS, AWAITING_AUTHORSHIP, + LEDGER_VERSION, buildLedger, decideRegeneration, sectionHash, @@ -27,7 +28,13 @@ export { type RegenerationDecision, } from './emit/bootstrap.js'; export { validateDspack } from './emit/validate.js'; -export { regenerateSections, type RegenerateSectionsResult, type RegenerateReport } from './regenerate.js'; +export { + regenerateSections, + type RegenerateSectionsResult, + type RegenerateReport, + type ComponentReport, + type FreshFact, +} from './regenerate.js'; export type { DspackDocument } from './types.js'; export interface ExportProjectResult { diff --git a/src/regenerate.ts b/src/regenerate.ts index 14f5bbf..0e9cd5f 100644 --- a/src/regenerate.ts +++ b/src/regenerate.ts @@ -1,55 +1,176 @@ /** - * Section-scoped regeneration: refresh what the tool still owns, never touch - * what a human has taken over. + * Section-scoped regeneration with per-component ownership (ledger v2). * - * The whole-file refusal table (decideRegeneration) stays the answer for - * `generate` writing over an existing path. This module is the ITERATIVE - * path a composer needs: given the existing enriched document and a freshly - * generated one, produce a merged document at exactly the ledger's - * granularity: + * The ratified decision (per-component ledger proposal, 2026-08-04): the + * ownership rule of the bootstrap ledger — the tool owns what it recorded, + * only while the hash matches — applied at the entry level for the + * `components` section, with two forms of deletion memory: * - * - a generated section whose recorded hash still matches its content is - * tool-owned: it refreshes from the fresh document (hash updated); - * - a generated section whose content no longer matches its recorded hash - * is human-owned (edited after bootstrap): preserved verbatim, recorded - * hash kept, so the human-owned signal survives regeneration; - * - governance and every other non-generated section always carries over - * from the existing document verbatim; - * - within a human-owned `components` section, fresh components whose ids - * do not exist yet are ADDED (pure addition destroys nothing — the - * common rediscovery case of a new component in source). Existing - * entries are never modified; prop-level drift on enriched components is - * out of scope until a per-component ledger exists, and pretending - * otherwise would risk silently clobbering enrichment. + * - an ORPHANED hash (id in the ledger map, entry absent from the + * document) is proof the tool generated it and someone removed it: + * rediscovery skips restoration and asks (deletedAwaitingDecision); + * - a `doNotRediscover` tombstone is the explicit, permanent form: + * rediscovery skips without ambiguity (suppressed). * - * The invariant is decideRegeneration's, restated: regeneration never - * destroys human-authored content, and there is deliberately no force - * override. A document without a ledger is human-owned in full; this - * function refuses it the same way the CLI does. + * Every other section keeps the 0.4.0 section-level semantics. The + * invariants are unchanged and load-bearing: no force override; fail-closed + * on malformed or contradictory ledger state (pathed findings); tools never + * overwrite, restore, or invent authored content; every action is reported. + * + * v1 interop, by construction: `generated.components` is written ONLY while + * every entry is tool-owned, no tombstones exist, and no orphans exist — + * otherwise the key is omitted, which makes the published 0.4.0 mutation + * paths refuse (its human-sections branch sees `components` outside + * `generated`). Ledger-v2 documents therefore require this exporter version + * or later; the version floor, not a patch release, is the ratified + * mitigation for the shipped 0.4.0 pure-addition residual. + * + * `freshDelta` is review information, never a merge: fresh-side facts only + * (added props, added enum values, changed scalars, with values and + * JSON-pointer-like paths). Existing-only facts are presumptively authored + * and never reported; source REMOVALS inside human-owned entries are + * therefore undetectable here — that stated ceiling is the evidence trigger + * for any future stored-base (A2) discussion. Nothing from the report is + * persisted into the document. */ -import { GENERATED_SECTIONS, sectionHash, type BootstrapLedger } from './emit/bootstrap.js'; +import { GENERATED_SECTIONS, LEDGER_VERSION, sectionHash, type BootstrapLedger } from './emit/bootstrap.js'; import type { DspackDocument } from './types.js'; +export interface LedgerFinding { + /** JSON-pointer-like path into the document. */ + path: string; + message: string; +} + export type RegenerateSectionsResult = | { ok: true; document: DspackDocument; report: RegenerateReport } - | { ok: false; reason: string }; + | { ok: false; reason: string; findings?: LedgerFinding[] }; export interface RegenerateReport { - /** Tool-owned sections replaced from the fresh document (hashes updated). */ + /** Non-components sections replaced from the fresh document. */ refreshed: string[]; - /** Human-owned generated sections preserved verbatim (hash mismatch). */ + /** Non-components sections preserved verbatim (section-level human-owned). */ preservedHumanOwned: string[]; - /** Generated sections present before but absent from the fresh document: kept, flagged. */ + /** Non-components sections kept although absent from the fresh document. */ keptMissingInFresh: string[]; - /** Component ids added inside a human-owned components section (pure addition). */ - addedComponents: string[]; + /** Entry-level outcome for the components section. */ + components: ComponentReport; + /** Present when a v1 ledger was migrated during this run. */ + migration?: 'tool-owned' | 'human-owned'; } +export interface ComponentReport { + /** Newly discovered: no entry, no ledger record, no tombstone. Added tool-owned. */ + added: string[]; + /** Tool-owned entries replaced from fresh extraction. */ + refreshed: string[]; + /** Tool-owned entries identical in fresh extraction. */ + unchanged: string[]; + /** Entries byte-identical to fresh output reclaimed as tool-owned (self-heal). */ + readopted: string[]; + /** Human-owned entries preserved verbatim, with fresh-side facts for review. */ + preservedEnriched: Array<{ id: string; freshDelta: FreshFact[] }>; + /** Tool-owned entries whose source component disappeared: removed, reported. */ + removedWithSource: string[]; + /** Human-owned entries whose source component disappeared: kept, reported. */ + keptMissingInFresh: string[]; + /** Orphaned hash + id present in fresh: restoration skipped, human decision required. */ + deletedAwaitingDecision: string[]; + /** Tombstoned ids present in fresh: skipped without ambiguity. */ + suppressed: string[]; + /** Tombstoned ids nonetheless present in the document (v1 interop or hand edit). */ + suppressedButPresent: string[]; + /** Fresh ids colliding with existing sub-component vocabulary: never auto-added. */ + restoredConflict: Array<{ id: string; parent: string }>; + /** Orphaned hashes whose id vanished from source too: record retired. */ + entryHashRetired: string[]; +} + +/** One fresh-side fact on a preserved entry. Report-only; never persisted. */ +export interface FreshFact { + /** JSON-pointer-like location inside the entry, e.g. "/props/variant/values". */ + path: string; + /** The freshly discovered value (bounded: a descriptor, scalar, or value list). */ + fresh: unknown; +} + +const HEX64 = /^[0-9a-f]{64}$/; + function ledgerOf(doc: DspackDocument): BootstrapLedger | undefined { const metadata = (doc.metadata ?? {}) as Record; return metadata['x-bootstrap'] as BootstrapLedger | undefined; } +/** Shape gate for v2 ledger state. Fail-closed: pathed findings, no repair. */ +function validateLedgerShape(ledger: BootstrapLedger): LedgerFinding[] { + const findings: LedgerFinding[] = []; + const base = '/metadata/x-bootstrap'; + if (ledger.ledger !== undefined && ledger.ledger !== LEDGER_VERSION) { + findings.push({ path: `${base}/ledger`, message: `unknown ledger version '${String(ledger.ledger)}' (this tool understands '${LEDGER_VERSION}')` }); + } + if (ledger.ledger === LEDGER_VERSION && (ledger.components === undefined || typeof ledger.components !== 'object' || Array.isArray(ledger.components))) { + findings.push({ path: `${base}/components`, message: 'ledger declares v2 but carries no per-entry hash map (partially migrated state)' }); + } + if (ledger.components !== undefined && typeof ledger.components === 'object' && !Array.isArray(ledger.components)) { + for (const [id, hash] of Object.entries(ledger.components)) { + if (typeof hash !== 'string' || !HEX64.test(hash)) { + findings.push({ path: `${base}/components/${id}`, message: 'per-entry hash is not a sha256 hex string' }); + } + } + } + if (ledger.doNotRediscover !== undefined && (!Array.isArray(ledger.doNotRediscover) || ledger.doNotRediscover.some((t) => typeof t !== 'string'))) { + findings.push({ path: `${base}/doNotRediscover`, message: 'doNotRediscover must be an array of component id strings' }); + } + return findings; +} + +/** Existing sub-component vocabulary: sub id -> parent component id. */ +function subVocabulary(components: Record): Map { + const vocab = new Map(); + for (const [parent, entry] of Object.entries(components)) { + const composition = (entry as { composition?: { subComponents?: unknown[] } }).composition; + for (const raw of composition?.subComponents ?? []) { + const id = typeof raw === 'string' ? raw : (raw as { id?: string })?.id; + if (id) vocab.set(id, parent); + } + } + return vocab; +} + +/** Fresh-side facts only: added props, added enum values, changed scalars. */ +function computeFreshDelta(existing: Record, fresh: Record): FreshFact[] { + const facts: FreshFact[] = []; + for (const key of ['name', 'description', 'status']) { + if (fresh[key] !== undefined && JSON.stringify(fresh[key]) !== JSON.stringify(existing[key])) { + facts.push({ path: `/${key}`, fresh: fresh[key] }); + } + } + const existingProps = (existing.props ?? {}) as Record>; + const freshProps = (fresh.props ?? {}) as Record>; + for (const [prop, descriptor] of Object.entries(freshProps)) { + const current = existingProps[prop]; + if (!current) { + facts.push({ path: `/props/${prop}`, fresh: descriptor }); + continue; + } + const freshValues = Array.isArray(descriptor.values) ? descriptor.values : undefined; + const currentValues = Array.isArray(current.values) ? current.values : undefined; + if (freshValues) { + const known = new Set((currentValues ?? []).map((v) => JSON.stringify(v))); + const added = freshValues.filter((v) => !known.has(JSON.stringify(v))); + if (added.length > 0) facts.push({ path: `/props/${prop}/values`, fresh: added }); + } + for (const scalar of ['type', 'default', 'required']) { + if (descriptor[scalar] !== undefined && JSON.stringify(descriptor[scalar]) !== JSON.stringify(current[scalar])) { + facts.push({ path: `/props/${prop}/${scalar}`, fresh: descriptor[scalar] }); + } + } + } + // Existing-only facts are presumptively authored: never reported. Source + // removals inside enriched entries are therefore invisible here, by design. + return facts; +} + export function regenerateSections(existing: DspackDocument, fresh: DspackDocument): RegenerateSectionsResult { const existingLedger = ledgerOf(existing); if (!existingLedger?.generated) { @@ -61,62 +182,198 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume }; } const freshLedger = ledgerOf(fresh); - if (!freshLedger?.generated) { - return { ok: false, reason: 'the fresh document carries no x-bootstrap ledger; regenerate it with this tool first' }; + if (!freshLedger?.generated || !freshLedger.components) { + return { ok: false, reason: 'the fresh document carries no v2 x-bootstrap ledger; regenerate it with this tool version first' }; + } + const shapeFindings = validateLedgerShape(existingLedger); + if (shapeFindings.length > 0) { + return { + ok: false, + reason: `the existing ledger state is malformed or contradictory; refusing fail-closed (${shapeFindings.length} finding(s), no force override)`, + findings: shapeFindings, + }; } const report: RegenerateReport = { refreshed: [], preservedHumanOwned: [], keptMissingInFresh: [], - addedComponents: [], + components: { + added: [], + refreshed: [], + unchanged: [], + readopted: [], + preservedEnriched: [], + removedWithSource: [], + keptMissingInFresh: [], + deletedAwaitingDecision: [], + suppressed: [], + suppressedButPresent: [], + restoredConflict: [], + entryHashRetired: [], + }, }; - // Start from the existing document: governance and every non-generated key - // carry over by construction; only generated sections are touched below. + // ---- Migration: a v1 ledger gains entry grain, meaning-preservingly. ---- + const tombstones = [...(existingLedger.doNotRediscover ?? [])]; + const existingComponents = ((existing as Record).components as Record | undefined) ?? {}; + let entryHashes: Record; + if (existingLedger.components !== undefined) { + entryHashes = { ...(existingLedger.components as Record) }; + } else if ( + existingLedger.generated.components !== undefined && + sectionHash(existingComponents) === existingLedger.generated.components + ) { + // Section provably tool-owned: everything in it is, at finer grain. + entryHashes = Object.fromEntries(Object.entries(existingComponents).map(([id, entry]) => [id, sectionHash(entry)])); + report.migration = 'tool-owned'; + } else { + // Human-owned section: entries start unattributed (absent-from-map); + // the re-adoption rule below reclaims byte-identical ones this same run. + entryHashes = {}; + report.migration = 'human-owned'; + } + const merged = structuredClone(existing) as Record; const mergedGenerated: Record = { ...existingLedger.generated }; + delete mergedGenerated.components; // re-derived below under the v2 invariant + // ---- Non-components generated sections: 0.4.0 semantics, unchanged. ---- for (const section of GENERATED_SECTIONS) { + if (section === 'components') continue; const existingValue = (existing as Record)[section]; const freshValue = (fresh as Record)[section]; const recorded = existingLedger.generated[section]; - - const toolOwned = - existingValue !== undefined && recorded !== undefined && sectionHash(existingValue) === recorded; - + const toolOwned = existingValue !== undefined && recorded !== undefined && sectionHash(existingValue) === recorded; if (freshValue === undefined) { if (existingValue !== undefined) report.keptMissingInFresh.push(section); continue; } - if (existingValue === undefined || toolOwned) { merged[section] = structuredClone(freshValue); mergedGenerated[section] = freshLedger.generated[section] ?? sectionHash(freshValue); report.refreshed.push(section); - continue; + } else { + report.preservedHumanOwned.push(section); } + } + + // ---- Components: the entry-level engine. ---- + const freshComponents = ((fresh as Record).components as Record | undefined) ?? {}; + const subVocab = subVocabulary(existingComponents); + const tombstoneSet = new Set(tombstones); + const out: Record = {}; + const outHashes: Record = {}; + const c = report.components; - // Human-owned (present, hash mismatch) or human-authored (present, never - // recorded): preserved verbatim, recorded hash untouched. - report.preservedHumanOwned.push(section); + // Retained entries, in existing document order. + for (const [id, entry] of Object.entries(existingComponents)) { + if (tombstoneSet.has(id)) { + // A tombstoned id should not be resident: report, never delete. + c.suppressedButPresent.push(id); + out[id] = entry; + if (entryHashes[id] !== undefined) outHashes[id] = entryHashes[id]; + continue; + } + const recorded = entryHashes[id]; + const currentHash = sectionHash(entry); + const freshEntry = freshComponents[id]; + const toolOwned = recorded !== undefined && currentHash === recorded; - if (section === 'components') { - const target = merged.components as Record; - for (const [id, entry] of Object.entries(freshValue as Record)) { - if (!(id in target)) { - target[id] = structuredClone(entry); - report.addedComponents.push(id); - } + if (toolOwned) { + if (freshEntry === undefined) { + // Tool-owned, gone from source, no authored state attached: the + // tool's to remove — reported, never silent. + c.removedWithSource.push(id); + continue; } + const freshHash = sectionHash(freshEntry); + if (freshHash === recorded) { + c.unchanged.push(id); + out[id] = entry; + outHashes[id] = recorded; + } else { + c.refreshed.push(id); + out[id] = structuredClone(freshEntry); + outHashes[id] = freshHash; + } + continue; } + + // Human-owned (stale hash, or unattributed after migration). + if (freshEntry !== undefined && currentHash === sectionHash(freshEntry)) { + // Byte-identical to fresh output: provably indistinguishable from the + // tool's own — reclaiming it destroys nothing (self-heal). + c.readopted.push(id); + out[id] = entry; + outHashes[id] = currentHash; + continue; + } + if (freshEntry !== undefined) { + c.preservedEnriched.push({ id, freshDelta: computeFreshDelta(entry as Record, freshEntry as Record) }); + } else { + c.keptMissingInFresh.push(id); + } + out[id] = entry; + if (recorded !== undefined) outHashes[id] = recorded; // the stale hash IS the human-owned signal + } + + // Ids absent from the document: fresh additions, orphans, tombstones. + for (const [id, freshEntry] of Object.entries(freshComponents)) { + if (id in existingComponents) continue; + if (tombstoneSet.has(id)) { + c.suppressed.push(id); + continue; + } + const parent = subVocab.get(id); + if (parent !== undefined) { + // The id lives on as sub-component vocabulary (the #13 restructure + // shape): never auto-add; report with the restructure hint. Checked + // BEFORE the orphan branch — when both signals exist, "restructured + // under " is the specific diagnosis, and deletion memory still + // persists so the id stays protected if the author later un-nests it. + c.restoredConflict.push({ id, parent }); + if (entryHashes[id] !== undefined) outHashes[id] = entryHashes[id]; + continue; + } + if (entryHashes[id] !== undefined) { + // Orphaned hash: the tool generated this id and someone removed it. + // Skip-and-ask; the orphan persists until the author decides. + c.deletedAwaitingDecision.push(id); + outHashes[id] = entryHashes[id]; + continue; + } + c.added.push(id); + out[id] = structuredClone(freshEntry); + outHashes[id] = sectionHash(freshEntry); + } + + // Orphans whose id vanished from source too: nothing left to decide. + for (const id of Object.keys(entryHashes)) { + if (!(id in existingComponents) && !(id in freshComponents) && !tombstoneSet.has(id)) { + c.entryHashRetired.push(id); + } + } + + merged.components = out; + + // ---- Ledger write-out (the v1 fail-closed invariant). ---- + const orphansExist = Object.keys(outHashes).some((id) => !(id in out)); + const allToolOwned = Object.entries(out).every(([id, entry]) => outHashes[id] !== undefined && sectionHash(entry) === outHashes[id]); + if (allToolOwned && !orphansExist && tombstones.length === 0) { + mergedGenerated.components = sectionHash(out); } + // else: omitted — shipped 0.4.0 paths then refuse (components present in the + // document, absent from `generated` = its human-authored-sections branch). const metadata = { ...(merged.metadata as Record) }; metadata['x-bootstrap'] = { ...existingLedger, spec: freshLedger.spec ?? existingLedger.spec, + ledger: LEDGER_VERSION, generated: mergedGenerated, + components: outHashes, + doNotRediscover: tombstones, }; merged.metadata = metadata; diff --git a/src/tests/ledger-v2.test.ts b/src/tests/ledger-v2.test.ts new file mode 100644 index 0000000..b25c5a8 --- /dev/null +++ b/src/tests/ledger-v2.test.ts @@ -0,0 +1,301 @@ +/** + * Ledger v2: per-component ownership, the ratified decision proposal made + * executable. Every case runs against REAL generator output over the + * shadcn-demo fixture; the two original limitations from + * dspack-export#13 land fail-first (they FAIL against the shipped 0.4.0 + * section-level semantics and pass only once entry-level regeneration + * exists). + * + * The shipped-v1 behavior fixture imports the PUBLISHED 0.4.0 package + * (devDep alias `dspack-export-shipped`) so the fail-closed claims are + * checked against the exact bytes adopters have, not against this repo's + * history. + */ +import { describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from '../config.js'; +import { generateDocument } from '../generate.js'; +import { sectionHash } from '../emit/bootstrap.js'; +import { regenerateSections } from '../regenerate.js'; +import type { DspackDocument } from '../types.js'; +// The exact published 0.4.0 decision table — the live-hazard fixture. +import { decideRegeneration as shippedDecideRegeneration } from 'dspack-export-shipped'; + +const configPath = fileURLToPath(new URL('../../fixtures/shadcn-demo/dspack-export.config.json', import.meta.url)); + +// One extraction per suite (CI-hardware lesson from 0.4.0); clones everywhere. +const base = generateDocument(loadConfig(configPath), { generatedAt: '2026-06-11T00:00:00.000Z' }).document; +const generate = (): DspackDocument => structuredClone(base); +const ledgerOf = (d: any) => d.metadata['x-bootstrap']; + +/** Author enrichment on one entry (the #13 pattern). */ +function enrich(doc: DspackDocument): DspackDocument { + const e = structuredClone(doc) as any; + e.components.button.whenToUse = 'Any user-initiated action.'; + e.components.button.props.label = { type: 'string', required: true }; + e.intents = [{ id: 'demo-intent', description: 'A demo intent.' }]; + return e as DspackDocument; +} + +describe('ledger v2 shape (fresh generation)', () => { + it('writes the explicit version, per-entry hashes, and an empty tombstone list', () => { + const ledger = ledgerOf(generate()); + expect(ledger.ledger).toBe('2'); + expect(ledger.doNotRediscover).toEqual([]); + for (const [id, entry] of Object.entries((generate() as any).components)) { + expect(ledger.components[id], id).toBe(sectionHash(entry)); + } + // All tool-owned, no tombstones, no orphans: the legacy signal is present. + expect(ledger.generated.components).toBe(sectionHash((generate() as any).components)); + }); +}); + +describe('regeneration states (the ratified table)', () => { + it('adds a newly discovered id (no prior record) as tool-owned and reports it', () => { + const existing = generate(); + const fresh = structuredClone(generate()) as any; + fresh.components['brand-new'] = { name: 'BrandNew', description: 'New in source.' }; + fresh.metadata['x-bootstrap'].components['brand-new'] = sectionHash(fresh.components['brand-new']); + const r = regenerateSections(existing, fresh as DspackDocument); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.added).toEqual(['brand-new']); + expect(ledgerOf(r.document).components['brand-new']).toBe(sectionHash((r.document as any).components['brand-new'])); + }); + + it('refreshes a tool-owned entry deterministically when source changes', () => { + const existing = enrich(generate()); // button human-owned; card stays tool-owned + const fresh = structuredClone(generate()) as any; + fresh.components.card.description = 'Updated JSDoc from source.'; + fresh.metadata['x-bootstrap'].components.card = sectionHash(fresh.components.card); + const r = regenerateSections(existing, fresh as DspackDocument); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.refreshed).toContain('card'); + expect((r.document as any).components.card.description).toBe('Updated JSDoc from source.'); + // L1a: the enriched sibling no longer blocks tool-owned refreshes. + expect((r.document as any).components.button.whenToUse).toBe('Any user-initiated action.'); + }); + + it('preserves a human-owned entry against changed discovery and reports freshDelta (fresh-side, names + values)', () => { + const existing = enrich(generate()); + const fresh = structuredClone(generate()) as any; + fresh.components.button.props.variant.values.push('sparkly'); + fresh.metadata['x-bootstrap'].components.button = sectionHash(fresh.components.button); + const r = regenerateSections(existing, fresh as DspackDocument); + expect(r.ok).toBe(true); + if (!r.ok) return; + const entry = r.report.components.preservedEnriched.find((p) => p.id === 'button'); + expect(entry).toBeDefined(); + // The authored value is untouched; the fresh fact is reported with its value. + expect((r.document as any).components.button.props.variant.values).not.toContain('sparkly'); + const delta = entry!.freshDelta.find((f) => f.path.includes('/props/variant')); + expect(delta).toBeDefined(); + expect(JSON.stringify(delta!.fresh)).toContain('sparkly'); + // Direction filter: the authored `label` prop (existing-only) is NOT reported as drift. + expect(entry!.freshDelta.some((f) => f.path.includes('/props/label'))).toBe(false); + }); + + it('hand-deletion leaves an orphaned hash: skip restoration, ask (deletedAwaitingDecision)', () => { + const existing = structuredClone(generate()) as any; + delete existing.components.badge; // author deletes by hand-editing the committed file + const r = regenerateSections(existing as DspackDocument, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.deletedAwaitingDecision).toEqual(['badge']); + expect((r.document as any).components.badge).toBeUndefined(); // NOT silently restored + expect(ledgerOf(r.document).components.badge).toBeDefined(); // orphan persists until resolved + }); + + it('explicit doNotRediscover: skip without ambiguity (suppressed)', () => { + const existing = structuredClone(generate()) as any; + delete existing.components.badge; + delete existing.metadata['x-bootstrap'].components.badge; // orphan resolved by the author… + existing.metadata['x-bootstrap'].doNotRediscover = ['badge']; // …into an explicit tombstone + const r = regenerateSections(existing as DspackDocument, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.suppressed).toEqual(['badge']); + expect(r.report.components.deletedAwaitingDecision).toEqual([]); + expect((r.document as any).components.badge).toBeUndefined(); + }); + + it('removes a source-removed component only while tool-owned (removedWithSource)', () => { + const existing = generate(); + const fresh = structuredClone(generate()) as any; + delete fresh.components.badge; + delete fresh.metadata['x-bootstrap'].components.badge; + const r = regenerateSections(existing, fresh as DspackDocument); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.removedWithSource).toEqual(['badge']); + expect((r.document as any).components.badge).toBeUndefined(); + expect(ledgerOf(r.document).components.badge).toBeUndefined(); + }); + + it('keeps a human-owned entry whose source vanished and reports the drift', () => { + const existing = enrich(generate()); + const fresh = structuredClone(generate()) as any; + delete fresh.components.button; + delete fresh.metadata['x-bootstrap'].components.button; + const r = regenerateSections(existing, fresh as DspackDocument); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.keptMissingInFresh).toContain('button'); + expect((r.document as any).components.button.whenToUse).toBe('Any user-initiated action.'); + }); +}); + +describe('the #13 restructure case (fail-first for L2)', () => { + /** The exact evidence: flat discoveries restructured into sub-components. */ + function restructure(doc: DspackDocument): DspackDocument { + const e = structuredClone(doc) as any; + e.components.card.composition = { + subComponents: [ + { id: 'badge', name: 'Badge', description: 'Now a sub of card.', acceptsChildren: 'text' }, + ], + }; + delete e.components.badge; // removed as top-level, exists as sub vocabulary + return e as DspackDocument; + } + + it('never re-adds an id that is now sub-component vocabulary (restoredConflict, with the parent named)', () => { + const existing = restructure(generate()); + const r = regenerateSections(existing, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect((r.document as any).components.badge).toBeUndefined(); + const conflict = r.report.components.restoredConflict.find((c) => c.id === 'badge'); + expect(conflict?.parent).toBe('card'); + }); +}); + +describe('shipped-0.4.0 fail-closed fixture (the v1 hazard boundary)', () => { + it('tombstone-only and orphan-only v2 documents refuse under the published decideRegeneration', () => { + // Tombstone-only: nothing enriched, one id suppressed. + const tombstoned = structuredClone(generate()) as any; + delete tombstoned.components.badge; + delete tombstoned.metadata['x-bootstrap'].components.badge; + tombstoned.metadata['x-bootstrap'].doNotRediscover = ['badge']; + // v2 write rule: tombstones present -> generated.components omitted. + const written = regenerateSections(tombstoned as DspackDocument, (() => { + const f = structuredClone(generate()) as any; + delete f.components.badge; + delete f.metadata['x-bootstrap'].components.badge; + return f as DspackDocument; + })()); + expect(written.ok).toBe(true); + if (!written.ok) return; + expect(ledgerOf(written.document).generated.components).toBeUndefined(); + const verdictTombstone = shippedDecideRegeneration(JSON.stringify(written.document)); + expect(verdictTombstone.allow).toBe(false); + + // Orphan-only: a hand-deletion nobody has resolved yet. + const orphaned = structuredClone(generate()) as any; + delete orphaned.components.badge; + const writtenOrphan = regenerateSections(orphaned as DspackDocument, generate()); + expect(writtenOrphan.ok).toBe(true); + if (!writtenOrphan.ok) return; + expect(ledgerOf(writtenOrphan.document).generated.components).toBeUndefined(); + expect(shippedDecideRegeneration(JSON.stringify(writtenOrphan.document)).allow).toBe(false); + }); +}); + +describe('migration (v1 documents)', () => { + function v1Doc(enriched: boolean): DspackDocument { + const d = (enriched ? enrich(generate()) : generate()) as any; + delete d.metadata['x-bootstrap'].ledger; + delete d.metadata['x-bootstrap'].components; + delete d.metadata['x-bootstrap'].doNotRediscover; + if (enriched) { + // v1 human-owned section: the recorded hash predates the enrichment. + } else { + d.metadata['x-bootstrap'].generated.components = sectionHash(d.components); + } + return d as DspackDocument; + } + + it('tool-owned v1 section: per-entry hashes synthesized; byte-stable outside x-bootstrap', () => { + const existing = v1Doc(false); + const before = JSON.stringify({ ...(existing as any), metadata: undefined }); + const r = regenerateSections(existing, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.migration).toBe('tool-owned'); + expect(Object.keys(ledgerOf(r.document).components).length).toBeGreaterThan(0); + expect(JSON.stringify({ ...(r.document as any), metadata: undefined })).toBe(before); + }); + + it('human-owned v1 section: entries start unattributed; re-adoption claims byte-identical ones; zero bytes of authored content change', () => { + const existing = v1Doc(true); + const r = regenerateSections(existing, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.migration).toBe('human-owned'); + // The enriched entry stays exactly as authored. + expect((r.document as any).components.button.whenToUse).toBe('Any user-initiated action.'); + expect((r.document as any).components.button.props.label.required).toBe(true); + // Governance verbatim. + expect((r.document as any).intents[0].id).toBe('demo-intent'); + // Byte-identical entries re-acquire tool ownership (self-heal). + expect(r.report.components.readopted).toContain('card'); + expect(ledgerOf(r.document).components.card).toBe(sectionHash((r.document as any).components.card)); + // The enriched entry is NOT claimed. + expect(ledgerOf(r.document).components.button).toBeUndefined(); + }); + + it('a hand-added doNotRediscover on a v1 ledger survives migration as authored data', () => { + const existing = v1Doc(false) as any; + delete existing.components.badge; + existing.metadata['x-bootstrap'].generated.components = sectionHash(existing.components); + existing.metadata['x-bootstrap'].doNotRediscover = ['badge']; + const r = regenerateSections(existing as DspackDocument, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(ledgerOf(r.document).doNotRediscover).toEqual(['badge']); + expect(r.report.components.suppressed).toEqual(['badge']); + }); +}); + +describe('fail-closed states', () => { + it('refuses malformed and contradictory v2 ledgers with pathed findings; no force path exists', () => { + const partiallyMigrated = structuredClone(generate()) as any; + delete partiallyMigrated.metadata['x-bootstrap'].components; // claims v2, missing the map + const r1 = regenerateSections(partiallyMigrated as DspackDocument, generate()); + expect(r1.ok).toBe(false); + if (!r1.ok) expect(r1.findings?.some((f) => f.path.includes('x-bootstrap'))).toBe(true); + + const badHash = structuredClone(generate()) as any; + badHash.metadata['x-bootstrap'].components.button = 42; + const r2 = regenerateSections(badHash as DspackDocument, generate()); + expect(r2.ok).toBe(false); + if (!r2.ok) expect(r2.findings?.some((f) => f.path.includes('components/button'))).toBe(true); + + const badTombstones = structuredClone(generate()) as any; + badTombstones.metadata['x-bootstrap'].doNotRediscover = 'badge'; + const r3 = regenerateSections(badTombstones as DspackDocument, generate()); + expect(r3.ok).toBe(false); + + const unknownVersion = structuredClone(generate()) as any; + unknownVersion.metadata['x-bootstrap'].ledger = '3'; + const r4 = regenerateSections(unknownVersion as DspackDocument, generate()); + expect(r4.ok).toBe(false); + + // Ledger-less: unchanged v1 semantics, no force override. + const bare = structuredClone(generate()) as any; + delete bare.metadata['x-bootstrap']; + const r5 = regenerateSections(bare as DspackDocument, generate()); + expect(r5.ok).toBe(false); + if (!r5.ok) expect(r5.reason).toContain('no force override'); + }); + + it('reconciles a tombstoned id that is nonetheless present (suppressedButPresent)', () => { + const existing = structuredClone(generate()) as any; + existing.metadata['x-bootstrap'].doNotRediscover = ['badge']; // v1 interop or hand edit left it resident + const r = regenerateSections(existing as DspackDocument, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.suppressedButPresent).toEqual(['badge']); + expect((r.document as any).components.badge).toBeDefined(); // report, never delete + }); +}); diff --git a/src/tests/regenerate.test.ts b/src/tests/regenerate.test.ts index 998caa0..f2ad402 100644 --- a/src/tests/regenerate.test.ts +++ b/src/tests/regenerate.test.ts @@ -47,17 +47,23 @@ describe('regenerateSections', () => { expect(result.ok).toBe(true); if (!result.ok) return; - // Human-owned components preserved: the enrichment survives. - expect(result.report.preservedHumanOwned).toContain('components'); + // v2: ownership is entry-level — the enriched entry is preserved while + // its untouched siblings stay tool-owned (L1a resolved). + expect(result.report.components.preservedEnriched.map((p) => p.id)).toContain('button'); expect((result.document as any).components.button.whenToUse).toBe('Any user-initiated action.'); expect((result.document as any).components.button.props.label.required).toBe(true); // Governance carried over verbatim. expect((result.document as any).intents[0].id).toBe('demo-intent'); // Untouched generated sections refresh (tokens hash still matches). expect(result.report.refreshed).toContain('tokens'); - // The human-owned recorded hash is untouched, so the signal survives: + // v2 write rule: an enriched entry exists, so the legacy section-level + // signal is omitted and pre-v2 mutation paths fail closed. const ledger = (result.document as any).metadata['x-bootstrap']; - expect(sectionHash((result.document as any).components)).not.toBe(ledger.generated.components); + expect(ledger.generated.components).toBeUndefined(); + // Enriched after v2: human-owned via the STALE recorded hash (the + // migrated form is absent-from-map; both classify identically). + expect(ledger.components.button).toBeDefined(); + expect(ledger.components.button).not.toBe(sectionHash((result.document as any).components.button)); expect(sectionHash((result.document as any).tokens)).toBe(ledger.generated.tokens); }); @@ -68,7 +74,7 @@ describe('regenerateSections', () => { const result = regenerateSections(existing, fresh as DspackDocument); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.report.addedComponents).toEqual(['brand-new']); + expect(result.report.components.added).toEqual(['brand-new']); expect((result.document as any).components['brand-new'].name).toBe('BrandNew'); // Existing entries untouched by the addition. expect((result.document as any).components.button.whenToUse).toBe('Any user-initiated action.'); @@ -101,6 +107,11 @@ describe('regenerateSections', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.report.preservedHumanOwned).toEqual([]); - expect(result.report.addedComponents).toEqual([]); + expect(result.report.components.added).toEqual([]); + expect(result.report.components.preservedEnriched).toEqual([]); + expect(result.report.components.deletedAwaitingDecision).toEqual([]); + // Untouched bootstrap: every entry unchanged, the legacy signal present. + expect(result.report.components.unchanged.length).toBeGreaterThan(0); + expect((result.document as any).metadata['x-bootstrap'].generated.components).toBeDefined(); }); }); From 69b9d78c7b43cfa07b7324fcb472a6c2bfe94f27 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Tue, 4 Aug 2026 08:48:29 -0400 Subject: [PATCH 2/3] feat: explicit restoredConflict resolutions (ratified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conflict's orphaned hash is never auto-retired — the exporter can prove the restructure happened but not that it is intended as permanent, so retirement stays an owner decision. The three ratified outcomes: - keep nested: tombstone + retired hash (a document edit the composer saves); subsequent runs report suppressed and the conflict stops — tombstone classification already precedes the conflict check, now pinned by test - restore top-level: new regenerateSections(existing, fresh, { restoreTopLevel: [id] }) one-shot intent — restored from fresh as tool-owned, nested authored representation preserved, orphaned hash replaced by the restored entry's own, reported as restoredTopLevel {id, parent} (both representations now exist); also serves plain orphans as an immediate restore. Unhonorable intents (present, tombstoned, missing from fresh) refuse the whole run with every id named; nothing partially honored; never persisted - leave unresolved: no change, memory preserved, conflict keeps reporting (pinned) Fail-first: the three restore-intent tests failed against the previous code (no options parameter, no restoredTopLevel class); outcomes 1 and 3 are behavior pins. 131/132 green. Pack smoke extended: restore intent from the tarball plus a strict node16 tsc compile of the published type surface — which caught RegenerateOptions missing from the library entry (now exported). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 14 +++++ README.md | 10 ++-- scripts/pack-test.sh | 29 +++++++++++ src/index.ts | 1 + src/regenerate.ts | 52 ++++++++++++++++++- src/tests/ledger-v2.test.ts | 100 ++++++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a48a80..f28bb94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ the two granularity limitations recorded in #13. from the document is an orphan — rediscovery never silently restores the component; it reports `deletedAwaitingDecision` and carries the hash forward until a human resolves it. +- **Explicit `restoredConflict` resolutions**: an id that lives on as + authored sub-component vocabulary is never auto-added and its memory is + never auto-retired (that would convert an inference into ownership + state). The owner resolves it explicitly: *keep nested* (tombstone the + id and retire the hash — subsequent runs report `suppressed`, the + conflict stops), *restore top-level* (the new + `regenerateSections(existing, fresh, { restoreTopLevel: [id] })` intent: + restored from fresh as tool-owned, the nested authored representation + preserved, the orphaned hash replaced by the restored entry's own, + reported as `restoredTopLevel` with the parent named — both + representations now exist), or *leave unresolved* (no change, the + memory and the report persist). Intents that cannot be honored — + already present, tombstoned, missing from fresh — refuse the whole run + with every id named; nothing is partially honored. - **`doNotRediscover` tombstones** (plain component ids, authored data): rediscovery skips them unambiguously (`suppressed`); a tombstoned id that is nonetheless present in the document reconciles as diff --git a/README.md b/README.md index 289017c..3ae8c3a 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,13 @@ tracked per entry (`ledger.components`, id → hash), so `regenerateSections` can refresh untouched entries while preserving enriched ones verbatim. A hash whose entry was hand-deleted is deletion memory — rediscovery skips it and asks (`deletedAwaitingDecision`) instead of silently restoring it — and -ids listed in `ledger.doNotRediscover` are never re-added. Any document -using these v2 states omits the v1 whole-section signal, so older releases -refuse to touch it rather than mishandle it. +ids listed in `ledger.doNotRediscover` are never re-added. An id you +re-authored as a sub-component of another entry reports `restoredConflict` +until you decide: tombstone it to keep the nested form, or pass +`regenerateSections(existing, fresh, { restoreTopLevel: [id] })` to restore +the top-level entry alongside it. Any document using these v2 states omits +the v1 whole-section signal, so older releases refuse to touch it rather +than mishandle it. **Regeneration never destroys human-authored content.** If the output file contains anything this tool does not own — governance blocks, edited diff --git a/scripts/pack-test.sh b/scripts/pack-test.sh index 8c8c0b6..1639419 100755 --- a/scripts/pack-test.sh +++ b/scripts/pack-test.sh @@ -54,6 +54,12 @@ if (!r1.ok) throw new Error("orphan merge refused"); if (r1.document.components.badge !== undefined) throw new Error("orphan silently restored"); if (!r1.report.components.deletedAwaitingDecision.includes("badge")) throw new Error("deletion not asked about"); +// Explicit restore-top-level intent resolves the deletion immediately. +const restored = regenerateSections(structuredClone(r1.document), exportProject(configPath).document, { restoreTopLevel: ["badge"] }); +if (!restored.ok) throw new Error("restore intent refused"); +if (restored.document.components.badge === undefined) throw new Error("restore intent not honored"); +if (restored.report.components.restoredTopLevel[0]?.id !== "badge") throw new Error("restore not reported"); + // Tombstone suppression. const tombstoned = structuredClone(r1.document); tombstoned.metadata["x-bootstrap"].doNotRediscover = ["badge"]; @@ -73,5 +79,28 @@ console.log("pack-and-install smoke: OK (v2 ledger; enrichment + governance pres SMOKE REPO_ROOT="$OLDPWD" node smoke.mjs +# The published type surface must compile for an external consumer +# (a Node consumer, hence @types/node — generatedAtFromEnv types ProcessEnv). +npm install --no-fund --no-audit -D @types/node >/dev/null +cat > smoke-types.mts <<'TYPES' +import { + exportProject, + regenerateSections, + decideRegeneration, + LEDGER_VERSION, + type BootstrapLedger, + type RegenerateOptions, + type RegenerateReport, + type ComponentReport, + type FreshFact, +} from "@aestheticfunction/dspack-export"; +const opts: RegenerateOptions = { restoreTopLevel: ["badge"] }; +const useAll: unknown[] = [exportProject, regenerateSections, decideRegeneration, LEDGER_VERSION, opts]; +export type Surface = [BootstrapLedger, RegenerateReport, ComponentReport, FreshFact]; +export default useAll; +TYPES +"$OLDPWD/node_modules/.bin/tsc" --noEmit --strict --target es2022 --module node16 --moduleResolution node16 smoke-types.mts +echo "type surface: OK" + ./node_modules/.bin/dspack-export --help 2>/dev/null | head -1 >/dev/null || node node_modules/.bin/dspack-export 2>/dev/null || true node -e "const p=require('@aestheticfunction/dspack-export/package.json'); if(p.version!=='0.5.0') throw new Error('version mismatch: '+p.version); console.log('bin + version OK:', p.version)" diff --git a/src/index.ts b/src/index.ts index 1d4b743..3252be8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export { export { validateDspack } from './emit/validate.js'; export { regenerateSections, + type RegenerateOptions, type RegenerateSectionsResult, type RegenerateReport, type ComponentReport, diff --git a/src/regenerate.ts b/src/regenerate.ts index 0e9cd5f..31fbf02 100644 --- a/src/regenerate.ts +++ b/src/regenerate.ts @@ -82,6 +82,9 @@ export interface ComponentReport { suppressedButPresent: string[]; /** Fresh ids colliding with existing sub-component vocabulary: never auto-added. */ restoredConflict: Array<{ id: string; parent: string }>; + /** Explicit restoreTopLevel intents honored: restored tool-owned from fresh. + When parent is present, both representations now exist in the document. */ + restoredTopLevel: Array<{ id: string; parent?: string }>; /** Orphaned hashes whose id vanished from source too: record retired. */ entryHashRetired: string[]; } @@ -171,7 +174,22 @@ function computeFreshDelta(existing: Record, fresh: Record(); + if (options?.restoreTopLevel !== undefined) { + if (!Array.isArray(options.restoreTopLevel) || options.restoreTopLevel.some((id) => typeof id !== 'string')) { + return { ok: false, reason: 'restoreTopLevel must be an array of component id strings' }; + } + for (const id of options.restoreTopLevel) restoreIntents.add(id); + } + const report: RegenerateReport = { refreshed: [], preservedHumanOwned: [], @@ -210,6 +236,7 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume suppressed: [], suppressedButPresent: [], restoredConflict: [], + restoredTopLevel: [], entryHashRetired: [], }, }; @@ -262,6 +289,18 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume const freshComponents = ((fresh as Record).components as Record | undefined) ?? {}; const subVocab = subVocabulary(existingComponents); const tombstoneSet = new Set(tombstones); + + // Restore intents are explicit owner decisions: honor them exactly or + // refuse the whole run with every problem named — never partially. + const intentProblems: string[] = []; + for (const id of restoreIntents) { + if (id in existingComponents) intentProblems.push(`'${id}' is already present in the document`); + else if (tombstoneSet.has(id)) intentProblems.push(`'${id}' carries a doNotRediscover tombstone; remove the tombstone first`); + else if (!(id in freshComponents)) intentProblems.push(`'${id}' is not present in fresh extraction`); + } + if (intentProblems.length > 0) { + return { ok: false, reason: `restoreTopLevel intent(s) cannot be honored: ${intentProblems.join('; ')} (no force override)` }; + } const out: Record = {}; const outHashes: Record = {}; const c = report.components; @@ -325,6 +364,17 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume c.suppressed.push(id); continue; } + if (restoreIntents.has(id)) { + // The ratified "Restore top-level" outcome: restored from fresh as + // tool-owned; any nested authored representation stays untouched + // (both now exist, and the report says so via parent); the orphaned + // hash is replaced by the restored entry's own. + const conflictParent = subVocab.get(id); + c.restoredTopLevel.push(conflictParent !== undefined ? { id, parent: conflictParent } : { id }); + out[id] = structuredClone(freshEntry); + outHashes[id] = sectionHash(freshEntry); + continue; + } const parent = subVocab.get(id); if (parent !== undefined) { // The id lives on as sub-component vocabulary (the #13 restructure diff --git a/src/tests/ledger-v2.test.ts b/src/tests/ledger-v2.test.ts index b25c5a8..53f4185 100644 --- a/src/tests/ledger-v2.test.ts +++ b/src/tests/ledger-v2.test.ts @@ -299,3 +299,103 @@ describe('fail-closed states', () => { expect((r.document as any).components.badge).toBeDefined(); // report, never delete }); }); + +describe('restoredConflict explicit resolutions (ratified 2026-08-04)', () => { + /** Same evidence shape as the #13 suite: badge restructured under card. */ + function restructure(doc: DspackDocument): DspackDocument { + const e = structuredClone(doc) as any; + e.components.card.composition = { + subComponents: [ + { id: 'badge', name: 'Badge', description: 'Now a sub of card.', acceptsChildren: 'text' }, + ], + }; + delete e.components.badge; + return e as DspackDocument; + } + + it('outcome 3 (leave unresolved): no change, memory preserved, the conflict keeps reporting', () => { + const r1 = regenerateSections(restructure(generate()), generate()); + expect(r1.ok).toBe(true); + if (!r1.ok) return; + expect(ledgerOf(r1.document).components.badge).toBeDefined(); // deletion memory persists + const r2 = regenerateSections(r1.document, generate()); + expect(r2.ok).toBe(true); + if (!r2.ok) return; + expect((r2.document as any).components.badge).toBeUndefined(); // never silently re-added + expect(r2.report.components.restoredConflict.map((x) => x.id)).toContain('badge'); + expect(ledgerOf(r2.document).components.badge).toBeDefined(); + }); + + it('outcome 1 (keep nested): tombstone + retired hash ends the reporting — suppressed, not conflict', () => { + const existing = restructure(generate()) as any; + // The owner decision the composer's "Keep nested" saves: + existing.metadata['x-bootstrap'].doNotRediscover = ['badge']; + delete existing.metadata['x-bootstrap'].components.badge; + const r = regenerateSections(existing as DspackDocument, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.components.suppressed).toContain('badge'); + expect(r.report.components.restoredConflict).toEqual([]); + expect((r.document as any).components.badge).toBeUndefined(); + // The nested authored representation is preserved unchanged. + expect((r.document as any).components.card.composition.subComponents[0].id).toBe('badge'); + }); + + it('outcome 2 (restore top-level): explicit intent restores tool-owned, preserves nested, replaces the hash', () => { + const existing = restructure(generate()); + const freshBadge = (generate() as any).components.badge; + const r = regenerateSections(existing, generate(), { restoreTopLevel: ['badge'] }); + expect(r.ok).toBe(true); + if (!r.ok) return; + // Restored from fresh, tool-owned, hash replaced with the restored entry's own. + expect((r.document as any).components.badge).toEqual(freshBadge); + expect(ledgerOf(r.document).components.badge).toBe(sectionHash(freshBadge)); + // Both representations now exist, and the report says so by name. + expect(r.report.components.restoredTopLevel).toEqual([{ id: 'badge', parent: 'card' }]); + expect(r.report.components.restoredConflict).toEqual([]); + expect((r.document as any).components.card.composition.subComponents[0].id).toBe('badge'); + // Subsequent runs treat the restored entry as ordinary tool-owned. + const r2 = regenerateSections(r.document, generate()); + expect(r2.ok).toBe(true); + if (!r2.ok) return; + expect(r2.report.components.unchanged).toContain('badge'); + expect(r2.report.components.restoredConflict).toEqual([]); + }); + + it('restore intent also serves a plain orphan (immediate restore, no parent reported)', () => { + const existing = structuredClone(generate()) as any; + delete existing.components.badge; + const r = regenerateSections(existing as DspackDocument, generate(), { restoreTopLevel: ['badge'] }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect((r.document as any).components.badge).toBeDefined(); + expect(r.report.components.restoredTopLevel).toEqual([{ id: 'badge' }]); + expect(r.report.components.deletedAwaitingDecision).toEqual([]); + }); + + it('refuses unhonorable or contradictory restore intents, fail-closed with every id named', () => { + // Already present in the document. + const present = regenerateSections(generate(), generate(), { restoreTopLevel: ['badge'] }); + expect(present.ok).toBe(false); + if (!present.ok) expect(present.reason).toContain("'badge'"); + // Not present in fresh extraction. + const gone = structuredClone(generate()) as any; + delete gone.components.badge; + const freshWithout = structuredClone(generate()) as any; + delete freshWithout.components.badge; + delete freshWithout.metadata['x-bootstrap'].components.badge; + const missing = regenerateSections(gone as DspackDocument, freshWithout as DspackDocument, { restoreTopLevel: ['badge'] }); + expect(missing.ok).toBe(false); + // Tombstoned: two contradictory owner decisions never auto-resolve. + const dead = structuredClone(generate()) as any; + delete dead.components.badge; + delete dead.metadata['x-bootstrap'].components.badge; + dead.metadata['x-bootstrap'].doNotRediscover = ['badge']; + const contradicted = regenerateSections(dead as DspackDocument, generate(), { restoreTopLevel: ['badge'] }); + expect(contradicted.ok).toBe(false); + if (!contradicted.ok) expect(contradicted.reason).toContain('tombstone'); + // Malformed option shape. + const malformed = regenerateSections(structuredClone(generate()), generate(), { restoreTopLevel: [42] } as any); + expect(malformed.ok).toBe(false); + }); +}); From aabc5526e0b44801ea546e9f8804731b980315c2 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Tue, 4 Aug 2026 09:21:57 -0400 Subject: [PATCH 3/3] fix: close the adversarial-sweep findings before release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An 8-verifier adversarial sweep + completeness critic over the release checklist produced executed counterexamples; each is now closed and pinned: - decideRegeneration refuses v2 standing decisions (tombstones, deletion memory) even when the components section was hand-deleted — the whole-file path previously allowed an overwrite that erased them - v1 human-owned migration never silently restores an absence: the migration run seeds deletion memory for every fresh-only id and asks (deletedAwaitingDecision) — absence of a record is not evidence of newness until v2 has been writing records; a hand-deletion made under 0.4.0 is no longer un-deleted by the upgrade - deletion memory is never auto-retired: entryHashRetired removed; an orphan whose id is transiently absent from fresh keeps its record and keeps asking (previously two runs converted a recorded deletion into a silent re-add) - ledger.components = null refuses with a pathed finding instead of crashing; pathed findings pinned for bad tombstones + unknown versions - intent-refusal precision pinned: every unhonorable id named, mixed valid+invalid lists never partially honored - migration precision pinned: tool-owned migration attributes every entry and refreshes changed source; byte-stability outside x-bootstrap (not merely outside metadata) pinned for both branches with differing fresh metadata; restore pinned with fresh-differs-from-orphan hash replacement and deep-equal nested preservation - GENERATOR_VERSION 0.3.0 -> 0.5.0 (checklist item; provenance in every golden regenerated deterministically) - pack test's bin check was vacuous (|| true); it now runs the real bin against the committed golden with a real exit code - CHANGELOG/README wording corrected: shipped 0.4.0's decideRegeneration refuses v2-active documents; its regenerateSections predates tombstones — that limitation is the version floor's justification, stated plainly instead of overclaimed 139/140 green (skip = DS_MCP-gated roundtrip, run separately against ds-mcp: 1/1 green); pack smoke green including strict node16 type surface and the real bin invocation. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 29 ++- README.md | 5 +- .../dtcg-tokens-demo.dspack.json | 2 +- fixtures/shadcn-demo/shadcn-demo.dspack.json | 2 +- .../shadcn-v4-demo/shadcn-v4-demo.dspack.json | 2 +- .../vuetify-demo/vuetify-demo.dspack.json | 2 +- scripts/pack-test.sh | 7 +- src/emit/bootstrap.ts | 23 +++ src/generate.ts | 2 +- src/regenerate.ts | 35 +++- src/tests/ledger-v2.test.ts | 168 +++++++++++++++++- 11 files changed, 251 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f28bb94..c745d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,11 +35,16 @@ the two granularity limitations recorded in #13. `suppressedButPresent`. - **Fail-closed against pre-v2 tooling**: `generated.components` (the v1 whole-section signal) is written only when every entry is tool-owned and - no tombstone or orphan exists. Any v2-active document therefore refuses - under shipped 0.4.0's `decideRegeneration`/`regenerateSections` — byte- - tested against the published package (`dspack-export-shipped` devDep). - Ledger-v2 documents require this version or later (version floor; the - ratified alternative to patching 0.4.x). + no tombstone or orphan exists, so any v2-active document refuses under + shipped 0.4.0's `decideRegeneration` (the whole-file path) — byte-tested + against the published package (`dspack-export-shipped` devDep). This + release also teaches `decideRegeneration` to refuse whenever a v2 ledger + carries tombstones or deletion memory, even if the components section + was hand-deleted. Honest limit: shipped 0.4.0's `regenerateSections` + predates tombstones and treats the unrecorded section as human-owned + pure-addition — it would re-add suppressed ids. That is exactly why + **ledger-v2 documents require this version or later** (the ratified + version floor rather than a 0.4.x patch). - **`freshDelta`** on preserved human-owned entries: fresh-side facts only (added props/variants/enum values, changed scalars, with values and JSON-pointer-like paths), report-only, never persisted, never a merge. @@ -54,8 +59,18 @@ the two granularity limitations recorded in #13. - Regeneration report gains per-entry classifications (`added`, `refreshed`, `unchanged`, `readopted`, `preservedEnriched`, `removedWithSource`, `keptMissingInFresh`, `deletedAwaitingDecision`, `suppressed`, - `suppressedButPresent`, `restoredConflict`, `entryHashRetired`) so callers - (the Studio composer) can present every decision explicitly. + `suppressedButPresent`, `restoredConflict`, `restoredTopLevel`) so callers + (the Studio composer) can present every decision explicitly. Deletion + memory is never auto-retired: an orphan whose id is also absent from + fresh extraction keeps its record and keeps asking (a transient + extraction absence must not turn a recorded deletion into a future + silent re-add). +- **Migration asks instead of guessing**: a v1 human-owned components + section cannot distinguish "hand-deleted" from "newly discovered since + the snapshot", so the migration run seeds deletion memory for every + fresh-only id and reports it `deletedAwaitingDecision` — nothing is + silently added across the v1→v2 boundary. Restoring is one explicit + decision (`restoreTopLevel`, or clear the memory and rediscover). - `LEDGER_VERSION`, `ComponentReport`, and `FreshFact` are exported; golden fixtures regenerated with v2 ledgers; the pack-and-install boundary test now exercises orphan skip-and-ask and tombstone suppression diff --git a/README.md b/README.md index 3ae8c3a..0b22744 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ re-authored as a sub-component of another entry reports `restoredConflict` until you decide: tombstone it to keep the nested form, or pass `regenerateSections(existing, fresh, { restoreTopLevel: [id] })` to restore the top-level entry alongside it. Any document using these v2 states omits -the v1 whole-section signal, so older releases refuse to touch it rather -than mishandle it. +the v1 whole-section signal, so older releases' whole-file regeneration +refuses to touch it; the 0.4.0 merge path predates tombstones, which is +why ledger-v2 documents require 0.5.0 or later. **Regeneration never destroys human-authored content.** If the output file contains anything this tool does not own — governance blocks, edited diff --git a/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json b/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json index 51c94f2..20bc612 100644 --- a/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json +++ b/fixtures/dtcg-tokens-demo/dtcg-tokens-demo.dspack.json @@ -4,7 +4,7 @@ "description": "Fixture exercising DTCG design-token-file import alongside CSS token extraction.", "version": "1.0.0", "metadata": { - "generatedBy": "@aestheticfunction/dspack-export@0.3.0", + "generatedBy": "@aestheticfunction/dspack-export@0.5.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; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", diff --git a/fixtures/shadcn-demo/shadcn-demo.dspack.json b/fixtures/shadcn-demo/shadcn-demo.dspack.json index 84a6439..f17b56e 100644 --- a/fixtures/shadcn-demo/shadcn-demo.dspack.json +++ b/fixtures/shadcn-demo/shadcn-demo.dspack.json @@ -4,7 +4,7 @@ "description": "Demo shadcn-style design system used as the dspack-export golden fixture.", "version": "1.0.0", "metadata": { - "generatedBy": "@aestheticfunction/dspack-export@0.3.0", + "generatedBy": "@aestheticfunction/dspack-export@0.5.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; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", diff --git a/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json b/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json index 8ba1106..d88a9bd 100644 --- a/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json +++ b/fixtures/shadcn-v4-demo/shadcn-v4-demo.dspack.json @@ -4,7 +4,7 @@ "description": "Tailwind v4 / @theme-style fixture for dspack-export.", "version": "1.0.0", "metadata": { - "generatedBy": "@aestheticfunction/dspack-export@0.3.0", + "generatedBy": "@aestheticfunction/dspack-export@0.5.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; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", diff --git a/fixtures/vuetify-demo/vuetify-demo.dspack.json b/fixtures/vuetify-demo/vuetify-demo.dspack.json index 904f856..28e942d 100644 --- a/fixtures/vuetify-demo/vuetify-demo.dspack.json +++ b/fixtures/vuetify-demo/vuetify-demo.dspack.json @@ -4,7 +4,7 @@ "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.3.0", + "generatedBy": "@aestheticfunction/dspack-export@0.5.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; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", diff --git a/scripts/pack-test.sh b/scripts/pack-test.sh index 1639419..cae7b7f 100755 --- a/scripts/pack-test.sh +++ b/scripts/pack-test.sh @@ -102,5 +102,8 @@ TYPES "$OLDPWD/node_modules/.bin/tsc" --noEmit --strict --target es2022 --module node16 --moduleResolution node16 smoke-types.mts echo "type surface: OK" -./node_modules/.bin/dspack-export --help 2>/dev/null | head -1 >/dev/null || node node_modules/.bin/dspack-export 2>/dev/null || true -node -e "const p=require('@aestheticfunction/dspack-export/package.json'); if(p.version!=='0.5.0') throw new Error('version mismatch: '+p.version); console.log('bin + version OK:', p.version)" +# The bin must actually run from the tarball: validate the committed golden +# (a real invocation with a real exit code — no swallowed failures). +node node_modules/.bin/dspack-export validate "$OLDPWD/fixtures/shadcn-demo/shadcn-demo.dspack.json" >/dev/null +echo "bin runs: OK" +node -e "const p=require('@aestheticfunction/dspack-export/package.json'); if(p.version!=='0.5.0') throw new Error('version mismatch: '+p.version); console.log('version OK:', p.version)" diff --git a/src/emit/bootstrap.ts b/src/emit/bootstrap.ts index a100278..b22c063 100644 --- a/src/emit/bootstrap.ts +++ b/src/emit/bootstrap.ts @@ -135,6 +135,29 @@ export function decideRegeneration(existingContent: string | null): Regeneration }; } + // v2 standing decisions: the whole-file path cannot honor tombstones or + // deletion memory, so their presence refuses outright — including when the + // components section itself was hand-deleted (no other check would see + // that). Entry-level iteration lives in regenerateSections. + if (ledger.ledger === LEDGER_VERSION) { + const tombstones = Array.isArray(ledger.doNotRediscover) ? ledger.doNotRediscover : []; + if (tombstones.length > 0) { + return { + allow: false, + reason: `the document carries standing doNotRediscover decisions (${tombstones.join(', ')}); whole-file regeneration would erase them. Iterate through rediscovery (regenerateSections / the composer) instead. ${WORKFLOW_HINT}`, + }; + } + const map = ledger.components !== null && typeof ledger.components === 'object' && !Array.isArray(ledger.components) ? ledger.components : {}; + const entries = (existing.components ?? {}) as Record; + const orphans = Object.keys(map).filter((id) => !(id in entries)); + if (orphans.length > 0) { + return { + allow: false, + reason: `the ledger carries deletion memory for ${orphans.join(', ')} (entries removed, decision pending); whole-file regeneration would erase it. Resolve through rediscovery (regenerateSections / the composer) instead. ${WORKFLOW_HINT}`, + }; + } + } + const humanSections = Object.keys(existing).filter( (key) => !BASE_KEYS.has(key) && !(key in ledger.generated), ); diff --git a/src/generate.ts b/src/generate.ts index 6eec60b..45b47ad 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.3.0'; +export const GENERATOR_VERSION = '0.5.0'; export interface GenerateOptions { /** ISO 8601 timestamp override for deterministic output. */ diff --git a/src/regenerate.ts b/src/regenerate.ts index 31fbf02..0bb67f2 100644 --- a/src/regenerate.ts +++ b/src/regenerate.ts @@ -85,8 +85,6 @@ export interface ComponentReport { /** Explicit restoreTopLevel intents honored: restored tool-owned from fresh. When parent is present, both representations now exist in the document. */ restoredTopLevel: Array<{ id: string; parent?: string }>; - /** Orphaned hashes whose id vanished from source too: record retired. */ - entryHashRetired: string[]; } /** One fresh-side fact on a preserved entry. Report-only; never persisted. */ @@ -111,10 +109,10 @@ function validateLedgerShape(ledger: BootstrapLedger): LedgerFinding[] { if (ledger.ledger !== undefined && ledger.ledger !== LEDGER_VERSION) { findings.push({ path: `${base}/ledger`, message: `unknown ledger version '${String(ledger.ledger)}' (this tool understands '${LEDGER_VERSION}')` }); } - if (ledger.ledger === LEDGER_VERSION && (ledger.components === undefined || typeof ledger.components !== 'object' || Array.isArray(ledger.components))) { + if (ledger.ledger === LEDGER_VERSION && (ledger.components === undefined || ledger.components === null || typeof ledger.components !== 'object' || Array.isArray(ledger.components))) { findings.push({ path: `${base}/components`, message: 'ledger declares v2 but carries no per-entry hash map (partially migrated state)' }); } - if (ledger.components !== undefined && typeof ledger.components === 'object' && !Array.isArray(ledger.components)) { + if (ledger.components !== undefined && ledger.components !== null && typeof ledger.components === 'object' && !Array.isArray(ledger.components)) { for (const [id, hash] of Object.entries(ledger.components)) { if (typeof hash !== 'string' || !HEX64.test(hash)) { findings.push({ path: `${base}/components/${id}`, message: 'per-entry hash is not a sha256 hex string' }); @@ -237,7 +235,6 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume suppressedButPresent: [], restoredConflict: [], restoredTopLevel: [], - entryHashRetired: [], }, }; @@ -290,6 +287,20 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume const subVocab = subVocabulary(existingComponents); const tombstoneSet = new Set(tombstones); + // A v1 human-owned section cannot distinguish "hand-deleted" from "newly + // discovered since the snapshot" — absence of a record is only evidence + // once v2 has been writing records. Seed deletion memory for every + // fresh-only id so THIS run asks (deletedAwaitingDecision) instead of + // silently adding; restoring is one explicit decision away. Sub-vocabulary + // ids are excluded — the conflict classification is their protection — + // and tombstoned ids already carry their decision. + if (report.migration === 'human-owned') { + for (const [id, freshEntry] of Object.entries(freshComponents)) { + if (id in existingComponents || tombstoneSet.has(id) || subVocab.has(id)) continue; + entryHashes[id] = sectionHash(freshEntry); + } + } + // Restore intents are explicit owner decisions: honor them exactly or // refuse the whole run with every problem named — never partially. const intentProblems: string[] = []; @@ -398,10 +409,14 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume outHashes[id] = sectionHash(freshEntry); } - // Orphans whose id vanished from source too: nothing left to decide. + // Orphans whose id is absent from fresh too: the memory KEEPS. A transient + // extraction absence must never convert a recorded deletion into a future + // silent re-add; retirement only happens through an explicit decision + // (restore clears the memory, or a tombstone supersedes it). for (const id of Object.keys(entryHashes)) { if (!(id in existingComponents) && !(id in freshComponents) && !tombstoneSet.has(id)) { - c.entryHashRetired.push(id); + c.deletedAwaitingDecision.push(id); + outHashes[id] = entryHashes[id]; } } @@ -413,8 +428,10 @@ export function regenerateSections(existing: DspackDocument, fresh: DspackDocume if (allToolOwned && !orphansExist && tombstones.length === 0) { mergedGenerated.components = sectionHash(out); } - // else: omitted — shipped 0.4.0 paths then refuse (components present in the - // document, absent from `generated` = its human-authored-sections branch). + // else: omitted — shipped 0.4.0's decideRegeneration (the whole-file path) + // then refuses. Its regenerateSections predates tombstones and treats the + // unrecorded section as human-owned pure-addition — which is exactly why + // ledger-v2 documents require this version or later (the version floor). const metadata = { ...(merged.metadata as Record) }; metadata['x-bootstrap'] = { diff --git a/src/tests/ledger-v2.test.ts b/src/tests/ledger-v2.test.ts index 53f4185..f958c59 100644 --- a/src/tests/ledger-v2.test.ts +++ b/src/tests/ledger-v2.test.ts @@ -15,7 +15,7 @@ import { describe, expect, it } from 'vitest'; import { fileURLToPath } from 'node:url'; import { loadConfig } from '../config.js'; import { generateDocument } from '../generate.js'; -import { sectionHash } from '../emit/bootstrap.js'; +import { decideRegeneration, sectionHash } from '../emit/bootstrap.js'; import { regenerateSections } from '../regenerate.js'; import type { DspackDocument } from '../types.js'; // The exact published 0.4.0 decision table — the live-hazard fixture. @@ -399,3 +399,169 @@ describe('restoredConflict explicit resolutions (ratified 2026-08-04)', () => { expect(malformed.ok).toBe(false); }); }); + +describe('release-verification pins (adversarial sweep, 2026-08-04)', () => { + it('decideRegeneration refuses v2 standing decisions even when the components section was hand-deleted', () => { + // Tombstones survive only if the whole-file path reads the LEDGER — with + // the components key gone, no other check would see them. + const dead = structuredClone(generate()) as any; + delete dead.components; + delete dead.metadata['x-bootstrap'].generated.components; + dead.metadata['x-bootstrap'].components = {}; + dead.metadata['x-bootstrap'].doNotRediscover = ['badge']; + const vTomb = decideRegeneration(JSON.stringify(dead)); + expect(vTomb.allow).toBe(false); + if (!vTomb.allow) expect(vTomb.reason).toContain('doNotRediscover'); + + const orphaned = structuredClone(generate()) as any; + delete orphaned.components; // every recorded hash is now deletion memory + delete orphaned.metadata['x-bootstrap'].generated.components; + const vOrph = decideRegeneration(JSON.stringify(orphaned)); + expect(vOrph.allow).toBe(false); + if (!vOrph.allow) expect(vOrph.reason).toContain('deletion memory'); + }); + + it('v1 human-owned migration never silently restores an absence: every fresh-only id asks', () => { + const existing = enrich(generate()) as any; + delete existing.metadata['x-bootstrap'].ledger; + delete existing.metadata['x-bootstrap'].components; + delete existing.metadata['x-bootstrap'].doNotRediscover; + delete existing.components.badge; // the hand-deletion 0.4.0 could not remember + const r = regenerateSections(existing as DspackDocument, generate()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.migration).toBe('human-owned'); + expect((r.document as any).components.badge).toBeUndefined(); // NOT restored + expect(r.report.components.added).toEqual([]); + expect(r.report.components.deletedAwaitingDecision).toContain('badge'); + expect(ledgerOf(r.document).components.badge).toBeDefined(); // memory seeded + // The seeded memory persists: the next run still asks. + const r2 = regenerateSections(r.document, generate()); + expect(r2.ok).toBe(true); + if (!r2.ok) return; + expect((r2.document as any).components.badge).toBeUndefined(); + expect(r2.report.components.deletedAwaitingDecision).toContain('badge'); + // And restoring stays one explicit decision away. + const restored = regenerateSections(r.document, generate(), { restoreTopLevel: ['badge'] }); + expect(restored.ok).toBe(true); + if (!restored.ok) return; + expect((restored.document as any).components.badge).toBeDefined(); + }); + + it('a transient extraction absence never destroys deletion memory', () => { + const existing = structuredClone(generate()) as any; + delete existing.components.badge; // orphan with memory + const freshWithout = structuredClone(generate()) as any; + delete freshWithout.components.badge; + delete freshWithout.metadata['x-bootstrap'].components.badge; + const r1 = regenerateSections(existing as DspackDocument, freshWithout as DspackDocument); + expect(r1.ok).toBe(true); + if (!r1.ok) return; + expect(r1.report.components.deletedAwaitingDecision).toContain('badge'); + expect(ledgerOf(r1.document).components.badge).toBeDefined(); // kept, not retired + // The source id returns: STILL asks — never a silent re-add. + const r2 = regenerateSections(r1.document, generate()); + expect(r2.ok).toBe(true); + if (!r2.ok) return; + expect((r2.document as any).components.badge).toBeUndefined(); + expect(r2.report.components.deletedAwaitingDecision).toContain('badge'); + }); + + it('components: null is a pathed refusal, not a crash; malformed shapes name their paths', () => { + const nullMap = structuredClone(generate()) as any; + nullMap.metadata['x-bootstrap'].components = null; + const rn = regenerateSections(nullMap as DspackDocument, generate()); + expect(rn.ok).toBe(false); + if (!rn.ok) expect((rn as any).findings?.some((f: any) => f.path === '/metadata/x-bootstrap/components')).toBe(true); + + const badTombs = structuredClone(generate()) as any; + badTombs.metadata['x-bootstrap'].doNotRediscover = [42]; + const rt = regenerateSections(badTombs as DspackDocument, generate()); + expect(rt.ok).toBe(false); + if (!rt.ok) expect((rt as any).findings?.some((f: any) => f.path === '/metadata/x-bootstrap/doNotRediscover')).toBe(true); + + const badVersion = structuredClone(generate()) as any; + badVersion.metadata['x-bootstrap'].ledger = '3'; + const rv = regenerateSections(badVersion as DspackDocument, generate()); + expect(rv.ok).toBe(false); + if (!rv.ok) expect((rv as any).findings?.some((f: any) => f.path === '/metadata/x-bootstrap/ledger')).toBe(true); + }); + + it('every unhonorable intent is named; a mixed list is never partially honored', () => { + const doc = structuredClone(generate()) as any; + delete doc.components.badge; // a VALID restore target sits in the list + const r = regenerateSections(doc as DspackDocument, generate(), { restoreTopLevel: ['badge', 'button', 'nope'] }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toContain("'button'"); // already present + expect(r.reason).toContain("'nope'"); // missing from fresh + } + const again = regenerateSections(doc as DspackDocument, generate()); + expect(again.ok).toBe(true); + if (!again.ok) return; + expect((again.document as any).components.badge).toBeUndefined(); // valid intent was NOT honored + }); + + it('tool-owned v1 migration attributes EVERY entry and genuinely refreshes changed source', () => { + const existing = structuredClone(generate()) as any; + delete existing.metadata['x-bootstrap'].ledger; + delete existing.metadata['x-bootstrap'].components; + delete existing.metadata['x-bootstrap'].doNotRediscover; + existing.metadata['x-bootstrap'].generated.components = sectionHash(existing.components); + const fresh = structuredClone(generate()) as any; + fresh.components.card.description = 'Fresh description from changed source.'; + fresh.metadata['x-bootstrap'].components.card = sectionHash(fresh.components.card); + const r = regenerateSections(existing as DspackDocument, fresh as DspackDocument); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.report.migration).toBe('tool-owned'); + // The map covers exactly the document ids: nothing left unattributed. + expect(Object.keys(ledgerOf(r.document).components).sort()).toEqual(Object.keys((r.document as any).components).sort()); + // Migrated ownership is real: changed source REFRESHES, nothing is demoted. + expect(r.report.components.refreshed).toContain('card'); + expect(r.report.components.preservedEnriched).toEqual([]); + expect(r.report.components.readopted).toEqual([]); + expect((r.document as any).components.card.description).toBe('Fresh description from changed source.'); + }); + + it('migration is byte-stable outside x-bootstrap even when fresh metadata differs (both branches)', () => { + const freshDifferent = () => { + const f = structuredClone(generate()) as any; + f.metadata.generatedAt = '2027-01-01T00:00:00.000Z'; + return f as DspackDocument; + }; + const outside = (d: any) => JSON.stringify({ ...d, metadata: { ...d.metadata, 'x-bootstrap': undefined } }); + for (const enriched of [false, true]) { + const existing = (enriched ? enrich(generate()) : generate()) as any; + delete existing.metadata['x-bootstrap'].ledger; + delete existing.metadata['x-bootstrap'].components; + delete existing.metadata['x-bootstrap'].doNotRediscover; + if (!enriched) existing.metadata['x-bootstrap'].generated.components = sectionHash(existing.components); + const before = outside(existing); + const r = regenerateSections(existing as DspackDocument, freshDifferent()); + expect(r.ok, `branch enriched=${enriched}`).toBe(true); + if (!r.ok) continue; + expect(outside(r.document), `branch enriched=${enriched}`).toBe(before); + } + }); + + it('restore with changed source replaces the orphan hash and preserves the nested representation deep-equal', () => { + const existing = structuredClone(generate()) as any; + existing.components.card.composition = { + subComponents: [{ id: 'badge', name: 'Badge', description: 'Now a sub of card.', acceptsChildren: 'text' }], + }; + delete existing.components.badge; + const orphanHash = existing.metadata['x-bootstrap'].components.badge; + const cardAsAuthored = structuredClone(existing.components.card); + const fresh = structuredClone(generate()) as any; + fresh.components.badge.description = 'Changed in source since the deletion.'; + fresh.metadata['x-bootstrap'].components.badge = sectionHash(fresh.components.badge); + const r = regenerateSections(existing as DspackDocument, fresh as DspackDocument, { restoreTopLevel: ['badge'] }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(ledgerOf(r.document).components.badge).toBe(sectionHash(fresh.components.badge)); + expect(ledgerOf(r.document).components.badge).not.toBe(orphanHash); // replaced, never kept + expect((r.document as any).components.badge.description).toBe('Changed in source since the deletion.'); + expect((r.document as any).components.card).toEqual(cardAsAuthored); // nested rep byte-faithful + }); +});