|
|
// @vitest-environment jsdom
|
|
|
|
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
import { basename, dirname, join, sep } from 'node:path';
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
|
import { createActiveApp, type ActiveApp } from '$active-app';
|
|
|
import { createActiveDom } from '$adom';
|
|
|
import { createActivePrefs, createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';
|
|
|
import type { LangNode } from '$libs/langs';
|
|
|
import { state } from '$libs/reactive';
|
|
|
import {
|
|
|
ActiveUixMissingServiceError,
|
|
|
attachActiveUix,
|
|
|
createActiveUix
|
|
|
} from './active-uix/active-uix.svelte';
|
|
|
import { defineUixServices, UIX_REQUIRED_SERVICES } from './active-uix/services';
|
|
|
import { createActiveEidos, ActiveEidosConfigError } from './eidos/active-eidos.svelte';
|
|
|
import {
|
|
|
_resetMorfoRegistryForTesting,
|
|
|
registerMorfo,
|
|
|
type Morfo,
|
|
|
v
|
|
|
} from './morfo';
|
|
|
import { toggleMorfo } from './morfo/components/toggle';
|
|
|
import { EngineSemantic, defineEngineSemantic } from './sema';
|
|
|
import { SemaConfigError } from './sema/errors';
|
|
|
import { SEMA_FAMILY_POLICY } from './sema/types';
|
|
|
import { createSomaRuntime } from './soma/runtime.svelte';
|
|
|
import { UIX_LAYER_CONTRACTS } from './contracts';
|
|
|
import { commonLangs } from './langs';
|
|
|
|
|
|
const minimalLang = { schema: {} satisfies LangNode, defaultLocale: 'es' as const };
|
|
|
const prefsProjectionSchema = standardPrefsDimensions({
|
|
|
languages: ['es-ES', 'ar-EG'],
|
|
|
locales: ['es-ES', 'ar-EG'],
|
|
|
currencies: ['EUR'],
|
|
|
defaults: {
|
|
|
language: 'es-ES',
|
|
|
locale: 'es-ES',
|
|
|
currency: 'EUR'
|
|
|
}
|
|
|
});
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
const REPO_ROOT = join(HERE, '..', '..');
|
|
|
const SOURCE_EXTENSIONS = ['.ts', '.svelte'] as const;
|
|
|
|
|
|
function preferencesHarness() {
|
|
|
return {
|
|
|
getTheme: () => 'base',
|
|
|
getMode: () => 'light' as const,
|
|
|
getDensity: () => 'comfortable' as const,
|
|
|
getScaling: () => '100' as const,
|
|
|
onPreferenceChange: () => () => {}
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function listSourceFiles(root: string): string[] {
|
|
|
const stat = statSync(root);
|
|
|
if (stat.isFile()) {
|
|
|
return SOURCE_EXTENSIONS.some((ext) => root.endsWith(ext)) ? [root] : [];
|
|
|
}
|
|
|
|
|
|
return readdirSync(root)
|
|
|
.flatMap((entry) => listSourceFiles(join(root, entry)))
|
|
|
.filter(
|
|
|
(path) => !path.endsWith('.test.ts') && !path.includes(`${join('components', 'icon', 'lib')}`)
|
|
|
)
|
|
|
// Active-dev-track components are excluded from the audited catalogue
|
|
|
// (same doctrine as collectPublicSomaComponentDirs) — their files are
|
|
|
// skipped by every file-scanning guard until their track lands.
|
|
|
.filter(
|
|
|
(path) =>
|
|
|
![...ACTIVE_DEV_TRACK].some(
|
|
|
(name) =>
|
|
|
path.includes(`${join('components', name)}${sep}`) ||
|
|
|
path.endsWith(`${join('components', `${name}.ts`)}`)
|
|
|
)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function grepSources(root: string, pattern: RegExp): string[] {
|
|
|
return listSourceFiles(root).filter((file) => pattern.test(readFileSync(file, 'utf8')));
|
|
|
}
|
|
|
|
|
|
function stripComments(source: string): string {
|
|
|
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
|
}
|
|
|
|
|
|
function collectKnownMorfoDataAttrs(): Set<string> {
|
|
|
const attrs = new Set<string>();
|
|
|
// Public morfos (`morfo/components/*.ts`) AND internal primitives
|
|
|
// (`morfo/internal/*.ts`) — the internal directory holds picker-shell
|
|
|
// and any future shared chrome. Their `data-{name}` markers are part
|
|
|
// of the catalogue too (see audit-codex P1 #5).
|
|
|
const internalRoot = join(HERE, 'morfo', 'internal');
|
|
|
const morfoFiles = [
|
|
|
...listSourceFiles(join(HERE, 'morfo', 'components')),
|
|
|
...(existsSync(internalRoot) ? listSourceFiles(internalRoot) : [])
|
|
|
];
|
|
|
for (const file of morfoFiles) {
|
|
|
const source = stripComments(readFileSync(file, 'utf8'));
|
|
|
const component = /kebab:\s*'([^']+)'/.exec(source)?.[1];
|
|
|
if (!component) continue;
|
|
|
|
|
|
const partRe = /kebab:\s*'([^']+)'/g;
|
|
|
let partMatch: RegExpExecArray | null;
|
|
|
while ((partMatch = partRe.exec(source))) {
|
|
|
const part = partMatch[1];
|
|
|
attrs.add(part === 'provider' ? `data-${component}` : `data-${component}-${part}`);
|
|
|
}
|
|
|
|
|
|
const dataRe = /attr:\s*'([^']+)'/g;
|
|
|
let dataMatch: RegExpExecArray | null;
|
|
|
while ((dataMatch = dataRe.exec(source))) {
|
|
|
if (dataMatch[1].startsWith('data-')) attrs.add(dataMatch[1]);
|
|
|
}
|
|
|
}
|
|
|
// Compiler-emitted from every part's `archetype` declaration — a known
|
|
|
// name catalogue-wide. Composition overrides (a field-overlay trigger
|
|
|
// stamped over the composed Popover trigger's generic archetype) write it
|
|
|
// manually with the value their OWN morfo part declares.
|
|
|
attrs.add('data-archetype');
|
|
|
return attrs;
|
|
|
}
|
|
|
|
|
|
function collectDataAttrs(source: string): Set<string> {
|
|
|
const clean = stripComments(source);
|
|
|
const attrs = new Set<string>();
|
|
|
const stringRe = /['"`](data-[a-zA-Z0-9_-]+)['"`]/g;
|
|
|
const inlineRe = /\b(data-[a-zA-Z0-9_-]+)(?=(?:=|\s|>|\/|}|\]))/g;
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
|
while ((match = stringRe.exec(clean))) attrs.add(match[1]);
|
|
|
while ((match = inlineRe.exec(clean))) attrs.add(match[1]);
|
|
|
|
|
|
return attrs;
|
|
|
}
|
|
|
|
|
|
function toPascalName(kebab: string): string {
|
|
|
return kebab
|
|
|
.split('-')
|
|
|
.map((segment) => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`)
|
|
|
.join('');
|
|
|
}
|
|
|
|
|
|
// Soma component directories that are internal primitives, not public
|
|
|
// components. They ship under `soma/components/{name}/` because they
|
|
|
// have eidos chrome + a provider, but their morfo lives in
|
|
|
// `morfo/internal/` rather than `morfo/components/` (see audit-codex P1
|
|
|
// #5). Audit guards must skip them when comparing soma <-> morfo
|
|
|
// catalogues, otherwise the asymmetry produces a false positive.
|
|
|
// Soma modules that are NOT public component namespaces: shared chrome or
|
|
|
// behaviour bridges consumed by other layers, with no Provider/parts API of
|
|
|
// their own (each documents the split in its index.ts — the eidos root owns
|
|
|
// state + render; the bridge owns runtime emission / keyboard dispatch / pure
|
|
|
// nav maths). They are excluded from BOTH sides of the dir↔scope equality:
|
|
|
// a bridge may truthfully declare 'soma' in its morfo scope (it implements
|
|
|
// soma behaviour) without being a public soma component namespace.
|
|
|
// picker-shell — the pickers' shared footer chrome (no morfo of its own)
|
|
|
// metrics — live-update signal bridge (scope ['sema','eidos'])
|
|
|
// menu-dial — keyboard dispatch + linear nav maths + emission bridge
|
|
|
// onion-menu — keyboard dispatch + radial nav maths + emission bridge
|
|
|
const SOMA_INTERNAL_PRIMITIVES = new Set(['picker-shell', 'metrics', 'menu-dial', 'onion-menu']);
|
|
|
|
|
|
function collectPublicSomaComponentDirs(): string[] {
|
|
|
const root = join(HERE, 'soma', 'components');
|
|
|
return readdirSync(root, { withFileTypes: true })
|
|
|
.filter((entry) => entry.isDirectory())
|
|
|
.map((entry) => entry.name)
|
|
|
.filter(
|
|
|
(name) =>
|
|
|
name !== 'internal' &&
|
|
|
!SOMA_INTERNAL_PRIMITIVES.has(name) &&
|
|
|
!ACTIVE_DEV_TRACK.has(name) &&
|
|
|
existsSync(join(root, name, 'index.ts'))
|
|
|
)
|
|
|
.sort();
|
|
|
}
|
|
|
|
|
|
function collectMorfoComponentNamesByScope(scope: string): string[] {
|
|
|
const root = join(HERE, 'morfo', 'components');
|
|
|
return readdirSync(root)
|
|
|
.filter((file) => file.endsWith('.ts') && !file.endsWith('.test.ts'))
|
|
|
.filter((file) => {
|
|
|
const source = stripComments(readFileSync(join(root, file), 'utf8'));
|
|
|
return new RegExp(`scope:\\s*\\[[^\\]]*'${scope}'`).test(source);
|
|
|
})
|
|
|
.map((file) => file.replace(/\.ts$/, ''))
|
|
|
.filter((name) => !ACTIVE_DEV_TRACK.has(name))
|
|
|
.sort();
|
|
|
}
|
|
|
|
|
|
// Active-dev-track components, excluded from the audited/maintained catalogue.
|
|
|
//
|
|
|
// EMPTY since 2026-08-10 (author directive): `palabras` and `chronos` were the
|
|
|
// two entries, exempted while their tracks were in flight. The exemption is
|
|
|
// lifted — they answer to the same catalogue guards as everybody else, and a
|
|
|
// violation of theirs is a violation like any other. The set stays (typed, and
|
|
|
// consulted by six guards) because a future in-flight track is a legitimate use;
|
|
|
// what is not legitimate is leaving an entry in it after its track lands.
|
|
|
const ACTIVE_DEV_TRACK = new Set<string>([]);
|
|
|
|
|
|
// MOR-4 enumeration guard (clean-room 2026-07-10). The acceptance machinery
|
|
|
// (`component-audit`, `eidos-lint-all`) enumerates BY MORFO FILE — an eidos
|
|
|
// component dir with no morfo is invisible to every gate (card-group shipped
|
|
|
// that way). A morfo may live in `morfo/components/` (public catalogue) or
|
|
|
// `morfo/internal/` (shared layers, e.g. picker-shell). Exemptions, each with
|
|
|
// its own invariant:
|
|
|
// - shared internal layer still without a contract: spin-field (its CSS
|
|
|
// discipline is covered by recipe-css-contract; the foundation-scope
|
|
|
// R-* work owns the rest);
|
|
|
// - pure API aliases / glyph primitives with ZERO own surface (no css —
|
|
|
// the composed primitive's morfo IS the contract): icon-button (Button
|
|
|
// alias), svg (glyph atoms). The guard asserts the category stays true:
|
|
|
// the moment one ships a stylesheet it needs a morfo like everyone else.
|
|
|
const MORFO_EXEMPT_SHARED_LAYERS = new Set(['spin-field']);
|
|
|
const MORFO_EXEMPT_PURE_ALIASES = new Set(['icon-button', 'svg']);
|
|
|
|
|
|
function collectMorfoEnumerationViolations(): string[] {
|
|
|
const eidosRoot = join(HERE, 'eidos', 'components');
|
|
|
const morfoRoots = [join(HERE, 'morfo', 'components'), join(HERE, 'morfo', 'internal')];
|
|
|
const violations: string[] = [];
|
|
|
for (const entry of readdirSync(eidosRoot, { withFileTypes: true })) {
|
|
|
if (!entry.isDirectory()) continue;
|
|
|
const kebab = entry.name;
|
|
|
if (ACTIVE_DEV_TRACK.has(kebab) || MORFO_EXEMPT_SHARED_LAYERS.has(kebab)) continue;
|
|
|
if (MORFO_EXEMPT_PURE_ALIASES.has(kebab)) {
|
|
|
const css = readdirSync(join(eidosRoot, kebab)).filter((f) => f.endsWith('.css'));
|
|
|
if (css.length > 0)
|
|
|
violations.push(
|
|
|
`${kebab}: pure-alias exemption broken — ships ${css.join(', ')} → needs a morfo`
|
|
|
);
|
|
|
continue;
|
|
|
}
|
|
|
if (!morfoRoots.some((root) => existsSync(join(root, `${kebab}.ts`))))
|
|
|
violations.push(`${kebab}: no morfo — invisible to component-audit / eidos-lint`);
|
|
|
}
|
|
|
return violations.sort();
|
|
|
}
|
|
|
|
|
|
// SYS-1 scope-drift guard. `scope` enumerates the layers that IMPLEMENT a
|
|
|
// component, so a component shipping an eidos recipe (a wrapper `.svelte` or a
|
|
|
// `.css` under `eidos/components/{kebab}/` — a README-only dir does NOT count)
|
|
|
// MUST list `'eidos'` in its morfo `scope`. The 2026-06-26 audit found this
|
|
|
// drifted across 62 components; this guard keeps it from drifting back.
|
|
|
function collectScopeDriftViolations(): string[] {
|
|
|
const internalRoot = join(HERE, 'morfo', 'internal');
|
|
|
const eidosRoot = join(HERE, 'eidos', 'components');
|
|
|
const morfoFiles = [
|
|
|
...listSourceFiles(join(HERE, 'morfo', 'components')),
|
|
|
...(existsSync(internalRoot) ? listSourceFiles(internalRoot) : [])
|
|
|
];
|
|
|
const violations: string[] = [];
|
|
|
|
|
|
for (const file of morfoFiles) {
|
|
|
const source = stripComments(readFileSync(file, 'utf8'));
|
|
|
const kebab = /kebab:\s*'([^']+)'/.exec(source)?.[1];
|
|
|
if (kebab && ACTIVE_DEV_TRACK.has(kebab)) continue;
|
|
|
// The morfo-level scope is the only `scope: [ ... ]` ARRAY (part/event
|
|
|
// scope is a string literal), so the first array match is it.
|
|
|
const scopeBody = /\bscope:\s*\[([^\]]*)\]/.exec(source)?.[1];
|
|
|
if (!kebab || scopeBody === undefined) continue;
|
|
|
|
|
|
const eidosDir = join(eidosRoot, kebab);
|
|
|
const hasEidosRecipe =
|
|
|
existsSync(eidosDir) &&
|
|
|
readdirSync(eidosDir).some((entry) => entry.endsWith('.svelte') || entry.endsWith('.css'));
|
|
|
if (hasEidosRecipe && !/'eidos'/.test(scopeBody)) {
|
|
|
violations.push(`${kebab}: eidos recipe exists but morfo scope omits 'eidos'`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return violations.sort();
|
|
|
}
|
|
|
|
|
|
// VG-8: a component morfo MUST be declared `as const satisfies Morfo`, never
|
|
|
// `: Morfo`. The annotation widens the literal object to the interface, so the
|
|
|
// compiler stops checking the contract against the actual literals — that is
|
|
|
// exactly how alert-dialog-001 shipped a morfo the schema couldn't validate.
|
|
|
function collectMorfoDeclarationViolations(): string[] {
|
|
|
const internalRoot = join(HERE, 'morfo', 'internal');
|
|
|
const morfoFiles = [
|
|
|
...listSourceFiles(join(HERE, 'morfo', 'components')),
|
|
|
...(existsSync(internalRoot) ? listSourceFiles(internalRoot) : [])
|
|
|
];
|
|
|
const violations: string[] = [];
|
|
|
|
|
|
for (const file of morfoFiles) {
|
|
|
const source = stripComments(readFileSync(file, 'utf8'));
|
|
|
const kebab = /kebab:\s*'([^']+)'/.exec(source)?.[1];
|
|
|
if (kebab && ACTIVE_DEV_TRACK.has(kebab)) continue;
|
|
|
if (!/export const \w+Morfo\b/.test(source)) continue;
|
|
|
|
|
|
const name = file.replace(REPO_ROOT, '');
|
|
|
if (/export const \w+Morfo\s*:\s*Morfo\b/.test(source)) {
|
|
|
violations.push(`${name}: ': Morfo' widens the literal — use 'as const satisfies Morfo'`);
|
|
|
} else if (!/as const satisfies Morfo\b/.test(source)) {
|
|
|
violations.push(`${name}: missing 'as const satisfies Morfo'`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return violations.sort();
|
|
|
}
|
|
|
|
|
|
// D9: an event a morfo declares and no provider emits is INERT — it reads like
|
|
|
// a promise and does nothing. `book-deviations.md` types the sibling defect (a
|
|
|
// channel signature the activation does not include) as «mecánicamente
|
|
|
// comprobable y merece guard»; this is the same shape one level up, and it is
|
|
|
// the class that produced D1 (AlertDialog mute), D3 (`commit-reset` in five
|
|
|
// pickers), D4 (ColorPicker) and D9 (chronos' whole carry).
|
|
|
//
|
|
|
// The test is EMISSION, not `trigger('literal')`: chronos dispatches through a
|
|
|
// variable (`emitOnChip`), so a literal-call grep reports a false negative — it
|
|
|
// did, twice, during the audit. What holds is weaker and robust: the name has to
|
|
|
// appear as a string literal SOMEWHERE the provider can reach. That means the
|
|
|
// component's own soma dir plus the shared libraries a provider dispatches from
|
|
|
// — `$libs/selection` is where combobox's and select's `commit-select` lives,
|
|
|
// and without it they read as inert when they are not.
|
|
|
const INERT_EVENT_SCOPE = ['libs', join('uix', 'soma', 'datetime'), join('uix', 'soma', 'layers')];
|
|
|
|
|
|
// EMPTY since 2026-08-13 — the 7-entry queue measured on 2026-08-12 (ledger
|
|
|
// D13) resolved one by one under M1(ii): tooltip ×3 WIRED (the F4 recipe),
|
|
|
// virtual-list/grid ×3 declared `emission: 'host'` (the provider's written
|
|
|
// decision, now expressible), gradient-picker commit-reset RETIRED (S-14 —
|
|
|
// its transaction is delegated to the composed generic Picker). Kept as a
|
|
|
// set so the next real instance has a signed place to land.
|
|
|
const INERT_EVENT_DEBT = new Set<string>([]);
|
|
|
|
|
|
function sourceFilesUnder(root: string): string[] {
|
|
|
if (!existsSync(root)) return [];
|
|
|
return listSourceFiles(root).filter((f) => !f.includes('.test.'));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Two properties of this scan, both MEASURED (census over the 255 declared
|
|
|
* events, 2026-08-13) — do not "tighten" either without re-running it:
|
|
|
*
|
|
|
* - The match is a plain quoted-name lookup, not `trigger('name')`, because the
|
|
|
* name very often reaches the call through a VARIABLE: `const eventName =
|
|
|
* next ? 'commit-select' : 'commit-unselect'; trigger(eventName)`. Requiring
|
|
|
* the literal inside the call turns 27 real emissions into violations
|
|
|
* (checkbox, accordion, calendar, select, combobox, listbox, grid-list,
|
|
|
* tag-group, range-calendar, chronos, aura, palabras…). Its price: a name
|
|
|
* quoted in a COMMENT satisfies the check — that is how onion-menu's two
|
|
|
* drill events passed until their factory docblock was reworded.
|
|
|
* - The blob covers the component's EIDOS directory as well as its soma one.
|
|
|
* For the view components (metrics, menu-dial, onion-menu) there is no
|
|
|
* provider class: the eidos view registers the parts and emits, so scanning
|
|
|
* soma alone reported 4 live events as inert.
|
|
|
*/
|
|
|
function collectInertEventViolations(): string[] {
|
|
|
const morfoDir = join(HERE, 'morfo', 'components');
|
|
|
const somaDir = join(HERE, 'soma', 'components');
|
|
|
const eidosDir = join(HERE, 'eidos', 'components');
|
|
|
const shared = INERT_EVENT_SCOPE.flatMap((rel) =>
|
|
|
sourceFilesUnder(join(REPO_ROOT, 'src', rel))
|
|
|
)
|
|
|
.map((f) => readFileSync(f, 'utf8'))
|
|
|
.join('\n');
|
|
|
|
|
|
const violations: string[] = [];
|
|
|
const fixed: string[] = [];
|
|
|
|
|
|
for (const file of listSourceFiles(morfoDir)) {
|
|
|
const component = basename(file, '.ts');
|
|
|
const source = readFileSync(file, 'utf8');
|
|
|
// `semantic:` following `name:` is what discriminates an EVENT from a
|
|
|
// part (parts have `name:` too). The optional middle line is `emission`
|
|
|
// (D.2) — without it in the pattern, an event carrying the flag between
|
|
|
// the two anchors would fall out of the census entirely: invisible, not
|
|
|
// exempt. Measured with the probe that added the flag to a real event.
|
|
|
const matches = [
|
|
|
...source.matchAll(
|
|
|
/name:\s*'([a-z]+-[a-z0-9-]+)',\s*\n(?:\s*emission:\s*'[a-z-]+',\s*\n)?\s*semantic:/g
|
|
|
)
|
|
|
];
|
|
|
if (matches.length === 0) continue;
|
|
|
const dir = join(somaDir, component);
|
|
|
if (!existsSync(dir)) continue;
|
|
|
|
|
|
const blob =
|
|
|
[...sourceFilesUnder(dir), ...sourceFilesUnder(join(eidosDir, component))]
|
|
|
.map((f) => readFileSync(f, 'utf8'))
|
|
|
.join('\n') + shared;
|
|
|
|
|
|
for (const [i, match] of matches.entries()) {
|
|
|
const name = match[1];
|
|
|
const id = `${component}.${name}`;
|
|
|
// D.2: a declared `emission` other than 'runtime' says WHO fires the
|
|
|
// event instead of this repo's providers — declared exemption, not
|
|
|
// debt. The chunk runs to the next event declaration, so the flag is
|
|
|
// read from the event it belongs to.
|
|
|
const chunk = source.slice(match.index, matches[i + 1]?.index);
|
|
|
const emission =
|
|
|
/emission:\s*'(runtime|host|external|declared-only)'/.exec(chunk)?.[1] ?? 'runtime';
|
|
|
if (emission !== 'runtime') {
|
|
|
if (INERT_EVENT_DEBT.has(id)) fixed.push(id);
|
|
|
continue;
|
|
|
}
|
|
|
const emitted = blob.includes(`'${name}'`) || blob.includes(`"${name}"`);
|
|
|
if (emitted) {
|
|
|
if (INERT_EVENT_DEBT.has(id)) fixed.push(id);
|
|
|
continue;
|
|
|
}
|
|
|
if (INERT_EVENT_DEBT.has(id)) continue;
|
|
|
violations.push(`${id}: declared, never emitted`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
for (const id of fixed.sort()) violations.push(`${id}: FIXED — remove it from INERT_EVENT_DEBT`);
|
|
|
return violations.sort();
|
|
|
}
|
|
|
|
|
|
// D8: `defaultElement` is a CONTRACT — `assertContract`, the eidos recipes and
|
|
|
// anyone reading the morfo take it as the tag that lands in the DOM. Nothing
|
|
|
// enforced it, and a census on 2026-08-12 found 32 parts where it lied. Seven
|
|
|
// were the whole `arrow` family (all declaring `div`; five render the shared
|
|
|
// primitive's `<span>`, two an `<svg>` of their own) and five were field labels
|
|
|
// declaring `label` while rendering `<div>`. Both are fixed; the rest are in
|
|
|
// `DEFAULT_ELEMENT_DEBT` below because each needs a per-row decision about
|
|
|
// WHICH side is wrong, and several are a11y calls (a declared `li` rendered as
|
|
|
// a `div` loses the list semantics APG expects). Ledger: D8 / D11.
|
|
|
const DEFAULT_ELEMENT_DEBT = new Set<string>([
|
|
|
// Each of these needs a change with CONSEQUENCES, not a declaration edit.
|
|
|
// Ledger D11 carries the reasoning per row.
|
|
|
|
|
|
// EMPTY, and it should stay that way. The 28 rows the census found were
|
|
|
// closed on 2026-08-12; the last four each needed a change with consequences
|
|
|
// rather than a declaration edit, and got one:
|
|
|
//
|
|
|
// chronos.event-chip the morfo was right (`div` + `role='button'`,
|
|
|
// because the chip CONTAINS the resize handle and
|
|
|
// a button admits no nested control). The
|
|
|
// affordances a real button gives for free —
|
|
|
// `tabindex` and Enter/Space activation — are
|
|
|
// built now, so the element could follow.
|
|
|
// tooltip.trigger a role-less focusable `div` became the `<button>`
|
|
|
// its morfo always declared, with the `type`
|
|
|
// literal so it cannot submit a form.
|
|
|
// stepper.item a generic wrapper between `role='tablist'` and
|
|
|
// `role='tab'` broke the owns relationship; it is
|
|
|
// `role='presentation'` now, like an `<li>` inside
|
|
|
// a menu.
|
|
|
// color-picker.channel-input declared `input` while composing
|
|
|
// `ColorField.Input`, which is `div` + `role='group'`.
|
|
|
// No composition could have satisfied it.
|
|
|
//
|
|
|
// A new entry here is a decision to ship a contract that lies. Fix the side
|
|
|
// that is wrong instead: the morfo when the component renders the more
|
|
|
// correct element, the component when the morfo does.
|
|
|
]);
|
|
|
|
|
|
/**
|
|
|
* The tag a soma part component renders as the PART element — the one that
|
|
|
* spreads the merged props. Follows one level of indirection (`<Arrow …>` →
|
|
|
* `components/internal/arrow.svelte`), so parts that delegate to a shared
|
|
|
* primitive are covered rather than silently skipped: that hole is what let the
|
|
|
* whole `arrow` family drift unnoticed.
|
|
|
*
|
|
|
* The rule is the SPREAD, not "the tag in the last `{:else}`". The first draft
|
|
|
* used the latter and went FALSE-GREEN: `internal/arrow.svelte`'s last `{:else}`
|
|
|
* holds a decorative `<svg {width} {height}>` with no spread, so the match
|
|
|
* failed, the tag came back `undefined`, and an injected regression on
|
|
|
* `popover.arrow` passed the guard. Caught by injecting one before trusting it.
|
|
|
*/
|
|
|
const PART_ELEMENT = /<([A-Za-z][A-Za-z0-9]*)\s+\{\.\.\.\w*[Pp]rops/g;
|
|
|
|
|
|
/**
|
|
|
* ALL of them, not the first: a part can be polymorphic by design —
|
|
|
* `media-player.media` renders `<audio>` or `<video>` by media kind,
|
|
|
* `breadcrumb.link` a `<span>` for the current page and an `<a>` otherwise.
|
|
|
* `defaultElement` is the DEFAULT, so the contract holds when the declared tag
|
|
|
* is among the branches; reading only the first would flag both as lies.
|
|
|
*/
|
|
|
function renderedTags(file: string, depth = 0): string[] {
|
|
|
if (!existsSync(file)) return [];
|
|
|
const source = readFileSync(file, 'utf8');
|
|
|
const out: string[] = [];
|
|
|
for (const [, tag] of source.matchAll(PART_ELEMENT)) {
|
|
|
if (tag[0] === tag[0].toLowerCase()) {
|
|
|
out.push(tag);
|
|
|
continue;
|
|
|
}
|
|
|
if (depth > 0) continue;
|
|
|
const spec = new RegExp(`import\\s+${tag}\\s+from\\s+'([^']+)'`).exec(source)?.[1];
|
|
|
if (spec) out.push(...renderedTags(join(dirname(file), spec), depth + 1));
|
|
|
}
|
|
|
return [...new Set(out)];
|
|
|
}
|
|
|
|
|
|
function collectDefaultElementViolations(): string[] {
|
|
|
const morfoDir = join(HERE, 'morfo', 'components');
|
|
|
const somaDir = join(HERE, 'soma', 'components');
|
|
|
const violations: string[] = [];
|
|
|
const fixed: string[] = [];
|
|
|
|
|
|
for (const file of listSourceFiles(morfoDir)) {
|
|
|
const component = basename(file, '.ts');
|
|
|
const source = stripComments(readFileSync(file, 'utf8'));
|
|
|
const parts = /kebab:\s*'([a-z0-9-]+)'[\s\S]{0,400}?defaultElement:\s*'([a-z0-9]+)'/g;
|
|
|
let match: RegExpExecArray | null;
|
|
|
while ((match = parts.exec(source))) {
|
|
|
const [, part, declared] = match;
|
|
|
const rendered = renderedTags(
|
|
|
join(somaDir, component, 'components', `${component}-${part}.svelte`)
|
|
|
);
|
|
|
if (rendered.length === 0) continue;
|
|
|
const id = `${component}.${part}`;
|
|
|
if (rendered.includes(declared)) {
|
|
|
if (DEFAULT_ELEMENT_DEBT.has(id)) fixed.push(id);
|
|
|
continue;
|
|
|
}
|
|
|
if (DEFAULT_ELEMENT_DEBT.has(id)) continue;
|
|
|
const shape = rendered.map((t) => `<${t}>`).join(' | ');
|
|
|
violations.push(`${id}: morfo says <${declared}>, component renders ${shape}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// A debt list that outlives its debt is a lie in the other direction.
|
|
|
for (const id of fixed.sort()) {
|
|
|
violations.push(`${id}: FIXED — remove it from DEFAULT_ELEMENT_DEBT`);
|
|
|
}
|
|
|
return violations.sort();
|
|
|
}
|
|
|
|
|
|
// A31: a per-item membership predicate (`isSelected` / `isItemChecked` /
|
|
|
// `isExpanded` / `isItemPressed` / …) must NOT read a reactive array with
|
|
|
// `.current.includes(...)` / `.indexOf(...)` — called once per item that is
|
|
|
// O(N²) and the documented "hangs at 30+" hazard. Lift a `Set` in a provider
|
|
|
// `$derived` and do O(1) `.has(value)` (the 9 components the audit fixed all do).
|
|
|
function collectA31Violations(): string[] {
|
|
|
const root = join(HERE, 'soma', 'components');
|
|
|
const re =
|
|
|
/\bis\w*(?:Selected|Checked|Expanded|Pressed|Highlighted|Active)\b[^{};]*\{[^{}]*\.current\.(?:includes|indexOf)\b/;
|
|
|
return listSourceFiles(root)
|
|
|
.filter((file) => file.endsWith('.svelte.ts'))
|
|
|
.filter((file) => !ACTIVE_DEV_TRACK.has(basename(dirname(file))))
|
|
|
.filter((file) => re.test(stripComments(readFileSync(file, 'utf8'))))
|
|
|
.map((file) => `${file.replace(REPO_ROOT, '')}: per-item membership uses .current.includes (A31 O(N²))`)
|
|
|
.sort();
|
|
|
}
|
|
|
|
|
|
// A30: a child must register its `inputId` with the parent Field via a DIRECT
|
|
|
// constructor assign, never inside a `$effect` — an effect for a one-shot id
|
|
|
// registration is wasteful + order-fragile (the 12 field-family providers that
|
|
|
// do it right assign directly; date/time/color-field drifted into an effect).
|
|
|
//
|
|
|
// The source MUST be `opts.<x>.current` — a prop that is already available at
|
|
|
// construction, so the effect is pure overhead. A reactive source set LATER by
|
|
|
// a child part (file-upload's `this.hiddenInputId`, '' until the hidden-input
|
|
|
// child mounts) genuinely needs the effect to observe it; that is NOT the
|
|
|
// anti-pattern and the `opts.*` requirement correctly leaves it green.
|
|
|
function collectA30Violations(): string[] {
|
|
|
const root = join(HERE, 'soma', 'components');
|
|
|
// `$effect(() => { … inputId.current = opts.X.current … })`, tempered so the
|
|
|
// assign must live INSIDE this effect's body (stop at the `});` that closes it).
|
|
|
const re =
|
|
|
/\$effect\s*\(\s*\(\s*\)\s*=>\s*\{(?:(?!\}\);)[\s\S]){0,400}?inputId\.current\s*=\s*opts\.\w+\.current/;
|
|
|
return listSourceFiles(root)
|
|
|
.filter((file) => file.endsWith('.svelte.ts'))
|
|
|
.filter((file) => !ACTIVE_DEV_TRACK.has(basename(dirname(file))))
|
|
|
.filter((file) => re.test(stripComments(readFileSync(file, 'utf8'))))
|
|
|
.map((file) => `${file.replace(REPO_ROOT, '')}: inputId registered inside a $effect (A30)`)
|
|
|
.sort();
|
|
|
}
|
|
|
|
|
|
// THEME-SYS-1: overlay z-index lives in a named scale, never a raw integer.
|
|
|
// A recipe `*-z` token must reference `var(--z-index-overlay-*)` (the flat
|
|
|
// overlay micro-band) or `var(--z-index-*)` (the depth-plane ladder) — a bare
|
|
|
// integer is an ad-hoc parallel scale that drifts out of order (the audit found
|
|
|
// tooltip 76 < dropdown 80). Local `z-index: 0..5` in component CSS is relative
|
|
|
// stacking, NOT this — it stays (THEMING.md §35). This guards the recipe defs.
|
|
|
function collectMagicZViolations(): string[] {
|
|
|
const base = stripComments(readFileSync(join(HERE, 'eidos', 'lib', 'recipes', 'base.ts'), 'utf8'));
|
|
|
const violations: string[] = [];
|
|
|
const re = /'([\w-]*-z)':\s*'(\d[^']*)'/g;
|
|
|
let match: RegExpExecArray | null;
|
|
|
while ((match = re.exec(base))) {
|
|
|
violations.push(
|
|
|
`recipes/base.ts: '${match[1]}': '${match[2]}' is a raw z-index — use var(--z-index-overlay-*)`
|
|
|
);
|
|
|
}
|
|
|
return violations.sort();
|
|
|
}
|
|
|
|
|
|
function collectComponentLangPathViolations(source: string, file: string): string[] {
|
|
|
const clean = stripComments(source);
|
|
|
const violations: string[] = [];
|
|
|
const refRe = /#\?components\.([^|'"`\s]+)/g;
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
|
while ((match = refRe.exec(clean))) {
|
|
|
const path = match[1];
|
|
|
const segments = path.split('.');
|
|
|
if (segments.some((segment) => /[A-Z]/.test(segment))) {
|
|
|
violations.push(`${file.replace(REPO_ROOT, '')}: components.${path}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return violations;
|
|
|
}
|
|
|
|
|
|
function collectLangCatalogKeyViolations(node: unknown, prefix = 'components'): string[] {
|
|
|
if (!node || typeof node !== 'object') return [];
|
|
|
|
|
|
return Object.entries(node as Record<string, unknown>).flatMap(([key, value]) => {
|
|
|
const path = `${prefix}.${key}`;
|
|
|
const ownViolation = /[A-Z]/.test(key) ? [path] : [];
|
|
|
return [...ownViolation, ...collectLangCatalogKeyViolations(value, path)];
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function collectBrokenSomaComponentReadmeLinks(): string[] {
|
|
|
const root = join(HERE, 'soma', 'components');
|
|
|
return collectPublicSomaComponentDirs().flatMap((dir) => {
|
|
|
const file = join(root, dir, 'README.md');
|
|
|
const source = readFileSync(file, 'utf8');
|
|
|
const violations: string[] = [];
|
|
|
const linkRe = /\]\((\.\.\/[^)]+README\.md)\)/g;
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
|
while ((match = linkRe.exec(source))) {
|
|
|
const target = join(root, dir, match[1]);
|
|
|
if (!existsSync(target)) {
|
|
|
violations.push(`${dir}/README.md -> ${match[1]}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return violations;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
describe('UIX layer contracts', () => {
|
|
|
// S-19(ii) — ONE sink, materialized by the root. Only the composition root
|
|
|
// holds both ends (the shared live-region sink and the engine), so it
|
|
|
// registers the announce channel with a late-bound closure; an app-written
|
|
|
// wiring could only reach the channel's self-owned fallback — a SECOND
|
|
|
// live pair, the thing AUX-1 forbids.
|
|
|
it('registers the announce channel on the SHARED sink (S-19 ii)', async () => {
|
|
|
const uix = createActiveUix({ langs: minimalLang });
|
|
|
try {
|
|
|
expect(uix.events?.getChannel('announce')).toBeDefined();
|
|
|
const target = document.createElement('div');
|
|
|
document.body.appendChild(target);
|
|
|
await uix.events!.emit({
|
|
|
target,
|
|
|
name: 'signal-announce',
|
|
|
family: 'signal',
|
|
|
intent: 'threat',
|
|
|
message: 'Peligro'
|
|
|
});
|
|
|
// The message lands in uix's OWN assertive region…
|
|
|
expect(document.getElementById('uix-announce-assertive')?.textContent).toContain('Peligro');
|
|
|
// …and the channel never grew its fallback pair: ONE alert region total.
|
|
|
expect(document.querySelectorAll("[role='alert']").length).toBe(1);
|
|
|
target.remove();
|
|
|
} finally {
|
|
|
uix.dispose();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('honours the explicit announce opt-out at the root', () => {
|
|
|
const uix = createActiveUix({ langs: minimalLang, events: { announce: false } });
|
|
|
try {
|
|
|
expect(uix.events?.getChannel('announce')).toBeUndefined();
|
|
|
} finally {
|
|
|
uix.dispose();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('pins ActiveUix public service names and rejects legacy aliases', () => {
|
|
|
const uix = createActiveUix({ langs: minimalLang });
|
|
|
try {
|
|
|
for (const key of UIX_LAYER_CONTRACTS.activeUix.publicSurface) {
|
|
|
expect(key in uix, key).toBe(true);
|
|
|
}
|
|
|
for (const key of UIX_LAYER_CONTRACTS.activeUix.forbiddenPublicSurface) {
|
|
|
expect(key in uix, key).toBe(false);
|
|
|
}
|
|
|
|
|
|
expect(uix.ownsApp).toBe(true);
|
|
|
expect(uix.app).toBeUndefined();
|
|
|
expect(uix.events).toBeInstanceOf(EngineSemantic);
|
|
|
} finally {
|
|
|
uix.dispose();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('keeps attach mode as consumption-only: required services are langs + dom', () => {
|
|
|
expect(UIX_REQUIRED_SERVICES).toEqual(UIX_LAYER_CONTRACTS.activeUix.attachRequires);
|
|
|
|
|
|
const app = createActiveApp({ services: {} });
|
|
|
try {
|
|
|
attachActiveUix(app as unknown as ActiveApp);
|
|
|
expect.fail('attachActiveUix should reject an app without UIX services');
|
|
|
} catch (err) {
|
|
|
if (!(err instanceof ActiveUixMissingServiceError)) throw err;
|
|
|
expect(err.missing).toEqual(['langs', 'dom']);
|
|
|
expect(err.missing).not.toContain('events');
|
|
|
} finally {
|
|
|
app.dispose();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('defineUixServices publishes events, never semantic, and cannot attach without dom', () => {
|
|
|
const services = defineUixServices({ langs: minimalLang });
|
|
|
expect(Object.keys(services).sort()).toEqual([
|
|
|
'clipboard',
|
|
|
'dom',
|
|
|
'events',
|
|
|
'format',
|
|
|
'langs',
|
|
|
'motion',
|
|
|
'scene',
|
|
|
'sound'
|
|
|
]);
|
|
|
expect('semantic' in services).toBe(false);
|
|
|
expect('lang' in services).toBe(false);
|
|
|
expect('frontend' in services).toBe(false);
|
|
|
|
|
|
const noDomServices = defineUixServices({ langs: minimalLang, dom: false });
|
|
|
expect('dom' in noDomServices).toBe(false);
|
|
|
expect('events' in noDomServices).toBe(false);
|
|
|
expect('motion' in noDomServices).toBe(false);
|
|
|
expect('sound' in noDomServices).toBe(false);
|
|
|
|
|
|
const app = createActiveApp({ services: noDomServices });
|
|
|
try {
|
|
|
expect(() => attachActiveUix(app as unknown as ActiveApp)).toThrow(
|
|
|
ActiveUixMissingServiceError
|
|
|
);
|
|
|
} finally {
|
|
|
app.dispose();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('guards ActiveUix as composition root, not Soma/Eidos bootstrapper', () => {
|
|
|
const source = readFileSync(join(HERE, 'active-uix', 'active-uix.svelte.ts'), 'utf8');
|
|
|
|
|
|
expect(source).not.toMatch(/from ['"].*\/soma/);
|
|
|
expect(source).not.toMatch(/from ['"].*\/eidos/);
|
|
|
expect(source).not.toMatch(/\bSoma\.create\(/);
|
|
|
expect(source).not.toMatch(/\bActiveEidos\.create\(/);
|
|
|
expect(source).not.toMatch(/\bEidos\.create\(/);
|
|
|
});
|
|
|
|
|
|
it('prefs DOM projection owns only cross-modal attrs', () => {
|
|
|
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.implementation).toBe(
|
|
|
'$prefs.createActivePrefsDomProjection'
|
|
|
);
|
|
|
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.requires).toEqual(['prefs', 'dom']);
|
|
|
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.ownsAttrs).toEqual([
|
|
|
'dir',
|
|
|
'lang',
|
|
|
'data-motion',
|
|
|
'data-sound',
|
|
|
'data-haptic'
|
|
|
]);
|
|
|
expect(UIX_LAYER_CONTRACTS.prefsDomProjection.forbiddenAttrs).toEqual([
|
|
|
'data-theme',
|
|
|
'data-mode',
|
|
|
'data-density'
|
|
|
]);
|
|
|
});
|
|
|
|
|
|
it('implements cross-modal projection in prefs, not ActiveUix', () => {
|
|
|
const dom = createActiveDom();
|
|
|
const prefs = createActivePrefs({ schema: prefsProjectionSchema });
|
|
|
const target = document.createElement('div');
|
|
|
const projection = createActivePrefsDomProjection({ prefs, dom, target });
|
|
|
|
|
|
try {
|
|
|
for (const attr of UIX_LAYER_CONTRACTS.prefsDomProjection.ownsAttrs) {
|
|
|
expect(target.hasAttribute(attr), attr).toBe(true);
|
|
|
}
|
|
|
for (const attr of UIX_LAYER_CONTRACTS.prefsDomProjection.forbiddenAttrs) {
|
|
|
expect(target.hasAttribute(attr), attr).toBe(false);
|
|
|
}
|
|
|
} finally {
|
|
|
projection.dispose();
|
|
|
prefs.dispose();
|
|
|
dom.dispose();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('SomaRuntime has one public part API and only depends on injected services', async () => {
|
|
|
const dom = createActiveDom();
|
|
|
try {
|
|
|
const runtime = createSomaRuntime(toggleMorfo, { dom });
|
|
|
for (const key of UIX_LAYER_CONTRACTS.somaRuntime.publicSurface) {
|
|
|
expect(key in runtime, key).toBe(true);
|
|
|
}
|
|
|
for (const key of UIX_LAYER_CONTRACTS.somaRuntime.forbiddenPublicSurface) {
|
|
|
expect(key in runtime, key).toBe(false);
|
|
|
}
|
|
|
|
|
|
const id = state('toggle-contract');
|
|
|
runtime.part('provider', { id });
|
|
|
expect(runtime.partProps('provider')).toMatchObject({
|
|
|
id: 'toggle-contract',
|
|
|
'data-toggle': ''
|
|
|
});
|
|
|
} finally {
|
|
|
dom.dispose();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('guards Soma components from legacy runtime registration APIs', () => {
|
|
|
const violations = grepSources(
|
|
|
join(HERE, 'soma', 'components'),
|
|
|
/registerContract\(|registerPart\(/
|
|
|
);
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma component barrel exports all public component modules', () => {
|
|
|
const indexSource = readFileSync(join(HERE, 'soma', 'components', 'index.ts'), 'utf8');
|
|
|
const missingExports = collectPublicSomaComponentDirs()
|
|
|
.map((dir) => ({
|
|
|
dir,
|
|
|
name: toPascalName(dir),
|
|
|
exportRe: new RegExp(`export \\* as ${toPascalName(dir)} from ['"]\\./${dir}['"];`)
|
|
|
}))
|
|
|
.filter(({ exportRe }) => !exportRe.test(indexSource))
|
|
|
.map(({ dir, name }) => `${dir} -> ${name}`);
|
|
|
|
|
|
expect(missingExports).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma Provider exports use the canonical root wrapper filename', () => {
|
|
|
const violations = collectPublicSomaComponentDirs()
|
|
|
.filter((dir) => {
|
|
|
const source = readFileSync(join(HERE, 'soma', 'components', dir, 'exports.ts'), 'utf8');
|
|
|
const expected = `export { default as Provider } from './components/${dir}.svelte';`;
|
|
|
return !source.includes(expected);
|
|
|
})
|
|
|
.map((dir) => `${dir}/exports.ts`);
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Date/Time field roots from swallowing provider DOM attrs', () => {
|
|
|
for (const dir of ['date-field', 'time-field']) {
|
|
|
const source = readFileSync(
|
|
|
join(HERE, 'soma', 'components', dir, 'components', `${dir}.svelte`),
|
|
|
'utf8'
|
|
|
);
|
|
|
|
|
|
expect(source, dir).toContain('ref = $bindable(null)');
|
|
|
expect(source, dir).toContain('...restProps');
|
|
|
expect(source, dir).toContain('const mergedProps');
|
|
|
expect(source, dir).toContain('<div {...mergedProps}>');
|
|
|
expect(source, dir).not.toContain('child({ props: {} })');
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('guards picker roots are DOM-passthrough composition shells', () => {
|
|
|
// Pickers are DOM-passthrough composition shells: they render a
|
|
|
// `<div>` (so morfo runtime / sema can latch onto the root),
|
|
|
// expose `ref` + `child` + DOM attrs via `WithChild`, and accept
|
|
|
// children as composition slot. The wrapper must merge restProps
|
|
|
// into the rendered element and offer both the child-snippet
|
|
|
// branch and the children branch.
|
|
|
const roots = [
|
|
|
['date-picker', 'DatePickerProviderProps'],
|
|
|
['date-range-picker', 'DateRangePickerProviderProps'],
|
|
|
['time-picker', 'TimePickerProviderProps'],
|
|
|
['time-range-picker', 'TimeRangePickerProviderProps']
|
|
|
] as const;
|
|
|
|
|
|
for (const [dir, typeName] of roots) {
|
|
|
const types = readFileSync(join(HERE, 'soma', 'components', dir, 'types.ts'), 'utf8');
|
|
|
const wrapper = readFileSync(
|
|
|
join(HERE, 'soma', 'components', dir, 'components', `${dir}.svelte`),
|
|
|
'utf8'
|
|
|
);
|
|
|
// Match both shapes:
|
|
|
// `WithChild<{ ... }>;`
|
|
|
// `WithChild<Omit<PrimitiveDivAttributes, ...> & { ... }>;`
|
|
|
// per audit-codex P0/P1 refactor of picker ProviderProps to
|
|
|
// extend PrimitiveDivAttributes.
|
|
|
const rootType =
|
|
|
new RegExp(`export type ${typeName}\\s*=\\s*WithChild<[\\s\\S]*?\\}>;`).exec(types)?.[0] ?? '';
|
|
|
|
|
|
expect(rootType, `${dir}.types.ts uses WithChild<{...}>`).not.toBe('');
|
|
|
expect(rootType, dir).toContain('children?: Snippet;');
|
|
|
expect(wrapper, dir).toContain('ref = $bindable(null)');
|
|
|
expect(wrapper, dir).toContain('...restProps');
|
|
|
expect(wrapper, dir).toContain('<div {...mergedProps}>');
|
|
|
expect(wrapper, dir).toContain('{@render children?.()}');
|
|
|
expect(wrapper, dir).not.toContain('child({ props: {} })');
|
|
|
}
|
|
|
});
|
|
|
|
|
|
it('guards Soma public barrels from provider implementation filenames', () => {
|
|
|
const violations = collectPublicSomaComponentDirs()
|
|
|
.map((dir) => join(HERE, 'soma', 'components', dir, 'exports.ts'))
|
|
|
.filter((file) =>
|
|
|
/from ['"][^'"]*provider\.svelte(?:\.ts)?['"]/.test(readFileSync(file, 'utf8'))
|
|
|
)
|
|
|
.map((file) => file.replace(REPO_ROOT, ''));
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma public barrels from shared lib facades', () => {
|
|
|
const violations = collectPublicSomaComponentDirs()
|
|
|
.map((dir) => join(HERE, 'soma', 'components', dir, 'exports.ts'))
|
|
|
.filter((file) => /from ['"]\$libs\//.test(readFileSync(file, 'utf8')))
|
|
|
.map((file) => file.replace(REPO_ROOT, ''));
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma public component modules against Morfo soma scope', () => {
|
|
|
// Internal bridges may declare 'soma' truthfully (they implement soma
|
|
|
// behaviour) without being public namespaces — subtract them so the
|
|
|
// dir↔scope equality compares public surfaces on both sides.
|
|
|
const publicSomaScoped = collectMorfoComponentNamesByScope('soma').filter(
|
|
|
(name) => !SOMA_INTERNAL_PRIMITIVES.has(name)
|
|
|
);
|
|
|
expect(collectPublicSomaComponentDirs()).toEqual(publicSomaScoped);
|
|
|
});
|
|
|
|
|
|
it("guards every component with an eidos recipe declares 'eidos' in morfo scope (SYS-1)", () => {
|
|
|
const violations = collectScopeDriftViolations();
|
|
|
expect(
|
|
|
violations,
|
|
|
`scope-drift — add 'eidos' to the morfo scope of:\n ${violations.join('\n ')}`
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards every eidos component dir is enumerable by the acceptance machinery (MOR-4)', () => {
|
|
|
const violations = collectMorfoEnumerationViolations();
|
|
|
expect(
|
|
|
violations,
|
|
|
`morfo-enumeration holes:\n ${violations.join('\n ')}`
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it("guards every morfo is declared 'as const satisfies Morfo', never ': Morfo' (VG-8)", () => {
|
|
|
const violations = collectMorfoDeclarationViolations();
|
|
|
expect(violations, `morfo declaration drift:\n ${violations.join('\n ')}`).toEqual([]);
|
|
|
});
|
|
|
|
|
|
// S-34: `SEMA_FAMILY_POLICY` is declared the ONE source of the intent policy
|
|
|
// ("editing it reshapes the discriminated unions"), and the TYPE side honours
|
|
|
// that — `IntentRequiredFamily` derives from it. The RUNTIME side does not:
|
|
|
// the sium schema that validates all 172 morfos re-writes the same partition
|
|
|
// as fixed literals, so editing the const would move the compile-time gate
|
|
|
// and leave the runtime one on the old partition, silently.
|
|
|
//
|
|
|
// Deriving the schema from the const instead would buy a VALUE import
|
|
|
// morfo → sema, and that edge is deliberately type-only today (morfo is "the
|
|
|
// contract / DNA; pure TypeScript, no runtime" — theming/channels.md §3).
|
|
|
// So the two sides are tied HERE, in the cross-layer contract test, which
|
|
|
// already imports both.
|
|
|
it('guards the morfo runtime schema against the intent policy const (S-34)', () => {
|
|
|
const source = readFileSync(join(HERE, 'morfo', 'schema.ts'), 'utf8');
|
|
|
|
|
|
// Each `const X = union(...)` in the schema, expanded through references
|
|
|
// to other unions declared above it.
|
|
|
const unions = new Map<string, string[]>();
|
|
|
for (const m of source.matchAll(/const (sema\w*FamilySchema) = union\(([\s\S]*?)\);/g)) {
|
|
|
const members = [...m[2].matchAll(/literal\('([a-z-]+)'\)|\b(sema\w*FamilySchema)\b/g)];
|
|
|
unions.set(
|
|
|
m[1],
|
|
|
members.flatMap(([, lit, ref]) => (lit ? [lit] : (unions.get(ref) ?? [])))
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const fromPolicy = (required: boolean) =>
|
|
|
Object.entries(SEMA_FAMILY_POLICY)
|
|
|
.filter(([, p]) => (p.intentRequirement === 'required') === required)
|
|
|
.map(([family]) => family)
|
|
|
.sort();
|
|
|
|
|
|
const drift = 'intent policy drift — the schema partition no longer matches SEMA_FAMILY_POLICY';
|
|
|
expect([...(unions.get('semaIntentRequiredFamilySchema') ?? [])].sort(), drift).toEqual(
|
|
|
fromPolicy(true)
|
|
|
);
|
|
|
expect([...(unions.get('semaIntentOptionalFamilySchema') ?? [])].sort(), drift).toEqual(
|
|
|
fromPolicy(false)
|
|
|
);
|
|
|
});
|
|
|
|
|
|
it('guards declared events against the ones a provider actually emits (D9)', () => {
|
|
|
const violations = collectInertEventViolations();
|
|
|
expect(
|
|
|
violations,
|
|
|
`declared and never emitted — inert, mute, and reading as if they did something:\n ${violations.join('\n ')}\n\n` +
|
|
|
'Emit it, or retire the declaration. Do not widen INERT_EVENT_DEBT to pass.'
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards defaultElement against the tag the soma part actually renders (D8)', () => {
|
|
|
const violations = collectDefaultElementViolations();
|
|
|
expect(
|
|
|
violations,
|
|
|
`defaultElement lies about the rendered tag:\n ${violations.join('\n ')}\n\n` +
|
|
|
'Fix the side that is wrong — the morfo when the component renders the more\n' +
|
|
|
'correct element, the component when the morfo does. Do not widen the\n' +
|
|
|
'allow-list to make this pass.'
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards per-item membership predicates against O(N²) .current.includes (A31)', () => {
|
|
|
const violations = collectA31Violations();
|
|
|
expect(
|
|
|
violations,
|
|
|
`A31 — lift a Set + use .has() in:\n ${violations.join('\n ')}`
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards inputId Field registration against $effect wrapping (A30)', () => {
|
|
|
const violations = collectA30Violations();
|
|
|
expect(
|
|
|
violations,
|
|
|
`A30 — assign inputId directly in the constructor, not in a $effect:\n ${violations.join('\n ')}`
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards overlay z-index against raw integers in recipes (THEME-SYS-1)', () => {
|
|
|
const violations = collectMagicZViolations();
|
|
|
expect(
|
|
|
violations,
|
|
|
`magic z-index — reference the overlay scale:\n ${violations.join('\n ')}`
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma public component modules with local README docs', () => {
|
|
|
const missingReadmes = collectPublicSomaComponentDirs().filter(
|
|
|
(dir) => !existsSync(join(HERE, 'soma', 'components', dir, 'README.md'))
|
|
|
);
|
|
|
|
|
|
expect(missingReadmes).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma component README links to existing component docs', () => {
|
|
|
expect(collectBrokenSomaComponentReadmeLinks()).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma from external re-export facades', () => {
|
|
|
expect(existsSync(join(HERE, 'soma', 'external'))).toBe(false);
|
|
|
const violations = grepSources(join(HERE, 'soma'), /external\/(?:dates|colors)/);
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma root barrel as scope-only', () => {
|
|
|
const source = readFileSync(join(HERE, 'soma', 'index.ts'), 'utf8');
|
|
|
|
|
|
expect(source).toContain("from './core/soma.svelte'");
|
|
|
expect(source).toContain("from './errors'");
|
|
|
expect(existsSync(join(HERE, 'soma', 'core', 'langs.ts'))).toBe(false);
|
|
|
expect(source).not.toMatch(
|
|
|
/from ['"]\.\/(?:provider|props|keyboard|typeahead|css|id|types|runtime\.svelte|components|layers|datetime|color)/
|
|
|
);
|
|
|
});
|
|
|
|
|
|
it('guards Soma internals from importing their own public alias', () => {
|
|
|
const violations = listSourceFiles(join(HERE, 'soma'))
|
|
|
.filter((file) => {
|
|
|
const source = stripComments(readFileSync(file, 'utf8'));
|
|
|
return /from ['"]\$soma(?:['"]|\/)/.test(source);
|
|
|
})
|
|
|
.map((file) => file.replace(REPO_ROOT, ''));
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma from direct mutable DOM writes', () => {
|
|
|
// The rule is about the LIVE document: a UIX-managed attr must travel
|
|
|
// through `dom.apply` so one owner writes it. A file that only ever
|
|
|
// touches a DETACHED buffer is outside that concern — there is nothing
|
|
|
// to coordinate, and no `ActiveDom` to route through.
|
|
|
//
|
|
|
// `palabras/engine/operations/parse-html.ts` is the paste pipeline: it
|
|
|
// works on an inert `DOMParser` document (its own header: "it's a parse
|
|
|
// buffer (inert document / detached container), never live"), marking
|
|
|
// Word's fake-list paragraphs before regrouping them. The marker never
|
|
|
// reaches the page; `normalizeDocument` consumes and drops it.
|
|
|
const detachedBufferOnly = join(
|
|
|
HERE,
|
|
|
'soma',
|
|
|
'components',
|
|
|
'palabras',
|
|
|
'engine',
|
|
|
'operations',
|
|
|
'parse-html.ts'
|
|
|
);
|
|
|
const violations = grepSources(
|
|
|
join(HERE, 'soma'),
|
|
|
/\.setAttribute\(|\.removeAttribute\(|\.toggleAttribute\(|\.classList\.|\.style\.setProperty\(|\.style\.[a-zA-Z]+\s*=/
|
|
|
).filter((file) => file !== detachedBufferOnly);
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma timers through the injected scheduler (raw only as the no-timers fallback)', () => {
|
|
|
// Widened from soma/components to ALL of soma (SOM-3, clean-room
|
|
|
// 2026-07-10: layers/ + datetime/ escaped the old scope and held raw
|
|
|
// timers for months). Evidence-based instead of a file allowlist: a
|
|
|
// file may keep a raw setTimeout ONLY as the documented no-timers
|
|
|
// fallback of the scheduler-preferred shape (semaDelay / dismissal /
|
|
|
// safe-polygon / announce-global / announcer / image-provider), which
|
|
|
// the same file evidences by ALSO calling `.schedule(` on the injected
|
|
|
// service. A raw timer with no scheduler path anywhere in the file is
|
|
|
// a violation.
|
|
|
const violations = listSourceFiles(join(HERE, 'soma')).filter((file) => {
|
|
|
const source = stripComments(readFileSync(file, 'utf8'));
|
|
|
if (!/\bsetTimeout\(|\bsetInterval\(/.test(source)) return false;
|
|
|
return !/\.schedule\(|\.interval\(/.test(source);
|
|
|
});
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma components through the shared typeahead helper', () => {
|
|
|
const violations = grepSources(
|
|
|
join(HERE, 'soma', 'components'),
|
|
|
/class\s+Typeahead|typeaheadBuffer|typeaheadTimerKey|clearTypeaheadTimer/
|
|
|
);
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards Soma component listeners through ActiveDom', () => {
|
|
|
// media-provider.ts is the pluggable playback-engine PORT — Svelte-
|
|
|
// agnostic BY DESIGN (its header: "no runes, no $adom"): third-party
|
|
|
// engines (hls.js adapters, embeds) implement the same contract, so it
|
|
|
// must not depend on the component runtime. Its listeners live on the
|
|
|
// engine's own element and are cleaned in destroy().
|
|
|
const allowedPort = join(HERE, 'soma', 'components', 'media-player', 'media-provider.ts');
|
|
|
const violations = grepSources(
|
|
|
join(HERE, 'soma', 'components'),
|
|
|
/\.(addEventListener|removeEventListener)\(/
|
|
|
).filter((file) => file !== allowedPort);
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards hardcoded Soma component data attrs with morfo contracts', () => {
|
|
|
const knownAttrs = collectKnownMorfoDataAttrs();
|
|
|
const violations = listSourceFiles(join(HERE, 'soma', 'components')).flatMap((file) => {
|
|
|
const attrs = collectDataAttrs(readFileSync(file, 'utf8'));
|
|
|
return [...attrs]
|
|
|
.filter((attr) => !knownAttrs.has(attr))
|
|
|
.map((attr) => `${file.replace(REPO_ROOT, '')}: ${attr}`);
|
|
|
});
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards component translation namespaces as kebab-case', () => {
|
|
|
const sourceRoots = [join(HERE, 'soma', 'components'), join(HERE, 'morfo', 'components')];
|
|
|
const pathViolations = sourceRoots.flatMap((root) =>
|
|
|
listSourceFiles(root).flatMap((file) =>
|
|
|
collectComponentLangPathViolations(readFileSync(file, 'utf8'), file)
|
|
|
)
|
|
|
);
|
|
|
|
|
|
expect(pathViolations).toEqual([]);
|
|
|
expect(collectLangCatalogKeyViolations(commonLangs, 'common')).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('events/sema uses events as service name and requires dom or projector for visual projection', () => {
|
|
|
const factory = defineEngineSemantic({ visual: false });
|
|
|
expect(factory.name).toBe(UIX_LAYER_CONTRACTS.events.publicServiceName);
|
|
|
// Derived from the table, never hardcoded: `sound` is in there because the
|
|
|
// factory forwards it as `soundEngine` (single AudioContext in attach).
|
|
|
expect(factory.serviceDependencies).toEqual(UIX_LAYER_CONTRACTS.events.serviceDependencies);
|
|
|
expect(UIX_LAYER_CONTRACTS.events.morfoPayloadName).toBe('semantic');
|
|
|
expect(UIX_LAYER_CONTRACTS.events.requiresOneOfForVisualProjection).toEqual([
|
|
|
'dom',
|
|
|
'projector'
|
|
|
]);
|
|
|
|
|
|
expect(() => new EngineSemantic()).toThrow(SemaConfigError);
|
|
|
expect(() => new EngineSemantic({ visual: false })).not.toThrow();
|
|
|
});
|
|
|
|
|
|
it('guards Sema production code from direct global listeners and console output', () => {
|
|
|
const violations = listSourceFiles(join(HERE, 'sema'))
|
|
|
.filter((file) => {
|
|
|
const source = stripComments(readFileSync(file, 'utf8'));
|
|
|
return /(?:document|window)\.(?:addEventListener|removeEventListener)\(|console\./.test(
|
|
|
source
|
|
|
);
|
|
|
})
|
|
|
.map((file) => file.replace(REPO_ROOT, ''));
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('ActiveEidos is the visual runtime and does not recreate an engine layer', () => {
|
|
|
expect(UIX_LAYER_CONTRACTS.activeEidos.publicRuntime).toBe('ActiveEidos');
|
|
|
expect(existsSync(new URL('./eidos/engine', import.meta.url))).toBe(false);
|
|
|
expect(existsSync(new URL('./eidos/core/headless', import.meta.url))).toBe(false);
|
|
|
expect(UIX_LAYER_CONTRACTS.activeEidos.constructionOptionalServices).toEqual([
|
|
|
'langs',
|
|
|
'format',
|
|
|
'prefs'
|
|
|
]);
|
|
|
expect(UIX_LAYER_CONTRACTS.activeEidos.requiredAccessors).toEqual(['langs', 'prefs']);
|
|
|
expect(UIX_LAYER_CONTRACTS.activeEidos.optionalAccessors).toEqual(['format']);
|
|
|
|
|
|
expect(() => createActiveEidos({ preferences: preferencesHarness() })).toThrow(
|
|
|
ActiveEidosConfigError
|
|
|
);
|
|
|
const eidos = createActiveEidos({
|
|
|
preferences: preferencesHarness(),
|
|
|
applyDom: false
|
|
|
});
|
|
|
expect(eidos.renderStaticCss()).toContain('--size-md-control-height');
|
|
|
expect(() => eidos.prefs).toThrow(ActiveEidosConfigError);
|
|
|
expect(eidos.format).toBeUndefined();
|
|
|
eidos.dispose();
|
|
|
});
|
|
|
|
|
|
it('guards Eidos public surface from wrapper/engine reintroduction', () => {
|
|
|
const eidosRoot = join(HERE, 'eidos');
|
|
|
const violations = grepSources(
|
|
|
eidosRoot,
|
|
|
/\bEngineEidos\b|\bclass\s+Eidos\b|\bEidos\.create\(|from ['"].*\/engine/
|
|
|
);
|
|
|
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('guards UIX docs shell from writing visual prefs through ActivePrefs', () => {
|
|
|
const violations = grepSources(
|
|
|
join(REPO_ROOT, 'web', 'routes', 'uix'),
|
|
|
/prefs\.theme|setIntent\(['"]theme['"]/
|
|
|
);
|
|
|
expect(violations).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('Morfo declares text slots as absolute idlangrefs (texts:) — catalog lives outside the morfo', () => {
|
|
|
_resetMorfoRegistryForTesting();
|
|
|
|
|
|
try {
|
|
|
const sample: Morfo = {
|
|
|
name: 'ContractSample',
|
|
|
kebab: 'contract-sample',
|
|
|
scope: ['soma'],
|
|
|
texts: {
|
|
|
label: '#?components.contract-sample.label|Contract'
|
|
|
},
|
|
|
parts: [
|
|
|
{
|
|
|
name: 'Provider',
|
|
|
kebab: 'provider',
|
|
|
kind: 'public',
|
|
|
defaultElement: 'div',
|
|
|
optional: false,
|
|
|
data: [],
|
|
|
aria: [
|
|
|
{
|
|
|
attr: 'aria-label',
|
|
|
value: v.translationRef('#?components.contract-sample.label|Contract')
|
|
|
}
|
|
|
]
|
|
|
}
|
|
|
]
|
|
|
};
|
|
|
|
|
|
registerMorfo(sample);
|
|
|
|
|
|
// Morfo owns the abstract surface (text slots as idlangrefs); content
|
|
|
// lives in src/uix/langs/components/{kebab}.ts and is merged by
|
|
|
// ActiveUix into the components.* namespace.
|
|
|
expect(sample.texts?.label).toBe('#?components.contract-sample.label|Contract');
|
|
|
expect(sample.texts?.label.startsWith('#?')).toBe(true);
|
|
|
} finally {
|
|
|
_resetMorfoRegistryForTesting();
|
|
|
}
|
|
|
});
|
|
|
});
|