Firma SS16, quinta aplicacion de la doctrina "la capa sostiene la pluma, no es
la dueña". El censo, el ledger y la ley del espacio cerrado auditaban SOLO
quien LEE (recetas de eidos); quien ESCRIBE la custom property por instancia
vive en soma/arts y ningun instrumento del eje lo habia enumerado jamas.
EL BUG VIVO QUE ESTO DESTAPO — y no se parcheo, se diagnostico
--drawer-overlay-opacity era DOS especies bajo un nombre: el knob de tema (el
contrato dice 62%) y el canal del arrastre (0..1). soma escribia el canal SIN
UNIDAD sobre el nombre del knob; color-mix() exige porcentaje, la funcion caia
invalida y el velo computaba rgba(0,0,0,0): NO PINTABA. Y el inline dejaba el
knob inalcanzable para cualquier tema. Se separan: el publico se queda como
knob (nadie lo escribe en runtime), el arrastre pasa a
--_drawer-overlay-progress (sin unidad, que para un multiplicador es lo
correcto) y la receta los COMPONE - calc(knob * progress) -, asi que el
arrastre ATENUA el tema en vez de destruirlo.
Medido en Chrome: reposo 0.408471 (antes rgba 0,0,0,0) - el tema LLEGA (20%
-> 0.1318, 62% -> 0.4085, 100% -> 0.6588; antes ninguno movia nada) - el
arrastre sigue (Escape real -> alpha 0; 0/0.25/0.5/1 lineal) - control
negativo: reinyectando la escritura vieja el velo vuelve a caer, o sea que la
unidad era el SINTOMA y las dos especies la enfermedad.
⚠ Estaba adjudicado EN PROSA en theming-sentinel-exceptions.ts:815 desde hacia
dias: la excepcion se trago el bug.
LOS 18 NOMBRES A SU SITIO
Clase B (11 en command/dialog/scroll-area/toast + 4 del drawer): forma publica
sin contrato y sin UN SOLO lector en el repo - una API publicada que el
framework no consume. A --_{c}-*, y los README de soma dejan de enseñarlos
como API del consumidor: ahora enseñan LA PARTE, con la formulacion verbatim
que SS15 ya habia verificado. Clase D (tree-view, gradient-picker): tenian
lector, verificados en navegador. --scrollbar-width NO se renombro por
inercia: es una escritura sobre el unico <body>, la doctrina no le llega, y
queda REGISTRADA con su razon en vez de inventarle un dueño.
LA AGUJA - src/uix/value-channels.test.ts (fichero propio, 6 tests)
Vitest y no script, porque el gate termina en la suite y eso es lo que
convierte la doctrina en ley. Deriva el vocabulario de sistema RESTANDO el
contrato a lo que emite el generador (lista derivada, nunca a mano). Barre
NUEVE raices - las siete nuevas verificadas a cero ANTES de asertarlas - y el
quinto test asserta que cada raiz declarada se anduvo de verdad: una raiz que
resuelve a cero ficheros es la puerta que nadie habria visto.
Dos correcciones al dimensionado, por medida: el arbol tiene SEIS formas de
escribir, no cuatro, y una de las que faltaban era LA CANONICA (la
--_${component}-... que SS14 y SS15 firmaron) - un guard ciego a ella habria
dado verde sobre su propio destino. Y un barrido mas ancho marcaba en rojo un
anchor-name, que en gramatica es identico a un nombre de propiedad: probado y
REVERTIDO. El instrumento miente primero.
Mutaciones: cinco, con el arbol byte a byte identico. Incluyen las dos que
prueban lo que las correcciones añaden (una clave _ DEL contrato pasa de verde
a rojo; un --_ legitimo en blocks pasa de rojo a verde).
LO QUE EL ADVERSARIAL CORRIGIO DE MI PROPIA LEY
Dictamen: "es LEY sobre la mitad que barre, y sigue siendo PROSA sobre el
absoluto que enuncia". Cierto: decia "toda escritura por instancia" y la aguja
no mira eidos, donde viven ONCE escrituras de nombres contratados. Corregido -
el enunciado nombra sus nueve raices y DECLARA sus dos fronteras (eidos, con
su expediente abierto; web/routes, congelado); las salidas son CUATRO y no
tres (la cuarta, sistema, es verde); y el registro se queda con cuatro campos
por entrada (since, reason, destination, heldBecause) con un test que exige
los cuatro - la diferencia entre un registro y un cajon.
Y la relectura con ojo de abogado encontro DOS absolutos mas, en direccion
contraria, escritos bajo "WHAT THE GUARD DOES NOT CHECK": "por instancia" no
es decidible estaticamente (el antecedente del guard es MAS ANCHO que el de la
doctrina), y el {c} de --_{c}-* no lo comprueba nadie, solo el guion bajo.
SS18 ABIERTO, y es la respuesta medida a "¿puede volver a nacer un nombre sin
dueño sin que nadie se entere?": SI, desde eidos. 11 contratadas + 18 sin
dueño, partidas en dos especies (la fundacion generando su vocabulario, que es
legitima, y las props ergonomicas del consumidor, que no es lo mismo).
dialog-overlay-opacity es el GEMELO EXACTO del velo del drawer y sigue vivo.
No es "añadir el root": distinguir las dos especies EXIGE FIRMA.
Guards: value-channels 6/6 - recipe-css-contract + reach-floor + generated-css
55/55 - docs:check 0/0 sobre 819 - --debt 1148/0/0 - tsc 0 propios - prettier
limpio.
BREAKING: los 18 nombres publicos ya no existen; el canal se lee --_{c}-* y
sigue sin ser de nadie para fijarlo. Y consumir un --{c}-* del contrato desde
un provider es ROJO desde hoy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
parent
839f79eeb7
commit
f1f686d097
@ -0,0 +1,648 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { renderGeneratedBaseEidosCss } from './eidos/lib/generated-css';
|
||||
import { THEME_BASE_RECIPE_TOKENS } from './eidos/lib/recipes/base';
|
||||
import type { RecipeTokenValue } from './eidos/lib/config-types';
|
||||
|
||||
/**
|
||||
* VALUE CHANNELS — the guard on the side that WRITES (firma §16, 2026-08-27;
|
||||
* its SCOPE written down the same day, see THE BORDER below).
|
||||
*
|
||||
* THE LAW. In the roots this file sweeps, a per-instance custom-property write
|
||||
* lives in `--_{c}-*` and `base.ts` does not declare that name. If the name IS
|
||||
* in the contract — public `--{c}-x` or private `--_{c}-x`, the same species
|
||||
* either way — it is a TOKEN THAT LIES: an inline per-instance write beats
|
||||
* every normal rule, so the theme entitled to that name moves nothing.
|
||||
*
|
||||
* THE BORDER, because a law whose guard does not reach where it points is
|
||||
* prose. Two roots are OUT, and neither is empty:
|
||||
*
|
||||
* · `src/uix/eidos` — 11 writes of CONTRACTED names and 18 ownerless public
|
||||
* forms live there TODAY (measured 2026-08-27 by pointing this same scanner
|
||||
* at it). They are not one species but two, and that is why the root is not
|
||||
* simply added here: `lib/render-css`, `lib/build-scheme`,
|
||||
* `lib/build-space-scale` are the FOUNDATION emitting its own vocabulary,
|
||||
* which is what a foundation is for; the ten in `components/*.svelte` are
|
||||
* ergonomic props a wrapper offers its CONSUMER, which is a different
|
||||
* question — one of them, `--dialog-overlay-opacity`, is the exact twin of
|
||||
* the drawer veil this firma just fixed. Telling the two apart takes a
|
||||
* signature. The expediente is `docs/next-features.md` §18.
|
||||
* · `web/routes` — FROZEN (`05dcb4db7`) and rebuilt whole; its 204 writes are
|
||||
* a tree that is leaving. Judging them would also need the clause this law
|
||||
* does not have: a demo writing `--button-palette-solid` inline is a
|
||||
* CONSUMER using a token for what it is, not a framework publishing a name.
|
||||
*
|
||||
* So the law's subject is THE FRAMEWORK'S WRITING SIDE, not «everyone». Seven
|
||||
* of the nine roots swept write nothing at all today (`libs` 257 files, `svrs`
|
||||
* 79, `sema` 100, `morfo` 181, `active-uix` 11, `blocks` 143, `packs` 37 — zero
|
||||
* writes between them); they are swept so a first write cannot be born unseen,
|
||||
* and they are NOT held to zero: a block that tomorrow stamps its own
|
||||
* `--_{c}-*` geometry is legal, and reddening it would enforce a census instead
|
||||
* of the law.
|
||||
*
|
||||
* WHY THIS FILE EXISTS AT ALL. The theming axis reads
|
||||
* `src/uix/eidos/components` and nothing else (`scripts/theming-census.ts`,
|
||||
* `const ROOT`) — the census, the debt ledger, R-5.1 and the closed-namespace
|
||||
* law all live on the side that READS. Whoever WRITES the value lives in
|
||||
* `src/uix/soma` and `src/arts`, and no instrument of the axis had ever
|
||||
* enumerated it. This is `05dcb4db7` («el guard vive donde NACE el valor»)
|
||||
* applied to theming: a guard aimed at the observed artefact measures the side
|
||||
* that reads and reports the number as if it were the whole.
|
||||
*
|
||||
* WHY A VITEST FILE AND NOT A SCRIPT. `npm run gate` ends in `npm run test`,
|
||||
* so the suite is what turns a doctrine into a law; a `scripts/*.ts` needs a
|
||||
* new gate entry and an author who remembers to add it. And why at `src/uix/`
|
||||
* root rather than under `eidos/`: its SUBJECT is the writing side and its
|
||||
* JUDGE is the eidos contract, so it belongs to no single layer — the same
|
||||
* reason `src/uix/contracts.test.ts` sits here.
|
||||
*
|
||||
* WHAT IT DOES NOT DO. It never reuses the census's contract reader, and it
|
||||
* never opens a `.css` file: this guard has no opinion about CONSUMPTION.
|
||||
* A name with zero readers and a name with ten are judged identically, by the
|
||||
* shape of the name and nothing else — which is the whole point, since the
|
||||
* fifteen names this firma retired had zero readers and were still API.
|
||||
*
|
||||
* And two things it does not CHECK, written here so nobody infers them from a
|
||||
* green — each one a place where the sentence and the instrument do not meet:
|
||||
*
|
||||
* · «PER INSTANCE» is not statically decidable, so this file does not decide
|
||||
* it. Its antecedent is WIDER than the doctrine's: any write of a
|
||||
* contracted or ownerless name in these roots is red, per-instance or not.
|
||||
* That is what the registry is FOR rather than a leak in it —
|
||||
* `--scrollbar-width` is one write on the single `<body>`, so the doctrine
|
||||
* does not reach it while this guard does, and the entry carries the
|
||||
* difference in writing.
|
||||
* · The `{c}` of `--_{c}-*` is NOT enforced — only the underscore is.
|
||||
* `--_totally-invented-thing` passes here. The component segment is naming
|
||||
* doctrine (THEMING §6) with its own enforcement, and the tree holds
|
||||
* legitimate exceptions to the letter besides: a shared LAYER writes in the
|
||||
* HOST's namespace (`--_{host}-floating-*`, §15) and some privates are
|
||||
* layer-scoped rather than component-scoped
|
||||
* (`--_viewport-placement-offset`). Closing it is a rename axis, not a line.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The roots the law reaches — its scope, written down rather than implied by
|
||||
* whatever this array happened to hold. `src/uix/eidos` and `web/routes` are
|
||||
* OUT by declaration, not by omission: see THE BORDER above.
|
||||
*
|
||||
* The first two WRITE today (soma 1281 files, arts 448). The other seven wrote
|
||||
* nothing on 2026-08-27 and are here so a first write cannot be born unseen —
|
||||
* held to the same two reds as everybody else, never to zero.
|
||||
*/
|
||||
const LAW_ROOTS = [
|
||||
'src/uix/soma',
|
||||
'src/arts',
|
||||
'src/uix/blocks',
|
||||
'src/packs',
|
||||
'src/libs',
|
||||
'src/svrs',
|
||||
'src/uix/sema',
|
||||
'src/uix/morfo',
|
||||
'src/uix/active-uix'
|
||||
] as const;
|
||||
|
||||
const SOURCE_FILE = /\.(ts|js|svelte)$/;
|
||||
/**
|
||||
* Tests are EXCLUDED, with a reason: a spec asserting `'--_toast-swipe-move-x'`
|
||||
* OBSERVES a writer, it is not one, and the writer it observes is already in
|
||||
* scope. Nothing a test file writes reaches a user's DOM. (Measured cost of the
|
||||
* choice over soma + arts, 2026-08-27: 2014 files with tests, 1729 without —
|
||||
* the whole sweep is 2537 files across the nine roots; the only name that
|
||||
* lives exclusively in a test is `--ty`, the spring fixture the sizing census
|
||||
* had already classified as noise.)
|
||||
*/
|
||||
const TEST_FILE = /\.(test|spec)\.(ts|js)$/;
|
||||
|
||||
type WriteForm =
|
||||
| 'object-key'
|
||||
| 'computed-key'
|
||||
| 'declaration'
|
||||
| 'property-call'
|
||||
| 'member-assign'
|
||||
| 'style-directive';
|
||||
|
||||
interface Write {
|
||||
readonly name: string;
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
readonly form: WriteForm;
|
||||
}
|
||||
|
||||
// ── The registry of open adjudications ──────────────────────────────────────
|
||||
//
|
||||
// It KEEPS its registry, and the doctrine is the debt ledger's
|
||||
// (`scripts/theming-census-debt.ts`), not the exception file's: it may only
|
||||
// SHRINK, an entry whose name stops being an ownerless write is reported STALE
|
||||
// and stays red until the line is deleted, and nothing regenerates it. An
|
||||
// exception says «this is fine»; every entry here says the opposite — «this one
|
||||
// is outside the law's antecedent, we measured why, and it is waiting for a
|
||||
// signature».
|
||||
//
|
||||
// ⚠ THE OBJECTION IT HAS TO ANSWER, because it is the house's own and it is 24
|
||||
// hours old: `PENDING_PRIVATE_RENAME` was DISMANTLED on 2026-08-26 over this
|
||||
// same species of name («there is no third state»). The difference that keeps
|
||||
// this registry alive is not permissiveness, it is FORM. That one held names
|
||||
// awaiting a rename already decided — a queue, and a queue with no deadline is
|
||||
// a cupboard. These hold names the law does not reach (`--scrollbar-width` is
|
||||
// not per-instance) or cannot judge alone (the `palabras` track), which is the
|
||||
// ledger's own case. What stops it becoming that cupboard is the shape of an
|
||||
// ENTRY: four fields, and the fourth is the one a drawer can never fill.
|
||||
//
|
||||
// since — the date it entered. An undated registry cannot age.
|
||||
// reason — why the law's antecedent does not close over it. MEASURED.
|
||||
// destination — where it goes, named. Not «to be decided».
|
||||
// heldBecause — why it did not move on the day it was written. This is the
|
||||
// field that makes a stalled entry visible AS stalled: an
|
||||
// entry that cannot say what blocks it has nothing blocking it,
|
||||
// and belongs in the commit rather than in this list.
|
||||
interface Adjudication {
|
||||
readonly since: string;
|
||||
readonly reason: string;
|
||||
readonly destination: string;
|
||||
readonly heldBecause: string;
|
||||
}
|
||||
|
||||
const PALABRAS_SCHEME: Adjudication = {
|
||||
since: '2026-08-27',
|
||||
reason:
|
||||
"The `palabras` WIP track, found by the `member-assign` form (`out['--x'] = v` on a " +
|
||||
'style object) that the sizing census of §16 did not scan. Each name is READ by ' +
|
||||
'`eidos/components/palabras/palabras.css` with a fallback and re-declared inline per ' +
|
||||
'BLOCK on purpose — the engine comment states the intent: beat the doc-root value. ' +
|
||||
'So it is the species by shape, and arguably the designed cascade of a document ' +
|
||||
'editor by function; that is precisely what has to be adjudicated, not asserted.',
|
||||
destination:
|
||||
"The `palabras` axis, which is already in the author's tray. Registered rather than " +
|
||||
'excluded as a WIP track (the exclusion `recipe-css-contract.test.ts` grants it on ' +
|
||||
'the READ side) because the debt ledger settled this the other way: «THE WIP TRACKS ' +
|
||||
'ARE IN … debt is real». An eighth sibling cannot slip in unsigned.',
|
||||
heldBecause:
|
||||
"Renaming them is not this axis's call to make: the seven are READ by `palabras.css` " +
|
||||
'with a fallback, so the flip is writer + reader in one edit inside a track whose own ' +
|
||||
'codemod is already adjudicated and waiting in that tray. Moving them here would ' +
|
||||
'collide with it, and the collision would be silent — the same file, two hands.'
|
||||
} as const;
|
||||
|
||||
const ADJUDICATION_PENDING: Readonly<Record<string, Adjudication>> = {
|
||||
'--palabras-scheme-list-item-gap': PALABRAS_SCHEME,
|
||||
'--palabras-scheme-list-marker-color': PALABRAS_SCHEME,
|
||||
'--palabras-scheme-list-check-box': PALABRAS_SCHEME,
|
||||
'--palabras-scheme-list-check-box-color': PALABRAS_SCHEME,
|
||||
'--palabras-scheme-table-border-inner': PALABRAS_SCHEME,
|
||||
'--palabras-scheme-columns-rule': PALABRAS_SCHEME,
|
||||
'--palabras-scheme-columns-col-pad': PALABRAS_SCHEME,
|
||||
'--scrollbar-width': {
|
||||
since: '2026-08-27',
|
||||
reason:
|
||||
'NOT per-instance, so the law does not reach it: `arts/adom/body-scroll-lock` ' +
|
||||
'writes it ONCE on `document.body` (N locks collapse into a single write against ' +
|
||||
'the one body) and its value is a fact of the VIEWPORT — `innerWidth - ' +
|
||||
'clientWidth` — not of any component instance. Zero readers in the repo; the ' +
|
||||
'compensation the lock actually performs is the `padding-right` on the line above.',
|
||||
destination:
|
||||
'A SIGNATURE choosing one of two branches: DECLARE it as system vocabulary, or ' +
|
||||
'WITHDRAW it. There is no third branch — renaming it to `--_adom-scrollbar-width` ' +
|
||||
'would apply a rule whose condition is not met and hide a global measurement ' +
|
||||
'behind a private name for no reader.',
|
||||
heldBecause:
|
||||
'Both branches cost exactly what §16 says must be signed, in opposite directions: ' +
|
||||
'declaring it publishes API that nobody reads (the defect this axis chases), and ' +
|
||||
'withdrawing it DELETES a name, which this repo does not do without an explicit ' +
|
||||
'instruction. A pass with no signature can only take a third option, and the third ' +
|
||||
'option is the wrong one — so the 2026-08-27 pass took none and said so here.'
|
||||
}
|
||||
};
|
||||
|
||||
// ── Reading the contract ────────────────────────────────────────────────────
|
||||
|
||||
const RECIPE_TOKENS = THEME_BASE_RECIPE_TOKENS as unknown as Readonly<
|
||||
Record<string, Readonly<Record<string, RecipeTokenValue>>>
|
||||
>;
|
||||
|
||||
/** `{c}` + `{key}` → the custom property the generator emits. */
|
||||
function recipeTokenName(component: string, key: string): string {
|
||||
if (key.startsWith('_')) return `--_${component}-${key.slice(1)}`;
|
||||
return `--${component}-${key}`;
|
||||
}
|
||||
|
||||
/** Every name `lib/recipes/base.ts` declares — the token that LIES if written. */
|
||||
function contractNames(): ReadonlySet<string> {
|
||||
const names = new Set<string>();
|
||||
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
|
||||
for (const key of Object.keys(tokens)) {
|
||||
if (key === 'composition') continue;
|
||||
names.add(recipeTokenName(component, key));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* The legitimate SYSTEM vocabulary — DERIVED, never hand-written: every custom
|
||||
* property the foundation emits, minus the recipe contract. That subtraction is
|
||||
* what keeps the two reds apart, since a contract token comes out of the same
|
||||
* renderer. An art writing `--color-*` / `--space-*` / `--motion-*` speaks the
|
||||
* foundation's language; it is not inventing a surface.
|
||||
*/
|
||||
function systemNames(contract: ReadonlySet<string>): ReadonlySet<string> {
|
||||
const names = new Set<string>();
|
||||
for (const match of renderGeneratedBaseEidosCss().matchAll(/(--[A-Za-z_][\w-]*)\s*:/g)) {
|
||||
if (!contract.has(match[1])) names.add(match[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// ── The scanner ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// SIX write forms. The firma named four; the other two were found by sweeping
|
||||
// the tree instead of the pattern, and one of them is the CANONICAL form:
|
||||
// · `computed-key` — ``[`--_${component}-floating-anchor-width`]:``, the
|
||||
// `component: string` shape §14 and §15 signed. A guard that could not see
|
||||
// it would be blind to the very destination it enforces.
|
||||
// · `member-assign` — `out['--x'] = v` onto a style object (`palabras`).
|
||||
//
|
||||
// TWO shapes it deliberately does NOT resolve, said out loud rather than
|
||||
// implied by a green: (1) a name ASSEMBLED across statements — `const c =
|
||||
// \`--_${this.component}-indicator-\`` then ``[`${c}x`]`` in
|
||||
// `soma/layers/measured-indicator` — which no static reader can rebuild; (2) a
|
||||
// name reaching `setProperty` through a variable, as in `body-scroll-lock`'s
|
||||
// `MANAGED_PROPERTIES` loop, whose name is nonetheless caught at the literal
|
||||
// `setProperty` site three lines down. A bare-literal sweep was tried for (2)
|
||||
// and REVERTED: it red-flagged `--floating-anchor-{id}`, which is an
|
||||
// `anchor-name` VALUE — a dashed-ident, identical in grammar to a property
|
||||
// name and never written as one.
|
||||
//
|
||||
// A custom-property name, in the grammar the repo actually uses: `--` then a
|
||||
// letter or `_`. `$` is admitted INSIDE the name because that is the character
|
||||
// this scanner paints over a template interpolation — see `scanScript`. It is
|
||||
// deliberately NOT `_`: painting `--${c}-x` with underscores would forge a
|
||||
// private prefix and turn the guard's own red into a green.
|
||||
const NAME = String.raw`--[A-Za-z_$][\w$-]*`;
|
||||
/** Render a scanned name for a human: the interpolation runs come back as `{}`. */
|
||||
const readable = (name: string) => name.replace(/\$+/g, '{}');
|
||||
|
||||
/** `--x: …` inside a style string — the DECLARATION form. */
|
||||
const DECLARATION = new RegExp(String.raw`(?:^|[;{\s])(${NAME})\s*:`, 'g');
|
||||
/** `'--x':` — the object-literal KEY form. */
|
||||
const OBJECT_KEY = new RegExp(String.raw`(['"\`])(${NAME})\1\s*:`, 'g');
|
||||
/** ``[`--_${c}-x`]:`` — the COMPUTED key, which is the canonical §14/§15 form. */
|
||||
const COMPUTED_KEY = new RegExp(String.raw`\[\s*(['"\`])(${NAME})\1\s*\]\s*:`, 'g');
|
||||
/** `setProperty('--x', …)` and the `$adom` helper `writeProperty(el, '--x', …)`. */
|
||||
const PROPERTY_CALL = new RegExp(
|
||||
String.raw`(?:set|write)Property\(\s*(?:[^,()]*,\s*)?(['"\`])(${NAME})\1`,
|
||||
'g'
|
||||
);
|
||||
/** `out['--x'] = v` — the member ASSIGNMENT onto a style object. */
|
||||
const MEMBER_ASSIGN = new RegExp(String.raw`\[\s*(['"\`])(${NAME})\1\s*\]\s*=[^=]`, 'g');
|
||||
/** Svelte's `style:--x={…}` directive. */
|
||||
const STYLE_DIRECTIVE = new RegExp(String.raw`style:(${NAME})`, 'g');
|
||||
/** A `style` attribute in svelte markup, in its three spellings. */
|
||||
const STYLE_ATTRIBUTE = /style=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g;
|
||||
const SCRIPT_BLOCK = /<script[^>]*>([\s\S]*?)<\/script>/g;
|
||||
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
||||
|
||||
interface ScriptScan {
|
||||
/** Comments blanked to spaces; every `${…}` rendered as `{}`. Same length. */
|
||||
readonly normalized: string;
|
||||
/** Content span of every string / template literal, in `normalized`. */
|
||||
readonly strings: readonly { readonly start: number; readonly end: number }[];
|
||||
/** Content span of every `${…}` interpolation, in the ORIGINAL source. */
|
||||
readonly interpolations: readonly { readonly start: number; readonly end: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a script and separate CODE from COMMENTS from STRING LITERALS.
|
||||
*
|
||||
* A regex alone cannot do this, and the difference is not academic: the census
|
||||
* that sized this firma counted `--ty` (a JSDoc `@default` example in
|
||||
* `arts/motion/drivers.ts`), `--state` (prose in a comment) and `---` (a
|
||||
* markdown alignment marker in `palabras`) among its writes. A guard whose reds
|
||||
* include prose teaches its reader to skim them.
|
||||
*
|
||||
* Interpolations are rendered as `{}` rather than dropped, so a name BUILT from
|
||||
* a variable keeps its shape: ``--_${component}-floating-anchor-width`` reads
|
||||
* as `--_{}-floating-anchor-width` and stays private, while a hypothetical
|
||||
* ``--${component}-indicator-x`` reads as `--{}-indicator-x` and is red. That
|
||||
* is the canonical form of §14/§15 — a guard blind to it would be blind to the
|
||||
* destination it is enforcing.
|
||||
*/
|
||||
function scanScript(source: string): ScriptScan {
|
||||
const out = source.split('');
|
||||
const strings: { start: number; end: number }[] = [];
|
||||
const interpolations: { start: number; end: number }[] = [];
|
||||
let i = 0;
|
||||
|
||||
/** Blank a span to spaces, keeping newlines so line numbers survive. */
|
||||
const blank = (from: number, to: number) => {
|
||||
for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' ';
|
||||
};
|
||||
|
||||
/** Paint a `${…}` span with `$`, keeping length AND newlines. */
|
||||
const stamp = (from: number, to: number) => {
|
||||
for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = '$';
|
||||
};
|
||||
|
||||
/** Index just past the string literal that starts at `at`. */
|
||||
const skipString = (at: number): number => {
|
||||
const quote = source[at];
|
||||
let k = at + 1;
|
||||
while (k < source.length) {
|
||||
const ch = source[k];
|
||||
if (ch === '\\') {
|
||||
k += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) return k + 1;
|
||||
if (quote !== '`' && ch === '\n') return k;
|
||||
if (quote === '`' && ch === '$' && source[k + 1] === '{') {
|
||||
k = skipBraces(k + 1);
|
||||
continue;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
return k;
|
||||
};
|
||||
|
||||
/** Index just past the `{…}` that starts at `at`, string-aware. */
|
||||
const skipBraces = (at: number): number => {
|
||||
let depth = 0;
|
||||
let k = at;
|
||||
while (k < source.length) {
|
||||
const ch = source[k];
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
k = skipString(k);
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth += 1;
|
||||
else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return k + 1;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
return k;
|
||||
};
|
||||
|
||||
while (i < source.length) {
|
||||
const c = source[i];
|
||||
const next = source[i + 1];
|
||||
|
||||
if (c === '/' && next === '/') {
|
||||
const end = source.indexOf('\n', i);
|
||||
const stop = end === -1 ? source.length : end;
|
||||
blank(i, stop);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
const end = source.indexOf('*/', i + 2);
|
||||
const stop = end === -1 ? source.length : end + 2;
|
||||
blank(i, stop);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
const end = skipString(i);
|
||||
// `end` is past the closing quote unless the literal was unterminated.
|
||||
const closed = source[end - 1] === c && end - 1 > i;
|
||||
strings.push({ start: i + 1, end: closed ? end - 1 : end });
|
||||
if (c === '`') {
|
||||
// Stamp each interpolation and remember it for the recursive pass.
|
||||
let k = i + 1;
|
||||
while (k < end) {
|
||||
if (source[k] === '\\') {
|
||||
k += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[k] === '$' && source[k + 1] === '{') {
|
||||
const close = skipBraces(k + 1);
|
||||
interpolations.push({ start: k + 2, end: Math.max(k + 2, close - 1) });
|
||||
stamp(k, close);
|
||||
k = close;
|
||||
continue;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return { normalized: out.join(''), strings, interpolations };
|
||||
}
|
||||
|
||||
const lineAt = (source: string, index: number) => source.slice(0, index).split('\n').length;
|
||||
|
||||
function collectFromScript(source: string, file: string, lineOffset = 0): Write[] {
|
||||
const writes: Write[] = [];
|
||||
const { normalized, strings, interpolations } = scanScript(source);
|
||||
const at = (index: number) => lineOffset + lineAt(normalized, index);
|
||||
|
||||
const push = (name: string, index: number, form: WriteForm) =>
|
||||
writes.push({ name, file, line: at(index), form });
|
||||
|
||||
for (const m of normalized.matchAll(OBJECT_KEY)) push(m[2], m.index, 'object-key');
|
||||
for (const m of normalized.matchAll(COMPUTED_KEY)) push(m[2], m.index, 'computed-key');
|
||||
for (const m of normalized.matchAll(PROPERTY_CALL)) push(m[2], m.index, 'property-call');
|
||||
for (const m of normalized.matchAll(MEMBER_ASSIGN)) push(m[2], m.index, 'member-assign');
|
||||
|
||||
for (const { start, end } of strings) {
|
||||
for (const m of normalized.slice(start, end).matchAll(DECLARATION)) {
|
||||
push(m[1], start, 'declaration');
|
||||
}
|
||||
}
|
||||
|
||||
// The interpolations were painted over above, so their own contents are only
|
||||
// visible to a second, independent pass over the same bytes.
|
||||
for (const { start, end } of interpolations) {
|
||||
writes.push(
|
||||
...collectFromScript(source.slice(start, end), file, lineOffset + lineAt(source, start) - 1)
|
||||
);
|
||||
}
|
||||
|
||||
return writes;
|
||||
}
|
||||
|
||||
function collectWrites(file: string): Write[] {
|
||||
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
const path = file.replace(/\\/g, '/');
|
||||
|
||||
if (!path.endsWith('.svelte')) return collectFromScript(source, path);
|
||||
|
||||
const writes: Write[] = [];
|
||||
const markup = source.replace(HTML_COMMENT, (m) => m.replace(/[^\n]/g, ' ')).split('');
|
||||
|
||||
for (const m of source.matchAll(SCRIPT_BLOCK)) {
|
||||
writes.push(...collectFromScript(m[1], path, lineAt(source, m.index) - 1));
|
||||
// A script body is not markup: blank it before the directive sweep.
|
||||
for (let k = m.index; k < m.index + m[0].length; k += 1) {
|
||||
if (markup[k] !== '\n') markup[k] = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
const html = markup.join('');
|
||||
for (const m of html.matchAll(STYLE_DIRECTIVE)) {
|
||||
writes.push({ name: m[1], file: path, line: lineAt(html, m.index), form: 'style-directive' });
|
||||
}
|
||||
for (const m of html.matchAll(STYLE_ATTRIBUTE)) {
|
||||
for (const d of (m[1] ?? m[2] ?? m[3] ?? '').matchAll(DECLARATION)) {
|
||||
writes.push({ name: d[1], file: path, line: lineAt(html, m.index), form: 'declaration' });
|
||||
}
|
||||
}
|
||||
|
||||
return writes;
|
||||
}
|
||||
|
||||
function sourceFiles(root: string): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (SOURCE_FILE.test(entry.name) && !TEST_FILE.test(entry.name)) out.push(full);
|
||||
}
|
||||
};
|
||||
if (statSync(root).isDirectory()) walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
const sweep = (roots: readonly string[]) =>
|
||||
roots.flatMap((root) => sourceFiles(root).flatMap(collectWrites));
|
||||
|
||||
// ── The classification ──────────────────────────────────────────────────────
|
||||
|
||||
type Verdict = 'private' | 'system' | 'contract-lie' | 'ownerless';
|
||||
|
||||
/**
|
||||
* FOUR verdicts, two green and two red — and the ORDER of these lines is the
|
||||
* law, not an implementation detail.
|
||||
*
|
||||
* THE CONTRACT IS ASKED FIRST, and it was not always: until the scope pass of
|
||||
* 2026-08-27 the `--_` prefix answered first, so the 124 `_`-prefixed keys
|
||||
* `base.ts` declares could be written inline for free. That was a hole, because
|
||||
* a private recipe token is a THEME'S token too — `render-css.ts:1900` emits a
|
||||
* `_x` key as `--_{c}-x` out of the same `EidosConfig.recipes` a theme
|
||||
* supplies, so an inline per-instance write beats it exactly as it beats a
|
||||
* public one. The species is «the theme is entitled to this name and the write
|
||||
* outranks it», and the underscore has nothing to say about that.
|
||||
*
|
||||
* Measured before flipping, on both sides of the wall: of 64 private writes in
|
||||
* soma + arts and 46 in eidos, ZERO name a key the contract declares. The flip
|
||||
* closes the hole and reddens nothing — which is why it is a correction and not
|
||||
* an axis.
|
||||
*/
|
||||
function classify(
|
||||
name: string,
|
||||
contract: ReadonlySet<string>,
|
||||
system: ReadonlySet<string>
|
||||
): Verdict {
|
||||
if (contract.has(name)) return 'contract-lie';
|
||||
if (name.startsWith('--_')) return 'private';
|
||||
if (system.has(name)) return 'system';
|
||||
return 'ownerless';
|
||||
}
|
||||
|
||||
const show = (write: Write) =>
|
||||
`${readable(write.name)} ← ${write.file}:${write.line} (${write.form})`;
|
||||
|
||||
describe('value channels — the guard on the side that WRITES (§16)', () => {
|
||||
const contract = contractNames();
|
||||
const system = systemNames(contract);
|
||||
const writes = sweep(LAW_ROOTS);
|
||||
const verdicts = (want: Verdict) =>
|
||||
writes.filter((write) => classify(write.name, contract, system) === want);
|
||||
|
||||
it('sweeps a non-empty space', () => {
|
||||
// A guard that inspects nothing passes. Assert the instrument found the
|
||||
// tree before believing a word it says about it. Floors, not equalities:
|
||||
// this file must not fail because a component was added.
|
||||
expect(LAW_ROOTS.flatMap(sourceFiles).length).toBeGreaterThan(2400);
|
||||
expect(writes.length).toBeGreaterThan(50);
|
||||
expect(new Set(writes.map((write) => write.name)).size).toBeGreaterThan(40);
|
||||
expect(contract.size).toBeGreaterThan(1000);
|
||||
expect(system.size).toBeGreaterThan(100);
|
||||
// Every form the scanner claims to see must actually be seen somewhere,
|
||||
// or the claim is untested surface.
|
||||
expect(new Set(writes.map((write) => write.form))).toEqual(
|
||||
new Set<WriteForm>([
|
||||
'object-key',
|
||||
'computed-key',
|
||||
'declaration',
|
||||
'property-call',
|
||||
'member-assign',
|
||||
'style-directive'
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('never writes a name the contract declares — a token that LIES', () => {
|
||||
expect(
|
||||
verdicts('contract-lie').map(show),
|
||||
'A token that LIES: the name is declared in `lib/recipes/base.ts`, so a theme is ' +
|
||||
'entitled to move it — and an inline per-instance write beats every normal rule, ' +
|
||||
'so the theme moves NOTHING. Write the value into `--_{c}-*` and let the recipe ' +
|
||||
'COMPOSE the two, the way the drawer veil now does: `var(--drawer-overlay-opacity) ' +
|
||||
'* var(--_drawer-overlay-progress, 1)`.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes no public form without an owner', () => {
|
||||
expect(
|
||||
verdicts('ownerless')
|
||||
.filter((write) => !(write.name in ADJUDICATION_PENDING))
|
||||
.map(show),
|
||||
'A public FORM with no owner: a `--{c}-x` that no contract declares and no theme ' +
|
||||
'can reach, published as API by its spelling alone. Rename it to `--_{c}-*` — ' +
|
||||
'writer and every reader in ONE edit — or declare it in the contract if it ' +
|
||||
'really is theming surface.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the adjudication registry SHRINKING — no stale entry', () => {
|
||||
const written = new Set(writes.map((write) => write.name));
|
||||
expect(
|
||||
Object.keys(ADJUDICATION_PENDING).filter(
|
||||
(name) => !written.has(name) || classify(name, contract, system) !== 'ownerless'
|
||||
),
|
||||
'STALE registry entry: this name is no longer an ownerless write — it was renamed, ' +
|
||||
'contracted, adopted by the system, or deleted. Delete the line in the same ' +
|
||||
'commit that earned it; the registry only ever shrinks.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps every registry entry DATED, reasoned, aimed and held', () => {
|
||||
// The four fields are what separate a registry from a cupboard, so they are
|
||||
// asserted rather than trusted to the type: a required field satisfied by
|
||||
// `''` is a field nobody filled. `heldBecause` is the load-bearing one —
|
||||
// an entry that cannot say what blocks it has nothing blocking it.
|
||||
expect(
|
||||
Object.entries(ADJUDICATION_PENDING)
|
||||
.filter(([, entry]) =>
|
||||
[entry.since, entry.reason, entry.destination, entry.heldBecause].some(
|
||||
(field) => field.trim().length < 4
|
||||
)
|
||||
)
|
||||
.map(([name]) => name),
|
||||
'A registry entry with an empty field. Every one carries WHEN it entered, WHY the ' +
|
||||
"law's antecedent misses it, WHERE it is going, and WHAT stopped it moving that " +
|
||||
'day. Without the fourth, a name parks here for free.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('walks every root it claims to sweep', () => {
|
||||
// A guard that inspects nothing passes, and a root that stops resolving is
|
||||
// exactly that: seven of the nine write nothing today, so no other
|
||||
// assertion in this file would notice one of them going empty. This is the
|
||||
// only check that fails when the SWEEP shrinks rather than the tree.
|
||||
expect(
|
||||
LAW_ROOTS.filter((root) => sourceFiles(root).length === 0),
|
||||
'A swept root resolved to zero source files. Either it moved and this list is ' +
|
||||
'stale, or the walk broke — both make every green above a green over a hole.'
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue