You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/soma-attr-audit.ts

152 lines
6.0 KiB

/**
* 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 };
/** 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<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();

Powered by TurnKey Linux.