claude-code-system-prompts/system-prompts/data-design-sync-sync-hashes-module.md
2026-06-10 13:10:24 -06:00

12 KiB

// The hash recipes — single source of truth for every consumer that must // agree byte-for-byte: package-build.mjs writes the recipe outputs into // _ds_sync.json (the uploaded sidecar future syncs diff against) and stamps // per-component sourceKeys into .stories-map.json; package-capture.mjs / // compare.mjs key their local grade lifecycle on the stamped sourceKey; // lib/preview-rebuild.mjs re-stamps after targeted recompiles; // lib/remote-diff.mjs compares a fetched sidecar against a fresh build. // "Verified" carry-forward is sound only because all of them compute the // same hashes from the same recipe — never fork this logic into a harness. // // Factorization, by what a change should cost: // - sourceKey (KEY_RECIPE) — the GRADE contract: the user's own inputs // (story files, owned previews, story set, preview-affecting config, // committed forks). A change re-grades that component. // - renderHash — the per-component ARTIFACT fingerprint: feeds the upload // partition and the churn detector (artifacts moved while sourceKey // held ⇒ pipeline churn ⇒ sampled spot-check, never a re-grade storm). // - styleSha — the global styling surface, upload partition only. // gradeKey = H(sourceKey).

import { createHash } from 'node:crypto'; import { readFileSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url';

function hashFile(h, p, label) { h.update(label); try { h.update(readFileSync(p)); } catch { h.update('∅'); } } function hashDir(h, dir, prefix, skip) { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { h.update('∅'); return; } for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) { if (e.name.startsWith('.') || skip?.has(e.name)) continue; if (e.isDirectory()) hashDir(h, join(dir, e.name), ${prefix}${e.name}/, skip); else hashFile(h, join(dir, e.name), ${prefix}${e.name}); } }

// JSON with sorted object keys, so config slices hash stably across // key-order churn. undefined collapses to null. function canonical(v) { if (Array.isArray(v)) return [${v.map(canonical).join(',')}]; if (v && typeof v === 'object') { return {${Object.keys(v).sort().map((k) => ${JSON.stringify(k)}:${canonical(v[k])}).join(',')}}; } return JSON.stringify(v) ?? 'null'; }

// Global styling surface — feeds the upload partition only (upload.styling), // never grades. The package shape includes the compiled DS bundle body (a DS // recompile re-ships the styling surface); the storybook shape excludes it // (the bundle ships via bundleSha12 → upload.bundle). export function styleShaFor(OUT, { includeBundleBody }) { const h = createHash('sha256'); if (includeBundleBody) { // Body only — the first-line @ds-bundle header embeds per-file hashes, // so including it would invalidate everything whenever anything changes. h.update('bundlejs'); try { const src = readFileSync(join(OUT, '_ds_bundle.js'), 'utf8'); h.update(src.slice(src.indexOf('\n') + 1)); } catch { h.update('∅'); } } hashFile(h, join(OUT, '_ds_bundle.css'), 'bundlecss'); hashFile(h, join(OUT, 'styles.css'), 'styles'); hashDir(h, join(OUT, 'fonts'), 'fonts/'); hashDir(h, join(OUT, 'tokens'), 'tokens/'); // The whole vendor runtime, not just the decorators: every preview card // loads _vendor/react.js, so a React version bump must flip the styling // surface and re-ship _vendor/** (upload.styling). hashDir(h, join(OUT, '_vendor'), '_vendor/'); return h.digest('hex'); }

// Per-component render contract. The card html is hashed MINUS its first-line // @dsCard marker — the marker embeds the display group, and a pure regroup // must not read as a contract change (the viewport attr does belong: capture // honors it). For storybook components the story contract (names/export keys, // NOT the title-embedding storybook id) and the story-file fingerprint join — // an owned preview doesn't recompile when its story file changes, but the // contract must move either way. export function renderHashFor(OUT, c, { stories, srcSha } = {}) { const h = createHash('sha256'); hashFile(h, join(OUT, '_preview', ${c.name}.js), 'preview'); hashFile(h, join(OUT, '_preview', ${c.name}.css), 'previewcss'); h.update('html'); try { const html = readFileSync(join(OUT, 'components', c.group, c.name, ${c.name}.html), 'utf8'); const nl = html.indexOf('\n'); h.update(/viewport="[^"]*"/.exec(html.slice(0, nl))?.[0] ?? ''); h.update(html.slice(nl + 1)); } catch { h.update('∅'); } if (stories) h.update(JSON.stringify(stories.map((s) => [s.name, s.exportKey ?? null, s.emitted ?? null]))); if (srcSha !== undefined) h.update(String(srcSha ?? '')); return h.digest('hex').slice(0, 16); }

// Auxiliary docs surface — guidelines/, README.md. Neither affects renders // (no verification impact) but both upload, and without a hash a docs-only // edit would be invisible to the diff and never ship. export function auxShaFor(OUT) { const h = createHash('sha256'); hashDir(h, join(OUT, 'guidelines'), 'guidelines/'); hashFile(h, join(OUT, 'README.md'), 'readme'); return h.digest('hex').slice(0, 16); }

export function gradeKeyFrom(key) { return createHash('sha256').update(key).digest('hex').slice(0, 16); }

// ── sourceKey: the grade contract, keyed on what the user expressed ─────── // Versioned: the sidecar and capture jsons record keyRecipe, so a recipe // change reads as "unknown — re-verify", never as source churn. ANY change // to what feeds these hashes MUST bump this constant in the same commit — // same number over different bytes makes every existing anchor read as // total source churn (a full grade-wipe storm) instead of taking the // render-hash fallback. The golden-key test in resync-driver.test.ts // enforces the pairing. export const KEY_RECIPE = 5;

// Config slices in the grade contract: the knobs that change the preview's // DOM/mount semantics, plus committed lib forks. Asset-surface knobs // (cssEntry/tokensPkg/extraFonts/runtimeFontPrefixes) stay in the styling // trust class — deliberately NOT keyed; auto-detected siblings are derived // state whose churn rides renderHash into the spot-check tier. Computed at // BUILD time and stamped — consumers read the stamp, never live config, so // the key always describes the artifacts on disk. export function configSlicesFor(cfg = {}, designSyncDir = resolve('.design-sync')) { const g = createHash('sha256'); g.update('provider'); g.update(canonical(cfg.provider ?? null)); g.update('storyImports'); g.update(canonical(cfg.storyImports ?? null)); g.update('extraEntries'); g.update(canonical(cfg.extraEntries ?? null)); // cfg.tsconfig is keyed by VALUE (which tsconfig the preview compiles // resolve through — path aliases are mount semantics); the referenced // file's CONTENT is a repo source outside the named inputs, same class as // story-import closures — its churn moves compiled bytes and rides the // spot-check tier. g.update('tsconfig'); g.update(canonical(cfg.tsconfig ?? null)); // cfg.libOverrides is deliberately NOT keyed: its values are declaration // prose with no render effect, and fork behavior is fully keyed by the // fork file bytes below (loading keys off file existence, not the map). let forks = []; // preview-gen-package.mjs is the dead fork the build itself tells users to // delete ([OVERRIDE_DEAD] — never loaded); following that instruction must // not move the slice. try { forks = readdirSync(join(designSyncDir, 'overrides')).filter((f) => f.endsWith('.mjs') && f !== 'preview-gen-package.mjs').sort(); } catch { /* no forks */ } for (const f of forks) hashFile(g, join(designSyncDir, 'overrides', f), fork:${f}); const global = g.digest('hex'); const titleMap = cfg.titleMap ?? {}; const overrides = cfg.overrides ?? {}; return { global, componentFor(name) { const h = createHash('sha256'); h.update('override'); h.update(canonical(overrides[name] ?? null)); // Only remaps INTO this component are its identity; {title: null} // exclusions remove the component from the manifest entirely. h.update('titlemap'); h.update(canonical(Object.entries(titleMap).filter(([, v]) => v === name).sort())); return h.digest('hex'); }, }; }

// The user-authored preview source for a component, or null: the owned // previews/.tsx when present, else a HAND-MODIFIED generated wrapper // in .cache/previews/ (the take-ownership ramp — the build preserves and // compiles it, so it is live user content). Mirrors previews.mjs's marker // convention: a cache file whose first-line marker hash matches its body is // pristine generated output (pipeline-owned — never keyed; its churn rides // renderHash); markerless, hashless, or edited-under-marker files key like // owned ones. A forked previews.mjs with a different marker scheme reads as // "modified" here — over-keying, the safe direction. export function userPreviewFor(name, designSyncDir = resolve('.design-sync')) { try { return readFileSync(join(designSyncDir, 'previews', ${name}.tsx)); } catch { /* not owned */ } let src; try { src = readFileSync(join(designSyncDir, '.cache', 'previews', ${name}.tsx), 'utf8'); } catch { return null; } const nl = src.indexOf('\n'); const m = /^\uFEFF?// @ds-preview generated(?:\s+([0-9a-f]{12}))?\b/.exec(nl < 0 ? src : src.slice(0, nl)); const body = nl < 0 ? '' : src.slice(nl + 1); if (m?.[1] && m[1] === createHash('sha256').update(body).digest('hex').slice(0, 12)) return null; return Buffer.from(src); }

// Per-component grade contract. The owned preview is read at build/rebuild // time, right after its bytes were compiled; the package shape passes no // stories/srcSha. emitted labels are generator dedup output — excluded. export function sourceKeyFor(name, { globalSlice, componentSlice, stories = null, srcSha = undefined, designSyncDir = resolve('.design-sync') } = {}) { const h = createHash('sha256'); h.update(recipe:${KEY_RECIPE}); h.update('global'); h.update(globalSlice ?? ''); h.update('component'); h.update(componentSlice ?? ''); h.update('src'); h.update(String(srcSha ?? '')); h.update('owned'); h.update(userPreviewFor(name, designSyncDir) ?? '∅'); if (stories) { h.update('stories'); h.update(JSON.stringify(stories.map((s) => [s.name, s.exportKey ?? null]))); } return h.digest('hex').slice(0, 16); }

// Reference-storybook fingerprint — compare's [REFERENCE_STALE?]/sampler and // the driver's drift trigger must agree on one recipe. project.json carries // a generatedAt timestamp — excluded. export function sbBaseShaFor(sbDir) { const h = createHash('sha256'); hashDir(h, sbDir, 'sb/', new Set(['project.json'])); return h.digest('hex'); }

// Staged-scripts fingerprint, recorded in the sidecar so a spot-check event // can be traced to a skill release. Informational — never a partition input. export function scriptsShaFor() { const libDir = fileURLToPath(new URL('.', import.meta.url)); const root = fileURLToPath(new URL('..', import.meta.url)); const h = createHash('sha256'); hashDir(h, libDir, 'lib/'); for (const f of ['package-build.mjs', 'package-validate.mjs', 'package-capture.mjs', 'resync.mjs', 'storybook/compare.mjs', 'storybook/http-serve.mjs', 'storybook/probe.mjs']) { hashFile(h, join(root, f), f); } return h.digest('hex').slice(0, 16); }