Harden UIX event and color-role guards

astra
dev 2 weeks ago
parent e648502c57
commit 0b8ec4ce4f

@ -198,8 +198,8 @@ Queda una condición del autor: el push (L-123).
| L-155 | Announce sin pin directo en attach (default ON) ni en motor desnudo (opt-in); la viñeta de attach de sema.md nombra solo `defineEngineSemantic` y omite `defineUixServices({ events: { announce: false } })`, que también sirve | F4 adversarial L-ADV-5/6 | — | pequeño | DIFERIDO | hoy ciertas (medido); solo contracts.test.ts:670-702 pinea standalone |
| L-156 | Siete handoffs de julio en minúscula (`docs/process/continue-*.md`: agente, arts-docs-reconciliation, audit-fixes, cleanroom-fixes, proof-of-human, runed-tabbable-port, with-stumbles-s2-s6) quedaron fuera del censo del plan | F6 | — | — | DATO | no llevan cabecera CONGELADO ni están transcritos aquí: son históricos (varios se declaran cerrados en su cabecera) |
| L-157 | `docs-check` I10 cosecha los identificadores del código LÍNEA A LÍNEA (`identifiersOf`), así que el conjunto de verdad sigue a cómo prettier parte o junta las listas de import/export: 219 de 267 READMEs cambiaron su conjunto (55 crecen, 76 encogen) | F5 medición de guards (8a7a31fb9) | — | pequeño | ARREGLADO | `docs-check` I10 usa ahora AST de TypeScript para declaraciones e imports/exports de `.ts` y bloques `<script>` de `.svelte`; prueba de formato compacto/multilínea 2/2, inyección negativa de prop fantasma rechazada y `docs:check` 0/0 tras restaurar el README. Sin cambio de runtime. |
| L-158 | `pack-census.test.ts` detecta emisiones con `source.includes("'evento'")`: solo la comilla SIMPLE. Una emisión escrita con comilla doble o backtick produce un falso «nadie lo emite» | F5 medición de guards | — | trivial | CONFIRMADO | medido: dos emisiones de chronos estaban con comilla doble y el formateo las normalizó. Solo puede dar rojo falso, nunca tapar |
| L-159 | `recipe-css-contract.test.ts` delimita el lado derecho de un `export type` con el PRIMER `;`: un infractor repartido en ≥2 miembros de un tipo objeto queda fuera del barrido | F5 medición de guards | — | trivial | CONFIRMADO | hipotético hoy: 0 fugas medidas en el árbol (ningún literal de rol vive en el cuerpo de un `export type` fuera del span) |
| L-158 | `pack-census.test.ts` detecta emisiones con `source.includes("'evento'")`: solo la comilla SIMPLE. Una emisión escrita con comilla doble o backtick produce un falso «nadie lo emite» | F5 medición de guards | — | trivial | ARREGLADO | El censo reconoce literales de evento con comillas simples, dobles y backticks; prueba de las tres formas y rechazo de nombre distinto/sin comillas. Sin cambio de runtime. |
| L-159 | `recipe-css-contract.test.ts` delimita el lado derecho de un `export type` con el PRIMER `;`: un infractor repartido en ≥2 miembros de un tipo objeto queda fuera del barrido | F5 medición de guards | — | trivial | ARREGLADO | El guard de roles usa AST de TypeScript y recorre el tipo completo; prueba sintética de objeto con miembros separados por punto y coma, exclusión de Extract y tipo privado, y corpus exportado no vacío. Sin cambio de runtime. |
| L-160 | `.prettierrc` usa `endOfLine: "auto"`: un CR huérfano en un fichero hace que Prettier lo reescriba ENTERO en CR, y entonces el fichero es UNA sola línea para cualquier guard que parta por saltos de línea | F5 (8a7a31fb9) | — | pequeño | DIFERIDO | pasó en `REVIEW-theming-2026-08-24.md` (normalizado a LF ahí mismo); fijar `endOfLine: "lf"` reescribiría el árbol entero: fila aparte |
| L-161 | El `README.md` de la raíz era una plantilla npm vacía que mandaba `npm install vicen`, con el repositorio `private: true` y sin paquete publicado | pasada de documentación 2026-09-17 | — | trivial | ARREGLADO | reescrito como puerta: la tesis en cuatro líneas, «se consume como fuente», y la tabla de a dónde ir |
| L-162 | No existía ninguna página que describiera el repositorio como objeto: sus zonas, la ley de congelación de `web/routes/`, `scripts/` como zona tipada, el workspace único | pasada de documentación 2026-09-17 | — | pequeño | ARREGLADO | NUEVA `docs/repository.md` (E0), enlazada desde el mapa, el README de la raíz, getting-started y AGENTS.md |

@ -180,6 +180,18 @@ markup no aportan nombres. Una prop inventada en un README hizo fallar I10; el
archivo se restauró y `docs:check` volvió a 0 errores y 0 avisos. `check:gate`
terminó sin errores nuevos en `src/` ni `scripts/`. No cambia el runtime de UIX.
### Guardias de censo L-158 y tipos L-159 (2026-09-21)
El censo de eventos de Sema reconoce nombres literales con comillas simples,
dobles y backticks. Su prueba fija los tres casos y rechaza un nombre distinto.
El guard de roles Eidos analiza los `export type` mediante el AST de TypeScript:
ya inspecciona todos los miembros de un tipo objeto aunque haya `;` internos,
sin denunciar `Extract<ColorRole, …>` ni tipos privados. Una prueba sintética
reproduce el caso antes invisible y el guard comprueba que el corpus de tipos
exportados inspeccionado no está vacío. Son cambios de tests, sin cambio de
runtime ni del árbol `web/`. El gate completo pasó 470/470 archivos y
5464/5464 pruebas; `apps/base` pasó check, build y smoke.
El [ledger de cierre](./LEDGER-cierre-2026-09.md) es la fuente vigente del estado de
trabajos anteriores; los handoffs congelados sirven de procedencia. Este plan
ordena la auditoría, **no crea otro ledger ni considera cerradas sus filas**.

@ -1,5 +1,6 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
import { THEME_BASE_RECIPE_TOKENS } from './lib/recipes/base';
@ -46,6 +47,33 @@ const RECIPE_TOKENS = THEME_BASE_RECIPE_TOKENS as unknown as Readonly<
// Their recipes/CSS re-enter the contract when the tracks land.
const WIP_TRACKS = new Set(['palabras', 'chronos']);
/** Find exported aliases that spell multiple colour roles instead of deriving them. */
function handSpelledRoleAliases(sourceFile: ts.SourceFile, roleNames: readonly string[]): string[] {
const violations: string[] = [];
for (const declaration of sourceFile.statements) {
if (!ts.isTypeAliasDeclaration(declaration)) continue;
if (!declaration.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
continue;
}
const spelled: string[] = [];
const visit = (node: ts.Node): void => {
// Extract selects from the derived union and does not re-declare its vocabulary.
if (ts.isTypeReferenceNode(node) && node.typeName.getText(sourceFile) === 'Extract') return;
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) {
if (roleNames.includes(node.literal.text)) spelled.push(node.literal.text);
}
ts.forEachChild(node, visit);
};
visit(declaration.type);
if (spelled.length >= 2) {
violations.push(
`${sourceFile.fileName}: ${declaration.name.text} re-spells [${spelled.join(', ')}] — derive from COLOR_ROLES`
);
}
}
return violations;
}
/** Extract all CSS values from a recipe entry. Supports the three TSC
* forms: bare string/number, RecipeTokenSingle (one declaration), and
* RecipeTokenMultiDeclaration (declarations[]). Returns the values
@ -364,29 +392,39 @@ describe('Eidos recipe CSS contract', () => {
const LIB_DIR = 'src/uix/eidos/lib';
const roleNames = COLOR_ROLES as readonly string[];
const violations: string[] = [];
let scanned = 0;
for (const file of readdirSync(LIB_DIR)) {
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue;
if (file === 'config-types.ts') continue;
const source = readFileSync(join(LIB_DIR, file), 'utf8');
for (const match of source.matchAll(/export type (\w+) =([^;]*);/g)) {
// `Extract<ColorRole, 'a' | 'b'>` SELECTS from the derived union, so it
// tracks the const by construction. Only a fresh literal union
// re-declares the vocabulary, which is the shape that fell behind.
const rhs = match[2].replace(/Extract<[\s\S]*?>/g, '');
const spelled = [...rhs.matchAll(/'([a-z-]+)'/g)]
.map((literal) => literal[1])
.filter((literal) => roleNames.includes(literal));
if (spelled.length >= 2) {
violations.push(
`${file}: ${match[1]} re-spells [${spelled.join(', ')}] — derive from COLOR_ROLES`
);
}
}
const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
scanned += parsed.statements.filter(
(statement) =>
ts.isTypeAliasDeclaration(statement) &&
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
).length;
violations.push(...handSpelledRoleAliases(parsed, roleNames));
}
expect(scanned).toBeGreaterThan(0);
expect(violations).toEqual([]);
});
it('checks role literals after semicolons inside exported object types', () => {
const source = `
type Private = 'primary' | 'secondary';
export type Selected = Extract<ColorRole, 'primary' | 'secondary'>;
export type Broken = {
label: string;
color: 'primary' | "secondary";
};
`;
const parsed = ts.createSourceFile('synthetic.ts', source, ts.ScriptTarget.Latest, true);
expect(handSpelledRoleAliases(parsed, COLOR_ROLES)).toEqual([
'synthetic.ts: Broken re-spells [primary, secondary] — derive from COLOR_ROLES'
]);
});
it('pairs a dynamic `data-color` stamp with `data-color-custom` (runtime openness)', () => {
// The type guard above proves the *type* stays open, but is blind to the
// RUNTIME: a wrapper that stamps `data-color={value}` without also stamping

@ -363,6 +363,11 @@ function stripComments(source: string): string {
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
}
/** A quoted event name remains a literal with any of the three JS quote styles. */
function containsEventLiteral(source: string, name: string): boolean {
return [`'${name}'`, `"${name}"`, `\`${name}\``].some((literal) => source.includes(literal));
}
function readSourceTree(dir: string): string | undefined {
if (!existsSync(dir)) return undefined;
const parts: string[] = [];
@ -395,6 +400,14 @@ function somaSourceOf(componentKebab: string): string | undefined {
// ── The census ────────────────────────────────────────────────────────────
describe('pack census — every cascade rule can fire, and fires where the morfo says', () => {
it('recognizes emitted event literals regardless of quote style', () => {
for (const literal of ["'emerge-open'", '"emerge-open"', '`emerge-open`']) {
expect(containsEventLiteral(`const event = ${literal};`, 'emerge-open')).toBe(true);
}
expect(containsEventLiteral("const event = 'emerge-open-sub';", 'emerge-open')).toBe(false);
expect(containsEventLiteral('const event = emerge-open;', 'emerge-open')).toBe(false);
});
it('finds the catalogue (sanity: the census is not scanning an empty tree)', () => {
expect(PACKS.length).toBeGreaterThan(60);
expect(RULES.length).toBeGreaterThan(150);
@ -559,7 +572,7 @@ describe('pack census — every cascade rule can fire, and fires where the morfo
const key = `${resolved.morfo.kebab}:[${candidates.join(',')}]`;
if (seen.has(key)) continue;
seen.add(key);
if (!candidates.some((name) => source.includes(`'${name}'`))) {
if (!candidates.some((name) => containsEventLiteral(source, name))) {
offenders.push(
`${pack.file}: none of [${candidates.join(', ')}] is emitted by any ` +
`${resolved.morfo.kebab} provider`

Loading…
Cancel
Save

Powered by TurnKey Linux.