test(morfo): censo de foco bidireccional — la declaración y su ejecutor no pueden divergir

focus-census.test.ts vigila las tres redes:
1. trap ⇒ FocusScope cableado CON policy (y trap:true exige scope);
2. roving ⇒ RovingFocusGroup o executor.custom firmado;
3. INVERSA (la regla fundacional del eje): maquinaria de foco sin
   declaración falla. Red: FocusScope.use | RovingFocusGroup | registro de
   stop | getDirectionalKeys COMBINADO con dom.focus — las flechas que
   mueven VALOR o highlight no son maquinaria de stop.

La red inversa destapó 11 no declarados; adjudicados VERIFICANDO providers:
4 declaran (calendarios: roving 2D real) y 7 son ESPECIES fuera de la unión
con acta en INVERSE_EXCEPTIONS: accordion (walk: todos los triggers
tabulables), navigation-menu (disclosure, eje vivo ajeno), rating-group
(slider: flechas mueven VALOR), select (aria-activedescendant),
date/time/color-field (segmentos con tabindex 0 constante). Los kinds
candidatos (walk/activedescendant/slider/segments) son decisión del AUTOR
— registrados, jamás dodge.

Mentira plantada y MORDIDA en verificación: toolbar trap:true falso +
avatar roving falso ⇒ 2 rojos exactos; restore byte-idéntico.
Guard anti-barrido-vacío: >150 morfos, ≥28 declarando.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent df3e56b2a7
commit 1151667abb

@ -0,0 +1,190 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import type { Morfo, MorfoFocus } from './types';
/**
* The census guard of the focus-first axis (signed 2026-08-26,
* `docs/process/PLAN-focus-first.md`): the `focus` declaration and its
* executor live in different files, and history shows exactly what that
* permits — the field shipped for months with ZERO readers, Popover declared
* `trap: true` while shipping `trap: false`, and two normative doc rows
* claimed consumers that did not exist. This census closes the drift in
* BOTH directions:
*
* 1. a `trap` declaration must have `FocusScope` wired WITH the policy
* (and `trap: true` species MUST actually use FocusScope);
* 2. a `roving` declaration must consume the shared `RovingFocusGroup`
* OR carry a signed `executor.custom` reason;
* 3. the INVERSE (the author's inversion that founded the axis): a
* component whose provider uses FocusScope or hand-rolls a roving
* tab stop without declaring `focus` fails the census.
*
* The signed exceptions are listed with their reasons; growing a list is a
* decision, not a convenience.
*/
const COMPONENTS_DIR = join(__dirname, '..', 'soma', 'components');
const MORFO_DIR = join(__dirname, 'components');
/** kebab -> reason. Declaring morfos whose executor evidence lives elsewhere. */
const EXECUTOR_EXCEPTIONS: Record<string, string> = {
// Composes Dialog: the FocusScope (and its policy wiring) lives in
// dialog-provider, which alert-dialog's provider re-exports/extends.
'alert-dialog': 'composes Dialog — the executor is dialog-provider’s FocusScope',
// Eidos-scope components (no soma provider): their morfo declares the
// species policy for docs/agents, and the eidos component hand-implements
// focus behaviour — the 63-without-executor class, narrowed here to two.
'menu-dial': 'eidos-scope: no soma provider; trap:false policy, hand-rolled focus in eidos',
'onion-menu': 'eidos-scope: no soma provider; trap:false policy, hand-rolled focus in eidos'
};
/**
* kebab -> reason. Components whose soma sources SHOW focus machinery but
* which legitimately do not declare `focus` themselves.
*/
const INVERSE_EXCEPTIONS: Record<string, string> = {
// Extends Dialog's provider (see EXECUTOR_EXCEPTIONS) — the declaration
// lives on its own morfo, the machinery on dialog's.
'alert-dialog': 'machinery belongs to the composed dialog-provider',
// ── Adjudicated 2026-08-26 when the hardened net surfaced them: each is
// a REAL focus behaviour that the trap|roving union does not yet name.
// The candidate kinds (`walk`, `activedescendant`, `slider`, `segments`)
// are the axis' logged follow-up — the author's call, never a census
// dodge. Verified against the shipped providers, not assumed:
accordion:
'directional walk over natively tabbable triggers (APG accordion: no roving tabindex — every trigger is a tab stop)',
'navigation-menu':
'disclosure navigation: tabbable triggers walked with arrows, no stop management; focus behaviour owned by the live navigation-menu axis',
'rating-group': 'slider model: one focusable root, arrows move the VALUE, not focus',
select:
'aria-activedescendant strategy: virtual highlight (highlightedId), the single DOM stop stays on the anchor',
'date-field':
'segment field: every spinbutton segment is a native tab stop (constant tabindex 0); Left/Right walk focus as a convenience',
'time-field':
'segment field: every spinbutton segment is a native tab stop (constant tabindex 0); Left/Right walk focus as a convenience',
'color-field':
'segment field: every spinbutton segment is a native tab stop (constant tabindex 0); Left/Right walk focus as a convenience'
};
function loadMorfos(): Array<{ kebab: string; focus: MorfoFocus | undefined }> {
const out: Array<{ kebab: string; focus: MorfoFocus | undefined }> = [];
for (const file of readdirSync(MORFO_DIR)) {
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue;
const source = readFileSync(join(MORFO_DIR, file), 'utf-8');
const kebabMatch = /\n\tkebab: '([a-z0-9-]+)'/.exec(source);
if (!kebabMatch) continue;
const kebab = kebabMatch[1];
const focusMatch = /\n\tfocus: \{/.test(source);
if (!focusMatch) {
out.push({ kebab, focus: undefined });
continue;
}
const kind = /\n\tfocus: \{\r?\n(?:\t\t\/\/[^\n]*\r?\n)*\t\tkind: '(trap|roving)'/.exec(
source
)?.[1] as MorfoFocus['kind'] | undefined;
const trapDefault = /\n\t\ttrap: (true|false)/.exec(source)?.[1];
const hasCustom = /\n\t\texecutor: \{/.test(source);
out.push({
kebab,
focus:
kind === 'trap'
? ({ kind: 'trap', trap: trapDefault === 'true' } as MorfoFocus)
: kind === 'roving'
? ({
kind: 'roving',
parts: [],
orientation: 'horizontal',
...(hasCustom ? { executor: { custom: 'signed' } } : {})
} as MorfoFocus)
: undefined
});
}
return out;
}
function somaSources(kebab: string): string {
const dir = join(COMPONENTS_DIR, kebab);
if (!existsSync(dir)) return '';
let out = '';
const walk = (d: string) => {
for (const entry of readdirSync(d, { withFileTypes: true })) {
const p = join(d, entry.name);
if (entry.isDirectory()) walk(p);
else if (/\.(svelte\.ts|svelte)$/.test(entry.name) && !entry.name.includes('.test.'))
out += readFileSync(p, 'utf-8');
}
};
walk(dir);
return out;
}
describe('focus census — declaration ↔ executor, both directions', () => {
const morfos = loadMorfos();
const declaring = morfos.filter((m) => m.focus !== undefined);
it('the census inspects a real corpus (guard against the empty sweep)', () => {
expect(morfos.length).toBeGreaterThan(150);
expect(declaring.length).toBeGreaterThanOrEqual(28);
});
it('every trap declaration has FocusScope wired with the declared policy', () => {
const offenders: string[] = [];
for (const m of declaring) {
if (m.focus?.kind !== 'trap') continue;
if (EXECUTOR_EXCEPTIONS[m.kebab]) continue;
const src = somaSources(m.kebab);
const usesScope = src.includes('FocusScope.use');
const wiresPolicy = /policy:.*runtime\.focus/.test(src);
// trap:false species may legitimately skip FocusScope entirely
// (nothing to trap); but if the scope IS used, the policy must ride.
if (m.focus.trap && !usesScope) offenders.push(`${m.kebab}: trap:true without FocusScope`);
if (usesScope && !wiresPolicy) offenders.push(`${m.kebab}: FocusScope without policy`);
}
expect(offenders).toEqual([]);
});
it('every roving declaration consumes the shared primitive or signs its executor', () => {
const offenders: string[] = [];
for (const m of declaring) {
if (m.focus?.kind !== 'roving') continue;
if (m.focus.executor) continue; // signed custom — visible in the morfo
if (EXECUTOR_EXCEPTIONS[m.kebab]) continue;
const src = somaSources(m.kebab);
if (!src.includes('RovingFocusGroup')) {
offenders.push(`${m.kebab}: roving without RovingFocusGroup and without a signed executor`);
}
}
expect(offenders).toEqual([]);
});
it('the INVERSE: focus machinery without a declaration fails (the axis’ founding rule)', () => {
const declared = new Set(declaring.map((m) => m.kebab));
const offenders: string[] = [];
for (const m of morfos) {
if (declared.has(m.kebab)) continue;
if (INVERSE_EXCEPTIONS[m.kebab]) continue;
const src = somaSources(m.kebab);
if (!src) continue;
// The primitive/scope imports catch executor users; the pattern
// catches hand-rolled FOCUS-STOP machinery. Deliberately NOT
// `getDirectionalKeys` alone — arrows that move a VALUE (rating,
// media seek) or an aria-activedescendant HIGHLIGHT (select,
// command) are not tab-stop machinery; the activedescendant
// strategy is the axis' logged follow-up (a third `kind`, the
// author's call), not this census' net. Hand-rolled focus movement
// shows as directional keys COMBINED with a stop/`.focus()` walk —
// the tab-stop registry names are the reliable signal.
const machinery =
src.includes('FocusScope.use') ||
src.includes('RovingFocusGroup') ||
/currentTabStop|rovingIndex|rovingTarget/.test(src) ||
(/getDirectionalKeys/.test(src) && /(dom\.focus\(|\.focus\(\))/.test(src));
if (machinery) {
offenders.push(`${m.kebab}: uses focus machinery but declares no focus policy`);
}
}
expect(offenders).toEqual([]);
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.