Files
whynot-design/bin/whynot-design.mjs
tegwick 2de30beb7b
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
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>
2026-06-27 19:35:45 +02:00

208 lines
8.6 KiB
JavaScript
Executable File

#!/usr/bin/env node
// =============================================================
// whynot-design CLI — consumer drift-check (WHYNOT-WP-0003 · T05)
//
// Runs IN A CONSUMING REPO: npx @whynot/design drift
//
// Compares the consumer's adopted sync-point (.whynot-design.lock) against the
// installed package's ir/manifest.json (or an explicit --manifest), and reports
// added / changed / removed components + token changes. Read-only against the
// package; the only file it writes is .whynot-design.lock (and only on --update).
//
// This is the DOWNSTREAM mirror of the upstream adapter drift
// (adapters/ADAPTER_CONTRACT.md) — same report shape, same exit codes:
// 0 in sync 2 usage/config error 3 drift detected
// =============================================================
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join, dirname, resolve, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const EXIT = { OK: 0, USAGE: 2, DRIFT: 3 };
function fail(msg) {
process.stderr.write(`whynot-design: ${msg}\n`);
process.exit(EXIT.USAGE);
}
function readJson(path, label) {
if (!existsSync(path)) fail(`${label} not found at ${path}`);
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (e) {
fail(`${label} at ${path} is not valid JSON: ${e.message}`);
}
}
function parseArgs(argv) {
const args = { _: [], flags: {} };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--json" || a === "--update") args.flags[a.slice(2)] = true;
else if (a === "--lock" || a === "--manifest" || a === "--version") args.flags[a.slice(2)] = argv[++i];
else if (a.startsWith("--")) fail(`unknown flag ${a}`);
else args._.push(a);
}
return args;
}
function resolvePath(p, base) {
return isAbsolute(p) ? p : resolve(base, p);
}
// ---------- drift core ----------
// Compare an adopted lock against a target manifest. Pure; reused for --json,
// the human report, and --update. Mirrors the adapter drift component shape.
function computeDrift(lock, manifest) {
const adopted = lock.manifestHashes.components || {};
const current = Object.fromEntries(manifest.components.map((c) => [c.name, c.hash]));
const groupOf = Object.fromEntries(manifest.components.map((c) => [c.name, c.group]));
const names = [...new Set([...Object.keys(adopted), ...Object.keys(current)])].sort();
const components = [];
for (const name of names) {
if (!(name in adopted)) components.push({ name, group: groupOf[name], status: "added" });
else if (!(name in current)) components.push({ name, status: "removed" });
else if (adopted[name] !== current[name]) components.push({ name, group: groupOf[name], status: "changed", from: adopted[name], to: current[name] });
else components.push({ name, group: groupOf[name], status: "ok" });
}
const tokensChanged = lock.manifestHashes.tokens !== manifest.tokensHash;
const drifted = tokensChanged || components.some((c) => c.status !== "ok");
return {
tool: "@whynot/design drift",
generatedAt: new Date().toISOString(),
adopted: { designVersion: lock.designVersion, adoptedAt: lock.adoptedAt },
target: { designVersion: manifest.designVersion, generatedAt: manifest.generatedAt },
schemaVersionMismatch: lock.manifestSchemaVersion !== manifest.schemaVersion
? { adopted: lock.manifestSchemaVersion, target: manifest.schemaVersion }
: null,
tokens: { status: tokensChanged ? "changed" : "ok" },
components,
drift: drifted,
};
}
function lockFromManifest(manifest) {
return {
designVersion: manifest.designVersion,
adoptedAt: new Date().toISOString(),
manifestSchemaVersion: manifest.schemaVersion,
manifestHashes: {
tokens: manifest.tokensHash,
components: Object.fromEntries(manifest.components.map((c) => [c.name, c.hash])),
},
};
}
function printHuman(report) {
const out = [];
out.push(`whynot-design drift`);
out.push(` adopted: ${report.adopted.designVersion} (${report.adopted.adoptedAt})`);
out.push(` target: ${report.target.designVersion} (${report.target.generatedAt})`);
if (report.schemaVersionMismatch) {
out.push(` ! manifest schemaVersion differs (${report.schemaVersionMismatch.adopted}${report.schemaVersionMismatch.target}); hashes may not be directly comparable.`);
}
out.push("");
const added = report.components.filter((c) => c.status === "added");
const changed = report.components.filter((c) => c.status === "changed");
const removed = report.components.filter((c) => c.status === "removed");
out.push(`Tokens: ${report.tokens.status === "changed" ? "changed" : "unchanged"}`);
out.push(`Components: +${added.length} added · ~${changed.length} changed · -${removed.length} removed · ${report.components.length} total`);
if (added.length) out.push(` + ${added.map((c) => c.name).join(", ")}`);
if (changed.length) out.push(` ~ ${changed.map((c) => c.name).join(", ")}`);
if (removed.length) out.push(` - ${removed.map((c) => c.name).join(", ")}`);
out.push("");
if (report.drift) {
out.push(`Drift detected vs your adopted sync-point.`);
out.push(`Adopt this version: npx @whynot/design drift --update`);
} else {
out.push(`In sync with ${report.target.designVersion}. No drift.`);
}
process.stdout.write(out.join("\n") + "\n");
}
// ---------- drift command ----------
function cmdDrift(args) {
const cwd = process.cwd();
const lockPath = resolvePath(args.flags.lock || ".whynot-design.lock", cwd);
const manifestPath = args.flags.manifest
? resolvePath(args.flags.manifest, cwd)
: join(PKG_ROOT, "ir", "manifest.json");
const manifest = readJson(manifestPath, "ir/manifest.json");
if (!Array.isArray(manifest.components) || typeof manifest.tokensHash !== "string") {
fail(`${manifestPath} is not a valid ir/manifest.json`);
}
if (args.flags.version && manifest.designVersion !== args.flags.version) {
fail(`--version ${args.flags.version} does not match the resolved manifest (designVersion ${manifest.designVersion}). Install that version or point --manifest at it.`);
}
// First adopt: no lock yet. --update bootstraps it; otherwise guide the user.
if (!existsSync(lockPath)) {
if (args.flags.update) {
writeFileSync(lockPath, JSON.stringify(lockFromManifest(manifest), null, 2) + "\n");
if (args.flags.json) process.stdout.write(JSON.stringify({ adopted: manifest.designVersion, created: true }, null, 2) + "\n");
else process.stdout.write(`Adopted ${manifest.designVersion} as the initial sync-point → ${lockPath}\n`);
return EXIT.OK;
}
fail(`no .whynot-design.lock found. Adopt the installed version first:\n npx @whynot/design drift --update`);
}
const lock = readJson(lockPath, ".whynot-design.lock");
if (!lock.manifestHashes || !lock.manifestHashes.components) {
fail(`${lockPath} is missing manifestHashes.components`);
}
const report = computeDrift(lock, manifest);
if (args.flags.update) {
writeFileSync(lockPath, JSON.stringify(lockFromManifest(manifest), null, 2) + "\n");
if (args.flags.json) process.stdout.write(JSON.stringify({ ...report, updated: true }, null, 2) + "\n");
else {
printHuman(report);
process.stdout.write(`\nAdopted ${manifest.designVersion}${lockPath}\n`);
}
return EXIT.OK; // --update reconciles, so it always lands in sync
}
if (args.flags.json) process.stdout.write(JSON.stringify(report, null, 2) + "\n");
else printHuman(report);
return report.drift ? EXIT.DRIFT : EXIT.OK;
}
// ---------- dispatch ----------
function main() {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
const cmd = args._[0];
if (!cmd || cmd === "help" || args.flags.help) {
process.stdout.write(`whynot-design — consumer-side design-system tooling
Usage:
npx @whynot/design drift [options] Report changes since your adopted sync-point
Options:
--update Adopt the target version as the new sync-point (writes .whynot-design.lock)
--json Machine-readable output
--manifest <path> Diff against an explicit ir/manifest.json (default: the installed package's)
--version <x.y.z> Assert the resolved manifest is this version (guards against the wrong install)
--lock <path> Path to the consumer lock (default: ./.whynot-design.lock)
Exit codes: 0 in sync · 2 usage/config error · 3 drift detected
`);
return EXIT.OK;
}
if (cmd === "drift") return cmdDrift(args);
fail(`unknown command '${cmd}'. Try: npx @whynot/design help`);
}
process.exit(main());