mirror of
https://github.com/Piebald-AI/claude-code-system-prompts.git
synced 2026-06-13 14:43:33 +08:00
240 lines
12 KiB
Markdown
240 lines
12 KiB
Markdown
<!--
|
|
name: 'Data: Design sync sync hashes module'
|
|
description: Bundled design sync hash helper module that keeps package builds, captures, preview rebuilds, remote diffs, and sync sidecars aligned on render, style, source, and auxiliary hashes
|
|
ccVersion: 2.1.172
|
|
-->
|
|
// 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/<Name>.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);
|
|
}
|