diff --git a/src/uix/sema/pack-census.test.ts b/src/uix/sema/pack-census.test.ts new file mode 100644 index 000000000..a688a1a73 --- /dev/null +++ b/src/uix/sema/pack-census.test.ts @@ -0,0 +1,545 @@ +/** + * Census guard — a cascade rule must be able to FIRE, and fire WHERE the morfo + * says it will. + * + * WHY THIS EXISTS. A pack rule is a selector plus a signature. Both halves can + * be perfectly typed and still never meet the DOM: `semaSelector` proves the + * PART exists and the EVENT NAME exists, but nothing proved they belong + * together. The audit of 2026-08-05 measured the cost of that gap — 17 of 212 + * rules across 13 packs either never matched, wrote into a channel their family + * never activates, or named a family in their tuning that was not the family + * they modified. None of it was visible: 256 sema tests passed with every one + * of them present, because a rule that never matches simply falls back to the + * family base and the component keeps making a sound. The wrong one. + * + * The class had already reincided twice inside a single file + * (`components/navigation-menu.ts`, whose own header documents the first + * repair while the two rules added in the same commit reintroduced the defect), + * which is the signature of a missing guard rather than a careless author. + * + * WHAT IT CHECKS, and why each one is mechanical: + * + * 1. TARGET — the part a rule selects must be the part the morfo declares as + * the event's `target`. That is where the runtime stamps `data-event*`, so + * any other part yields a selector no node can satisfy. + * 2. CHANNEL — a rule that declares a `sound` / `haptic` signature must have + * that channel in its effective activation (`rule.channels` when present, + * otherwise the family's `activeChannels`). D.8: declaring a signature the + * activation does not include leaves an INERT rule that reads as if it did + * something. + * 3. TUNING HEAD — `soundTuning('commit.subtle')` applied to a `signal` event + * lies about the base it modifies. The naming law lives in `sounds.ts` and + * `sounds-grammar.test.ts` guards the KEYS; this guards the APPLICATION, + * which is the half that can drift. Source-level on purpose: the key is a + * literal an author types, and `soundTuning()` returns a resolved override + * with no memory of it. + * 4. EMISSION — an event a rule tunes must be emitted by some provider. + * Otherwise the rule, its comment and the component README all describe a + * perception that never happens. + * + * Every exception is signed with the reason it earned the pass. Growing those + * lists is a decision, not a convenience. + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { partMarkerAttr } from '../morfo/compile'; +import type { PartRef } from '../types'; +import type { Morfo, MorfoEvent } from '../morfo/types'; +import type { SemaChannelId } from './channels'; +import { SEMA_MAP, type Sema } from './sema-map'; +import type { SemaFamily } from './types'; + +// ── Catalogue ───────────────────────────────────────────────────────────── + +const packModules = import.meta.glob('./components/*.ts', { eager: true }) as Record< + string, + Record +>; +const morfoModules = import.meta.glob('../morfo/components/*.ts', { eager: true }) as Record< + string, + Record +>; + +function isSema(value: unknown): value is Sema { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Sema).name === 'string' && + Array.isArray((value as Sema).cascade) + ); +} + +function isMorfo(value: unknown): value is Morfo { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Morfo).kebab === 'string' && + Array.isArray((value as Morfo).parts) + ); +} + +interface PackEntry { + /** File basename without extension — the pack's source of record. */ + readonly file: string; + readonly pack: Sema; +} + +/** Every pack FILE, not the barrel: the barrel is known to be incomplete. */ +const PACKS: readonly PackEntry[] = Object.entries(packModules) + .filter(([path]) => !path.endsWith('/index.ts')) + .flatMap(([path, mod]) => { + const file = path.replace(/^.*\//, '').replace(/\.ts$/, ''); + return Object.values(mod) + .filter(isSema) + .map((pack) => ({ file, pack })); + }) + .sort((a, b) => a.file.localeCompare(b.file)); + +const MORFOS: readonly Morfo[] = Object.values(morfoModules) + .flatMap((mod) => Object.values(mod)) + .filter(isMorfo); + +/** marker attr → the (morfo, part) pairs that emit it. */ +const MARKER_INDEX = new Map(); +for (const morfo of MORFOS) { + for (const part of morfo.parts) { + const marker = partMarkerAttr(morfo.kebab, part.kebab); + const bucket = MARKER_INDEX.get(marker) ?? []; + bucket.push({ morfo, part: part.kebab }); + MARKER_INDEX.set(marker, bucket); + } +} + +// ── Selector parsing ────────────────────────────────────────────────────── + +const SEGMENT_RE = /\[([a-zA-Z][\w-]*)(?:([~^|$*]?)="((?:[^"\\]|\\.)*)")?\]/g; + +interface ParsedSelector { + /** The bare `[data-*]` part marker — the anchor of the compound. */ + readonly marker: string | undefined; + readonly eventName: string | undefined; + readonly eventNamePrefix: string | undefined; + readonly eventFamily: SemaFamily | undefined; + readonly eventIntent: string | undefined; +} + +function parseSelector(selector: string): ParsedSelector { + // An `ancestor` is joined with a space; the PART is always the last compound. + const compound = selector.trim().split(/\s+/).pop() ?? ''; + let marker: string | undefined; + let eventName: string | undefined; + let eventNamePrefix: string | undefined; + let eventFamily: SemaFamily | undefined; + let eventIntent: string | undefined; + + for (const match of compound.matchAll(SEGMENT_RE)) { + const [, attr, operator, value] = match; + if (value === undefined) { + if (attr.startsWith('data-') && marker === undefined) marker = attr; + continue; + } + if (attr === 'data-event') { + if (operator === '^') eventNamePrefix = value; + else eventName = value; + } else if (attr === 'data-event-family') { + eventFamily = value as SemaFamily; + } else if (attr === 'data-event-intent') { + eventIntent = value; + } + } + + return { marker, eventName, eventNamePrefix, eventFamily, eventIntent }; +} + +function resolveMarker(marker: string, ownKebab: string): { morfo: Morfo; part: string } | undefined { + const bucket = MARKER_INDEX.get(marker); + if (!bucket || bucket.length === 0) return undefined; + // A marker can be ambiguous (`data-card-header` = card/header or + // card-header/provider). The pack's own morfo wins — that is the one the + // typed builder was called with. + return bucket.find((entry) => entry.morfo.kebab === ownKebab) ?? bucket[0]; +} + +function eventsOf(morfo: Morfo): readonly MorfoEvent[] { + return (morfo.events ?? []) as readonly MorfoEvent[]; +} + +/** + * The parts a rule may legitimately select for this event: the canonical + * `target` plus whatever `allowedTargets` declares. A provider redirects the + * stamp with `targetOverride` for repeated parts (the pressed day, the clicked + * page); the morfo is the only place that can distinguish that from drift. + */ +function stampPartsOf(event: MorfoEvent): readonly string[] { + const target = event.semantic.target as PartRef | undefined; + const canonical = target && typeof target === 'object' ? [target.target] : []; + const allowed = (event.semantic.allowedTargets ?? []).map((ref) => ref.target); + return [...canonical, ...allowed]; +} + +/** Events of `morfo` a rule's matchers can reach. */ +function candidateEvents(morfo: Morfo, parsed: ParsedSelector): readonly MorfoEvent[] { + return eventsOf(morfo).filter((event) => { + if (parsed.eventName !== undefined && event.name !== parsed.eventName) return false; + if (parsed.eventNamePrefix !== undefined && !event.name.startsWith(parsed.eventNamePrefix)) { + return false; + } + if (parsed.eventFamily !== undefined) { + const allowed = [ + event.semantic.family, + ...(event.semantic.allowedFamilies ?? []) + ] as readonly SemaFamily[]; + if (!allowed.includes(parsed.eventFamily)) return false; + } + return true; + }); +} + +/** The families one candidate event can stamp — `eventFamily` pins it when present. */ +function familiesOf(event: MorfoEvent, parsed: ParsedSelector): readonly SemaFamily[] { + if (parsed.eventFamily !== undefined) return [parsed.eventFamily]; + return [ + event.semantic.family, + ...(event.semantic.allowedFamilies ?? []) + ] as readonly SemaFamily[]; +} + +interface RuleEntry { + readonly pack: PackEntry; + readonly index: number; + readonly rule: Sema['cascade'][number]; + readonly parsed: ParsedSelector; + readonly id: string; +} + +const RULES: readonly RuleEntry[] = PACKS.flatMap((pack) => + pack.pack.cascade.map((rule, index) => ({ + pack, + index, + rule, + parsed: parseSelector(rule.selector), + id: `${pack.file}#${index}` + })) +); + +// ── 1 · Target agreement ────────────────────────────────────────────────── + +/** + * Rules whose selected part is neither the declared `target` nor one of the + * event's `allowedTargets`. Empty on purpose: a legitimate redirection is + * declared in the morfo, where soma, sema and eidos can all read it — not + * excused here, where only this file would know. + */ +const TARGET_EXCEPTIONS: Record = {}; + +// ── 2 · Channel activation ──────────────────────────────────────────────── + +/** Channel ids a cascade rule can declare a signature for. */ +const RULE_CHANNELS = ['sound', 'haptic'] as const satisfies readonly SemaChannelId[]; + +/** + * PENDING AUTHOR DECISION (S-30/S-38, audited 2026-08-05, waived 2026-08-06): + * five rules write a haptic signature their family never activates — inert + * since birth. D.8 (signed) gives the two exits: widen with `channels` plus a + * written perceptual reason (the slider / css-field / drag-drop pattern), or + * drop the block. Widening also requires COMPLETING the firma: shift/emerge + * have no `base.haptic`, and `applyOverride` clones a partial as if it were + * whole (S-31) — `{kind:'tick'}` alone ends in `navigator.vibrate(NaN)`. + * These waivers record the finding; they do not bless it. + */ +const CHANNEL_EXCEPTIONS: Record = { + 'dialog#7': 'S-38 pending: alertdialog pulse on emerge — widen with channels, or drop', + 'dialog#9': 'S-38 pending: sheet appearance tap on emerge — widen with channels, or drop', + 'editable#0': 'S-38 pending: enter-mode tick on shift — widen + complete firma, or drop', + 'stepper#0': 'S-38 pending: step tick on shift — widen + complete firma, or drop', + 'timeline#0': 'S-38 pending: feed-reveal tick on emerge, with no user gesture behind it' +}; + +// ── 3 · Tuning head ─────────────────────────────────────────────────────── + +/** + * PENDING AUTHOR DECISION (S-39, audited 2026-08-05, waived 2026-08-06): ten + * applications import a `commit.*` ladder into `shift` / `signal` / `contact` + * events, so the key's head lies about the base it modifies — and five + * validation warnings resolve 8–13× below the family that exists to claim + * attention. The candidate fix keeps today's levels (family-true keys with the + * gain each site resolves NOW, recalibration deferred to the perceptual + * snapshot); chronos stays waived regardless — write-excluded by policy. + */ +const TUNING_HEAD_EXCEPTIONS: Record = { + 'calendar:shift-navigate': 'S-39 pending: commit.subtle on shift', + 'chronos:shift-navigate': 'S-39 + write-excluded by project policy', + 'css-field:signal-warn-invalid': 'S-39 pending: commit.soft on signal', + 'editable:shift-enter-mode': 'S-39 pending: commit.subtle on shift', + 'file-upload:trigger-picker': 'S-39 pending: commit.subtle on contact', + 'file-upload:signal-warn-reject': 'S-39 pending: commit.soft on signal', + 'password-field:signal-notify-caps-state': 'S-39 pending: commit.subtle on signal', + 'stepper:shift-step': 'S-39 pending: commit.subtle on shift', + 'tags-input:signal-warn-reject': 'S-39 pending: commit.soft on signal', + 'textarea:signal-warn-count-overflow': 'S-39 pending: commit.subtle on signal' +}; + +/** + * `{eventName → tuning key}` per pack file, read from the source: the key is a + * literal the author types and `soundTuning()` forgets it on return. + */ +function tuningApplications(file: string): { eventName: string; key: string }[] { + const source = readFileSync(join(__dirname, 'components', `${file}.ts`), 'utf-8'); + const out: { eventName: string; key: string }[] = []; + // Rules are object literals that always open with `selector:`; slicing at + // each `selector:` gives one rule's text without needing a parser. + const blocks = source.split(/\bselector:/).slice(1); + for (const block of blocks) { + const eventName = block.match(/eventName:\s*'([^']+)'/)?.[1]; + const key = block.match(/soundTuning\(\s*'([^']+)'/)?.[1]; + if (eventName && key) out.push({ eventName, key }); + } + return out; +} + +// ── 4 · Emission ────────────────────────────────────────────────────────── + +const SOMA_DIR = join(__dirname, '..', 'soma', 'components'); +/** Shared emission helpers — `list-selection.ts` names the event for every + * listbox-shaped component, so the literal lives outside the component dir. */ +const SOMA_LAYERS_DIR = join(__dirname, '..', 'soma', 'layers'); +/** Some composition wrappers drive the provider runtime themselves — + * menu-dial / onion-menu call `runtime.trigger` from their eidos component, + * with not one call in their soma dir. Emission lives in both layers. */ +const EIDOS_DIR = join(__dirname, '..', 'eidos', 'components'); + +const EMISSION_EXCEPTIONS: Record = { + 'chronos:handle-drag': + 'chronos is write-excluded by project policy — audited 2026-08-06 (S-37), the provider never emits it', + 'chronos:handle-resize': + 'chronos is write-excluded by project policy — audited 2026-08-06 (S-37), the provider never emits it', + // PENDING AUTHOR DECISION (S-14, waived 2026-08-06): the Clear action lives + // in the composed generic Picker, which emits ITS commit-reset with the + // picker pack's firma. Either the event leaves this morfo (the delegated + // verb — same class as color/date/time pickers), or the provider emits it. + 'gradient-picker:commit-reset': 'S-14 pending: delegated verb — retire the event, or emit it', + // PENDING AUTHOR DECISION (found by this census 2026-08-06, not in the 44): + // tooltip declares three events NOBODY emits, so its SILENT rule never + // matches and no data-event-* ever stamps (the eidos motion preset that + // reads `present` included). Today's silence is accidental, not the + // declared design. Either the provider emits, or the events retire. + 'tooltip:present': 'dead declared contract — pending: emit, or retire the events', + 'tooltip:dismiss': 'dead declared contract — pending: emit, or retire the events', + 'tooltip:dismiss-escape': 'dead declared contract — pending: emit, or retire the events' +}; + +/** + * The source of a component's soma implementation, concatenated. Deliberately + * NOT limited to `trigger('…')`: providers compute the name into a variable + * (`const eventName = wasExpanded ? 'emerge-collapse' : 'emerge-expand'`), so + * the presence of the LITERAL anywhere in the implementation is the honest + * question — a name that appears nowhere cannot be emitted. + */ +const SOMA_SOURCE_CACHE = new Map(); + +/** + * Strip comments so prose cannot satisfy the census. menu-dial's soma file + * documents `trigger('open' | 'close' | 'commit-select', …)` in a doc block + * while every real call lives in its eidos wrapper — a raw substring search + * passed it for the wrong reason. + */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(//g, '') + .replace(/(^|\s)\/\/[^\n]*/g, '$1'); +} + +function readSourceTree(dir: string): string | undefined { + if (!existsSync(dir)) return undefined; + const parts: string[] = []; + const walk = (current: string) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) walk(path); + else if (/\.(ts|svelte)$/.test(entry.name) && !/\.(test|spec)\.ts$/.test(entry.name)) { + parts.push(stripComments(readFileSync(path, 'utf-8'))); + } + } + }; + walk(dir); + return parts.join('\n'); +} + +const SHARED_EMITTERS = readSourceTree(SOMA_LAYERS_DIR) ?? ''; + +function somaSourceOf(componentKebab: string): string | undefined { + if (SOMA_SOURCE_CACHE.has(componentKebab)) return SOMA_SOURCE_CACHE.get(componentKebab); + const own = [ + readSourceTree(join(SOMA_DIR, componentKebab)), + readSourceTree(join(EIDOS_DIR, componentKebab)) + ].filter((tree): tree is string => tree !== undefined); + const source = own.length === 0 ? undefined : `${own.join('\n')}\n${SHARED_EMITTERS}`; + SOMA_SOURCE_CACHE.set(componentKebab, source); + return source; +} + +// ── The census ──────────────────────────────────────────────────────────── + +describe('pack census — every cascade rule can fire, and fires where the morfo says', () => { + it('finds the catalogue (sanity: the census is not scanning an empty tree)', () => { + expect(PACKS.length).toBeGreaterThan(60); + expect(RULES.length).toBeGreaterThan(150); + expect(MORFOS.length).toBeGreaterThan(150); + }); + + it('resolves every rule selector to a real part of a real morfo', () => { + const unresolved = RULES.filter(({ rule, parsed, pack }) => { + void rule; + return parsed.marker === undefined || !resolveMarker(parsed.marker, pack.pack.name); + }).map(({ id, rule }) => `${id}: ${rule.selector}`); + expect(unresolved, 'a selector whose marker no morfo emits can never match').toEqual([]); + }); + + it('selects a part the morfo declares as a stamp target', () => { + const offenders: string[] = []; + for (const { id, rule, parsed, pack } of RULES) { + if (id in TARGET_EXCEPTIONS) continue; + if (parsed.eventName === undefined || parsed.marker === undefined) continue; + const resolved = resolveMarker(parsed.marker, pack.pack.name); + if (!resolved) continue; + const event = eventsOf(resolved.morfo).find((e) => e.name === parsed.eventName); + if (!event) continue; + const stampParts = stampPartsOf(event); + if (stampParts.length === 0 || stampParts.includes(resolved.part)) continue; + offenders.push( + `${id}: selects '${resolved.part}' but ${resolved.morfo.kebab}.${parsed.eventName} ` + + `stamps on [${stampParts.join(', ')}] — ${rule.selector}` + ); + } + expect( + offenders, + 'the runtime stamps on the declared target (or a declared `allowedTargets` part); a rule ' + + 'aimed anywhere else never matches. Fix the selector, or declare the redirection in the morfo' + ).toEqual([]); + }); + + it('declares no signature for a channel its activation does not include', () => { + const offenders: string[] = []; + for (const { id, rule, parsed, pack } of RULES) { + if (id in CHANNEL_EXCEPTIONS) continue; + if (parsed.marker === undefined) continue; + const resolved = resolveMarker(parsed.marker, pack.pack.name); + if (!resolved) continue; + // Activation resolves per (event, family) occurrence, honouring the + // full precedence: the rule's own `channels` (capa 5a) wins, then the + // EVENT's `channels` declared in the morfo (capa 4 — css-field / + // number-field / virtual-list use it), then the family default. + const activations: { family: SemaFamily; active: readonly SemaChannelId[] }[] = []; + for (const event of candidateEvents(resolved.morfo, parsed)) { + for (const family of familiesOf(event, parsed)) { + activations.push({ + family, + active: + rule.channels ?? + event.semantic.channels ?? + SEMA_MAP.families[family]?.activeChannels ?? + [] + }); + } + } + if (activations.length === 0) continue; + for (const channel of RULE_CHANNELS) { + if (rule[channel] === undefined) continue; + if (activations.some(({ active }) => active.includes(channel))) continue; + const families = [...new Set(activations.map((a) => a.family))]; + offenders.push( + `${id}: declares '${channel}' but families [${families.join(', ')}] ` + + `never activate it — ${rule.selector}` + ); + } + } + expect( + offenders, + 'D.8: a signature the activation does not include is an INERT rule — widen with `channels` ' + + 'and write the perceptual reason, or drop the block' + ).toEqual([]); + }); + + it('heads every applied tuning with the family it actually modifies', () => { + const offenders: string[] = []; + for (const { file, pack } of PACKS) { + const morfo = MORFOS.find((m) => m.kebab === pack.name); + if (!morfo) continue; + for (const { eventName, key } of tuningApplications(file)) { + const event = eventsOf(morfo).find((e) => e.name === eventName); + if (!event) continue; + const head = key.split('.')[0]; + const family = event.semantic.family; + if (head === family) continue; + if (`${file}:${eventName}` in TUNING_HEAD_EXCEPTIONS) continue; + offenders.push(`${file}: soundTuning('${key}') on '${eventName}', family '${family}'`); + } + } + expect( + offenders, + "the key's first segment names the family whose base the tuning modifies — applying it to " + + 'another family makes the resulting level unreadable, and turns aditive tunings into a trap' + ).toEqual([]); + }); + + it('tunes only events some provider actually emits', () => { + const seen = new Set(); + const offenders: string[] = []; + for (const { id, parsed, pack } of RULES) { + void id; + if (parsed.marker === undefined) continue; + // Census the morfo the SELECTOR names — a pack may scope a rule to a + // composed component's part (media-player over Button / Slider), and it + // is that component's provider which emits. + const resolved = resolveMarker(parsed.marker, pack.pack.name); + if (!resolved) continue; + const source = somaSourceOf(resolved.morfo.kebab); + if (source === undefined) continue; // no implementation of its own + // Reachability is per RULE, not per named event: a family- or + // intent-matched rule (tooltip matches `eventFamily: 'emerge'`) is just + // as dead when none of the events it can see is ever emitted. + const candidates = candidateEvents(resolved.morfo, parsed) + .map((event) => event.name) + .filter((name) => !(`${resolved.morfo.kebab}:${name}` in EMISSION_EXCEPTIONS)); + if (candidates.length === 0) continue; + const key = `${resolved.morfo.kebab}:[${candidates.join(',')}]`; + if (seen.has(key)) continue; + seen.add(key); + if (!candidates.some((name) => source.includes(`'${name}'`))) { + offenders.push( + `${pack.file}: none of [${candidates.join(', ')}] is emitted by any ` + + `${resolved.morfo.kebab} provider` + ); + } + } + expect( + offenders, + 'a rule for an event nobody emits is dead code that documents a perception that never happens' + ).toEqual([]); + }); + + it('keeps the exception lists honest (every entry still names a live case)', () => { + const ruleIds = new Set(RULES.map((r) => r.id)); + const stale = [ + ...Object.keys(TARGET_EXCEPTIONS).filter((id) => !ruleIds.has(id)), + ...Object.keys(CHANNEL_EXCEPTIONS).filter((id) => !ruleIds.has(id)), + ...Object.keys(TUNING_HEAD_EXCEPTIONS).filter((key) => { + const [file, eventName] = key.split(':'); + const pack = PACKS.find((p) => p.file === file); + if (!pack) return true; + const morfo = MORFOS.find((m) => m.kebab === pack.pack.name); + return !morfo || !eventsOf(morfo).some((e) => e.name === eventName); + }), + ...Object.keys(EMISSION_EXCEPTIONS).filter((key) => { + const [kebab, eventName] = key.split(':'); + const morfo = MORFOS.find((m) => m.kebab === kebab); + return !morfo || !eventsOf(morfo).some((e) => e.name === eventName); + }) + ]; + expect(stale, 'these exceptions excuse cases that no longer exist — delete them').toEqual([]); + }); +});