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.
1901 lines
77 KiB
1901 lines
77 KiB
/**
|
|
* Component completion audit.
|
|
*
|
|
* Walks every UIX component (morfo + eidos wrapper + recipe CSS + demo)
|
|
* and scores it against the rules in
|
|
* `docs/guides/completion-checklist.md`.
|
|
*
|
|
* Output: `tmp/component-audit.md` — markdown report with summary table
|
|
* + per-component findings.
|
|
*
|
|
* Usage:
|
|
* node --import tsx/esm scripts/component-audit.ts
|
|
* node --import tsx/esm scripts/component-audit.ts --only rating-group,calendar
|
|
* node --import tsx/esm scripts/component-audit.ts --severity error
|
|
*
|
|
* Approach: text/regex parsing of source files. Doesn't dynamically import
|
|
* morfos (avoids full TS resolution + circular dep risk). The morfo files
|
|
* are declarative `as const satisfies Morfo` exports, so regex catches
|
|
* the structural facts we need. Cross-validation against canonical
|
|
* vocabularies via static imports of `INTENTS`, `SEMA_FAMILIES`,
|
|
* `SEMA_VERBS`, `ARCHETYPE_VOCABULARY`.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { INTENTS } from '../src/uix/intent.ts';
|
|
import { SEMA_FAMILIES } from '../src/uix/sema/event.ts';
|
|
import { SEMA_VERBS } from '../src/uix/sema/verbs.ts';
|
|
import { ARCHETYPE_VOCABULARY } from '../src/uix/morfo/types.ts';
|
|
import {
|
|
censusAudit,
|
|
names as censusNames,
|
|
type CensusRow,
|
|
type NameFinding
|
|
} from './theming-census.ts';
|
|
|
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO = join(__dirname, '..');
|
|
const MORFO_DIR = join(REPO, 'src/uix/morfo/components');
|
|
const EIDOS_DIR = join(REPO, 'src/uix/eidos/components');
|
|
const DEMO_DIR = join(REPO, 'web/routes/uix/components');
|
|
const OUT = join(REPO, 'tmp/component-audit.md');
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
type Severity = 'error' | 'warn' | 'info';
|
|
|
|
interface CheckResult {
|
|
rule: string;
|
|
severity: Severity;
|
|
passed: boolean;
|
|
detail?: string;
|
|
}
|
|
|
|
interface ComponentReport {
|
|
component: string;
|
|
kebab: string;
|
|
exists: { morfo: boolean; eidos: boolean; demo: boolean };
|
|
interactive: boolean;
|
|
passive: boolean;
|
|
checks: CheckResult[];
|
|
}
|
|
|
|
// ─── CLI flags ──────────────────────────────────────────────────────────────
|
|
|
|
const args = process.argv.slice(2);
|
|
const onlyArg = args.find((a) => a.startsWith('--only'));
|
|
const onlyList = onlyArg
|
|
? (onlyArg.includes('=') ? onlyArg.split('=')[1] : args[args.indexOf(onlyArg) + 1])
|
|
?.split(',')
|
|
.map((s) => s.trim())
|
|
: undefined;
|
|
|
|
const severityArg = args.find((a) => a.startsWith('--severity'));
|
|
const minSeverity = (
|
|
severityArg
|
|
? severityArg.includes('=')
|
|
? severityArg.split('=')[1]
|
|
: args[args.indexOf(severityArg) + 1]
|
|
: undefined
|
|
) as Severity | undefined;
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function tryRead(path: string): string | null {
|
|
try {
|
|
return readFileSync(path, 'utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function pass(rule: string, severity: Severity, detail?: string): CheckResult {
|
|
return { rule, severity, passed: true, ...(detail ? { detail } : {}) };
|
|
}
|
|
|
|
function fail(rule: string, severity: Severity, detail: string): CheckResult {
|
|
return { rule, severity, passed: false, detail };
|
|
}
|
|
|
|
// Match `name: '...'` or `name: "..."` inside morfo event blocks.
|
|
const EVENT_NAME_RE = /name:\s*['"]([\w-]+)['"]/g;
|
|
// Match family field
|
|
const EVENT_FAMILY_RE = /family:\s*['"]([\w-]+)['"]/g;
|
|
// Match verb field
|
|
const EVENT_VERB_RE = /verb:\s*['"]([\w-]+)['"]/g;
|
|
// Sequence field
|
|
const EVENT_SEQUENCE_RE = /sequence:\s*['"](pre|coincident|post)['"]/g;
|
|
// Part kebab
|
|
const PART_KEBAB_RE = /kebab:\s*['"]([\w-]+)['"]/g;
|
|
// Archetype
|
|
const PART_ARCH_RE = /archetype:\s*['"]([\w-]+)['"]/g;
|
|
// keyboard keys
|
|
const KEYBOARD_KEY_RE = /key:\s*['"]([^'"]+)['"]/g;
|
|
|
|
const VALENCED_FAMILIES = new Set(['commit', 'signal']);
|
|
const ALL_VERBS = new Set<string>(
|
|
Object.values(SEMA_VERBS as Record<string, readonly string[]>).flat()
|
|
);
|
|
const VERBS_BY_FAMILY = SEMA_VERBS as Record<string, readonly string[]>;
|
|
const ARCHETYPE_SET = new Set(ARCHETYPE_VOCABULARY);
|
|
const INTENT_SET = new Set(INTENTS);
|
|
|
|
// ─── Declared --color-* tokens (loaded once for R-2.6) ──────────────────────
|
|
//
|
|
// R-2.6 norm: any `var(--color-X)` referenced by a component CSS, recipe
|
|
// or eidos foundation must have X declared in `generated/base.css`.
|
|
// The set is the closed contract emitted by `appendThemeColorDeclarations`
|
|
// + recipe-token generators in `src/uix/eidos/lib/render-css.ts`.
|
|
const GENERATED_CSS = join(REPO, 'src/uix/eidos/generated/base.css');
|
|
const DECLARED_COLOR_TOKENS: Set<string> = (() => {
|
|
const set = new Set<string>();
|
|
const src = tryRead(GENERATED_CSS);
|
|
if (!src) return set;
|
|
for (const m of src.matchAll(/\s--color-([a-z0-9-]+):/g)) set.add(m[1]);
|
|
return set;
|
|
})();
|
|
|
|
// ─── Recipe Contract shared state (R-4.x) ───────────────────────────────────
|
|
//
|
|
// R-4.x rules enforce `src/uix/eidos/RECIPE_CONTRACT.md` — the transversal
|
|
// theming systems every recipe must consume (state-layer, tokenized elevation,
|
|
// opacity token, logical axes, motion channel) instead of hand-rolling its own
|
|
// idiom. ALL R-4.x graduated to `error`: R-4.1/4.2/4.3/4.4/4.6 after the
|
|
// 2026-07-02 mechanical backfill, R-4.5 after the motion migration emptied
|
|
// its 15-component backlog (every recipe now consumes the channel's presets,
|
|
// signatures or registered keyframes — see RECIPE_CONTRACT.md §2).
|
|
|
|
// Active parallel dev tracks — UNFINISHED components deliberately outside the
|
|
// completion machinery (same exclusion recipe-css-contract.test.ts uses).
|
|
// Excluded from the audited catalog + verdicts so WIP noise doesn't read as
|
|
// catalog breakage; auditable on demand via an explicit `--only words`.
|
|
const WIP_TRACKS = new Set(['words', 'palabras', 'chronos']);
|
|
|
|
// R-4.4 — physical-axis token keys declared in `lib/recipes/base.ts`,
|
|
// attributed to their component block. Logical axes (`padding-inline` /
|
|
// `padding-block`) are the canon; physical axes break RTL. Covers BOTH the
|
|
// spelled-out grammar (`padding-x-*` / `margin-y` / `inset-x`) AND the
|
|
// abbreviated segments (`px` / `py` / `mx` / `my` as full key segments —
|
|
// e.g. `'trigger-px-sm'`), which the original regex missed: 198 abbreviated
|
|
// keys evaded it until the 2026-07-06 normalization codemod emptied them
|
|
// (user decision — one vocabulary, excluded tracks included). Segment
|
|
// anchoring keeps legal keys like `padding-xs` (an axis-less size step) out.
|
|
// Attribution: top-level component keys in the recipe set sit at one-tab
|
|
// indentation (`\taccordion: {`); every axis-token line is charged to the last
|
|
// component key seen above it.
|
|
const RECIPES_BASE_PATH = join(REPO, 'src/uix/eidos/lib/recipes/base.ts');
|
|
const PHYSICAL_AXIS_TOKEN_RE =
|
|
/'(?:[a-z0-9-]*(?:padding|margin|inset)-(?:x|y)(?:-[a-z0-9-]+)?|(?:[a-z0-9-]+-)?(?:p|m)(?:x|y)(?:-[a-z0-9-]+)?)'\s*:/;
|
|
const PHYSICAL_AXIS_TOKENS: Map<string, string[]> = (() => {
|
|
const map = new Map<string, string[]>();
|
|
const src = tryRead(RECIPES_BASE_PATH);
|
|
if (!src) return map;
|
|
let component: string | null = null;
|
|
for (const line of src.split(/\r?\n/)) {
|
|
const key = line.match(/^\t'?([a-z][a-z0-9-]*)'?:\s*\{\s*$/);
|
|
if (key) {
|
|
component = key[1];
|
|
continue;
|
|
}
|
|
if (!component) continue;
|
|
const hit = line.match(PHYSICAL_AXIS_TOKEN_RE);
|
|
if (hit) {
|
|
const token = hit[0].replace(/['\s:]/g, '');
|
|
const list = map.get(component) ?? [];
|
|
list.push(token);
|
|
map.set(component, list);
|
|
}
|
|
}
|
|
return map;
|
|
})();
|
|
|
|
// R-5.3 — la GRAMÁTICA de los nombres de token (D-TH.6, firmada 2026-08-20).
|
|
// A diferencia del guard de eventos, que valida pertenencia a un vocabulario
|
|
// cerrado, éste valida una FORMA: slot de tinta `fg`, modificador interactivo
|
|
// DELANTE, y lo dimensional/contextual detrás. La gramática NO se reimplementa
|
|
// aquí: se consume del censo (`theming-census --names`), que es la misma
|
|
// fuente que ejecutó el codemod — dos implementaciones de una gramática son
|
|
// dos gramáticas que acaban discrepando.
|
|
//
|
|
// Las claves en cola de migración a la capa de estado (§38) se reportan
|
|
// APARTE: no son deuda de nombre, son knobs que van a desaparecer, y
|
|
// renombrarlos sería churn sobre lo condenado.
|
|
const NAME_FINDINGS: Map<string, { deviated: NameFinding[]; stateLayer: NameFinding[] }> =
|
|
await (async () => {
|
|
const map = new Map<string, { deviated: NameFinding[]; stateLayer: NameFinding[] }>();
|
|
let findings: NameFinding[] = [];
|
|
try {
|
|
findings = await censusNames();
|
|
} catch {
|
|
return map;
|
|
}
|
|
for (const f of findings) {
|
|
const entry = map.get(f.component) ?? { deviated: [], stateLayer: [] };
|
|
if (f.klass === 'ink' || f.klass === 'modifier' || f.klass === 'both') entry.deviated.push(f);
|
|
else if (f.klass === 'state-layer') entry.stateLayer.push(f);
|
|
map.set(f.component, entry);
|
|
}
|
|
return map;
|
|
})();
|
|
|
|
// R-5.1 / R-5.2 — theme REACH, checked against the DEBT LEDGER (signed
|
|
// 2026-08-25). As with R-5.3, the classification is NOT reimplemented here: it
|
|
// is consumed from the census (`theming-census`), the same source that
|
|
// generated the baseline. Two implementations of one measure are two measures.
|
|
//
|
|
// The ledger (`scripts/theming-census-debt.ts`) registers every declaration
|
|
// that does not reach a theme, one per key (1154 at the 2026-08-26 baseline:
|
|
// global + literal + non-derived private, over 76 components). It is NOT an
|
|
// exceptions file — an exception asserts «this is fine» and every entry
|
|
// asserts the opposite —: it is registered debt, the written act that lets
|
|
// R-5.1 be `error` without declaring the indebted recipes broken. The full
|
|
// doctrine lives in that file's header.
|
|
//
|
|
// Precedence between the two acts, spelled out because it is not obvious:
|
|
// · the README valve `R-5.1 exception:` / `R-5.2 exception:` stays INTACT
|
|
// and is honoured like on every R-x rule of this file — it is a
|
|
// component-level signature and this row respects it;
|
|
// · the catalogue floor (`theming-reach-floor.test.ts`) reads NO READMEs: it
|
|
// measures against the ledger alone. A valve can soften the ROW without
|
|
// blinding the RATCHET, which is the one thing that must not be lost;
|
|
// · no valve excuses a STALE: it is not a deviation to sign, it is wrong
|
|
// bookkeeping — the entry claims a knob is broken that is already fixed.
|
|
const CENSUS_STATE = (() => {
|
|
try {
|
|
return censusAudit();
|
|
} catch (err) {
|
|
// A census that fails to boot is the census's problem, not this audit's:
|
|
// the two rows are skipped instead of accusing 162 components. But a
|
|
// skipped guard must never be a SILENT one (the prepareWith lesson): say
|
|
// so loudly — the floor test, which calls censusAudit() directly, stays
|
|
// red meanwhile as the mechanical backstop.
|
|
console.warn(
|
|
`[component-audit] censusAudit() failed — R-5.1/R-5.2 SKIPPED for every component: ${
|
|
String((err as Error)?.message ?? err).split('\n')[0]
|
|
}`
|
|
);
|
|
return null;
|
|
}
|
|
})();
|
|
const CENSUS_ROWS = new Map<string, CensusRow>(
|
|
(CENSUS_STATE?.rows ?? []).map((r) => [r.component, r])
|
|
);
|
|
const DEBT_BY_COMPONENT = CENSUS_STATE?.debt.byComponent ?? new Map();
|
|
|
|
// ─── Component discovery ────────────────────────────────────────────────────
|
|
|
|
function listComponents(): { kebab: string; morfoPath: string }[] {
|
|
const entries = readdirSync(MORFO_DIR);
|
|
return entries
|
|
.filter((e) => e.endsWith('.ts') && !e.endsWith('.test.ts'))
|
|
.map((e) => ({ kebab: e.replace(/\.ts$/, ''), morfoPath: join(MORFO_DIR, e) }))
|
|
.filter((c) => (onlyList ? onlyList.includes(c.kebab) : !WIP_TRACKS.has(c.kebab)))
|
|
.sort((a, b) => a.kebab.localeCompare(b.kebab));
|
|
}
|
|
|
|
// ─── Checks ─────────────────────────────────────────────────────────────────
|
|
|
|
function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
|
|
// A-1.1: Morfo const export
|
|
const morfoVarMatch = src.match(/export const (\w+Morfo)\b/);
|
|
if (!morfoVarMatch) {
|
|
out.push(fail('A-1.1', 'error', 'No `export const {Name}Morfo` found'));
|
|
return out;
|
|
}
|
|
out.push(pass('A-1.1', 'error'));
|
|
|
|
// A-1.2: name + kebab + scope
|
|
const hasName = /\bname:\s*['"][\w-]+['"]/.test(src);
|
|
const hasKebab = /\bkebab:\s*['"][\w-]+['"]/.test(src);
|
|
const hasScope = /\bscope:\s*\[/.test(src);
|
|
if (hasName && hasKebab && hasScope) out.push(pass('A-1.2', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-1.2',
|
|
'error',
|
|
`Missing: ${[!hasName && 'name', !hasKebab && 'kebab', !hasScope && 'scope'].filter(Boolean).join(', ')}`
|
|
)
|
|
);
|
|
|
|
// A-1.3: morfo.texts.label is a valid component-scoped idlangref.
|
|
// Old `translations: { label: { es, en } }` is illegal — caught by tsc, but
|
|
// flag it here too for explicit migration error messaging.
|
|
const hasLegacyTranslations = /^\ttranslations:\s*\{/m.test(src);
|
|
if (hasLegacyTranslations) {
|
|
out.push(
|
|
fail(
|
|
'A-1.3',
|
|
'error',
|
|
'Legacy `translations:` field present. Migrate to `texts:` idlangrefs (see translations:check).'
|
|
)
|
|
);
|
|
} else {
|
|
const labelRefMatch = src.match(/^\t\ttexts:[\s\S]*?\blabel:\s*['"`](#\?[^'"`]+)['"`]/m);
|
|
// Use compact form when texts block exists; the indentation is one tab
|
|
// deeper than `\t`.
|
|
const compactLabel = src.match(/^\ttexts:\s*\{[\s\S]*?\blabel:\s*['"`](#\?[^'"`]+)['"`]/m);
|
|
const hit = labelRefMatch ?? compactLabel;
|
|
if (hit) {
|
|
const ref = hit[1];
|
|
const validShape = /^#\?(?:components\.[a-z][a-z0-9-]*|common)\..+\|.+$/.test(ref);
|
|
if (validShape) out.push(pass('A-1.3', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-1.3',
|
|
'error',
|
|
`texts.label idlangref malformed: ${ref}. Expected '#?components.{kebab}.label|Fallback' or '#?common.…|Fallback'.`
|
|
)
|
|
);
|
|
} else {
|
|
// Pure visual primitives (scope=['eidos'] only, no events, no
|
|
// interactive surface) don't own text slots — the label lives on
|
|
// the consumer (e.g. the button hosting the icon). Skip A-1.3 for
|
|
// them.
|
|
const scopeMatch = src.match(/scope:\s*\[([^\]]+)\]/);
|
|
const scopes = scopeMatch
|
|
? scopeMatch[1].split(',').map((s) => s.trim().replace(/['"]/g, ''))
|
|
: [];
|
|
const isEidosOnlyLeaf =
|
|
scopes.length === 1 && scopes[0] === 'eidos' && !/events:\s*\[/.test(src);
|
|
if (isEidosOnlyLeaf) {
|
|
out.push(pass('A-1.3', 'error', 'eidos-only passive primitive — no text slot'));
|
|
} else {
|
|
out.push(
|
|
fail(
|
|
'A-1.3',
|
|
'error',
|
|
'No `texts.label` declared. Add `texts: { label: \"#?components.{kebab}.label|Fallback\" }` and ship the entry in src/uix/langs/components/{kebab}.ts.'
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A-1.4: apg for interactive components — a pattern URL, or the explicit
|
|
// `'none — {rationale}'` form for widgets the APG doesn't cover
|
|
// (checkpoint C5, 2026-07-07). The morfo carries the decision; README
|
|
// exceptions are no longer honored for this rule — absence always flags.
|
|
const apgValue = src.match(/\bapg:\s*['"]([^'"]+)['"]/)?.[1];
|
|
if (info.interactive) {
|
|
if (apgValue && /^https?:/.test(apgValue)) out.push(pass('A-1.4', 'warn'));
|
|
else if (apgValue && /^none\s*—\s*\S/.test(apgValue))
|
|
out.push(pass('A-1.4', 'warn', `no official pattern: ${apgValue}`));
|
|
else if (apgValue)
|
|
out.push(
|
|
fail('A-1.4', 'warn', `apg malformed: '${apgValue}' — use a URL or 'none — {rationale}'`)
|
|
);
|
|
else
|
|
out.push(
|
|
fail('A-1.4', 'warn', "No apg declared — cite the pattern URL or 'none — {rationale}'")
|
|
);
|
|
}
|
|
|
|
// A-2.1: at least one Provider part — kebab='provider'. Archetype can vary
|
|
// (icon uses 'image', viewport uses 'provider', etc.) so don't enforce that.
|
|
const hasProvider = /kebab:\s*['"]provider['"]/m.test(src);
|
|
if (hasProvider) out.push(pass('A-2.1', 'error'));
|
|
else out.push(fail('A-2.1', 'error', 'No part with kebab: "provider" found'));
|
|
|
|
// A-2.5: archetypes ∈ ARCHETYPE_VOCABULARY
|
|
const archetypes = [...src.matchAll(PART_ARCH_RE)].map((m) => m[1]);
|
|
const invalidArchs = archetypes.filter((a) => !ARCHETYPE_SET.has(a as never));
|
|
if (invalidArchs.length === 0) out.push(pass('A-2.5', 'error'));
|
|
else
|
|
out.push(
|
|
fail('A-2.5', 'error', `Invented archetypes: ${[...new Set(invalidArchs)].join(', ')}`)
|
|
);
|
|
|
|
// A-3.1: events.length >= 1 if interactive — only count EVENT blocks, not
|
|
// part names. `expression: 'delegated'` exonerates: a composite whose
|
|
// perceptual events live in its children's contracts (checkpoint S11b,
|
|
// 2026-07-07; participation doctrine in docs/architecture/sema.md).
|
|
const eventBlocks = extractEventBlocks(src);
|
|
const eventNames = eventBlocks
|
|
.map((b) => b.match(/name:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((n): n is string => Boolean(n));
|
|
const declaredEvents = eventBlocks.length;
|
|
const expression = src.match(/\bexpression:\s*['"]([a-z-]+)['"]/)?.[1];
|
|
// Scope gate: events fire through the SOMA runtime — a `scope: ['eidos']`
|
|
// morfo architecturally cannot declare functional events, so A-3.1 only
|
|
// applies when 'soma' is in scope (badge/qr-code remove buttons are plain
|
|
// DOM affordances; growing them a soma layer is a separate decision).
|
|
const hasSomaScope = /\bscope:\s*\[[^\]]*['"]soma['"]/.test(src);
|
|
if (info.interactive && declaredEvents === 0 && hasSomaScope) {
|
|
if (expression === 'delegated') {
|
|
out.push(pass('A-3.1', 'error', "0 events — delegated composite (expression: 'delegated')"));
|
|
} else {
|
|
out.push(
|
|
fail(
|
|
'A-3.1',
|
|
'error',
|
|
"Interactive component has 0 events (declare them, or `expression: 'delegated'` when a composed child owns them)"
|
|
)
|
|
);
|
|
}
|
|
} else if (info.interactive && declaredEvents === 0) {
|
|
out.push(
|
|
pass('A-3.1', 'error', 'eidos-native (no soma runtime — functional events not possible)')
|
|
);
|
|
} else {
|
|
out.push(pass('A-3.1', 'error', `${declaredEvents} events declared`));
|
|
}
|
|
|
|
// A-3.3 + A-3.4: family + verb canonical — only inside event blocks
|
|
const families = eventBlocks
|
|
.map((b) => b.match(/family:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((f): f is string => Boolean(f));
|
|
const verbs = eventBlocks
|
|
.map((b) => b.match(/verb:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((v): v is string => Boolean(v));
|
|
const invalidFamilies = families.filter((f) => !SEMA_FAMILIES.includes(f as never));
|
|
if (invalidFamilies.length === 0) out.push(pass('A-3.3', 'error'));
|
|
else
|
|
out.push(
|
|
fail('A-3.3', 'error', `Invented families: ${[...new Set(invalidFamilies)].join(', ')}`)
|
|
);
|
|
|
|
const invalidVerbs = verbs.filter((v) => !ALL_VERBS.has(v));
|
|
if (invalidVerbs.length === 0) out.push(pass('A-3.4', 'error'));
|
|
else
|
|
out.push(
|
|
fail('A-3.4', 'error', `Verbs not in SEMA_VERBS: ${[...new Set(invalidVerbs)].join(', ')}`)
|
|
);
|
|
|
|
// Cross-family verb integrity (verb must belong to declared family in same event)
|
|
const familyVerbMismatches: string[] = [];
|
|
for (const block of eventBlocks) {
|
|
const fm = block.match(/family:\s*['"]([\w-]+)['"]/)?.[1];
|
|
const vm = block.match(/verb:\s*['"]([\w-]+)['"]/)?.[1];
|
|
if (fm && vm && VERBS_BY_FAMILY[fm] && !VERBS_BY_FAMILY[fm].includes(vm)) {
|
|
familyVerbMismatches.push(`${fm}.${vm}`);
|
|
}
|
|
}
|
|
if (familyVerbMismatches.length === 0) out.push(pass('A-3.4b', 'warn'));
|
|
else out.push(fail('A-3.4b', 'warn', `Verb/family mismatch: ${familyVerbMismatches.join(', ')}`));
|
|
|
|
// A-3.5: sequence declared — only inside event blocks
|
|
const sequences = eventBlocks
|
|
.map((b) => b.match(/sequence:\s*['"](pre|coincident|post)['"]/)?.[1])
|
|
.filter((s): s is string => Boolean(s));
|
|
if (info.interactive && declaredEvents > 0) {
|
|
if (sequences.length === declaredEvents) {
|
|
out.push(pass('A-3.5', 'warn'));
|
|
} else {
|
|
out.push(
|
|
fail(
|
|
'A-3.5',
|
|
'warn',
|
|
`${declaredEvents} events, ${sequences.length} sequences declared (some events missing sequence)`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// A-3.6: event name follows `{verb}-{x}`, `{family}-{verb}`, OR is a
|
|
// bare canonical verb on its own (e.g. `present`, `open`, `close`).
|
|
// Bare verbs are valid when the event has no variant to disambiguate.
|
|
const badNames = eventNames.filter((n) => {
|
|
if (!n.includes('-')) {
|
|
// Accept bare canonical verbs and bare family names.
|
|
return !ALL_VERBS.has(n) && !SEMA_FAMILIES.includes(n as never);
|
|
}
|
|
const [head] = n.split('-');
|
|
return !ALL_VERBS.has(head) && !SEMA_FAMILIES.includes(head as never);
|
|
});
|
|
if (badNames.length === 0) out.push(pass('A-3.6', 'warn'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-3.6',
|
|
'warn',
|
|
`Event names not matching {verb}-X or {family}-X: ${badNames.join(', ')}`
|
|
)
|
|
);
|
|
|
|
// A-3.7: event/keyboard coverage heuristic.
|
|
//
|
|
// Per the COMPONENT_GUIDE: "every distinct keyboard action that mutates
|
|
// state has a corresponding semantic event. Pure focus moves don't need
|
|
// an event." So we COUNT only the actions that mutate state — arrow
|
|
// navigation, page/month/year shifts, home/end inside a grid are pure
|
|
// focus moves and must not inflate the ratio.
|
|
const allKeyboardActions = [...src.matchAll(/action:\s*['"]([\w-]+)['"]/g)].map((m) => m[1]);
|
|
const focusMoveActions = new Set([
|
|
// Calendar / date grid navigation
|
|
'next-day',
|
|
'prev-day',
|
|
'next-week',
|
|
'prev-week',
|
|
'next-month',
|
|
'prev-month',
|
|
'next-year',
|
|
'prev-year',
|
|
'first-day-of-week',
|
|
'last-day-of-week',
|
|
// Month-grid / year-grid intra-grid navigation. Same semantics as
|
|
// next-day / first-day-of-week in a Calendar — these move focus
|
|
// inside the grid, they don't mutate the selected value.
|
|
'next-row',
|
|
'prev-row',
|
|
'first-month',
|
|
'last-month',
|
|
'first-year',
|
|
'last-year',
|
|
'next-page',
|
|
'prev-page',
|
|
// Generic listbox / menu / tablist navigation
|
|
'next-item',
|
|
'prev-item',
|
|
'first-item',
|
|
'last-item',
|
|
'next-tab',
|
|
'prev-tab',
|
|
'first-tab',
|
|
'last-tab',
|
|
// Generic focus moves
|
|
'focus-next',
|
|
'focus-prev',
|
|
'focus-first',
|
|
'focus-last',
|
|
'focus-up',
|
|
'focus-down',
|
|
'focus-left',
|
|
'focus-right',
|
|
// Segment / character navigation (date-field, time-field, color-field,
|
|
// number-field, pin-input). These are focus moves within the input,
|
|
// not value mutations.
|
|
'next-segment',
|
|
'prev-segment',
|
|
'first-segment',
|
|
'last-segment',
|
|
'next-char',
|
|
'prev-char',
|
|
// Drag / drop sub-actions handled by a single commit event family.
|
|
'pick-up',
|
|
'drop-here',
|
|
'drop-cancel',
|
|
'move-up',
|
|
'move-down',
|
|
'move-left',
|
|
'move-right',
|
|
// Generic next/prev navigation (drag-drop reorder, generic lists).
|
|
'next',
|
|
'prev',
|
|
// Grid / tree row+cell navigation.
|
|
'next-row',
|
|
'prev-row',
|
|
'next-cell',
|
|
'prev-cell',
|
|
'first-row',
|
|
'last-row',
|
|
'first-cell',
|
|
'last-cell',
|
|
'page-up',
|
|
'page-down',
|
|
// Value-update keys collapsed into a single commit event (slider /
|
|
// number-field / rating-group / splitter all emit a single
|
|
// `commit-set` / `commit-resize` regardless of which arrow or
|
|
// PageUp/Down the user pressed).
|
|
'increment',
|
|
'decrement',
|
|
'increment-large',
|
|
'decrement-large',
|
|
'resize',
|
|
'minimize',
|
|
'maximize',
|
|
// Drag-drop activate/cancel are covered by `commit-drag-start` /
|
|
// `commit-drop` / `commit-cancel` declared on the morfo.
|
|
'activate',
|
|
'cancel',
|
|
// Popover/dialog open/close delegated to the containing overlay layer:
|
|
// the surrounding popover / dialog morfo already declares the
|
|
// emerge events. A combobox / select / menu that hosts Escape →
|
|
// close shouldn't be flagged for under-declaration when the
|
|
// overlay layer owns the sema event.
|
|
'open',
|
|
'close',
|
|
'dismiss',
|
|
'toggle'
|
|
]);
|
|
// DISTINCT mutating actions — the rule's own stated semantics ("Enter and
|
|
// Space both map to the same `select` action"): one action bound to many
|
|
// keys is ONE mutation to cover, not N. float-panel's `kb-move` rides 13
|
|
// keys yet maps to a single handle gesture — counting occurrences flagged
|
|
// it as under-declared while the wiring was complete.
|
|
const mutatingKeyboardActions = new Set(
|
|
allKeyboardActions.filter((a) => !focusMoveActions.has(a))
|
|
);
|
|
const keys = [...src.matchAll(KEYBOARD_KEY_RE)].length;
|
|
const mutatingKeyCount = mutatingKeyboardActions.size;
|
|
if (info.interactive) {
|
|
// Only count MUTATING actions toward the ratio. A calendar with 10
|
|
// keys / 2 mutating (Enter, Space) / 1 commit-select event passes
|
|
// because Enter and Space both map to the same `select` action.
|
|
if (mutatingKeyCount >= 4 && declaredEvents > 0 && mutatingKeyCount >= declaredEvents * 2) {
|
|
out.push(
|
|
fail(
|
|
'A-3.7',
|
|
'error',
|
|
`${mutatingKeyCount} mutating keyboard actions but only ${declaredEvents} semantic events — likely under-declared (total keys ${keys})`
|
|
)
|
|
);
|
|
} else {
|
|
out.push(
|
|
pass(
|
|
'A-3.7',
|
|
'error',
|
|
`${keys} keys (${mutatingKeyCount} mutating) / ${declaredEvents} events`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// A-3.10: intent valenced families — only inside event blocks
|
|
const intentMatches = eventBlocks
|
|
.map((b) => b.match(/intent:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((i): i is string => Boolean(i));
|
|
const invalidIntents = intentMatches.filter((i) => !INTENT_SET.has(i as never));
|
|
if (invalidIntents.length === 0) out.push(pass('A-3.10', 'warn'));
|
|
else
|
|
out.push(fail('A-3.10', 'warn', `Invalid intents: ${[...new Set(invalidIntents)].join(', ')}`));
|
|
|
|
// A-4.4: keyboard key normalization
|
|
const allKeys = [...src.matchAll(KEYBOARD_KEY_RE)].map((m) => m[1]);
|
|
const badKeys = allKeys.filter((k) => /^(Spacebar|Esc|Del|Ins|PgUp|PgDn)$/.test(k));
|
|
if (badKeys.length === 0) out.push(pass('A-4.4', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-4.4',
|
|
'error',
|
|
`Non-standard key values: ${[...new Set(badKeys)].join(', ')}. Use KeyboardEvent.key names: ' '/Escape/Delete/...`
|
|
)
|
|
);
|
|
|
|
return out;
|
|
}
|
|
|
|
function countEventBlocks(src: string): number {
|
|
// Count top-level entries in events: [...] array. We use the named matches —
|
|
// each event has a `name:` field, so count those that appear inside the
|
|
// events array. Heuristic: count `name:` occurrences that aren't immediately
|
|
// nested inside another known nested structure.
|
|
// Simpler approach: events: [ ... ] block extraction + counting top-level
|
|
// objects.
|
|
return extractEventBlocks(src).length;
|
|
}
|
|
|
|
function extractEventBlocks(src: string): string[] {
|
|
// Find `events: [` then walk brackets until matching `]`. Then split top-
|
|
// level `{ ... }` blocks.
|
|
const eventsIdx = src.indexOf('events:');
|
|
if (eventsIdx === -1) return [];
|
|
const arrStart = src.indexOf('[', eventsIdx);
|
|
if (arrStart === -1) return [];
|
|
let depth = 0;
|
|
let i = arrStart;
|
|
for (; i < src.length; i++) {
|
|
const ch = src[i];
|
|
if (ch === '[') depth++;
|
|
else if (ch === ']') {
|
|
depth--;
|
|
if (depth === 0) break;
|
|
}
|
|
}
|
|
const arrContent = src.slice(arrStart + 1, i);
|
|
const blocks: string[] = [];
|
|
let blockDepth = 0;
|
|
let blockStart = -1;
|
|
for (let j = 0; j < arrContent.length; j++) {
|
|
const ch = arrContent[j];
|
|
if (ch === '{') {
|
|
if (blockDepth === 0) blockStart = j;
|
|
blockDepth++;
|
|
} else if (ch === '}') {
|
|
blockDepth--;
|
|
if (blockDepth === 0 && blockStart !== -1) {
|
|
blocks.push(arrContent.slice(blockStart, j + 1));
|
|
blockStart = -1;
|
|
}
|
|
}
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function checkEidos(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const dir = join(EIDOS_DIR, kebab);
|
|
if (!existsSync(dir)) {
|
|
out.push(fail('E-existence', 'error', `No eidos wrapper directory at ${dir}`));
|
|
return out;
|
|
}
|
|
|
|
// E-1.1: has {name}.svelte root file
|
|
const rootSveltePath = join(dir, `${kebab}.svelte`);
|
|
if (existsSync(rootSveltePath)) out.push(pass('E-1.1', 'error'));
|
|
else out.push(fail('E-1.1', 'error', `Missing root ${kebab}.svelte`));
|
|
|
|
// E-1.2 + E-1.3 + E-1.4: index.ts shape
|
|
const indexPath = join(dir, 'index.ts');
|
|
const idx = tryRead(indexPath);
|
|
if (!idx) {
|
|
out.push(fail('E-1.2', 'error', 'Missing index.ts'));
|
|
} else {
|
|
// E-1.2: NO Object.assign on wrapper components
|
|
const usesObjectAssign =
|
|
/Object\.assign\s*\(\s*\w+Component\b/.test(idx) ||
|
|
/Object\.assign\s*\(\s*\w+Root\b/.test(idx);
|
|
if (!usesObjectAssign) out.push(pass('E-1.2', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'E-1.2',
|
|
'error',
|
|
'Uses Object.assign on component constructor — hydration glitch. Use explicit .Part = Part assignment.'
|
|
)
|
|
);
|
|
|
|
// E-1.3: named + default export of {Name}
|
|
// Allow: `export { X }`, `export const X`, `export type X`, `export default X`,
|
|
// `export * from ...`, and re-exports via `export { default as X }`.
|
|
const namedExport =
|
|
/export\s*\{\s*[A-Z]\w*\s*[,}]/.test(idx) ||
|
|
/export\s+const\s+[A-Z]/.test(idx) ||
|
|
/export\s*\*\s*from/.test(idx) ||
|
|
/export\s*\{\s*default\s+as/.test(idx) ||
|
|
// `export type { X }` — type exports satisfy named-export for
|
|
// single-part components (Toggle, Switch) that only ship types
|
|
// alongside the default component.
|
|
/export\s+type\s*\{\s*[A-Z]\w*/.test(idx);
|
|
const defaultExport =
|
|
/export\s+default\s+\w+/.test(idx) ||
|
|
/export\s+default\s+function/.test(idx) ||
|
|
/export\s*\{\s*[A-Z]\w*\s+as\s+default\b/.test(idx) ||
|
|
// Re-exporting the default of another module: `export { default } from './x.svelte';`
|
|
/export\s*\{\s*default\s*\}\s*from/.test(idx);
|
|
if (namedExport && defaultExport) out.push(pass('E-1.3', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'E-1.3',
|
|
'error',
|
|
`Missing ${[!namedExport && 'named', !defaultExport && 'default'].filter(Boolean).join(' + ')} export`
|
|
)
|
|
);
|
|
|
|
// E-1.4: no Provider/Base/Root/Parts exports
|
|
const badExports = ['Provider', 'Base', 'Parts'].filter((tag) =>
|
|
new RegExp(`\\bexport\\s*\\{[^}]*\\b${tag}\\b`).test(idx)
|
|
);
|
|
if (badExports.length === 0) out.push(pass('E-1.4', 'error'));
|
|
else out.push(fail('E-1.4', 'error', `Forbidden exports: ${badExports.join(', ')}`));
|
|
}
|
|
|
|
// E-2.1: types.ts exists
|
|
const typesPath = join(dir, 'types.ts');
|
|
if (existsSync(typesPath)) out.push(pass('E-2.1', 'error'));
|
|
else out.push(fail('E-2.1', 'error', 'Missing types.ts'));
|
|
|
|
// E-2.2: {name}.css exists + is wired. Two sanctioned wirings:
|
|
// (a) current pattern — the component's own wrapper imports it
|
|
// (`import './{kebab}.css'` in {kebab}.svelte or a sibling module);
|
|
// (b) legacy/layout primitives — imported from eidos/index.css.
|
|
// The index.css-only check was stale (pre-wrapper-import architecture)
|
|
// and mass-failed 120 components that follow the current pattern.
|
|
const recipePath = join(dir, `${kebab}.css`);
|
|
if (existsSync(recipePath)) {
|
|
const eidosIndexCss = tryRead(join(REPO, 'src/uix/eidos/index.css'));
|
|
const inIndex = Boolean(
|
|
eidosIndexCss && eidosIndexCss.includes(`components/${kebab}/${kebab}.css`)
|
|
);
|
|
let importedLocally = false;
|
|
if (!inIndex) {
|
|
for (const entry of readdirSync(dir)) {
|
|
if (!/\.(svelte|ts)$/.test(entry) || entry.endsWith('.css')) continue;
|
|
const src = tryRead(join(dir, entry));
|
|
if (src && src.includes(`./${kebab}.css`)) {
|
|
importedLocally = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (inIndex || importedLocally) {
|
|
out.push(pass('E-2.2', 'error', inIndex ? 'imported from index.css' : 'imported by wrapper'));
|
|
} else {
|
|
out.push(
|
|
fail(
|
|
'E-2.2',
|
|
'error',
|
|
`${kebab}.css exists but no import found (neither the wrapper nor eidos/index.css imports it)`
|
|
)
|
|
);
|
|
}
|
|
} else {
|
|
const cssExc = readmeException(kebab, 'E-2.2');
|
|
if (cssExc) out.push(pass('E-2.2', 'error', `exception documented: ${cssExc}`));
|
|
else out.push(fail('E-2.2', 'error', `Missing ${kebab}.css`));
|
|
}
|
|
|
|
// E-2.3: README.md exists
|
|
const readmePath = join(dir, 'README.md');
|
|
if (existsSync(readmePath)) out.push(pass('E-2.3', 'error'));
|
|
else out.push(fail('E-2.3', 'error', 'Missing README.md'));
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Deliberate deviations: a rule can be waived by a documented exception in the
|
|
* component README — the same valve A-2.3 / R-1.7 already use. Greppable form:
|
|
* `R-1.1 exception: <reason>` (anywhere in the component's README.md)
|
|
* The audit then reports the rule as PASS with the documented reason, so the
|
|
* deviation is an explicit contract instead of a silent divergence.
|
|
*/
|
|
function readmeException(kebab: string, ruleId: string): string | null {
|
|
const readme = tryRead(join(EIDOS_DIR, kebab, 'README.md'));
|
|
if (!readme) return null;
|
|
const m = readme.match(new RegExp('`?' + ruleId + ' exception`?:\\s*([^\\n]+)'));
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
|
|
function checkRecipe(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const dir = join(EIDOS_DIR, kebab);
|
|
const mainCss = tryRead(join(dir, `${kebab}.css`));
|
|
if (!mainCss) return out; // E-2.2 will catch missing file
|
|
// R-* discipline covers EVERY stylesheet the component ships, not just the
|
|
// main recipe — secondary sheets (calendar-select.css, …-spectrum.css) were
|
|
// invisible to the scanner (clean-room 2026-07-10, THM-1). Main recipe
|
|
// first, then siblings in stable order.
|
|
const siblingCss = readdirSync(dir)
|
|
.filter((f) => f.endsWith('.css') && f !== `${kebab}.css`)
|
|
.sort()
|
|
.map((f) => tryRead(join(dir, f)) ?? '');
|
|
const css = [mainCss, ...siblingCss].join('\n');
|
|
|
|
// R-1.1: root selector
|
|
const hasRoot = new RegExp(`\\[data-${kebab}\\b`).test(css);
|
|
const rootExc = readmeException(kebab, 'R-1.1');
|
|
if (hasRoot) out.push(pass('R-1.1', 'error'));
|
|
else if (rootExc) out.push(pass('R-1.1', 'error', `exception documented: ${rootExc}`));
|
|
else out.push(fail('R-1.1', 'error', `Missing root selector [data-${kebab}]`));
|
|
|
|
// R-1.2: data-disabled state styled. Only required when the morfo
|
|
// actually declares `data-disabled` on any part — otherwise the audit
|
|
// would demand defensive styling for state the contract never emits.
|
|
const morfoDeclaresDisabled = (tryRead(join(MORFO_DIR, `${kebab}.ts`)) ?? '').includes(
|
|
'data-disabled'
|
|
);
|
|
if (info.interactive && morfoDeclaresDisabled) {
|
|
const hasDisabled = /\[data-disabled\b/.test(css);
|
|
const disabledExc = readmeException(kebab, 'R-1.2');
|
|
if (hasDisabled) out.push(pass('R-1.2', 'error'));
|
|
else if (disabledExc) out.push(pass('R-1.2', 'error', `exception documented: ${disabledExc}`));
|
|
else out.push(fail('R-1.2', 'error', 'No [data-disabled] styles'));
|
|
}
|
|
|
|
if (info.interactive) {
|
|
// R-1.5: a visible focus treatment. Four sanctioned evidences (the
|
|
// foundation doctrine in archetypes.css): the `:focus-visible` pseudo,
|
|
// the field shell's `:focus-within`, a `:has(...:focus…)` control rule,
|
|
// or the canonical state-attr ring (`[data-focused]` + focus-ring
|
|
// tokens, written by soma — the spin-field/search-field pattern).
|
|
const hasFocus = /:focus-visible\b|:focus-within\b|:has\([^)]*:focus|\[data-focused\]/.test(
|
|
css
|
|
);
|
|
const focusExc = readmeException(kebab, 'R-1.5');
|
|
if (hasFocus) out.push(pass('R-1.5', 'error'));
|
|
else if (focusExc) out.push(pass('R-1.5', 'error', `exception documented: ${focusExc}`));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-1.5',
|
|
'error',
|
|
'No focus treatment (:focus-visible / :focus-within / [data-focused])'
|
|
)
|
|
);
|
|
}
|
|
|
|
// R-1.3: data-readonly if applicable
|
|
const morfoSrc = tryRead(join(MORFO_DIR, `${kebab}.ts`)) ?? '';
|
|
if (morfoSrc.includes('data-readonly')) {
|
|
const hasReadonly = /\[data-readonly\b/.test(css);
|
|
const roExc = readmeException(kebab, 'R-1.3');
|
|
if (hasReadonly) out.push(pass('R-1.3', 'warn'));
|
|
else if (roExc) out.push(pass('R-1.3', 'warn', `exception documented: ${roExc}`));
|
|
else out.push(fail('R-1.3', 'warn', '[data-readonly] declared in morfo but not styled'));
|
|
}
|
|
|
|
// R-1.4: data-invalid if applicable
|
|
if (morfoSrc.includes('data-invalid')) {
|
|
const hasInvalid = /\[data-invalid\b/.test(css);
|
|
const invExc = readmeException(kebab, 'R-1.4');
|
|
if (hasInvalid) out.push(pass('R-1.4', 'warn'));
|
|
else if (invExc) out.push(pass('R-1.4', 'warn', `exception documented: ${invExc}`));
|
|
else out.push(fail('R-1.4', 'warn', '[data-invalid] declared in morfo but not styled'));
|
|
}
|
|
|
|
// R-2.1: no raw colors (hex/rgb/named). Escape valve (same as the R-4.x
|
|
// rules): a `/* literal: <reason> */` on the declaration line marks a
|
|
// physically-fixed color — the doctrine's explicit exception (e.g. the
|
|
// natural-time-picker daylight skies).
|
|
const rawColors: string[] = [];
|
|
// Scan comment-blanked text (offsets preserved) so prose mentioning a color
|
|
// can't false-positive; the escape-valve lookup runs on the ORIGINAL line.
|
|
const colorScanCss = blankCssComments(css);
|
|
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
|
|
for (const m of colorScanCss.matchAll(HEX_RE)) {
|
|
if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[0].length))) continue;
|
|
rawColors.push(m[0]);
|
|
}
|
|
// Function notation: rgb()/rgba() plus the modern spaces (hsl/hwb/lab/lch/
|
|
// oklab/oklch) are raw colors all the same. Only literal channel values
|
|
// count — `rgb(var(--x) / 0.5)` composes a token and passes. `color-mix()`
|
|
// stays out for the same reason.
|
|
const COLOR_FN_RE = /\b((?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[\d.])/g;
|
|
for (const m of colorScanCss.matchAll(COLOR_FN_RE)) {
|
|
if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[1].length))) continue;
|
|
rawColors.push(m[1]);
|
|
}
|
|
// Allow `currentColor`, `transparent`, `inherit`
|
|
if (rawColors.length === 0) out.push(pass('R-2.1', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-2.1',
|
|
'error',
|
|
`Raw colors detected (${rawColors.length}): ${rawColors.slice(0, 5).join(', ')}${rawColors.length > 5 ? '...' : ''}`
|
|
)
|
|
);
|
|
|
|
// R-2.5: no --eidos-* or --soma-* invented vars
|
|
const badVars: string[] = [];
|
|
for (const m of css.matchAll(/--(eidos|soma|air)-[\w-]+/g)) badVars.push(m[0]);
|
|
if (badVars.length === 0) out.push(pass('R-2.5', 'error'));
|
|
else
|
|
out.push(
|
|
fail('R-2.5', 'error', `Invented vars: ${[...new Set(badVars)].slice(0, 5).join(', ')}`)
|
|
);
|
|
|
|
// R-2.6: every var(--color-X) is declared in generated/base.css.
|
|
// Catches typos and references to roles that never made it into the
|
|
// theme contract (the 2026-05-21 bug had 17 broken refs sitting
|
|
// silently until visual inspection).
|
|
//
|
|
// ACTA 2026-08-27 — the needle tolerates whitespace after `var(`. This file
|
|
// carried the SAME defect `830cd7671` corrected in the census: THREE needles
|
|
// here demanded a literal `var(--` (this one, R-4.3 and R-4.6), and prettier
|
|
// breaks a long declaration right after the paren, so `var( --color-x )`
|
|
// slipped past. In the census a miss MISCLASSIFIED a knob; in the two regex
|
|
// needles here the check simply stops LOOKING, which is worse in kind — a
|
|
// guard that inspects nothing passes. Measured BEFORE changing anything:
|
|
// zero victims at all three sites. The corpus holds 15 prettier-broken
|
|
// `var( --` today and not one of them is a bare `--color-*`, a raw-layer ref
|
|
// or a `:hover` state-layer. Corrected anyway, for M0's reason: a needle left
|
|
// blind keeps the bug armed for the next line prettier decides to break.
|
|
// The tolerance is COPIED, not invented — `recipe-css-contract.test.ts:150`
|
|
// already reads `var\(\s*(--[a-z][a-z0-9-]+)\s*[,)]`, and `\s*` on BOTH sides
|
|
// is what the break actually produces (`var( --x, … )`).
|
|
if (DECLARED_COLOR_TOKENS.size > 0) {
|
|
const undefRefs: string[] = [];
|
|
for (const m of css.matchAll(/var\(\s*--color-([a-z0-9-]+)\s*\)/g)) {
|
|
if (!DECLARED_COLOR_TOKENS.has(m[1])) undefRefs.push(m[1]);
|
|
}
|
|
if (undefRefs.length === 0) out.push(pass('R-2.6', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-2.6',
|
|
'error',
|
|
`Undeclared --color-* refs (${undefRefs.length}): ${[...new Set(undefRefs)].slice(0, 5).join(', ')}`
|
|
)
|
|
);
|
|
}
|
|
|
|
// R-3.2: no legacy color names
|
|
const legacy: string[] = [];
|
|
for (const cls of ['success', 'warning', 'danger', 'info']) {
|
|
const re = new RegExp(`\\[data-color=['"]${cls}['"]\\]`);
|
|
if (re.test(css)) legacy.push(cls);
|
|
}
|
|
if (legacy.length === 0) out.push(pass('R-3.2', 'error'));
|
|
else out.push(fail('R-3.2', 'error', `Legacy color names: ${legacy.join(', ')}`));
|
|
|
|
// R-2.7: no literal typography in recipes.
|
|
//
|
|
// Eidos foundation owns one type-system anchor (`--font-size-*`,
|
|
// `--font-weight-*`, `--leading-ui`, `--font-ui`, named styles via
|
|
// `--style-{name}-*`). Recipes MUST consume that anchor via tokens
|
|
// or component-scoped tokens that themselves resolve to it.
|
|
//
|
|
// A raw literal (`font-size: 12px`, `line-height: 1.4`,
|
|
// `letter-spacing: 0.02em`, `font-weight: 500`) breaks the
|
|
// vertebration: changing the foundation no longer propagates here.
|
|
//
|
|
// Allowed escape valves — these don't count as drift:
|
|
// - `inherit`, `initial`, `unset`, `currentColor` (CSS keywords)
|
|
// - `0` / `0px` / `0em` / `1` (numeric identities — no semantic
|
|
// typography intent; common for tight-leading on icons or
|
|
// zero-tracking on monospace)
|
|
// - any value wrapped in `var(...)` (the whole point)
|
|
// - any value with a comment `/* literal: <reason> */` on the
|
|
// same line (explicit opt-out for justified exceptions)
|
|
//
|
|
// Note: `font-family` is checked but the audit isn't strict about
|
|
// generic-keyword fallbacks (`sans-serif`, `serif`, `monospace`)
|
|
// since those only appear inside `var(--font-family-*, sans-serif)`
|
|
// fallback chains, which are themselves a `var()` and already pass.
|
|
const TYPO_PROPS = ['font-size', 'font-weight', 'line-height', 'letter-spacing'] as const;
|
|
const ALLOWED_LITERALS = new Set(['0', '0px', '0em', '0rem', '1', 'inherit', 'initial', 'unset']);
|
|
const literalTypoOffenses: string[] = [];
|
|
// Declaration-wise, NOT line-wise: prettier breaks a long `font-size: calc( … )`
|
|
// across lines, and a per-line matcher then reads `calc(` as the whole value and
|
|
// loses the `/* literal: … */` valve that sits after the `;` (six false positives
|
|
// measured after the format one-shot). `(?<![-\w])` keeps `--font-size-*` custom
|
|
// properties out of it; `[^;{}]` spans newlines.
|
|
const TYPO_DECL_RE =
|
|
/(?<![-\w])(font-size|font-weight|line-height|letter-spacing)\s*:\s*([^;{}]+);[^\S\n]*(\/\*[^*]*\*\/)?/g;
|
|
for (const m of css.matchAll(TYPO_DECL_RE)) {
|
|
const [, prop, rawValue, comment] = m;
|
|
const value = rawValue.replace(/\s+/g, ' ').trim();
|
|
if (value.startsWith('var(')) continue;
|
|
if (ALLOWED_LITERALS.has(value)) continue;
|
|
if (comment && /literal:/i.test(comment)) continue;
|
|
literalTypoOffenses.push(`${prop}: ${value}`);
|
|
void TYPO_PROPS; // silence unused-var on the tuple if linter cares
|
|
}
|
|
if (literalTypoOffenses.length === 0) out.push(pass('R-2.7', 'warn'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-2.7',
|
|
'warn',
|
|
`Literal typography (${literalTypoOffenses.length}): ${literalTypoOffenses.slice(0, 5).join('; ')}${literalTypoOffenses.length > 5 ? '...' : ''}. Use a recipe token or named-style var, or add a /* literal: <reason> */ comment.`
|
|
)
|
|
);
|
|
|
|
// R-4.x — Recipe Contract (transversal systems). WIP tracks excluded even
|
|
// under an explicit `--only` run (the contract applies when the track lands).
|
|
if (!WIP_TRACKS.has(kebab)) {
|
|
out.push(...checkRecipeContract(kebab, css, info));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// ─── Recipe Contract checks (R-4.x — src/uix/eidos/RECIPE_CONTRACT.md) ──────
|
|
|
|
/** The full source line(s) spanning a match — used to find escape-valve comments. */
|
|
function declarationWindow(css: string, start: number, matchLength: number): string {
|
|
const lineStart = css.lastIndexOf('\n', start) + 1;
|
|
const lineEnd = css.indexOf('\n', start + matchLength);
|
|
return css.slice(lineStart, lineEnd === -1 ? css.length : lineEnd);
|
|
}
|
|
|
|
/** Blank out CSS comments preserving offsets — so structural scans (e.g. the
|
|
* R-4.5 `@keyframes` finder) don't match prose inside comments, while
|
|
* escape-valve lookups still run against the ORIGINAL text at the same index. */
|
|
function blankCssComments(css: string): string {
|
|
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
}
|
|
|
|
/** Blank out every `@keyframes … { … }` block (brace-balanced), preserving offsets. */
|
|
function stripKeyframesBlocks(css: string): string {
|
|
let out = css;
|
|
const re = /@keyframes\s+[\w-]+\s*\{/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(out)) !== null) {
|
|
let depth = 0;
|
|
let i = m.index + m[0].length - 1; // at the opening brace
|
|
for (; i < out.length; i++) {
|
|
if (out[i] === '{') depth++;
|
|
else if (out[i] === '}') {
|
|
depth--;
|
|
if (depth === 0) break;
|
|
}
|
|
}
|
|
const blanked = out.slice(m.index, i + 1).replace(/[^\n]/g, ' ');
|
|
out = out.slice(0, m.index) + blanked + out.slice(i + 1);
|
|
re.lastIndex = m.index + blanked.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function checkRecipeContract(kebab: string, css: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
|
|
// R-4.1: no literal box-shadow. Elevation composes `var(--shadow-*)` /
|
|
// `var(--depth-{plane}-*)`; the inset-ring pattern carries `var()` too, so
|
|
// any tokenized value passes. Escape valve: `/* literal: <reason> */` on
|
|
// the declaration's line.
|
|
const shadowOffenses: string[] = [];
|
|
for (const m of css.matchAll(/box-shadow\s*:\s*([^;{}]+)/g)) {
|
|
const value = m[1].trim();
|
|
if (value.includes('var(')) continue;
|
|
if (/^(none|inherit|initial|unset|revert(?:-layer)?)$/.test(value)) continue;
|
|
if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[0].length))) continue;
|
|
shadowOffenses.push(value.length > 48 ? `${value.slice(0, 48)}…` : value);
|
|
}
|
|
if (shadowOffenses.length === 0) out.push(pass('R-4.1', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-4.1',
|
|
'error',
|
|
`Literal box-shadow (${shadowOffenses.length}): ${shadowOffenses.slice(0, 3).join(' · ')}${shadowOffenses.length > 3 ? '…' : ''}. Use var(--shadow-*) / var(--depth-{plane}-*), or add /* literal: <reason> */.`
|
|
)
|
|
);
|
|
|
|
// R-4.2: no literal fractional opacity outside @keyframes. Disabled /
|
|
// muted states consume `var(--opacity-*)`. 0 and 1 are visibility
|
|
// toggles, not tokens — allowed.
|
|
const noKeyframes = stripKeyframesBlocks(css);
|
|
const opacityOffenses: string[] = [];
|
|
for (const m of noKeyframes.matchAll(/(?<![\w-])opacity\s*:\s*(0?\.\d+)\s*[;}]/g)) {
|
|
if (/literal:/i.test(declarationWindow(noKeyframes, m.index ?? 0, m[0].length))) continue;
|
|
opacityOffenses.push(m[1]);
|
|
}
|
|
if (opacityOffenses.length === 0) out.push(pass('R-4.2', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-4.2',
|
|
'error',
|
|
`Literal opacity (${opacityOffenses.length}): ${[...new Set(opacityOffenses)].join(', ')}. Use var(--opacity-disabled) / var(--opacity-*), or add /* literal: <reason> */.`
|
|
)
|
|
);
|
|
|
|
// R-4.3: hover backgrounds are the state-layer or a palette token. Scan
|
|
// innermost rule blocks whose selector carries `:hover`; flag background
|
|
// values with no `var(` at all (raw color / gradient) and hand-rolled
|
|
// `color-mix(… currentColor …)` state-layer imitations.
|
|
if (info.interactive) {
|
|
const hoverOffenses: string[] = [];
|
|
for (const block of noKeyframes.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
const selector = block[1];
|
|
if (!selector.includes(':hover')) continue;
|
|
const body = block[2];
|
|
const bodyOffset = (block.index ?? 0) + block[0].indexOf(body);
|
|
for (const decl of body.matchAll(/background(?:-color|-image)?\s*:\s*([^;]+);/g)) {
|
|
const value = decl[1].trim();
|
|
if (/^(none|transparent|inherit|initial|unset|revert(?:-layer)?)$/.test(value)) continue;
|
|
const window = declarationWindow(
|
|
noKeyframes,
|
|
bodyOffset + (decl.index ?? 0),
|
|
decl[0].length
|
|
);
|
|
if (/literal:/i.test(window)) continue;
|
|
// Tolerant `var(\s*--`: same needle fix as R-2.6 (ACTA above). This site
|
|
// is the FRAGILE one — `value` is `decl[1].trim()` straight off the
|
|
// source, never normalized, so a broken line arrives with its newline
|
|
// and tabs intact. Not a `/g` regex: `.test()` has no use for the flag,
|
|
// and a global needle keeps `lastIndex` BETWEEN CALLS on the same regex
|
|
// OBJECT — measured, `/a/g` as a module const answers true, FALSE, true
|
|
// on three `.test('a')`. An inline literal is re-created on every
|
|
// evaluation, so this site would survive the flag by accident; the trap
|
|
// bites once a needle is hoisted, which is where every named needle in
|
|
// this file lives (`PHYSICAL_AXIS_TOKEN_RE`, non-global; the `/g` ones
|
|
// are read with `matchAll`, which does not move the source's index).
|
|
// The census's own ACTA states the rule for ITS needles, all of them
|
|
// hoisted, where the carry is real.
|
|
const handRolledStateLayer =
|
|
/color-mix\([^)]*currentColor/i.test(value) && !/var\(\s*--state-/.test(value);
|
|
if (value.includes('var(') && !handRolledStateLayer) continue;
|
|
hoverOffenses.push(value.length > 48 ? `${value.slice(0, 48)}…` : value);
|
|
}
|
|
}
|
|
if (hoverOffenses.length === 0) out.push(pass('R-4.3', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-4.3',
|
|
'error',
|
|
`:hover background outside the contract (${hoverOffenses.length}): ${hoverOffenses.slice(0, 3).join(' · ')}${hoverOffenses.length > 3 ? '…' : ''}. Neutral tier → var(--state-hover); valenced tier → palette token.`
|
|
)
|
|
);
|
|
}
|
|
|
|
// R-4.4: no physical-axis token keys in this component's recipe block
|
|
// (lib/recipes/base.ts). Logical axes (padding-inline/-block) are canon.
|
|
const axisTokens = PHYSICAL_AXIS_TOKENS.get(kebab) ?? [];
|
|
if (axisTokens.length === 0) out.push(pass('R-4.4', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-4.4',
|
|
'error',
|
|
`Physical-axis recipe tokens (${axisTokens.length}): ${[...new Set(axisTokens)].slice(0, 4).join(', ')}${axisTokens.length > 4 ? '…' : ''}. Rename to padding-inline/-block (logical axes).`
|
|
)
|
|
);
|
|
|
|
// R-5.3: los nombres de token siguen la gramática firmada (D-TH.6).
|
|
const nameEntry = NAME_FINDINGS.get(kebab);
|
|
const deviatedNames = nameEntry?.deviated ?? [];
|
|
const queuedNames = nameEntry?.stateLayer ?? [];
|
|
if (deviatedNames.length === 0)
|
|
out.push(
|
|
pass(
|
|
'R-5.3',
|
|
'error',
|
|
queuedNames.length
|
|
? `${queuedNames.length} knob(s) de hover neutro en cola de migración a la capa de estado (§38) — no son deuda de nombre`
|
|
: undefined
|
|
)
|
|
);
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-5.3',
|
|
'error',
|
|
`Nombres de token fuera de la gramática (${deviatedNames.length}): ${deviatedNames
|
|
.slice(0, 3)
|
|
.map((f) => `${f.key} → ${f.to}`)
|
|
.join(
|
|
' · '
|
|
)}${deviatedNames.length > 3 ? '…' : ''}. Tinta = fg; el modificador interactivo va DELANTE (theming §6.7 r7).`
|
|
)
|
|
);
|
|
|
|
// R-5.1: ninguna declaración de apariencia queda fuera de alcance SIN ACTA.
|
|
// El acta es una de tres: el token público (alcanza), la anotación
|
|
// `/* literal: <razón> */` sobre la declaración (desviación firmada), o una
|
|
// entrada del ledger de deuda (deuda registrada). Una cuarta declaración
|
|
// —ni token, ni anotación, ni entrada— es regresión, y con el ledger a cero
|
|
// deltas eso es exactamente lo que significa `newDebt`.
|
|
const debtEntry = DEBT_BY_COMPONENT.get(kebab) as
|
|
| { newDebt: string[]; stale: string[]; registered: number }
|
|
| undefined;
|
|
const newDebt = debtEntry?.newDebt ?? [];
|
|
const staleDebt = debtEntry?.stale ?? [];
|
|
const registeredDebt = debtEntry?.registered ?? 0;
|
|
const ledgerNote = registeredDebt
|
|
? `${registeredDebt} knob(s) sin alcance REGISTRADOS en el ledger de deuda (no es alcance, es deuda contada)`
|
|
: undefined;
|
|
const debtExc = readmeException(kebab, 'R-5.1');
|
|
if (staleDebt.length > 0)
|
|
// Antes que la válvula a propósito: una entrada obsoleta no se firma, se
|
|
// borra — y el mensaje dice cuál, para que borrarla sea una línea.
|
|
out.push(
|
|
fail(
|
|
'R-5.1',
|
|
'error',
|
|
`Ledger de deuda STALE (${staleDebt.length}): ${staleDebt.slice(0, 3).join(' · ')}${staleDebt.length > 3 ? '…' : ''}. Ese knob ya no es deuda (tokenizado, anotado o muerto) — borra la(s) línea(s) en scripts/theming-census-debt.ts.`
|
|
)
|
|
);
|
|
else if (newDebt.length === 0) out.push(pass('R-5.1', 'error', ledgerNote));
|
|
else if (debtExc) out.push(pass('R-5.1', 'error', `exception documented: ${debtExc}`));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-5.1',
|
|
'error',
|
|
`Knobs fuera de alcance sin acta (${newDebt.length}): ${newDebt.slice(0, 3).join(' · ')}${newDebt.length > 3 ? '…' : ''}. Tokeniza (var(--${kebab}-…)), anota /* literal: <razón> */, o firma la entrada en scripts/theming-census-debt.ts.`
|
|
)
|
|
);
|
|
|
|
// R-5.2: todo componente con receta TEMATIZABLE tiene contrato escrito.
|
|
// «Tematizable» es lo medido, no lo supuesto: un knob `system` o
|
|
// `structural` no cuenta, y por eso `heading` / `text` / `display` —cuya
|
|
// superficie ES la capa tipográfica `--style-*`— pasan sin acuñar nada, y
|
|
// las cinco recetas estructurales también. Un componente sin clave pública
|
|
// pasa cuando cada uno de sus knobs tematizables está en el ledger: la
|
|
// entrada del ledger ES su registro escrito (`field-langs`, 37 de 37).
|
|
const censusRow = CENSUS_ROWS.get(kebab);
|
|
if (censusRow) {
|
|
const themeable = censusRow.public + censusRow.private + censusRow.global + censusRow.literal;
|
|
const contractExc = readmeException(kebab, 'R-5.2');
|
|
if (themeable === 0)
|
|
out.push(
|
|
pass(
|
|
'R-5.2',
|
|
'error',
|
|
`nada que declarar: sus ${censusRow.knobs} knob(s) son sistema transversal o estructurales`
|
|
)
|
|
);
|
|
else if (censusRow.contractKeys > 0) out.push(pass('R-5.2', 'error'));
|
|
else if (contractExc) out.push(pass('R-5.2', 'error', `exception documented: ${contractExc}`));
|
|
else if (newDebt.length === 0)
|
|
// No public key, but every knob accounted for. The breakdown is named
|
|
// instead of passing in silence. NOTE (2026-08-26, closed-space law): the
|
|
// `var(--{c}-x, fallback)` idiom this branch used to describe is no
|
|
// longer sanctioned — `recipe-css-contract.test.ts` now bites an
|
|
// own-name outside the contract WITH or WITHOUT fallback, and the
|
|
// transitional names lived in its PENDING_PRIVATE_RENAME registry
|
|
// (dismantled at zero later that same day). After
|
|
// that law the only component this branch can reach with `public > 0`
|
|
// is a WIP track outside this audit's catalogue; the wording stays as
|
|
// honest belt-and-braces for whatever future state reaches it.
|
|
out.push(
|
|
pass(
|
|
'R-5.2',
|
|
'error',
|
|
`sin clave pública en base.ts; sus ${themeable} knob(s) tematizables: ${censusRow.public} referencia pública con fallback · ${censusRow.private} privado · ${censusRow.global + censusRow.literal} en el ledger de deuda`
|
|
)
|
|
);
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-5.2',
|
|
'error',
|
|
`Sin entrada en lib/recipes/base.ts y ${newDebt.length} de sus ${themeable} knob(s) tematizables sin acta. Declara el contrato del componente, o registra la deuda.`
|
|
)
|
|
);
|
|
}
|
|
|
|
// R-4.5: local @keyframes need a `/* functional: <reason> */` annotation —
|
|
// perceptual signatures live in EidosConfig.motion (generated, themeable),
|
|
// not in component CSS. Look back 300 chars for the annotation.
|
|
const unannotated: string[] = [];
|
|
// Scan on comment-blanked css (offsets preserved) so prose mentioning
|
|
// "@keyframes" inside a comment never counts; the annotation lookback
|
|
// runs against the ORIGINAL text (annotations live in comments).
|
|
const commentFree = blankCssComments(css);
|
|
for (const m of commentFree.matchAll(/@keyframes\s+([\w-]+)/g)) {
|
|
const at = m.index ?? 0;
|
|
const lookback = css.slice(Math.max(0, at - 300), at);
|
|
if (/functional:/i.test(lookback)) continue;
|
|
unannotated.push(m[1]);
|
|
}
|
|
if (unannotated.length === 0) out.push(pass('R-4.5', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-4.5',
|
|
'error',
|
|
`@keyframes without /* functional: … */ annotation: ${unannotated.slice(0, 4).join(', ')}${unannotated.length > 4 ? '…' : ''}. Perceptual signatures belong in EidosConfig.motion.`
|
|
)
|
|
);
|
|
|
|
// R-4.7: `!important` needs a same-line `/* important: <reason> */`
|
|
// annotation (THM-5, clean-room 2026-07-10 — 15 shipped uses had no
|
|
// governing rule). The declaration wins EVERY cascade fight, so the
|
|
// reason must be written where it happens (soma inline styles, the
|
|
// group's cross-recipe radius fusion, reduced-motion kills, …).
|
|
// Scanned on comment-blanked text so prose mentioning `!important`
|
|
// never counts; the valve lookup runs on the ORIGINAL line.
|
|
const importantScan = blankCssComments(noKeyframes);
|
|
const importantOffenses: string[] = [];
|
|
for (const m of importantScan.matchAll(/!important/g)) {
|
|
const line = declarationWindow(noKeyframes, m.index ?? 0, m[0].length);
|
|
if (/important:/i.test(line)) continue;
|
|
importantOffenses.push(line.trim().slice(0, 48));
|
|
}
|
|
if (importantOffenses.length === 0) out.push(pass('R-4.7', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-4.7',
|
|
'error',
|
|
`Unannotated !important (${importantOffenses.length}): ${importantOffenses.slice(0, 3).join(' · ')}${importantOffenses.length > 3 ? '…' : ''}. Add /* important: <reason> */ on the line.`
|
|
)
|
|
);
|
|
|
|
// R-4.6: no direct raw-layer color refs in component CSS — consume
|
|
// `var(--color-{role}-{slot})` or recipe tokens instead.
|
|
const rawLayerRefs: string[] = [];
|
|
// Tolerant `var(\s*--`: same needle fix as R-2.6 (ACTA above). The report
|
|
// strips the `var(` by PATTERN and not by a fixed 4, so a match that spans a
|
|
// prettier break does not print its own whitespace back at the reader.
|
|
for (const m of css.matchAll(/var\(\s*--(scale|primitive)-[a-z0-9-]+/g)) {
|
|
if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[0].length))) continue;
|
|
rawLayerRefs.push(m[0].replace(/^var\(\s*/, ''));
|
|
}
|
|
if (rawLayerRefs.length === 0) out.push(pass('R-4.6', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-4.6',
|
|
'error',
|
|
`Direct raw-layer refs (${rawLayerRefs.length}): ${[...new Set(rawLayerRefs)].slice(0, 3).join(', ')}${rawLayerRefs.length > 3 ? '…' : ''}. Consume var(--color-{role}-{slot}) or a recipe token.`
|
|
)
|
|
);
|
|
|
|
return out;
|
|
}
|
|
|
|
function checkDemo(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const demoPath = join(DEMO_DIR, kebab, '+page.svelte');
|
|
const src = tryRead(demoPath);
|
|
if (!src) {
|
|
out.push(fail('D-existence', 'error', `No demo page at ${demoPath}`));
|
|
return out;
|
|
}
|
|
|
|
// D-1.1: data-uix-canvas-inner outer
|
|
if (/<div\s+data-uix-canvas-inner\b/.test(src)) out.push(pass('D-1.1', 'error'));
|
|
else out.push(fail('D-1.1', 'error', 'Missing outer <div data-uix-canvas-inner>'));
|
|
|
|
// D-1.2: Tab union matches a canonical template. The v2 layout
|
|
// (DEMO_AUTHORING_GUIDE §type Tab) is the 9-tab canonical; the v1 6-tab
|
|
// union remains accepted while the demo migration is in flight — the old
|
|
// exact-6-tab check was stale and failed every ALREADY-migrated demo.
|
|
const tabUnionV2Re =
|
|
/type Tab\s*=\s*'live'\s*\|\s*'system'\s*\|\s*'motion'\s*\|\s*'sema'\s*\|\s*'services'\s*\|\s*'api'\s*\|\s*'morfo'\s*\|\s*'recipe'\s*\|\s*'a11y'/;
|
|
const tabUnionV1Re =
|
|
/type Tab\s*=\s*'live'\s*\|\s*'api'\s*\|\s*'morfo'\s*\|\s*'sema'\s*\|\s*'recipe'\s*\|\s*'a11y'/;
|
|
if (tabUnionV2Re.test(src)) out.push(pass('D-1.2', 'error', 'v2 9-tab template'));
|
|
else if (tabUnionV1Re.test(src))
|
|
out.push(pass('D-1.2', 'error', 'legacy v1 6-tab template (pending v2 migration)'));
|
|
else
|
|
out.push(
|
|
fail('D-1.2', 'error', 'Tab union matches neither the v2 9-tab nor the v1 6-tab template')
|
|
);
|
|
|
|
// D-1.3: imports compileMorfo
|
|
if (/import\s*\{\s*compileMorfo\b/.test(src)) out.push(pass('D-1.3', 'error'));
|
|
else out.push(fail('D-1.3', 'error', 'No `import { compileMorfo }`'));
|
|
|
|
// D-1.5: MutationObserver
|
|
if (info.interactive) {
|
|
if (/new\s+MutationObserver/.test(src)) out.push(pass('D-1.5', 'error'));
|
|
else out.push(fail('D-1.5', 'error', 'No MutationObserver for trace strip'));
|
|
}
|
|
|
|
// D-1.6: stage between header and tablist
|
|
if (/data-uix-stage\b/.test(src)) out.push(pass('D-1.6', 'error'));
|
|
else out.push(fail('D-1.6', 'error', 'No <div data-uix-stage>'));
|
|
|
|
// D-1.7: trace strip for interactive
|
|
if (info.interactive) {
|
|
if (/data-uix-stage-trace\b/.test(src)) out.push(pass('D-1.7', 'error'));
|
|
else out.push(fail('D-1.7', 'error', 'No <div data-uix-stage-trace>'));
|
|
}
|
|
|
|
// D-2.x: header
|
|
const hasEyebrow = /data-uix-eyebrow\b/.test(src);
|
|
const hasTitle = /data-uix-page-title\b/.test(src);
|
|
const hasLede = /data-uix-page-lede\b/.test(src);
|
|
const hasMeta = /data-uix-page-meta\b/.test(src);
|
|
if (hasEyebrow) out.push(pass('D-2.1', 'error'));
|
|
else out.push(fail('D-2.1', 'error', 'Missing data-uix-eyebrow'));
|
|
if (hasTitle && hasLede) out.push(pass('D-2.2', 'error'));
|
|
else out.push(fail('D-2.2', 'error', 'Missing title or lede in header'));
|
|
if (hasMeta) out.push(pass('D-2.3', 'error'));
|
|
else out.push(fail('D-2.3', 'error', 'Missing data-uix-page-meta pills'));
|
|
|
|
// D-3.1: snippet parity. The v2 template derives a single `eidosSnippet`
|
|
// (guide §12 — "the snippet", singular); v1 demos additionally carry a
|
|
// `somaSnippet`. Require the eidos snippet; treat soma as optional so the
|
|
// migrated demos stop mass-failing against the stale two-snippet check.
|
|
const hasSomaSnip = /somaSnippet\b/.test(src);
|
|
const hasEidosSnip = /eidosSnippet\b/.test(src);
|
|
if (hasEidosSnip) {
|
|
out.push(
|
|
pass('D-3.1', 'error', hasSomaSnip ? 'soma + eidos snippets (v1)' : 'eidos snippet (v2)')
|
|
);
|
|
} else {
|
|
out.push(fail('D-3.1', 'error', 'Missing eidosSnippet'));
|
|
}
|
|
|
|
// D-4.3: Play buttons emit via uix.events.emit
|
|
if (info.interactive) {
|
|
const hasPlay = /uix\.events\??\.emit/.test(src) || /events\.emit\(/.test(src);
|
|
if (hasPlay) out.push(pass('D-4.3', 'error'));
|
|
else out.push(fail('D-4.3', 'warn', 'No uix.events.emit(...) — Play buttons may not be wired'));
|
|
}
|
|
|
|
// D-7.4: chip parity vs type union.
|
|
//
|
|
// For every chip-controlled prop (`size`, `variant`, `color`), the
|
|
// demo's chip array must enumerate the full union declared in
|
|
// `eidos/components/{kebab}/types.ts`. Truncated arrays (e.g.
|
|
// `field` shipped 'outline'|'ghost' while ControlVariant has 3)
|
|
// silently lie about the surface.
|
|
const typesPath = join(EIDOS_DIR, kebab, 'types.ts');
|
|
const typesSrc = tryRead(typesPath);
|
|
if (typesSrc) {
|
|
for (const prop of ['size', 'variant', 'color'] as const) {
|
|
const declared = extractTypeUnion(typesSrc, prop, kebab);
|
|
if (!declared || declared.size === 0) continue;
|
|
const exposed = extractDemoChipSet(src, prop);
|
|
if (!exposed) {
|
|
out.push(
|
|
fail(
|
|
'D-7.4',
|
|
'warn',
|
|
`'${prop}' type exposes ${declared.size} values but no chip array found in demo`
|
|
)
|
|
);
|
|
continue;
|
|
}
|
|
const missing = [...declared].filter((v) => !exposed.has(v));
|
|
const extra = [...exposed].filter((v) => !declared.has(v));
|
|
if (missing.length === 0 && extra.length === 0) {
|
|
out.push(pass('D-7.4', 'error', `${prop}: ${declared.size} values`));
|
|
} else {
|
|
const parts: string[] = [];
|
|
if (missing.length) parts.push(`missing ${missing.join(', ')}`);
|
|
if (extra.length) parts.push(`extra ${extra.join(', ')}`);
|
|
out.push(fail('D-7.4', 'error', `'${prop}' chip drift — ${parts.join(' · ')}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// Parse `export type {Name}{Prop} = …` and return the set of string-literal
|
|
// values in the union. Supports both direct unions ('a' | 'b' | 'c') and
|
|
// `Extract<Base, 'a' | 'b' | 'c'>`. Returns null if the type isn't found,
|
|
// or the type references an unresolved alias we can't follow (deliberate
|
|
// fail-safe: don't emit a false alarm).
|
|
function extractTypeUnion(
|
|
typesSrc: string,
|
|
prop: 'size' | 'variant' | 'color',
|
|
kebab: string
|
|
): Set<string> | null {
|
|
// Match `export type {PascalKebab}{Prop} = …` exactly — no intermediate
|
|
// PascalCase tokens, so we don't pick up sub-part types like
|
|
// `FormActionColor` when auditing the root form demo. The root prop type
|
|
// follows the canonical naming convention `{Name}{Prop}` (CheckboxColor,
|
|
// ComboboxSize, FieldVariant, …).
|
|
const cap = prop.charAt(0).toUpperCase() + prop.slice(1);
|
|
const pascal = kebab
|
|
.split('-')
|
|
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
.join('');
|
|
const re = new RegExp(
|
|
`export\\s+type\\s+${pascal}${cap}\\s*=\\s*([^;\\n]+(?:\\n[^;\\n]+)*);`,
|
|
'm'
|
|
);
|
|
const m = typesSrc.match(re);
|
|
if (!m) return null;
|
|
const body = m[1];
|
|
// Pull every quoted literal from the body. This handles both
|
|
// `'a' | 'b' | 'c'`
|
|
// and
|
|
// `Extract<X, 'a' | 'b' | 'c'>`
|
|
// equally. If the body has no string literals at all (e.g. it just
|
|
// aliases another type — `ControlVariant`), we can't resolve the
|
|
// transitive union here without a TS pass; return null so the audit
|
|
// stays silent.
|
|
const literals = [...body.matchAll(/'([a-z][a-z0-9-]*)'/g)].map((x) => x[1]);
|
|
if (literals.length === 0) {
|
|
// Resolve a single-alias chain like `= ControlVariant` /
|
|
// `= SelectionVariant` / `= ChipVariant` / etc. via the canonical
|
|
// vocab defined in `src/uix/eidos/lib/types.ts`.
|
|
const alias = body.trim();
|
|
const resolved = SHARED_VARIANT_VOCAB.get(alias);
|
|
return resolved ? new Set(resolved) : null;
|
|
}
|
|
return new Set(literals);
|
|
}
|
|
|
|
// Canonical shared unions from `src/uix/eidos/lib/types.ts`. Keep in sync
|
|
// with that file — duplicated here so the audit doesn't import Svelte types.
|
|
const SHARED_VARIANT_VOCAB = new Map<string, readonly string[]>([
|
|
['ControlVariant', ['surface', 'outline', 'ghost']],
|
|
['SelectionVariant', ['solid', 'outline', 'ghost']],
|
|
['ChipVariant', ['soft', 'solid', 'outline', 'ghost']],
|
|
['MarkerVariant', ['solid', 'soft', 'outline']],
|
|
// `segmented` joined the tabs archetype in EIDOS_VARIANTS (lib/types.ts) —
|
|
// keep this mirror in sync with that const.
|
|
['TabsVariant', ['line', 'surface', 'pills', 'segmented']],
|
|
// NOTE: canonical ColorRole (lib/config-types.ts) also includes `tertiary`,
|
|
// but it is RESERVED (no component consumes it) — component types expose
|
|
// the 8-value subset below. Revisit when tertiary gains a consumer.
|
|
['ColorRole', ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']],
|
|
['AffirmativeColorRole', ['primary', 'secondary', 'neutral', 'affirm']],
|
|
['ProgressiveColorRole', ['primary', 'secondary', 'neutral', 'affirm', 'fulfill']],
|
|
['EditableColorRole', ['primary', 'secondary', 'neutral', 'affirm', 'risk', 'threat']]
|
|
]);
|
|
|
|
// Find the chip array that the demo binds to the given prop. Two shapes:
|
|
// <span data-uix-control-label>{prop}</span> … {#each [...] as …}
|
|
// const {prop}s: SomeType[] = [...]
|
|
function extractDemoChipSet(
|
|
demoSrc: string,
|
|
prop: 'size' | 'variant' | 'color'
|
|
): Set<string> | null {
|
|
// Shape A: const declaration `const sizes/variants/colors: T[] = ['a', 'b']`.
|
|
const constRe = new RegExp(`const\\s+${prop}s(?:\\s*:\\s*[^=]+)?\\s*=\\s*\\[([^\\]]+)\\]`, 'm');
|
|
const cm = demoSrc.match(constRe);
|
|
if (cm) {
|
|
const literals = [...cm[1].matchAll(/'([a-z][a-z0-9-]*)'/g)].map((x) => x[1]);
|
|
if (literals.length) return new Set(literals);
|
|
}
|
|
// Shape B: inline `{#each ['a', 'b', 'c'] as …}` after a control label.
|
|
// Look for the control label line, then scan the next ~25 lines.
|
|
const labelRe = new RegExp(`data-uix-control-label[^>]*>\\s*${prop}(?:\\s|<)`, 'm');
|
|
const labelIdx = demoSrc.search(labelRe);
|
|
if (labelIdx === -1) return null;
|
|
const slice = demoSrc.slice(labelIdx, labelIdx + 800);
|
|
const arrRe = /\{#each\s*\[([^\]]+)\]\s*as\s+\w+/;
|
|
const am = slice.match(arrRe);
|
|
if (!am) return null;
|
|
const literals = [...am[1].matchAll(/'([a-z][a-z0-9-]*)'/g)].map((x) => x[1]);
|
|
if (!literals.length) return null;
|
|
return new Set(literals);
|
|
}
|
|
|
|
// S9 embark guard (checkpoint verdict, 2026-07-07): the F-1.x pre-flight
|
|
// dossier is BLOCKING (error) only for components embarked AFTER the
|
|
// checkpoint. The catalog snapshot in component-audit.grandfather.json is
|
|
// grandfathered to warn — its dossiers get written in the fix phase (P7)
|
|
// without painting the whole catalog red meanwhile.
|
|
const GRANDFATHERED: Set<string> = (() => {
|
|
const src = tryRead(join(__dirname, 'component-audit.grandfather.json'));
|
|
if (!src) return new Set();
|
|
try {
|
|
const parsed = JSON.parse(src) as { components?: string[] };
|
|
return new Set(parsed.components ?? []);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
})();
|
|
|
|
function checkReadme(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const path = join(EIDOS_DIR, kebab, 'README.md');
|
|
const src = tryRead(path);
|
|
if (!src) return out; // E-2.3 catches
|
|
|
|
const preflightSev = GRANDFATHERED.has(kebab) ? 'warn' : 'error';
|
|
|
|
// F-1.1 Baseline
|
|
if (/##\s*Baseline\b/m.test(src)) out.push(pass('F-1.1', preflightSev));
|
|
else out.push(fail('F-1.1', preflightSev, 'Missing ## Baseline section'));
|
|
|
|
// F-1.2 Comparativa (3+ external references).
|
|
// Use `\n## ` as the section terminator. The previous regex used `$` with
|
|
// the `m` flag, which matched end-of-line and truncated the section body
|
|
// to just the header.
|
|
const compMatch = src.match(/##\s*Comparativa\b[\s\S]*?(?=\n##\s|$)/);
|
|
if (compMatch) {
|
|
// Count rows in the comparison table
|
|
const rows = compMatch[0].match(/^\|/gm) || [];
|
|
const refCount = Math.max(0, rows.length - 2); // header + separator
|
|
if (refCount >= 3) out.push(pass('F-1.2', preflightSev, `${refCount} references`));
|
|
else
|
|
out.push(
|
|
fail('F-1.2', preflightSev, `Only ${refCount} references in ## Comparativa (need ≥3)`)
|
|
);
|
|
} else {
|
|
out.push(fail('F-1.2', preflightSev, 'Missing ## Comparativa section'));
|
|
}
|
|
|
|
// F-1.3 Decisiones
|
|
if (/##\s*Decisiones\b/m.test(src)) out.push(pass('F-1.3', 'warn'));
|
|
else out.push(fail('F-1.3', 'warn', 'Missing ## Decisiones section'));
|
|
|
|
// F-1.4 Gaps with disposition. Same regex fix as Comparativa above.
|
|
const gapsMatch = src.match(/##\s*Gaps\b[\s\S]*?(?=\n##\s|$)/);
|
|
if (gapsMatch) {
|
|
// Letter boundaries, not `\b`: prettier normalizes `*diferir*` to
|
|
// `_diferir_`, and `_` is a word character, so `\b` stopped matching.
|
|
const dispositions = (
|
|
gapsMatch[0].match(/(?<!\p{L})(implementar|diferir|descartar)(?!\p{L})/giu) || []
|
|
).length;
|
|
if (dispositions >= 1)
|
|
out.push(pass('F-1.4', preflightSev, `${dispositions} disposition markers`));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'F-1.4',
|
|
preflightSev,
|
|
'## Gaps lacks disposition markers (implementar/diferir/descartar)'
|
|
)
|
|
);
|
|
} else {
|
|
out.push(fail('F-1.4', preflightSev, 'Missing ## Gaps section'));
|
|
}
|
|
|
|
// F-1.5: passive components must justify
|
|
if (info.passive) {
|
|
if (/##\s*Passive\s+justification\b/im.test(src)) {
|
|
out.push(pass('F-1.5', preflightSev));
|
|
} else {
|
|
out.push(fail('F-1.5', preflightSev, 'Passive component lacks ## Passive justification'));
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// ─── Build report per component ─────────────────────────────────────────────
|
|
|
|
function buildReport(kebab: string): ComponentReport {
|
|
const morfoPath = join(MORFO_DIR, `${kebab}.ts`);
|
|
const eidosDir = join(EIDOS_DIR, kebab);
|
|
const demoPath = join(DEMO_DIR, kebab, '+page.svelte');
|
|
const morfoSrc = tryRead(morfoPath) ?? '';
|
|
|
|
// Classify: interactive if it declares events or keyboard keys — or if its
|
|
// PARTS are interactive by contract (archetype / role / element evidence).
|
|
// The old events||keys test was circular: "interactive with 0 events"
|
|
// (A-3.1) could never fire for a component whose only interactivity signal
|
|
// was its parts — pin-input (textbox Input, 0 events, 0 keys) audited as
|
|
// passive (checkpoint C8, 2026-07-07).
|
|
const eventCount = countEventBlocks(morfoSrc);
|
|
const keyCount = [...morfoSrc.matchAll(KEYBOARD_KEY_RE)].length;
|
|
const interactiveParts =
|
|
/archetype:\s*['"](?:trigger|field-trigger|input|item)['"]/.test(morfoSrc) ||
|
|
/\brole:\s*['"](?:button|textbox|searchbox|combobox|listbox|option|slider|spinbutton|switch|checkbox|radio|menuitem|menuitemcheckbox|menuitemradio|tab|gridcell)['"]/.test(
|
|
morfoSrc
|
|
) ||
|
|
/defaultElement:\s*['"](?:button|input|select|textarea)['"]/.test(morfoSrc);
|
|
const interactive = eventCount > 0 || keyCount > 0 || interactiveParts;
|
|
const passive = !interactive;
|
|
|
|
const report: ComponentReport = {
|
|
component: morfoSrc.match(/\bname:\s*['"]([^'"]+)['"]/)?.[1] ?? kebab,
|
|
kebab,
|
|
exists: {
|
|
morfo: morfoSrc.length > 0,
|
|
eidos: existsSync(eidosDir),
|
|
demo: existsSync(demoPath)
|
|
},
|
|
interactive,
|
|
passive,
|
|
checks: []
|
|
};
|
|
|
|
if (morfoSrc) report.checks.push(...checkMorfo(kebab, morfoSrc, report));
|
|
if (report.exists.eidos) {
|
|
report.checks.push(...checkEidos(kebab, report));
|
|
report.checks.push(...checkRecipe(kebab, report));
|
|
report.checks.push(...checkReadme(kebab, report));
|
|
}
|
|
if (report.exists.demo) {
|
|
// Demo rules downgraded to WARN (user decision, 2026-07-10): the demos
|
|
// will be re-homed in a NEW demo frame for the whole system, so drift
|
|
// against the CURRENT v1/v2 page template is not fix-work. The D-*
|
|
// data stays informational; the rules get rewritten against the new
|
|
// frame when it lands.
|
|
report.checks.push(
|
|
...checkDemo(kebab, report).map((check) =>
|
|
check.severity === 'error' ? { ...check, severity: 'warn' as const } : check
|
|
)
|
|
);
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
// ─── Verdict ────────────────────────────────────────────────────────────────
|
|
|
|
function verdict(r: ComponentReport): 'PASS' | 'NEEDS-WORK' | 'BROKEN' {
|
|
// D-* (demo) rules are verdict-NEUTRAL (user decision, 2026-07-10): the
|
|
// demos will be re-homed in a new demo frame, so current-template drift
|
|
// stays visible as data but never paints the component matrix.
|
|
const counted = r.checks.filter((c) => !c.passed && !c.rule.startsWith('D-'));
|
|
const errors = counted.filter((c) => c.severity === 'error').length;
|
|
const warns = counted.filter((c) => c.severity === 'warn').length;
|
|
if (errors > 5) return 'BROKEN';
|
|
if (errors > 0 || warns > 3) return 'NEEDS-WORK';
|
|
return 'PASS';
|
|
}
|
|
|
|
// ─── Markdown rendering ─────────────────────────────────────────────────────
|
|
|
|
function renderMarkdown(reports: ComponentReport[]): string {
|
|
const lines: string[] = [];
|
|
lines.push('# UIX component audit report');
|
|
lines.push('');
|
|
lines.push(
|
|
`> Generated by \`scripts/component-audit.ts\` against \`docs/guides/completion-checklist.md\`.`
|
|
);
|
|
lines.push(`> Date: ${new Date().toISOString()}`);
|
|
lines.push('');
|
|
|
|
// Summary table
|
|
const passCount = reports.filter((r) => verdict(r) === 'PASS').length;
|
|
const needsCount = reports.filter((r) => verdict(r) === 'NEEDS-WORK').length;
|
|
const brokenCount = reports.filter((r) => verdict(r) === 'BROKEN').length;
|
|
lines.push('## Summary');
|
|
lines.push('');
|
|
lines.push(`- **Total components audited**: ${reports.length}`);
|
|
lines.push(
|
|
`- **WIP tracks excluded** (unfinished, audited on demand via \`--only\`): ${[...WIP_TRACKS].join(', ')}`
|
|
);
|
|
lines.push(`- **PASS**: ${passCount}`);
|
|
lines.push(`- **NEEDS-WORK**: ${needsCount}`);
|
|
lines.push(`- **BROKEN**: ${brokenCount}`);
|
|
lines.push('');
|
|
|
|
// Sorted: BROKEN first, then NEEDS-WORK, then PASS
|
|
const order = { BROKEN: 0, 'NEEDS-WORK': 1, PASS: 2 };
|
|
reports.sort((a, b) => order[verdict(a)] - order[verdict(b)] || a.kebab.localeCompare(b.kebab));
|
|
|
|
lines.push('## Component scoreboard');
|
|
lines.push('');
|
|
lines.push('| Component | Verdict | Errors | Warns | Demo | Eidos | Type |');
|
|
lines.push('| --- | --- | ---: | ---: | :-: | :-: | --- |');
|
|
for (const r of reports) {
|
|
const errors = r.checks.filter((c) => !c.passed && c.severity === 'error').length;
|
|
const warns = r.checks.filter((c) => !c.passed && c.severity === 'warn').length;
|
|
const v = verdict(r);
|
|
const demo = r.exists.demo ? '✓' : '—';
|
|
const eidos = r.exists.eidos ? '✓' : '—';
|
|
const type = r.passive ? 'passive' : 'interactive';
|
|
lines.push(
|
|
`| \`${r.kebab}\` | **${v}** | ${errors} | ${warns} | ${demo} | ${eidos} | ${type} |`
|
|
);
|
|
}
|
|
lines.push('');
|
|
|
|
// Detail per component
|
|
lines.push('## Detail per component');
|
|
lines.push('');
|
|
for (const r of reports) {
|
|
const v = verdict(r);
|
|
const errors = r.checks.filter((c) => !c.passed && c.severity === 'error');
|
|
const warns = r.checks.filter((c) => !c.passed && c.severity === 'warn');
|
|
if (v === 'PASS' && errors.length === 0 && warns.length === 0) continue;
|
|
|
|
lines.push(`### ${r.kebab} — ${v}`);
|
|
lines.push('');
|
|
lines.push(
|
|
`- Morfo: ${r.exists.morfo ? '✓' : '—'} · Eidos wrapper: ${r.exists.eidos ? '✓' : '—'} · Demo: ${r.exists.demo ? '✓' : '—'} · Type: ${r.interactive ? 'interactive' : 'passive'}`
|
|
);
|
|
lines.push('');
|
|
if (errors.length) {
|
|
lines.push('**Errors:**');
|
|
lines.push('');
|
|
for (const c of errors) {
|
|
const filtered = minSeverity && severityRank(minSeverity) > severityRank('error');
|
|
if (filtered) continue;
|
|
lines.push(`- \`${c.rule}\` — ${c.detail}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
if (warns.length && (!minSeverity || severityRank(minSeverity) <= severityRank('warn'))) {
|
|
lines.push('**Warnings:**');
|
|
lines.push('');
|
|
for (const c of warns) {
|
|
lines.push(`- \`${c.rule}\` — ${c.detail}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function severityRank(s: Severity): number {
|
|
return s === 'error' ? 0 : s === 'warn' ? 1 : 2;
|
|
}
|
|
|
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
|
|
function main(): void {
|
|
const components = listComponents();
|
|
const reports: ComponentReport[] = [];
|
|
for (const c of components) {
|
|
try {
|
|
reports.push(buildReport(c.kebab));
|
|
} catch (err) {
|
|
console.error(`[component-audit] Failed on ${c.kebab}:`, err);
|
|
}
|
|
}
|
|
|
|
const md = renderMarkdown(reports);
|
|
mkdirSync(dirname(OUT), { recursive: true });
|
|
writeFileSync(OUT, md);
|
|
|
|
// Console summary
|
|
const broken = reports.filter((r) => verdict(r) === 'BROKEN').length;
|
|
const needs = reports.filter((r) => verdict(r) === 'NEEDS-WORK').length;
|
|
const pass = reports.filter((r) => verdict(r) === 'PASS').length;
|
|
console.log(`[component-audit] ${reports.length} components`);
|
|
console.log(` PASS: ${pass}`);
|
|
console.log(` NEEDS-WORK: ${needs}`);
|
|
console.log(` BROKEN: ${broken}`);
|
|
console.log(` Report: ${OUT}`);
|
|
|
|
// Exit non-zero if any BROKEN
|
|
process.exit(broken > 0 ? 1 : 0);
|
|
}
|
|
|
|
main();
|