feat(consumer): versioned IR manifest + drift-check (WHYNOT-WP-0003 T03-T07,T09)
Make ir/ the unit of versioned downstream consumption so consuming repos can pin a version, inspect it, and follow changes at their own pace. - T03 ir/manifest.json: per-version inventory + diff anchor with deterministic sha256-over-canonicalised-JSON hashes; no-churn generatedAt; manifest schema. - T07 ir/INDEX.md: human-readable catalog generated by make ir. - T04 .whynot-design.lock sync-point format + lock schema. - T05 npx @whynot/design drift: consumer drift-check (bin entry), exit 0/2/3, --json/--update/--manifest/--version/--lock. - T06 CONSUMING.md guide + examples/consumer-fixture/ runnable demo; README + MultiFrameworkSupport cross-links; fix README version pin (@0.3.0 not @v0.3.0). - T09 CONSUMER_CONTRACT_PARITY.md design-only note (live-UI parity deferred). T02 (publish) and T08 (showcase, blocked on WP-0002 T11) remain wait. Repo stays in dev mode; no outward publish performed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
// designbook/preview/comp-*.html → exemplar renders
|
||||
// =============================================================
|
||||
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, existsSync, readdirSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -25,6 +26,12 @@ const DESIGNBOOK = join(REPO, "designbook");
|
||||
const IR = join(REPO, "ir");
|
||||
const KIT = "whynot-control";
|
||||
|
||||
// Bump when the SHAPE of ir/manifest.json changes (consumers branch on this).
|
||||
// Hash stability is governed here: any change that would alter existing hashes
|
||||
// without a meaningful contract change must bump this so a consumer can tell a
|
||||
// re-canonicalisation apart from a real design change. See ir/SCHEMA.md.
|
||||
const MANIFEST_SCHEMA_VERSION = "1.0.0";
|
||||
|
||||
// Which ui-kit files hold reusable design-system components (NOT app screens/demo data).
|
||||
const COMPONENT_SOURCES = ["Atoms.jsx", "Chrome.jsx"];
|
||||
|
||||
@@ -226,6 +233,113 @@ function validateContract(c) {
|
||||
return errs;
|
||||
}
|
||||
|
||||
// ---------- Manifest: deterministic content hashes (WHYNOT-WP-0003 · T03) ----------
|
||||
// Canonicalise before hashing so the hash is invariant to key order and
|
||||
// whitespace and sensitive ONLY to meaningful contract/token change.
|
||||
function canonicalise(value) {
|
||||
if (Array.isArray(value)) return value.map(canonicalise);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.keys(value).sort().reduce((acc, k) => {
|
||||
acc[k] = canonicalise(value[k]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contentHash(value) {
|
||||
return "sha256:" + createHash("sha256")
|
||||
.update(JSON.stringify(canonicalise(value)))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
// Build ir/manifest.json. `generatedAt` is reused from the prior manifest when
|
||||
// nothing hashed changed, so a no-op `make ir` does not churn the committed file.
|
||||
function buildManifest(tokens, contracts) {
|
||||
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
||||
const components = contracts
|
||||
.map((c) => ({ name: c.name, group: c.group, hash: contentHash(c) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const tokensHash = contentHash(tokens);
|
||||
|
||||
const manifestPath = join(IR, "manifest.json");
|
||||
let generatedAt = new Date().toISOString();
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
const prev = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
const unchanged =
|
||||
prev.schemaVersion === MANIFEST_SCHEMA_VERSION &&
|
||||
prev.designVersion === pkg.version &&
|
||||
prev.tokensHash === tokensHash &&
|
||||
JSON.stringify(prev.components) === JSON.stringify(components);
|
||||
if (unchanged && prev.generatedAt) generatedAt = prev.generatedAt;
|
||||
} catch { /* malformed prior manifest — regenerate fresh */ }
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
||||
designVersion: pkg.version,
|
||||
generatedAt,
|
||||
tokensHash,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Index: human-readable catalog (WHYNOT-WP-0003 · T07) ----------
|
||||
// A browsable view of a version — no clone or run needed. Generated from the
|
||||
// same contracts the manifest hashes, so the two never disagree.
|
||||
function buildIndex(manifest, contracts) {
|
||||
const byGroup = new Map();
|
||||
for (const c of contracts) {
|
||||
if (!byGroup.has(c.group)) byGroup.set(c.group, []);
|
||||
byGroup.get(c.group).push(c);
|
||||
}
|
||||
const hashOf = new Map(manifest.components.map((m) => [m.name, m.hash]));
|
||||
|
||||
const lines = [];
|
||||
lines.push("<!-- GENERATED by scripts/ir-extract.mjs (make ir) — do not hand-edit. -->");
|
||||
lines.push(`# whynot-design IR catalog`);
|
||||
lines.push("");
|
||||
lines.push(`**designVersion** \`${manifest.designVersion}\` · **components** ${contracts.length} · **generated** ${manifest.generatedAt}`);
|
||||
lines.push("");
|
||||
lines.push("Machine-readable companion: [`manifest.json`](./manifest.json) (per-component + token hashes).");
|
||||
lines.push("");
|
||||
|
||||
for (const group of [...byGroup.keys()].sort()) {
|
||||
lines.push(`## ${group}`);
|
||||
lines.push("");
|
||||
for (const c of byGroup.get(group).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
lines.push(`### ${c.name} \`<${c.tag}>\``);
|
||||
lines.push("");
|
||||
lines.push(c.description);
|
||||
lines.push("");
|
||||
const portableProps = (c.props || []).filter((p) => p.portable !== false);
|
||||
if (portableProps.length) {
|
||||
lines.push("| prop | attribute | type | default |");
|
||||
lines.push("| --- | --- | --- | --- |");
|
||||
for (const p of portableProps) {
|
||||
const type = p.type === "enum" ? `enum(${(p.enum || []).join(" \\| ")})` : p.type;
|
||||
lines.push(`| \`${p.name}\` | \`${p.attribute}\` | ${type} | ${p.default !== undefined ? `\`${p.default}\`` : "—"} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
const nonPortable = (c.props || []).filter((p) => p.portable === false);
|
||||
if (nonPortable.length) {
|
||||
lines.push(`_Non-portable (React-only) props:_ ${nonPortable.map((p) => `\`${p.name}\``).join(", ")}.`);
|
||||
lines.push("");
|
||||
}
|
||||
if (c.slots) lines.push(`**Slots:** ${c.slots.map((s) => `\`${s.name}\``).join(", ")} `);
|
||||
if (c.events) lines.push(`**Events:** ${c.events.map((e) => `\`${e.name}\``).join(", ")} `);
|
||||
if (c.variants) lines.push(`**Variants:** ${c.variants.map((v) => `${v.axis} (${v.values.join("/")})`).join(", ")} `);
|
||||
lines.push(`**Contract:** [\`components/${c.name}.json\`](./components/${c.name}.json) · **hash** \`${hashOf.get(c.name)}\`` +
|
||||
(c.exemplarRef ? ` · **exemplar:** [\`exemplars/${c.name}.html\`](./exemplars/${c.name}.html)` : ""));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ---------- Emit ----------
|
||||
function resetDir(dir) {
|
||||
if (existsSync(dir)) for (const f of readdirSync(dir)) rmSync(join(dir, f), { recursive: true, force: true });
|
||||
@@ -265,6 +379,16 @@ function main() {
|
||||
for (const e of allErrs) console.error(" - " + e);
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
log("Manifest → ir/manifest.json");
|
||||
const manifest = buildManifest(tokens, comps.map((c) => c.contract));
|
||||
writeFileSync(join(IR, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
||||
log(` designVersion ${manifest.designVersion}, ${manifest.components.length} components, tokensHash ${manifest.tokensHash}`);
|
||||
|
||||
log("Index → ir/INDEX.md");
|
||||
writeFileSync(join(IR, "INDEX.md"), buildIndex(manifest, comps.map((c) => c.contract)));
|
||||
log(` catalog for ${manifest.components.length} components`);
|
||||
|
||||
log("\nIR extracted. Review the ir/ git diff (the blueprint change).");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user