`scripts/soma-attr-audit.ts` compiles every morfo and flags attrs a provider
hardcodes in its props getter that the morfo already declares with a resolvable
value (segmenting the provider by `runtime.part('<kebab>')` to map getter→part).
Measures the migration backlog and catches new drift.
Current backlog: 607 hits across 50 components (worst: words 49, dropdown/context
menus ~28, combobox/command/time-field ~24). The migrated spin-field family is
down to its legitimate residue — number-field 1, css-field 2 (formatted
aria-valuetext, parsed aria-valuenow — values the morfo can't express).
`soma-attr-audit.test.ts` pins each migrated component to its legit-override
ceiling so it can't regress to hardcoded attrs. Extend the ceiling map as more
components migrate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
parent
c44a259916
commit
9d11e8e4eb
@ -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('<kebab>', …)` 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 <kebab>]`
|
||||
*/
|
||||
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<Morfo[]> {
|
||||
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<string, unknown>
|
||||
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<string> {
|
||||
const compiled = compileMorfo(morfo)
|
||||
const part = compiled.parts.byKebab.get(partKebab) as
|
||||
| { staticAttrs?: Record<string, unknown>; dynamicAttrs?: { attr: string }[] }
|
||||
| undefined
|
||||
const set = new Set<string>()
|
||||
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<string, Hit[]>()
|
||||
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<string, string[]>()
|
||||
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()
|
||||
@ -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 <kebab>` to see the residue).
|
||||
* The full backlog for not-yet-migrated components lives in the script output.
|
||||
*/
|
||||
const MIGRATED_CEILINGS: Record<string, number> = {
|
||||
'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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue