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>
This commit is contained in:
@@ -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) ----------
|
||||
|
||||
Reference in New Issue
Block a user