diff --git a/scripts/soma-attr-audit.ts b/scripts/soma-attr-audit.ts new file mode 100644 index 000000000..80be47e9d --- /dev/null +++ b/scripts/soma-attr-audit.ts @@ -0,0 +1,120 @@ +/** + * 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 } + +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 + for (const line of segment.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() diff --git a/src/uix/soma/soma-attr-audit.test.ts b/src/uix/soma/soma-attr-audit.test.ts new file mode 100644 index 000000000..c8c7761a3 --- /dev/null +++ b/src/uix/soma/soma-attr-audit.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; + +import { loadMorfos, auditComponent } from '../../../scripts/soma-attr-audit'; + +/** + * Regression guard for the morfo↔soma attr de-duplication migration. + * + * A migrated provider sources its role/aria/data from `runtimePart.renderProps()` + * and must not re-declare them. The only hits that may remain are LEGITIMATE + * value overrides the morfo can't express (a locale-formatted `aria-valuetext`, + * a parsed `aria-valuenow`). This test pins each migrated component to its + * legit-override ceiling so it can't silently drift back to hardcoded attrs. + * + * As a component is migrated, add it here with its ceiling (run + * `npx tsx scripts/soma-attr-audit.ts --component ` to see the residue). + * The full backlog for not-yet-migrated components lives in the script output. + */ +const MIGRATED_CEILINGS: Record = { + 'number-field': 1, // input: aria-valuetext (formatted) + 'css-field': 2 // input: aria-valuenow (parsed) + aria-valuetext +}; + +describe('soma↔morfo attr de-dup guard', () => { + it('migrated providers re-declare no morfo attrs beyond their legit value overrides', async () => { + const morfos = await loadMorfos(); + const violations: string[] = []; + + for (const [kebab, ceiling] of Object.entries(MIGRATED_CEILINGS)) { + const morfo = morfos.find((m) => m.kebab === kebab); + if (!morfo) { + violations.push(`${kebab}: morfo not found`); + continue; + } + const hits = auditComponent(morfo); + if (hits.length > ceiling) { + violations.push( + `${kebab}: ${hits.length} hits (ceiling ${ceiling}) — ` + + hits.map((h) => `${h.part}.${h.attr}`).join(', ') + ); + } + } + + expect(violations).toEqual([]); + }); +});