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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -14,7 +14,7 @@
],
"license": "Apache-2.0",
"type": "module",
"main": "dist/cli.js",
"main": "dist/index.js",
"bin": {
"dspack-export": "dist/cli.js"
},
Expand All @@ -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",
Expand Down Expand Up @@ -67,5 +68,9 @@
"src/emit/schema",
"README.md",
"LICENSE"
]
],
"exports": {
".": "./dist/index.js",
"./package.json": "./package.json"
}
}
50 changes: 50 additions & 0 deletions scripts/pack-test.sh
Original file line number Diff line number Diff line change
@@ -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)"
Comment on lines +47 to +50
52 changes: 52 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 <path>`,
* 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 };
}
124 changes: 124 additions & 0 deletions src/regenerate.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
const mergedGenerated: Record<string, string> = { ...existingLedger.generated };

for (const section of GENERATED_SECTIONS) {
const existingValue = (existing as Record<string, unknown>)[section];
const freshValue = (fresh as Record<string, unknown>)[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<string, unknown>;
for (const [id, entry] of Object.entries(freshValue as Record<string, unknown>)) {
if (!(id in target)) {
target[id] = structuredClone(entry);
report.addedComponents.push(id);
}
}
}
}

const metadata = { ...(merged.metadata as Record<string, unknown>) };
metadata['x-bootstrap'] = {
...existingLedger,
spec: freshLedger.spec ?? existingLedger.spec,
generated: mergedGenerated,
};
merged.metadata = metadata;

return { ok: true, document: merged as DspackDocument, report };
}
106 changes: 106 additions & 0 deletions src/tests/regenerate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>;
delete fresh.themes;
delete (fresh.metadata['x-bootstrap'].generated as Record<string, string>).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([]);
});
});
Loading