Files
whynot-design/adapters/lit/adapt.mjs
tegwick 05fa31e2b5
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
fix(adapter): resolve all WHYNOT-WP-0002 drift — designbook-refresh green
Triage the three surfaced divergences the governance-correct way (no stack->React
back-edit, no ir/ hand-edit); make adapt-lit/parity-lit/designbook-refresh now
exit 0:

- PipelineStrip: documented TAG_OVERRIDES in scripts/ir-extract.mjs maps the
  React 'PipelineStrip' to the established tag wn-pipeline (the web-component tag
  is an IR-projection detail, not React-dictated; the component name stays
  faithful). Tag now matches the element; parity tests it (no longer skipped).
- PageHeader.actions: the drift detector now collects each element's named slots
  and treats an IR prop honoured by a same-named slot (<slot name="actions">) as
  satisfied (prop-via-slot, informational) rather than prop-missing.
- Sidebar.current: recorded as an auditable accepted divergence in
  adapters/lit/drift.accepted.json (React monolithic 'current' key vs Lit per-item
  'active' on composable <wn-sidebar-item>) — listed, downgraded to info, not gated.

Rendered surfaces (src/, examples/) untouched — verified zero diff; parity renders
all 10 components green. Adapt/parity outputs idempotent (stable re-run).

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

195 lines
8.5 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, loadAccepted } 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 accepted = loadAccepted(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, accepted))
.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();