diff --git a/AGENTS.md b/AGENTS.md index fcef7ef3e..ae5f663e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,16 +28,9 @@ npm run format # Auto-format with Prettier ## Critical Architecture -### Dual Alias Configuration Required +### One Alias Table -Path aliases must be synced in BOTH [`svelte.config.js`](svelte.config.js:11) AND [`vite.config.ts`](vite.config.ts:14) for TypeScript, Svelte compiler, and Vitest to resolve consistently: - -- `@/` → `src/` -- `@/ling` → `src/lib/ling` (i18n) -- `@/logr` → `src/lib/logr` (logging) -- `@/glob` → `src/lib/glob` (globalization) -- `@/actx` → `src/lib/actx` (audio context) -- `$uix` → `src/uix` (UI components) +Path aliases live in ONE module, [`uix.aliases.js`](uix.aliases.js), imported by `vite.config.ts`, `svelte.config.js`, the scripts and any app in the workspace. Never copy it into a config; `src/uix/aliases.test.ts` fails if a second copy appears. How an app consumes it: [`docs/consuming.md`](docs/consuming.md). ### Svelte 5 Runes Mode Enforced diff --git a/CLAUDE.md b/CLAUDE.md index 4bbe5a927..85a6b0052 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,9 +80,11 @@ npm run format # Auto-format ## Path Aliases -Aliases are kept in sync between `svelte.config.js` and `vite.config.ts`. Source -of truth is `vite.config.ts` (single `aliases` const reused by both top-level -resolve and the server-test project). +Aliases live in ONE module, `uix.aliases.js` at the repo root: `vite.config.ts`, +`svelte.config.js`, `scripts/generate-boot.ts`, `scripts/docs-check.ts` and any +app in the workspace import it (`docs/consuming.md`). Never copy the table. Order +is load-bearing (longer specifier first); `src/uix/aliases.test.ts` guards order, +resolution and the absence of a second copy. The table below is a summary. | Alias | Target | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | diff --git a/scripts/docs-check.ts b/scripts/docs-check.ts index 74f0228a8..c69262600 100644 --- a/scripts/docs-check.ts +++ b/scripts/docs-check.ts @@ -27,7 +27,7 @@ * 7. Generated vocabularies appendix — docs/canon/vocabularies.md must match * an in-memory regeneration from the code consts. * 8. Code-block imports — `$`/`@/` import heads in fenced ts/js/svelte blocks - * must be real repo aliases (extracted from vite.config.ts, never copied) + * must be real repo aliases (imported from uix.aliases.js, never copied) * and resolve on disk. `$lib` is retired: forbidden in framework-owned * docs (docs/**, src/uix/**); tolerated in consumer-app examples * elsewhere. (Born 2026-08-05: the exhaustive opts audit found phantom @@ -54,6 +54,7 @@ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join, dirname, resolve, relative, sep } from 'node:path'; +import { UIX_ALIASES } from '../uix.aliases.js'; import { generateVocabulariesDoc } from './docs-vocabularies'; @@ -768,27 +769,19 @@ const codeCorpus = liveCorpus.filter((f) => !isAspirational(f)); // ── Invariant 8 — code-block imports resolve ─────────────────────────────── // -// The alias table is EXTRACTED from vite.config.ts (the declared source of -// truth for aliases), never copied — copying it here would recreate the -// disease this script exists to catch. - -const ALIASES = new Map(); -{ - const viteSrc = read(join(REPO, 'vite.config.ts')); - for (const m of viteSrc.matchAll( - /['"]([$@][\w./-]*)['"]\s*:\s*resolve\(__dirname,\s*['"]([^'"]+)['"]\)/g - )) { - ALIASES.set(m[1], m[2]); - } - if (ALIASES.size < 10) { - report( - 'error', - 'I8-imports', - join(REPO, 'vite.config.ts'), - 1, - `alias extraction found only ${ALIASES.size} entries — the aliases const changed shape` - ); - } +// The alias table is IMPORTED from `uix.aliases.js`, the one module Vite, +// SvelteKit and the boot compiler also read — never copied, never parsed out +// of a config file. + +const ALIASES = new Map(Object.entries(UIX_ALIASES)); +if (ALIASES.size < 10) { + report( + 'error', + 'I8-imports', + join(REPO, 'uix.aliases.js'), + 1, + `the alias table has only ${ALIASES.size} entries — uix.aliases.js changed shape` + ); } /** SvelteKit's own virtual modules — legal without being repo aliases. */ @@ -868,7 +861,7 @@ for (const file of codeCorpus) { 'I8-imports', file, line, - `\`${spec}\` — \`${head}\` is not a repo alias (vite.config.ts)` + `\`${spec}\` — \`${head}\` is not a repo alias (uix.aliases.js)` ); } else if (!specResolves(spec)) { report( diff --git a/scripts/generate-boot.ts b/scripts/generate-boot.ts index a6f414196..e21afb346 100644 --- a/scripts/generate-boot.ts +++ b/scripts/generate-boot.ts @@ -50,9 +50,9 @@ * `node:crypto` is scaffolding too — it computes the hash here and never * ships. * - * Aliases are EXTRACTED from `vite.config.ts` rather than copied — the - * same rule `docs-check.ts` invariant 8 follows, for the same reason: a - * copied table drifts and takes the guard's credibility with it. + * Aliases come from `uix.aliases.js`, the one table Vite, SvelteKit, + * `docs-check.ts` and this compiler all import: the boot resolves every + * specifier exactly as the runtime it must agree with does. * * **And it RUNS what it writes.** The text guards are static, and a boot * that throws on line one passes every one of them: measured, this @@ -83,7 +83,7 @@ */ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; @@ -91,28 +91,13 @@ import { runInNewContext } from 'node:vm'; import { build, type Plugin } from 'esbuild'; import { assertBootScript } from '../src/uix/active-uix/boot/script-guard.ts'; +import { resolveUixAliases } from '../uix.aliases.js'; const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const BOOT_ENTRY = resolve(REPO, 'src/uix/active-uix/boot/entry.ts'); export const BOOT_OUTPUT = resolve(REPO, 'src/uix/active-uix/generated/boot.js'); -export function readViteAliases(): Record { - const source = readFileSync(resolve(REPO, 'vite.config.ts'), 'utf8'); - const aliases: Record = {}; - for (const match of source.matchAll( - /['"]([$@][\w./-]*)['"]\s*:\s*resolve\(__dirname,\s*['"]([^'"]+)['"]\)/g - )) { - aliases[match[1]] = resolve(REPO, match[2]); - } - if (Object.keys(aliases).length < 10) { - throw new Error( - `alias extraction found only ${Object.keys(aliases).length} entries — the aliases const in vite.config.ts changed shape` - ); - } - return aliases; -} - export interface BuildBootScriptOptions { /** * Absolute path of the module that exports `bootPrefsSchema` — the @@ -198,7 +183,7 @@ export async function compileBoot( target: 'es2020', minify: true, legalComments: 'none', - alias: readViteAliases(), + alias: resolveUixAliases(REPO), plugins: options.schema === undefined ? [inputCollector(inputs)] diff --git a/src/uix/active-uix/boot/pack.test.ts b/src/uix/active-uix/boot/pack.test.ts index 3002e2410..322c5f754 100644 --- a/src/uix/active-uix/boot/pack.test.ts +++ b/src/uix/active-uix/boot/pack.test.ts @@ -21,7 +21,7 @@ import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { afterAll, describe, expect, it } from 'vitest'; -import { readViteAliases } from '../../../../scripts/generate-boot.ts'; +import { resolveUixAliases } from '../../../../uix.aliases.js'; const RENDER_ENTRY = resolve('src/uix/active-uix/boot/render.ts'); @@ -40,7 +40,7 @@ describe('renderUixBootScript inside a packaged server build', () => { format: 'esm', platform: 'node', outfile, - alias: readViteAliases() + alias: resolveUixAliases(resolve('.')) }); // The premise of the test, stated rather than assumed: the compiled boot diff --git a/src/uix/aliases.test.ts b/src/uix/aliases.test.ts new file mode 100644 index 000000000..1dca3e3b9 --- /dev/null +++ b/src/uix/aliases.test.ts @@ -0,0 +1,68 @@ +/** + * The import map exists ONCE (`uix.aliases.js`) and it resolves. + * + * Until 2026-09-16 the table was written twice — in `vite.config.ts` and, by + * hand, in `svelte.config.js` — and two scripts parsed it back out of the Vite + * config with a regex. Every consumer now imports the module, so the guard has + * four jobs: the table points at things that exist, nobody writes a second + * copy, the order the resolvers depend on holds, and SvelteKit's generated + * tsconfig carries every entry. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { resolveUixAliases, UIX_ALIASES } from '../../uix.aliases.js'; + +const REPO = resolve('.'); +const entries = Object.entries(UIX_ALIASES); + +describe('uix.aliases.js — the one import map', () => { + it('has the whole table, and every entry points at something on disk', () => { + // Anti-empty: a table that lost its entries would make every other assertion vacuous. + expect(entries.length).toBeGreaterThanOrEqual(38); + const missing = entries.filter(([, path]) => !existsSync(resolve(REPO, path))); + expect(missing).toEqual([]); + }); + + it('resolves every entry against the root it is given, in table order', () => { + const resolved = resolveUixAliases(REPO); + expect(Object.keys(resolved)).toEqual(entries.map(([specifier]) => specifier)); + for (const [specifier, path] of entries) { + expect(resolved[specifier]).toBe(resolve(REPO, path)); + } + }); + + it('lists a longer specifier before any key that is its prefix', () => { + // Vite and esbuild take the FIRST string alias that matches `key` or + // `key/…`, so `$svrs` listed before `$svrs/auth` would swallow it. + const keys = entries.map(([specifier]) => specifier); + const shadowed: string[] = []; + keys.forEach((key, index) => { + for (const longer of keys.slice(index + 1)) { + if (longer.startsWith(`${key}/`)) shadowed.push(`${key} before ${longer}`); + } + }); + expect(shadowed).toEqual([]); + }); + + it('is not copied back into either config', () => { + for (const config of ['vite.config.ts', 'svelte.config.js']) { + const source = readFileSync(resolve(REPO, config), 'utf8'); + expect(source, config).toContain('resolveUixAliases(__dirname)'); + expect(source, config).not.toMatch(/resolve\(__dirname,\s*['"]src\//); + } + }); + + it("reaches SvelteKit's generated tsconfig, entry by entry", () => { + const generated = resolve(REPO, '.svelte-kit/tsconfig.json'); + expect(existsSync(generated), 'run `svelte-kit sync` (npm install runs it)').toBe(true); + const { compilerOptions } = JSON.parse(readFileSync(generated, 'utf8')) as { + compilerOptions: { paths: Record }; + }; + const absent = entries + .map(([specifier]) => specifier) + .filter((specifier) => compilerOptions.paths[specifier] === undefined); + expect(absent).toEqual([]); + }); +}); diff --git a/svelte.config.js b/svelte.config.js index e96d0386a..c737f9a75 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,5 +1,6 @@ import adapter from '@sveltejs/adapter-static'; -import { resolve, dirname } from 'path'; +import { dirname } from 'path'; +import { resolveUixAliases } from './uix.aliases.js'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -11,66 +12,14 @@ const config = { files: { routes: 'web/routes' }, - alias: { - // Sincronizado con vite.config.ts para que TypeScript, - // el compilador de Svelte y Vitest resuelvan el mismo alias. - - // ── Runtime artifacts (src/arts) ──────────────────────────────── - '$active-app': resolve(__dirname, 'src/arts/active-app'), - '$adom': resolve(__dirname, 'src/arts/adom'), - '$agent': resolve(__dirname, 'src/arts/agent'), - '$auth': resolve(__dirname, 'src/arts/auth'), - '$bus': resolve(__dirname, 'src/arts/bus'), - '$cache': resolve(__dirname, 'src/arts/cache'), - '$clipboard': resolve(__dirname, 'src/arts/clipboard'), - '$color': resolve(__dirname, 'src/arts/color'), - '$connection': resolve(__dirname, 'src/arts/connection'), - '$ethereal': resolve(__dirname, 'src/arts/ethereal'), - '$format': resolve(__dirname, 'src/arts/format'), - '$http': resolve(__dirname, 'src/arts/http'), - '$langs': resolve(__dirname, 'src/arts/langs'), - '$logger': resolve(__dirname, 'src/arts/logger'), - '$motion': resolve(__dirname, 'src/arts/motion'), - '$orca': resolve(__dirname, 'src/arts/orca'), - '$perf': resolve(__dirname, 'src/arts/perf'), - '$perm': resolve(__dirname, 'src/arts/perm'), - '$prefs': resolve(__dirname, 'src/arts/prefs'), - '$scene': resolve(__dirname, 'src/arts/scene'), - '$session': resolve(__dirname, 'src/arts/session'), - '$sium': resolve(__dirname, 'src/arts/sium'), - '$sound': resolve(__dirname, 'src/arts/sound'), - '$storage': resolve(__dirname, 'src/arts/storage'), - '$timer': resolve(__dirname, 'src/arts/timer'), - - // ── Pure helpers (src/libs) ───────────────────────────────────── - '$libs': resolve(__dirname, 'src/libs'), - '$locale': resolve(__dirname, 'src/libs/locale'), - '$reactive': resolve(__dirname, 'src/libs/reactive'), - - // ── Encapsulated opt-in packs (src/packs) ─────────────────────── - '$packs': resolve(__dirname, 'src/packs'), - - // ── Server-authoritative engines (src/svrs) ───────────────────── - // The GATE of each engine is a native SvelteKit server-only - // module (`*.server.ts`); the alias points at the file so no - // consumer import changes. Longest specifier first. - '$svrs/auth/testing': resolve(__dirname, 'src/svrs/auth/testing.server.ts'), - '$svrs/auth': resolve(__dirname, 'src/svrs/auth/index.server.ts'), - '$svrs': resolve(__dirname, 'src/svrs'), - - // ── UIX layers (src/uix) ──────────────────────────────────────── - '$uix': resolve(__dirname, 'src/uix'), - '$active-uix': resolve(__dirname, 'src/uix/active-uix'), - '$soma': resolve(__dirname, 'src/uix/soma'), - - // ── Blocks tier (src/uix/blocks) — page-function compositions ─── - '$blocks': resolve(__dirname, 'src/uix/blocks'), - - // ── Demo controls (src/lib/_demo) ─────────────────────────────── - '$demo': resolve(__dirname, 'src/lib/_demo'), - - // ── @/ catch-all ──────────────────────────────────────────────── - '@': resolve(__dirname, 'src'), + // ONE table (`uix.aliases.js`), shared with vite.config.ts and the scripts. + alias: resolveUixAliases(__dirname), + typescript: { + // Kit's generated include lists routes, lib and src — not the root module + // every tool now imports. Checked like the rest. + config: (config) => { + config.include.push('../uix.aliases.js'); + } }, }, vitePlugin: { diff --git a/uix.aliases.js b/uix.aliases.js new file mode 100644 index 000000000..0317d4812 --- /dev/null +++ b/uix.aliases.js @@ -0,0 +1,93 @@ +/** + * The framework's import map — ONE table for every tool that resolves a `$…` + * or `@/` specifier: `vite.config.ts` (dev, build, both vitest projects), + * `svelte.config.js` (Kit writes it into `.svelte-kit/tsconfig.json` and hands + * it to Vite), `scripts/generate-boot.ts` (esbuild), `scripts/docs-check.ts` + * (invariant 8), and any app in this workspace that consumes the framework as + * source (`docs/consuming.md`). + * + * It is `.js` with JSDoc, not `.ts`: Node loads `svelte.config.js` with no + * bundler, and that file imports this one. + * + * ORDER IS LOAD-BEARING. Vite and esbuild match a string alias as a prefix + * (`$svrs` also matches `$svrs/auth/testing`) and SvelteKit hands them over in + * insertion order, so a longer specifier must come before any key that is its + * prefix. `src/uix/aliases.test.ts` fails if it does not. + */ +import { resolve } from 'node:path'; + +/** + * Specifier → path relative to the repository root. + * + * @type {Readonly>} + */ +export const UIX_ALIASES = Object.freeze({ + // ── Runtime artifacts (src/arts) ────────────────────────────────────── + '$active-app': 'src/arts/active-app', + $adom: 'src/arts/adom', + $agent: 'src/arts/agent', + $auth: 'src/arts/auth', + $bus: 'src/arts/bus', + $cache: 'src/arts/cache', + $clipboard: 'src/arts/clipboard', + $color: 'src/arts/color', + $connection: 'src/arts/connection', + $ethereal: 'src/arts/ethereal', + $format: 'src/arts/format', + $http: 'src/arts/http', + $langs: 'src/arts/langs', + $logger: 'src/arts/logger', + $motion: 'src/arts/motion', + $orca: 'src/arts/orca', + $perf: 'src/arts/perf', + $perm: 'src/arts/perm', + $prefs: 'src/arts/prefs', + $scene: 'src/arts/scene', + $session: 'src/arts/session', + $sium: 'src/arts/sium', + $sound: 'src/arts/sound', + $storage: 'src/arts/storage', + $timer: 'src/arts/timer', + + // ── Pure helpers (src/libs) ─────────────────────────────────────────── + $libs: 'src/libs', + $locale: 'src/libs/locale', + $reactive: 'src/libs/reactive', + + // ── Encapsulated opt-in packs (src/packs) ───────────────────────────── + $packs: 'src/packs', + + // ── Server-authoritative engines (src/svrs) ─────────────────────────── + // The GATE of each engine is a native SvelteKit server-only module + // (`*.server.ts`), so the alias points at the file and no consumer + // import changes. Longest specifier first: these are prefix matches. + '$svrs/auth/testing': 'src/svrs/auth/testing.server.ts', + '$svrs/auth': 'src/svrs/auth/index.server.ts', + $svrs: 'src/svrs', + + // ── UIX layers (src/uix) ────────────────────────────────────────────── + $uix: 'src/uix', + '$active-uix': 'src/uix/active-uix', + $soma: 'src/uix/soma', + + // ── Blocks tier (src/uix/blocks) — page-function compositions ───────── + $blocks: 'src/uix/blocks', + + // ── Demo controls (src/lib/_demo) — the frozen docs site's widgets ──── + $demo: 'src/lib/_demo', + + // ── @/ catch-all ────────────────────────────────────────────────────── + '@': 'src' +}); + +/** + * The same table with absolute paths, in table order. + * + * @param {string} root Absolute path of the repository root. + * @returns {Record} + */ +export function resolveUixAliases(root) { + return Object.fromEntries( + Object.entries(UIX_ALIASES).map(([specifier, path]) => [specifier, resolve(root, path)]) + ); +} diff --git a/vite.config.ts b/vite.config.ts index 8af024fb3..edff89969 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,70 +3,13 @@ import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; import { sveltekit } from '@sveltejs/kit/vite'; import { fileURLToPath } from 'url'; -import { resolve, dirname } from 'path'; +import { dirname } from 'path'; +import { resolveUixAliases } from './uix.aliases.js'; // __dirname no existe en ESM — se reconstruye así const __dirname = dirname(fileURLToPath(import.meta.url)); -const aliases = { - // ── Runtime artifacts (src/arts) ────────────────────────────────────── - '$active-app': resolve(__dirname, 'src/arts/active-app'), - '$adom': resolve(__dirname, 'src/arts/adom'), - '$agent': resolve(__dirname, 'src/arts/agent'), - '$auth': resolve(__dirname, 'src/arts/auth'), - '$bus': resolve(__dirname, 'src/arts/bus'), - '$cache': resolve(__dirname, 'src/arts/cache'), - '$clipboard': resolve(__dirname, 'src/arts/clipboard'), - '$color': resolve(__dirname, 'src/arts/color'), - '$connection': resolve(__dirname, 'src/arts/connection'), - '$ethereal': resolve(__dirname, 'src/arts/ethereal'), - '$format': resolve(__dirname, 'src/arts/format'), - '$http': resolve(__dirname, 'src/arts/http'), - '$langs': resolve(__dirname, 'src/arts/langs'), - '$logger': resolve(__dirname, 'src/arts/logger'), - '$motion': resolve(__dirname, 'src/arts/motion'), - '$orca': resolve(__dirname, 'src/arts/orca'), - '$perf': resolve(__dirname, 'src/arts/perf'), - '$perm': resolve(__dirname, 'src/arts/perm'), - '$prefs': resolve(__dirname, 'src/arts/prefs'), - '$scene': resolve(__dirname, 'src/arts/scene'), - '$session': resolve(__dirname, 'src/arts/session'), - '$sium': resolve(__dirname, 'src/arts/sium'), - '$sound': resolve(__dirname, 'src/arts/sound'), - '$storage': resolve(__dirname, 'src/arts/storage'), - '$timer': resolve(__dirname, 'src/arts/timer'), - - // ── Pure helpers (src/libs) ─────────────────────────────────────────── - '$libs': resolve(__dirname, 'src/libs'), - '$locale': resolve(__dirname, 'src/libs/locale'), - '$reactive': resolve(__dirname, 'src/libs/reactive'), - - // ── Encapsulated opt-in packs (src/packs) ───────────────────────────── - '$packs': resolve(__dirname, 'src/packs'), - - // ── Server-authoritative engines (src/svrs) ─────────────────────────── - // The GATE of each engine is a native SvelteKit server-only module - // (`*.server.ts`), so the alias points at the file and no consumer - // import changes. Longest specifier first: these are prefix matches. - '$svrs/auth/testing': resolve(__dirname, 'src/svrs/auth/testing.server.ts'), - '$svrs/auth': resolve(__dirname, 'src/svrs/auth/index.server.ts'), - '$svrs': resolve(__dirname, 'src/svrs'), - - // ── UIX layers (src/uix) ────────────────────────────────────────────── - '$uix': resolve(__dirname, 'src/uix'), - '$active-uix': resolve(__dirname, 'src/uix/active-uix'), - '$soma': resolve(__dirname, 'src/uix/soma'), - - // ── Blocks tier (src/uix/blocks) — page-function compositions ───────── - '$blocks': resolve(__dirname, 'src/uix/blocks'), - - // ── Demo controls (src/lib/_demo) — reusable widgets for the per-component - // demo pages so booleans, enums, text and ranges look the same on every page. - '$demo': resolve(__dirname, 'src/lib/_demo'), - - // ── @/ catch-all ────────────────────────────────────────────────────── - '@': resolve(__dirname, 'src'), -}; +const aliases = resolveUixAliases(__dirname); export default defineConfig({ plugins: [tailwindcss(), sveltekit()],