/** * Soma ↔ morfo attribute-duplication audit. * * "Morfo declares, soma executes." A provider should not re-declare in its * `props` getter an attr the morfo already declares with a resolvable value — * spread `runtimePart.renderProps()` instead. This script flags every such * duplication so the migration backlog is measurable and new drift is caught. * * For each morfo at `src/uix/morfo/components/*.ts`: * 1. compile it → per part, the set of attrs it declares WITH a value * (compiled staticAttrs + dynamicAttrs; no-value contract attrs like a * bare `data-disabled` are excluded — those are legitimately soma-owned). * 2. read the matching soma provider, split it into segments by each * `runtime.part('', …)` call, and scan each segment for hardcoded * attr literals (`role:` / `type:` / `'aria-*':` / `'data-*':`). * 3. report (component · part · attr) where the attr is BOTH hardcoded AND * morfo-declared. * * Some hits are legitimate value overrides (e.g. a locale-formatted * `aria-valuetext` over the morfo's raw value) — those are reviewed during the * rollout. The number trends to zero as providers adopt `renderProps()`. * * Usage: `npx tsx scripts/soma-attr-audit.ts [--component ]` */ import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { compileMorfo } from '../src/uix/morfo/compile'; import type { Morfo } from '../src/uix/morfo/types'; const MORFOS_DIR = 'src/uix/morfo/components'; const SOMA_DIR = 'src/uix/soma/components'; const ATTR_KEY = /^\s*'?(role|type|aria-[a-z-]+|data-[a-z-]+)'?\s*:/; const PART_CALL = /runtime\.part\(\s*'([a-z-]+)'/g; export async function loadMorfos(): Promise { const out: Morfo[] = []; for (const f of readdirSync(MORFOS_DIR).filter( (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') )) { const mod = (await import(pathToFileURL(join(MORFOS_DIR, f)).href)) as Record; for (const v of Object.values(mod)) { if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) out.push(v as Morfo); } } return out; } /** Attrs the compiled part declares with a resolvable value (excludes no-value contract attrs). */ function declaredAttrs(morfo: Morfo, partKebab: string): Set { const compiled = compileMorfo(morfo); const part = compiled.parts.byKebab.get(partKebab) as | { staticAttrs?: Record; dynamicAttrs?: { attr: string }[] } | undefined; const set = new Set(); if (!part) return set; for (const k of Object.keys(part.staticAttrs ?? {})) set.add(k); for (const d of part.dynamicAttrs ?? []) set.add(d.attr); return set; } /** Read every provider source file for a component (glob `*-provider.svelte.ts`). */ function providerSources(kebab: string): string { const dir = join(SOMA_DIR, kebab); if (!existsSync(dir)) return ''; return readdirSync(dir) .filter((f) => f.endsWith('-provider.svelte.ts')) .map((f) => readFileSync(join(dir, f), 'utf8')) .join('\n'); } type Hit = { component: string; part: string; attr: string }; /** Content of each `.assert({ … })` call in a segment, via brace matching. */ function assertBlocks(segment: string): string[] { const blocks: string[] = []; const re = /\.assert\(\{/g; let m: RegExpExecArray | null; while ((m = re.exec(segment))) { let depth = 1; let j = m.index + m[0].length; const startContent = j; while (j < segment.length && depth > 0) { const ch = segment[j]; if (ch === '{') depth++; else if (ch === '}') depth--; j++; } blocks.push(segment.slice(startContent, j - 1)); } return blocks; } export function auditComponent(morfo: Morfo): Hit[] { const src = providerSources(morfo.kebab); if (!src) return []; // Segment the source by `runtime.part('X')`: each segment's hardcoded attrs // belong to part X (the props getter sits between this call and the next). const calls = [...src.matchAll(PART_CALL)]; if (calls.length === 0) return []; const hits: Hit[] = []; for (let i = 0; i < calls.length; i++) { const partKebab = calls[i][1]; const start = calls[i].index ?? 0; const end = i + 1 < calls.length ? (calls[i + 1].index ?? src.length) : src.length; const segment = src.slice(start, end); const declared = declaredAttrs(morfo, partKebab); if (declared.size === 0) continue; // Scan ONLY inside `.assert({ … })` blocks — the real part props getters. // This excludes type annotations, per-part source registrations // (`props: { role: () => … }`), and non-part helper getters (a hidden // `inputProps` with its own `type`) that would otherwise false-positive. for (const block of assertBlocks(segment)) { for (const line of block.split('\n')) { const m = ATTR_KEY.exec(line); if (m && declared.has(m[1])) hits.push({ component: morfo.kebab, part: partKebab, attr: m[1] }); } } } return hits; } async function main() { const onlyArg = process.argv.indexOf('--component'); const only = onlyArg >= 0 ? process.argv[onlyArg + 1] : undefined; const morfos = (await loadMorfos()).filter((m) => !only || m.kebab === only); const byComponent = new Map(); for (const m of morfos) { const hits = auditComponent(m); if (hits.length) byComponent.set(m.kebab, hits); } const total = [...byComponent.values()].reduce((n, h) => n + h.length, 0); const sorted = [...byComponent.entries()].sort((a, b) => b[1].length - a[1].length); console.log( `\nSoma↔morfo attr duplication — ${total} hits across ${byComponent.size} components\n` ); for (const [component, hits] of sorted) { const byPart = new Map(); for (const h of hits) (byPart.get(h.part) ?? byPart.set(h.part, []).get(h.part)!).push(h.attr); console.log(` ${component} (${hits.length})`); for (const [part, attrs] of byPart) console.log(` ${part}: ${attrs.join(', ')}`); } console.log(''); } // Run only when invoked directly (`tsx scripts/soma-attr-audit.ts`), not on import. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();