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
// =============================================================
// adapters/lit/scaffold.mjs — component scaffold + drift (WHYNOT-WP-0002 · T07)
//
// Pure functions over ir/ + the Lit source tree (src/elements/*.js). Per
// adapters/ADAPTER_CONTRACT.md:
// • IR component with no <wn-*> counterpart → a write-once STUB
// (adapters/lit/stubs/<Name>.js), never into the hand-authored tree.
// • IR component with a counterpart → a DRIFT REPORT
// (adapters/lit/drift/<Name>.md + a machine roll-up); never an overwrite.
//
// Behaviour is never generated — stubs carry a TODO; drift is for human triage.
// =============================================================
import { readFileSync , readdirSync , existsSync } from "node:fs" ;
import { join } from "node:path" ;
const ELEMENT _FILES = [ "atoms.js" , "form.js" , "layout.js" , "chrome.js" ] ;
// ---------- Parse the Lit source tree → tag → element descriptor ----------
// Extract the balanced { … } block that starts at/after `from`.
function balancedBlock ( src , from ) {
let i = src . indexOf ( "{" , from ) , depth = 0 ;
const begin = i ;
for ( ; i < src . length ; i ++ ) {
if ( src [ i ] === "{" ) depth ++ ;
else if ( src [ i ] === "}" && -- depth === 0 ) return src . slice ( begin , i + 1 ) ;
}
return src . slice ( begin ) ;
}
// Parse a `static properties = { … }` block body into { name: {attribute,type,reflect} }.
function parseProps ( block ) {
const props = { } ;
// Each entry: `name: { … },` — scan key then its balanced object.
const re = /([A-Za-z_$][\w$]*)\s*:\s*\{/g ;
let m ;
while ( ( m = re . exec ( block ) ) ) {
const name = m [ 1 ] ;
const decl = balancedBlock ( block , m . index + m [ 0 ] . length - 1 ) ;
const attrM = /attribute\s*:\s*(false|"([^"]+)"|'([^']+)')/ . exec ( decl ) ;
const typeM = /type\s*:\s*([A-Za-z]+)/ . exec ( decl ) ;
const reflect = /reflect\s*:\s*true/ . test ( decl ) ;
let attribute ;
if ( attrM ) attribute = attrM [ 1 ] === "false" ? false : ( attrM [ 2 ] || attrM [ 3 ] ) ;
else attribute = name . toLowerCase ( ) ; // Lit default: lowercased property name
props [ name ] = { attribute , type : typeM ? typeM [ 1 ] : "String" , reflect } ;
re . lastIndex = m . index + m [ 0 ] . length ; // resume after the key (decl re-scanned harmlessly)
}
return props ;
}
// Find a class's own `static properties` (walks `extends` within the same file set).
function classProps ( name , classes ) {
const cls = classes [ name ] ;
if ( ! cls ) return { } ;
const own = cls . propsBlock ? parseProps ( cls . propsBlock ) : { } ;
const inherited = cls . extends && classes [ cls . extends ] ? classProps ( cls . extends , classes ) : { } ;
return { ... inherited , ... own } ;
}
2026-06-30 09:53:59 +02:00
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 || [ ] ) ] ) ;
}
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
export function parseLitElements ( repo ) {
const classes = { } ; // className → { propsBlock, extends, file }
const defines = [ ] ; // { tag, className, file }
for ( const file of ELEMENT _FILES ) {
const path = join ( repo , "src" , "elements" , file ) ;
if ( ! existsSync ( path ) ) continue ;
const src = readFileSync ( path , "utf8" ) ;
const classRe = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$]*)/g ;
let m ;
while ( ( m = classRe . exec ( src ) ) ) {
const [ className , base ] = [ m [ 1 ] , m [ 2 ] ] ;
const propsAt = src . indexOf ( "static properties" , m . index ) ;
// bound the search to before the next class declaration
classRe . lastIndex = m . index + m [ 0 ] . length ;
const nextClass = src . indexOf ( "class " , m . index + m [ 0 ] . length ) ;
const propsBlock = propsAt !== - 1 && ( nextClass === - 1 || propsAt < nextClass )
? balancedBlock ( src , propsAt ) : null ;
2026-06-30 09:53:59 +02:00
// 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 } ;
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
}
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 = { } ;
2026-06-30 09:53:59 +02:00
for ( const d of defines ) byTag [ d . tag ] = { ... d , props : classProps ( d . className , classes ) , slots : classSlots ( d . className , classes ) } ;
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
return byTag ;
}
// ---------- Drift: IR contract vs Lit element ----------
// Actionable drift gates `make adapt-lit` (exit 3). The rest is informational:
// • non-portable — React style/callbacks that inherently have no attribute form;
// the Lit element is correct to omit them. Surfaced (never dropped), never gated.
// • prop-extra — the Lit element is richer than the minimal React designbook;
// a divergence worth noting, but not a defect to block on.
const ACTIONABLE = new Set ( [ "prop-missing" , "attribute-mismatch" , "variant-axis-missing" , "tag-mismatch" ] ) ;
export function litAttrOf ( decl ) {
return decl . attribute === false ? null : decl . attribute ;
}
2026-06-30 09:53:59 +02:00
// 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" ;
}
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
return issues . some ( ( i ) => i . severity === "drift" ) ? "drift" : "ok" ;
}
// Find a likely renamed counterpart when no exact tag match (e.g. wn-pipeline-strip → wn-pipeline).
function likelyCounterpart ( tag , byTag ) {
if ( byTag [ tag ] ) return null ;
const head = tag . replace ( /^wn-/ , "" ) . split ( "-" ) [ 0 ] ; // "pipeline"
const hit = Object . keys ( byTag ) . find ( ( t ) => t . replace ( /^wn-/ , "" ) . split ( "-" ) [ 0 ] === head ) ;
return hit || null ;
}
2026-06-30 09:53:59 +02:00
export function componentDrift ( contract , byTag , accepted = { } ) {
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
const tag = contract . tag ;
const el = byTag [ tag ] ;
if ( ! el ) {
const alt = likelyCounterpart ( tag , 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). ` } ] ;
2026-06-30 09:53:59 +02:00
return { name : contract . name , status : classify ( issues , contract . name , accepted ) , tag , issues } ;
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
}
return { name : contract . name , status : "new" , tag , issues : [
{ kind : "no-counterpart" , detail : ` no < ${ tag } > in src/elements/ — stub generated. ` } ,
] } ;
}
const issues = [ ] ;
const litProps = el . props ;
2026-06-30 09:53:59 +02:00
const litSlots = el . slots || new Set ( ) ;
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
for ( const p of contract . props || [ ] ) {
if ( p . portable === false ) {
issues . push ( { kind : "non-portable" , prop : p . name ,
detail : ` type= ${ p . type } ; not attribute-mappable — handle explicitly, never drop. ` } ) ;
continue ;
}
if ( p . attribute === false ) continue ; // property-only by contract
const lit = litProps [ p . name ] ;
if ( ! lit ) {
2026-06-30 09:53:59 +02:00
// 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 ;
}
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
issues . push ( { kind : "prop-missing" , prop : p . name ,
detail : ` in IR (attribute ' ${ p . attribute } '), absent on < ${ tag } > ` } ) ;
continue ;
}
const litAttr = litAttrOf ( lit ) ;
if ( litAttr !== p . attribute ) {
issues . push ( { kind : "attribute-mismatch" , prop : p . name , expected : p . attribute , actual : litAttr === null ? "(property-only)" : litAttr } ) ;
}
}
// Variant axes must have a backing property.
for ( const v of contract . variants || [ ] ) {
if ( ! litProps [ v . axis ] ) issues . push ( { kind : "variant-axis-missing" , prop : v . axis ,
detail : ` IR variant axis ' ${ v . axis } ' ( ${ v . values . join ( "/" ) } ) has no Lit property. ` } ) ;
}
// Extra Lit properties not described by the IR contract.
const irNames = new Set ( ( contract . props || [ ] ) . map ( ( p ) => p . name ) ) ;
for ( const name of Object . keys ( litProps ) ) {
if ( ! irNames . has ( name ) ) issues . push ( { kind : "prop-extra" , prop : name ,
detail : ` on < ${ tag } > (attribute ' ${ litAttrOf ( litProps [ name ] ) ? ? "(property-only)" } '), not in IR contract. ` } ) ;
}
2026-06-30 09:53:59 +02:00
return { name : contract . name , status : classify ( issues , contract . name , accepted ) , tag , issues } ;
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
}
// ---------- Stub generation (write-once) ----------
export function renderStub ( contract ) {
const tag = contract . tag ;
const cls = "Wn" + contract . name ;
const props = ( contract . props || [ ] ) . filter ( ( p ) => p . portable !== false && p . attribute !== false ) ;
const litType = ( t ) => ( t === "boolean" ? "Boolean" : t === "number" ? "Number" : "String" ) ;
const propLines = props . map ( ( p ) => {
const attr = p . attribute !== p . name . toLowerCase ( ) ? ` , attribute: " ${ p . attribute } " ` : "" ;
return ` ${ p . name } : { type: ${ litType ( p . type ) } ${ attr } }, ` ;
} ) . join ( "\n" ) ;
const nonPortable = ( contract . props || [ ] ) . filter ( ( p ) => p . portable === false ) ;
const npNote = nonPortable . length
? ` \n // Non-portable props (handle explicitly, do not drop): ${ nonPortable . map ( ( p ) => p . name ) . join ( ", " ) } . `
: "" ;
const slotMarkup = ( contract . slots || [ ] ) . some ( ( s ) => s . name === "default" )
? "<slot></slot>" : "" ;
return ` // @generated STUB by adapters/lit (WHYNOT-WP-0002 T07) — from ir/components/ ${ contract . name } .json.
// Write-once scaffold: skeleton + typed reactive properties + a behaviour TODO.
// Move into src/elements/ and implement; the adapter never overwrites this once edited.
import { LitElement , html } from "lit" ;
export class $ { cls } extends LitElement {
createRenderRoot ( ) { return this ; } // light DOM, matches the existing wn-* elements${npNote}
static properties = {
$ { propLines || " // (no attribute-mappable props in the contract)" }
} ;
render ( ) {
// TODO(${contract.name}): implement per ir/exemplars/${contract.name}.html and the design language.
return html \ ` <div class=" ${ tag } " part="root"> ${ slotMarkup } </div> \` ;
}
}
// customElements.define("${tag}", ${cls}); // ← uncomment when integrating
` ;
}