Files
whynot-design/adapters/lit/adapt.mjs
tegwick 552d8fe926 feat(adapter): Lit component scaffold + drift report (WHYNOT-WP-0002 T07)
Extend make adapt-lit beyond tokens: parse src/elements/*.js, compare each IR
component contract against its <wn-*> element, and emit per-component drift
reports + a machine roll-up (adapters/lit/drift/), with write-once stubs
(adapters/lit/stubs/) for genuinely new components. Never overwrites
hand-authored sources.

- Severity split: actionable drift (prop-missing, attribute-mismatch,
  variant-axis-missing, tag-mismatch) gates with exit 3; non-portable + prop-extra
  are informational (the IR carries React style/onClick; Lit is richer than the
  minimal designbook) and don't gate.
- Current state: 7 ok, 3 actionable drift for human triage — PipelineStrip
  (wn-pipeline-strip vs hand-authored wn-pipeline rename), PageHeader (actions is
  a slot, not a prop), Sidebar (IR 'current' axis absent on the element).
- _report.json reuses generatedAt/irRef when drift is unchanged (no git churn).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:03:22 +02:00

194 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// =============================================================
// adapters/lit/adapt.mjs — the Lit reference adapter (WHYNOT-WP-0002)
//
// Projects the technology-neutral IR (ir/) onto the Lit stack. Per
// adapters/ADAPTER_CONTRACT.md this is scaffold + drift-detect, never a rewrite:
//
// • tokens (T06) → FULLY generated, deterministic (this file, today)
// • new component → stub (T07)
// • changed component → drift report (T07), never an overwrite
//
// Exit codes: 0 ok · 2 usage/config · 3 drift · 4 parity · 5 internal.
//
// Run via `make adapt-lit`. Tokens regenerate the :root block of
// src/styles/colors_and_type.css between generated markers; the hand-authored
// type/utility CSS after it is preserved untouched. Re-running with an unchanged
// ir/tokens.json is a no-op (byte-identical output).
// =============================================================
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseLitElements, componentDrift, renderStub } from "./scaffold.mjs";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const TOKENS_JSON = join(REPO, "ir", "tokens.json");
const TOKEN_CSS = join(REPO, "src", "styles", "colors_and_type.css");
const IR_COMPONENTS = join(REPO, "ir", "components");
const DRIFT_DIR = join(REPO, "adapters", "lit", "drift");
const STUBS_DIR = join(REPO, "adapters", "lit", "stubs");
const BEGIN = "/* @generated tokens — regenerated by `make adapt-lit` from ir/tokens.json. DO NOT EDIT. */";
const END = "/* @end generated tokens */";
// ---------- tokens: ir/tokens.json (DTCG) → CSS custom properties ----------
function refToVar(value) {
// {group.key} alias → var(--key). Literals pass through unchanged.
const m = /^\{[A-Za-z0-9]+\.([A-Za-z0-9-]+)\}$/.exec(String(value).trim());
return m ? `var(--${m[1]})` : value;
}
function renderRootBlock(tokens) {
const lines = [BEGIN, ":root {"];
for (const [group, entries] of Object.entries(tokens)) {
lines.push(` /* ${group} */`);
for (const [key, tok] of Object.entries(entries)) {
if (key === "$type") continue;
lines.push(` --${key}: ${refToVar(tok.$value)};`);
}
}
lines.push("}", END);
return lines.join("\n");
}
// Replace the current :root token region with the freshly generated block.
// First run (no markers): replace the first `:root { … }` via brace matching.
// Later runs: replace strictly between the @generated markers.
function spliceTokenBlock(css, block) {
const b = css.indexOf(BEGIN);
if (b !== -1) {
const e = css.indexOf(END, b);
if (e === -1) throw new Error("Found @generated marker without its @end — refusing to guess.");
return css.slice(0, b) + block + css.slice(e + END.length);
}
const rootAt = css.indexOf(":root");
if (rootAt === -1) throw new Error("No :root block in colors_and_type.css to replace.");
let i = css.indexOf("{", rootAt), depth = 0;
for (; i < css.length; i++) {
if (css[i] === "{") depth++;
else if (css[i] === "}" && --depth === 0) break;
}
return css.slice(0, rootAt) + block + css.slice(i + 1);
}
function generateTokens() {
if (!existsSync(TOKENS_JSON)) {
console.error("No ir/tokens.json — run `make ir` first.");
process.exit(2);
}
const tokens = JSON.parse(readFileSync(TOKENS_JSON, "utf8"));
const block = renderRootBlock(tokens);
const before = existsSync(TOKEN_CSS) ? readFileSync(TOKEN_CSS, "utf8") : `${block}\n`;
const after = existsSync(TOKEN_CSS) ? spliceTokenBlock(before, block) : `${block}\n`;
const count = block.split("\n").filter((l) => l.trim().startsWith("--")).length;
if (after === before) {
console.log(`tokens: up to date (${count} custom properties, no change).`);
return;
}
writeFileSync(TOKEN_CSS, after);
console.log(`tokens: regenerated ${count} custom properties → src/styles/colors_and_type.css`);
}
// ---------- T07: component scaffold + drift ----------
function irRef() {
try {
return execSync("git rev-parse --short HEAD", { cwd: REPO }).toString().trim();
} catch { return "(no-git)"; }
}
function renderDriftMd(c) {
const head = {
ok: "✓ in sync with the IR contract.",
drift: "⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`.",
new: " new component — a stub was generated; integrate it.",
removed: " removed from the IR.",
}[c.status];
const lines = [
`<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->`,
`# Drift — ${c.name} \`<${c.tag}>\``,
"",
`**Status:** ${c.status}${head}`,
"",
];
if (!c.issues.length) lines.push("No issues.");
else {
lines.push("| severity | kind | prop | detail |", "| --- | --- | --- | --- |");
const order = { drift: 0, info: 1 };
for (const i of [...c.issues].sort((a, b) => order[a.severity] - order[b.severity])) {
const detail = i.detail || (i.expected !== undefined ? `expected \`${i.expected}\`, actual \`${i.actual}\`` : "");
lines.push(`| ${i.severity === "drift" ? "**drift**" : "info"} | ${i.kind} | ${i.prop ? `\`${i.prop}\`` : "—"} | ${detail} |`);
}
}
lines.push("");
return lines.join("\n");
}
function runScaffold() {
if (!existsSync(IR_COMPONENTS)) {
console.error("No ir/components/ — run `make ir` first.");
process.exit(2);
}
const byTag = parseLitElements(REPO);
const contracts = readdirSync(IR_COMPONENTS).filter((f) => f.endsWith(".json"))
.map((f) => JSON.parse(readFileSync(join(IR_COMPONENTS, f), "utf8")));
const results = contracts.map((c) => componentDrift(c, byTag))
.sort((a, b) => a.name.localeCompare(b.name));
// Per-component drift docs: snapshot, so wipe stale ones first (idempotency rule 4).
mkdirSync(DRIFT_DIR, { recursive: true });
for (const f of readdirSync(DRIFT_DIR)) if (f.endsWith(".md")) rmSync(join(DRIFT_DIR, f));
for (const c of results) writeFileSync(join(DRIFT_DIR, `${c.name}.md`), renderDriftMd(c));
// Machine roll-up. Reuse generatedAt when nothing material changed so a no-op
// `make adapt-lit` produces no git churn (matches ir/manifest.json discipline).
const reportPath = join(DRIFT_DIR, "_report.json");
let generatedAt = new Date().toISOString();
let ref = irRef();
if (existsSync(reportPath)) {
try {
const prev = JSON.parse(readFileSync(reportPath, "utf8"));
if (JSON.stringify(prev.components) === JSON.stringify(results)) {
if (prev.generatedAt) generatedAt = prev.generatedAt;
if (prev.irRef) ref = prev.irRef; // unchanged drift ⇒ keep the prior ref, no churn
}
} catch { /* regenerate fresh */ }
}
const report = { stack: "lit", generatedAt, irRef: ref, components: results };
writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
// Write-once stubs for genuinely new components.
let stubbed = 0;
for (const c of results.filter((r) => r.status === "new")) {
mkdirSync(STUBS_DIR, { recursive: true });
const out = join(STUBS_DIR, `${c.name}.js`);
if (!existsSync(out)) { writeFileSync(out, renderStub(contracts.find((x) => x.name === c.name))); stubbed++; }
}
const drift = results.filter((r) => r.status === "drift");
const isNew = results.filter((r) => r.status === "new");
const ok = results.filter((r) => r.status === "ok");
const infoCount = ok.reduce((n, r) => n + r.issues.length, 0);
console.log(`scaffold: ${ok.length} ok · ${drift.length} drift · ${isNew.length} new (${stubbed} stub${stubbed === 1 ? "" : "s"} written) → adapters/lit/drift/`);
if (infoCount) console.log(` (${infoCount} informational note${infoCount === 1 ? "" : "s"} on in-sync components — non-portable/extra props, not gated)`);
for (const c of [...drift, ...isNew]) {
const actionable = c.issues.filter((i) => i.severity === "drift");
console.log(` ${c.status === "drift" ? "⚠" : ""} ${c.name}: ${actionable.map((i) => `${i.kind}(${i.prop ?? ""})`).join(", ") || c.issues.map((i) => i.kind).join(", ")}`);
}
return drift.length + isNew.length > 0; // → drift exit code
}
function main() {
generateTokens();
const drifted = runScaffold();
if (drifted) {
console.log("adapt-lit: drift detected — see adapters/lit/drift/. Exit 3 (review, non-fatal).");
process.exit(3);
}
console.log("adapt-lit: tokens + scaffold done, no drift.");
}
main();