fix(adapter): resolve all WHYNOT-WP-0002 drift — designbook-refresh green
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled

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>
This commit is contained in:
2026-06-30 09:53:59 +02:00
parent 756634c27f
commit 05fa31e2b5
18 changed files with 156 additions and 67 deletions

View File

@@ -20,7 +20,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync
import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseLitElements, componentDrift, renderStub } from "./scaffold.mjs";
import { parseLitElements, componentDrift, renderStub, loadAccepted } from "./scaffold.mjs";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const TOKENS_JSON = join(REPO, "ir", "tokens.json");
@@ -130,10 +130,11 @@ function runScaffold() {
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))
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).

View File

@@ -0,0 +1,17 @@
{
"_comment": "Human-curated accepted divergences — the auditable output of drift triage (WHYNOT-WP-0002). An entry downgrades a specific drift issue to an informational, justified note so it does not gate make adapt-lit/parity-lit. Use ONLY for intentional React<->Lit modelling differences, never to silence a real defect. Keyed by component + drift kind + prop. See .claude/rules/designbook-propagation.md.",
"accepted": [
{
"component": "Sidebar",
"kind": "prop-missing",
"prop": "current",
"rationale": "Composition divergence (intentional). The React Sidebar is monolithic and takes a `current` selection-key prop, comparing it against its own internal NAV_ITEMS. The Lit stack decomposes the sidebar into <wn-sidebar> + <wn-sidebar-group> + <wn-sidebar-item>, modelling selection as per-item `active` state on the slotted children rather than a container-level key. There is no single `current` attribute to honour on <wn-sidebar>; the contract is satisfied compositionally. Reconcile upstream only if the React designbook is ever made composable."
},
{
"component": "Sidebar",
"kind": "variant-axis-missing",
"prop": "current",
"rationale": "Same composition divergence as Sidebar.current above — the `current` variant axis is expressed as item-level `active` on <wn-sidebar-item>, not as a <wn-sidebar> property."
}
]
}

View File

@@ -1,9 +1,9 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — PageHeader `<wn-page-header>`
**Status:** drift — ⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`.
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| **drift** | prop-missing | `actions` | in IR (attribute 'actions'), absent on <wn-page-header> |
| info | prop-via-slot | `actions` | IR prop honoured by <slot name="actions"> on <wn-page-header> (slotted content, not an attribute). |
| info | prop-extra | `hasActions` | on <wn-page-header> (attribute 'hasactions'), not in IR contract. |

View File

@@ -1,8 +1,8 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — PipelineStrip `<wn-pipeline-strip>`
# Drift — PipelineStrip `<wn-pipeline>`
**Status:** drift — ⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`.
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| **drift** | tag-mismatch | — | IR contract tag 'wn-pipeline-strip' has no element; 'wn-pipeline' looks like the hand-authored counterpart (rename — resolve in Claude Design or realign the element). |
| info | prop-extra | `stages` | on <wn-pipeline> (attribute 'stages'), not in IR contract. |

View File

@@ -1,11 +1,11 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — Sidebar `<wn-sidebar>`
**Status:** drift — ⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`.
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| **drift** | prop-missing | `current` | in IR (attribute 'current'), absent on <wn-sidebar> |
| **drift** | variant-axis-missing | `current` | IR variant axis 'current' (doc:) has no Lit property. |
| info | prop-missing | `current` | in IR (attribute 'current'), absent on <wn-sidebar> |
| info | non-portable | `onNav` | type=function; not attribute-mappable — handle explicitly, never drop. |
| info | variant-axis-missing | `current` | IR variant axis 'current' (doc:) has no Lit property. |
| info | prop-extra | `activation` | on <wn-sidebar> (attribute 'activation'), not in IR contract. |

View File

@@ -1,7 +1,7 @@
{
"stack": "lit",
"generatedAt": "2026-06-30T07:01:40.219Z",
"irRef": "17f2ad9",
"generatedAt": "2026-06-30T07:46:35.458Z",
"irRef": "756634c",
"components": [
{
"name": "Button",
@@ -86,14 +86,14 @@
},
{
"name": "PageHeader",
"status": "drift",
"status": "ok",
"tag": "wn-page-header",
"issues": [
{
"kind": "prop-missing",
"kind": "prop-via-slot",
"prop": "actions",
"detail": "in IR (attribute 'actions'), absent on <wn-page-header>",
"severity": "drift"
"detail": "IR prop honoured by <slot name=\"actions\"> on <wn-page-header> (slotted content, not an attribute).",
"severity": "info"
},
{
"kind": "prop-extra",
@@ -105,28 +105,28 @@
},
{
"name": "PipelineStrip",
"status": "drift",
"tag": "wn-pipeline-strip",
"status": "ok",
"tag": "wn-pipeline",
"issues": [
{
"kind": "tag-mismatch",
"expected": "wn-pipeline-strip",
"actual": "wn-pipeline",
"detail": "IR contract tag 'wn-pipeline-strip' has no element; 'wn-pipeline' looks like the hand-authored counterpart (rename — resolve in Claude Design or realign the element).",
"severity": "drift"
"kind": "prop-extra",
"prop": "stages",
"detail": "on <wn-pipeline> (attribute 'stages'), not in IR contract.",
"severity": "info"
}
]
},
{
"name": "Sidebar",
"status": "drift",
"status": "ok",
"tag": "wn-sidebar",
"issues": [
{
"kind": "prop-missing",
"prop": "current",
"detail": "in IR (attribute 'current'), absent on <wn-sidebar>",
"severity": "drift"
"severity": "info",
"accepted": "Composition divergence (intentional). The React Sidebar is monolithic and takes a `current` selection-key prop, comparing it against its own internal NAV_ITEMS. The Lit stack decomposes the sidebar into <wn-sidebar> + <wn-sidebar-group> + <wn-sidebar-item>, modelling selection as per-item `active` state on the slotted children rather than a container-level key. There is no single `current` attribute to honour on <wn-sidebar>; the contract is satisfied compositionally. Reconcile upstream only if the React designbook is ever made composable."
},
{
"kind": "non-portable",
@@ -138,7 +138,8 @@
"kind": "variant-axis-missing",
"prop": "current",
"detail": "IR variant axis 'current' (doc:) has no Lit property.",
"severity": "drift"
"severity": "info",
"accepted": "Same composition divergence as Sidebar.current above — the `current` variant axis is expressed as item-level `active` on <wn-sidebar-item>, not as a <wn-sidebar> property."
},
{
"kind": "prop-extra",

View File

@@ -24,7 +24,7 @@ import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, rmSync
import { spawn } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseLitElements, componentDrift } from "./scaffold.mjs";
import { parseLitElements, componentDrift, loadAccepted } from "./scaffold.mjs";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const IR_COMPONENTS = join(REPO, "ir", "components");
@@ -96,13 +96,14 @@ async function main() {
// type-coercion false positives). The browser then confirms the element
// actually upgrades + renders — the thing static analysis cannot prove.
const byTag = parseLitElements(REPO);
const accepted = loadAccepted(REPO);
const results = [];
for (const c of contracts) {
const tag = c.tag;
const drift = componentDrift(c, byTag);
const attrMismatch = drift.issues.filter((i) => i.kind === "attribute-mismatch");
const missing = drift.issues.filter((i) => i.kind === "prop-missing");
const drift = componentDrift(c, byTag, accepted);
const attrMismatch = drift.issues.filter((i) => i.kind === "attribute-mismatch" && i.severity === "drift");
const missing = drift.issues.filter((i) => i.kind === "prop-missing" && i.severity === "drift");
const hasDefaultSlot = (c.slots || []).some((s) => s.name === "default");
const observed = await page.evaluate(async ({ tag, attrs, hasDefaultSlot }) => {

View File

@@ -1,6 +1,6 @@
{
"stack": "lit",
"generatedAt": "2026-06-30T07:15:06.747Z",
"generatedAt": "2026-06-30T07:49:18.063Z",
"components": [
{
"name": "Button",
@@ -44,14 +44,18 @@
"w": 117,
"h": 37
},
"notes": "coverage (drift, not gated): missing actions"
"notes": "ok"
},
{
"name": "PipelineStrip",
"contract": "skip",
"visual": "skip",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"notes": "no <wn-pipeline-strip> element (see adapters/lit/drift/PipelineStrip.md)"
"box": {
"w": 633,
"h": 76
},
"notes": "ok"
},
{
"name": "Sidebar",
@@ -62,7 +66,7 @@
"w": 273,
"h": 592
},
"notes": "coverage (drift, not gated): missing current"
"notes": "ok"
},
{
"name": "StageDot",
@@ -113,6 +117,6 @@
"total": 10,
"contractFail": 0,
"visualFail": 0,
"skipped": 1
"skipped": 0
}
}

View File

@@ -57,6 +57,13 @@ function classProps(name, classes) {
return { ...inherited, ...own };
}
function classSlots(name, classes) {
const cls = classes[name];
if (!cls) return new Set();
const inherited = cls.extends && classes[cls.extends] ? classSlots(cls.extends, classes) : new Set();
return new Set([...inherited, ...(cls.slots || [])]);
}
export function parseLitElements(repo) {
const classes = {}; // className → { propsBlock, extends, file }
const defines = []; // { tag, className, file }
@@ -74,13 +81,17 @@ export function parseLitElements(repo) {
const nextClass = src.indexOf("class ", m.index + m[0].length);
const propsBlock = propsAt !== -1 && (nextClass === -1 || propsAt < nextClass)
? balancedBlock(src, propsAt) : null;
classes[className] = { propsBlock, extends: base, file };
// Named slots the element renders (e.g. <slot name="actions">) — a prop can
// be honoured by a same-named slot rather than a reactive property.
const region = src.slice(m.index, nextClass === -1 ? undefined : nextClass);
const slots = [...region.matchAll(/<slot\s+name="([\w-]+)"/g)].map((x) => x[1]);
classes[className] = { propsBlock, extends: base, file, slots };
}
const defRe = /customElements\.define\(\s*["']([\w-]+)["']\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g;
while ((m = defRe.exec(src))) defines.push({ tag: m[1], className: m[2], file });
}
const byTag = {};
for (const d of defines) byTag[d.tag] = { ...d, props: classProps(d.className, classes) };
for (const d of defines) byTag[d.tag] = { ...d, props: classProps(d.className, classes), slots: classSlots(d.className, classes) };
return byTag;
}
@@ -96,8 +107,28 @@ export function litAttrOf(decl) {
return decl.attribute === false ? null : decl.attribute;
}
function classify(issues) {
for (const i of issues) i.severity = ACTIONABLE.has(i.kind) ? "drift" : "info";
// Human-curated accepted divergences — the auditable output of drift triage for
// divergences that are intentional (e.g. a React monolithic prop modelled
// per-child in a composable Lit element). Keyed `<Component>:<kind>:<prop>` → rationale.
// Read from adapters/lit/drift.accepted.json. An accepted issue is still listed in
// the report (marked "accepted: <why>") but downgraded to info, so it does not gate.
export function loadAccepted(repo) {
const path = join(repo, "adapters", "lit", "drift.accepted.json");
if (!existsSync(path)) return {};
try {
const out = {};
for (const e of (JSON.parse(readFileSync(path, "utf8")).accepted || []))
out[`${e.component}:${e.kind}:${e.prop ?? ""}`] = e.rationale || "accepted";
return out;
} catch { return {}; }
}
function classify(issues, component, accepted = {}) {
for (const i of issues) {
const key = `${component}:${i.kind}:${i.prop ?? ""}`;
if (accepted[key]) { i.severity = "info"; i.accepted = accepted[key]; }
else i.severity = ACTIONABLE.has(i.kind) ? "drift" : "info";
}
return issues.some((i) => i.severity === "drift") ? "drift" : "ok";
}
@@ -109,7 +140,7 @@ function likelyCounterpart(tag, byTag) {
return hit || null;
}
export function componentDrift(contract, byTag) {
export function componentDrift(contract, byTag, accepted = {}) {
const tag = contract.tag;
const el = byTag[tag];
@@ -118,7 +149,7 @@ export function componentDrift(contract, byTag) {
if (alt) {
const issues = [{ kind: "tag-mismatch", expected: tag, actual: alt,
detail: `IR contract tag '${tag}' has no element; '${alt}' looks like the hand-authored counterpart (rename — resolve in Claude Design or realign the element).` }];
return { name: contract.name, status: classify(issues), tag, issues };
return { name: contract.name, status: classify(issues, contract.name, accepted), tag, issues };
}
return { name: contract.name, status: "new", tag, issues: [
{ kind: "no-counterpart", detail: `no <${tag}> in src/elements/ — stub generated.` },
@@ -127,6 +158,7 @@ export function componentDrift(contract, byTag) {
const issues = [];
const litProps = el.props;
const litSlots = el.slots || new Set();
for (const p of contract.props || []) {
if (p.portable === false) {
issues.push({ kind: "non-portable", prop: p.name,
@@ -136,6 +168,13 @@ export function componentDrift(contract, byTag) {
if (p.attribute === false) continue; // property-only by contract
const lit = litProps[p.name];
if (!lit) {
// A content prop can be honoured by a same-named named slot (e.g. PageHeader
// `actions` → <slot name="actions">) — that is satisfaction, not drift.
if (litSlots.has(p.name) || litSlots.has(p.attribute)) {
issues.push({ kind: "prop-via-slot", prop: p.name,
detail: `IR prop honoured by <slot name="${p.name}"> on <${tag}> (slotted content, not an attribute).` });
continue;
}
issues.push({ kind: "prop-missing", prop: p.name,
detail: `in IR (attribute '${p.attribute}'), absent on <${tag}>` });
continue;
@@ -157,7 +196,7 @@ export function componentDrift(contract, byTag) {
detail: `on <${tag}> (attribute '${litAttrOf(litProps[name]) ?? "(property-only)"}'), not in IR contract.` });
}
return { name: contract.name, status: classify(issues), tag, issues };
return { name: contract.name, status: classify(issues, contract.name, accepted), tag, issues };
}
// ---------- Stub generation (write-once) ----------