You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
207 lines
7.2 KiB
207 lines
7.2 KiB
/**
|
|
* The closed vocabulary an eidos recipe may NAME through the `data-event*`
|
|
* stamp.
|
|
*
|
|
* `src/uix/sema/stamp.ts` writes `data-event` = the morfo event's `name`, plus
|
|
* `data-event-family` / `data-event-intent` / `data-event-direction` from the
|
|
* same signal. The
|
|
* structural linter (`src/uix/eidos/lint.ts`) classifies those attrs as
|
|
* "eidos-only" and never reads their VALUE — which is how
|
|
* `[data-event='commit-resize']` survived in `splitter.css` from the
|
|
* 2026-05-22 rename (`commit-resize` → `commit-set`, bd2e40366) without a
|
|
* single test complaining. A recipe hooked to a name nobody emits is dead
|
|
* CSS that looks alive.
|
|
*
|
|
* The check is CATALOGUE-wide, not per-component, and deliberately so:
|
|
* composition means a node receives stamps from another component's morfo
|
|
* (the card-group item carries `data-toggle-group-item` on the same node and
|
|
* receives `commit-block`, which toggle-group declares; the calendar
|
|
* nav-buttons receive `contact-activate` from the composed `Button`). A
|
|
* per-component ERROR would be a dozen false positives. The mismatch is
|
|
* still surfaced — as a WARN.
|
|
*/
|
|
|
|
import { readdirSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import { INTENTS } from '../src/uix/intent';
|
|
import { SEMA_DIRECTIONS, SEMA_FAMILY_POLICY } from '../src/uix/sema/types';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const MORFO_DIRS = [
|
|
join(__dirname, '..', 'src', 'uix', 'morfo', 'components'),
|
|
join(__dirname, '..', 'src', 'uix', 'morfo', 'internal')
|
|
];
|
|
|
|
const FAMILIES: readonly string[] = Object.keys(SEMA_FAMILY_POLICY);
|
|
const INTENT_VALUES: readonly string[] = INTENTS;
|
|
/**
|
|
* Imported, never restated. `scripts/` is outside the `tsconfig` graph, so a
|
|
* literal `['forward', 'backward']` written here would be unchecked prose that
|
|
* silently outlives the vocabulary it guards — the same shape of defect this
|
|
* whole file exists to prevent. `SemaDirection` is derived FROM this array
|
|
* (`sema/types.ts`), so the two cannot diverge.
|
|
*/
|
|
const DIRECTION_VALUES: readonly string[] = SEMA_DIRECTIONS;
|
|
|
|
export interface EventVocabulary {
|
|
/** event `name` → the morfo kebabs that declare it. */
|
|
readonly names: ReadonlyMap<string, readonly string[]>;
|
|
}
|
|
|
|
/** Import every morfo in the catalogue and index its declared event names. */
|
|
export async function loadEventVocabulary(): Promise<EventVocabulary> {
|
|
const names = new Map<string, string[]>();
|
|
for (const dir of MORFO_DIRS) {
|
|
for (const file of readdirSync(dir)) {
|
|
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue;
|
|
// file:// URL — Node's ESM loader rejects bare Windows paths ("g:\…").
|
|
const mod = (await import(pathToFileURL(join(dir, file)).href)) as Record<string, unknown>;
|
|
for (const value of Object.values(mod)) {
|
|
if (typeof value !== 'object' || value === null) continue;
|
|
const morfo = value as { kebab?: unknown; parts?: unknown; events?: unknown };
|
|
if (typeof morfo.kebab !== 'string' || !Array.isArray(morfo.parts)) continue;
|
|
if (!Array.isArray(morfo.events)) continue;
|
|
for (const event of morfo.events as readonly { name?: unknown }[]) {
|
|
if (typeof event?.name !== 'string') continue;
|
|
const owners = names.get(event.name);
|
|
if (owners) owners.push(morfo.kebab);
|
|
else names.set(event.name, [morfo.kebab]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { names };
|
|
}
|
|
|
|
export interface EventValueFinding {
|
|
readonly level: 'error' | 'warn';
|
|
/** The selector the attribute was authored in. */
|
|
readonly rule: string;
|
|
readonly message: string;
|
|
}
|
|
|
|
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
|
const SELECTOR_LINE = /(?:^|\})\s*([^\s{}][^{}]*?)\s*\{/g;
|
|
const EVENT_ATTR =
|
|
/\[\s*(data-event-family|data-event-intent|data-event-direction|data-event)\s*([~^$*|]?)=\s*(?:'([^']*)'|"([^"]*)"|([^\]\s]+))/gi;
|
|
|
|
/**
|
|
* Validate every `data-event` / `-family` / `-intent` / `-direction` VALUE in
|
|
* a CSS file.
|
|
*
|
|
* `ownKebab` (optional) turns "the name exists, but no morfo of this
|
|
* component declares it" into a WARN — the composition case.
|
|
*/
|
|
export function lintEventValues(
|
|
cssText: string,
|
|
vocabulary: EventVocabulary,
|
|
ownKebab?: string
|
|
): EventValueFinding[] {
|
|
// Comments first: splitter.css documents the hook in prose right above the
|
|
// rule, and a commented name is not a selector.
|
|
const text = cssText.replace(COMMENT, '');
|
|
const out: EventValueFinding[] = [];
|
|
|
|
for (const ruleMatch of text.matchAll(SELECTOR_LINE)) {
|
|
const rule = ruleMatch[1].trim().replace(/\s+/g, ' ');
|
|
for (const m of rule.matchAll(EVENT_ATTR)) {
|
|
const attr = m[1].toLowerCase();
|
|
const op = m[2];
|
|
const value = m[3] ?? m[4] ?? m[5];
|
|
|
|
if (attr === 'data-event-family') {
|
|
check(out, rule, attr, op, value, FAMILIES, 'a canon sema family');
|
|
continue;
|
|
}
|
|
if (attr === 'data-event-intent') {
|
|
check(out, rule, attr, op, value, INTENT_VALUES, 'a canon intent');
|
|
continue;
|
|
}
|
|
if (attr === 'data-event-direction') {
|
|
check(out, rule, attr, op, value, DIRECTION_VALUES, 'a sense of traversal');
|
|
continue;
|
|
}
|
|
|
|
const matched = matchable(value, op, vocabulary.names.keys());
|
|
if (!matched) {
|
|
out.push(unmodelledOperator(rule, attr, op, value));
|
|
continue;
|
|
}
|
|
if (matched.length === 0) {
|
|
out.push({
|
|
level: 'error',
|
|
rule,
|
|
message: `[${attr}${op}='${value}'] — no morfo declares an event ${op === '^' ? 'starting with' : 'named'} '${value}'.`
|
|
});
|
|
continue;
|
|
}
|
|
if (!ownKebab) continue;
|
|
const owners = [...new Set(matched.flatMap((name) => vocabulary.names.get(name) ?? []))];
|
|
if (owners.includes(ownKebab)) continue;
|
|
out.push({
|
|
level: 'warn',
|
|
rule,
|
|
message: `[${attr}${op}='${value}'] — declared by ${owners.join(', ')}, not by the '${ownKebab}' morfo (composition, or drift).`
|
|
});
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function check(
|
|
out: EventValueFinding[],
|
|
rule: string,
|
|
attr: string,
|
|
op: string,
|
|
value: string,
|
|
vocabulary: readonly string[],
|
|
label: string
|
|
): void {
|
|
const matched = matchable(value, op, vocabulary);
|
|
if (!matched) {
|
|
out.push(unmodelledOperator(rule, attr, op, value));
|
|
return;
|
|
}
|
|
if (matched.length > 0) return;
|
|
out.push({
|
|
level: 'error',
|
|
rule,
|
|
message: `[${attr}${op}='${value}'] — '${value}' is not ${label} {${vocabulary.join(', ')}}.`
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The vocabulary entries this attribute selector can ever match.
|
|
* `undefined` — an operator this guard does not model (nothing uses one today).
|
|
*/
|
|
function matchable(value: string, op: string, vocabulary: Iterable<string>): string[] | undefined {
|
|
if (op === '') return [...vocabulary].filter((entry) => entry === value);
|
|
if (op === '^') return [...vocabulary].filter((entry) => entry.startsWith(value));
|
|
return undefined;
|
|
}
|
|
|
|
function unmodelledOperator(
|
|
rule: string,
|
|
attr: string,
|
|
op: string,
|
|
value: string
|
|
): EventValueFinding {
|
|
return {
|
|
level: 'warn',
|
|
rule,
|
|
message: `[${attr}${op}='${value}'] — operator "${op}=" is not modelled; value left unvalidated.`
|
|
};
|
|
}
|
|
|
|
export function formatEventFindings(findings: readonly EventValueFinding[], indent = ' '): string {
|
|
const lines: string[] = [];
|
|
for (const f of findings) {
|
|
lines.push(`${indent}${f.level === 'error' ? '✗' : '·'} ${f.rule}`);
|
|
lines.push(`${indent} ${f.message}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|