diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a2037b..ad128c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,5 +17,7 @@ jobs: - run: npm ci - run: npm run build - run: npm test + - name: pack-and-install boundary + run: npm run test:pack - name: Vendored-schema drift check run: npm run check:sync diff --git a/package.json b/package.json index 3093f03..e151df2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aestheticfunction/dspack-export", - "version": "0.3.0", + "version": "0.4.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", @@ -14,7 +14,7 @@ ], "license": "Apache-2.0", "type": "module", - "main": "dist/cli.js", + "main": "dist/index.js", "bin": { "dspack-export": "dist/cli.js" }, @@ -37,7 +37,8 @@ "test": "vitest run", "generate:fixture": "npm run build && SOURCE_DATE_EPOCH=1781049600 node dist/cli.js generate --config fixtures/shadcn-demo/dspack-export.config.json", "generate:fixture:vue": "npm run build && SOURCE_DATE_EPOCH=1781049600 node dist/cli.js generate --config fixtures/vuetify-demo/dspack-export.config.json", - "check:sync": "node scripts/check-sync.mjs" + "check:sync": "node scripts/check-sync.mjs", + "test:pack": "bash scripts/pack-test.sh" }, "dependencies": { "@babel/parser": "^7.28.5", @@ -67,5 +68,9 @@ "src/emit/schema", "README.md", "LICENSE" - ] + ], + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } } diff --git a/scripts/pack-test.sh b/scripts/pack-test.sh new file mode 100755 index 0000000..2eddaac --- /dev/null +++ b/scripts/pack-test.sh @@ -0,0 +1,50 @@ +#!/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. +set -euo pipefail +cd "$(dirname "$0")/.." + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +npm run build >/dev/null +TARBALL="$(npm pack --pack-destination "$WORK" 2>/dev/null | tail -1)" +echo "packed: $TARBALL" + +cd "$WORK" +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"; + +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"); + +// Enrich, then regenerate: human-owned preserved with its hash, governance verbatim. +const enriched = structuredClone(document); +enriched.components.button.whenToUse = "Any user-initiated action."; +enriched.intents = [{ id: "demo-intent", description: "A demo intent." }]; +const fresh = exportProject(configPath).document; +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"); + +// Ledger-less documents fail closed; there is no force override. +const bare = structuredClone(document); +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)"); +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)" diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a395edf --- /dev/null +++ b/src/index.ts @@ -0,0 +1,52 @@ +/** + * Library surface of dspack-export. The CLI (src/cli.ts, the + * `dspack-export` bin) remains the file-writing front-end; these functions + * are the same pipeline for in-process hosts (the dspack-studio agent): + * + * exportProject(configPath) load config -> generate -> validate; + * returns the document, writes nothing + * regenerateSections(existing, fresh) the ledger-scoped iterative merge + * + * Ownership semantics (x-bootstrap ledger, refusal table, no force + * override) are exported so consumers reason with the same primitives. + */ +import { loadConfig } from './config.js'; +import { generateDocument, generatedAtFromEnv } from './generate.js'; +import { validateDspack } from './emit/validate.js'; +import type { DspackDocument } from './types.js'; + +export { loadConfig, type ExporterConfig, type ResolvedConfig } from './config.js'; +export { generateDocument, generatedAtFromEnv, GENERATOR_VERSION, type GenerateResult } from './generate.js'; +export { + GENERATED_SECTIONS, + AWAITING_AUTHORSHIP, + buildLedger, + decideRegeneration, + sectionHash, + type BootstrapLedger, + type RegenerationDecision, +} from './emit/bootstrap.js'; +export { validateDspack } from './emit/validate.js'; +export { regenerateSections, type RegenerateSectionsResult, type RegenerateReport } from './regenerate.js'; +export type { DspackDocument } from './types.js'; + +export interface ExportProjectResult { + document: DspackDocument; + warnings: string[]; +} + +/** + * The programmatic equivalent of `dspack-export generate --config `, + * minus every filesystem write: load and resolve the config, generate, and + * schema-validate. Throws on an invalid config or a document that fails the + * vendored v0.4 shape gate (the same fail-fast the CLI exits 1 on). + */ +export function exportProject(configPath: string): ExportProjectResult { + const config = loadConfig(configPath); + const { document, warnings } = generateDocument(config, { generatedAt: generatedAtFromEnv() }); + const validation = validateDspack(document); + if (!validation.valid) { + throw new Error(`generated document failed dspack v0.4 schema validation: ${validation.errors.join('; ')}`); + } + return { document, warnings }; +} diff --git a/src/regenerate.ts b/src/regenerate.ts new file mode 100644 index 0000000..14f5bbf --- /dev/null +++ b/src/regenerate.ts @@ -0,0 +1,124 @@ +/** + * Section-scoped regeneration: refresh what the tool still owns, never touch + * what a human has taken over. + * + * 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: + * + * - 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. + * + * 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. + */ +import { GENERATED_SECTIONS, sectionHash, type BootstrapLedger } from './emit/bootstrap.js'; +import type { DspackDocument } from './types.js'; + +export type RegenerateSectionsResult = + | { ok: true; document: DspackDocument; report: RegenerateReport } + | { ok: false; reason: string }; + +export interface RegenerateReport { + /** Tool-owned sections replaced from the fresh document (hashes updated). */ + refreshed: string[]; + /** Human-owned generated sections preserved verbatim (hash mismatch). */ + preservedHumanOwned: string[]; + /** Generated sections present before but absent from the fresh document: kept, flagged. */ + keptMissingInFresh: string[]; + /** Component ids added inside a human-owned components section (pure addition). */ + addedComponents: string[]; +} + +function ledgerOf(doc: DspackDocument): BootstrapLedger | undefined { + const metadata = (doc.metadata ?? {}) as Record; + return metadata['x-bootstrap'] as BootstrapLedger | undefined; +} + +export function regenerateSections(existing: DspackDocument, fresh: DspackDocument): RegenerateSectionsResult { + const existingLedger = ledgerOf(existing); + if (!existingLedger?.generated) { + return { + ok: false, + reason: + 'the existing document has no x-bootstrap ledger, so it is human-owned in full; ' + + 'section-scoped regeneration applies only to bootstrapped documents (there is no force override)', + }; + } + 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' }; + } + + const report: RegenerateReport = { + refreshed: [], + preservedHumanOwned: [], + keptMissingInFresh: [], + addedComponents: [], + }; + + // Start from the existing document: governance and every non-generated key + // carry over by construction; only generated sections are touched below. + const merged = structuredClone(existing) as Record; + const mergedGenerated: Record = { ...existingLedger.generated }; + + for (const section of GENERATED_SECTIONS) { + 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; + + 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; + } + + // Human-owned (present, hash mismatch) or human-authored (present, never + // recorded): preserved verbatim, recorded hash untouched. + report.preservedHumanOwned.push(section); + + 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); + } + } + } + } + + const metadata = { ...(merged.metadata as Record) }; + metadata['x-bootstrap'] = { + ...existingLedger, + spec: freshLedger.spec ?? existingLedger.spec, + generated: mergedGenerated, + }; + merged.metadata = metadata; + + return { ok: true, document: merged as DspackDocument, report }; +} diff --git a/src/tests/regenerate.test.ts b/src/tests/regenerate.test.ts new file mode 100644 index 0000000..998caa0 --- /dev/null +++ b/src/tests/regenerate.test.ts @@ -0,0 +1,106 @@ +/** + * Section-scoped regeneration semantics, pinned against REAL generator + * output: both documents come from generateDocument over the shadcn-demo + * fixture, and the "enrichment" edits mirror exactly what the composer's + * authoring flow does (props added, composition authored, governance + * written). The invariant under test is decideRegeneration's, restated at + * section granularity: regeneration never destroys human-authored content. + */ +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'; + +const configPath = fileURLToPath(new URL('../../fixtures/shadcn-demo/dspack-export.config.json', import.meta.url)); + +// The extractor (Babel + react-docgen) is the expensive step: run it ONCE +// and hand out clones — CI hardware timed the per-test invocation out. +const base = generateDocument(loadConfig(configPath), { generatedAt: '2026-06-11T00:00:00.000Z' }).document; +const generate = (): DspackDocument => structuredClone(base); + +/** The composer's enrichment: edit components (human-owned), author governance. */ +function enrich(doc: DspackDocument): DspackDocument { + const enriched = structuredClone(doc) as Record; + enriched.components.button.whenToUse = 'Any user-initiated action.'; + enriched.components.button.props.label = { type: 'string', required: true }; + enriched.intents = [{ id: 'demo-intent', description: 'A demo intent.' }]; + return enriched as DspackDocument; +} + +describe('regenerateSections', () => { + it('refuses a document without a ledger (human-owned in full, no force override)', () => { + const fresh = generate(); + const unbootstrapped = structuredClone(fresh) as Record; + delete unbootstrapped.metadata['x-bootstrap']; + const result = regenerateSections(unbootstrapped as DspackDocument, fresh); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain('no force override'); + }); + + it('refreshes tool-owned sections and preserves human-owned + governance verbatim', () => { + const existing = enrich(generate()); + const fresh = generate(); + const result = regenerateSections(existing, fresh); + expect(result.ok).toBe(true); + if (!result.ok) return; + + // Human-owned components preserved: the enrichment survives. + expect(result.report.preservedHumanOwned).toContain('components'); + 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: + const ledger = (result.document as any).metadata['x-bootstrap']; + expect(sectionHash((result.document as any).components)).not.toBe(ledger.generated.components); + expect(sectionHash((result.document as any).tokens)).toBe(ledger.generated.tokens); + }); + + it('adds newly discovered components inside a human-owned components section (pure addition)', () => { + const existing = enrich(generate()); + const fresh = structuredClone(generate()) as Record; + fresh.components['brand-new'] = { name: 'BrandNew', description: 'Discovered after enrichment.' }; + 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.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.'); + }); + + it('never modifies an existing component entry, even when fresh extraction differs', () => { + const existing = enrich(generate()); + const fresh = structuredClone(generate()) as Record; + fresh.components.button.props.variant.values.push('sparkly'); + const result = regenerateSections(existing, fresh as DspackDocument); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect((result.document as any).components.button.props.variant.values).not.toContain('sparkly'); + }); + + it('keeps sections the fresh document no longer produces, flagged', () => { + const existing = generate(); + const fresh = structuredClone(generate()) as Record; + delete fresh.themes; + delete (fresh.metadata['x-bootstrap'].generated as Record).themes; + const result = regenerateSections(existing, fresh as DspackDocument); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.report.keptMissingInFresh).toContain('themes'); + expect((result.document as any).themes).toBeDefined(); + }); + + it('round-trips cleanly on an untouched bootstrap (everything refreshes, nothing preserved)', () => { + const result = regenerateSections(generate(), generate()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.report.preservedHumanOwned).toEqual([]); + expect(result.report.addedComponents).toEqual([]); + }); +});