|
|
|
|
@ -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<string, unknown>
|
|
|
|
|
>;
|
|
|
|
|
const morfoModules = import.meta.glob('../morfo/components/*.ts', { eager: true }) as Record<
|
|
|
|
|
string,
|
|
|
|
|
Record<string, unknown>
|
|
|
|
|
>;
|
|
|
|
|
|
|
|
|
|
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<string, { morfo: Morfo; part: string }[]>();
|
|
|
|
|
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<string, string> = {};
|
|
|
|
|
|
|
|
|
|
// ── 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<string, string> = {
|
|
|
|
|
'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<string, string> = {
|
|
|
|
|
'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<string, string> = {
|
|
|
|
|
'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<string, string | undefined>();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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(/<!--[\s\S]*?-->/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<string>();
|
|
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
});
|