diff --git a/README.md b/README.md index dd95c393..0945aa2a 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,12 @@ npm install @animus-ui/next-plugin # Next.js Two files define your design system: -**`theme.ts`** — define your tokens: +**`theme.ts`** — define your theme: ```tsx import { createTheme } from '@animus-ui/system'; -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ sm: 480, md: 768, lg: 1024 }) .addColors({ gray: { 50: '#fafafa', 500: '#555', 900: '#080808' }, @@ -75,12 +75,17 @@ export const tokens = createTheme() text: 'gray.900', }, }) - .addScale({ name: 'space', values: { sm: '0.5rem', md: '1rem', lg: '1.5rem' } }) + .addScale({ + name: 'space', + values: { sm: '0.5rem', md: '1rem', lg: '1.5rem' }, + }) .build(); // Type augmentation — token names autocomplete everywhere +type AppTheme = typeof theme; + declare module '@animus-ui/system' { - interface Theme extends typeof tokens {} + interface Theme extends AppTheme {} } ``` @@ -108,6 +113,18 @@ export const { system: ds, createGlobalStyles } = createSystem() .build(); ``` +Consuming a published design-system kit? `.extend()` (available on both +builders, first in the chain) merges the kit's registries and tokens into +yours — its props type-check, extract, and resolve through your single merged +config, and your local definitions win on conflict: + +```tsx +import { system as kitSystem, theme as kitTheme } from '@acme/kit'; + +export const theme = createTheme().extend(kitTheme).build(); +export const { system: ds } = createSystem().extend(kitSystem).build(); +``` + **`vite.config.ts`**: ```tsx diff --git a/e2e/next-app/src/ds.ts b/e2e/next-app/src/ds.ts index 38a7f448..0d01213b 100644 --- a/e2e/next-app/src/ds.ts +++ b/e2e/next-app/src/ds.ts @@ -9,7 +9,7 @@ import { space, typography, } from '@animus-ui/system/groups'; -import { ds as testDs } from '@animus-ui/test-ds'; +import { system as testDs } from '@animus-ui/test-ds/definition'; // ─── Transforms ───────────────────────────────────────────── @@ -21,6 +21,10 @@ export const size = createTransform('size', (value) => { // ─── Tokens ───────────────────────────────────────────────── +// DELIBERATE legacy lane (openspec: first-class-extension, Migration Plan +// step 2 / G6): this fixture keeps the deprecated `tokens` export name — the +// loader accepts it as the fallback spelling while `theme` is the documented +// name. Do not rename during the deprecation window. export const tokens = createTheme() .addBreakpoints({ sm: 640, md: 768, lg: 1024, xl: 1280 }) .addColors({ @@ -189,6 +193,15 @@ declare module '@animus-ui/system' { // ─── System ───────────────────────────────────────────────── +// DELIBERATE legacy lane (openspec: first-class-extension, Migration Plan +// step 2 / G6): this fixture is the `createSystem({ includes: [...] })` +// deprecation-window witness — the alias keeps its frozen semantics (type +// admission + discovery anchor, NO runtime registry merge), so every group +// the components need is still registered locally. react-router-app covers +// the `from()` chain; vite-app/next16-app/vinext-app use `.extend()` +// (showcase remains on `includes:` pending its deferred migration — +// registry row 13; see its ds.ts). Do not migrate this lane until removal +// is specced. export const { system: ds, createGlobalStyles, diff --git a/e2e/next16-app/src/ds.ts b/e2e/next16-app/src/ds.ts index 9640ecc6..21499b4e 100644 --- a/e2e/next16-app/src/ds.ts +++ b/e2e/next16-app/src/ds.ts @@ -1,15 +1,6 @@ import { createSystem, createTheme, createTransform } from '@animus-ui/system'; -import { - border, - color, - flex, - layout, - positioning, - shadows, - space, - typography, -} from '@animus-ui/system/groups'; -import { ds as testDs } from '@animus-ui/test-ds'; +import { shadows } from '@animus-ui/system/groups'; +import { system as testDs } from '@animus-ui/test-ds/definition'; // ─── Transforms ───────────────────────────────────────────── @@ -21,7 +12,7 @@ export const size = createTransform('size', (value) => { // ─── Tokens ───────────────────────────────────────────────── -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ sm: 640, md: 768, lg: 1024, xl: 1280 }) .addColors({ gray: { @@ -164,7 +155,7 @@ export const tokens = createTheme() }) .build(); -export type TestTheme = typeof tokens; +export type TestTheme = typeof theme; declare module '@animus-ui/system' { interface Theme extends TestTheme {} @@ -176,15 +167,16 @@ export const { system: ds, createGlobalStyles, createKeyframes, -} = createSystem({ - includes: [testDs], -}) - .addGroup('space', space) - .addGroup('layout', { ...layout, ...flex }) - .addGroup('text', typography) - .addGroup('surface', { ...color, ...border, ...shadows }) - .addGroup('positioning', positioning) - .build(); + // extend()-form lane (openspec: first-class-extension, D1): test-ds's + // registries MERGE into this system — the kit alone provides the space/ + // layout/text/surface/positioning groups the components use. The only + // LOCAL registration is the additive, transform-free `shadows` prop set + // (boxShadow/shadow/textShadow — the kit does not register them, and the + // Card/Button styles resolve their `shadows`-scale values through the + // registry). Re-spreading kit groups would coalesce under D12 transform + // equality (name + captured source); this lane stays pure-extend + additive + // as the recommended consumption shape. +} = createSystem().extend(testDs).addProps(shadows).build(); // ─── Keyframes ────────────────────────────────────────────── diff --git a/e2e/packed-app/src/ds.ts b/e2e/packed-app/src/ds.ts index d5a9d15b..07670159 100644 --- a/e2e/packed-app/src/ds.ts +++ b/e2e/packed-app/src/ds.ts @@ -9,7 +9,7 @@ import { typography, } from '@animus-ui/system/groups'; -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ sm: 640, md: 768, lg: 1024 }) .addColors({ blue: { 100: '#dbeafe', 500: '#3b82f6', 700: '#1d4ed8' }, @@ -60,7 +60,7 @@ export const tokens = createTheme() }) .build(); -export type PackedAppTheme = typeof tokens; +export type PackedAppTheme = typeof theme; declare module '@animus-ui/system' { interface Theme extends PackedAppTheme {} diff --git a/e2e/react-router-app/src/ds.ts b/e2e/react-router-app/src/ds.ts index d533bd3f..476639ed 100644 --- a/e2e/react-router-app/src/ds.ts +++ b/e2e/react-router-app/src/ds.ts @@ -7,17 +7,24 @@ import { space, typography, } from '@animus-ui/system/groups'; -import { ds as testDs } from '@animus-ui/test-ds'; +import { system as testDs } from '@animus-ui/test-ds/definition'; +// DELIBERATE legacy lane (openspec: first-class-extension, Migration Plan +// step 2 / G6): this fixture keeps the deprecated `tokens` export name — it +// witnesses the loader's accepted fallback (rust-system-loader › "tokens +// fallback accepted"). Do not rename to `theme` while the deprecation window +// is open; every non-legacy fixture already uses `theme`. export const tokens = createTheme() .addColors({ blue: { 100: '#dbeafe', 500: '#3b82f6', 700: '#1d4ed8' }, gray: { 100: '#f5f5f5', 500: '#737373', 800: '#262626', 950: '#0a0a0a' }, + green: { 500: '#22c55e', 700: '#15803d' }, red: { 500: '#ef4444', 700: '#b91c1c' }, }) .addColorModes('dark', { dark: { primary: { _: 'blue.500', hover: 'blue.700' }, + secondary: 'green.500', danger: 'red.500', background: 'gray.950', surface: 'gray.800', @@ -26,6 +33,7 @@ export const tokens = createTheme() }, light: { primary: { _: 'blue.700', hover: 'blue.500' }, + secondary: 'green.700', danger: 'red.700', background: 'gray.100', surface: 'gray.100', @@ -39,6 +47,7 @@ export const tokens = createTheme() 0: '0', 4: '0.25rem', 8: '0.5rem', + 12: '0.75rem', 16: '1rem', 24: '1.5rem', 32: '2rem', @@ -46,7 +55,13 @@ export const tokens = createTheme() }) .addScale({ name: 'fontSizes', - values: { 14: '0.875rem', 16: '1rem', 24: '1.5rem', 32: '2rem' }, + values: { + 12: '0.75rem', + 14: '0.875rem', + 16: '1rem', + 24: '1.5rem', + 32: '2rem', + }, }) .build(); @@ -56,9 +71,16 @@ declare module '@animus-ui/system' { interface Theme extends ReactRouterTheme {} } -export const { system: ds, createGlobalStyles } = createSystem({ - includes: [testDs], -}) +// DELIBERATE legacy lane (openspec: first-class-extension, Migration Plan +// step 2 / G6): this fixture is the `from()` deprecation-window witness — the +// chain keeps from()'s frozen semantics (type admission + discovery anchor, +// NO runtime registry merge), so every group the components need is still +// registered locally. next-app covers the `includes:` constructor alias; +// vite-app/next16-app/vinext-app use `.extend()` (showcase remains on +// `includes:` pending its deferred migration — registry row 13; see its +// ds.ts). Do not migrate this lane until removal is specced. +export const { system: ds, createGlobalStyles } = createSystem() + .from(testDs) .addGroup('space', space) .addGroup('layout', { ...layout, ...flex }) .addGroup('text', typography) diff --git a/e2e/vinext-app/src/ds.ts b/e2e/vinext-app/src/ds.ts index cd3e08e4..cce1519d 100644 --- a/e2e/vinext-app/src/ds.ts +++ b/e2e/vinext-app/src/ds.ts @@ -1,23 +1,17 @@ import { createSystem, createTheme } from '@animus-ui/system'; -import { - border, - color, - flex, - layout, - space, - typography, -} from '@animus-ui/system/groups'; -import { ds as testDs } from '@animus-ui/test-ds'; +import { system as testDs } from '@animus-ui/test-ds/definition'; -export const tokens = createTheme() +export const theme = createTheme() .addColors({ blue: { 100: '#dbeafe', 500: '#3b82f6', 700: '#1d4ed8' }, gray: { 100: '#f5f5f5', 500: '#737373', 800: '#262626', 950: '#0a0a0a' }, + green: { 500: '#22c55e', 700: '#15803d' }, red: { 500: '#ef4444', 700: '#b91c1c' }, }) .addColorModes('dark', { dark: { primary: { _: 'blue.500', hover: 'blue.700' }, + secondary: 'green.500', danger: 'red.500', background: 'gray.950', surface: 'gray.800', @@ -26,6 +20,7 @@ export const tokens = createTheme() }, light: { primary: { _: 'blue.700', hover: 'blue.500' }, + secondary: 'green.700', danger: 'red.700', background: 'gray.100', surface: 'gray.100', @@ -39,6 +34,7 @@ export const tokens = createTheme() 0: '0', 4: '0.25rem', 8: '0.5rem', + 12: '0.75rem', 16: '1rem', 24: '1.5rem', 32: '2rem', @@ -46,23 +42,30 @@ export const tokens = createTheme() }) .addScale({ name: 'fontSizes', - values: { 14: '0.875rem', 16: '1rem', 24: '1.5rem', 32: '2rem' }, + values: { + 12: '0.75rem', + 14: '0.875rem', + 16: '1rem', + 24: '1.5rem', + 32: '2rem', + }, }) .build(); -export type VinextTheme = typeof tokens; +export type VinextTheme = typeof theme; declare module '@animus-ui/system' { interface Theme extends VinextTheme {} } -export const { system: ds, createGlobalStyles } = createSystem({ - includes: [testDs], -}) - .addGroup('space', space) - .addGroup('layout', { ...layout, ...flex }) - .addGroup('text', typography) - .addGroup('surface', { ...color, ...border }) +// extend()-form lane (openspec: first-class-extension, D1): test-ds's +// registries MERGE into this system — every group the components use +// (space, layout, plus the kit's text/surface/positioning) arrives through +// `.extend(testDs)` alone. Nothing is re-registered locally — re-spreading +// kit groups would coalesce under D12 transform equality (name + captured +// source), but pure extension is the recommended consumption shape. +export const { system: ds, createGlobalStyles } = createSystem() + .extend(testDs) .build(); export const globalStyles = createGlobalStyles({ diff --git a/e2e/vite-app/scripts/assert-build.ts b/e2e/vite-app/scripts/assert-build.ts index 36e735dd..7bbda969 100644 --- a/e2e/vite-app/scripts/assert-build.ts +++ b/e2e/vite-app/scripts/assert-build.ts @@ -23,7 +23,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { tokens } from '../src/ds'; +import { theme } from '../src/ds'; const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const DIST = resolve(APP_ROOT, 'dist'); @@ -173,6 +173,32 @@ async function main(): Promise { assertClassNameFormat(css, { prefix: 'animus-' }); assertGlobalBaseline(css); + // asset() delivery witness (standardize-inheritance-and-assets): the + // package-owned test font declared via + // `asset('@animus-ui/test-ds/assets/test-font.woff2')` in src/ds.ts must + // arrive as the bundler-resolved (hashed, base-prefixed) URL inside the + // @font-face block, with the emitted file present in dist. (Placeholder + // survival is covered by assertNoPlaceholders above, for every lane.) + const fontFaceBlock = css.match(/@font-face[^}]*AnimusTestFont[^}]*\}/)?.[0]; + if (!fontFaceBlock) { + throw new AssertionError( + 'asset() witness: expected the AnimusTestFont @font-face block in the dist CSS' + ); + } + const fontUrl = fontFaceBlock.match(/url\((['"]?)([^'")]+)\1\)/)?.[2]; + if (!fontUrl || !/test-font[^'")]*\.woff2$/.test(fontUrl)) { + throw new AssertionError( + `asset() witness: expected a bundler-resolved test-font woff2 URL in the @font-face block, got ${fontUrl ?? ''}`, + { fontFaceBlock } + ); + } + await readFile(resolve(DIST, fontUrl.replace(/^\//, ''))).catch(() => { + throw new AssertionError( + `asset() witness: the @font-face URL ${fontUrl} does not correspond to an emitted file in dist`, + { fontUrl } + ); + }); + // Guardrail G2 (modern-css-surface): every @container / @supports / // non-breakpoint @media condition at-rule must nest inside a named @layer // block. Runs NON-VACUOUSLY here — the test-ds Card (raw @container/@media/ @@ -202,6 +228,26 @@ async function main(): Promise { ); } + // Merged-config extraction witness (openspec: first-class-extension, NS-1; + // rust-system-loader › "Merged configuration is the extraction authority"): + // App.tsx uses `top={12}` and `zIndex={10}` on Box, and the `positioning` + // group that registers both props comes ONLY from `.extend(testDs)` — + // src/ds.ts deliberately does not re-register it. These declarations can + // reach the dist CSS only through the MERGED configuration, and `top:12px` + // additionally pins the kit's `size` transform surviving the registry + // snapshot merge (no serialized round-trip, design D7). + for (const probe of [ + ['top:12px', 'top: 12px'], + ['z-index:10', 'z-index: 10'], + ] as const) { + if (!css.includes(probe[0]) && !css.includes(probe[1])) { + throw new AssertionError( + `merged-config witness: expected \`${probe[0]}\` (kit-registered positioning prop through .extend()) in the dist CSS`, + { probe: probe[0] } + ); + } + } + // Built-in condition composite witness (inc 06): the app Card authors // `_osDark` WITHOUT registering it — it must resolve through the DEFAULT // built-in set across the full registry → manifest → plugin → engine wire. @@ -252,7 +298,7 @@ async function main(): Promise { // would authorize exactly this script. Ordering is the actual no-flash // contract — the script must precede the plugin's own `@layer` style tag AND // the stylesheet link. - const artifact = createAppearanceBootstrap(tokens); + const artifact = createAppearanceBootstrap(theme); const indexHtml = await readFile(resolve(DIST, 'index.html'), 'utf8'); assertHeadInjectionContract(indexHtml, { code: artifact.code, @@ -270,8 +316,10 @@ async function main(): Promise { }); const jsFiles = await findJsFiles(DIST); + const jsSources: string[] = []; for (const jsFile of jsFiles) { const js = await readFile(jsFile, 'utf8'); + jsSources.push(js); assertNoEmotionImports(js); // Bootstrap entry-point isolation: the generator lives behind the @@ -292,6 +340,29 @@ async function main(): Promise { } } + // Root-import transform witness (extraction-dx remediation): App.tsx + // imports the test-ds Card at the PACKAGE ROOT (`from '@animus-ui/test-ds'`) + // while ds.ts declares the kit at a subpath — the root import must ride the + // same src redirect. An untransformed dist chain still renders (with + // className "") while its CSS sits unreferenced in the sheet, so CSS + // presence alone stays green: every emitted Card class must also be + // REFERENCED from a JS bundle, and both Cards (app + test-ds) must emit. + const cardClasses = new Set(css.match(/animus-Card-[0-9a-f]+/g) ?? []); + if (cardClasses.size < 2) { + throw new AssertionError( + `root-import witness: expected the app Card AND the test-ds Card to emit classes, found: ${[...cardClasses].join(', ') || ''}`, + { cardClasses: [...cardClasses] } + ); + } + for (const cardClass of cardClasses) { + if (!jsSources.some((source) => source.includes(cardClass))) { + throw new AssertionError( + `root-import witness: class ${cardClass} is emitted in CSS but referenced by no JS bundle — a Card import bypassed the transform (package-root src redirect)`, + { cardClass } + ); + } + } + console.log( `[vite-app:assert] ${cssFiles.length} CSS file(s), ${jsFiles.length} JS file(s) validated — all assertions passed` ); diff --git a/e2e/vite-app/src/App.tsx b/e2e/vite-app/src/App.tsx index cb2c9f6b..75551f27 100644 --- a/e2e/vite-app/src/App.tsx +++ b/e2e/vite-app/src/App.tsx @@ -55,7 +55,17 @@ export function App() { - + {/* + Merged-config extraction witness (openspec: first-class-extension, + NS-1 / rust-system-loader › "Merged configuration is the extraction + authority"): `top` and `zIndex` belong to the `positioning` group, + which ONLY test-ds registers — src/ds.ts deliberately does not + re-register it, so these usages emit CSS solely because + `.extend(testDs)` merges the kit's registries into the extracting + config. `top` additionally proves the kit's `size` transform survives + the snapshot merge (12 → 12px). Pinned by scripts/assert-build.ts. + */} + Cross-package test-ds Card diff --git a/e2e/vite-app/src/ds.ts b/e2e/vite-app/src/ds.ts index 22de5811..3184d317 100644 --- a/e2e/vite-app/src/ds.ts +++ b/e2e/vite-app/src/ds.ts @@ -1,16 +1,7 @@ -import { createSystem, createTheme } from '@animus-ui/system'; -import { - border, - color, - flex, - layout, - positioning, - space, - typography, -} from '@animus-ui/system/groups'; -import { ds as testDs } from '@animus-ui/test-ds'; +import { asset, createSystem, createTheme } from '@animus-ui/system'; +import { system as testDs } from '@animus-ui/test-ds/definition'; -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ sm: 640, md: 768, lg: 1024 }) .addColors({ blue: { 100: '#dbeafe', 500: '#3b82f6', 700: '#1d4ed8' }, @@ -66,6 +57,7 @@ export const tokens = createTheme() 0: '0', 4: '0.25rem', 8: '0.5rem', + 12: '0.75rem', 16: '1rem', 24: '1.5rem', 32: '2rem', @@ -83,7 +75,7 @@ export const tokens = createTheme() }) .build(); -export type ViteAppTheme = typeof tokens; +export type ViteAppTheme = typeof theme; declare module '@animus-ui/system' { interface Theme extends ViteAppTheme {} @@ -93,32 +85,62 @@ export const { system: ds, createGlobalStyles, createKeyframes, -} = createSystem({ - includes: [testDs], -}) - .addGroup('space', space) - .addGroup('layout', { ...layout, ...flex }) - .addGroup('text', typography) - .addGroup('surface', { ...color, ...border }) - .addGroup('positioning', positioning) - // Condition alias registry (modern-css-surface inc 03). Registered here so - // aliased condition blocks (e.g. `_motionReduce`) resolve during THIS app's - // extraction — proving the manifest `conditionAliases` field flows through - // the full plugin glue (loadSystemConfig → analyze args → engine adapter). + // extend()-form witness (openspec: first-class-extension, D1/NS-1): this + // lane consumes test-ds through the single extension verb — a REAL registry + // merge. EVERY group here (space, layout, text, surface, positioning) and + // the kit's condition aliases arrive through `.extend(testDs)` alone; the + // app deliberately re-registers nothing, so the merged config IS the kit's + // registry surface plus the local `_motionReduce` re-assertion below. + // (Re-spreading kit groups locally would coalesce under D12 transform + // equality — name + captured source — but pure extension is the + // recommended consumption shape: the merge already provides them.) + // + // Box.tsx opts into the kit's `positioning` group and App.tsx uses + // `top`/`zIndex`, making the emitted CSS the end-to-end witness that a + // kit-registered prop flows through the MERGED config into extraction + // output (rust-system-loader › "Merged configuration is the extraction + // authority"). The legacy lanes stay deliberate elsewhere: next-app keeps + // the deprecated `includes:` alias, react-router-app keeps the deprecated + // `from()` chain (G6). +} = createSystem() + .extend(testDs) + // Condition alias registry (modern-css-surface inc 03). The kit already + // carries `_motionReduce`; this local registration re-asserts it with an + // identical value (post-extend app calls override silently, NS-4) so the + // manifest `conditionAliases` plugin glue keeps a local witness here. .addConditions({ _motionReduce: '@media (prefers-reduced-motion: reduce)', }) .build(); -export const globalStyles = createGlobalStyles({ - '*, *::before, *::after': { boxSizing: 'border-box' }, - body: { - m: 0, - bg: 'background', - color: 'text', - fontFamily: 'system-ui, sans-serif', +export const globalStyles = createGlobalStyles( + { + '*, *::before, *::after': { boxSizing: 'border-box' }, + body: { + m: 0, + bg: 'background', + color: 'text', + fontFamily: 'system-ui, sans-serif', + }, }, -}); + { + // asset() witness (standardize-inheritance-and-assets): a package-owned + // font resolves through the host bundler — the assert lane pins the + // hashed URL in the delivered CSS and the emitted file in dist/. + fontFaces: [ + { + family: 'AnimusTestFont', + src: [ + { + url: asset('@animus-ui/test-ds/assets/test-font.woff2'), + format: 'woff2', + }, + ], + display: 'swap', + }, + ], + } +); export const animations = createKeyframes({ fadeIn: { diff --git a/e2e/vite-app/vite.config.ts b/e2e/vite-app/vite.config.ts index 862b0778..823e9a40 100644 --- a/e2e/vite-app/vite.config.ts +++ b/e2e/vite-app/vite.config.ts @@ -4,7 +4,7 @@ import { cloudflare } from '@cloudflare/vite-plugin'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; -import { tokens } from './src/ds'; +import { theme } from './src/ds'; // Config-time only (openspec: system-color-scheme, D6 — the Vite path is // plugin-injected opt-in). The generator reads the built theme's declared mode @@ -16,7 +16,7 @@ import { tokens } from './src/ds'; // build tooling, and its storage-access code must never reach a client bundle // (spec: "Bootstrap entry-point isolation"). `scripts/assert-build.ts` pins // that as a build-output fact. -const appearanceBootstrap = createAppearanceBootstrap(tokens); +const appearanceBootstrap = createAppearanceBootstrap(theme); export default defineConfig({ plugins: [ diff --git a/packages/_assertions/src/assert-css.ts b/packages/_assertions/src/assert-css.ts index 8020b602..d21c7936 100644 --- a/packages/_assertions/src/assert-css.ts +++ b/packages/_assertions/src/assert-css.ts @@ -68,15 +68,22 @@ export function assertLayerOrder(css: string, config?: LayerOrderConfig): void { } } +// Pipeline-internal markers that must never survive into delivered CSS: +// unresolved transform slots, and asset() placeholders the host plugin +// failed to substitute (standardize-inheritance-and-assets). +const PLACEHOLDER_MARKERS = ['__TRANSFORM__', 'animus-asset:'] as const; + export function assertNoPlaceholders(css: string): void { - const idx = css.indexOf('__TRANSFORM__'); - if (idx !== -1) { - const start = Math.max(0, idx - 60); - const end = Math.min(css.length, idx + 60); - throw new AssertionError( - `assertNoPlaceholders: found __TRANSFORM__ at offset ${idx}`, - { context: css.slice(start, end) } - ); + for (const marker of PLACEHOLDER_MARKERS) { + const idx = css.indexOf(marker); + if (idx !== -1) { + const start = Math.max(0, idx - 60); + const end = Math.min(css.length, idx + 60); + throw new AssertionError( + `assertNoPlaceholders: found ${marker} at offset ${idx}`, + { context: css.slice(start, end) } + ); + } } } diff --git a/packages/_parity/baseline-intents.md b/packages/_parity/baseline-intents.md index 86c7c2df..763daed4 100644 --- a/packages/_parity/baseline-intents.md +++ b/packages/_parity/baseline-intents.md @@ -76,3 +76,24 @@ committed production/development pair. Ordinary parity runs never write it. longhands resolve semantic tokens at top level and in responsive slots; a `borderTopColor` literal passes through). New units only — every pre-existing unit stays byte-identical in the same run. +- [x] `member-target-extraction-20260804` — refresh once after + `inline-asserted-targets.tsx` gained the static-member arm: + `asComponent(Compound.Item as unknown as typeof Compound.Item)` now + EXTRACTS (chain_walk resolves dotted static-member paths, peeling + assertions at every hop) instead of bailing — the 0.1.3 reproduction + probe 4 gap. Only this unit drifts; every other unit stays + byte-identical in the same run. CAVEAT (recorded by the follow-up + refresh below): this refresh also baselined a dev/prod asymmetry it + did not flag — production reconciliation pruned the wrapped `Item` + while development kept it. +- [x] `as-component-target-keep-20260804` — refresh once after review fixed + the dev/prod asymmetry the previous intent baselined: reconciliation + now keeps `asComponent()` wrap targets (the emitted wrapper calls + `createComponent(, …)`, merging the target's class onto the + element, so the target's CSS is runtime-required whenever the wrapper + renders even though the target never appears as a JSX tag). In + `inline-asserted-targets.tsx` the production oracle gains the + `animus-Item-*` padding rule (matching what development always kept) + and the reconciliation report stops counting `Item` as eliminated. + Only this unit drifts; every other unit stays byte-identical in the + same run. diff --git a/packages/_parity/baselines/v2/development.json b/packages/_parity/baselines/v2/development.json index 3c786118..ba8c98a3 100644 --- a/packages/_parity/baselines/v2/development.json +++ b/packages/_parity/baselines/v2/development.json @@ -1,8 +1,8 @@ { - "corpusSha256": "017faab6c9e37c032c38dbdec1a86e3df3bdf1726fdbc53fc287f20246da817a", + "corpusSha256": "c7ec02289baf247a8792f10c10b54af7692b96cd8274b23aacc1efbfd8922083", "engine": "v2", "mode": "development", - "refreshIntent": "ani-closeout-fixture-batch-20260803", + "refreshIntent": "as-component-target-keep-20260804", "surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108", "units": { "extract-all": { @@ -1245,9 +1245,9 @@ }, "parity/inline-asserted-targets.tsx": { "code": { - "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" + "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nconst Item = createComponent('i', 'animus-Item-2da98471', {});\nexport const Compound = { Item };\n\nexport const MemberWrapped = createComponent(Compound.Item, 'animus-MemberWrapped-b520c131', {});\n\nexport const App = () => (\n \n \n \n \n);\n\n" }, - "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n .animus-Item-2da98471 {\n padding: 4px;\n }\n .animus-MemberWrapped-b520c131 {\n display: inline-grid;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", "diagnostics": [], "hasComponents": { "inline-asserted-targets.tsx": true @@ -1255,12 +1255,14 @@ "observables": { "componentFragmentKeys": [ "inline-asserted-targets.tsx::AssertedBox", - "inline-asserted-targets.tsx::AssertedLink" + "inline-asserted-targets.tsx::AssertedLink", + "inline-asserted-targets.tsx::Item", + "inline-asserted-targets.tsx::MemberWrapped" ], - "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"}}", + "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"},\"inline-asserted-targets.tsx::Item\":{\"base\":\" .animus-Item-2da98471 {\\n padding: 4px;\\n }\\n\"},\"inline-asserted-targets.tsx::MemberWrapped\":{\"base\":\" .animus-MemberWrapped-b520c131 {\\n display: inline-grid;\\n }\\n\"}}", "dynamicPropsJson": "{}", "reverseProvenanceEdges": [], - "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n .animus-Item-2da98471 {\\n padding: 4px;\\n }\\n .animus-MemberWrapped-b520c131 {\\n display: inline-grid;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", "systemPropMapJson": "{}" }, "parseCount": 1 diff --git a/packages/_parity/baselines/v2/production.json b/packages/_parity/baselines/v2/production.json index 215c21fe..e57e98d0 100644 --- a/packages/_parity/baselines/v2/production.json +++ b/packages/_parity/baselines/v2/production.json @@ -1,8 +1,8 @@ { - "corpusSha256": "017faab6c9e37c032c38dbdec1a86e3df3bdf1726fdbc53fc287f20246da817a", + "corpusSha256": "c7ec02289baf247a8792f10c10b54af7692b96cd8274b23aacc1efbfd8922083", "engine": "v2", "mode": "production", - "refreshIntent": "ani-closeout-fixture-batch-20260803", + "refreshIntent": "as-component-target-keep-20260804", "surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108", "units": { "extract-all": { @@ -1207,9 +1207,9 @@ }, "parity/inline-asserted-targets.tsx": { "code": { - "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" + "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nconst Item = createComponent('i', 'animus-Item-2da98471', {});\nexport const Compound = { Item };\n\nexport const MemberWrapped = createComponent(Compound.Item, 'animus-MemberWrapped-b520c131', {});\n\nexport const App = () => (\n \n \n \n \n);\n\n" }, - "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n .animus-Item-2da98471 {\n padding: 4px;\n }\n .animus-MemberWrapped-b520c131 {\n display: inline-grid;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", "diagnostics": [], "hasComponents": { "inline-asserted-targets.tsx": true @@ -1217,12 +1217,14 @@ "observables": { "componentFragmentKeys": [ "inline-asserted-targets.tsx::AssertedBox", - "inline-asserted-targets.tsx::AssertedLink" + "inline-asserted-targets.tsx::AssertedLink", + "inline-asserted-targets.tsx::Item", + "inline-asserted-targets.tsx::MemberWrapped" ], - "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"}}", + "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"},\"inline-asserted-targets.tsx::Item\":{\"base\":\" .animus-Item-2da98471 {\\n padding: 4px;\\n }\\n\"},\"inline-asserted-targets.tsx::MemberWrapped\":{\"base\":\" .animus-MemberWrapped-b520c131 {\\n display: inline-grid;\\n }\\n\"}}", "dynamicPropsJson": "{}", "reverseProvenanceEdges": [], - "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n .animus-Item-2da98471 {\\n padding: 4px;\\n }\\n .animus-MemberWrapped-b520c131 {\\n display: inline-grid;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", "systemPropMapJson": "{}" }, "parseCount": 1 diff --git a/packages/_parity/corpus/inline-asserted-targets.tsx b/packages/_parity/corpus/inline-asserted-targets.tsx index b27fec89..9844fdde 100644 --- a/packages/_parity/corpus/inline-asserted-targets.tsx +++ b/packages/_parity/corpus/inline-asserted-targets.tsx @@ -13,8 +13,16 @@ export const AssertedLink = ds .styles({ fontWeight: 600 }) .asComponent(Plain as typeof Plain); +const Item = ds.styles({ padding: '4px' }).asElement('i'); +export const Compound = { Item }; + +export const MemberWrapped = ds + .styles({ display: 'inline-grid' }) + .asComponent(Compound.Item as unknown as typeof Compound.Item); + export const App = () => ( + ); diff --git a/packages/_parity/last-failure.txt b/packages/_parity/last-failure.txt index 12154553..fc9f295a 100644 --- a/packages/_parity/last-failure.txt +++ b/packages/_parity/last-failure.txt @@ -1,25 +1,7 @@ parity baseline — engines: baseline:v2 vs v2 — devMode: false -Units passed: 58/62 (93.55%) -Divergences: 16 (16 unregistered) - -Failing units (sorted): - parity/compose-default.tsx · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4915830bde6cc1b232dc9d6c647babdd96e12829e119908bbc4b74a2c092aab0] — unit missing from baseline - parity/compose-default.tsx · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> cf6a211755fc6c262977c92c7d597d6236487bdfcefa6c6271aa4da2c2dd16a9] — unit missing from baseline - parity/compose-default.tsx · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 1266c87a04fdfdb5d0e1130a2d8b1ec4b31e56015ea7c98f44af149962e7de30] — unit missing from baseline - parity/compose-default.tsx · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline - parity/compose-slot-bail · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4b37897d9204026eeea32f2856cf1d0dd51cabbb690deb5c94a96fab607e651a] — unit missing from baseline - parity/compose-slot-bail · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 0bcdbe4d15cdb55c56e356d13b47db44d010a071f8ef1ccd5d43128538e026e9] — unit missing from baseline - parity/compose-slot-bail · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> eb510234d7aeff62e13d3d4a5b85e365f3c74296b117b9f35bcb255fdf4700d0] — unit missing from baseline - parity/compose-slot-bail · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 2b307dcebf2a65fade6dccfb80f3da674761c329e77cdd00136015aa4154a77d] — unit missing from baseline - parity/duplicate-compose-modules · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> f09a6e35591a0ee0ba0bd6da71bfedd1dbf4710d9512c45d25ddca05cfc92ff7] — unit missing from baseline - parity/duplicate-compose-modules · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 11dcc454ac92666f2422fbfdaab4db9ac630b371e0e3a48ff02db5acb5990fa3] — unit missing from baseline - parity/duplicate-compose-modules · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 721beef9fbbcbd86b5a62342fc8c2d2dd60d3cc929edfcbedfe8e08e97e831a2] — unit missing from baseline - parity/duplicate-compose-modules · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline - parity/extension-compounds · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 1e3c8beba6c5136f4db1444fed1d186b620103d40db24f60974e2b4224c898c2] — unit missing from baseline - parity/extension-compounds · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 688682e39283c0336be3dd0f7bd351f3f4d757cbb9a90d77e4bfa77bbe437b4b] — unit missing from baseline - parity/extension-compounds · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 46320e834841834b95f66811952ef4d2e8c47001aa5a2260a905687cf7d64c37] — unit missing from baseline - parity/extension-compounds · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline +Units passed: 64/64 (100.00%) +Divergences: 0 (0 unregistered) Usage-case families: ok mdx-provider-scope — expected identical, observed identical @@ -32,41 +14,20 @@ Usage-case families: ok use-client-comment — expected identical, observed identical ok string-transforms-literal — expected identical, observed identical ok cyclic-extension — expected identical, observed identical - VIOLATED duplicate-compose-modules — expected identical, observed divergence - VIOLATED extension-compounds — expected identical, observed divergence - VIOLATED compose-default — expected identical, observed divergence - VIOLATED compose-slot-bail — expected identical, observed divergence - VIOLATED family duplicate-compose-modules: expected identical, saw 4 divergence(s) - VIOLATED family extension-compounds: expected identical, saw 4 divergence(s) - VIOLATED family compose-default: expected identical, saw 4 divergence(s) - VIOLATED family compose-slot-bail: expected identical, saw 4 divergence(s) + ok duplicate-compose-modules — expected identical, observed identical + ok extension-compounds — expected identical, observed identical + ok compose-default — expected identical, observed identical + ok compose-slot-bail — expected identical, observed identical Baseline metadata errors: - baseline corpus digest differs + active register row parity/inline-asserted-targets.tsx · css matches no current drift + active register row parity/inline-asserted-targets.tsx · observables matches no current drift --- parity baseline — engines: baseline:v2 vs v2 — devMode: true -Units passed: 58/62 (93.55%) -Divergences: 16 (16 unregistered) - -Failing units (sorted): - parity/compose-default.tsx · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4915830bde6cc1b232dc9d6c647babdd96e12829e119908bbc4b74a2c092aab0] — unit missing from baseline - parity/compose-default.tsx · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> cf6a211755fc6c262977c92c7d597d6236487bdfcefa6c6271aa4da2c2dd16a9] — unit missing from baseline - parity/compose-default.tsx · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 1266c87a04fdfdb5d0e1130a2d8b1ec4b31e56015ea7c98f44af149962e7de30] — unit missing from baseline - parity/compose-default.tsx · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline - parity/compose-slot-bail · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4b37897d9204026eeea32f2856cf1d0dd51cabbb690deb5c94a96fab607e651a] — unit missing from baseline - parity/compose-slot-bail · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 0bcdbe4d15cdb55c56e356d13b47db44d010a071f8ef1ccd5d43128538e026e9] — unit missing from baseline - parity/compose-slot-bail · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> eb510234d7aeff62e13d3d4a5b85e365f3c74296b117b9f35bcb255fdf4700d0] — unit missing from baseline - parity/compose-slot-bail · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 2b307dcebf2a65fade6dccfb80f3da674761c329e77cdd00136015aa4154a77d] — unit missing from baseline - parity/duplicate-compose-modules · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> f09a6e35591a0ee0ba0bd6da71bfedd1dbf4710d9512c45d25ddca05cfc92ff7] — unit missing from baseline - parity/duplicate-compose-modules · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 11dcc454ac92666f2422fbfdaab4db9ac630b371e0e3a48ff02db5acb5990fa3] — unit missing from baseline - parity/duplicate-compose-modules · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 721beef9fbbcbd86b5a62342fc8c2d2dd60d3cc929edfcbedfe8e08e97e831a2] — unit missing from baseline - parity/duplicate-compose-modules · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline - parity/extension-compounds · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 1e3c8beba6c5136f4db1444fed1d186b620103d40db24f60974e2b4224c898c2] — unit missing from baseline - parity/extension-compounds · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 688682e39283c0336be3dd0f7bd351f3f4d757cbb9a90d77e4bfa77bbe437b4b] — unit missing from baseline - parity/extension-compounds · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 46320e834841834b95f66811952ef4d2e8c47001aa5a2260a905687cf7d64c37] — unit missing from baseline - parity/extension-compounds · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline +Units passed: 64/64 (100.00%) +Divergences: 0 (0 unregistered) Usage-case families: ok mdx-provider-scope — expected identical, observed identical @@ -79,13 +40,10 @@ Usage-case families: ok use-client-comment — expected identical, observed identical ok string-transforms-literal — expected identical, observed identical ok cyclic-extension — expected identical, observed identical - VIOLATED duplicate-compose-modules — expected identical, observed divergence - VIOLATED extension-compounds — expected identical, observed divergence - VIOLATED compose-default — expected identical, observed divergence - VIOLATED compose-slot-bail — expected identical, observed divergence - VIOLATED family duplicate-compose-modules: expected identical, saw 4 divergence(s) - VIOLATED family extension-compounds: expected identical, saw 4 divergence(s) - VIOLATED family compose-default: expected identical, saw 4 divergence(s) - VIOLATED family compose-slot-bail: expected identical, saw 4 divergence(s) + ok duplicate-compose-modules — expected identical, observed identical + ok extension-compounds — expected identical, observed identical + ok compose-default — expected identical, observed identical + ok compose-slot-bail — expected identical, observed identical Baseline metadata errors: - baseline corpus digest differs + active register row parity/inline-asserted-targets.tsx · css matches no current drift + active register row parity/inline-asserted-targets.tsx · observables matches no current drift diff --git a/packages/extract/CLAUDE.md b/packages/extract/CLAUDE.md index c0c03db5..75f0b670 100644 --- a/packages/extract/CLAUDE.md +++ b/packages/extract/CLAUDE.md @@ -35,8 +35,9 @@ Loaded via the hand-written `index-v2.js` loader (fail-loud on missing binary). - `new ExtractEngine(options?: EngineOptions)` — config object (all fields optional, absent = v1 defaults): `themeJson`, `variableMapJson`, `contextualVarsJson`, `configJson`, `groupRegistryJson`, - `selectorAliasesJson`, `globalStyleBlocksJson`, `keyframesJson`, - `packageResolutionJson`, `pathAliasesJson`, `runtimeImport`, `cssModuleId`, + `selectorAliasesJson`, `conditionAliasesJson`, `globalStyleBlocksJson`, + `keyframesJson`, `packageResolutionJson`, `pathAliasesJson`, + `staticCssJson`, `externalDirsJson`, `runtimeImport`, `cssModuleId`, `systemPropsModuleId`, `devMode`. NAPI `Option` fields reject `null` (coerce with `?? undefined`). No selector-order field (retired). - `analyze(fileEntriesJson) → string` — parse-once fact extraction over the @@ -53,7 +54,12 @@ Loaded via the hand-written `index-v2.js` loader (fail-loud on missing binary). strips TS types via OXC, bundles + evaluates the SystemInstance with rquickjs, returns `{ propConfig, groupRegistry, scalesJson, variableMapJson, variableCss, contextualVarsJson, selectorAliases?, selectorOrder?, -globalStyleBlocks?, keyframesBlocks? }` (snake_case → camelCase auto). +conditionAliases?, globalStyleBlocks?, keyframesBlocks?, dependencies, +sourceThemeManifests? }` (snake_case → camelCase auto). + `sourceThemeManifests` is the per-module built-theme token capture + (`{ modulePath: { exportName: [token paths] } }`) — the source-token + witness for the cross-source correlation diagnostic; `None` when no + evaluated module exports a built theme. - `discoverChains(fileEntriesJson) → string`, `extractFacts(fileEntriesJson) → string`, `engineVersion() → string` — fact/probe surfaces consumed by the parity harness. diff --git a/packages/extract/crates/extract-v2/index.d.ts b/packages/extract/crates/extract-v2/index.d.ts index b4236b67..06cef1b0 100644 --- a/packages/extract/crates/extract-v2/index.d.ts +++ b/packages/extract/crates/extract-v2/index.d.ts @@ -79,6 +79,13 @@ export interface EngineOptions { * the serialized `staticCss` plugin option. */ staticCssJson?: string + /** + * rootDir-relative directory prefixes of discovered external packages + * (JSON string array). Files under these dirs get the external-token + * candidate walk (extraction-diagnostics: cross-source correlation); + * absent = no candidates recorded. + */ + externalDirsJson?: string /** v1 `dev_mode`: retain all components (skip reconciliation pruning). */ devMode?: boolean } @@ -123,4 +130,11 @@ export interface NapiSystemConfig { * as the geological-reset membership set. */ dependencies: Array + /** + * Per-module built-theme token manifests captured during evaluation + * (`{ modulePath: { exportName: [token paths] } }`) — the source-token + * witness for the cross-source correlation diagnostic. Absent when no + * evaluated module exports a built theme. + */ + sourceThemeManifests?: string } diff --git a/packages/extract/crates/extract-v2/src/analyze_css.rs b/packages/extract/crates/extract-v2/src/analyze_css.rs index 310c5bd1..2379948f 100644 --- a/packages/extract/crates/extract-v2/src/analyze_css.rs +++ b/packages/extract/crates/extract-v2/src/analyze_css.rs @@ -114,6 +114,10 @@ pub struct CssInputs { pub path_aliases: Vec, /// Forced-emission declarations (spec: static-emission-overrides). pub static_css: Option, + /// rootDir-relative directory prefixes of discovered external packages + /// (`externalDirsJson`). Files under these dirs get the external-token + /// candidate walk (cross-source correlation); empty = no candidates. + pub external_dirs: Vec, pub dev_mode: bool, } @@ -132,6 +136,7 @@ impl CssInputs { package_resolution_json: Option<&str>, path_aliases_json: Option<&str>, static_css_json: Option<&str>, + external_dirs_json: Option<&str>, dev_mode: bool, ) -> Result { fn parse( @@ -198,6 +203,7 @@ impl CssInputs { package_map: parse("packageResolutionJson", package_resolution_json)?, path_aliases, static_css, + external_dirs: parse("externalDirsJson", external_dirs_json)?, dev_mode, }) } @@ -224,6 +230,12 @@ pub struct CssDiagnostic { pub component: String, pub kind: String, pub message: String, + /// Structured token path (`scale.key`) for diagnostics that reference a + /// specific theme token — set only by the external-token candidate walk + /// (cross-source correlation). Skipped from the manifest when absent, so + /// every existing diagnostic serializes byte-identically. + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, } pub struct CssOutput { @@ -416,6 +428,7 @@ fn shed_unresolved_alias_decls( return true; } diagnostics.push(CssDiagnostic { + token: None, file: file.to_string(), component: component.to_string(), kind: "warn".to_string(), @@ -523,6 +536,7 @@ fn warn_token_shaped_value( return; } diagnostics.push(CssDiagnostic { + token: None, file: file.to_string(), component: component.to_string(), kind: "warn".to_string(), @@ -535,6 +549,219 @@ fn warn_token_shaped_value( }); } +/// CSS property → theme scale NAME, for every propConfig entry whose scale is +/// a string reference (`property` + fan-out `properties`), plus the +/// color-family pass-throughs → `colors`. Inline object/array scales resolve +/// locally and never correspond to theme tokens, so they contribute nothing. +fn scale_name_by_css_property(config: &PropConfigMap) -> FxHashMap { + let mut map: FxHashMap = FxHashMap::default(); + for pc in config.values() { + let Some(Value::String(scale)) = &pc.scale else { + continue; + }; + map.insert(camel_to_kebab(&pc.property), scale.clone()); + for p in &pc.properties { + map.insert(camel_to_kebab(p), scale.clone()); + } + } + for p in crate::theme::COLOR_FAMILY_PASS_THROUGH { + map.entry(camel_to_kebab(p)) + .or_insert_with(|| "colors".to_string()); + } + map +} + +/// A value that could be an unresolved SCALE KEY: a single bare segment or a +/// dotted path over `[A-Za-z0-9_-]` (leading digits admitted — numeric scale +/// keys are common). Resolved outputs (`var(...)`, `#hex`, values with +/// whitespace/commas/quotes) are rejected by shape. Deliberately broad — the +/// TS-side join only reports a candidate whose token the SOURCE package's own +/// manifest defines, which is what keeps CSS literals silent. +fn is_scale_key_shaped_value(value: &str) -> bool { + let mut segment_len = 0usize; + for (i, c) in value.chars().enumerate() { + if c == '.' { + if segment_len == 0 { + return false; // leading dot or empty segment + } + segment_len = 0; + } else if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + if i == 0 && c == '-' { + return false; // custom-property / negative-value shapes + } + segment_len += 1; + } else { + return false; + } + } + segment_len > 0 +} + +/// Bare values that are valid CSS in (nearly) any property position: the +/// CSS-wide keywords plus a handful of universally-common keyword values. +/// A bare candidate matching one of these is presumed a CSS LITERAL even +/// when a source package happens to define a same-named token — +/// `colors.transparent` is near-universal in design-system palettes, and +/// reporting `borderColor: 'transparent'` against it would turn a correct +/// declaration into a strict-mode build failure. Keywords are matched +/// case-insensitively (CSS keywords are; `currentColor` is authored camel). +/// Dotted paths and brace aliases are never keyword-shaped, so only the +/// bare-segment candidate arm consults this. +const CSS_KEYWORD_VALUES: &[&str] = &[ + // CSS-wide keywords (valid on every property). + "inherit", + "initial", + "unset", + "revert", + "revert-layer", + // Universally-common keyword values on scale-qualified properties. + "auto", + "none", + "normal", + "transparent", + "currentcolor", + "bold", + "bolder", + "lighter", +]; + +fn is_css_keyword_value(value: &str) -> bool { + CSS_KEYWORD_VALUES + .iter() + .any(|kw| value.eq_ignore_ascii_case(kw)) +} + +fn record_external_candidates_in_decls( + decls: &[CssDeclaration], + scale_names: &FxHashMap, + file: &str, + component: &str, + diagnostics: &mut Vec, +) { + for d in decls { + if d.property.starts_with("--") { + continue; + } + // An unresolved brace alias already carries its full token path and + // may sit on ANY property (`boxShadow: '0 0 8px {colors.glow}'`); a + // bare or dotted survivor is only a candidate on a scale-qualified, + // non-exempt property, qualified by that property's scale name. + let spans = unresolved_alias_spans(&d.value); + let tokens: Vec = if spans.is_empty() { + if TOKEN_SHAPE_EXEMPT_PROPERTIES.contains(&d.property.as_str()) { + continue; + } + let Some(scale) = scale_names.get(&d.property) else { + continue; + }; + if !is_scale_key_shaped_value(&d.value) || is_css_keyword_value(&d.value) { + continue; + } + vec![format!("{}.{}", scale, d.value)] + } else { + spans + .iter() + .map(|s| { + let content = s.trim_matches(|c| c == '{' || c == '}'); + // `{colors.primary/40}` alpha syntax: the token is the + // path before the alpha suffix. + content.split('/').next().unwrap_or(content).to_string() + }) + .collect() + }; + for token in tokens { + diagnostics.push(CssDiagnostic { + token: Some(token.clone()), + file: file.to_string(), + component: component.to_string(), + kind: "external-token-candidate".to_string(), + message: format!( + "'{}' in '{}' did not resolve against the consumer theme", + token, d.property + ), + }); + } + } +} + +fn record_external_candidates_in_styles( + styles: &ResolvedStyles, + scale_names: &FxHashMap, + file: &str, + component: &str, + diagnostics: &mut Vec, +) { + record_external_candidates_in_decls( + &styles.declarations, + scale_names, + file, + component, + diagnostics, + ); + for (_, decls) in &styles.pseudo_selectors { + record_external_candidates_in_decls(decls, scale_names, file, component, diagnostics); + } + for group in &styles.conditioned { + record_external_candidates_in_decls( + &group.declarations, + scale_names, + file, + component, + diagnostics, + ); + } +} + +/// The external-token candidate walk (extraction-diagnostics: cross-source +/// correlation). Runs only for components whose file lives under a declared +/// external package dir, BEFORE the alias shed — so unresolved brace aliases +/// are still present and contribute their token paths. Candidates use their +/// own diagnostic kind, which the default plugin surfacing drops (unknown +/// kind): the TS-side correlation join owns their presentation after checking +/// each token against the source package's captured manifest. +fn record_external_token_candidates( + css: &ComponentCss, + scale_names: &FxHashMap, + file: &str, + component: &str, + diagnostics: &mut Vec, +) { + if let Some(base) = css.base.as_ref() { + record_external_candidates_in_styles(base, scale_names, file, component, diagnostics); + } + for vc in &css.variants { + for (_, styles) in &vc.options { + record_external_candidates_in_styles(styles, scale_names, file, component, diagnostics); + } + } + for styles in &css.compounds { + record_external_candidates_in_styles(styles, scale_names, file, component, diagnostics); + } + for (_, styles) in &css.states { + record_external_candidates_in_styles(styles, scale_names, file, component, diagnostics); + } +} + +/// Is this rootDir-relative file under one of the declared external dirs? +/// Both sides originate from the host's `path.relative`, which emits +/// backslashes on Windows — normalize to `/` before the containment check +/// (comparison only; recorded diagnostics keep the authored paths). +fn is_external_file(file: &str, external_dirs: &[String]) -> bool { + // The common case — no external packages declared — pays nothing. + if external_dirs.is_empty() { + return false; + } + let file = file.replace('\\', "/"); + external_dirs.iter().any(|dir| { + let dir = dir.replace('\\', "/"); + let dir = dir.trim_end_matches('/'); + !dir.is_empty() + && file + .strip_prefix(dir) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + fn shed_unresolved_aliases_in_styles( styles: &mut ResolvedStyles, scale_family: &FxHashSet, @@ -579,6 +806,7 @@ fn emit_eval_drop_bail( detail: &str, ) { diagnostics.push(CssDiagnostic { + token: None, file: file.to_string(), component: binding.to_string(), kind: "bail".to_string(), @@ -632,6 +860,7 @@ fn emit_compose_slot_bail( binding: &str, ) { diagnostics.push(CssDiagnostic { + token: None, file: file.to_string(), component: family_name.to_string(), kind: "bail".to_string(), @@ -743,6 +972,25 @@ fn resolve_usage_identity( evaluated_ids: &FxHashSet, ids_by_binding: &IdsByBinding, ) -> Vec { + // Dotted static-member path (`Compound.Item`, `Ns.Compound.Item`): the + // ROOT resolves through the import table to its defining file and the + // LAST segment names the component there (the `const Compound = { Item }` + // namespace idiom), falling back to the bare-name layer. Used by the + // asComponent wrap-target keep; JSX member tags resolve separately via + // compose `member_expr_bindings`. + if let Some((path_head, last)) = local.rsplit_once('.') { + let root = path_head.split('.').next().unwrap_or(path_head); + let root_file = files + .get(file) + .and_then(|ff| ff.imports.iter().find(|i| i.local == root)) + .and_then(|imp| resolve_import_source(file, &imp.source, files, inputs)) + .unwrap_or_else(|| file.to_string()); + let local_id = format!("{}::{}", root_file, last); + if evaluated_ids.contains(&local_id) { + return vec![local_id]; + } + return ids_by_binding.get(last).cloned().unwrap_or_default(); + } let local_id = format!("{}::{}", file, local); if evaluated_ids.contains(&local_id) { return vec![local_id]; @@ -1103,6 +1351,7 @@ fn run_with_system_floor( if t.valid { if let Err(err) = evaluator.register(&t.name, &t.source) { diagnostics.push(CssDiagnostic { + token: None, file: t.file.clone(), component: format!("createTransform('{}')", t.name), kind: "warn".to_string(), @@ -1122,6 +1371,7 @@ fn run_with_system_floor( if !t.valid { for diag in &t.diagnostics { diagnostics.push(CssDiagnostic { + token: None, file: t.file.clone(), component: format!("createTransform('{}')", t.name), kind: "bail".to_string(), @@ -1159,6 +1409,7 @@ fn run_with_system_floor( if !d.extractable { if let Some(reason) = &d.bail_reason { diagnostics.push(CssDiagnostic { + token: None, file: file_path.clone(), component: d.binding.clone(), kind: "bail".to_string(), @@ -1230,6 +1481,13 @@ fn run_with_system_floor( let mut evaluated: FxHashMap = FxHashMap::default(); // Derived once per run — the properties on which a token SHAPE is suspicious. let scale_family_props = scale_family_css_properties(&inputs.config); + // Derived once per run — CSS property → scale name, for qualifying + // external-token candidates (empty external_dirs skips the walk entirely). + let scale_names = if inputs.external_dirs.is_empty() { + FxHashMap::default() + } else { + scale_name_by_css_property(&inputs.config) + }; let mut inherited_active_props: FxHashMap> = FxHashMap::default(); for component_id in &sorted_ids { @@ -1264,6 +1522,7 @@ fn run_with_system_floor( let custom_configs = out.custom_prop_configs; for warning in &out.skip_warnings { diagnostics.push(CssDiagnostic { + token: None, file: file_path.to_string(), component: chain.descriptor.binding.clone(), kind: "skip".to_string(), @@ -1271,6 +1530,18 @@ fn run_with_system_floor( }); } + // Cross-source correlation: candidate walk BEFORE the shed so + // unresolved brace aliases still carry their token paths. + if is_external_file(file_path, &inputs.external_dirs) { + record_external_token_candidates( + &component_css, + &scale_names, + file_path, + &chain.descriptor.binding, + &mut diagnostics, + ); + } + // Quirk shed 01: unresolvable-alias leak → drop declaration // + warn (v1 leaks the raw `{scale.path}` literal). shed_unresolved_aliases( @@ -2186,6 +2457,59 @@ fn run_with_system_floor( } } + // asComponent() wrap targets are runtime-rendered whenever their wrapper + // is: the emitted wrapper calls `createComponent(, …)`, which + // merges the target's own class onto the element — so the target's CSS + // must survive reconciliation even when the target never appears as a + // JSX tag itself. A bare target resolves like any other local/imported + // usage; a dotted target resolves its ROOT through the import table to + // the defining file and takes the LAST segment there (the + // `const Compound = { Item }` namespace idiom), falling back to the + // bare-name layer. A non-animus target resolves to nothing and nothing + // is kept. Kept unconditionally (exactly like compose slots above) — + // over-keeping is safe; under-keeping ships a wrapper whose merged + // class has no rule (dev keeps it, production silently dropped it). + // The target's variant OPTIONS and STATES are retained in full: props + // the wrapper does not consume forward to the target at runtime and + // activate the target's own variant/state classes, but the scan filter + // attributes `` only against the WRAPPER's config + // (which lacks the target's variants), so per-usage pruning has no + // sound signal here — conservative retention is the only safe floor. + for component_id in &sorted_ids { + let Some((file_path, chain_idx)) = chain_lookup.get(component_id.as_str()) else { + continue; + }; + let chain = &files[*file_path].chains[*chain_idx]; + if chain.descriptor.terminal != TerminalKind::AsComponent { + continue; + } + let tag = chain.descriptor.tag.as_str(); + if tag.is_empty() { + continue; + } + let target_ids = + resolve_usage_identity(file_path, tag, files, inputs, &evaluated_ids, &ids_by_binding); + for id in target_ids { + if let Some(variant_config) = variant_configs_for_ledger.get(&id) { + let used = usage_ledger.variant_usage.entry(id.clone()).or_default(); + for (prop, options) in variant_config { + used.entry(prop.clone()) + .or_default() + .extend(options.0.iter().cloned()); + } + } + if let Some((target_css, _, _, _, _, _, _)) = evaluated.get(&id) { + if !target_css.states.is_empty() { + let states = usage_ledger.state_usage.entry(id.clone()).or_default(); + for (state_name, _) in &target_css.states { + states.insert(state_name.clone()); + } + } + } + usage_ledger.rendered_components.insert(id); + } + } + // -- Phase 5e mirror: reconcile ------------------------------------------ let mut reconciled_components: Vec<(String, ComponentCss)> = sorted_ids .iter() @@ -2525,6 +2849,7 @@ mod tests { None, None, None, + None, false, ) .unwrap(); @@ -2714,6 +3039,90 @@ mod tests { assert!(!out.sheets.base.contains("grid"), "{}", out.sheets.base); } + #[test] + fn as_component_targets_survive_prod_reconciliation() { + // The wrapper renders `createComponent(, …)` at runtime, so + // the target's class is on the element whenever the wrapper is — + // production must keep the target's CSS even though it never appears + // as a JSX tag. Covers the bare-identifier form and the + // `const Compound = { Item }` static-member namespace idiom + // (dev/prod parity: dev always kept these). + let out = analyze( + &[( + "a.tsx", + "const Inner = ds.styles({ display: 'grid' }).asElement('i');\n\ + export const Wrapped = ds.styles({ p: 8 }).asComponent(Inner);\n\ + const Item = ds.styles({ display: 'inline-grid' }).asElement('i');\n\ + export const Compound = { Item };\n\ + export const MemberWrapped = ds.styles({ p: 8 }).asComponent(Compound.Item);\n\ + export const App = () => <>;\n", + )], + &test_inputs(), + ); + // Full declarations: `display: grid` is not a substring of + // `display: inline-grid`, so each arm is asserted independently. + assert!( + out.sheets.base.contains("display: grid"), + "{}", + out.sheets.base + ); + assert!( + out.sheets.base.contains("display: inline-grid"), + "{}", + out.sheets.base + ); + } + + #[test] + fn as_component_target_variants_and_states_survive_prod() { + // Props the wrapper does not consume forward to the target at + // runtime and activate the target's OWN variant/state classes, but + // the scan attributes `` only against the + // wrapper's config — so the target's options and states must be + // retained in full, not pruned per observed usage. + let out = analyze( + &[( + "a.tsx", + "const Chip = ds.styles({ display: 'flex' })\n\ + .variant({ prop: 'tone', variants: { blue: { opacity: 1 }, red: { opacity: 0.5 } } })\n\ + .states({ active: { visibility: 'visible' } })\n\ + .asElement('span');\n\ + export const Wrapped = ds.styles({ p: 8 }).asComponent(Chip);\n\ + export const App = () => ;\n", + )], + &test_inputs(), + ); + assert!( + out.sheets.variants.contains("opacity: 1"), + "{}", + out.sheets.variants + ); + assert!( + out.sheets.variants.contains("opacity: 0.5"), + "{}", + out.sheets.variants + ); + assert!( + out.sheets.states.contains("visibility: visible"), + "{}", + out.sheets.states + ); + } + + #[test] + fn external_file_containment_normalizes_windows_separators() { + // Both sides originate from the host's `path.relative`, which emits + // backslashes on Windows; containment must hold across separator + // styles, and a sibling-prefix dir must never claim the file. + let dirs = vec!["kit/src".to_string()]; + assert!(is_external_file("kit\\src\\Card.tsx", &dirs)); + assert!(is_external_file( + "kit/src/Card.tsx", + &["kit\\src".to_string()] + )); + assert!(!is_external_file("kit\\src-extra\\Card.tsx", &dirs)); + } + #[test] fn dev_mode_keeps_unused_components() { let mut inputs = test_inputs(); @@ -2793,6 +3202,7 @@ mod tests { None, None, None, + None, false, ) .unwrap(); @@ -2848,6 +3258,158 @@ mod tests { assert!(w.message.contains("emitted as authored"), "{}", w.message); } + /// token_shape_inputs with `kit/src` declared as an external package dir + /// (extraction-diagnostics: cross-source correlation candidates). + fn external_dir_inputs() -> CssInputs { + let mut inputs = token_shape_inputs(); + inputs.external_dirs = vec!["kit/src".into()]; + inputs + } + + fn candidates_of(out: &CssOutput) -> Vec<&CssDiagnostic> { + out.diagnostics + .iter() + .filter(|d| d.kind == "external-token-candidate") + .collect() + } + + #[test] + fn external_scale_key_miss_records_candidate_with_token() { + // The flagship correlation case: a kit component references a kit + // token via a BARE scale key the consumer theme does not define. + // Emission keeps the shipped pass-through; the candidate (not a warn) + // carries the scale-qualified token for the TS-side witness join. + let out = analyze( + &[( + "kit/src/Card.tsx", + "export const KitCard = ds.styles({ display: 'flex', bg: 'externalAccent' }).asElement('div');\nexport const App = () => ;\n", + )], + &external_dir_inputs(), + ); + assert!( + out.sheets.base.contains("background-color: externalAccent"), + "{}", + out.sheets.base + ); + let candidates = candidates_of(&out); + assert_eq!(candidates.len(), 1, "{:?}", out.diagnostics); + let c = candidates[0]; + assert_eq!(c.file, "kit/src/Card.tsx"); + assert_eq!(c.component, "KitCard"); + assert_eq!(c.token.as_deref(), Some("colors.externalAccent")); + // Candidates are NOT the always-on warn channel. + assert!(warns_of(&out).is_empty(), "{:?}", out.diagnostics); + } + + #[test] + fn external_brace_alias_records_candidate_before_shed() { + // The candidate walk runs BEFORE the alias shed, so a dropped + // declaration still contributes its token path (alpha suffix + // stripped). The shed itself is unchanged: declaration dropped, warn + // emitted. + let out = analyze( + &[( + "kit/src/Card.tsx", + "export const KitCard = ds.styles({ display: 'flex', bg: '{colors.kitAccent/40}' }).asElement('div');\nexport const App = () => ;\n", + )], + &external_dir_inputs(), + ); + assert!(!out.css.contains("{colors.kitAccent"), "{}", out.css); + let candidates = candidates_of(&out); + assert_eq!(candidates.len(), 1, "{:?}", out.diagnostics); + assert_eq!( + candidates[0].token.as_deref(), + Some("colors.kitAccent"), + "{:?}", + out.diagnostics + ); + assert_eq!(warns_of(&out).len(), 1, "{:?}", out.diagnostics); + } + + #[test] + fn bare_css_keywords_record_no_candidate() { + // `transparent` / `inherit` on a scale-qualified property are valid + // CSS literals, and near-universal token names in kit palettes — the + // witness join would misfire on them, turning correct declarations + // into strict-mode build failures. Keyword-shaped bare values are + // presumed literals and never become candidates; a non-keyword bare + // key on the same property still does (positive control). + let out = analyze( + &[( + "kit/src/Card.tsx", + "export const KitCard = ds.styles({ bg: 'transparent', color: 'inherit', borderColor: 'currentColor' }).asElement('div');\nexport const App = () => ;\n", + )], + &external_dir_inputs(), + ); + assert!(candidates_of(&out).is_empty(), "{:?}", out.diagnostics); + + let control = analyze( + &[( + "kit/src/Card.tsx", + "export const KitCard = ds.styles({ bg: 'externalAccent' }).asElement('div');\nexport const App = () => ;\n", + )], + &external_dir_inputs(), + ); + assert_eq!( + candidates_of(&control).len(), + 1, + "{:?}", + control.diagnostics + ); + } + + #[test] + fn consumer_local_miss_records_no_candidate() { + // Consumer-local components keep the existing pass-through with no + // candidate — the correlation covers discovered sources only. + let out = analyze( + &[( + "src/App.tsx", + "export const Local = ds.styles({ bg: 'externalAccent' }).asElement('div');\nexport const App = () => ;\n", + )], + &external_dir_inputs(), + ); + assert!(candidates_of(&out).is_empty(), "{:?}", out.diagnostics); + } + + #[test] + fn external_resolved_and_literal_values_record_no_scale_key_candidate_noise() { + // A resolving key becomes a theme literal (`#ff2800` — rejected by + // shape); `display: flex` has no scale. Only shape-plausible misses + // on scale-qualified properties survive as candidates. + let out = analyze( + &[( + "kit/src/Card.tsx", + "export const KitCard = ds.styles({ display: 'flex', bg: 'primary' }).asElement('div');\nexport const App = () => ;\n", + )], + &external_dir_inputs(), + ); + assert!(candidates_of(&out).is_empty(), "{:?}", out.diagnostics); + } + + #[test] + fn scale_key_shape_predicate_bounds() { + assert!(is_scale_key_shaped_value("externalAccent")); + assert!(is_scale_key_shaped_value("16")); + assert!(is_scale_key_shaped_value("accent.solid")); + assert!(is_scale_key_shaped_value("red")); + assert!(!is_scale_key_shaped_value("var(--x)")); + assert!(!is_scale_key_shaped_value("#fff")); + assert!(!is_scale_key_shaped_value("0 0 4px")); + assert!(!is_scale_key_shaped_value("-4")); + assert!(!is_scale_key_shaped_value("a..b")); + assert!(!is_scale_key_shaped_value("")); + } + + #[test] + fn is_external_file_requires_directory_boundary() { + let dirs = vec!["kit/src".to_string()]; + assert!(is_external_file("kit/src/Card.tsx", &dirs)); + assert!(!is_external_file("kit/srcx/Card.tsx", &dirs)); + assert!(!is_external_file("src/App.tsx", &dirs)); + assert!(!is_external_file("kit/src", &dirs)); + } + #[test] fn resolving_scale_key_does_not_warn() { let out = analyze( @@ -2948,6 +3510,7 @@ mod tests { None, None, None, + None, false, ) .unwrap(); @@ -3805,6 +4368,7 @@ mod tests { None, None, None, + None, false, ) .unwrap() diff --git a/packages/extract/crates/extract-v2/src/chain_walk.rs b/packages/extract/crates/extract-v2/src/chain_walk.rs index fc4bfd23..9b534be4 100644 --- a/packages/extract/crates/extract-v2/src/chain_walk.rs +++ b/packages/extract/crates/extract-v2/src/chain_walk.rs @@ -235,6 +235,24 @@ fn unwrap_type_assertions<'a, 'b>(expr: &'a Expression<'b>) -> &'a Expression<'b } } +/// Render a dotted static-member path (`Compound.Item`, `Ns.Compound.Item`) +/// when every link is a plain identifier or static member — type-assertion +/// wrappers are peeled at every hop, since assertions are erased type-level +/// syntax and must never change extraction. Computed members, calls, and any +/// other base return None (the caller bails loudly). The emitter renders an +/// AsComponent tag VERBATIM into `createComponent(, …)`, so a dotted +/// path is exactly as valid at the definition site as the identifier form. +fn static_member_path(expr: &Expression<'_>) -> Option { + match unwrap_type_assertions(expr) { + Expression::Identifier(id) => Some(id.name.to_string()), + Expression::StaticMemberExpression(member) => { + let base = static_member_path(&member.object)?; + Some(format!("{}.{}", base, member.property.name.as_str())) + } + _ => None, + } +} + fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> TerminalArg { match terminal { TerminalKind::AsClass => TerminalArg::Resolved(String::new()), @@ -258,10 +276,11 @@ fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> T .first() .and_then(|arg| arg.as_expression()) .map(unwrap_type_assertions) + .and_then(static_member_path) { - Some(Expression::Identifier(id)) => TerminalArg::Resolved(id.name.to_string()), - _ => TerminalArg::Unresolvable( - "target has no static identifier name".to_string(), + Some(path) => TerminalArg::Resolved(path), + None => TerminalArg::Unresolvable( + "target has no static identifier or member path".to_string(), ), } } @@ -442,6 +461,69 @@ mod tests { assert_eq!(chains[0].tag, "div"); } + #[test] + fn extracts_as_component_with_static_member_target() { + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const Wrapped = animus.styles({ p: 8 }).asComponent(Compound.Item); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(chains[0].extractable); + assert_eq!(chains[0].tag, "Compound.Item"); + } + + #[test] + fn extracts_as_component_with_asserted_static_member_target() { + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const Wrapped = animus + .styles({ p: 8 }) + .asComponent(Compound.Item as unknown as typeof Compound.Item); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(chains[0].extractable); + assert_eq!(chains[0].tag, "Compound.Item"); + } + + #[test] + fn extracts_deep_static_member_paths_with_inner_assertions() { + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const Wrapped = animus + .styles({ p: 8 }) + .asComponent((Ns as any).Compound.Item); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(chains[0].extractable); + assert_eq!(chains[0].tag, "Ns.Compound.Item"); + } + + #[test] + fn bails_on_computed_member_as_component_target() { + // Computed access stays a bail even with a literal key — evaluation + // territory, and the bail is loud (named reason), never silent. + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const Wrapped = animus.styles({ p: 8 }).asComponent(Compound['Item']); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(!chains[0].extractable); + assert!(chains[0] + .bail_reason + .as_deref() + .unwrap_or_default() + .contains("static identifier or member path")); + assert_ne!(chains[0].tag, "unknown"); + } + #[test] fn bails_on_unresolvable_as_component_target() { // A computed target has no static name to emit; the chain must bail diff --git a/packages/extract/crates/extract-v2/src/engine.rs b/packages/extract/crates/extract-v2/src/engine.rs index 355a151d..141111fe 100644 --- a/packages/extract/crates/extract-v2/src/engine.rs +++ b/packages/extract/crates/extract-v2/src/engine.rs @@ -102,6 +102,11 @@ pub struct EngineOptions { /// Forced-emission declarations (spec: static-emission-overrides) — /// the serialized `staticCss` plugin option. pub static_css_json: Option, + /// rootDir-relative directory prefixes of discovered external packages + /// (JSON string array). Files under these dirs get the external-token + /// candidate walk (extraction-diagnostics: cross-source correlation); + /// absent = no candidates recorded. + pub external_dirs_json: Option, /// v1 `dev_mode`: retain all components (skip reconciliation pruning). pub dev_mode: Option, } @@ -211,6 +216,7 @@ impl ExtractEngine { o.package_resolution_json.as_deref(), o.path_aliases_json.as_deref(), o.static_css_json.as_deref(), + o.external_dirs_json.as_deref(), o.dev_mode.unwrap_or(false), ) .map_err(napi::Error::from_reason)?; diff --git a/packages/extract/crates/extract-v2/src/forced_usage.rs b/packages/extract/crates/extract-v2/src/forced_usage.rs index a53863a4..0f014628 100644 --- a/packages/extract/crates/extract-v2/src/forced_usage.rs +++ b/packages/extract/crates/extract-v2/src/forced_usage.rs @@ -121,6 +121,7 @@ pub fn merge_into_report( fn warn(warnings: &mut Vec, component: &str, message: String) { warnings.push(CssDiagnostic { + token: None, file: STATIC_CSS_SOURCE.to_string(), component: component.to_string(), kind: "warn".to_string(), diff --git a/packages/extract/crates/extract-v2/src/lib.rs b/packages/extract/crates/extract-v2/src/lib.rs index 2d226947..fa97d583 100644 --- a/packages/extract/crates/extract-v2/src/lib.rs +++ b/packages/extract/crates/extract-v2/src/lib.rs @@ -76,6 +76,11 @@ pub struct NapiSystemConfig { /// (sorted; entry included, runtime stubs excluded). The plugins use this /// as the geological-reset membership set. pub dependencies: Vec, + /// Per-module built-theme token manifests captured during evaluation + /// (`{ modulePath: { exportName: [token paths] } }`) — the source-token + /// witness for the cross-source correlation diagnostic. Absent when no + /// evaluated module exports a built theme. + pub source_theme_manifests: Option, } #[napi] @@ -104,6 +109,7 @@ pub fn load_system_module( global_style_blocks: config.global_style_blocks, keyframes_blocks: config.keyframes_blocks, dependencies: config.dependencies, + source_theme_manifests: config.source_theme_manifests, }) } diff --git a/packages/extract/crates/extract-v2/src/theme.rs b/packages/extract/crates/extract-v2/src/theme.rs index 99b4a1dc..07e06782 100644 --- a/packages/extract/crates/extract-v2/src/theme.rs +++ b/packages/extract/crates/extract-v2/src/theme.rs @@ -2315,6 +2315,34 @@ mod tests { assert!(css.contains("src: url('./assets/inter.woff2');")); } + #[test] + fn font_face_asset_placeholder_passes_through_byte_exact() { + // standardize-inheritance-and-assets: `asset()` placeholders + // (`animus-asset:`) are just strings to the emitter — + // the shipped byte-exact url pass-through carries them verbatim for + // host-plugin substitution. + let owner = TestCtxOwner::new(); + let blocks = json!({ + "globals": { + "styles": {}, + "fontFaces": [{ + "family": "Inter", + "src": [{ + "url": "animus-asset:@acme/tokens/fonts/inter.woff2", + "format": "woff2" + }] + }] + } + }); + let css = resolve_all_global_blocks(&blocks, &owner.ctx()); + assert!( + css.contains( + "src: url('animus-asset:@acme/tokens/fonts/inter.woff2') format('woff2');" + ), + "placeholder must survive byte-exact:\n{css}" + ); + } + #[test] fn font_face_family_resolves_font_scale_token() { let mut owner = TestCtxOwner::new(); diff --git a/packages/extract/crates/system-loader/src/lib.rs b/packages/extract/crates/system-loader/src/lib.rs index e3dc2bcb..33186c0f 100644 --- a/packages/extract/crates/system-loader/src/lib.rs +++ b/packages/extract/crates/system-loader/src/lib.rs @@ -49,6 +49,14 @@ pub struct SystemConfig { /// have no path). Sorted. Plugins use this as the geological-reset /// membership set so transitive system edits invalidate correctly. pub dependencies: Vec, + /// Per-module built-theme token manifests captured during the one + /// evaluation this load already performs (extraction-diagnostics: the + /// source-token witness for the cross-source correlation diagnostic). + /// JSON shape: `{ modulePath: { exportName: [variableMap token paths] } }`, + /// keyed by the same canonical paths as `dependencies`. `None` when no + /// evaluated module exports a built theme. Capture never triggers extra + /// evaluation, resolution, or filesystem access. + pub source_theme_manifests: Option, } // --------------------------------------------------------------------------- @@ -294,6 +302,27 @@ fn module_export_name(name: &oxc::ast::ast::ModuleExportName<'_>) -> String { } } +/// Non-code asset extensions that carry no module semantics in the sandbox. +const ASSET_EXTENSIONS: &[&str] = &[ + ".woff2", ".woff", ".ttf", ".otf", ".eot", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", + ".svg", ".ico", ".mp4", ".webm", ".mp3", ".wasm", ".pdf", +]; + +/// Why a specifier is a bundler asset import (None for ordinary modules). +fn asset_import_reason(specifier: &str) -> Option<&'static str> { + if let Some((_, query)) = specifier.split_once('?') { + if matches!(query, "url" | "raw" | "inline" | "no-inline") { + return Some("bundler asset query suffix"); + } + } + let path = specifier.split('?').next().unwrap_or(specifier); + let lower = path.to_ascii_lowercase(); + if ASSET_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) { + return Some("binary asset extension"); + } + None +} + /// Bundle registry key for a stubbed runtime package. fn stub_key(specifier: &str) -> String { format!("__stub__/{}", specifier) @@ -514,6 +543,22 @@ pub fn resolve_all_deps( for info in import_infos { let spec = &info.specifier; + // Bundler asset imports cannot traverse system evaluation: a + // query-suffixed specifier (?url/?raw/?inline) or a binary asset + // extension has no module semantics in the sandbox — crawling it + // yields an exports-less module whose `.default` is undefined, + // the least debuggable failure this loader can produce. Fail + // loud, name the specifier, and point at the supported form. + if let Some(reason) = asset_import_reason(spec) { + return Err(format!( + "asset import '{}' in '{}' cannot traverse system \ + evaluation ({}); reference package-owned assets with \ + asset('') from @animus-ui/system, or use a \ + literal URL string (e.g. '/fonts/inter.woff2') — the \ + host bundler's asset pipeline owns resolution", + spec, current_path, reason + )); + } if spec.starts_with('.') || spec.starts_with('/') { // Relative import match resolve_relative(current_dir, spec) { @@ -1158,14 +1203,80 @@ fn execute_bundle( .eval(access_script.as_bytes()) .map_err(|e| format!("failed to access entry module exports: {}", e))?; - extract_system_config(&ctx, &namespace, export_name) + let mut config = extract_system_config(&ctx, &namespace, export_name)?; + config.source_theme_manifests = extract_source_theme_manifests(&ctx); + Ok(config) }) } +/// Capture per-module built-theme token manifests from the already-evaluated +/// module registry (the source-token witness for the cross-source correlation +/// diagnostic). A built theme is recognized by its non-enumerable `manifest` +/// object carrying `tokenMap` (or legacy `variableMap`); only the token PATHS +/// (keys) are captured. +/// A library bundle export (`{ system, theme }`, with `tokens` accepted as +/// the legacy spelling — recognized exactly as the builders do, by +/// `system.toConfig` being callable) contributes its theme half: a kit whose +/// only export is the bundle would otherwise yield no +/// witness and silently lose the correlation diagnostic. Pure registry walk — +/// no additional evaluation, resolution, or filesystem access happens here. +fn extract_source_theme_manifests(ctx: &rquickjs::Ctx<'_>) -> Option { + let script = r#"(() => { + const out = {}; + const themeTokens = (v) => { + if ( + v && typeof v === 'object' && + v.manifest && typeof v.manifest === 'object' && + ( + v.manifest.tokenMap && typeof v.manifest.tokenMap === 'object' || + v.manifest.variableMap && typeof v.manifest.variableMap === 'object' + ) + ) { + const map = v.manifest.tokenMap && typeof v.manifest.tokenMap === 'object' + ? v.manifest.tokenMap + : v.manifest.variableMap; + return Object.keys(map); + } + return null; + }; + for (const path in __modules) { + const ns = __modules[path]; + if (!ns || typeof ns !== 'object') continue; + const themes = {}; + for (const key of Object.keys(ns)) { + try { + const v = ns[key]; + let tokens = themeTokens(v); + // Bundle discriminator mirrors isLibraryBundle in + // packages/system/src/SystemBuilder.ts (this sandbox cannot import + // TS) — keep the two in sync. + if ( + tokens === null && + v && typeof v === 'object' && + v.system && typeof v.system.toConfig === 'function' + ) { + tokens = themeTokens(v.theme); + if (tokens === null) tokens = themeTokens(v.tokens); + } + if (tokens !== null) themes[key] = tokens; + } catch (_e) {} + } + if (Object.keys(themes).length > 0) out[path] = themes; + } + return JSON.stringify(out); +})()"#; + let json: String = ctx.eval(script.as_bytes()).ok()?; + if json == "{}" { + None + } else { + Some(json) + } +} + /// Extract SystemConfig from the module namespace. -fn extract_system_config( - _ctx: &rquickjs::Ctx<'_>, - namespace: &Object<'_>, +fn extract_system_config<'js>( + ctx: &rquickjs::Ctx<'js>, + namespace: &Object<'js>, export_name: Option<&str>, ) -> Result { // Find SystemInstance (export with .toConfig()) @@ -1201,11 +1312,79 @@ fn extract_system_config( let selector_order: Option = config_obj.get("selectorOrder").ok(); let condition_aliases: Option = config_obj.get("conditionAliases").ok(); - // Find theme (export named 'tokens' or 'theme' with .serialize()) - let theme_obj: Object = namespace - .get::<_, Object>("tokens") - .or_else(|_| namespace.get::<_, Object>("theme")) - .map_err(|_| "no 'tokens' or 'theme' export found".to_string())?; + // Find theme (export named 'theme' with .serialize(), 'tokens' accepted + // as a fallback — D9: public naming standardizes on 'theme'). When both + // names are exported and each is a built theme (callable .serialize()), + // they must be the SAME object — two distinct built themes make the + // serialized winner ambiguous, so the load fails naming both exports. + // Reference equality is judged inside the QuickJS context; serialized + // output is never compared. + let theme_export = namespace.get::<_, Object>("theme").ok(); + let tokens_export = namespace.get::<_, Object>("tokens").ok(); + + let is_built_theme = |obj: &Object<'_>| obj.get::<_, Function>("serialize").is_ok(); + if let (Some(theme), Some(tokens)) = (&theme_export, &tokens_export) { + if is_built_theme(theme) && is_built_theme(tokens) { + let same_object: bool = ctx + .eval::(b"(a, b) => a === b" as &[u8]) + .and_then(|is_same| is_same.call((theme.clone(), tokens.clone()))) + .map_err(|e| format!("theme export identity check failed: {}", e))?; + if !same_object { + return Err( + "both 'theme' and 'tokens' exports are built themes but not the same \ + object; the loader serializes exactly one theme — alias them (e.g. \ + `export const tokens = theme`) or remove one" + .to_string(), + ); + } + } + } + + // Selection with diagnosis — never a silent drop. A `theme` export that + // is a ThemeBuilder missing its trailing .build() is the closest-miss + // authoring error the D9 migration window invites: falling through to a + // legacy `tokens` export would extract a configuration the author did + // not edit, and reporting "no export found" would deny an export that is + // plainly present. Only a NON-builder `theme` value (an unrelated object + // that happens to use the name) still falls back to built `tokens`. + let is_theme_builder = |obj: &Object<'_>| { + obj.get::<_, Function>("build").is_ok() && obj.get::<_, Function>("addScale").is_ok() + }; + let built_theme = theme_export.clone().filter(|theme| is_built_theme(theme)); + let built_tokens = tokens_export.clone().filter(|tokens| is_built_theme(tokens)); + let theme_obj: Object = if let Some(theme) = built_theme { + theme + } else if theme_export.as_ref().is_some_and(is_theme_builder) { + return Err( + "'theme' export is a ThemeBuilder that was never built — add the trailing \ + .build() (`export const theme = createTheme()/* ... */.build()`); refusing \ + to fall back to any 'tokens' export" + .to_string(), + ); + } else if let Some(tokens) = built_tokens { + tokens + } else if tokens_export.as_ref().is_some_and(is_theme_builder) { + return Err( + "'tokens' export is a ThemeBuilder that was never built — add the trailing \ + .build() (`export const tokens = createTheme()/* ... */.build()`)" + .to_string(), + ); + } else { + let present = match (theme_export.is_some(), tokens_export.is_some()) { + (true, true) => Some("'theme' and 'tokens' exports are present but neither is"), + (true, false) => Some("'theme' export is present but it is not"), + (false, true) => Some("'tokens' export is present but it is not"), + (false, false) => None, + }; + return Err(match present { + Some(what) => format!( + "{} a built theme (no callable .serialize()) — export a built theme: \ + `export const theme = createTheme()/* ... */.build()`", + what + ), + None => "no 'theme' or 'tokens' export found".to_string(), + }); + }; let serialize_fn: Function = theme_obj .get("serialize") @@ -1248,6 +1427,9 @@ fn extract_system_config( // Populated by load_system_module from the resolved module graph; // execute_bundle only sees the assembled bundle text. dependencies: Vec::new(), + // Populated by execute_bundle after config extraction (the registry + // walk needs the live rquickjs context, not the namespace alone). + source_theme_manifests: None, }) } @@ -1837,6 +2019,564 @@ export const ds = tokens; ); } + #[test] + fn source_theme_manifests_capture_built_theme_token_paths() { + // extraction-diagnostics (cross-source correlation): a built theme + // exported by ANY module in the already-evaluated graph contributes + // all tokenMap paths, including non-emitted scales, keyed by canonical + // module path. + let dir = scratch_dir("theme-manifests"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "import { kitTokens } from './kit/index';\n\ + export const kitRef = kitTokens;\n\ + export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + write_fixture( + &dir.join("kit/index.ts"), + "export const kitTokens = {\n\ + colors: { externalAccent: '#f0f' },\n\ + manifest: {\n\ + tokenMap: { 'space.externalGap': '1rem' },\n\ + variableMap: {},\n\ + },\n\ + };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + + let canonical_dir = fs::canonicalize(&dir).expect("canonicalize scratch dir"); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("system with a kit theme in the graph must load"); + let manifests = config + .source_theme_manifests + .expect("built-theme export must be captured"); + let parsed: serde_json::Value = + serde_json::from_str(&manifests).expect("manifests JSON parses"); + let kit_path = canonical_dir + .join("kit/index.ts") + .to_string_lossy() + .to_string(); + assert_eq!( + parsed[&kit_path]["kitTokens"], + serde_json::json!(["space.externalGap"]), + "kit module must contribute its token paths: {parsed}" + ); + } + + #[test] + fn source_theme_manifests_capture_bundle_only_exports() { + // A kit whose ONLY export is the library bundle (`{ system, tokens }`) + // carries its built theme at `bundle.tokens.manifest` — the capture + // must probe the tokens half (bundle recognized exactly as the + // builders do: `system.toConfig` callable) or the correlation + // diagnostic silently loses its source-token witness. + let dir = scratch_dir("bundle-theme-manifests"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "import { kit } from './kit/index';\n\ + export const kitRef = kit;\n\ + export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + write_fixture( + &dir.join("kit/index.ts"), + "export const kit = {\n\ + system: { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) },\n\ + tokens: {\n\ + colors: { externalAccent: '#f0f' },\n\ + manifest: { variableMap: { 'colors.externalAccent': '--color-external-accent' } },\n\ + },\n\ + };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + + let canonical_dir = fs::canonicalize(&dir).expect("canonicalize scratch dir"); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("system with a bundle-only kit must load"); + let manifests = config + .source_theme_manifests + .expect("bundle tokens half must be captured"); + let parsed: serde_json::Value = + serde_json::from_str(&manifests).expect("manifests JSON parses"); + let kit_path = canonical_dir + .join("kit/index.ts") + .to_string_lossy() + .to_string(); + assert_eq!( + parsed[&kit_path]["kit"], + serde_json::json!(["colors.externalAccent"]), + "bundle export must contribute its tokens half's paths: {parsed}" + ); + } + + #[test] + fn source_theme_manifests_capture_bundle_theme_spelling() { + // first-class-extension (D9/D11): the canonical library bundle is + // `{ system, theme }` (`tokens` is the legacy spelling). A kit whose + // only export is a theme-spelled bundle must still contribute its + // source-token witness or cross-source correlation silently degrades. + let dir = scratch_dir("bundle-theme-spelling"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "import { kit } from './kit/index';\n\ + export const kitRef = kit;\n\ + export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + write_fixture( + &dir.join("kit/index.ts"), + "export const kit = {\n\ + system: { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) },\n\ + theme: {\n\ + colors: { externalAccent: '#f0f' },\n\ + manifest: { variableMap: { 'colors.externalAccent': '--color-external-accent' } },\n\ + },\n\ + };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + + let canonical_dir = fs::canonicalize(&dir).expect("canonicalize scratch dir"); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("system with a theme-spelled bundle kit must load"); + let manifests = config + .source_theme_manifests + .expect("bundle theme half must be captured"); + let parsed: serde_json::Value = + serde_json::from_str(&manifests).expect("manifests JSON parses"); + let kit_path = canonical_dir + .join("kit/index.ts") + .to_string_lossy() + .to_string(); + assert_eq!( + parsed[&kit_path]["kit"], + serde_json::json!(["colors.externalAccent"]), + "theme-spelled bundle must contribute its theme half's paths: {parsed}" + ); + } + + #[test] + fn asset_placeholder_survives_the_loader_round_trip() { + // standardize-inheritance-and-assets (rust-system-loader delta): an + // `asset()` placeholder inside a global style block's fontFaces + // serializes through evaluation with its specifier bytes intact and + // WITHOUT any resolution attempt — the scratch dir contains no such + // file, and the load must not care. + let dir = scratch_dir("asset-placeholder"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "const asset = (specifier: string) => 'animus-asset:' + specifier;\n\ + export const globals = {\n\ + __brand: 'GlobalStyleBlock',\n\ + styles: { body: { margin: 0 } },\n\ + fontFaces: [{\n\ + family: 'Inter',\n\ + src: [{ url: asset('@acme/tokens/fonts/inter.woff2'), format: 'woff2' }],\n\ + }],\n\ + };\n\ + export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("system with an asset placeholder must load"); + let blocks = config + .global_style_blocks + .expect("global style block captured"); + assert!( + blocks.contains("animus-asset:@acme/tokens/fonts/inter.woff2"), + "placeholder must survive serialization verbatim: {blocks}" + ); + } + + #[test] + fn source_theme_manifests_absent_without_built_theme_exports() { + let dir = scratch_dir("no-theme-manifests"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("plain system must load"); + assert!( + config.source_theme_manifests.is_none(), + "no built-theme export → no captured manifests" + ); + } + + #[test] + fn theme_export_preferred_over_unrelated_tokens() { + // first-class-extension (rust-system-loader delta, D9): 'theme' is the + // preferred export name; an unrelated 'tokens' value that is not a + // built theme must not shadow it. + let dir = scratch_dir("theme-preferred"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const theme = {\n\ + serialize: () => ({\n\ + scalesJson: '{\"winner\":\"theme\"}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const tokens = { color: 'red' };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("'theme' beside a non-theme 'tokens' must load"); + assert!( + config.scales_json.contains("\"winner\":\"theme\""), + "the 'theme' export must be the serialized one: {}", + config.scales_json + ); + } + + #[test] + fn tokens_only_export_stays_supported() { + // first-class-extension (D9): 'tokens' stays fully supported when no + // 'theme' export exists — the fallback carries no deprecation failure. + let dir = scratch_dir("tokens-fallback"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{\"winner\":\"tokens\"}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("a tokens-only system must load"); + assert!( + config.scales_json.contains("\"winner\":\"tokens\""), + "the 'tokens' export must be the serialized one: {}", + config.scales_json + ); + } + + #[test] + fn built_tokens_export_wins_when_theme_is_unrelated() { + let dir = scratch_dir("tokens-beside-unrelated-theme"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const theme = { color: 'red' };\n\ + export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{\"winner\":\"tokens\"}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let config = result.expect("built tokens beside unrelated theme must load"); + assert!( + config.scales_json.contains("\"winner\":\"tokens\""), + "the built tokens export must be serialized: {}", + config.scales_json + ); + } + + #[test] + fn un_built_theme_export_fails_naming_the_forgotten_build() { + // A ThemeBuilder mistakenly exported without its trailing .build(): + // callable build/addScale, no serialize. The load must DIAGNOSE the + // near-miss, not claim no export exists. + let dir = scratch_dir("theme-unbuilt"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const theme = {\n\ + build: () => ({}),\n\ + addScale: () => ({}),\n\ + addColors: () => ({}),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let err = result.expect_err("an un-built 'theme' export must fail the load"); + assert!( + err.contains(".build()") && err.contains("'theme'"), + "error must name the export and the forgotten .build(): {}", + err + ); + } + + #[test] + fn un_built_theme_export_never_falls_back_to_stale_tokens() { + // The migration-window hazard: `theme` is canonical, and an author + // editing it without .build() must not have the extractor silently + // use a legacy `tokens` export they did not touch. + let dir = scratch_dir("theme-unbuilt-stale-tokens"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const theme = {\n\ + build: () => ({}),\n\ + addScale: () => ({}),\n\ + };\n\ + export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{\"winner\":\"STALE_TOKENS\"}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let err = + result.expect_err("an un-built 'theme' beside built 'tokens' must fail, not fall back"); + assert!( + err.contains(".build()"), + "error must point at the forgotten .build(): {}", + err + ); + } + + #[test] + fn un_built_tokens_export_fails_naming_the_forgotten_build() { + let dir = scratch_dir("tokens-unbuilt"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const tokens = {\n\ + build: () => ({}),\n\ + addScale: () => ({}),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let err = result.expect_err("an un-built 'tokens' export must fail the load"); + assert!( + err.contains(".build()") && err.contains("'tokens'"), + "error must name the export and the forgotten .build(): {}", + err + ); + } + + #[test] + fn non_theme_export_error_names_what_was_found() { + // An unrelated `theme` object with NO tokens fallback: the error must + // acknowledge the export it saw instead of denying any export exists. + let dir = scratch_dir("theme-unrelated-no-tokens"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const theme = { color: 'red' };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let err = result.expect_err("an unrelated 'theme' with no fallback must fail the load"); + assert!( + err.contains("'theme'") && err.contains("serialize"), + "error must name the export it found and the missing .serialize(): {}", + err + ); + } + + #[test] + fn aliased_theme_and_tokens_export_stays_valid() { + // Same-object aliasing (`export const tokens = theme`) is not a + // conflict — identity is judged by reference equality in the QuickJS + // context, never by comparing serialized output. + let dir = scratch_dir("theme-alias"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const theme = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const tokens = theme;\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + result.expect("aliasing 'tokens' to 'theme' must stay a valid load"); + } + + #[test] + fn distinct_built_theme_exports_fail_naming_both() { + // Two distinct built themes in the entry module make the serialized + // winner ambiguous — the load must fail with a diagnostic naming both + // exports, even when their serialized output would be identical. + let dir = scratch_dir("theme-conflict"); + let entry = dir.join("entry.ts"); + write_fixture( + &entry, + "export const theme = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const tokens = {\n\ + serialize: () => ({\n\ + scalesJson: '{}',\n\ + variableMapJson: '{}',\n\ + variableCss: '',\n\ + contextualVarsJson: '{}',\n\ + }),\n\ + };\n\ + export const system = { toConfig: () => ({ propConfig: '{}', groupRegistry: '{}' }) };\n", + ); + + let result = load_system_module(&entry.to_string_lossy(), &dir.to_string_lossy(), None); + let _ = fs::remove_dir_all(&dir); + + let error = result.expect_err("two distinct built themes must fail the load"); + assert!( + error.contains("'theme'") && error.contains("'tokens'"), + "diagnostic must name both exports: {error}" + ); + assert!( + error.contains("built themes"), + "diagnostic must say what the conflict is: {error}" + ); + } + + #[test] + fn asset_query_import_fails_naming_specifier_and_fix() { + let dir = scratch_dir("asset-query"); + let entry = dir.join("entry.ts"); + fs::write(dir.join("font.woff2"), b"wOF2FAKE").unwrap(); + write_fixture( + &entry, + "import fontUrl from './font.woff2?url';\n\ + export const value = fontUrl;\n", + ); + + let result = resolve_all_deps(&entry.to_string_lossy(), &dir.to_string_lossy()); + let _ = fs::remove_dir_all(&dir); + + let error = result.expect_err("a bundler asset-query import must fail the load"); + assert!( + error.contains("./font.woff2?url") && error.contains("literal"), + "error must name the specifier and point at the literal-URL fix: {error}" + ); + assert!( + error.contains("asset('')"), + "error must point at the sanctioned asset() form: {error}" + ); + assert!( + error.contains("entry.ts"), + "error must name the importing module: {error}" + ); + } + + #[test] + fn binary_asset_import_fails_naming_specifier() { + let dir = scratch_dir("asset-ext"); + let entry = dir.join("entry.ts"); + fs::write(dir.join("font.woff2"), b"wOF2FAKE").unwrap(); + write_fixture( + &entry, + "import fontUrl from './font.woff2';\n\ + export const value = fontUrl;\n", + ); + + let result = resolve_all_deps(&entry.to_string_lossy(), &dir.to_string_lossy()); + let _ = fs::remove_dir_all(&dir); + + let error = result.expect_err("a binary asset import must fail the load"); + assert!( + error.contains("./font.woff2"), + "error must name the specifier: {error}" + ); + } + #[test] fn node_builtin_import_fails_with_sandbox_reason() { let dir = scratch_dir("node-builtin"); diff --git a/packages/extract/pipeline/analyze-project-args.ts b/packages/extract/pipeline/analyze-project-args.ts index 92cba482..8236205f 100644 --- a/packages/extract/pipeline/analyze-project-args.ts +++ b/packages/extract/pipeline/analyze-project-args.ts @@ -1,7 +1,7 @@ /** * The positional argument tuple for the NAPI `analyzeProject` call. * - * This is the single authoritative copy of the 14-slot contract consumed by + * This is the single authoritative copy of the 17-slot contract consumed by * both extraction plugins (vite-plugin and next-plugin). The slot order is * mirrored by the Rust NAPI surface — changing it requires a coordinated * Rust-side update. @@ -27,6 +27,10 @@ export type AnalyzeProjectArgs = [ // Appended slot (modern-css-surface inc 03): condition alias map JSON. // Appended (not inserted mid-tuple) so existing slot positions are stable. conditionAliasesJson: string | null, + // Appended slot (standardize-inheritance-and-assets inc 02): rootDir- + // relative external package dirs (JSON string array) for the + // external-token candidate walk. + externalDirsJson: string | null, ]; /** @internal */ @@ -48,6 +52,9 @@ export interface AnalyzeProjectInputs { staticCssJson: string | null; /** Condition alias map JSON (modern-css-surface inc 03), or null. */ conditionAliasesJson: string | null; + /** rootDir-relative external package dirs (JSON string array) for the + * external-token candidate walk, or null. */ + externalDirsJson: string | null; } /** @internal */ @@ -71,5 +78,6 @@ export function buildAnalyzeProjectArgs( inputs.keyframesJson, inputs.staticCssJson, inputs.conditionAliasesJson, + inputs.externalDirsJson, ]; } diff --git a/packages/extract/pipeline/asset-placeholders.ts b/packages/extract/pipeline/asset-placeholders.ts new file mode 100644 index 00000000..992b1612 --- /dev/null +++ b/packages/extract/pipeline/asset-placeholders.ts @@ -0,0 +1,84 @@ +/** + * Host-side handling of `asset()` placeholders (global-styles-system): + * `animus-asset:` markers ride through the sandbox and the + * emitter byte-exact; each HOST plugin substitutes them with its bundler's + * resolved asset URL after extraction. This module owns the shared scanning + * and replacement mechanics — resolution and strict gating stay at the + * plugin call sites. + */ + +/** Mirrors the producer-side constant in `@animus-ui/system`'s `asset.ts` + * (extract must not take a runtime dependency on system); the pairing is + * pinned by a contract test in `tests/asset-placeholders.test.ts`. */ +export const ASSET_PLACEHOLDER_PREFIX = 'animus-asset:'; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const PREFIX_RE = escapeRegExp(ASSET_PLACEHOLDER_PREFIX); + +/** + * Quoted `url('animus-asset:')` form — the emitter always quotes + * `url()` values, so a quoted match carries the FULL specifier, including + * whitespace and parentheses (D5 pins the placeholder as specifier-verbatim, + * so scanning fidelity is the only place such characters can be honored). + */ +const QUOTED_PLACEHOLDER_RE = new RegExp(`(['"])${PREFIX_RE}([^'"]*?)\\1`, 'g'); + +/** Bare-form fallback: a placeholder up to CSS url()/quote/whitespace. */ +const BARE_PLACEHOLDER_RE = new RegExp(`${PREFIX_RE}([^'")\\s]+)`, 'g'); + +/** Unique asset specifiers referenced by placeholders, in appearance order. */ +export function findAssetSpecifiers(css: string): string[] { + // Overwhelmingly common case (no asset() usage at all): one substring + // scan instead of two regex traversals, on every dev analysis pass. + if (!css.includes(ASSET_PLACEHOLDER_PREFIX)) return []; + const seen = new Set(); + // Blank out quoted matches before the bare scan so the truncated tail of + // a quoted specifier is never reported as its own (bogus) specifier. + const remainder = css.replace( + QUOTED_PLACEHOLDER_RE, + (_match, _quote, specifier: string) => { + seen.add(specifier); + return ''; + } + ); + for (const match of remainder.matchAll(BARE_PLACEHOLDER_RE)) { + seen.add(match[1]); + } + return [...seen]; +} + +/** + * Replace each specifier's placeholder with its substitution. Specifiers + * absent from the map keep their placeholder — the caller decides whether + * that is a strict failure or a warn-and-emit-literally. A substitution may + * itself be a bundler marker (e.g. Vite's `__VITE_ASSET____`) that the + * host's own asset pipeline resolves to a hashed file name later. + * + * Each replacement is anchored to a following delimiter (quote, `)`, + * whitespace, or end of input) so a specifier that textually prefixes a + * longer one can never clobber the longer placeholder; longest-first + * ordering additionally lets the longer of two mapped specifiers claim its + * occurrences before the shorter runs. + */ +export function substituteAssetPlaceholders( + css: string, + urlBySpecifier: ReadonlyMap +): string { + if (urlBySpecifier.size === 0) return css; + if (!css.includes(ASSET_PLACEHOLDER_PREFIX)) return css; + const specifiers = [...urlBySpecifier.keys()].sort( + (a, b) => b.length - a.length + ); + let out = css; + for (const specifier of specifiers) { + const placeholder = new RegExp( + escapeRegExp(ASSET_PLACEHOLDER_PREFIX + specifier) + + String.raw`(?=['")\s]|$)`, + 'g' + ); + out = out.replace(placeholder, () => urlBySpecifier.get(specifier)!); + } + return out; +} diff --git a/packages/extract/pipeline/content-hash.ts b/packages/extract/pipeline/content-hash.ts index b4731dcb..51ce00df 100644 --- a/packages/extract/pipeline/content-hash.ts +++ b/packages/extract/pipeline/content-hash.ts @@ -2,9 +2,11 @@ import { createHash } from 'crypto'; /** * MD5 content hash used for file-change detection (HMR diffing) by both - * extraction plugins. The algorithm/encoding is a cross-plugin contract: - * cache keys written by one build path must compare equal in the next. + * extraction plugins, and for content-addressing copied asset bytes. The + * algorithm/encoding is a cross-plugin contract: cache keys written by one + * build path must compare equal in the next. Accepts a Buffer directly so + * binary sources hash without an intermediate string copy. */ -export function contentHash(source: string): string { +export function contentHash(source: string | Buffer): string { return createHash('md5').update(source).digest('hex'); } diff --git a/packages/extract/pipeline/correlate-external-tokens.ts b/packages/extract/pipeline/correlate-external-tokens.ts new file mode 100644 index 00000000..727fb4c7 --- /dev/null +++ b/packages/extract/pipeline/correlate-external-tokens.ts @@ -0,0 +1,185 @@ +import { existsSync, realpathSync } from 'fs'; +import { dirname, join, sep } from 'path'; + +import type { ManifestDiagnostic } from './manifest-diagnostics'; + +/** + * Cross-source token correlation (extraction-diagnostics): join the engine's + * `external-token-candidate` diagnostics against (a) file→specifier ownership + * from collection and (b) the source packages' own token manifests captured + * by the loader. A candidate only becomes a finding when the SOURCE package + * defines the token — that witness is what keeps CSS literals (`color: + * 'red'`) silent while naming the exact missing `createTheme().extend(...)` + * inheritance for real kit tokens. + */ + +/** Loader capture shape: `{ modulePath: { exportName: [token paths] } }`. */ +type SourceThemeManifests = Record>; + +/** + * Index the loader-captured theme manifests by owning specifier. Module paths + * are canonical absolute paths (symlinks resolved), so each package dir is + * realpath'd before prefix-matching; a module outside every known package dir + * (typically the consumer's own theme) contributes nothing. + */ +export function buildSourceTokenIndex(opts: { + sourceThemeManifestsJson: string | null | undefined; + /** Absolute package dir → owning specifier (collection `dirOwners`). */ + dirOwners: Record; +}): Map> { + const index = new Map>(); + if (!opts.sourceThemeManifestsJson) return index; + + let manifests: SourceThemeManifests; + try { + manifests = JSON.parse(opts.sourceThemeManifestsJson); + } catch { + return index; + } + + const realDirOwners: Array<{ dir: string; specifier: string }> = []; + const addOwnerDir = (dir: string, specifier: string): void => { + // First registration wins, matching collection's `??=` convention. + if (!realDirOwners.some((entry) => entry.dir === dir)) { + realDirOwners.push({ dir, specifier }); + } + }; + for (const [dir, specifier] of Object.entries(opts.dirOwners)) { + let real = dir; + try { + real = realpathSync(dir); + } catch { + // Keep the declared path — prefix matching simply may not hit. + } + addOwnerDir(real, specifier); + + // Collection keys ownership by the src/ dir (or the entry's own dir), + // but the loader resolves the same specifier through the exports map — + // its canonical module paths live under dist/. Join the two tree halves + // at the PACKAGE boundary: any module under the owning package's root + // belongs to the specifier. The walk starts at the owner dir itself and + // only registers a root that actually carries a package.json, so a + // missing dir can never escalate to the filesystem root and claim every + // module. + let packageRoot = real; + while ( + packageRoot !== dirname(packageRoot) && + !existsSync(join(packageRoot, 'package.json')) + ) { + packageRoot = dirname(packageRoot); + } + if (existsSync(join(packageRoot, 'package.json'))) { + addOwnerDir(packageRoot, specifier); + } + } + + for (const [modulePath, exports] of Object.entries(manifests)) { + const owner = realDirOwners.find( + ({ dir }) => modulePath === dir || modulePath.startsWith(dir + sep) + ); + if (!owner) continue; + let tokens = index.get(owner.specifier); + if (!tokens) { + tokens = new Set(); + index.set(owner.specifier, tokens); + } + for (const paths of Object.values(exports)) { + for (const token of paths) tokens.add(token); + } + } + + return index; +} + +/** + * The correlation join. Returns one teaching-error message per distinct + * (component, token, specifier) whose file belongs to a discovered source AND + * whose token that source's manifest defines. The messages follow the + * standard severity routing at the CALLER (throw under `strict`, warn + * otherwise) — this join never reports on its own. + */ +export function correlateExternalTokenDiagnostics(opts: { + diagnostics: ManifestDiagnostic[] | undefined; + /** rootDir-relative file path → owning specifier (collection `fileOwners`). */ + fileOwners: Record; + /** specifier → token paths the source itself defines. */ + sourceTokens: Map>; +}): string[] { + const messages: string[] = []; + const seen = new Set(); + + for (const diagnostic of opts.diagnostics ?? []) { + if (diagnostic.kind !== 'external-token-candidate' || !diagnostic.token) { + continue; + } + const specifier = opts.fileOwners[diagnostic.file]; + if (!specifier) continue; + if (!opts.sourceTokens.get(specifier)?.has(diagnostic.token)) continue; + + const key = `${diagnostic.component}\u0000${diagnostic.token}\u0000${specifier}`; + if (seen.has(key)) continue; + seen.add(key); + + messages.push( + `${diagnostic.component} (from '${specifier}') references token ` + + `'${diagnostic.token}', which the consumer theme does not define — ` + + `inherit the source's tokens with createTheme().extend(...) using the ` + + `tokens (or bundle) export of '${specifier}'` + ); + } + + return messages; +} + +// The source-token index is invariant between system loads / package +// collections but the gate runs on every analysis pass — memoize per +// dirOwners object (hosts allocate a fresh one per collection), revalidated +// against the manifests JSON (a system reload mints a new string). +const indexCache = new WeakMap< + object, + { manifestsJson: string; index: Map> } +>(); + +/** + * The complete cross-source token-contract gate both plugins share + * (extraction-diagnostics): index the loader-captured source manifests, + * join them against the engine's candidates, and route the resulting + * teaching errors — throw under `strict`, warn otherwise. One entry point + * so the two hosts cannot drift on wiring or severity semantics. + */ +export function enforceExternalTokenContracts(opts: { + diagnostics: ManifestDiagnostic[] | undefined; + /** rootDir-relative file path → owning specifier (collection `fileOwners`). */ + fileOwners: Record; + /** Absolute package dir → owning specifier (collection `dirOwners`). */ + dirOwners: Record; + sourceThemeManifestsJson: string | null | undefined; + strict: boolean | undefined; + /** Host log prefix, e.g. `[animus-extract]`. */ + prefix: string; + warn: (message: string) => void; +}): void { + const manifestsJson = opts.sourceThemeManifestsJson ?? ''; + const cached = indexCache.get(opts.dirOwners); + let sourceTokens: Map>; + if (cached && cached.manifestsJson === manifestsJson) { + sourceTokens = cached.index; + } else { + sourceTokens = buildSourceTokenIndex({ + sourceThemeManifestsJson: opts.sourceThemeManifestsJson, + dirOwners: opts.dirOwners, + }); + indexCache.set(opts.dirOwners, { manifestsJson, index: sourceTokens }); + } + + const messages = correlateExternalTokenDiagnostics({ + diagnostics: opts.diagnostics, + fileOwners: opts.fileOwners, + sourceTokens, + }); + if (messages.length === 0) return; + if (opts.strict) { + throw new Error(`${opts.prefix} ${messages.join('\n')}`); + } + for (const message of messages) opts.warn(message); +} diff --git a/packages/extract/pipeline/discover-packages.ts b/packages/extract/pipeline/discover-packages.ts index f46fc57e..5673a109 100644 --- a/packages/extract/pipeline/discover-packages.ts +++ b/packages/extract/pipeline/discover-packages.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, statSync } from 'fs'; -import { dirname, isAbsolute, join, relative, resolve } from 'path'; +import { dirname, extname, isAbsolute, join, relative, resolve } from 'path'; import { discoverFiles } from './discover-files'; @@ -33,6 +33,15 @@ export const PACKAGE_SRC_EXCLUDES = [ '.spec.', ]; +/** Exclusions for the compiled-output fallback when a package does not ship src/. */ +const PACKAGE_OUTPUT_EXCLUDES = [ + 'node_modules', + '.test.', + '.spec.', + '.d.ts', + '.map', +]; + /** * What one declared include specifier produced, so callers can tell a package * that contributed sources apart from one that silently contributed nothing. @@ -45,8 +54,11 @@ export interface ExternalPackageOutcome { * - `resolved` — resolved to a package and accounted for at least one source * - `unresolvable` — specifier could not be resolved (skipped, per spec) * - `empty` — resolved to a package root but accounted for no sources + * - `stale-dist` — resolved with a walked src/ tree whose newest source file + * is newer than the resolved dist entry file (first-class-extension D13: + * a merge would consume registry content the sources no longer match) */ - outcome: 'resolved' | 'unresolvable' | 'empty'; + outcome: 'resolved' | 'unresolvable' | 'empty' | 'stale-dist'; /** * Source files this specifier accounted for in the analysis set: files it * contributed, plus files a previous specifier or the caller's own file set @@ -84,6 +96,62 @@ function resolveAbsolutePathSpecifier( return candidates.find(isFile) ?? null; } +/** The package-name half of a bare specifier (`@scope/name/sub` → `@scope/name`). */ +function bareSpecifierPackageName(specifier: string): string { + const segments = specifier.split('/'); + return specifier.startsWith('@') + ? segments.slice(0, 2).join('/') + : segments[0]; +} + +function sourceEntryForSpecifier( + specifier: string, + srcDir: string, + extensionsSet: ReadonlySet +): string | null { + if (isAbsolute(specifier)) { + const resolved = resolveAbsolutePathSpecifier(specifier, extensionsSet); + if (!resolved) return null; + const inSource = relative(srcDir, resolved); + return !inSource.startsWith('..') ? resolved : null; + } + const packageName = bareSpecifierPackageName(specifier); + const subpath = specifier.slice(packageName.length + 1); + const sourceStem = join(srcDir, subpath || 'index'); + return resolveAbsolutePathSpecifier(sourceStem, extensionsSet); +} + +/** + * The D13 dist-freshness check: applicable only when a specifier has BOTH a + * walked src/ tree and a resolved dist entry that exists on disk outside that + * tree; stale when the dist entry's mtime is older than the newest walked + * source file's. Not applicable (never stale) when the entry resolves inside + * src/ (no dist to skew), when the entry file is missing, or when no source + * file's mtime is readable. Detection only — reporting is the caller's policy. + */ +function distEntryIsStale( + absEntry: string, + srcDir: string, + srcFiles: string[] +): boolean { + if (!relative(srcDir, absEntry).startsWith('..')) return false; + let distMtime: number; + try { + distMtime = statSync(absEntry).mtimeMs; + } catch { + return false; + } + let newestSrcMtime = -Infinity; + for (const srcFile of srcFiles) { + try { + newestSrcMtime = Math.max(newestSrcMtime, statSync(srcFile).mtimeMs); + } catch { + // An unreadable source file cannot witness staleness. + } + } + return distMtime < newestSrcMtime; +} + export interface CollectedExternalPackages { /** New file entries (rootDir-relative, preprocessed) for the analysis set. */ entries: Array<{ path: string; source: string }>; @@ -93,6 +161,12 @@ export interface CollectedExternalPackages { sourceEntries: Map; /** Absolute directories for bundler loader allowlisting (src/ or dist entry dir). */ packageDirs: string[]; + /** Absolute package dir → owning specifier (cross-source correlation). */ + dirOwners: Record; + /** rootDir-relative file path → owning specifier, for files THIS collection + * pushed (first-contributing specifier wins; files the caller's own set + * already supplied stay unattributed — they are consumer-owned). */ + fileOwners: Record; /** One record per declared specifier, in declaration order. */ outcomes: ExternalPackageOutcome[]; } @@ -152,6 +226,8 @@ export async function collectExternalPackageSources(opts: { const packageMap: Record = {}; const sourceEntries = new Map(); const packageDirs: string[] = []; + const dirOwners: Record = {}; + const fileOwners: Record = {}; const outcomes: ExternalPackageOutcome[] = []; const alreadyIngested = (relPath: string): boolean => @@ -180,19 +256,50 @@ export async function collectExternalPackageSources(opts: { const pkgRoot = findPackageRoot(absEntry); const srcDir = join(pkgRoot, 'src'); let fileCount = 0; + let staleDist = false; if (existsSync(srcDir)) { packageDirs.push(srcDir); - - // Redirect module resolution to the source entry when present - const srcEntry = join(srcDir, 'index.ts'); - if (existsSync(srcEntry)) { + dirOwners[srcDir] ??= specifier; + + // Redirect module resolution to the matching source entry. A declared + // package subpath such as `/definition` must not silently become the + // package root's `src/index.ts`. + const srcEntry = sourceEntryForSpecifier( + specifier, + srcDir, + extensionsSet + ); + if (srcEntry) { packageMap[specifier] = relative(rootDir, srcEntry); sourceEntries.set(specifier, srcEntry); } else { packageMap[specifier] = relative(rootDir, absEntry); } + // A kit declared at a subpath is routinely imported at its package + // ROOT by app code (`import { Card } from '@scope/kit'`), and a root + // key absent here bypasses every host's src redirect — the app then + // bundles untransformed dist chains that render unstyled. Register + // the derived root alias alongside the declared subpath when the + // package can serve it from src/; a declared root specifier's own + // pass still wins (guard for earlier, assignment above for later), + // and the alias adds no outcome record — it was never declared. + if (!isAbsolute(specifier)) { + const packageName = bareSpecifierPackageName(specifier); + if (packageName !== specifier && !(packageName in packageMap)) { + const rootEntry = sourceEntryForSpecifier( + packageName, + srcDir, + extensionsSet + ); + if (rootEntry) { + packageMap[packageName] = relative(rootDir, rootEntry); + sourceEntries.set(packageName, rootEntry); + } + } + } + // Discover with no patterns, then exclude by package-relative path so // fragments in the package's own location can't blank out its sources. const pkgFiles = discoverFiles(srcDir, srcDir, [], extensionsSet).filter( @@ -204,6 +311,9 @@ export async function collectExternalPackageSources(opts: { } ); + // D13 freshness gate over the already-walked file list (no second walk). + staleDist = distEntryIsStale(absEntry, srcDir, pkgFiles); + for (const pkgFile of pkgFiles) { const relPath = relative(rootDir, pkgFile); if (alreadyIngested(relPath)) { @@ -224,54 +334,107 @@ export async function collectExternalPackageSources(opts: { if (!processed) continue; entries.push({ path: processed.relPath, source: processed.source }); pushed.add(processed.relPath); + fileOwners[processed.relPath] ??= specifier; fileCount++; } } else { - // No src/ — fall back to the resolved (dist) entry file itself, - // exempt from extension filters (spec: npm-installed scenario). - packageDirs.push(dirname(absEntry)); + // No src/ — walk the compiled output beside the resolved entry. A + // definition-only entry cannot carry component call sites by itself, + // and dist-only npm packages are the normal publication shape. Always + // admit the entry's own extension even when the consumer customized its + // source-extension list (the previous single-entry fallback was exempt + // from that filter too). + const outputDir = dirname(absEntry); + packageDirs.push(outputDir); + dirOwners[outputDir] ??= specifier; const relPath = relative(rootDir, absEntry); packageMap[specifier] = relPath; - if (alreadyIngested(relPath)) { - fileCount++; - } else { - try { - const source = readFileSync(absEntry, 'utf-8'); - entries.push({ path: relPath, source }); - pushed.add(relPath); + const outputExtensions = new Set(extensionsSet); + outputExtensions.add(extname(absEntry)); + // Excludes match relative to the OUTPUT dir (mirror of the src/ walk): + // dist-only packages normally live under node_modules, so matching the + // full path would exclude every file of exactly the packages this + // branch exists for. + const outputFiles = discoverFiles( + outputDir, + outputDir, + [], + outputExtensions + ).filter((file) => { + const relToOutput = relative(outputDir, file); + return !PACKAGE_OUTPUT_EXCLUDES.some((pattern) => + relToOutput.includes(pattern) + ); + }); + if (!outputFiles.includes(absEntry)) outputFiles.unshift(absEntry); + + for (const outputFile of outputFiles) { + const outputRelPath = relative(rootDir, outputFile); + if (alreadyIngested(outputRelPath)) { fileCount++; + continue; + } + let source: string; + try { + source = readFileSync(outputFile, 'utf-8'); } catch (err) { - onUnreadable(relPath, err); + onUnreadable(outputRelPath, err); + continue; } + const processed = await preprocessFile( + source, + outputRelPath, + outputFile + ); + if (!processed) continue; + entries.push({ path: processed.relPath, source: processed.source }); + pushed.add(processed.relPath); + fileOwners[processed.relPath] ??= specifier; + fileCount++; } } outcomes.push({ specifier, - outcome: fileCount > 0 ? 'resolved' : 'empty', + outcome: staleDist ? 'stale-dist' : fileCount > 0 ? 'resolved' : 'empty', fileCount, }); } - return { entries, packageMap, sourceEntries, packageDirs, outcomes }; + return { + entries, + packageMap, + sourceEntries, + packageDirs, + dirOwners, + fileOwners, + outcomes, + }; } /** - * Extract external DS package names from `includes` declarations in the system file. + * Extract external DS package names from inheritance declarations in the + * system file. * - * Supports two forms: - * - Primary (1.0+): `createSystem({ includes: [identifier, ...] })` constructor arg - * - Legacy: `.includes([identifier, ...])` chain method (RC migration fallback) + * Supports four forms: + * - Primary: `createSystem(...).extend(identifier)` chain calls + * (repeatable, mixable with `.from()` links; each call + * contributes one source; a library-bundle identifier + * traces its base import) + * - Deprecated chain: `createSystem(...).from(identifier)` chain calls + * - Deprecated alias: `createSystem({ includes: [identifier, ...] })` + * - Legacy: `.includes([identifier, ...])` chain method (RC + * migration fallback) * * For each identifier found, traces back to its import declaration and returns * the import specifier: a bare specifier normalized to its package name, a * relative specifier resolved against the system file's directory into an * absolute path (so a sibling package referenced by path contributes discovery - * too). Only packages explicitly declared via `includes` are treated as - * external DS dependencies. + * too). Only packages explicitly declared through one of these forms are + * treated as external DS dependencies. * - * Falls back to empty array if no `includes` declaration is found. + * Falls back to empty array if no declaration is found. */ /** * The message for the strict/warn gate over unresolvable includes, or null @@ -289,6 +452,23 @@ export function unresolvableIncludesMessage( return `[animus-extract] unresolvable include specifier(s): ${unresolvable.join(', ')}`; } +/** + * The message for the strict/warn gate over stale dist entries + * (first-class-extension D13), or null when no declared specifier is stale. + * A stale dist silently skews merged registry content while discovery + * compiles the fresh sources, so it surfaces like an unresolvable specifier: + * non-strict consumers warn with this line, strict consumers throw it. + */ +export function staleDistIncludesMessage( + outcomes: ExternalPackageOutcome[] +): string | null { + const stale = outcomes + .filter((record) => record.outcome === 'stale-dist') + .map((record) => record.specifier); + if (stale.length === 0) return null; + return `[animus-extract] stale dist for include specifier(s): ${stale.join(', ')} — dist entry is older than the newest src/ file; rebuild the package(s) before extracting`; +} + export function extractSystemFilePackages(systemFilePath: string): string[] { let source: string; try { @@ -299,7 +479,7 @@ export function extractSystemFilePackages(systemFilePath: string): string[] { const identifiers = new Set(); - // Primary form: createSystem({ includes: [...] }) — constructor arg + // Deprecated alias: createSystem({ includes: [...] }) — constructor arg // Non-greedy match on object body; captures identifiers inside the bracket list. const constructorRegex = /createSystem\s*\(\s*\{[^}]*?\bincludes\s*:\s*\[([^\]]*)\]/gs; @@ -323,6 +503,128 @@ export function extractSystemFilePackages(systemFilePath: string): string[] { collectIdentifiers(constructorRegex); collectIdentifiers(chainRegex); + // Primary form: createSystem(...).extend(a).from(b) — the extension chain + // (`.extend()` primary, `.from()` its deprecated spelling; links mix freely). + // Anchored to createSystem call chains: a bare `.extend(`/`.from(` match + // would also catch `createTheme().extend(...)` in the same file and wrongly + // grant a token-only package discovery membership (its component files would + // enter extraction). Same matcher family as the regexes above: the anchor + // skips the createSystem argument list with a paren-depth counter (no string + // awareness — the `[^}]*?` tolerance level), then consumes consecutive + // `.extend()` | `.from()` links; a + // bundle passed as `kit` or `kit.system` traces its base identifier's import + // either way. Trivia (whitespace + comments) is tolerated between every + // token, and a builder bound to an identifier is tracked so chains split + // across statements (`const base = createSystem(); ds = base.extend(k)`) + // still contribute — a link the scan cannot see is a kit whose CSS + // silently vanishes (outcomes derive only from the returned specifiers). + const IDENT_START_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*/; + + /** Position after any run of whitespace, line comments, block comments. */ + const skipTrivia = (from: number): number => { + let pos = from; + for (;;) { + while (pos < source.length && /\s/.test(source[pos])) pos++; + if (source.startsWith('//', pos)) { + const newline = source.indexOf('\n', pos); + pos = newline === -1 ? source.length : newline + 1; + continue; + } + if (source.startsWith('/*', pos)) { + const close = source.indexOf('*/', pos + 2); + pos = close === -1 ? source.length : close + 2; + continue; + } + return pos; + } + }; + + /** + * Consume consecutive extend/from links starting at `from`, collecting + * each link's base identifier; tolerates trivia between tokens and a + * trailing comma in the argument list. Returns the position after the + * last consumed link (`from` itself when none matched). + */ + const consumeChainLinks = (from: number): number => { + let pos = from; + for (;;) { + let cursor = skipTrivia(pos); + if (source[cursor] !== '.') return pos; + cursor = skipTrivia(cursor + 1); + const method = IDENT_START_RE.exec(source.slice(cursor))?.[0]; + if (method !== 'extend' && method !== 'from') return pos; + cursor = skipTrivia(cursor + method.length); + if (source[cursor] !== '(') return pos; + cursor = skipTrivia(cursor + 1); + const base = IDENT_START_RE.exec(source.slice(cursor))?.[0]; + if (!base) return pos; + cursor = skipTrivia(cursor + base.length); + while (source[cursor] === '.') { + const afterDot = skipTrivia(cursor + 1); + const segment = IDENT_START_RE.exec(source.slice(afterDot))?.[0]; + if (!segment) break; + cursor = skipTrivia(afterDot + segment.length); + } + if (source[cursor] === ',') cursor = skipTrivia(cursor + 1); + if (source[cursor] !== ')') return pos; + identifiers.add(base); + pos = cursor + 1; + } + }; + + /** Identifier assigned directly before `index` (`const x = `), if any. */ + const boundIdentifierBefore = (index: number): string | null => { + const match = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*$/.exec( + source.slice(0, index) + ); + return match ? match[1] : null; + }; + + // Identifiers bound (directly or transitively) to a createSystem builder + // chain. Seeded by direct `x = createSystem(...)` bindings; extended by + // the fixpoint scan below when a root's own chain is re-bound. + const chainRootIdentifiers = new Set(); + + const createSystemAnchor = /createSystem\s*\(/g; + let anchorMatch: RegExpExecArray | null; + while ((anchorMatch = createSystemAnchor.exec(source)) !== null) { + let pos = anchorMatch.index + anchorMatch[0].length; + let depth = 1; + while (pos < source.length && depth > 0) { + const ch = source[pos]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + pos++; + } + consumeChainLinks(pos); + const bound = boundIdentifierBefore(anchorMatch.index); + if (bound) chainRootIdentifiers.add(bound); + } + + // Fixpoint over statement-split chains: scanning a root can bind new + // roots (`const withA = base.extend(a)`), which can carry further links. + const scannedRoots = new Set(); + for (;;) { + const pendingRoots = [...chainRootIdentifiers].filter( + (root) => !scannedRoots.has(root) + ); + if (pendingRoots.length === 0) break; + for (const root of pendingRoots) { + scannedRoots.add(root); + const rootUse = new RegExp( + `(? string; transformFile: ( source: string, @@ -111,6 +112,7 @@ interface V2ExtractEngineConfig { packageResolutionJson?: string; pathAliasesJson?: string; staticCssJson?: string; + externalDirsJson?: string; devMode: boolean; } @@ -170,7 +172,8 @@ export function createV2EngineApi(deps: V2EngineAdapterDeps): () => EngineApi { pathAliasesJson, keyframesJson, staticCssJson, - conditionAliasesJson + conditionAliasesJson, + externalDirsJson ) => { const filesJson = deps.rehydrateFilesJson ? deps.rehydrateFilesJson(filesJsonRaw) @@ -218,6 +221,7 @@ export function createV2EngineApi(deps: V2EngineAdapterDeps): () => EngineApi { packageResolutionJson: packageResolutionJson ?? undefined, pathAliasesJson: pathAliasesJson ?? undefined, staticCssJson: staticCssJson ?? undefined, + externalDirsJson: externalDirsJson ?? undefined, devMode, }; const engine = new native.ExtractEngine(config) as V2ExtractEngine; diff --git a/packages/extract/pipeline/index.ts b/packages/extract/pipeline/index.ts index fcc08ad5..a2dfe993 100644 --- a/packages/extract/pipeline/index.ts +++ b/packages/extract/pipeline/index.ts @@ -35,8 +35,15 @@ export { collectExternalPackageSources, extractSystemFilePackages, findPackageRoot, + staleDistIncludesMessage, unresolvableIncludesMessage, } from './discover-packages'; +export { + findAssetSpecifiers, + substituteAssetPlaceholders, +} from './asset-placeholders'; +export { resolveAssetFile, resolveThroughPathAliases } from './resolve-asset'; +export { enforceExternalTokenContracts } from './correlate-external-tokens'; export { buildPathAliasesJson } from './path-aliases'; export type { LightningTargets } from './post-process-css'; export { postProcessCss, resolveLightningTargets } from './post-process-css'; diff --git a/packages/extract/pipeline/manifest-diagnostics.ts b/packages/extract/pipeline/manifest-diagnostics.ts index 46f21495..d54ccd31 100644 --- a/packages/extract/pipeline/manifest-diagnostics.ts +++ b/packages/extract/pipeline/manifest-diagnostics.ts @@ -3,6 +3,9 @@ export type ManifestDiagnostic = { component: string; kind: string; message: string; + /** Structured token path (`scale.key`) — present only on + * `external-token-candidate` diagnostics (cross-source correlation). */ + token?: string; }; /** diff --git a/packages/extract/pipeline/resolve-asset.ts b/packages/extract/pipeline/resolve-asset.ts new file mode 100644 index 00000000..c0432df8 --- /dev/null +++ b/packages/extract/pipeline/resolve-asset.ts @@ -0,0 +1,140 @@ +/** + * Node-side asset specifier resolution shared by the host plugins (the + * asset() contract, global-styles-system): host path aliases apply FIRST — + * an alias such as `@fonts` works in application modules, so + * `asset('@fonts/inter.woff2')` must resolve identically — then direct Node + * resolution, then the package-root fallback (exports maps rarely list + * asset subpaths). Bundler-native resolution (Vite's `this.resolve`) is + * still preferred where available; this resolver covers the paths that have + * no bundler hook (Next's session, Vite's dev re-analysis). + */ +import { existsSync } from 'fs'; +import { createRequire } from 'module'; +import { dirname, isAbsolute, join } from 'path'; + +import type { PathAliasEntry } from './path-aliases'; + +// One resolution context per root — `createRequire` builds a module system +// anchor with its own cache, so reconstructing it per call throws that +// cache away. +const requireByRoot = new Map>(); + +function requireAnchoredAt(rootDir: string): ReturnType { + let req = requireByRoot.get(rootDir); + if (!req) { + req = createRequire(join(rootDir, 'package.json')); + requireByRoot.set(rootDir, req); + } + return req; +} + +function packageRootFromEntry(entry: string): string | null { + let current = dirname(entry); + while (true) { + if (existsSync(join(current, 'package.json'))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +// Per-call memo: the alias JSON is a stable string per config lifecycle and +// resolution runs once per specifier, so parse each distinct table once. +const aliasTableCache = new Map(); + +function parseAliasTable(pathAliasesJson: string): PathAliasEntry[] { + const cached = aliasTableCache.get(pathAliasesJson); + if (cached) return cached; + let aliases: PathAliasEntry[]; + try { + aliases = + (JSON.parse(pathAliasesJson) as { aliases?: PathAliasEntry[] }).aliases ?? + []; + } catch { + aliases = []; + } + aliasTableCache.set(pathAliasesJson, aliases); + return aliases; +} + +/** + * Map a specifier through the harvested host alias table (the same + * `pathAliasesJson` wire the engine consumes — entries pre-sorted longest + * pattern first by `buildPathAliasesJson`). Returns an absolute path to an + * EXISTING file, or null when no alias claims the specifier. + */ +export function resolveThroughPathAliases( + specifier: string, + rootDir: string, + pathAliasesJson: string | null | undefined +): string | null { + if (!pathAliasesJson) return null; + const aliases = parseAliasTable(pathAliasesJson); + for (const alias of aliases) { + let mapped: string | null = null; + if (alias.type === 'exact') { + if (alias.pattern === specifier) mapped = alias.replacement; + } else if (specifier.startsWith(alias.pattern)) { + mapped = alias.replacement + specifier.slice(alias.pattern.length); + } + if (mapped === null) continue; + const absolute = isAbsolute(mapped) ? mapped : join(rootDir, mapped); + if (existsSync(absolute)) return absolute; + } + return null; +} + +/** + * Resolve an asset specifier to an absolute file: host aliases, then direct + * Node resolution anchored at `rootDir`, then the package-root fallback. + * Returns null when nothing matches — strict gating stays at the caller. + */ +export function resolveAssetFile( + specifier: string, + rootDir: string, + pathAliasesJson?: string | null +): string | null { + const aliased = resolveThroughPathAliases( + specifier, + rootDir, + pathAliasesJson + ); + if (aliased) return aliased; + + const requireFromRoot = requireAnchoredAt(rootDir); + try { + return requireFromRoot.resolve(specifier); + } catch { + // Asset subpaths are rarely listed in exports maps — fall through to + // package-root resolution. + } + + const segments = specifier.split('/'); + const packageName = specifier.startsWith('@') + ? segments.slice(0, 2).join('/') + : segments[0]; + const subpath = specifier.slice(packageName.length + 1); + if (!subpath) return null; + + // Locate the physical package directory through Node's module search + // paths before asking for an exported entry. This also supports packages + // that intentionally expose only subpaths and have no `"."` export. + for (const modulesDir of requireFromRoot.resolve.paths(packageName) ?? []) { + const packageRoot = join(modulesDir, packageName); + if (!existsSync(join(packageRoot, 'package.json'))) continue; + const candidate = join(packageRoot, subpath); + if (existsSync(candidate)) return candidate; + } + + try { + // Resolve an actually exported entry, then walk to its package root. + // `package.json` itself is commonly hidden by an exports map. + const packageEntry = requireFromRoot.resolve(packageName); + const packageRoot = packageRootFromEntry(packageEntry); + if (!packageRoot) return null; + const candidate = join(packageRoot, subpath); + return existsSync(candidate) ? candidate : null; + } catch { + return null; + } +} diff --git a/packages/extract/pipeline/run-analysis.ts b/packages/extract/pipeline/run-analysis.ts index 59094ef6..4e138663 100644 --- a/packages/extract/pipeline/run-analysis.ts +++ b/packages/extract/pipeline/run-analysis.ts @@ -39,6 +39,8 @@ export interface AnalysisOptions { pathAliasesJson: string | null; /** Serialized staticCss forced-emission declarations, or null. */ staticCssJson?: string | null; + /** rootDir-relative external package dirs (external-token candidates). */ + externalDirs?: string[]; devMode: boolean; } @@ -73,9 +75,24 @@ export function buildAnalysisInputs( keyframesJson: opts.system.keyframesJson, staticCssJson: opts.staticCssJson ?? null, conditionAliasesJson: opts.system.conditionAliasesJson ?? null, + // The external-token candidate walk exists solely to feed the TS-side + // correlation join, and that join can only report a candidate whose + // token a SOURCE theme manifest defines. With no captured manifests + // every candidate would be computed, serialized, and dropped — so the + // dirs are withheld and the engine skips the walk entirely. + externalDirsJson: + opts.externalDirs?.length && hasSourceThemeManifests(opts.system) + ? JSON.stringify(opts.externalDirs) + : null, }; } +/** Whether the loader captured at least one source built-theme manifest. */ +function hasSourceThemeManifests(system: SystemConfig): boolean { + const json = system.sourceThemeManifestsJson; + return typeof json === 'string' && json.length > 0 && json !== '{}'; +} + /** * The one analysis invocation both plugins share: build the emitter * config, serialize inputs, call the NAPI `analyzeProject`, parse the diff --git a/packages/extract/pipeline/system-config.ts b/packages/extract/pipeline/system-config.ts index 0e2bf481..836ff0c1 100644 --- a/packages/extract/pipeline/system-config.ts +++ b/packages/extract/pipeline/system-config.ts @@ -27,6 +27,11 @@ export interface SystemConfig { * as the geological-reset membership set. Optional so pre-load * `emptySystemConfig()` defaults need not restate it. */ dependencies?: string[]; + /** Per-module built-theme token manifests captured during evaluation + * (`{ modulePath: { exportName: [token paths] } }`) — the source-token + * witness for the cross-source correlation diagnostic. Null when no + * evaluated module exports a built theme. */ + sourceThemeManifestsJson?: string | null; } /** @@ -80,5 +85,6 @@ export function loadSystemConfig( globalStyleBlocksJson: config.globalStyleBlocks || null, keyframesJson: config.keyframesBlocks || null, dependencies: config.dependencies ?? [], + sourceThemeManifestsJson: config.sourceThemeManifests || null, }; } diff --git a/packages/extract/tests/asset-placeholders.test.ts b/packages/extract/tests/asset-placeholders.test.ts new file mode 100644 index 00000000..5fa72a27 --- /dev/null +++ b/packages/extract/tests/asset-placeholders.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'vitest'; + +import { + ASSET_PLACEHOLDER_PREFIX, + findAssetSpecifiers, + substituteAssetPlaceholders, +} from '../pipeline/asset-placeholders'; + +/** + * Shared asset() placeholder mechanics (global-styles-system). D5 pins the + * placeholder format as `animus-asset:` verbatim, so fidelity for + * specifiers carrying whitespace or parentheses lives entirely in the + * quoted-url scan and the delimiter-anchored substitution. + */ + +test('the scanner-side scheme matches the producer constant in @animus-ui/system', () => { + // packages/system/__tests__/global-styles-font-faces.test.ts pins the + // producer constant to the same literal — the two assertions together tie + // the wire format across the package boundary without extract taking a + // runtime dependency on system. + expect(ASSET_PLACEHOLDER_PREFIX).toBe('animus-asset:'); +}); + +describe('findAssetSpecifiers', () => { + test('quoted url() form carries the full specifier, whitespace and parens included', () => { + const css = + "@font-face { src: url('animus-asset:@acme/fonts/My Font(Regular).woff2') format('woff2'); }"; + expect(findAssetSpecifiers(css)).toEqual([ + '@acme/fonts/My Font(Regular).woff2', + ]); + }); + + test('the truncated tail of a quoted specifier is never a bogus extra specifier', () => { + const css = + "src: url('animus-asset:@acme/a b.woff2'); background: url('animus-asset:@acme/plain.woff2');"; + expect(findAssetSpecifiers(css).sort()).toEqual([ + '@acme/a b.woff2', + '@acme/plain.woff2', + ]); + }); + + test('bare unquoted form still scans up to CSS delimiters', () => { + const css = 'src: url(animus-asset:@acme/tokens/inter.woff2);'; + expect(findAssetSpecifiers(css)).toEqual(['@acme/tokens/inter.woff2']); + }); + + test('duplicate references dedupe', () => { + const css = + "url('animus-asset:@acme/x.woff2') url('animus-asset:@acme/x.woff2')"; + expect(findAssetSpecifiers(css)).toEqual(['@acme/x.woff2']); + }); +}); + +describe('substituteAssetPlaceholders', () => { + test('replaces whitespace/paren specifiers inside quotes', () => { + const css = "src: url('animus-asset:@acme/fonts/My Font(Regular).woff2');"; + const out = substituteAssetPlaceholders( + css, + new Map([ + ['@acme/fonts/My Font(Regular).woff2', '/assets/font-abc.woff2'], + ]) + ); + expect(out).toBe("src: url('/assets/font-abc.woff2');"); + }); + + test('a specifier that prefixes a longer one never clobbers it', () => { + const css = + "url('animus-asset:@acme/a.woff') url('animus-asset:@acme/a.woff2')"; + const out = substituteAssetPlaceholders( + css, + new Map([ + ['@acme/a.woff', '/short.woff'], + ['@acme/a.woff2', '/long.woff2'], + ]) + ); + expect(out).toBe("url('/short.woff') url('/long.woff2')"); + }); + + test('unmapped specifiers keep their placeholder for the caller to gate', () => { + const css = "url('animus-asset:@acme/unknown.woff2')"; + expect( + substituteAssetPlaceholders(css, new Map([['@acme/other.woff2', '/x']])) + ).toBe(css); + }); + + test('substitution values containing $ are inserted literally', () => { + const css = "url('animus-asset:@acme/x.woff2')"; + const out = substituteAssetPlaceholders( + css, + new Map([['@acme/x.woff2', "__VITE_ASSET__a$'b__"]]) + ); + expect(out).toBe("url('__VITE_ASSET__a$'b__')"); + }); +}); diff --git a/packages/extract/tests/collect-external-packages.test.ts b/packages/extract/tests/collect-external-packages.test.ts index b8ae0dc3..3224f3e9 100644 --- a/packages/extract/tests/collect-external-packages.test.ts +++ b/packages/extract/tests/collect-external-packages.test.ts @@ -1,10 +1,11 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join, relative } from 'path'; import { afterEach, describe, expect, test } from 'vitest'; import { collectExternalPackageSources, + staleDistIncludesMessage, unresolvableIncludesMessage, } from '../pipeline/discover-packages'; @@ -101,6 +102,88 @@ describe('collectExternalPackageSources', () => { expect(result.sourceEntries.size).toBe(1); }); + test('redirects a package export subpath to its matching source module', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/index.ts': 'export const root = 1;', + 'src/definition.ts': 'export const system = 1;', + 'dist/definition.mjs': 'export const system = 1;', + }); + + const result = await collect(root, { + '@x/ds/definition': join(pkg, 'dist', 'definition.mjs'), + }); + + expect(result.packageMap).toEqual({ + '@x/ds/definition': 'packages/ds/src/definition.ts', + // Derived root alias — see the dedicated subpath/root-alias tests. + '@x/ds': 'packages/ds/src/index.ts', + }); + expect(result.sourceEntries.get('@x/ds/definition')).toBe( + join(pkg, 'src', 'definition.ts') + ); + }); + + test('a subpath specifier also registers its package root for app-side imports', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/index.ts': 'export const root = 1;', + 'src/definition.ts': 'export const system = 1;', + 'dist/definition.mjs': 'export const system = 1;', + }); + + const result = await collect(root, { + '@x/ds/definition': join(pkg, 'dist', 'definition.mjs'), + }); + + // ds.ts declares the kit at a subpath, but app code imports the package + // root — without the root key, root imports bypass the src redirect and + // ship untransformed dist chains. + expect(result.packageMap).toEqual({ + '@x/ds/definition': 'packages/ds/src/definition.ts', + '@x/ds': 'packages/ds/src/index.ts', + }); + expect(result.sourceEntries.get('@x/ds')).toBe( + join(pkg, 'src', 'index.ts') + ); + // The alias is derived, not declared: exactly one outcome record. + expect(result.outcomes).toEqual([ + { specifier: '@x/ds/definition', outcome: 'resolved', fileCount: 2 }, + ]); + }); + + test('an unscoped subpath specifier registers its package root too', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'kit'), { + 'src/index.ts': 'export const root = 1;', + 'src/definition.ts': 'export const system = 1;', + }); + + const result = await collect(root, { + 'kit/definition': join(pkg, 'src', 'definition.ts'), + }); + + expect(result.packageMap['kit']).toBe('packages/kit/src/index.ts'); + expect(result.sourceEntries.get('kit')).toBe(join(pkg, 'src', 'index.ts')); + }); + + test('a package without a root source entry registers no root alias', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + // Subpath-only src layout: nothing for the root to redirect to. + 'src/definition.ts': 'export const system = 1;', + }); + + const result = await collect(root, { + '@x/ds/definition': join(pkg, 'src', 'definition.ts'), + }); + + expect(result.packageMap).toEqual({ + '@x/ds/definition': 'packages/ds/src/definition.ts', + }); + expect(result.sourceEntries.has('@x/ds')).toBe(false); + }); + test('no src/ — ingests the resolved entry file itself, exempt from extension filters', async () => { const root = makeRoot(); const pkg = makePackage(join(root, 'node_modules', 'flat-pkg'), { @@ -120,6 +203,25 @@ describe('collectExternalPackageSources', () => { expect(result.packageDirs).toEqual([pkg]); }); + test('no src/ — walks compiled component modules beside the definition entry', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'node_modules', '@x', 'compiled-ds'), { + 'dist/definition.mjs': 'export const system = 1;', + 'dist/Button.mjs': 'export const Button = 1;', + 'dist/Button.d.ts': 'export declare const Button: unknown;', + 'dist/Button.mjs.map': '{}', + }); + const entry = join(pkg, 'dist', 'definition.mjs'); + + const result = await collect(root, { '@x/compiled-ds/definition': entry }); + + expect(result.entries.map((item) => item.path).sort()).toEqual([ + relative(root, join(pkg, 'dist', 'Button.mjs')), + relative(root, entry), + ]); + expect(result.packageDirs).toEqual([join(pkg, 'dist')]); + }); + test('src/ without index.ts falls back to the resolved entry in packageMap', async () => { const root = makeRoot(); const pkg = makePackage(join(root, 'packages', 'ds'), { @@ -322,4 +424,122 @@ describe('collectExternalPackageSources', () => { { path: 'packages/ds/src/Doc.mdx.tsx', source: 'compiled' }, ]); }); + + test('attributes pushed files and dirs to their owning specifier', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'kit'), { + 'src/index.ts': 'export const ds = 1;', + 'src/Card.tsx': 'export const Card = 2;', + }); + + const result = await collect(root, { + '@acme/ui-kit': join(pkg, 'src', 'index.ts'), + }); + + expect(result.dirOwners).toEqual({ + [join(pkg, 'src')]: '@acme/ui-kit', + }); + expect(result.fileOwners).toEqual({ + [relative(root, join(pkg, 'src', 'index.ts'))]: '@acme/ui-kit', + [relative(root, join(pkg, 'src', 'Card.tsx'))]: '@acme/ui-kit', + }); + }); + + test('files the caller already supplied stay unattributed (consumer-owned)', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'kit'), { + 'src/index.ts': 'export const ds = 1;', + 'src/Card.tsx': 'export const Card = 2;', + }); + const ownedRel = relative(root, join(pkg, 'src', 'Card.tsx')); + + const result = await collect( + root, + { '@acme/ui-kit': join(pkg, 'src', 'index.ts') }, + { hasEntry: (relPath) => relPath === ownedRel } + ); + + expect(result.fileOwners[ownedRel]).toBeUndefined(); + expect( + result.fileOwners[relative(root, join(pkg, 'src', 'index.ts'))] + ).toBe('@acme/ui-kit'); + }); + + test('a dist entry older than the newest src file yields a stale-dist outcome', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/index.ts': 'export const ds = 1;', + 'dist/index.mjs': 'export const ds = 1;', + }); + const distEntry = join(pkg, 'dist', 'index.mjs'); + const past = new Date(Date.now() - 60_000); + utimesSync(distEntry, past, past); + + const result = await collect(root, { '@x/ds': distEntry }); + + expect(result.outcomes).toEqual([ + { specifier: '@x/ds', outcome: 'stale-dist', fileCount: 1 }, + ]); + }); + + test('a dist entry at least as new as every src file stays resolved', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/index.ts': 'export const ds = 1;', + 'dist/index.mjs': 'export const ds = 1;', + }); + const distEntry = join(pkg, 'dist', 'index.mjs'); + const future = new Date(Date.now() + 60_000); + utimesSync(distEntry, future, future); + + const result = await collect(root, { '@x/ds': distEntry }); + + expect(result.outcomes).toEqual([ + { specifier: '@x/ds', outcome: 'resolved', fileCount: 1 }, + ]); + expect(staleDistIncludesMessage(result.outcomes)).toBeNull(); + }); + + test('the freshness gate does not apply without a dist entry or without src/', async () => { + const root = makeRoot(); + // Entry resolves inside src/ — there is no dist entry to be stale. + const srcOnly = makePackage(join(root, 'packages', 'src-only'), { + 'src/index.ts': 'export const ds = 1;', + }); + // No src/ tree — the dist entry is ingested directly, nothing to compare. + const distOnly = makePackage(join(root, 'packages', 'dist-only'), { + 'dist/index.mjs': 'export const flat = 1;', + }); + const distOnlyEntry = join(distOnly, 'dist', 'index.mjs'); + const past = new Date(Date.now() - 60_000); + utimesSync(distOnlyEntry, past, past); + + const result = await collect(root, { + '@x/src-only': join(srcOnly, 'src', 'index.ts'), + '@x/dist-only': distOnlyEntry, + }); + + expect(result.outcomes).toEqual([ + { specifier: '@x/src-only', outcome: 'resolved', fileCount: 1 }, + { specifier: '@x/dist-only', outcome: 'resolved', fileCount: 1 }, + ]); + }); + + test('staleDistIncludesMessage names every stale package, null when none are stale', () => { + expect( + staleDistIncludesMessage([ + { specifier: '@x/kit', outcome: 'stale-dist', fileCount: 3 }, + { specifier: '@x/ds', outcome: 'resolved', fileCount: 2 }, + { specifier: '@x/base', outcome: 'stale-dist', fileCount: 1 }, + ]) + ).toBe( + '[animus-extract] stale dist for include specifier(s): @x/kit, @x/base — dist entry is older than the newest src/ file; rebuild the package(s) before extracting' + ); + expect( + staleDistIncludesMessage([ + { specifier: '@x/ds', outcome: 'resolved', fileCount: 2 }, + { specifier: '@x/empty', outcome: 'empty', fileCount: 0 }, + ]) + ).toBeNull(); + }); }); diff --git a/packages/extract/tests/correlate-external-tokens.test.ts b/packages/extract/tests/correlate-external-tokens.test.ts new file mode 100644 index 00000000..b488393a --- /dev/null +++ b/packages/extract/tests/correlate-external-tokens.test.ts @@ -0,0 +1,224 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { + buildSourceTokenIndex, + correlateExternalTokenDiagnostics, + enforceExternalTokenContracts, +} from '../pipeline/correlate-external-tokens'; + +import type { ManifestDiagnostic } from '../pipeline/manifest-diagnostics'; + +/** + * The cross-source correlation join (extraction-diagnostics): engine + * candidates only become findings when the file belongs to a discovered + * source AND that source's own token manifest defines the token. + */ + +const KIT_DIR = '/repo/packages/kit/src'; + +function candidate( + overrides: Partial = {} +): ManifestDiagnostic { + return { + file: 'packages/kit/src/Card.tsx', + component: 'KitCard', + kind: 'external-token-candidate', + message: "'colors.externalAccent' in 'background-color' did not resolve", + token: 'colors.externalAccent', + ...overrides, + }; +} + +const FILE_OWNERS = { 'packages/kit/src/Card.tsx': '@acme/ui-kit' }; + +function kitTokenIndex(tokens: string[]): Map> { + return new Map([['@acme/ui-kit', new Set(tokens)]]); +} + +describe('correlateExternalTokenDiagnostics', () => { + test('witness hit produces the teaching error naming all four pieces', () => { + const messages = correlateExternalTokenDiagnostics({ + diagnostics: [candidate()], + fileOwners: FILE_OWNERS, + sourceTokens: kitTokenIndex(['colors.externalAccent']), + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('KitCard'); + expect(messages[0]).toContain("'@acme/ui-kit'"); + expect(messages[0]).toContain("'colors.externalAccent'"); + expect(messages[0]).toContain('createTheme().extend('); + }); + + test('a CSS literal the source does not define stays silent (witness miss)', () => { + const messages = correlateExternalTokenDiagnostics({ + diagnostics: [candidate({ token: 'colors.red' })], + fileOwners: FILE_OWNERS, + sourceTokens: kitTokenIndex(['colors.externalAccent']), + }); + + expect(messages).toEqual([]); + }); + + test('consumer-local files stay silent (no ownership)', () => { + const messages = correlateExternalTokenDiagnostics({ + diagnostics: [candidate({ file: 'src/App.tsx', component: 'Local' })], + fileOwners: FILE_OWNERS, + sourceTokens: kitTokenIndex(['colors.externalAccent']), + }); + + expect(messages).toEqual([]); + }); + + test('non-candidate kinds and tokenless diagnostics are ignored', () => { + const messages = correlateExternalTokenDiagnostics({ + diagnostics: [ + candidate({ kind: 'warn' }), + candidate({ token: undefined }), + ], + fileOwners: FILE_OWNERS, + sourceTokens: kitTokenIndex(['colors.externalAccent']), + }); + + expect(messages).toEqual([]); + }); + + test('duplicate (component, token, source) findings dedupe to one message', () => { + const messages = correlateExternalTokenDiagnostics({ + diagnostics: [candidate(), candidate()], + fileOwners: FILE_OWNERS, + sourceTokens: kitTokenIndex(['colors.externalAccent']), + }); + + expect(messages).toHaveLength(1); + }); +}); + +describe('buildSourceTokenIndex', () => { + test('maps modules under a package dir to its specifier, unioning exports', () => { + const index = buildSourceTokenIndex({ + sourceThemeManifestsJson: JSON.stringify({ + [`${KIT_DIR}/theme.ts`]: { + referenceTokens: ['colors.externalAccent', 'space.4'], + }, + [`${KIT_DIR}/extra.ts`]: { moreTokens: ['colors.deep'] }, + }), + dirOwners: { [KIT_DIR]: '@acme/ui-kit' }, + }); + + expect(index.get('@acme/ui-kit')).toEqual( + new Set(['colors.externalAccent', 'space.4', 'colors.deep']) + ); + }); + + test('modules outside every package dir contribute nothing', () => { + const index = buildSourceTokenIndex({ + sourceThemeManifestsJson: JSON.stringify({ + '/repo/src/theme.ts': { tokens: ['colors.consumer'] }, + }), + dirOwners: { [KIT_DIR]: '@acme/ui-kit' }, + }); + + expect(index.size).toBe(0); + }); + + test('absent or invalid manifests JSON yields an empty index', () => { + expect( + buildSourceTokenIndex({ + sourceThemeManifestsJson: null, + dirOwners: { [KIT_DIR]: '@acme/ui-kit' }, + }).size + ).toBe(0); + expect( + buildSourceTokenIndex({ + sourceThemeManifestsJson: 'not json', + dirOwners: { [KIT_DIR]: '@acme/ui-kit' }, + }).size + ).toBe(0); + }); +}); + +/** + * The src/dist join: collection keys `dirOwners` by the package's src/ dir, + * but the QuickJS loader resolves the same specifier through the exports map + * — its canonical module paths live under dist/. The index must join the two + * at the PACKAGE boundary (the package.json root) or the whole gate is inert + * for exactly the src-shipping workspace kits it targets. + */ +describe('buildSourceTokenIndex package-boundary join', () => { + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + /** A real on-disk package: package.json + src/ + dist/, realpath'd. */ + function makeKit(): { srcDir: string; distModule: string } { + const scratch = mkdtempSync(join(tmpdir(), 'animus-correlate-')); + tempRoots.push(scratch); + const pkgRoot = join(scratch, 'packages', 'kit'); + mkdirSync(join(pkgRoot, 'src'), { recursive: true }); + mkdirSync(join(pkgRoot, 'dist'), { recursive: true }); + writeFileSync(join(pkgRoot, 'package.json'), '{"name":"@acme/ui-kit"}'); + const realRoot = realpathSync(pkgRoot); + return { + srcDir: join(pkgRoot, 'src'), + distModule: join(realRoot, 'dist', 'theme.mjs'), + }; + } + + test('a loader dist module joins a src-keyed owner via the package root', () => { + const { srcDir, distModule } = makeKit(); + + const index = buildSourceTokenIndex({ + sourceThemeManifestsJson: JSON.stringify({ + [distModule]: { theme: ['colors.externalAccent'] }, + }), + dirOwners: { [srcDir]: '@acme/ui-kit/definition' }, + }); + + expect(index.get('@acme/ui-kit/definition')).toEqual( + new Set(['colors.externalAccent']) + ); + }); + + test('a missing owner dir never claims modules through the filesystem root', () => { + const index = buildSourceTokenIndex({ + sourceThemeManifestsJson: JSON.stringify({ + '/somewhere/else/theme.ts': { theme: ['colors.externalAccent'] }, + }), + dirOwners: { '/nonexistent-animus-test/packages/kit/src': '@x/ghost' }, + }); + + expect(index.size).toBe(0); + }); + + test('the full gate fires strict on a dist-shaped manifest', () => { + const { srcDir, distModule } = makeKit(); + + expect(() => + enforceExternalTokenContracts({ + diagnostics: [candidate()], + fileOwners: FILE_OWNERS, + dirOwners: { [srcDir]: '@acme/ui-kit' }, + sourceThemeManifestsJson: JSON.stringify({ + [distModule]: { theme: ['colors.externalAccent'] }, + }), + strict: true, + prefix: '[animus-extract]', + warn: () => {}, + }) + ).toThrow(/KitCard.*'colors\.externalAccent'/s); + }); +}); diff --git a/packages/extract/tests/discover-packages.test.ts b/packages/extract/tests/discover-packages.test.ts index 2b263d48..b057b407 100644 --- a/packages/extract/tests/discover-packages.test.ts +++ b/packages/extract/tests/discover-packages.test.ts @@ -186,4 +186,477 @@ describe('extractSystemFilePackages', () => { rmSync(join(path, '..'), { recursive: true, force: true }); } }); + + test('discovers package from a from() chain call', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as kitDs } from '@acme/ui-kit'; + + export const { system: ds } = createSystem() + .from(kitDs) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/ui-kit'); + expect(pkgs).not.toContain('@animus-ui/system'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('discovers every source of repeated from() calls', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as a } from '@ds-a/core'; + import { ds as b } from '@ds-b/core'; + + export const { system: ds } = createSystem() + .from(a) + .from(b) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@ds-a/core'); + expect(pkgs).toContain('@ds-b/core'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('traces a library-bundle identifier (and its member form) to its import', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/ui-kit'; + import { other } from '@acme/other-kit'; + + export const { system: ds } = createSystem() + .from(kit) + .from(other.system) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/ui-kit'); + expect(pkgs).toContain('@acme/other-kit'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('from() and legacy includes forms contribute to one discovered set', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as legacyDs } from '@animus-ui/test-ds'; + import { ds as kitDs } from '@acme/ui-kit'; + + export const { system: ds } = createSystem({ includes: [legacyDs] }) + .from(kitDs) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@animus-ui/test-ds'); + expect(pkgs).toContain('@acme/ui-kit'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('createTheme().from() never contributes discovery membership', () => { + const path = writeFixture(` + import { createSystem, createTheme } from '@animus-ui/system'; + import { tokens as kitTokens } from '@acme/tokens-only'; + import { ds as kitDs } from '@acme/ui-kit'; + + export const tokens = createTheme() + .from(kitTokens) + .addColors({ brand: { 500: '#3b82f6' } }) + .build(); + + export const { system: ds } = createSystem() + .from(kitDs) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/ui-kit'); + expect(pkgs).not.toContain('@acme/tokens-only'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('from() sources survive a reformatted chain', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as kitDs } from '@acme/ui-kit'; + + export const { system: ds } = createSystem() + .from( + kitDs + ) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/ui-kit'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('discovers package from an extend() chain call', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/kit'; + + export const { system: ds } = createSystem() + .extend(kit) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/kit'); + expect(pkgs).not.toContain('@animus-ui/system'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('discovers every source of repeated extend() calls', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as a } from '@ds-a/core'; + import { ds as b } from '@ds-b/core'; + + export const { system: ds } = createSystem() + .extend(a) + .extend(b) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@ds-a/core'); + expect(pkgs).toContain('@ds-b/core'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('discovers every source of a mixed extend()/from() chain', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as a } from '@ds-a/core'; + import { ds as b } from '@ds-b/core'; + import { ds as c } from '@ds-c/core'; + + export const { system: ds } = createSystem() + .extend(a) + .from(b) + .extend(c) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@ds-a/core'); + expect(pkgs).toContain('@ds-b/core'); + expect(pkgs).toContain('@ds-c/core'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('createTheme().extend() never contributes discovery membership', () => { + const path = writeFixture(` + import { createSystem, createTheme } from '@animus-ui/system'; + import { tokens as kitTokens } from '@acme/tokens-only'; + import { ds as kitDs } from '@acme/ui-kit'; + + export const theme = createTheme() + .extend(kitTokens) + .addColors({ brand: { 500: '#3b82f6' } }) + .build(); + + export const { system: ds } = createSystem() + .extend(kitDs) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/ui-kit'); + expect(pkgs).not.toContain('@acme/tokens-only'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('extend() and every legacy form feed one deduplicated set', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as legacyDs } from '@animus-ui/test-ds'; + import { kit } from '@acme/ui-kit'; + import { base } from '@acme/base'; + + export const { system: ds } = createSystem({ includes: [legacyDs, kit] }) + .extend(kit) + .from(base) + .addGroup('space', {}) + .build(); + `); + + try { + // Every named package appears exactly once — the package declared + // through both the includes: constructor and the extend() chain dedupes. + const pkgs = extractSystemFilePackages(path).sort(); + expect(pkgs).toEqual([ + '@acme/base', + '@acme/ui-kit', + '@animus-ui/test-ds', + ]); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('extend() sources survive a reformatted chain', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { ds as kitDs } from '@acme/ui-kit'; + + export const { system: ds } = createSystem() + .extend( + kitDs + ) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/ui-kit'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('extend() traces a library-bundle identifier (and its member form) to its import', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/ui-kit'; + import { other } from '@acme/other-kit'; + + export const { system: ds } = createSystem() + .extend(kit) + .extend(other.system) + .addGroup('space', {}) + .build(); + `); + + try { + const pkgs = extractSystemFilePackages(path); + expect(pkgs).toContain('@acme/ui-kit'); + expect(pkgs).toContain('@acme/other-kit'); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); + + test('preserves a package export subpath for host resolution', () => { + const path = writeFixture(` + import { createSystem } from '@animus-ui/system'; + import { system } from '@acme/ui-kit/definition'; + + export const { system: ds } = createSystem().extend(system).build(); + `); + + try { + expect(extractSystemFilePackages(path)).toEqual([ + '@acme/ui-kit/definition', + ]); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); +}); + +/** + * Trivia tolerance for the extension-chain scan: comments, multiline + * argument formatting, and builder chains split across statements are + * ordinary authoring shapes — a scanner that stops at them drops kits with + * no diagnostic (outcomes derive only from the returned specifiers, so a + * missing kit is invisible to the strict gates). + */ +describe('extractSystemFilePackages chain-scan tolerance', () => { + const expectDiscovered = (contents: string, expected: string[]): void => { + const path = writeFixture(contents); + try { + const pkgs = extractSystemFilePackages(path); + for (const specifier of expected) { + expect(pkgs).toContain(specifier); + } + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }; + + test('a line comment between the call and the first link', () => { + expectDiscovered( + ` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/ui-kit'; + + export const { system: ds } = createSystem({}) // base system + .extend(kit) + .build(); + `, + ['@acme/ui-kit'] + ); + }); + + test('a block comment between the call and the first link', () => { + expectDiscovered( + ` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/ui-kit'; + + export const { system: ds } = createSystem({}) /* base */ + .extend(kit) + .build(); + `, + ['@acme/ui-kit'] + ); + }); + + test('a comment between two links keeps the later kit', () => { + expectDiscovered( + ` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/ui-kit'; + import { other } from '@acme/other-kit'; + + export const { system: ds } = createSystem() + .extend(kit) // primary kit + .extend(other) + .build(); + `, + ['@acme/ui-kit', '@acme/other-kit'] + ); + }); + + test('a multiline argument with a trailing comma', () => { + expectDiscovered( + ` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/ui-kit'; + + export const { system: ds } = createSystem() + .extend( + kit, + ) + .build(); + `, + ['@acme/ui-kit'] + ); + }); + + test('a builder chain split across statements', () => { + expectDiscovered( + ` + import { createSystem } from '@animus-ui/system'; + import { kit } from '@acme/ui-kit'; + + const base = createSystem({}); + export const { system: ds } = base.extend(kit).build(); + `, + ['@acme/ui-kit'] + ); + }); + + test('transitively bound builder chains contribute every kit', () => { + expectDiscovered( + ` + import { createSystem } from '@animus-ui/system'; + import { a } from '@ds-a/core'; + import { b } from '@ds-b/core'; + + const base = createSystem(); + const withA = base.extend(a); + export const { system: ds } = withA.extend(b).build(); + `, + ['@ds-a/core', '@ds-b/core'] + ); + }); + + test('a split statement never adopts a createTheme() chain', () => { + expectDiscovered( + ` + import { createSystem, createTheme } from '@animus-ui/system'; + import { tokens } from '@acme/tokens-only'; + import { kit } from '@acme/ui-kit'; + + const themeBase = createTheme(); + export const theme = themeBase.extend(tokens).build(); + + const base = createSystem({}); + export const { system: ds } = base.extend(kit).build(); + `, + ['@acme/ui-kit'] + ); + const path = writeFixture(` + import { createSystem, createTheme } from '@animus-ui/system'; + import { tokens } from '@acme/tokens-only'; + import { kit } from '@acme/ui-kit'; + + const themeBase = createTheme(); + export const theme = themeBase.extend(tokens).build(); + + const base = createSystem({}); + export const { system: ds } = base.extend(kit).build(); + `); + try { + expect(extractSystemFilePackages(path)).not.toContain( + '@acme/tokens-only' + ); + } finally { + rmSync(path, { force: true }); + rmSync(join(path, '..'), { recursive: true, force: true }); + } + }); }); diff --git a/packages/extract/tests/resolve-asset.test.ts b/packages/extract/tests/resolve-asset.test.ts new file mode 100644 index 00000000..69028f97 --- /dev/null +++ b/packages/extract/tests/resolve-asset.test.ts @@ -0,0 +1,68 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { resolveAssetFile } from '../pipeline/resolve-asset'; + +describe('resolveAssetFile', () => { + it('finds a physical asset when package.json is hidden by exports', () => { + const root = mkdtempSync(join(tmpdir(), 'animus-resolve-asset-')); + const packageRoot = join(root, 'node_modules', '@acme', 'tokens'); + const entry = join(packageRoot, 'dist', 'index.js'); + const asset = join(packageRoot, 'fonts', 'inter.woff2'); + mkdirSync(join(packageRoot, 'dist'), { recursive: true }); + mkdirSync(join(packageRoot, 'fonts'), { recursive: true }); + writeFileSync(join(root, 'package.json'), '{}'); + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@acme/tokens', + exports: { '.': './dist/index.js' }, + }) + ); + writeFileSync(entry, 'module.exports = {};'); + writeFileSync(asset, 'font'); + + try { + const resolved = resolveAssetFile('@acme/tokens/fonts/inter.woff2', root); + expect(resolved).not.toBeNull(); + expect(realpathSync(resolved!)).toBe(realpathSync(asset)); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('finds an asset in a package that exports only a subpath', () => { + const root = mkdtempSync(join(tmpdir(), 'animus-resolve-asset-')); + const packageRoot = join(root, 'node_modules', '@acme', 'tokens'); + const definition = join(packageRoot, 'dist', 'definition.js'); + const asset = join(packageRoot, 'fonts', 'inter.woff2'); + mkdirSync(join(packageRoot, 'dist'), { recursive: true }); + mkdirSync(join(packageRoot, 'fonts'), { recursive: true }); + writeFileSync(join(root, 'package.json'), '{}'); + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@acme/tokens', + exports: { './definition': './dist/definition.js' }, + }) + ); + writeFileSync(definition, 'module.exports = {};'); + writeFileSync(asset, 'font'); + + try { + const resolved = resolveAssetFile('@acme/tokens/fonts/inter.woff2', root); + expect(resolved).not.toBeNull(); + expect(realpathSync(resolved!)).toBe(realpathSync(asset)); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index ff9288cf..3d9a61c2 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -60,9 +60,9 @@ it in a server-only module and inline it as the first child of ``. // appearance-bootstrap.ts — server-only; never import from a client component import { createAppearanceBootstrap } from '@animus-ui/system/bootstrap'; -import { tokens } from './src/ds'; +import { theme } from './src/ds'; -export const appearanceBootstrap = createAppearanceBootstrap(tokens); +export const appearanceBootstrap = createAppearanceBootstrap(theme); ``` ```tsx diff --git a/packages/next-plugin/src/extraction-session.ts b/packages/next-plugin/src/extraction-session.ts index ed668fc8..cac77259 100644 --- a/packages/next-plugin/src/extraction-session.ts +++ b/packages/next-plugin/src/extraction-session.ts @@ -6,24 +6,33 @@ import { contentHash, DEFAULT_EXTENSIONS, discoverFiles, + enforceExternalTokenContracts, extractSystemFilePackages, + findAssetSpecifiers, + findPackageRoot, loadSystemConfig, postProcessCss, preprocessMdx, + resolveAssetFile, resolveLightningTargets, runProjectAnalysis, serializeStaticCss, + staleDistIncludesMessage, + substituteAssetPlaceholders, toWatchKeys, unresolvableIncludesMessage, } from '@animus-ui/extract/pipeline'; import { existsSync, mkdirSync, + readdirSync, readFileSync, renameSync, + statSync, + unlinkSync, writeFileSync, } from 'fs'; -import { extname, join, relative, resolve } from 'path'; +import { basename, extname, join, relative, resolve } from 'path'; import { resolvePackagesByName } from './resolve-packages'; import { @@ -91,6 +100,22 @@ export class ExtractionSession { rootDir: string | null = null; /** Serialized path aliases, harvested by the adapter from bundler config. */ pathAliasesJson: string | null = null; + + // Per-specifier resolve/copy memo for substituteAssetReferences — the + // result is stable per loaded system, so it is cleared on system load. + private assetCopyCache = new Map< + string, + { + sourcePath: string; + mtimeMs: number; + size: number; + fileName: string; + url: string; + } + >(); + /** Physical asset files registered with the host watcher. */ + assetDependencyPaths = new Set(); + private assetDependencyKeys = new Set(); /** When set (Turbopack orchestration), every analysis also persists * `.animus/analysis-inputs.json` so isolated loader workers can hydrate. * Webpack mode leaves this off — its loader shares the process. */ @@ -103,6 +128,10 @@ export class ExtractionSession { /** Absolute directory prefixes for external DS packages (loader allowlisting). */ externalPackageDirs: string[] = []; + /** Absolute package dir → owning specifier (cross-source correlation). */ + private externalDirOwners: Record = {}; + /** rootDir-relative external file → owning specifier (correlation join). */ + private externalFileOwners: Record = {}; /** External package specifier → absolute source entry path. */ externalSourceEntries = new Map(); @@ -204,6 +233,7 @@ export class ExtractionSession { // entry). Membership is keyed lexically and canonically, so events via // symlinked or already-deleted paths still classify. One reset per // watch batch — the bundler already coalesces events per rebuild. + let assetChanged = false; try { let systemHit: string | undefined; if (!changes.modifiedFiles && !changes.removedFiles) { @@ -240,6 +270,25 @@ export class ExtractionSession { await promise; return; } + + const changed = [ + ...(changes.modifiedFiles ?? []), + ...(changes.removedFiles ?? []), + ]; + if ( + changed.some((path) => + toWatchKeys(path).some((key) => this.assetDependencyKeys.has(key)) + ) + ) { + // A changed asset invalidates the copy memo and forces re-analysis, + // but the batch may ALSO carry component edits and removals (branch + // switch, editor save-all, git checkout): fall through to the shared + // read/re-hash/prune flow instead of replaying the cache — an entry + // analyzed stale here would never re-surface, since its cache hash + // was never updated. + this.assetCopyCache.clear(); + assetChanged = true; + } } catch (err) { // Not a benign probe: this wraps the geological-reset re-run. // Swallowing keeps a transient failure from crashing the watch loop, @@ -321,7 +370,7 @@ export class ExtractionSession { } } - if (changedPaths.length > 0 || removedAny) { + if (changedPaths.length > 0 || removedAny || assetChanged) { // Every cached file rides with full source (v2 has no Rust-side cache). const fileEntries = this.buildFileEntriesFromCache(); @@ -387,6 +436,11 @@ export class ExtractionSession { rootDir, prefix: this.options.prefix, }); + // Asset specifiers resolve against the system just loaded — drop the + // per-specifier copy memo so a changed reference re-reads and re-hashes. + this.assetCopyCache.clear(); + this.assetDependencyPaths.clear(); + this.assetDependencyKeys.clear(); { // Refresh the geological-reset membership set: every loader-evaluated // module plus (defensively) the entry, keyed lexically and @@ -498,9 +552,22 @@ export class ExtractionSession { } this.warn(unresolvableMessage); } + // first-class-extension D13: a stale dist entry under an extended package + // rides the same strict/warn seam — a merge against it would silently + // skew registry content the discovered sources no longer match + // (vite-plugin parity). + const staleDistMessage = staleDistIncludesMessage(collected.outcomes); + if (staleDistMessage !== null) { + if (this.options.strict) { + throw new Error(staleDistMessage); + } + this.warn(staleDistMessage); + } const packageMap = collected.packageMap; this.lastPackageMap = packageMap; + this.externalDirOwners = collected.dirOwners; + this.externalFileOwners = collected.fileOwners; this.externalSourceEntries = collected.sourceEntries; for (const entry of collected.entries) { const hash = contentHash(entry.source); @@ -617,6 +684,9 @@ export class ExtractionSession { }, pathAliasesJson: this.pathAliasesJson, staticCssJson: this.staticCssJson, + externalDirs: this.externalPackageDirs.map((dir) => + relative(this.rootDir!, dir) + ), devMode, }; @@ -625,6 +695,21 @@ export class ExtractionSession { warn: (message) => this.warn(message), }); + // Cross-source token contracts (extraction-diagnostics): engine + // candidates × file ownership × source-token witness → the teaching + // error naming token, component, package, and the missing + // `createTheme().extend(...)`. Wiring and severity routing live in the + // shared pipeline gate (vite-plugin parity by construction). + enforceExternalTokenContracts({ + diagnostics: result.manifest?.diagnostics, + fileOwners: this.externalFileOwners, + dirOwners: this.externalDirOwners, + sourceThemeManifestsJson: system.sourceThemeManifestsJson, + strict: this.options.strict, + prefix: '[animus-next]', + warn: (message: string) => this.warn(message), + }); + bt.jsonSerialize = result.timings.serializeMs; bt.rustExtract = result.timings.extractMs; bt.jsonParse = result.timings.parseMs; @@ -641,11 +726,16 @@ export class ExtractionSession { } } + // asset() placeholder substitution (global-styles-system) happens before + // assembly so every consumer of the CSS (shared copy, disk artifact, + // Turbopack hydration) receives substituted urls. + const globalCss = this.substituteAssetReferences(result.globalCss, devMode); + // Assemble full stylesheet (canonical order via shared function) const { declaration, variables, body } = assembleStylesheet({ layers: this.options.layers, variableCss: system.variableCss, - globalCss: result.globalCss, + globalCss, componentCss: result.componentCss, split: true, }); @@ -771,4 +861,157 @@ export class ExtractionSession { writeFileSync(tmpPath, content); renameSync(tmpPath, join(dir, name)); } + + /** + * asset() placeholder substitution (global-styles-system): resolve each + * referenced specifier through Node resolution, copy the bytes into + * `.animus/assets/` under a content-hashed name, and substitute a + * RELATIVE url. `.animus/styles.css` is processed by Next's own CSS + * pipeline (webpack and Turbopack alike), which applies its native asset + * handling — publicPath and output hashing — to relative url() + * references. Unsubstitutable specifiers warn and emit literally in + * non-strict mode, fail the build under `strict: true`. + */ + private substituteAssetReferences( + globalCss: string, + devMode: boolean + ): string { + const specifiers = findAssetSpecifiers(globalCss); + const assetsDir = join(this.rootDir!, '.animus', 'assets'); + const expected = new Set(); + this.assetDependencyPaths.clear(); + this.assetDependencyKeys.clear(); + + const urlBySpecifier = new Map(); + for (const specifier of specifiers) { + // This runs per HMR rebuild; the resolve/read/hash/copy result is + // stable for the lifetime of a loaded system, so a memo (cleared on + // system load) reduces steady-state passes to one existsSync each. + // A missing copy (concurrent prune) falls through and self-heals. + const cached = this.assetCopyCache.get(specifier); + if (cached && existsSync(join(assetsDir, cached.fileName))) { + try { + const current = statSync(cached.sourcePath); + if ( + current.mtimeMs === cached.mtimeMs && + current.size === cached.size + ) { + this.trackAssetDependency(cached.sourcePath); + expected.add(cached.fileName); + urlBySpecifier.set(specifier, cached.url); + continue; + } + } catch { + // Re-resolve below; strict/non-strict policy remains centralized. + } + } + const resolvedPath = this.resolveAssetSpecifier(specifier); + if (!resolvedPath) { + const message = `unresolvable asset() specifier: ${specifier}`; + if (this.options.strict) throw new Error(`[animus-next] ${message}`); + this.warn(message); + urlBySpecifier.set(specifier, specifier); + continue; + } + const bytes = readFileSync(resolvedPath); + const sourceStat = statSync(resolvedPath); + this.trackAssetDependency(resolvedPath); + const ext = extname(resolvedPath); + const stem = basename(resolvedPath, ext); + const fileName = `${stem}.${contentHash(bytes).slice(0, 8)}${ext}`; + expected.add(fileName); + if (!existsSync(assetsDir)) { + mkdirSync(assetsDir, { recursive: true }); + } + const assetPath = join(assetsDir, fileName); + if (!existsSync(assetPath)) { + writeFileSync(assetPath, bytes); + } + const url = `./assets/${fileName}`; + urlBySpecifier.set(specifier, url); + this.assetCopyCache.set(specifier, { + sourcePath: resolvedPath, + mtimeMs: sourceStat.mtimeMs, + size: sourceStat.size, + fileName, + url, + }); + } + + // Content-hashed copies are never overwritten, so superseded revisions + // (and copies of assets no longer referenced at all) accumulate without + // this sync — runs AFTER the writes so the current set is always on + // disk, including when no asset() remains and everything is stale. + if (!devMode) pruneStaleAssets(assetsDir, expected); + + return substituteAssetPlaceholders(globalCss, urlBySpecifier); + } + + private trackAssetDependency(path: string): void { + this.assetDependencyPaths.add(path); + for (const key of toWatchKeys(path)) this.assetDependencyKeys.add(key); + } + + /** + * Resolve an asset specifier to an absolute file via the shared pipeline + * resolver (host aliases → Node resolution → package root), with one + * session-local last resort: an already-discovered source entry's package + * root (dist-less workspace kits the shared resolver cannot see). + */ + private resolveAssetSpecifier(specifier: string): string | null { + const resolved = resolveAssetFile( + specifier, + this.rootDir!, + this.pathAliasesJson + ); + if (resolved) return resolved; + + const segments = specifier.split('/'); + const packageName = specifier.startsWith('@') + ? segments.slice(0, 2).join('/') + : segments[0]; + const subpath = specifier.slice(packageName.length + 1); + if (!subpath) return null; + const sourceEntry = + this.externalSourceEntries.get(packageName) ?? + [...this.externalSourceEntries].find( + ([declared]) => + declared === packageName || declared.startsWith(`${packageName}/`) + )?.[1]; + if (sourceEntry) { + const candidate = join(findPackageRoot(sourceEntry), subpath); + if (existsSync(candidate)) return candidate; + } + return null; + } +} + +/** + * Sync `.animus/assets/` to the current build's content-hashed file set: + * anything else in the directory is a superseded revision (the copies are + * content-addressed and never overwritten) or the leftover of an asset() + * reference that no longer exists. Failures are tolerated per entry — Next + * dev evaluates the config in more than one process, and a concurrent + * session may have removed (or be about to rewrite) the same file; every + * pass rewrites whatever of its own set is missing, so races self-heal. + */ +export function pruneStaleAssets( + assetsDir: string, + expected: ReadonlySet +): void { + if (!existsSync(assetsDir)) return; + let entries: string[]; + try { + entries = readdirSync(assetsDir); + } catch { + return; + } + for (const entry of entries) { + if (expected.has(entry)) continue; + try { + unlinkSync(join(assetsDir, entry)); + } catch { + // Concurrent session removal, or an unexpected subdirectory — leave it. + } + } } diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 2620cea2..29b7661c 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -144,6 +144,10 @@ export class AnimusWebpackPlugin { if (existsSync(dep)) compilation.fileDependencies.add(dep); else compilation.missingDependencies.add(dep); } + for (const dep of this.session.assetDependencyPaths) { + if (existsSync(dep)) compilation.fileDependencies.add(dep); + else compilation.missingDependencies.add(dep); + } }; registerSystemDependencies(); diff --git a/packages/next-plugin/tests/analyze-project-args.test.ts b/packages/next-plugin/tests/analyze-project-args.test.ts index a196419b..c5d05c07 100644 --- a/packages/next-plugin/tests/analyze-project-args.test.ts +++ b/packages/next-plugin/tests/analyze-project-args.test.ts @@ -16,10 +16,11 @@ const inputs = { keyframesJson: 'next-keyframes', staticCssJson: 'next-static-css', conditionAliasesJson: 'next-condition-aliases', + externalDirsJson: 'next-external-dirs', }; describe('Next analyzeProject argument construction', () => { - test('pins all 16 production NAPI slots', () => { + test('pins all 17 production NAPI slots', () => { expect(buildAnalyzeProjectArgs({ ...inputs, devMode: false })).toEqual([ 'next-files', 'next-scales', @@ -37,10 +38,11 @@ describe('Next analyzeProject argument construction', () => { 'next-keyframes', 'next-static-css', 'next-condition-aliases', + 'next-external-dirs', ]); }); - test('pins all 16 HMR NAPI slots', () => { + test('pins all 17 HMR NAPI slots', () => { expect(buildAnalyzeProjectArgs({ ...inputs, devMode: true })).toEqual([ 'next-files', 'next-scales', @@ -58,6 +60,7 @@ describe('Next analyzeProject argument construction', () => { 'next-keyframes', 'next-static-css', 'next-condition-aliases', + 'next-external-dirs', ]); }); }); diff --git a/packages/next-plugin/tests/prune-stale-assets.test.ts b/packages/next-plugin/tests/prune-stale-assets.test.ts new file mode 100644 index 00000000..b8a65e56 --- /dev/null +++ b/packages/next-plugin/tests/prune-stale-assets.test.ts @@ -0,0 +1,74 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { pruneStaleAssets } from '../src/extraction-session'; + +/** + * `.animus/assets/` sync (asset() delivery): copies are content-addressed + * and never overwritten, so every pass prunes whatever the current build + * did not produce — superseded revisions and copies of removed references + * alike — while tolerating a missing directory and per-entry failures. + */ + +let scratch: string | null = null; + +function assetsDir(files: string[]): string { + scratch = mkdtempSync(join(tmpdir(), 'animus-prune-')); + const dir = join(scratch, 'assets'); + mkdirSync(dir); + for (const file of files) { + writeFileSync(join(dir, file), file); + } + return dir; +} + +afterEach(() => { + if (scratch) rmSync(scratch, { recursive: true, force: true }); + scratch = null; +}); + +describe('pruneStaleAssets', () => { + test('removes superseded revisions, keeps the current set', () => { + const dir = assetsDir([ + 'inter.aaaa1111.woff2', + 'inter.bbbb2222.woff2', + 'mono.cccc3333.woff2', + ]); + + pruneStaleAssets(dir, new Set(['inter.bbbb2222.woff2'])); + + expect(readdirSync(dir).sort()).toEqual(['inter.bbbb2222.woff2']); + }); + + test('an empty expected set clears every leftover copy', () => { + const dir = assetsDir(['inter.aaaa1111.woff2']); + + pruneStaleAssets(dir, new Set()); + + expect(readdirSync(dir)).toEqual([]); + }); + + test('a missing directory is a no-op', () => { + expect(() => + pruneStaleAssets(join(tmpdir(), 'animus-prune-does-not-exist'), new Set()) + ).not.toThrow(); + }); + + test('an unexpected subdirectory is tolerated, files beside it still prune', () => { + const dir = assetsDir(['inter.aaaa1111.woff2']); + mkdirSync(join(dir, 'nested')); + writeFileSync(join(dir, 'nested', 'keep.txt'), 'x'); + + pruneStaleAssets(dir, new Set()); + + expect(readdirSync(dir)).toEqual(['nested']); + }); +}); diff --git a/packages/next-plugin/tests/watch-asset-batch.test.ts b/packages/next-plugin/tests/watch-asset-batch.test.ts new file mode 100644 index 00000000..fa8327df --- /dev/null +++ b/packages/next-plugin/tests/watch-asset-batch.test.ts @@ -0,0 +1,179 @@ +/** + * Watch batches that touch an asset() dependency (spec: + * global-styles-system): a branch switch, editor save-all, or git checkout + * delivers the asset AND component edits in ONE batch, so the asset path + * must not short-circuit the component read/re-hash/prune flow — a replayed + * stale cache analyzes old component source and, because the cache was + * never updated, the edit never re-surfaces on a later cycle. + * + * Same harness as plugin-pipeline.test.ts: the NAPI boundary is mocked, the + * pure pipeline helpers and the session run for real over a temp project. + */ +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join, relative } from 'path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + loadSystemModule: vi.fn(), + analyzeProject: vi.fn(), + clearAnalysisCache: vi.fn(), +})); + +vi.mock('../src/singleton', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + engineApi: () => ({ + loadSystemModule: mocks.loadSystemModule, + analyzeProject: mocks.analyzeProject, + clearAnalysisCache: mocks.clearAnalysisCache, + }), + }; +}); + +import { ExtractionSession } from '../src/extraction-session'; + +const SYSTEM_CONFIG = { + propConfig: '{"props":{}}', + groupRegistry: '{"groups":{}}', + scalesJson: '{"space":{}}', + variableMapJson: '{"map":{}}', + variableCss: ':root{--anm-space-1: 4px}', + contextualVarsJson: null, + selectorAliases: null, + globalStyleBlocks: null, + keyframesBlocks: null, +}; + +const BUTTON_SOURCE = + "export const Button = animus.styles({ margin: 8 }).asElement('button');\n"; +const BUTTON_SOURCE_CHANGED = + "export const Button = animus.styles({ margin: 16 }).asElement('button');\n"; + +const tempRoots: string[] = []; + +function createProject(): { root: string; assetPath: string } { + const root = mkdtempSync(join(tmpdir(), 'animus-watch-asset-')); + tempRoots.push(root); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'package.json'), '{"name":"consumer"}'); + writeFileSync( + join(root, 'src', 'system.ts'), + 'export const system = { space: [0, 4, 8] };\n' + ); + writeFileSync(join(root, 'src', 'Button.tsx'), BUTTON_SOURCE); + const assetPath = join(root, 'logo.svg'); + writeFileSync(assetPath, ''); + return { root, assetPath }; +} + +/** Manifest whose global sheet references the asset by absolute specifier. */ +function buildManifest(assetPath: string): string { + return JSON.stringify({ + css: '.btn{margin:8;}', + sheets: { + global: `@layer anm-global{body{background:url('animus-asset:${assetPath}')}}`, + }, + system_prop_map: {}, + dynamic_props: {}, + diagnostics: [], + }); +} + +/** File entries JSON from the most recent analyzeProject invocation. */ +function lastAnalyzedEntries(): Array<{ path: string; source: string }> { + const calls = mocks.analyzeProject.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const args = calls[calls.length - 1] as unknown[]; + const filesArg = args.find( + (arg): arg is string => typeof arg === 'string' && arg.startsWith('[') + ); + expect(filesArg).toBeDefined(); + return JSON.parse(filesArg!); +} + +beforeEach(() => { + mocks.loadSystemModule.mockReset().mockReturnValue({ ...SYSTEM_CONFIG }); + mocks.analyzeProject.mockReset(); + mocks.clearAnalysisCache.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +async function startSession(root: string, assetPath: string) { + mocks.analyzeProject.mockImplementation(() => buildManifest(assetPath)); + const session = new ExtractionSession({ system: './src/system.ts' }); + session.rootDir = root; + await session.runFullPipeline(); + // The asset is a registered watch dependency after the full pipeline + // (require.resolve canonicalizes, so compare realpaths). + expect(session.assetDependencyPaths.has(realpathSync(assetPath))).toBe(true); + return session; +} + +describe('handleWatchUpdate asset+component batches', () => { + test('a component edit in the same batch as an asset change is analyzed fresh', async () => { + const { root, assetPath } = createProject(); + const session = await startSession(root, assetPath); + + const buttonPath = join(root, 'src', 'Button.tsx'); + writeFileSync(buttonPath, BUTTON_SOURCE_CHANGED); + writeFileSync(assetPath, 'touched'); + + await session.handleWatchUpdate({ + modifiedFiles: new Set([assetPath, buttonPath]), + removedFiles: new Set(), + }); + + const button = lastAnalyzedEntries().find( + (entry) => entry.path === relative(root, buttonPath) + ); + expect(button).toBeDefined(); + expect(button!.source).toBe(BUTTON_SOURCE_CHANGED); + }); + + test('a removal in the same batch as an asset change is pruned, not replayed', async () => { + const { root, assetPath } = createProject(); + const session = await startSession(root, assetPath); + + const buttonPath = join(root, 'src', 'Button.tsx'); + rmSync(buttonPath); + writeFileSync(assetPath, 'touched'); + + await session.handleWatchUpdate({ + modifiedFiles: new Set([assetPath]), + removedFiles: new Set([buttonPath]), + }); + + const ghost = lastAnalyzedEntries().find( + (entry) => entry.path === relative(root, buttonPath) + ); + expect(ghost).toBeUndefined(); + }); + + test('an asset-only batch still re-analyzes (asset substitution refresh)', async () => { + const { root, assetPath } = createProject(); + const session = await startSession(root, assetPath); + const callsBefore = mocks.analyzeProject.mock.calls.length; + + writeFileSync(assetPath, 'touched'); + await session.handleWatchUpdate({ + modifiedFiles: new Set([assetPath]), + removedFiles: new Set(), + }); + + expect(mocks.analyzeProject.mock.calls.length).toBe(callsBefore + 1); + }); +}); diff --git a/packages/showcase/src/components/docs/ColorPalette.tsx b/packages/showcase/src/components/docs/ColorPalette.tsx index ec6224f6..ac1ebfaf 100644 --- a/packages/showcase/src/components/docs/ColorPalette.tsx +++ b/packages/showcase/src/components/docs/ColorPalette.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { SYSTEM_MODE, persistColorMode } from '@animus-ui/system/appearance'; -import { ds, tokens } from '../../ds'; +import { ds, theme } from '../../ds'; // ─── Mode Preview Data ──────────────────────────────────────────── // Hardcoded hex values from ds.ts color mode definitions. @@ -34,7 +34,7 @@ export const MODE_NAMES: readonly string[] = MODES.map((m) => m.name); const SYSTEM_PREFERENCE = requireSystemPreference(); function requireSystemPreference() { - const mapping = tokens.manifest.systemPreference; + const mapping = theme.manifest.systemPreference; if (!mapping) { throw new Error( 'ColorPalette: the showcase theme must declare systemPreference — the System swatch previews its mapping.' diff --git a/packages/showcase/src/components/surfaces/SyntaxBlock.tsx b/packages/showcase/src/components/surfaces/SyntaxBlock.tsx index cd75f684..e4504cbc 100644 --- a/packages/showcase/src/components/surfaces/SyntaxBlock.tsx +++ b/packages/showcase/src/components/surfaces/SyntaxBlock.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { ChevronDown } from 'lucide-react'; import { Highlight, type PrismTheme } from 'prism-react-renderer'; -import { ds, tokens } from '../../ds'; +import { ds, theme } from '../../ds'; import { CopyButton } from '../docs/CopyButton'; // ─── Styled Elements ───────────────────────────────────────────── @@ -236,48 +236,48 @@ const LineNumberSpan = ds const animusTheme: PrismTheme = { plain: { - color: tokens.varRef('colors.text'), + color: theme.varRef('colors.text'), backgroundColor: 'transparent', }, styles: [ { types: ['keyword', 'atrule'], - style: { color: tokens.varRef('colors.primary') }, + style: { color: theme.varRef('colors.primary') }, }, { types: ['string', 'attr-value'], - style: { color: tokens.varRef('colors.status.success') }, + style: { color: theme.varRef('colors.status.success') }, }, { types: ['number'], - style: { color: tokens.varRef('colors.accent') }, + style: { color: theme.varRef('colors.accent') }, }, { types: ['comment'], style: { - color: tokens.varRef('colors.text.muted'), + color: theme.varRef('colors.text.muted'), fontStyle: 'italic' as const, }, }, { types: ['property', 'function'], - style: { color: tokens.varRef('colors.secondary') }, + style: { color: theme.varRef('colors.secondary') }, }, { types: ['selector', 'class-name', 'maybe-class-name', 'tag'], - style: { color: tokens.varRef('colors.status.warning') }, + style: { color: theme.varRef('colors.status.warning') }, }, { types: ['punctuation', 'operator'], - style: { color: tokens.varRef('colors.text.muted') }, + style: { color: theme.varRef('colors.text.muted') }, }, { types: ['builtin', 'constant'], - style: { color: tokens.varRef('colors.accent') }, + style: { color: theme.varRef('colors.accent') }, }, { types: ['attr-name'], - style: { color: tokens.varRef('colors.secondary') }, + style: { color: theme.varRef('colors.secondary') }, }, ], }; diff --git a/packages/showcase/src/constants/docsNav.ts b/packages/showcase/src/constants/docsNav.ts index 549e4565..532e36b2 100644 --- a/packages/showcase/src/constants/docsNav.ts +++ b/packages/showcase/src/constants/docsNav.ts @@ -49,6 +49,10 @@ export const DOCS_NAV: NavEntry[] = [ label: 'Theme Extension', path: '/docs/architecture/theme-extension', }, + { + label: 'Library Authoring', + path: '/docs/architecture/library-authoring', + }, { label: 'Global Styles', path: '/docs/architecture/global-styles' }, ], }, diff --git a/packages/showcase/src/content/advanced/extraction.mdx b/packages/showcase/src/content/advanced/extraction.mdx index e33fcf12..ce84123f 100644 --- a/packages/showcase/src/content/advanced/extraction.mdx +++ b/packages/showcase/src/content/advanced/extraction.mdx @@ -92,11 +92,11 @@ This page explains how that pipeline works from source to CSS. Getting style information from a TypeScript design system into a Rust crate requires crossing four serialization boundaries. ``` -tokens.serialize() → JSON string → bun subprocess → NAPI analyzeProject() → CSS string +theme.serialize() → JSON string → bun subprocess → NAPI analyzeProject() → CSS string (TypeScript) (IPC) (ESM isolation) (Rust NAPI) (output) ``` -**Boundary 1 — TypeScript to JSON.** `tokens.serialize()` is called by a bun subprocess that imports your system module. It flattens the token tree into `scalesJson`, `variableMapJson`, `variableCss`, and `contextualVarsJson` — plain JSON strings the Rust crate can deserialize. +**Boundary 1 — TypeScript to JSON.** `theme.serialize()` is called by a bun subprocess that imports your system module. It flattens the token tree into `scalesJson`, `variableMapJson`, `variableCss`, and `contextualVarsJson` — plain JSON strings the Rust crate can deserialize. **Boundary 2 — Subprocess IPC.** The plugin spawns a bun subprocess because the system module uses top-level imports that cannot be loaded into Vite's CJS plugin context. The subprocess returns the serialized output over stdout; the plugin reads and stores it. diff --git a/packages/showcase/src/content/advanced/typescript.mdx b/packages/showcase/src/content/advanced/typescript.mdx index 51e71b31..94989752 100644 --- a/packages/showcase/src/content/advanced/typescript.mdx +++ b/packages/showcase/src/content/advanced/typescript.mdx @@ -15,14 +15,14 @@ The `Theme` interface in `@animus-ui/system` is an empty baseline. Augmenting it ```ts // ds.ts -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ sm: 768, lg: 1200 }) .addColors({ ... }) .addColorModes('dark', { ... }) .addScale({ name: 'space', values: [0, 4, 8, 16, 24, 32, 48, 64] }) .build(); -export type AppTheme = typeof tokens; +export type AppTheme = typeof theme; declare module '@animus-ui/system' { interface Theme extends AppTheme {} @@ -34,7 +34,7 @@ declare module '@animus-ui/system' { that is already an ES module (has at least one `import` or `export`). Placing it in a plain script file (no imports/exports) makes it a global declaration, not augmentation, and it may conflict with the module's own interface. The - standard pattern is `ds.ts` — which exports `tokens`, `ds`, and `AppTheme` — + standard pattern is `ds.ts` — which exports `theme`, `ds`, and `AppTheme` — with the `declare module` block at the bottom. TypeScript must also include this file in compilation (via `tsconfig.json` `include` or a direct import) for the augmentation to take effect project-wide. @@ -267,7 +267,7 @@ interface Parser, T extends BaseTheme> { ## Path & Scale Types -These utilities power `EmittedTokenPaths` and the token ref type system. You typically consume them through `EmittedTokenPaths` rather than directly, but understanding the primitives helps when building tooling around the theme graph. +These utilities power `EmittedTokenPaths` and the token ref type system. You typically consume them through `EmittedTokenPaths` rather than directly, but understanding the primitives helps when building tooling around the theme graph. ### FindPath @@ -368,7 +368,7 @@ Reduces all paths to a flat map with primitive leaf values. Intermediate object import type { EmittedTokenPaths } from '@animus-ui/system'; // All valid {scale.key} ref paths for emitted token scales -type Refs = EmittedTokenPaths; +type Refs = EmittedTokenPaths; // → 'colors.primary' | 'colors.bg' | 'colors.text' | 'space.8' | ... ``` diff --git a/packages/showcase/src/content/architecture/library-authoring.mdx b/packages/showcase/src/content/architecture/library-authoring.mdx new file mode 100644 index 00000000..5c5f254f --- /dev/null +++ b/packages/showcase/src/content/architecture/library-authoring.mdx @@ -0,0 +1,94 @@ +import { Callout } from '../../components/docs/Callout'; + +# Library Authoring + +A design system library ships two built values — a system and a theme — and consumers compose them with `.extend()`. This page documents the definition-entry contract: what a published library exports, from where, and what must stay out of its published types. + +--- + +## The Definition Entry + +Expose your built system and built theme as named exports **`system`** and **`theme`** from one entry module — either the package root or a dedicated subpath such as `/definition`: + +```ts +// src/definition.ts (or your package root) +import { createSystem, createTheme } from '@animus-ui/system'; +import { color, border, space, typography } from '@animus-ui/system/groups'; + +export const theme = createTheme() + .addBreakpoints({ sm: 640, md: 768, lg: 1024 }) + .addColors({ gray: { 100: '#f5f5f5', 900: '#171717' } }) + .build(); + +export const { system } = createSystem() + .addGroup('space', space) + .addGroup('text', typography) + .addGroup('surface', { ...color, ...border }) + .build(); +``` + +Alternatively, ship one bundle export carrying both halves: + +```ts +import type { LibraryBundle } from '@animus-ui/system'; + +export const kit = { system, theme } satisfies LibraryBundle; +``` + +Use `satisfies`, not a `: LibraryBundle` annotation. A broad annotation erases +the concrete system and theme generics before a consumer can infer them. + +Consumers extend the named exports directly — each builder takes the value it understands, and a bundle works on both builders (each consumes its half and ignores the rest): + +```ts +// consumer ds.ts +import { system as kitSystem, theme as kitTheme } from '@acme/kit/definition'; + +export const theme = createTheme().extend(kitTheme).build(); +export const { system: ds } = createSystem().extend(kitSystem).build(); +``` + +Extraction discovers your package through the same imported identifiers the consumer passes to `.extend()` — the discovery tracer preserves `/definition` for host resolution, then derives package ownership separately while walking the package sources. + +Rules: + +- **Nothing else from that module.** The definition entry exports `system` and `theme` (or one bundle) and nothing more. Components, hooks, and utilities live behind other entry points. This keeps the module the extraction sandbox evaluates small and side-effect free. +- **`theme` is the name.** The loader prefers a `theme` export and accepts the legacy `tokens` spelling during the deprecation window. New libraries use `theme`; exporting two _distinct_ built themes under both names is a load error. +- **Re-export is valid consumption.** A consumer may re-export your `system` and `theme` unchanged instead of extending — the application still builds and extracts against the library definition as-is. +- **Create transforms with `createTransform`.** Cross-system transform equality is the transform's name plus the source text `createTransform` captures at creation — a consumer re-registering your prop with your own exported definition coalesces cleanly, while a same-named transform with a different body fails loud. Bare inline arrow transforms compare by their own source text; prefer named transforms so conflicts carry a useful name. + +--- + +## Theme Augmentation Stays Dev-Only + +A library augments the compilation-global `Theme` interface **only during its own development**, never in its published types. The final consumer owns `Theme`; a library-published augmentation would intersection-narrow it and silently corrupt the consumer's token types. + +The convention (used by the in-repo reference library `@animus-ui/test-ds`): keep the augmentation in a dedicated `dev-types.ts` module that nothing in the entry graph imports, and exclude the emitted `dev-types.d.ts` from the published declaration output. + +```ts +// src/dev-types.ts — dev-only, excluded from the published surface +import type { theme } from './definition'; + +type KitTheme = typeof theme; + +declare module '@animus-ui/system' { + interface Theme extends KitTheme {} +} +``` + + + Verify before publishing: no `.d.ts` reachable from your definition entry may + contain a `declare module '@animus-ui/system'` block augmenting `Theme`. + Keeping the augmentation file in the build program is fine (your components + need it to type-check) — strip its emitted declaration from the package + output. + + +--- + +## Keep Your Dist Fresh + +Under `.extend()` the consumer's extraction sandbox evaluates your **built dist**, while discovery walks your **src** tree when the published package includes it. A stale dist would silently change the merged registries, so discovery reports when a consumed package's dist entry is older than its newest source file. Non-strict development mode warns; strict extraction fails the build. In a workspace, rebuild the library before running a consumer's production build. diff --git a/packages/showcase/src/content/architecture/system-setup.mdx b/packages/showcase/src/content/architecture/system-setup.mdx index 4f618629..3fc9cc8f 100644 --- a/packages/showcase/src/content/architecture/system-setup.mdx +++ b/packages/showcase/src/content/architecture/system-setup.mdx @@ -254,49 +254,65 @@ ds.styles({ ## External System Composition -### includes (constructor arg) +### extend() -`includes` is a **static-analysis marker** consumed by the extraction pipeline. The Rust/TS analyzer reads the constructor-argument AST, traces each identifier back to its import declaration, resolves the package specifier, and walks the package's source tree for components to extract. Runtime is no-op by design — all work happens at compile time. This is the authoritative mechanism for multi-package design-system composition. +`.extend()` is the extension verb. It performs a **real merge**: the source's prop, group, selector, and condition registries become part of this builder, so the built system's inferred types, its `toConfig()` output, and extraction reachability all describe the same merged configuration. The extraction pipeline traces the same `.extend(identifier)` edge to its import and discovers the source package for component extraction — one edge, three consequences. ```ts -import { ds as baseDs } from '@acme/design-system'; +import { system as baseDs } from '@acme/design-system'; +import { transitions } from '@animus-ui/system/groups'; -export const { system: ds } = createSystem({ - includes: [baseDs], -}) - .addGroup('surface', { ...color, ...border }) +export const { system: ds } = createSystem() + .extend(baseDs) + // Additive only: register what the kit does NOT provide. The kit's own + // groups/props arrive through the merge and are not re-registered. + .addGroup('motion', transitions) + .addProps({ cursor: { property: 'cursor' } }) .build(); ``` - - `includes` does not merge the external system's prop, group, or selector - registries into this builder. Those registries stay independent. The - mechanism's job is package discovery for the extraction pipeline — enabling - components from both systems to appear in your app and be extracted — not - runtime composition. If you need the external system's prop definitions in - your builder, register them explicitly with `addGroup` or `addProps`. +Merge rules: + +- **Inherit first.** `.extend()` is only callable before any `addGroup` / `addProps` / `addSelectors` / `addConditions` call — "inherit first, then extend" is a compile error, not a lint. It is repeatable: chain one `.extend()` per consumed library. +- **Identical definitions coalesce — transforms by name + captured source.** Re-registering a prop with a byte-identical definition is allowed, exactly like the within-builder overlap rule. Transform equality is the same-instance fast path, then **`transformName` plus the source text captured by `createTransform`** — so a duplicated install of the same library version still coalesces, while a same-named transform with a different body fails loud. A name match against a transform built by an older release (no captured source) also fails loud rather than guessing. +- **Divergent definitions fail loud.** A prop defined differently by an extended source and builder state throws, naming both origins — including your own post-extend `addGroup`/`addProps` redefinition of a kit prop. Two extended sources that disagree (sibling conflict) also throw — sibling order is never used to pick a winner. +- **What the app layer changes post-extend.** New props and groups are additive; an `addGroup` under an inherited group name replaces that group's **membership**; selector and condition aliases registered locally override the inherited entries silently. Prop _definitions_ themselves are never silently rebound. + +### Deprecated: includes / from() + + + `createSystem({ includes: [...] })` and `createSystem().from(source)` are + deprecated. Both keep their exact pre-`extend()` semantics for at least one + minor release: **a discovery anchor with no runtime registry merge**; + `from()` additionally preserves its legacy type admission, while `includes` + does not admit source types. Under either form the external system's + registries stay independent — a prop the source registers is not in your + `toConfig()` output unless you re-register it yourself. Migrate by replacing either form with + `.extend(source)` and **deleting the now-duplicate re-registrations** — the + merge provides them; identical re-registrations coalesce, and any divergent + one fails loud naming both origins. --- @@ -413,9 +429,9 @@ const ratio = createTransform('ratio', (value) => { return str.includes(':') ? str.replace(':', ' / ') : str; }); -// ── Tokens ──────────────────────────────────────────────────── +// ── Theme ───────────────────────────────────────────────────── -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ sm: 768, md: 1024, lg: 1280 }) .addColors({ brand: { 500: '#FF2800', 600: '#E63946' }, diff --git a/packages/showcase/src/content/architecture/theme-extension.mdx b/packages/showcase/src/content/architecture/theme-extension.mdx index c0e69ddd..b299a445 100644 --- a/packages/showcase/src/content/architecture/theme-extension.mdx +++ b/packages/showcase/src/content/architecture/theme-extension.mdx @@ -7,36 +7,36 @@ import { APIBlock } from '../../components/docs/APIBlock'; # Theme Extension -Consuming a design system library means you receive a finished theme you cannot modify at the source. This page covers the three mechanisms for extending that theme without touching library code: `.from()` to inherit its token data, `.extendScale()` to append values to existing scales, and module augmentation to surface the extended type in `.styles()`. +Consuming a design system library means you receive a finished theme you cannot modify at the source. This page covers the mechanisms for extending that theme without touching library code: `.extend()` to inherit its complete configuration (with your local calls winning on conflict), `.extendScale()` to append values to existing scales, and module augmentation to surface the extended type in `.styles()`. The deprecated `.from()` is documented at the end with its frozen semantics. --- -## The .from() Pattern +## The .extend() Pattern -`.from()` ingests a built theme and merges its raw token data into the new builder's state. Functions on the source (including `manifest`, `serialize`, and `varRef`) are stripped — only enumerable data keys are carried forward. Emitted scale tracking and contextual variable registrations are copied from the source's manifest. +`.extend()` seeds the builder from a built theme (or a library bundle's theme half): tokens, modes, preferences, emitted-scale tracking, and `@property` registrations are all inherited as the **base**, and every call you make after it **wins on conflict**. It is only callable first — "inherit first, then extend" is enforced at the type level — and is repeatable for consuming several libraries. } @@ -45,28 +45,42 @@ outputLabel="result" ', + name: 'source', + type: 'BuiltTheme | { system, theme }', default: 'required', - desc: 'A built theme returned by .build(). All enumerable non-function properties are deep-merged. The source manifest is read to populate emittedScales and contextualVars.', + desc: 'A built theme returned by .build(), or a library bundle — extend() consumes the theme half and ignores the rest. Data, emission state, mode metadata, and registrations are inherited; a manifest round-trip is never substituted for live state.', }, ]} /> - - If the source theme has colors, call `.from()` before `.addColors()`. Both - operations write to `emittedScales`. Calling `.from()` first ensures the - source's emitted scale registrations are present before your own `addColors()` - extends them. +### Precedence: extend() is base-then-local-wins + +`.extend(kit)` reads like `class App extends Kit` — the kit is the base and the app overrides. This is the **mirror image** of the deprecated `.from()`, which is source-wins. Because `.extend()` must precede every augmentation call, precedence is positional and unambiguous: everything after the extend seed overrides it. + +```ts +// kit declares colors.accent = '#FFB627' +const theme = createTheme() + .extend(kitTheme) + .addColors({ accent: '#C1121F' }) // local wins: accent is #C1121F + .build(); +``` + +Two _extended sources_ are different: siblings have no hierarchy, so a leaf path they define divergently is a **hard error** naming the path and both sources. Equal values coalesce silently. Override a sibling disagreement intentionally with your own `add*` call after the extends. + + + During the deprecation window both verbs exist, but they must not appear in + the same chain: a path defined by both sources resolves to the **`from()` + source silently**, in either chain order — `from()` keeps its frozen + source-wins merge. Migrate a chain to `.extend()` wholesale or leave it on + `.from()` until you can. - - `.from()` copies mode data from the source manifest into the merged theme, but - if you need to validate new mode aliases against the combined color palette, - call `.addColorModes()` after `.from()` and `.addColors()`. + + Overriding an inherited token with a different literal value narrows the + inferred type to **your** literal — `theme.colors.accent` above types as the + local override, matching the runtime value. The merge is modeled as a + later-wins record merge, not a naive intersection, so conflicting literals + never collapse to `never`. --- @@ -79,14 +93,14 @@ outputLabel="result" name="extendScale" description="Appends or overrides values in an existing named scale. The update function receives the current typed scale values and returns additional entries to merge." returnType="ThemeBuilder" - available="addScale() or from() (scale must exist)" + available="addScale() or extend() (scale must exist)" example={ ({ ...current, @@ -103,7 +117,7 @@ export const tokens = createTheme() output={`// space scale now includes library values + 72, 96, 128 // fontSizes scale now includes library values + display // TypeScript knows about all extended keys`} - inputLabel="ds/tokens.ts" + inputLabel="ds.ts" outputLabel="result" /> } @@ -115,7 +129,7 @@ export const tokens = createTheme() name: 'key', type: 'keyof T', default: 'required', - desc: 'The name of the scale to extend. Must already exist on the builder — via addScale(), addColors(), or from().', + desc: 'The name of the scale to extend. Must already exist on the builder — via addScale(), addColors(), or extend().', }, { name: 'updateFn', @@ -141,7 +155,7 @@ The return type intersects the existing scale type with the update function's re Calling `.extendScale()` with a key that does not exist on the current builder is a type error. If you are extending a library scale that arrives via - `.from()`, the scale exists in the type state and TypeScript accepts it. If + `.extend()`, the scale exists in the type state and TypeScript accepts it. If the scale is not yet on the builder, call `.addScale()` first. @@ -152,9 +166,9 @@ The return type intersects the existing scale type with the update function's re The `Theme` interface in `@animus-ui/system` is an empty, augmentable interface. When you extend it with your built theme's type, `.styles()`, `.variant()`, and `.states()` gain typed autocomplete for your scale keys. ```ts -// ds.ts — after building tokens +// ds.ts — after building the theme -export type AppTheme = typeof tokens; +export type AppTheme = typeof theme; declare module '@animus-ui/system' { // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -175,7 +189,7 @@ ds.styles({ color: 'brand' }); // ✓ ds.styles({ fontSize: 'unknown' }); // ✗ — not in your fontSizes scale ``` -`typeof tokens` is the built theme type returned by `.build()`. The `BuiltTheme` shape attaches `manifest`, `serialize`, and `varRef` as non-enumerable phantom properties — they do not participate in augmentation. Only the scale data (colors, space, fontSizes, etc.) enters the `Theme` interface. +`typeof theme` is the built theme type returned by `.build()`. The `BuiltTheme` shape attaches `manifest`, `serialize`, and `varRef` as non-enumerable phantom properties — they do not participate in augmentation. Only the scale data (colors, space, fontSizes, etc.) enters the `Theme` interface. ### Theme interface @@ -189,7 +203,7 @@ When `Theme` is not augmented, CSS property values fall back to standard CSS typ ## Theme Merge Semantics -`.from()` and `.extendScale()` both call the internal `merge()` utility, which implements the same semantics exposed through the public type utilities. Understanding the merge rules explains when extension is safe and when values are replaced wholesale. +`.extendScale()` and the deprecated `.from()` both call the internal `merge()` utility, which implements the same semantics exposed through the public type utilities. Understanding the merge rules explains when extension is safe and when values are replaced wholesale. (`.extend()` shares the same value-level deep-merge shape but adds per-leaf provenance on top — that is what turns a sibling disagreement into an error instead of a silent overwrite, and what lets your own later calls win.) ### MergeTheme @@ -305,3 +319,24 @@ The runtime consequence: a scale value that is a function, array, or primitive i color nesting beyond two levels is uncommon in practice but worth knowing if you define deeply nested token groups. + +--- + +## Deprecated: The .from() Pattern + + + `.from()` is deprecated in favor of `.extend()` and keeps its exact frozen + semantics for at least one minor release: **source-wins** precedence — the + ingested theme's values override anything already on the chain — and + call-at-any-stage ordering. `.extend()` flips both (base-then-local-wins, + inherit-first); the flip ships only under the new name, so no existing + `.from()` call changes meaning. Never combine the two verbs in one chain (see + the warning above — shared paths resolve to the `from()` source silently). + + +`.from()` ingests a built theme and merges its raw token data into the new builder's state. Functions on the source (including `manifest`, `serialize`, and `varRef`) are stripped — only enumerable data keys are carried forward. Emitted scale tracking and contextual variable registrations are copied from the source's manifest. + +Because `.from()` is source-wins and callable anywhere, ordering folklore applies to it that `.extend()` deletes by construction: + +- **Call order with colors** — if the source theme has colors, call `.from()` before `.addColors()`, so the source's emitted scale registrations are present before your own `addColors()` extends them. +- **Mode aliases** — to validate new mode aliases against the combined color palette, call `.addColorModes()` after `.from()` and `.addColors()`. diff --git a/packages/showcase/src/content/architecture/theming.mdx b/packages/showcase/src/content/architecture/theming.mdx index 9ebccf8d..270857c6 100644 --- a/packages/showcase/src/content/architecture/theming.mdx +++ b/packages/showcase/src/content/architecture/theming.mdx @@ -51,7 +51,7 @@ The theme builder converts nested token definitions into a serialized contract t layer: 'finalize', description: 'Flattens the accumulated state. Attaches manifest, serialize(), and varRef() as non-enumerable properties on the returned object.', - code: ` .build();\n\nexport { tokens };`, + code: ` .build();\n\nexport { theme };`, }, ]} /> @@ -333,7 +333,7 @@ Calling `.build()` finalizes the theme and returns a `BuiltTheme`. Three non-enu ### SerializedTheme -`tokens.serialize()` produces the four fields consumed by the extraction pipeline: +`theme.serialize()` produces the four fields consumed by the extraction pipeline: -The Vite plugin calls `tokens.serialize()` once during `buildStart`. The Rust crate receives `scalesJson` and `variableMapJson` as inputs to its resolver. `variableCss` is injected verbatim into the virtual CSS module. +The Vite plugin calls `theme.serialize()` once during `buildStart`. The Rust crate receives `scalesJson` and `variableMapJson` as inputs to its resolver. `variableCss` is injected verbatim into the virtual CSS module. ### manifest -`tokens.manifest` is the unserialized form of the same data: +`theme.manifest` is the unserialized form of the same data: | { system: SystemInstance; theme?: BuiltTheme }', }, ]} - returns="SystemBuilder" + returns="SystemBuilder" /> - - `includes` is a static-analysis marker, not a type-level fiction or a runtime - operation. The extraction pipeline (`discover-packages.ts`) reads the - constructor-argument AST, traces each identifier back to its import - declaration, resolves the package specifier, and walks the package's source - tree for components to extract. Runtime is no-op by design — all work has - already happened at compile time. This is the authoritative mechanism for - multi-package design-system composition. - +`.extend()` performs a real registry merge: every prop, group, selector alias, and condition alias the source registers becomes part of this builder. The built system's type surface and its `toConfig()` output describe the same merged configuration, and the extraction pipeline traces the `.extend(identifier)` chain edge back to its import to discover the source package for component extraction. ```ts -import { ds as baseDs } from '@acme/design-system'; +import { system as baseDs } from '@acme/design-system'; +import { transitions } from '@animus-ui/system/groups'; -export const { system: ds } = createSystem({ - includes: [baseDs], -}) - .addGroup('surface', { ...color, ...border }) +export const { system: ds } = createSystem() + .extend(baseDs) + // Additive only — the kit's groups and props arrive through the merge. + .addGroup('motion', transitions) .build(); ``` +Rules: + +- **Inherit first.** `.extend()` is only callable before any extension call (`addGroup`, `addProps`, `addSelectors`, `addConditions`) — enforced at the type level, so "extend after add" is a compile error. Repeatable: chain one call per consumed library. +- **Coalesce or fail.** The within-builder overlap rule applies across systems: identical definitions coalesce; a divergent redefinition throws, naming both origins. Transform equality is the same-instance fast path, then **`transformName` + the source captured by `createTransform`** — duplicated installs of one library still coalesce; a same-named transform with a different body, or a name match against an older no-source instance, fails loud. +- **Sibling conflicts are errors.** Two extended sources that define the same name divergently throw an error naming both sources — extension order never picks a winner. +- **The app layer adds and re-shapes; it does not rebind.** New props/groups are additive; `addGroup` under an inherited group name replaces that group's membership; local selector/condition aliases override inherited entries silently. Redefining an inherited prop's _definition_ throws, naming both origins. + +### Deprecated: includes / from() + + + `createSystem({ includes: [externalDs] })` and `createSystem().from(externalDs)` + are deprecated in favor of `.extend()`. For at least one minor release they + keep their exact historical behavior: both are **discovery anchors with no + runtime registry merge**; `from()` additionally admits the source's types, + while `includes` does not. The external registries stay independent, and + props the source registers are absent from your `toConfig()` output unless + re-registered locally. New code should never mix the legacy forms with + `.extend()` in one chain. + + --- ## build() diff --git a/packages/showcase/src/content/reference/create-theme.mdx b/packages/showcase/src/content/reference/create-theme.mdx index 063a08cc..42c4988b 100644 --- a/packages/showcase/src/content/reference/create-theme.mdx +++ b/packages/showcase/src/content/reference/create-theme.mdx @@ -17,7 +17,7 @@ import { createTheme } from '@animus-ui/system'; ## ThemeBuilder Chain -Every method returns a new `ThemeBuilder` instance with an updated type state. The chain is order-independent — methods may be called in any sequence before `.build()`. +Every method returns a new `ThemeBuilder` instance with an updated type state. The augmentation methods are order-independent — they may be called in any sequence before `.build()` — with one positional rule: `.extend()` is only callable first, before any augmentation call ("inherit first, then extend", enforced at the type level). export const themeSteps = [ { @@ -51,11 +51,11 @@ export const themeSteps = [ repeatable: true, }, { - label: 'extendScale() / from()', + label: 'extend() / extendScale()', layer: 'extend', description: - 'extendScale() merges values into an existing scale. from() ingests a prior built theme.', - code: `.extendScale('colors', (c) => ({ ...c, brand: '{colors.violet.500}' }))\n.from(baseTokens)`, + 'extend() seeds the chain from a consumed library theme (first, before any add* call; later local calls win). extendScale() merges values into an existing scale. The deprecated from() keeps its frozen source-wins semantics during the deprecation window.', + code: `createTheme()\n .extend(kitTheme)\n .extendScale('colors', (c) => ({ ...c, brand: '{colors.violet.500}' }))`, repeatable: true, }, { @@ -374,13 +374,86 @@ createTheme() Values passed to `extendScale` may use `{'{'}scale.key{'}'}` token reference - syntax. Refs are resolved at `build()` time — the referenced scale must be - emitted for the ref to resolve to a `var()`. + syntax. Refs are resolved at `build()` time: a ref to an **emitted** target + becomes a `var()` reference; a ref to a non-emitted target resolves to the + target's raw value inline. Either way the resolved value is the same one the + target path carries — one value per path per compilation. --- -## from() +## extend() + +Extends the chain from a consumed library theme. The source's complete configuration — tokens, modes, preferences, registrations, emitted-scale set — seeds the builder as the **base**, and local calls made after `.extend()` **win on conflict**. Accepts a built theme or a library bundle (`{ system, theme }`), consuming the bundle's theme half and ignoring the rest. + + + + + + +```ts +import { theme as kitTheme } from '@acme/design-system'; + +export const theme = createTheme() + .extend(kitTheme) // kit is the base + .addColors({ brand: '#FF2800' }) // local wins on conflict + .extendScale('space', (s) => ({ ...s, 128: '8rem' })) // structural add + .build(); +``` + +Rules (all verified behavior): + +- **Inherit first.** `.extend()` is only callable before any augmentation method — calling it after `addColors()` (or any other `add*`/`extendScale` call) is a compile error. Repeatable: chain one call per consumed theme. +- **Base-then-local-wins precedence.** `.extend(kit)` reads like `class App extends Kit`: the kit seeds values, your later calls override them. This is the **mirror** of the deprecated `from()`, which is source-wins. +- **Sibling conflicts fail loud.** Two extended themes that define the same leaf path divergently throw at the second `.extend()`, naming the path and both sources positionally. Equal values coalesce silently; your own post-extend calls override silently. +- **Structure is progressive.** Extension merges scales by key — implicitly deleting an inherited token is impossible, and a reference left dangling by a structural change is a `build()`-time error. + + + During the deprecation window a chain can technically call both verbs, but a + path defined by both the `extend()` source and the `from()` source resolves to + the **`from()` source silently** — `from()` keeps its frozen source-wins merge + and runs after the extend seed, regardless of which verb appears first in the + chain. Pick one verb per chain; migrate `from()` chains wholesale. + + + + When a local call overrides an extended token with a **different literal** + (e.g. the kit says `accent: '#FFB627'`, you say `accent: '#C1121F'`), the + admission intersection of the two literal types collapses the scale's + inferred object type to `never` — reading `theme.colors.accent` becomes a + type error ("property has conflicting types in some constituents") even + though the runtime value is correct (yours wins). Read through a cast, e.g. + `(theme.colors as Record).accent`, until the type-level + override story lands. Token usage through `.styles()`/`varRef()` string + paths is unaffected. + + +--- + +## from() — deprecated + + + `from()` is deprecated in favor of `.extend()`. It keeps its exact frozen + semantics for at least one minor release: **source-wins** precedence (the + ingested theme's values override anything already on the chain) and + call-at-any-stage. `.extend()` flips the precedence (base-then-local-wins) and + is inherit-first; the flip ships **only** under the new name, so no existing + `from()` call changes meaning. + Ingests a previously built theme, merging all enumerable data into the current chain. Non-enumerable methods (`serialize`, `varRef`) are stripped. Emission state and contextual var declarations from the source manifest are imported. @@ -448,7 +521,7 @@ Finalizes the builder chain. Flattens all scale data, resolves token references, ```ts -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ sm: 640, md: 768, lg: 1024, xl: 1280 }) .addColors({ violet: { 500: '#8b5cf6', 700: '#6d28d9' } }) .addColorModes('dark', { @@ -488,18 +561,18 @@ The value returned by `.build()`. All scale data is enumerable on the object. Th ```ts // manifest — read by the plugin -tokens.manifest.variableCss; +theme.manifest.variableCss; // → ':root {\n --color-violet-500: #8b5cf6;\n ...\n}' // serialize() — consumed by the Vite plugin subprocess -tokens.serialize(); +theme.serialize(); // → { scalesJson, variableMapJson, variableCss, contextualVarsJson } // varRef() — runtime token lookup -tokens.varRef('colors.violet.500'); +theme.varRef('colors.violet.500'); // → 'var(--color-violet-500)' (emitted scale) -tokens.varRef('space.4'); +theme.varRef('space.4'); // → '16' (non-emitted scale — returns raw value) ``` @@ -520,10 +593,13 @@ createTheme() }); ``` - - A token ref that points to the same scale it lives in (e.g., `{'{'}space.4 - {'}'}` inside the `space` scale) is skipped with a console warning. - Cross-scale refs only. + + A token ref may point into the same scale it lives in (e.g., `{'{'}sizes.a + {'}'}` inside the `sizes` scale) — references are just edges in the resolution + DAG, resolved in dependency order after the complete merge. A reference + **cycle** is a build-time error naming the cycle. Every path gets exactly one + resolved value per compilation, identical whether the scale is emitted or + inlined. ### CSS Variable Naming @@ -537,16 +613,18 @@ createTheme() ### TypeScript Theme Augmentation -To get typed token access in `ds.styles()`, augment the module declaration with your built theme type: +To get typed token access in `ds.styles()`, augment the `Theme` interface with your built theme's type: ```ts import { createTheme } from '@animus-ui/system'; -export const tokens = createTheme() +export const theme = createTheme() .addColors({ violet: { 500: '#8b5cf6' } }) - .build() + .build(); + +export type AppTheme = typeof theme; declare module '@animus-ui/system' { - interface AnimusTheme extends typeof tokens {} + interface Theme extends AppTheme {} } ``` diff --git a/packages/showcase/src/content/start.mdx b/packages/showcase/src/content/start.mdx index ddd11c15..1678fdbd 100644 --- a/packages/showcase/src/content/start.mdx +++ b/packages/showcase/src/content/start.mdx @@ -23,7 +23,7 @@ import { color, space, typography } from '@animus-ui/system/groups'; // ── Tokens ──────────────────────────────────────────────────── -const tokens = createTheme() +const theme = createTheme() .addBreakpoints({ sm: 768, md: 1024, lg: 1200 }) .addColors({ gray: { @@ -77,7 +77,7 @@ const tokens = createTheme() }) .build(); -export type AppTheme = typeof tokens; +export type AppTheme = typeof theme; declare module '@animus-ui/system' { // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/showcase/src/ds.ts b/packages/showcase/src/ds.ts index 1e808dd4..47ff5562 100644 --- a/packages/showcase/src/ds.ts +++ b/packages/showcase/src/ds.ts @@ -20,7 +20,7 @@ import { transitions, typography, } from '@animus-ui/system/groups'; -import { ds as testDs } from '@animus-ui/test-ds'; +import { system as testDs } from '@animus-ui/test-ds/definition'; // ─── Custom Transforms ────────────────────────────────────── @@ -44,7 +44,7 @@ const ratio = createTransform('ratio', (value) => { // ─── Tokens ───────────────────────────────────────────────── -export const tokens = createTheme() +export const theme = createTheme() .addBreakpoints({ '2xs': 400, xs: 480, @@ -217,6 +217,11 @@ export const tokens = createTheme() secondary: 'fire.400', accent: 'gold.300', bg: { _: 'gray.950', muted: 'gray.900', inverse: 'warm.100' }, + // test-ds kit contract (cross-source token gate): components from + // @animus-ui/test-ds resolve `background` and `danger` against the + // consumer theme — every mode aliases them to its own roles. + background: 'gray.950', + danger: 'fire.600', surface: { _: 'gray.800', hover: 'gray.700' }, text: { _: 'warm.200', muted: 'warm.400', dim: 'warm.600' }, border: { _: 'gray.600', strong: 'gray.500' }, @@ -244,6 +249,8 @@ export const tokens = createTheme() secondary: 'fire.600', accent: 'gold.700', bg: { _: 'warm.100', muted: 'warm.200', inverse: 'gray.900' }, + background: 'warm.100', + danger: 'fire.600', surface: { _: 'warm.300', hover: 'warm.400' }, text: { _: 'gray.800', muted: 'gray.500', dim: 'warm.500' }, border: { _: 'warm.300', strong: 'warm.400' }, @@ -271,6 +278,8 @@ export const tokens = createTheme() secondary: 'gold.300', accent: 'gold.200', bg: { _: 'gray.950', muted: 'gray.900', inverse: 'gray.100' }, + background: 'gray.950', + danger: 'fire.600', surface: { _: 'gray.900', hover: 'gray.800' }, text: { _: 'gray.200', muted: 'gray.400', dim: 'warm.600' }, border: { _: 'gray.700', strong: 'gray.600' }, @@ -298,6 +307,8 @@ export const tokens = createTheme() secondary: 'gold.300', accent: 'fire.200', bg: { _: 'fire.950', muted: 'fire.900', inverse: 'warm.50' }, + background: 'fire.950', + danger: 'fire.500', surface: { _: 'fire.900', hover: 'fire.800' }, text: { _: 'warm.100', muted: 'warm.400', dim: 'warm.500' }, border: { _: 'fire.800', strong: 'fire.700' }, @@ -325,6 +336,8 @@ export const tokens = createTheme() secondary: 'ocean.600', accent: 'cyan.700', bg: { _: 'ocean.50', muted: 'ocean.100', inverse: 'ocean.900' }, + background: 'ocean.50', + danger: 'fire.700', surface: { _: 'ocean.200', hover: 'ocean.300' }, text: { _: 'gray.800', muted: 'ocean.700', dim: 'ocean.600' }, border: { _: 'ocean.200', strong: 'ocean.300' }, @@ -352,6 +365,8 @@ export const tokens = createTheme() secondary: 'forest.600', accent: 'lime.700', bg: { _: 'forest.50', muted: 'forest.100', inverse: 'forest.900' }, + background: 'forest.50', + danger: 'fire.700', surface: { _: 'forest.200', hover: 'forest.300' }, text: { _: 'gray.800', muted: 'forest.700', dim: 'forest.600' }, border: { _: 'forest.200', strong: 'forest.300' }, @@ -379,6 +394,8 @@ export const tokens = createTheme() secondary: 'violet.300', accent: 'rose.400', bg: { _: 'violet.950', muted: 'violet.900', inverse: 'violet.50' }, + background: 'violet.950', + danger: 'rose.500', surface: { _: 'violet.900', hover: 'violet.800' }, text: { _: 'gray.100', muted: 'violet.300', dim: 'warm.500' }, border: { _: 'violet.800', strong: 'violet.700' }, @@ -410,6 +427,8 @@ export const tokens = createTheme() secondary: 'rose.600', accent: 'violet.600', bg: { _: 'rose.50', muted: 'rose.100', inverse: 'rose.900' }, + background: 'rose.50', + danger: 'fire.700', surface: { _: 'rose.200', hover: 'rose.300' }, text: { _: 'gray.800', muted: 'gray.500', dim: 'rose.600' }, border: { _: 'rose.200', strong: 'rose.300' }, @@ -442,6 +461,8 @@ export const tokens = createTheme() secondary: 'copper.300', accent: 'cyan.300', bg: { _: 'copper.950', muted: 'copper.900', inverse: 'copper.50' }, + background: 'copper.950', + danger: 'fire.500', surface: { _: 'copper.900', hover: 'copper.800' }, text: { _: 'warm.200', muted: 'copper.300', dim: 'copper.500' }, border: { _: 'copper.800', strong: 'copper.700' }, @@ -474,6 +495,8 @@ export const tokens = createTheme() secondary: 'copper.600', accent: 'ocean.700', bg: { _: 'copper.50', muted: 'copper.100', inverse: 'copper.900' }, + background: 'copper.50', + danger: 'fire.700', surface: { _: 'copper.200', hover: 'copper.300' }, text: { _: 'gray.800', muted: 'copper.700', dim: 'copper.600' }, border: { _: 'copper.200', strong: 'copper.300' }, @@ -669,7 +692,7 @@ export const tokens = createTheme() ) .build(); -export type ShowcaseTheme = typeof tokens; +export type ShowcaseTheme = typeof theme; declare module '@animus-ui/system' { // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -682,6 +705,14 @@ export const { system: ds, createGlobalStyles, createKeyframes, + // DELIBERATE holdout on the deprecated `includes:` alias (openspec: + // first-class-extension, inc 07/row 13): this system re-spreads + // `border`/`layout` into custom `surface`/`arrange` groups (Home.tsx + // passes `border={1}` through `surface: true`). Under restored D12 + // transform equality (name + captured source) that re-spread now + // COALESCES, so migration to `.extend(testDs)` is unblocked — it is + // deferred to registry row 13 only to keep this increment's lane sweep + // stable. Migrate there; do not add new `includes:` consumers. } = createSystem({ includes: [testDs], }) diff --git a/packages/showcase/vite.config.ts b/packages/showcase/vite.config.ts index 1cf433b5..a3386ac2 100644 --- a/packages/showcase/vite.config.ts +++ b/packages/showcase/vite.config.ts @@ -5,13 +5,13 @@ import react from '@vitejs/plugin-react'; import remarkGfm from 'remark-gfm'; import { defineConfig } from 'vite'; -import { tokens } from './src/ds'; +import { theme } from './src/ds'; // Config-time only. The generator reads the built theme's declared mode names // and returns `{ code, cspHash }`; the plugin injects `code` at the head of the // document, before any stylesheet link. Nothing under `src/` may import this // module — the storage-access snippet is build tooling, never app code. -const appearanceBootstrap = createAppearanceBootstrap(tokens); +const appearanceBootstrap = createAppearanceBootstrap(theme); export default defineConfig({ preview: { diff --git a/packages/system/README.md b/packages/system/README.md index ca29f57b..b5c07d0a 100644 --- a/packages/system/README.md +++ b/packages/system/README.md @@ -15,12 +15,12 @@ Pair with a bundler plugin for extraction: ## Quick Start -### 1. Define tokens +### 1. Define the theme ```tsx import { createTheme } from '@animus-ui/system'; -const tokens = createTheme() +const theme = createTheme() .addBreakpoints({ sm: 480, md: 768, lg: 1024 }) .addColors({ gray: { 100: '#f0f0f0', 800: '#1a1a1a' }, @@ -30,11 +30,16 @@ const tokens = createTheme() dark: { primary: 'blue.400', bg: 'gray.800', text: 'gray.100' }, light: { primary: 'blue.700', bg: 'gray.100', text: 'gray.800' }, }) - .addScale({ name: 'space', values: { sm: '0.5rem', md: '1rem', lg: '1.5rem' } }) + .addScale({ + name: 'space', + values: { sm: '0.5rem', md: '1rem', lg: '1.5rem' }, + }) .build(); +type AppTheme = typeof theme; + declare module '@animus-ui/system' { - interface Theme extends typeof tokens {} + interface Theme extends AppTheme {} } ``` @@ -63,6 +68,26 @@ export const { system: ds, createGlobalStyles } = createSystem() .build(); ``` +To build on a published design-system kit, start either chain with +`.extend()` — it merges the kit's registries/tokens into yours (kit as base, +your later calls win on conflict), and the extraction pipeline discovers the +kit through the same edge: + +```tsx +import { system as kitSystem, theme as kitTheme } from '@acme/kit'; + +const theme = createTheme().extend(kitTheme).build(); +export const { system: ds } = createSystem() + .extend(kitSystem) + // Additive only — the kit's groups/props arrive through the merge. + .addProps({ cursor: { property: 'cursor' } }) + .build(); +``` + +(`createSystem({ includes: [...] })` and `.from()` are deprecated aliases from +the pre-merge era; they keep their old no-merge semantics for one more minor +release.) + Each group becomes an opt-in set of props that components can enable via `.system()`: ```tsx @@ -142,7 +167,7 @@ The type system prevents calling methods out of order. `.variant()` after `.stat per mode. An optional third argument opts the theme into OS participation: ```tsx -const tokens = createTheme() +const theme = createTheme() .addColors({ gray: { 100: '#f0f0f0', 800: '#1a1a1a' } }) .addColorModes( 'dark', @@ -229,7 +254,7 @@ theme: ```ts import { createAppearanceBootstrap } from '@animus-ui/system/bootstrap'; -const { code, cspHash } = createAppearanceBootstrap(tokens, { +const { code, cspHash } = createAppearanceBootstrap(theme, { storageKey: 'animus:appearance', // default }); ``` diff --git a/packages/system/__tests__/extend.test.ts b/packages/system/__tests__/extend.test.ts new file mode 100644 index 00000000..4833120a --- /dev/null +++ b/packages/system/__tests__/extend.test.ts @@ -0,0 +1,631 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { + areTransformsEqual, + createSystem, + createTransform, + type Prop, + type RegistrySnapshot, + type TransformFn, +} from '../src'; +import { resolveValue } from '../src/runtime/resolveClasses'; + +function prop(overrides: Partial = {}): Prop { + return { property: 'margin', ...overrides }; +} + +function snapshotOf(system: { + getRegistrySnapshot?(): RegistrySnapshot; +}): RegistrySnapshot { + const snapshot = system.getRegistrySnapshot?.(); + if (!snapshot) { + throw new Error('expected a built system to carry a registry snapshot'); + } + return snapshot; +} + +describe('areTransformsEqual (design D12)', () => { + const named = () => createTransform('px', (v) => `${v}px`); + + it('takes the identity fast path (including both-undefined)', () => { + const t = named(); + expect(areTransformsEqual(t, t)).toBe(true); + expect(areTransformsEqual(undefined, undefined)).toBe(true); + }); + + it('rejects a defined/undefined pair', () => { + expect(areTransformsEqual(named(), undefined)).toBe(false); + expect(areTransformsEqual(undefined, named())).toBe(false); + }); + + it('coalesces distinct named instances with equal name and captured source', () => { + expect(areTransformsEqual(named(), named())).toBe(true); + }); + + it('rejects named transforms with equal names but divergent sources', () => { + const a = createTransform('px', (v) => `${v}px`); + const b = createTransform('px', (v) => `${v}rem`); + expect(areTransformsEqual(a, b)).toBe(false); + }); + + it('rejects a name match when the captured source is missing on either side', () => { + // Simulates an instance built by an older @animus-ui/system: named, but + // no transformSource captured at creation. + const legacy = Object.assign(((v) => `${v}px`) as TransformFn, { + transformName: 'px', + }); + expect(areTransformsEqual(named(), legacy)).toBe(false); + expect(areTransformsEqual(legacy, named())).toBe(false); + }); + + it('rejects transforms with different names', () => { + const a = createTransform('px', (v) => `${v}px`); + const b = createTransform('rem', (v) => `${v}px`); + expect(areTransformsEqual(a, b)).toBe(false); + }); + + it('compares bare-function pairs by their own source text', () => { + const a: TransformFn = (value) => `0 0 ${value}px`; + const b: TransformFn = (value) => `0 0 ${value}px`; + const c: TransformFn = (value) => `0 0 ${value}rem`; + expect(areTransformsEqual(a, b)).toBe(true); + expect(areTransformsEqual(a, c)).toBe(false); + }); + + it('rejects a named/bare mix', () => { + const bare: TransformFn = (v) => `${v}px`; + expect(areTransformsEqual(named(), bare)).toBe(false); + expect(areTransformsEqual(bare, named())).toBe(false); + }); +}); + +describe('SystemBuilder extend()', () => { + const buildKit = () => + createSystem() + .addGroup('layout', { + gap: prop({ property: 'gap', scale: 'space' }), + }) + .build().system; + + // Scenario: "Extended prop is present end to end" (runtime half; the type + // half lives in types.test-d.tsx) + the G5 runtime witness. + it('merges an extended prop into the built config end to end', () => { + const kitDs = buildKit(); + const { system } = createSystem().extend(kitDs).build(); + const config = system.toConfig(); + + // G5 witness: the type-admitted name is present in the runtime config. + expect(Object.keys(JSON.parse(config.propConfig))).toContain('gap'); + expect(JSON.parse(config.propConfig).gap).toEqual( + JSON.parse(kitDs.toConfig().propConfig).gap + ); + expect(JSON.parse(config.groupRegistry)).toEqual({ layout: ['gap'] }); + }); + + it('merges extended selectors and conditions into the serialized config', () => { + const kitDs = createSystem() + .addSelectors({ _cardHover: '&[data-card]:hover' }) + .addConditions({ _compact: '@media (max-width: 400px)' }) + .build().system; + const config = createSystem().extend(kitDs).build().system.toConfig(); + + expect(JSON.parse(config.selectorAliases)._cardHover).toBe( + '&[data-card]:hover' + ); + expect(JSON.parse(config.conditionAliases)._compact).toMatchObject({ + value: '@media (max-width: 400px)', + kind: 'media', + }); + }); + + it('adopts a kit override of a built-in selector, preserving its order', () => { + const kitDs = createSystem() + .addSelectors({ _hover: '&:hover:not([data-frozen])' }) + .build().system; + const config = createSystem().extend(kitDs).build().system.toConfig(); + + expect(JSON.parse(config.selectorAliases)._hover).toBe( + '&:hover:not([data-frozen])' + ); + }); + + // Inc-12 F7 witness: selector order allocation CONTINUES past the existing + // maximum across successive merges (mirrors mergeConditions) — two kits + // each contributing one alias must never share order 500, and allocation + // is deterministic under either extension order (no conflict exists, so + // both orderings build). + it('allocates distinct selector orders across repeated extends, stable under re-ordering', () => { + const kitA = createSystem() + .addSelectors({ _cardHover: '&[data-card]:hover' }) + .build().system; + const kitB = createSystem() + .addSelectors({ _railOpen: '&[data-rail][data-open]' }) + .build().system; + + const ab = snapshotOf( + createSystem().extend(kitA).extend(kitB).build().system + ).selectors; + const ba = snapshotOf( + createSystem().extend(kitB).extend(kitA).build().system + ).selectors; + + // Distinct orders in both orderings — the F7 failure mode was both + // aliases landing on order 500. + expect(ab._cardHover.order).not.toBe(ab._railOpen.order); + expect(ba._railOpen.order).not.toBe(ba._cardHover.order); + + // Stable, deterministic allocation: the first-extended alias takes the + // 500 slot, the next continues at 510 — under either call order. + expect(ab._cardHover.order).toBe(500); + expect(ab._railOpen.order).toBe(510); + expect(ba._railOpen.order).toBe(500); + expect(ba._cardHover.order).toBe(510); + }); + + // Same F7 seam, builder-chain half: successive addSelectors calls on one + // chain continue numbering instead of restarting at 500. + it('continues selector order allocation across chained addSelectors calls', () => { + const { system } = createSystem() + .addSelectors({ _chainOne: '&[data-chain-one]' }) + .addSelectors({ _chainTwo: '&[data-chain-two]' }) + .build(); + const selectors = snapshotOf(system).selectors; + expect(selectors._chainOne.order).toBe(500); + expect(selectors._chainTwo.order).toBe(510); + }); + + // Scenario: "Bundle object feeds the system half". + it('consumes the system half of a bundle and ignores the theme half', () => { + const kitDs = buildKit(); + const direct = createSystem().extend(kitDs).build().system.toConfig(); + const viaTheme = createSystem() + .extend({ system: kitDs, theme: { colors: { accent: '#f0f' } } }) + .build() + .system.toConfig(); + const viaTokens = createSystem() + .extend({ system: kitDs, tokens: { colors: { accent: '#f0f' } } }) + .build() + .system.toConfig(); + + expect(viaTheme).toEqual(direct); + expect(viaTokens).toEqual(direct); + }); + + it('fails loud when the source carries no registry snapshot', () => { + const kitDs = buildKit(); + const legacy = { toConfig: () => kitDs.toConfig() }; + expect(() => createSystem().extend({ system: legacy })).toThrow( + /older @animus-ui\/system/ + ); + }); + + // Scenario: "Identical definitions coalesce". + it('coalesces byte-equivalent definitions from source and consumer', () => { + const kitDs = createSystem() + .addProps({ m: prop({ scale: 'space' }) }) + .build().system; + const { system } = createSystem() + .extend(kitDs) + .addProps({ m: prop({ scale: 'space' }) }) + .build(); + + expect(JSON.parse(system.toConfig().propConfig)).toEqual({ + m: { property: 'margin', scale: 'space' }, + }); + }); + + // Scenario: "Divergent prop definition fails" — the consumer's own chain + // diverging from an extended definition fails naming the prop, both scale + // bindings, and both origins. + it('fails a consumer addProps that diverges from an extended prop, naming both origins', () => { + const kitDs = buildKit(); + expect(() => + createSystem() + .extend(kitDs) + .addProps({ gap: prop({ property: 'gap', scale: 'sizes' }) }) + ).toThrow( + /Prop "gap".*Existing \(extended source #1\): property="gap", scale="space".*Incoming \(builder state\): property="gap", scale="sizes"/ + ); + }); + + it('fails a consumer addGroup that diverges from an extended prop, naming both origins', () => { + const kitDs = buildKit(); + expect(() => + createSystem() + .extend(kitDs) + .addGroup('spacing', { gap: prop({ property: 'gap', scale: 'sizes' }) }) + ).toThrow(/extended source #1.*builder state/s); + }); + + // Scenario: "Sibling sources conflict loudly" (G4, selector half) — + // order-independent, naming both extended sources. + it('fails divergent sibling selector aliases naming both sources, order-independent', () => { + const kitA = createSystem() + .addSelectors({ _hover: '&:hover:not([data-frozen])' }) + .build().system; + const kitB = createSystem() + .addSelectors({ _hover: '&:hover, &[data-hover]' }) + .build().system; + + expect(() => createSystem().extend(kitA).extend(kitB)).toThrow( + /selector alias "_hover".*extended source #1.*extended source #2/s + ); + expect(() => createSystem().extend(kitB).extend(kitA)).toThrow( + /selector alias "_hover".*extended source #1.*extended source #2/s + ); + }); + + // G4 (prop half): sibling divergence and dual-version divergence. + it('fails divergent sibling prop definitions naming both sources, order-independent', () => { + const kitA = createSystem() + .addProps({ gap: prop({ property: 'gap', scale: 'space' }) }) + .build().system; + const kitB = createSystem() + .addProps({ gap: prop({ property: 'gap', scale: 'sizes' }) }) + .build().system; + + expect(() => createSystem().extend(kitA).extend(kitB)).toThrow( + /Prop "gap".*Existing \(extended source #1\).*Incoming \(extended source #2\)/ + ); + expect(() => createSystem().extend(kitB).extend(kitA)).toThrow( + /Prop "gap".*Existing \(extended source #1\).*Incoming \(extended source #2\)/ + ); + }); + + it('fails one package present as two divergent instances, identifying both', () => { + // Simulates the same kit at two versions: same names, divergent values. + const v1 = createSystem() + .addProps({ gap: prop({ property: 'gap', scale: 'space' }) }) + .build().system; + const v2 = createSystem() + .addProps({ gap: prop({ property: 'gap', scale: 'spacing' }) }) + .build().system; + + expect(() => createSystem().extend(v1).extend(v2)).toThrow( + /extended source #1.*extended source #2/s + ); + }); + + it('fails divergent sibling condition aliases naming both sources', () => { + const kitA = createSystem() + .addConditions({ _compact: '@media (max-width: 400px)' }) + .build().system; + const kitB = createSystem() + .addConditions({ _compact: '@media (max-width: 500px)' }) + .build().system; + + expect(() => createSystem().extend(kitA).extend(kitB)).toThrow( + /condition alias "_compact".*extended source #1.*extended source #2/s + ); + }); + + it('fails divergent sibling group membership naming both sources', () => { + const kitA = createSystem() + .addGroup('layout', { gap: prop({ property: 'gap' }) }) + .build().system; + const kitB = createSystem() + .addGroup('layout', { + gap: prop({ property: 'gap' }), + rowGap: prop({ property: 'rowGap' }), + }) + .build().system; + + expect(() => createSystem().extend(kitA).extend(kitB)).toThrow( + /group "layout".*Existing \(extended source #1\): \[gap\].*Incoming \(extended source #2\): \[gap, rowGap\]/ + ); + }); + + it('fails cross-registry name collisions between extended sources, both directions', () => { + const conditionKit = createSystem() + .addConditions({ _compact: '@media (max-width: 400px)' }) + .build().system; + const selectorKit = createSystem() + .addSelectors({ _compact: '&[data-compact]' }) + .build().system; + + expect(() => + createSystem().extend(conditionKit).extend(selectorKit) + ).toThrow(/selector alias "_compact".*registered as a condition alias/s); + expect(() => + createSystem().extend(selectorKit).extend(conditionKit) + ).toThrow(/condition alias "_compact".*registered as a selector alias/s); + }); + + it('fails group-name-vs-prop-name cross-collisions between extended sources', () => { + const propKit = createSystem() + .addProps({ card: prop({ property: 'gridArea' }) }) + .build().system; + const groupKit = createSystem() + .addGroup('card', { cardPad: prop({ property: 'padding' }) }) + .build().system; + + expect(() => createSystem().extend(propKit).extend(groupKit)).toThrow( + /group name "card".*collides with an existing prop name/ + ); + expect(() => createSystem().extend(groupKit).extend(propKit)).toThrow( + /prop "card".*collides with an existing group name/ + ); + }); + + // Cached-instance coalesce: semantic function equality cannot be inferred + // from source text because equal-looking functions may capture different + // closure values. + it('coalesces repeated extension of one cached kit instance', () => { + const buildDualKit = () => + createSystem() + .addGroup('surface', { + px: prop({ + property: 'paddingLeft', + transform: createTransform('px', (v) => `${v}px`), + }), + glow: prop({ + property: 'boxShadow', + transform: (value) => `0 0 ${value}px`, + }), + }) + .addSelectors({ _cardHover: '&[data-card]:hover' }) + .addConditions({ _compact: '@media (max-width: 400px)' }) + .build().system; + + const kit = buildDualKit(); + const once = createSystem().extend(kit).build().system.toConfig(); + const twice = createSystem() + .extend(kit) + .extend(kit) + .build() + .system.toConfig(); + + // `transforms` carries live function references (distinct per kit + // instance), so the serialized fields and the transform key set are the + // comparable surface. + expect(twice.propConfig).toEqual(once.propConfig); + expect(twice.groupRegistry).toEqual(once.groupRegistry); + expect(twice.selectorAliases).toEqual(once.selectorAliases); + expect(twice.conditionAliases).toEqual(once.conditionAliases); + expect(Object.keys(twice.transforms)).toEqual(Object.keys(once.transforms)); + }); + + // D12's documented accepted residual (inc-02 review F3): byte-identical + // source with divergent closure captures COALESCES — source text is the + // cross-instance identity, and the closure environment is invisible to it. + // The first-registered instance wins. Pinned so the trade-off stays + // deliberate; the loud alternative (identity-only) was tried in-tree and + // reverted (false-conflicts every dual-install, forbids re-registration). + it('coalesces equal-source transforms that capture different closure values (documented residual)', () => { + const buildUnitKit = (unit: string) => + createSystem() + .addProps({ + size: prop({ + property: 'width', + transform: createTransform('unit', (value) => `${value}${unit}`), + }), + }) + .build().system; + + const { system } = createSystem() + .extend(buildUnitKit('px')) + .extend(buildUnitKit('rem')) + .build(); + const config = system.toConfig(); + expect(JSON.parse(config.propConfig).size.transform).toBe('unit'); + // First-registered wins: the 'px' capture is the surviving behavior. + expect(config.transforms.unit(4)).toBe('4px'); + }); + + it('coalesces structurally equal inline object and array scales', () => { + const buildScaledKit = () => + createSystem() + .addProps({ + mapped: prop({ scale: { sm: '4px', lg: '8px' } }), + listed: prop({ scale: ['4px', '8px'] }), + }) + .build().system; + + expect(() => + createSystem().extend(buildScaledKit()).extend(buildScaledKit()).build() + ).not.toThrow(); + }); + + it('rejects two props that serialize different transforms under one name', () => { + const first = createTransform('shared', (value) => `A:${value}`); + const second = createTransform('shared', (value) => `B:${value}`); + expect(() => + createSystem() + .addProps({ + first: prop({ transform: first }), + second: prop({ transform: second }), + }) + .build() + .system.toConfig() + ).toThrow(/Transform name "shared".*"first".*"second"/); + }); + + // Scenario: "Anonymous transform survives extension" (G7) — serialization + // would have dropped it; the snapshot-based merge must not. + it('carries an anonymous transform through extension and applies it identically', () => { + // Truly anonymous: an arrow assigned to a binding (or object property) + // gets an inferred fn.name, which serializeInstance would treat as a + // usable name — returning it from a factory keeps fn.name === ''. + const makeGlow = () => (value: string | number) => `0 0 ${value}px`; + const glowTransform = makeGlow(); + expect(glowTransform.name).toBe(''); + const kitDs = createSystem() + .addProps({ + glow: prop({ property: 'boxShadow', transform: glowTransform }), + }) + .build().system; + + // The serialized form drops the unnamed transform — reconstruction from + // toConfig() would lose it (the G7 failure mode). + const serialized = kitDs.toConfig(); + expect(JSON.parse(serialized.propConfig).glow.transform).toBeUndefined(); + expect(serialized.transforms).toEqual({}); + + const { system: merged } = createSystem().extend(kitDs).build(); + const mergedTransform = snapshotOf(merged).props.glow.transform; + expect(mergedTransform).not.toBe(glowTransform); + // Styled-output application through the runtime resolution path matches + // the source system exactly. + expect( + resolveValue(4, { varName: '--glow', transform: glowTransform }) + ).toBe('0 0 4px'); + expect(mergedTransform?.(4)).toBe( + snapshotOf(kitDs).props.glow.transform?.(4) + ); + }); + + // Scenario: "Post-build mutation does not leak" + snapshot immutability. + it('ignores post-build registry mutation in toConfig() and extension', () => { + const { system } = createSystem() + .addGroup('space', { m: prop({ scale: 'space' }) }) + .build(); + const before = system.toConfig(); + + (system.propRegistry as Record).rogue = { + property: 'color', + }; + (system.propRegistry as Record).m.scale = 'sizes'; + (system.groupRegistry as Record).space.push('rogue'); + + expect(system.toConfig()).toEqual(before); + + const consumer = createSystem().extend(system).build().system.toConfig(); + expect(JSON.parse(consumer.propConfig)).toEqual({ + m: { property: 'margin', scale: 'space' }, + }); + expect(JSON.parse(consumer.groupRegistry)).toEqual({ space: ['m'] }); + }); + + // Review probe P9 (inc 12): build() hands the instance shallow-copied + // registry containers, so instance-field mutation cannot reach the + // builder's private state — a second build() on the same builder must not + // bake the mutation into its snapshot. + it('keeps a rebuild pristine after instance-field mutation of a prior build (P9)', () => { + const builder = createSystem().addGroup('space', { + m: prop({ scale: 'space' }), + }); + const { system: first } = builder.build(); + const before = first.toConfig(); + + (first.propRegistry as Record).rogue = { + property: 'color', + }; + (first.groupRegistry as Record).rogueGroup = ['rogue']; + // Entry-depth mutation (review probe P9, second pass): a field inside a + // shared Prop entry must not reach a later build either. + (first.propRegistry as Record).m.scale = 'sizes'; + (first.groupRegistry as Record).space.push('rogue'); + + const { system: second } = builder.build(); + const after = second.toConfig(); + expect(JSON.parse(after.propConfig)).not.toHaveProperty('rogue'); + expect(JSON.parse(after.groupRegistry)).not.toHaveProperty('rogueGroup'); + expect(JSON.parse(after.propConfig).m.scale).toBe('space'); + expect(JSON.parse(after.groupRegistry).space).toEqual(['m']); + expect(after).toEqual(before); + }); + + it('ignores post-build mutation of nested properties arrays and object scales', () => { + const { system } = createSystem() + .addGroup('space', { + mx: prop({ + property: 'margin', + properties: ['marginLeft', 'marginRight'], + scale: { sm: '4px' }, + }), + }) + .build(); + const before = system.toConfig(); + + (system.propRegistry.mx.properties as string[]).push('marginTop'); + (system.propRegistry.mx.scale as Record).sm = '999px'; + + expect(system.toConfig()).toEqual(before); + + const consumer = createSystem().extend(system).build().system.toConfig(); + const mx = JSON.parse(consumer.propConfig).mx; + expect(mx.properties).toEqual(['marginLeft', 'marginRight']); + expect(mx.scale).toEqual({ sm: '4px' }); + }); + + it('captures transform serialization metadata at build time', () => { + const transform = createTransform('stable', (value) => `${value}px`); + const { system } = createSystem() + .addProps({ width: prop({ property: 'width', transform }) }) + .build(); + const before = system.toConfig(); + + transform.transformName = 'changed'; + + expect(system.toConfig()).toEqual(before); + expect(JSON.parse(system.toConfig().propConfig).width.transform).toBe( + 'stable' + ); + }); + + it('freezes the registry snapshot containers and entries', () => { + const { system } = createSystem() + .addGroup('space', { m: prop({ scale: 'space' }) }) + .build(); + const snapshot = snapshotOf(system); + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.props)).toBe(true); + expect(Object.isFrozen(snapshot.props.m)).toBe(true); + expect(Object.isFrozen(snapshot.groups)).toBe(true); + expect(Object.isFrozen(snapshot.groups.space)).toBe(true); + expect(Object.isFrozen(snapshot.selectors)).toBe(true); + expect(Object.isFrozen(snapshot.selectors._hover)).toBe(true); + expect(Object.isFrozen(snapshot.conditions)).toBe(true); + expect(Object.isFrozen(snapshot.conditions._motionReduce)).toBe(true); + }); +}); + +describe('deprecated extension aliases (frozen semantics)', () => { + const buildKit = () => + createSystem() + .addGroup('kitSurface', { kitGlow: prop({ property: 'boxShadow' }) }) + .build().system; + + // Scenario: "from() behavior is unchanged during the window" — byte-identical + // to a builder that never called from() (no registry merge). + it('keeps from() merge-free and byte-identical during the deprecation window', () => { + const kitDs = buildKit(); + const withFrom = createSystem() + .from(kitDs) + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + const without = createSystem() + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + + expect(withFrom.propConfig).toEqual(without.propConfig); + expect(withFrom.groupRegistry).toEqual(without.groupRegistry); + expect(withFrom.selectorAliases).toEqual(without.selectorAliases); + expect(withFrom.conditionAliases).toEqual(without.conditionAliases); + }); + + // Scenario: "Deprecation is visible to consumers" — the published types are + // emitted from these docblocks, so the source-level tags are the witness. + it('marks from() and includes as deprecated pointing at extend()', () => { + // NOT `new URL(relative, import.meta.url)` — Vite rewrites that pattern + // into a non-file asset URL under the test runner. + const builderSource = readFileSync( + resolve(fileURLToPath(import.meta.url), '../../src/SystemBuilder.ts'), + 'utf8' + ); + + const fromDeprecations = builderSource.match( + /@deprecated Use `extend\(source\)`/g + ); + expect(fromDeprecations?.length).toBeGreaterThanOrEqual(2); + expect(builderSource).toMatch( + /@deprecated Use `createSystem\(\)\.extend\(source\)`/ + ); + }); +}); diff --git a/packages/system/__tests__/global-styles-font-faces.test.ts b/packages/system/__tests__/global-styles-font-faces.test.ts index d9e582fe..4f4a8a89 100644 --- a/packages/system/__tests__/global-styles-font-faces.test.ts +++ b/packages/system/__tests__/global-styles-font-faces.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { asset, ASSET_PLACEHOLDER_PREFIX } from '../src'; import { createGlobalStyles } from './test-system'; /** @@ -55,3 +56,38 @@ describe('createGlobalStyles fontFaces', () => { expect(block.fontFaces).toHaveLength(1); }); }); + +describe('asset() references', () => { + it('produces the deterministic placeholder carrying the specifier verbatim', () => { + expect(asset('@acme/tokens/fonts/inter.woff2')).toBe( + 'animus-asset:@acme/tokens/fonts/inter.woff2' + ); + expect(ASSET_PLACEHOLDER_PREFIX).toBe('animus-asset:'); + }); + + it('rides src[].url through the factory as its placeholder string', () => { + const block = createGlobalStyles( + { body: { m: 0 } }, + { + fontFaces: [ + { + family: 'Inter', + src: [ + { url: asset('@acme/tokens/fonts/inter.woff2'), format: 'woff2' }, + ], + }, + ], + } + ); + + // The placeholder is a plain string on the block — exactly what the + // sandbox's JSON serialization and the emitter's byte-exact pass-through + // will carry to the host plugin for substitution. + expect(block.fontFaces?.[0].src[0].url).toBe( + 'animus-asset:@acme/tokens/fonts/inter.woff2' + ); + expect(JSON.parse(JSON.stringify(block)).fontFaces[0].src[0].url).toBe( + 'animus-asset:@acme/tokens/fonts/inter.woff2' + ); + }); +}); diff --git a/packages/system/__tests__/manifest-v2.test.ts b/packages/system/__tests__/manifest-v2.test.ts index 4e00895e..f001424b 100644 --- a/packages/system/__tests__/manifest-v2.test.ts +++ b/packages/system/__tests__/manifest-v2.test.ts @@ -193,6 +193,22 @@ describe('manifest v2 version, contract hash, and CSS fragments', () => { const manifest = buildReferenceFixture().manifest; expect(manifest.manifestVersion).toBe(2); expect(typeof manifest.emitterVersion).toBe('number'); + expect(manifest.emittedScales).toEqual(['colors']); + }); + + it('includes the exact emitted-scale set in the contract hash', () => { + const inline = createTheme() + .addScale({ name: 'space', values: { sm: '4px' } }) + .build(); + const emitted = createTheme() + .addScale({ name: 'space', emit: true, values: { sm: '4px' } }) + .build(); + + expect(inline.manifest.emittedScales).toEqual([]); + expect(emitted.manifest.emittedScales).toEqual(['space']); + expect(emitted.manifest.contractHash).not.toBe( + inline.manifest.contractHash + ); }); it('computes an identical contractHash for two identically authored builds', () => { @@ -335,7 +351,7 @@ describe('manifest v2 from() copy-on-write fidelity', () => { }); }); - it('carries tokenDefinitions, modeAliasDefinitions, emitterVersion, and contractHash through from()', () => { + it('carries tokenDefinitions, emittedScales, modeAliasDefinitions, emitterVersion, and contractHash through from()', () => { const source = buildSystemRegisteredFixture(); const rebuilt = createTheme().from(source).build(); @@ -343,6 +359,9 @@ describe('manifest v2 from() copy-on-write fidelity', () => { expect(rebuilt.manifest.tokenDefinitions).toEqual( source.manifest.tokenDefinitions ); + expect(rebuilt.manifest.emittedScales).toEqual( + source.manifest.emittedScales + ); expect(rebuilt.manifest.modeAliasDefinitions).toEqual( source.manifest.modeAliasDefinitions ); @@ -431,57 +450,62 @@ describe('manifest v2 from() copy-on-write fidelity', () => { /** * Captured from the PRE-increment emitter (2026-08-03, branch - * feat/color-system, clean tree) for the reference fixture above. Manifest v2 - * is metadata-only: this string may NEVER change while the fixture stands + * feat/color-system, clean tree) for the reference fixture above; re-captured + * 2026-08-04 under first-class-extension increment 03 (D4): deterministic + * emission sorts `:root` token declarations by token path. Re-captured again + * 2026-08-04 under increment 04 (G3 closure): `--breakpoint-*` lines and + * mode-block lines sort by property name and mode blocks by mode name — + * order mutations only, every declaration multiset-identical. Manifest v2 is + * metadata-only: this string may NEVER change while the fixture stands * (G1 — zero-variant themes emit byte-identical CSS). */ const REFERENCE_FIXTURE_VARIABLE_CSS = `:root { - --color-void: #000000; - --color-ember: #ff2800; + --color-bg: var(--color-void); --color-bone: #e8e0d0; + --color-ember: #ff2800; --color-gray-300: #666666; --color-gray-600: #333333; - --color-primary: var(--color-ember); - --color-bg: var(--color-void); --color-muted: var(--color-gray-300); - --breakpoint-xs: 480px; - --breakpoint-sm: 768px; - --breakpoint-md: 1024px; + --color-primary: var(--color-ember); + --color-void: #000000; --breakpoint-lg: 1200px; + --breakpoint-md: 1024px; + --breakpoint-sm: 768px; --breakpoint-xl: 1440px; + --breakpoint-xs: 480px; } [data-color-mode="dark"] { - --color-primary: #ff2800; --color-bg: #000000; --color-muted: #666666; + --color-primary: #ff2800; } [data-color-mode="light"] { - --color-primary: #000000; --color-bg: #e8e0d0; --color-muted: #333333; + --color-primary: #000000; }`; /** Same capture for the system-enabled registered fixture (both fragments). */ const SYSTEM_FIXTURE_VARIABLE_CSS = `@property --current-bg { syntax: ""; inherits: true; initial-value: transparent; } :root { - --color-ink: #101014; - --color-bone: #f5f2ea; --color-ash: #8a8a8a; - --color-fg: var(--color-ink); --color-bg: var(--color-bone); + --color-bone: #f5f2ea; + --color-fg: var(--color-ink); + --color-ink: #101014; --color-muted: var(--color-ash); - --breakpoint-sm: 768px; --breakpoint-lg: 1200px; + --breakpoint-sm: 768px; color-scheme: light; } @media (prefers-color-scheme: light) { :root:not([data-color-mode]) { - --color-fg: #101014; --color-bg: #f5f2ea; + --color-fg: #101014; --color-muted: #8a8a8a; color-scheme: light; } @@ -489,25 +513,25 @@ const SYSTEM_FIXTURE_VARIABLE_CSS = `@property --current-bg { syntax: ""; @media (prefers-color-scheme: dark) { :root:not([data-color-mode]) { - --color-fg: #f5f2ea; --color-bg: #101014; + --color-fg: #f5f2ea; --color-muted: #8a8a8a; color-scheme: dark; } } -[data-color-mode="paper"] { - --color-fg: #101014; - --color-bg: #f5f2ea; - --color-muted: #8a8a8a; - color-scheme: light; -} - [data-color-mode="midnight"] { - --color-fg: #f5f2ea; --color-bg: #101014; + --color-fg: #f5f2ea; --color-muted: #8a8a8a; color-scheme: dark; +} + +[data-color-mode="paper"] { + --color-bg: #f5f2ea; + --color-fg: #101014; + --color-muted: #8a8a8a; + color-scheme: light; }`; describe('zero-variant emission parity (G1 pin)', () => { diff --git a/packages/system/__tests__/system-builder.test.ts b/packages/system/__tests__/system-builder.test.ts index 373c3931..ce9a1bba 100644 --- a/packages/system/__tests__/system-builder.test.ts +++ b/packages/system/__tests__/system-builder.test.ts @@ -147,3 +147,59 @@ describe('SystemBuilder prop overlap equality', () => { expect(JSON.parse(config.propConfig).x.scale).toEqual(scale); }); }); + +describe('SystemBuilder from()', () => { + const buildKit = () => + createSystem() + .addGroup('kitSurface', { kitGlow: prop({ property: 'boxShadow' }) }) + .build().system; + + it('does not merge the source registry — consumer stays singular authority', () => { + const kitDs = buildKit(); + const withFrom = createSystem() + .from(kitDs) + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + const without = createSystem() + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + + expect(withFrom.propConfig).toEqual(without.propConfig); + expect(withFrom.groupRegistry).toEqual(without.groupRegistry); + expect(withFrom.selectorAliases).toEqual(without.selectorAliases); + expect(withFrom.conditionAliases).toEqual(without.conditionAliases); + }); + + it('accepts a library bundle identically to the direct system half', () => { + const kitDs = buildKit(); + const direct = createSystem() + .from(kitDs) + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + const viaBundle = createSystem() + .from({ system: kitDs, tokens: { colors: { accent: '#f0f' } } }) + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + + expect(viaBundle).toEqual(direct); + }); + + it('keeps the deprecated includes alias behaviorally identical to from()', () => { + const kitDs = buildKit(); + const viaFrom = createSystem() + .from(kitDs) + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + const viaIncludes = createSystem({ includes: [kitDs] }) + .addGroup('space', { m: prop() }) + .build() + .system.toConfig(); + + expect(viaIncludes).toEqual(viaFrom); + }); +}); diff --git a/packages/system/__tests__/system-scheme-emission.test.ts b/packages/system/__tests__/system-scheme-emission.test.ts index 6559d4c9..b944ec16 100644 --- a/packages/system/__tests__/system-scheme-emission.test.ts +++ b/packages/system/__tests__/system-scheme-emission.test.ts @@ -218,9 +218,11 @@ describe('guarded system fallback emission', () => { ':root:not([data-color-mode])' ); expect(mediaDark[0]).toBe(':root:not([data-color-mode]) {'); + // Declaration lines sort by property name since first-class-extension + // increment 04 (G3) — same declaration multiset, sorted order. expect(declarations).toEqual([ - '--color-fg: #f5f2ea;', '--color-bg: #101014;', + '--color-fg: #f5f2ea;', '--color-muted: #8a8a8a;', ]); }); @@ -257,9 +259,10 @@ describe('guarded system fallback emission', () => { it('keeps the explicit mode block alongside the guarded media blocks', () => { const css = buildSystemTheme().serialize().variableCss; expect(css).toContain('[data-color-mode="paper"] {'); + // Sorted by property name since first-class-extension increment 04 (G3). expect(blockDeclarations(css, '[data-color-mode="paper"]')).toEqual([ - '--color-fg: #101014;', '--color-bg: #f5f2ea;', + '--color-fg: #101014;', '--color-muted: #8a8a8a;', ]); // The guard is the attribute-wins mechanism: the media rule stops matching @@ -294,30 +297,39 @@ describe('guarded system fallback emission', () => { describe('zero-configuration byte parity', () => { /** - * Pinned pre-increment output, captured from the emitter before the system - * options existed. A diff here is a G4 trip, never a baseline to regenerate. + * Pinned unconfigured-theme output. Originally captured from the emitter + * before the system options existed; re-captured 2026-08-04 under + * first-class-extension increment 03 (D4): deterministic emission sorts + * `:root` token declarations by token path, moving the pin once and + * uniformly for EVERY theme. Re-captured again the same day under + * increment 04 (G3 closure): `--breakpoint-*` lines and mode-block lines + * sort by property name and mode blocks by mode name — order mutations + * only, declaration multiset identical. The invariant this pin protects is + * unchanged — system options must add zero bytes for a theme that never + * opts in — so a diff here is still a G4 trip, never a baseline to + * regenerate. */ const PRE_INCREMENT_VARIABLE_CSS = [ ':root {', - ' --color-ink: #101014;', - ' --color-bone: #f5f2ea;', ' --color-ash: #8a8a8a;', - ' --color-fg: var(--color-ink);', ' --color-bg: var(--color-bone);', + ' --color-bone: #f5f2ea;', + ' --color-fg: var(--color-ink);', + ' --color-ink: #101014;', ' --color-muted: var(--color-ash);', - ' --breakpoint-sm: 768px;', ' --breakpoint-lg: 1200px;', + ' --breakpoint-sm: 768px;', '}', '', - '[data-color-mode="paper"] {', - ' --color-fg: #101014;', - ' --color-bg: #f5f2ea;', + '[data-color-mode="midnight"] {', + ' --color-bg: #101014;', + ' --color-fg: #f5f2ea;', ' --color-muted: #8a8a8a;', '}', '', - '[data-color-mode="midnight"] {', - ' --color-fg: #f5f2ea;', - ' --color-bg: #101014;', + '[data-color-mode="paper"] {', + ' --color-bg: #f5f2ea;', + ' --color-fg: #101014;', ' --color-muted: #8a8a8a;', '}', ].join('\n'); @@ -346,6 +358,7 @@ describe('zero-configuration byte parity', () => { expect(Object.keys(manifest).sort()).toEqual([ 'contractHash', 'cssFragments', + 'emittedScales', 'emitterVersion', 'manifestVersion', 'modeAliasDefinitions', @@ -753,24 +766,23 @@ describe('merged-state option validation', () => { // ─── Reserved theme keys (F3) ──────────────────────────────── describe('reserved theme keys', () => { - it('rejects a scale named systemPreference', () => { - expect(() => - createTheme() - .addBreakpoints(breakpoints) - .addScale({ - name: 'systemPreference', - emit: true, - values: { a: '1px' }, - }) - ).toThrow(/'systemPreference' is a reserved theme key/); - }); - - it('rejects a scale named browserColorScheme', () => { + it.each([ + 'breakpoints', + 'modes', + 'mode', + 'systemPreference', + 'browserColorScheme', + 'modeBases', + '__emitted', + 'manifest', + 'serialize', + 'varRef', + ])('rejects a scale named %s at runtime', (reservedName) => { expect(() => createTheme() .addBreakpoints(breakpoints) - .addScale({ name: 'browserColorScheme', values: { a: '1px' } }) - ).toThrow(/'browserColorScheme' is a reserved theme key/); + .addScale({ name: reservedName, values: { a: '1px' } }) + ).toThrow(`'${reservedName}' is a reserved theme key`); }); }); diff --git a/packages/system/__tests__/theme-extend.test.ts b/packages/system/__tests__/theme-extend.test.ts new file mode 100644 index 00000000..f9caeb2e --- /dev/null +++ b/packages/system/__tests__/theme-extend.test.ts @@ -0,0 +1,833 @@ +/** + * Tests for ThemeBuilder.extend() (openspec change: first-class-extension, + * increment 04 — spec `theme-composition`, resolving D2/D5/D6). + * + * Scenario mapping (spec header → test): + * - "extend() composition entry point" › "Local values win over the extended + * source" → 'local addColors wins over the extended source value' + * - … › "Inherit-first is type-enforced" → types.test-d.tsx §17 (type half; + * no runtime gate exists on purpose) + * - … › "Bundle object feeds the theme half" → 'a bundle feeds the theme + * half (theme preferred, tokens accepted) and ignores the rest' + * - … › "Sibling themes conflict loudly" → 'sibling themes defining one path + * divergently fail loud naming both sources, order-independent' (+ the + * equal-value coalesce and NS-4 override tests) + * - "Late-binding reference resolution over the merged theme" › "Override + * recolors source-internal references" — the `.extend()`-spelled scenario, + * previously witnessed only through from() (theme-resolver.test.ts) → + * 'extend(): consumer override recolors kit-authored references' + * - "Structural progressivity of inherited tokens" › "Wholesale replacement + * is explicit" → 'addScale without replace keeps inherited keys' + * - … › "Dangling reference fails at build" → 'explicit replacement dropping + * a referenced key fails build() naming referencer, call, and dropped keys' + * - "Deep merge semantics on augmentation" › "addColors deep merges" / + * "addScale merges by key" / "Explicit replacement replaces wholesale" / + * "addColorModes merges modes" → the 'deep merge semantics (MODIFIED)' + * describe block + * - "Mode extension declares its base" › "Declared base fills coverage gaps" + * / "Missing base with gaps fails" → the 'mode bases (D6)' describe block + * - "Round-trip fidelity" › "Full round-trip" → 'extend(lib) with no + * augmentation serializes identically to the source' + * - "from() composition entry point" (MODIFIED) › "from() precedence is + * unchanged during the window" / "Deprecation is visible to consumers" → + * the 'from() freeze (G6)' describe block + * + * Determinism closures (inc 03 review-registered blind spots, journal + * 2026-08-04 15:04): reversed-declaration byte-identity now spanning mode + * blocks, breakpoint lines, and the variableMapJson wire (G3); emitted + * declarations with never-defined targets omitted with one aggregated + * warning (G2); mode-override declarations routed through the resolver + * (G2 — the review's executed probe); v1-manifest taint preserved through + * extend() (D8). The G1 mode-block witness lives in theme-resolver.test.ts + * beside the base-mode witness it extends. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; + +import { createTheme } from '../src'; + +const breakpoints = { sm: 768 } as const; + +/** Declaration lines (trimmed) inside the block whose header matches `header`. */ +function blockDeclarations(css: string, header: string): string[] { + const start = css.indexOf(header); + if (start === -1) throw new Error(`block '${header}' not found in:\n${css}`); + const open = css.indexOf('{', start); + const close = css.indexOf('}', open); + return css + .slice(open + 1, close) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** Kit fixture: emitted colors, modes, a non-emitted scale, and a reference. */ +function buildKitTheme() { + return createTheme() + .addBreakpoints({ sm: 768, lg: 1200 }) + .addColors({ ember: '#ff2800', void: '#000000', bone: '#e8e0d0' }) + .addColorModes('dark', { + dark: { primary: 'ember', bg: 'void', muted: 'bone' }, + light: { primary: 'void', bg: 'bone', muted: 'ember' }, + }) + .addScale({ + name: 'space', + values: { 0: '0', 4: '0.25rem', 8: '0.5rem' }, + }) + .addScale({ + name: 'shadows', + values: { glow: '0 0 12px {colors.ember}' }, + }) + .build(); +} + +// ─── extend() composition entry point ──────────────────────── + +describe('ThemeBuilder extend() composition', () => { + // Scenario: "Local values win over the extended source" (D2 — + // base-then-local-wins, the mirror of from()'s source-wins). + it('local addColors wins over the extended source value', () => { + const composed = createTheme() + .extend(buildKitTheme()) + .addColors({ ember: '#7c3aed' }) + .build(); + // Runtime reads go through a cast: intersecting the kit's literal color + // type with the override's reduces the conflicting key to `never` at the + // type level (the same admission-typing limitation from() has). + const colors = composed.colors as unknown as Record; + expect(colors.ember).toBe('#7c3aed'); + // Non-conflicting kit values survive as the base. + expect(colors.void).toBe('#000000'); + expect(composed.space[8]).toBe('0.5rem'); + }); + + // Scenario: "Bundle object feeds the theme half" — D9 `theme` preferred, + // pre-D9 `tokens` accepted, the system half ignored. + it('a bundle feeds the theme half (theme preferred, tokens accepted) and ignores the rest', () => { + const kit = buildKitTheme(); + const kitSystem = { toConfig: () => ({}) }; + const direct = createTheme().extend(kit).build(); + const viaTheme = createTheme() + .extend({ system: kitSystem, theme: kit }) + .build(); + const viaTokens = createTheme() + .extend({ system: kitSystem, tokens: kit }) + .build(); + + expect(viaTheme.serialize()).toEqual(direct.serialize()); + expect(viaTokens.serialize()).toEqual(direct.serialize()); + // `theme` wins over `tokens` when both are present (D9 naming). + const decoyTokens = createTheme() + .addBreakpoints(breakpoints) + .addColors({ ember: '#123456' }) + .build(); + const preferred = createTheme() + .extend({ system: kitSystem, theme: kit, tokens: decoyTokens }) + .build(); + expect(preferred.colors.ember).toBe('#ff2800'); + // The bundle's other halves never leak into the theme. + expect((viaTheme as Record).system).toBeUndefined(); + expect((viaTheme as Record).theme).toBeUndefined(); + }); + + // Scenario: "Sibling themes conflict loudly" (D3/G4) — positional origin + // labels are the accepted form until DEF-4's provenance artifact. + it('sibling themes defining one path divergently fail loud naming both sources, order-independent', () => { + const kitA = createTheme() + .addBreakpoints(breakpoints) + .addColors({ primary: '#ff2800' }) + .build(); + const kitB = createTheme() + .addBreakpoints(breakpoints) + .addColors({ primary: '#7c3aed' }) + .build(); + + expect(() => createTheme().extend(kitA).extend(kitB)).toThrow( + /path 'colors\.primary' is defined divergently by extended theme #1 \("#ff2800"\) and extended theme #2 \("#7c3aed"\)/ + ); + expect(() => createTheme().extend(kitB).extend(kitA)).toThrow( + /path 'colors\.primary'.*extended theme #1.*extended theme #2/s + ); + }); + + // Review F1 (executed probe): one sibling authors a LEAF where the other + // authors a nested BRANCH at the same path — per-leaf value provenance + // alone silently picked an order-dependent winner. Both orders must fail + // naming the path and both positional sources. + it('sibling branch-vs-leaf structural divergence fails loud naming both sources, order-independent', () => { + const leafKit = createTheme() + .addBreakpoints(breakpoints) + .addColors({ primary: '#ff2800' }) + .build(); + const branchKit = createTheme() + .addBreakpoints(breakpoints) + .addColors({ primary: { 500: '#7c3aed' } }) + .build(); + + expect(() => createTheme().extend(leafKit).extend(branchKit)).toThrow( + /path 'colors\.primary' is defined divergently by extended theme #1 \(a leaf value\) and extended theme #2 \(a nested branch\)/ + ); + expect(() => createTheme().extend(branchKit).extend(leafKit)).toThrow( + /path 'colors\.primary' is defined divergently by extended theme #1 \(a nested branch\) and extended theme #2 \(a leaf value\)/ + ); + }); + + it('coalesces sibling paths with equal values silently', () => { + const makeKit = () => + createTheme() + .addBreakpoints(breakpoints) + .addColors({ primary: '#ff2800' }) + .build(); + const merged = createTheme().extend(makeKit()).extend(makeKit()).build(); + expect(merged.colors.primary).toBe('#ff2800'); + }); + + // NS-4: app-over-kit resolves silently — only kit-beside-kit is loud. + it('lets the consumer override a sibling-shared value silently after extends', () => { + const kitA = createTheme() + .addBreakpoints(breakpoints) + .addColors({ primary: '#ff2800' }) + .build(); + const composed = createTheme() + .extend(kitA) + .addColors({ primary: '#123456' }) + .build(); + expect((composed.colors as unknown as Record).primary).toBe( + '#123456' + ); + }); + + it('never mutates the consumed kit theme during composition', () => { + const kit = buildKitTheme(); + const colorsBefore = JSON.parse(JSON.stringify(kit.colors)); + const spaceBefore = JSON.parse(JSON.stringify(kit.space)); + createTheme() + .extend(kit) + .addColors({ ember: '#7c3aed' }) + .addScale({ name: 'space', values: { 12: '0.75rem' } }) + .build(); + expect(kit.colors).toEqual(colorsBefore); + expect(kit.space).toEqual(spaceBefore); + }); + + // "Late-binding …" › "Override recolors source-internal references" — + // the `.extend()`-spelled form (mechanism witnessed via from() in + // theme-resolver.test.ts; this claims the previously-unclaimed spelling). + it('extend(): consumer override recolors kit-authored references', () => { + const kit = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'palette', values: { ember: '#ff2800' } }) + .addScale({ + name: 'shadows', + values: { glow: '0 0 12px {palette.ember}' }, + }) + .build(); + const composed = createTheme() + .extend(kit) + .addScale({ name: 'palette', values: { ember: '#7c3aed' } }) + .build(); + expect(composed.manifest.tokenMap['shadows.glow']).toBe('0 0 12px #7c3aed'); + }); + + it('preserves the v1-manifest fail-closed taint through extend() (D8)', () => { + const real = buildKitTheme(); + // v1 facsimile: same raw data, manifest limited to the v1 field set. + const v1Source: Record = {}; + for (const key of Object.keys(real)) { + v1Source[key] = (real as Record)[key]; + } + Object.defineProperty(v1Source, 'manifest', { + value: { + tokenMap: real.manifest.tokenMap, + variableMap: real.manifest.variableMap, + modes: real.manifest.modes, + variableCss: real.manifest.variableCss, + }, + enumerable: false, + }); + + const rebuilt = createTheme().extend(v1Source).build(); + expect(rebuilt.serialize().variableCss).toBe(real.manifest.variableCss); + expect(rebuilt.manifest.manifestVersion).toBeUndefined(); + expect(rebuilt.manifest.tokenDefinitions).toBeUndefined(); + expect(rebuilt.manifest.modeAliasDefinitions).toBeUndefined(); + expect(rebuilt.manifest.registrations).toBeUndefined(); + expect(rebuilt.manifest.contractHash).toBeUndefined(); + expect(rebuilt.manifest.cssFragments).toBeUndefined(); + }); +}); + +// ─── Round-trip fidelity (MODIFIED) ────────────────────────── + +describe('extend() round-trip fidelity', () => { + /** Rich source: system options + @property registration + emitted refs. */ + function buildRichSource() { + return createTheme() + .addBreakpoints({ sm: 768, lg: 1200 }) + .addColors({ ink: '#101014', bone: '#f5f2ea', ash: '#8a8a8a' }) + .addColorModes( + 'paper', + { + paper: { fg: 'ink', bg: 'bone', muted: 'ash' }, + midnight: { fg: 'bone', bg: 'ink', muted: 'ash' }, + }, + { + systemPreference: { light: 'paper', dark: 'midnight' }, + browserColorScheme: { paper: 'light', midnight: 'dark' }, + } + ) + .addScale({ + name: 'shadows', + emit: true, + values: { glow: '0 0 12px {colors.fg}' }, + }) + .declareContextualVars( + { colors: ['current-bg'] }, + { 'current-bg': { syntax: '', inherits: true } } + ) + .build(); + } + + // Scenario: "Full round-trip". + it('extend(lib) with no augmentation serializes identically to the source', () => { + const lib = buildRichSource(); + const rebuilt = createTheme().extend(lib).build(); + expect(rebuilt.serialize()).toEqual(lib.serialize()); + expect(rebuilt.manifest.contractHash).toBe(lib.manifest.contractHash); + expect(rebuilt.manifest.registrations).toEqual(lib.manifest.registrations); + }); + + // D6 exemption: a kit's OWN mode asymmetry is pre-existing behavior — the + // coverage gate applies to consumer-declared modes only, so the + // asymmetric source still round-trips. + it('round-trips a source whose own modes are asymmetric without a coverage error', () => { + const asymmetric = createTheme() + .addBreakpoints(breakpoints) + .addColors({ ink: '#101014', bone: '#f5f2ea' }) + .addColorModes('dark', { + dark: { primary: 'ink', extra: 'bone' }, + light: { primary: 'bone' }, + }) + .build(); + const rebuilt = createTheme().extend(asymmetric).build(); + expect(rebuilt.serialize()).toEqual(asymmetric.serialize()); + }); + + it('does not infer scale emission from synthetic mode-alias variables', () => { + const source = createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'colors', + values: { primary: '#001122', red: '#ff0000' }, + }) + .addColorModes('light', { light: { primary: 'red' } }) + .build(); + expect(source.manifest.variableMap).not.toHaveProperty('colors.red'); + expect(source.manifest.variableMap).toHaveProperty('colors.primary'); + + const rebuilt = createTheme().extend(source).build(); + expect(rebuilt.serialize()).toEqual(source.serialize()); + expect(rebuilt.manifest.variableMap).not.toHaveProperty('colors.red'); + }); + + it('preserves emission for an empty source scale before local extension', () => { + const source = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'space', emit: true, values: {} }) + .build(); + + const rebuilt = createTheme() + .extend(source) + .extendScale('space', () => ({ md: '8px' })) + .build(); + + expect(source.manifest.emittedScales).toContain('space'); + expect(rebuilt.manifest.variableMap['space.md']).toBe('--space-md'); + expect(rebuilt.manifest.tokenMap['space.md']).toBe('var(--space-md)'); + }); + + it('resolves a same-path palette token and semantic alias without a self-reference', () => { + const source = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'colors', values: { primary: '#ff0000' } }) + .addColorModes('light', { light: { primary: 'primary' } }) + .build(); + + expect(source.manifest.variableCss).toContain('--color-primary: #ff0000;'); + expect(source.manifest.variableCss).not.toContain( + '--color-primary: var(--color-primary);' + ); + expect(source.manifest.modes.light['colors.primary']).toBe('#ff0000'); + }); + + it('unions contextual variables from sibling extensions', () => { + const buildSource = (name: string) => + createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'space', values: { sm: '4px' } }) + .declareContextualVars({ space: [name] }) + .build(); + + const theme = createTheme() + .extend(buildSource('gap')) + .extend(buildSource('pad')) + .build(); + expect(theme.manifest.contextualVars?.space).toEqual(['gap', 'pad']); + }); + + it('preserves repeated contextual-variable entries on a no-op extension', () => { + const source = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'space', values: { sm: '4px' } }) + .declareContextualVars({ space: ['gap', 'gap'] }) + .build(); + + expect(createTheme().extend(source).build().serialize()).toEqual( + source.serialize() + ); + }); + + it('rejects divergent registrations for one contextual variable', () => { + const buildSource = (inherits: boolean) => + createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'space', values: { sm: '4px' } }) + .declareContextualVars( + { space: ['gap'] }, + { gap: { syntax: '', inherits } } + ) + .build(); + + expect(() => + createTheme().extend(buildSource(true)).extend(buildSource(false)) + ).toThrow(/contextual variable 'gap'.*divergent/); + }); +}); + +// ─── Deep merge semantics (MODIFIED) ───────────────────────── + +describe('deep merge semantics on augmentation (MODIFIED)', () => { + // Scenario: "addColors deep merges". + it('addColors deep merges: later caller wins on conflict, base preserved on non-conflict', () => { + const base = createTheme() + .addBreakpoints(breakpoints) + .addColors({ gray: { 50: '#fafafa', 100: '#f0f0f0' } }) + .build(); + const composed = createTheme() + .extend(base) + .addColors({ gray: { 50: '#ffffff' } }) + .build(); + // Runtime storage is nested; the type surface is flat dot-paths. + expect( + (composed.colors as unknown as Record).gray + ).toEqual({ + 50: '#ffffff', + 100: '#f0f0f0', + }); + }); + + // Scenario: "addScale merges by key" — the MODIFIED spec supersedes the + // old replace-by-name scenario (which the v3 runtime never implemented: + // `merge` has always deep-merged same-named scales by key). + it('addScale merges by key: union of keys, consumer value on conflict', () => { + const composed = createTheme() + .extend(buildKitTheme()) + .addScale({ name: 'space', values: { 4: '0.3rem', 12: '0.75rem' } }) + .build(); + expect(composed.space as unknown).toEqual({ + 0: '0', + 4: '0.3rem', + 8: '0.5rem', + 12: '0.75rem', + }); + }); + + // Scenario: "Explicit replacement replaces wholesale". + it('addScale replace: true replaces wholesale — exactly the consumer keys remain', () => { + const kit = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'radii', values: { sm: '2px', lg: '8px' } }) + .build(); + const replaced = createTheme() + .extend(kit) + .addScale({ name: 'radii', values: { pill: '999px' }, replace: true }) + .build(); + expect(replaced.radii as unknown).toEqual({ pill: '999px' }); + expect(replaced.manifest.tokenMap['radii.sm']).toBeUndefined(); + }); + + // Scenario: "addColorModes merges modes". + it('addColorModes merges modes: base modes plus the consumer mode', () => { + const composed = createTheme() + .extend(buildKitTheme()) + .addColorModes('dark', { + custom: { primary: 'bone', bg: 'ember', muted: 'void' }, + }) + .build(); + expect(Object.keys(composed.manifest.modes).sort()).toEqual([ + 'custom', + 'dark', + 'light', + ]); + }); +}); + +// ─── Structural progressivity (D5) ─────────────────────────── + +describe('structural progressivity of inherited tokens (D5)', () => { + function buildReferencingKit() { + return createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'shadows', values: { glow: '0 0 12px #ffffff' } }) + .addScale({ name: 'effects', values: { halo: '{shadows.glow} inset' } }) + .build(); + } + + // Scenario: "Wholesale replacement is explicit". + it('addScale without replace keeps inherited keys present', () => { + const composed = createTheme() + .extend(buildKitTheme()) + .addScale({ name: 'space', values: { 12: '0.75rem' } }) + .build(); + expect(composed.space[0]).toBe('0'); + expect(composed.space[4]).toBe('0.25rem'); + expect(composed.space[8]).toBe('0.5rem'); + }); + + // Scenario: "Dangling reference fails at build" — unconditional on usage + // (no component uses `effects.halo` anywhere; the build still fails). + it('explicit replacement dropping a referenced key fails build() naming referencer, call, and dropped keys', () => { + expect(() => + createTheme() + .extend(buildReferencingKit()) + .addScale({ + name: 'shadows', + values: { rim: '0 0 2px #000000' }, + replace: true, + }) + .build() + ).toThrow( + /build: dangling token reference\(s\) after explicit scale replacement — 'effects\.halo' \(extended theme #1\) references '\{shadows\.glow\}', dropped by addScale\(\{ name: 'shadows', replace: true \}\)\. Dropped keys: shadows\.glow\./ + ); + }); + + it('a re-added key after replacement makes the reference resolve again', () => { + const rebuilt = createTheme() + .extend(buildReferencingKit()) + .addScale({ + name: 'shadows', + values: { rim: '0 0 2px #000000' }, + replace: true, + }) + .addScale({ name: 'shadows', values: { glow: '0 0 9px #123456' } }) + .build(); + expect(rebuilt.manifest.tokenMap['effects.halo']).toBe( + '0 0 9px #123456 inset' + ); + }); + + it('references to paths never defined anywhere keep warn-and-literal (kit pattern)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const composed = createTheme() + .extend(buildKitTheme()) + .addScale({ name: 'accents', values: { ring: '{colors.brandTint}' } }) + .build(); + expect(composed.manifest.tokenMap['accents.ring']).toBe( + '{colors.brandTint}' + ); + expect(warnSpy).toHaveBeenCalledWith( + "[animus] Token ref {colors.brandTint} — path 'colors.brandTint' not found in token map" + ); + warnSpy.mockRestore(); + }); +}); + +// ─── Mode bases (D6) ───────────────────────────────────────── + +describe('mode extension declares its base (D6)', () => { + // Scenario: "Declared base fills coverage gaps". + it('a declared base fills coverage gaps with one aggregated diagnostic', () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const composed = createTheme() + .extend(buildKitTheme()) + .addColorModes( + 'dark', + { 'high-contrast': { primary: 'void' } }, + { basedOn: { 'high-contrast': 'light' } } + ) + .build(); + + const css = composed.serialize().variableCss; + const block = blockDeclarations(css, '[data-color-mode="high-contrast"]'); + // Overridden alias uses the consumer's value… + expect(block).toContain('--color-primary: #000000;'); + // …and the uncovered aliases resolve through the declared base (light). + expect(block).toContain('--color-bg: #e8e0d0;'); + expect(block).toContain('--color-muted: #ff2800;'); + // ONE aggregated report, never per-token spam. + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledWith( + "[animus] Mode 'high-contrast': 2 alias(es) inherit from 'light'" + ); + infoSpy.mockRestore(); + }); + + // Scenario: "Missing base with gaps fails". + it('a consumer mode with uncovered inherited aliases and no base fails listing them', () => { + expect(() => + createTheme() + .extend(buildKitTheme()) + .addColorModes('dark', { 'high-contrast': { primary: 'void' } }) + .build() + ).toThrow( + /build: mode 'high-contrast' leaves 2 inherited alias\(es\) uncovered and declares no base — uncovered: bg, muted/ + ); + }); + + it('resolves uncovered aliases through a multi-level basedOn chain, one diagnostic per mode', () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const composed = createTheme() + .extend(buildKitTheme()) + .addColorModes( + 'dark', + { + hc: { primary: 'void', bg: 'ember' }, + hcDim: { primary: 'bone' }, + }, + { basedOn: { hc: 'light', hcDim: 'hc' } } + ) + .build(); + const block = blockDeclarations( + composed.serialize().variableCss, + '[data-color-mode="hcDim"]' + ); + // hcDim's own override… + expect(block).toContain('--color-primary: #e8e0d0;'); + // …bg resolves through hc (one hop)… + expect(block).toContain('--color-bg: #ff2800;'); + // …muted resolves through hc → light (two hops). + expect(block).toContain('--color-muted: #ff2800;'); + expect(infoSpy).toHaveBeenCalledTimes(2); + expect(infoSpy).toHaveBeenCalledWith( + "[animus] Mode 'hc': 1 alias(es) inherit from 'light'" + ); + expect(infoSpy).toHaveBeenCalledWith( + "[animus] Mode 'hcDim': 2 alias(es) inherit from 'hc'" + ); + infoSpy.mockRestore(); + }); + + it('rejects a basedOn entry naming an unknown base mode', () => { + expect(() => + createTheme() + .extend(buildKitTheme()) + .addColorModes( + 'dark', + { 'high-contrast': { primary: 'void' } }, + { basedOn: { 'high-contrast': 'nocturne' } } + ) + ).toThrow( + /addColorModes: basedOn\['high-contrast'\] references unknown base mode 'nocturne'/ + ); + }); + + it('rejects a basedOn self-base and a basedOn cycle', () => { + expect(() => + createTheme() + .extend(buildKitTheme()) + .addColorModes( + 'dark', + { hc: { primary: 'void' } }, + { basedOn: { hc: 'hc' } } + ) + ).toThrow(/cannot base a mode on itself/); + + expect(() => + createTheme() + .extend(buildKitTheme()) + .addColorModes( + 'dark', + { hcA: { primary: 'void' }, hcB: { primary: 'bone' } }, + { basedOn: { hcA: 'hcB', hcB: 'hcA' } } + ) + ).toThrow(/basedOn chain cycles/); + }); + + it('a consumer mode covering every inherited alias needs no base', () => { + const composed = createTheme() + .extend(buildKitTheme()) + .addColorModes('dark', { + custom: { primary: 'bone', bg: 'ember', muted: 'void' }, + }) + .build(); + expect( + blockDeclarations( + composed.serialize().variableCss, + '[data-color-mode="custom"]' + ) + ).toEqual([ + '--color-bg: #ff2800;', + '--color-muted: #000000;', + '--color-primary: #e8e0d0;', + ]); + }); +}); + +// ─── Determinism closures (inc 03 review register) ─────────── + +describe('determinism closures (G2/G3)', () => { + // G3: reversed declarations — including breakpoint keys, color keys, mode + // config order, alias order, and scale order — are byte-identical across + // the WHOLE emitted CSS and the serialized wire. + function buildForwardDeclared() { + return createTheme() + .addBreakpoints({ sm: 768, lg: 1200, md: 1024 }) + .addScale({ name: 'space', values: { edge: '1rem', gutter: '2rem' } }) + .addColors({ ink: '#101014', bone: '#f5f2ea', ash: '#8a8a8a' }) + .addColorModes('paper', { + paper: { fg: 'ink', bg: 'bone', muted: 'ash' }, + midnight: { fg: 'bone', bg: 'ink', muted: 'ash' }, + }) + .build(); + } + function buildReverseDeclared() { + return createTheme() + .addBreakpoints({ md: 1024, lg: 1200, sm: 768 }) + .addColors({ ash: '#8a8a8a', bone: '#f5f2ea', ink: '#101014' }) + .addColorModes('paper', { + midnight: { muted: 'ash', bg: 'ink', fg: 'bone' }, + paper: { muted: 'ash', bg: 'bone', fg: 'ink' }, + }) + .addScale({ name: 'space', values: { gutter: '2rem', edge: '1rem' } }) + .build(); + } + + it('G3: reversed declarations are byte-identical across CSS, scalesJson, and variableMapJson', () => { + const forward = buildForwardDeclared().serialize(); + const reversed = buildReverseDeclared().serialize(); + expect(reversed.variableCss).toBe(forward.variableCss); + expect(reversed.scalesJson).toBe(forward.scalesJson); + expect(reversed.variableMapJson).toBe(forward.variableMapJson); + expect(reversed.contextualVarsJson).toBe(forward.contextualVarsJson); + }); + + // G2 (review probe, journal 2026-08-04 15:04): mode-override declarations + // previously carried RAW flattened values, leaking `{…}` into + // [data-color-mode] blocks when colors was a reference-valued addScale. + it('G2: mode-override declarations resolve through the resolver', () => { + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'palette', + values: { fire: '#ff2800', ice: '#0044ff' }, + }) + .addScale({ + name: 'colors', + values: { ember: '{palette.fire}', frost: '{palette.ice}' }, + }) + .addColorModes('warm', { + warm: { primary: 'ember' }, + cool: { primary: 'frost' }, + }) + .build(); + const css = theme.serialize().variableCss; + expect(css).not.toMatch(/\{[a-zA-Z0-9_.]+\}/); + expect(blockDeclarations(css, '[data-color-mode="warm"]')).toContain( + '--color-primary: #ff2800;' + ); + expect(blockDeclarations(css, '[data-color-mode="cool"]')).toContain( + '--color-primary: #0044ff;' + ); + expect(theme.manifest.modes.warm['colors.primary']).toBe('#ff2800'); + }); + + // G2: an emitted declaration whose reference target is never defined + // anywhere is OMITTED from emitted CSS with one aggregated warning — a + // literal `{…}` in shipped CSS is worse than an absent declaration. + it('G2: omits emitted declarations with never-defined targets, one aggregated warning', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'shadows', + emit: true, + values: { glow: '0 0 12px {colors.brandTint}', rim: '0 0 1px #000000' }, + }) + .build(); + const css = theme.serialize().variableCss; + expect(css).not.toContain('--shadows-glow'); + expect(css).toContain('--shadows-rim: 0 0 1px #000000;'); + expect(css).not.toMatch(/\{[a-zA-Z0-9_.]+\}/); + // ONE aggregated omission warning naming the omitted var (plus the + // resolver's existing once-per-missing-path warning). + const omissionCalls = warnSpy.mock.calls.filter(([message]) => + String(message).startsWith('[animus] Omitted') + ); + expect(omissionCalls).toEqual([ + [ + '[animus] Omitted 1 CSS declaration(s) whose token references never resolved: --shadows-glow', + ], + ]); + warnSpy.mockRestore(); + }); + + it('G2: omission covers TRANSITIVE never-defined targets reaching an emitted declaration', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'edges', values: { hot: '1px solid {ghost.color}' } }) + .addScale({ name: 'frames', emit: true, values: { card: '{edges.hot}' } }) + .build(); + const css = theme.serialize().variableCss; + expect(css).not.toContain('--frames-card'); + expect(css).not.toMatch(/\{[a-zA-Z0-9_.]+\}/); + // The aggregated warning names the transitively-omitted var (review F6). + const omissionCalls = warnSpy.mock.calls.filter(([message]) => + String(message).startsWith('[animus] Omitted') + ); + expect(omissionCalls).toEqual([ + [ + '[animus] Omitted 1 CSS declaration(s) whose token references never resolved: --frames-card', + ], + ]); + // The non-emitted surface keeps warn-and-literal (supported kit pattern). + expect(theme.manifest.tokenMap['edges.hot']).toBe( + '1px solid {ghost.color}' + ); + warnSpy.mockRestore(); + }); +}); + +// ─── from() freeze (G6) ────────────────────────────────────── + +describe('from() freeze during the deprecation window (G6)', () => { + // Scenario: "from() precedence is unchanged during the window" — source + // WINS over prior builder state, and from() stays callable after + // augmentation calls (no stage gate). + it('keeps from() source-wins and callable after augmentation', () => { + const lib = createTheme() + .addBreakpoints(breakpoints) + .addColors({ ember: '#ff2800' }) + .build(); + const theme = createTheme() + .addBreakpoints(breakpoints) + .addColors({ ember: '#00ff00' }) + .from(lib) + .build(); + expect((theme.colors as unknown as Record).ember).toBe( + '#ff2800' + ); + }); + + // Scenario: "Deprecation is visible to consumers" — the published types + // are emitted from this docblock, so the source-level tag is the witness. + it('marks theme from() as deprecated pointing at extend()', () => { + const builderSource = readFileSync( + resolve(fileURLToPath(import.meta.url), '../../src/theme/createTheme.ts'), + 'utf8' + ); + expect(builderSource).toMatch(/@deprecated Use `extend\(source\)`/); + }); +}); diff --git a/packages/system/__tests__/theme-resolver.test.ts b/packages/system/__tests__/theme-resolver.test.ts new file mode 100644 index 00000000..e2a0f89f --- /dev/null +++ b/packages/system/__tests__/theme-resolver.test.ts @@ -0,0 +1,450 @@ +/** + * Tests for the late-binding theme reference resolver (openspec change: + * first-class-extension, increment 03 — spec `theme-composition`, D4). + * + * Scenario mapping (spec header → test): + * - "Late-binding reference resolution over the merged theme" › + * "Declaration order is not observable" → 'G3: reversed declaration order + * produces byte-identical tokenMap and variableCss' (+ the forward-refs + * resolution check) + * - … › "Emitted and inlined forms agree" → 'G1: emitted and inlined forms + * of the same scale resolve every path to the same value' + the + * mode-block witness beside it (inc 04 closure of the review-registered + * G1 blind spot: the original witness covered the base mode only) + * - … › "Reference cycle fails the build" → the three cycle tests + * - … › "Override recolors source-internal references" → mechanism witness + * 're-resolves kit-authored references against later overrides' (the + * `.extend()`-spelled form of this scenario landed with increment 04 in + * theme-extend.test.ts; the late-binding mechanism it depends on is + * witnessed here through `from()`) + * - "Emitted scale references resolve in emitted CSS" › "Cross-scale + * reference in an emitted scale" → 'G2: cross-scale reference in an + * emitted scale resolves to a var() chain' + * + * Boundary pinned elsewhere: unresolvable references stay warn-and-literal + * (supported kit pattern — dangling-reference ERRORS belong to increment 04 + * with the explicit replacement form); the warn-once discipline is covered + * below. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { createTheme } from '../src'; + +const breakpoints = { sm: 768 } as const; + +// ─── Helpers: var() chasing for emission-parity comparison ─── + +/** Parse the `:root` block's custom-property declarations into a map. */ +function rootVariables(css: string): Record { + const start = css.indexOf(':root {'); + if (start === -1) return {}; + const open = css.indexOf('{', start); + const close = css.indexOf('}', open); + const map: Record = {}; + for (const line of css.slice(open + 1, close).split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('--')) continue; + const colonIdx = trimmed.indexOf(':'); + map[trimmed.slice(0, colonIdx)] = trimmed + .slice(colonIdx + 1) + .replace(/;$/, '') + .trim(); + } + return map; +} + +/** + * Substitute `var(--x)` occurrences from `vars` to a fixpoint — the cascade's + * late binding, replayed textually. This is what makes emitted and inlined + * forms comparable: both must chase to the same final value. + */ +function chaseVars(value: string, vars: Record): string { + let current = value; + for (let i = 0; i < 32; i++) { + const next = current.replace(/var\((--[^)]+)\)/g, (match, name: string) => + vars[name] !== undefined ? vars[name] : match + ); + if (next === current) return current; + current = next; + } + throw new Error(`var() chase did not terminate for: ${value}`); +} + +// ─── G3: declaration order is not observable ───────────────── + +/** + * Forward references in BOTH directions plus reversed key order inside a + * scale: under the old single-pass resolver the reversed build left + * `{base.unit}` literal (verified by execution during exploration). + */ +function buildForwardDeclared() { + return createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'base', values: { unit: '4px', double: '8px' } }) + .addScale({ name: 'mid', values: { gap: 'calc({base.unit} * 2)' } }) + .addScale({ + name: 'top', + values: { pad: 'calc({mid.gap} + {base.unit})' }, + }) + .addScale({ name: 'hues', emit: true, values: { red: '#ff2800' } }) + .addScale({ + name: 'paints', + emit: true, + values: { brand: '{hues.red}', flat: '#ffffff' }, + }) + .build(); +} + +function buildReverseDeclared() { + return createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'paints', + emit: true, + values: { flat: '#ffffff', brand: '{hues.red}' }, + }) + .addScale({ name: 'hues', emit: true, values: { red: '#ff2800' } }) + .addScale({ + name: 'top', + values: { pad: 'calc({mid.gap} + {base.unit})' }, + }) + .addScale({ name: 'mid', values: { gap: 'calc({base.unit} * 2)' } }) + .addScale({ name: 'base', values: { double: '8px', unit: '4px' } }) + .build(); +} + +describe('declaration-order independence (G3)', () => { + it('G3: reversed declaration order produces byte-identical tokenMap and variableCss', () => { + const forward = buildForwardDeclared().serialize(); + const reversed = buildReverseDeclared().serialize(); + expect(reversed.scalesJson).toBe(forward.scalesJson); + expect(reversed.variableCss).toBe(forward.variableCss); + }); + + it('resolves forward references fully in both declaration orders', () => { + for (const theme of [buildForwardDeclared(), buildReverseDeclared()]) { + expect(theme.manifest.tokenMap['mid.gap']).toBe('calc(4px * 2)'); + expect(theme.manifest.tokenMap['top.pad']).toBe( + 'calc(calc(4px * 2) + 4px)' + ); + expect(theme.serialize().variableCss).toContain( + '--paints-brand: var(--hues-red);' + ); + } + }); +}); + +// ─── G1: emitted and inlined forms agree ───────────────────── + +describe('emission parity (G1)', () => { + function buildEmitFlipped(emit: boolean) { + return createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'palette', + emit, + values: { ink: '#101014', accent: '{palette.ink}' }, + }) + .addScale({ + name: 'shadows', + values: { + glow: '0 0 12px {palette.ink/40}', + rim: '0 0 2px {palette.accent}', + }, + }) + .build(); + } + + it('G1: emitted and inlined forms of the same scale resolve every path to the same value', () => { + const emitted = buildEmitFlipped(true); + const inlined = buildEmitFlipped(false); + const vars = rootVariables(emitted.serialize().variableCss); + const emittedResolved = Object.fromEntries( + Object.entries(emitted.manifest.tokenMap).map(([path, value]) => [ + path, + chaseVars(value, vars), + ]) + ); + expect(emittedResolved).toEqual(inlined.manifest.tokenMap); + }); + + // ── Mode-block coverage (inc 04 — closes the registered G1 blind spot: + // the witness above covers the base mode on one fixture only) ── + + /** Parse the declarations of the `[data-color-mode="X"]` block. */ + function modeBlockVariables( + css: string, + mode: string + ): Record { + const header = `[data-color-mode="${mode}"]`; + const start = css.indexOf(header); + if (start === -1) throw new Error(`mode block '${mode}' not found`); + const open = css.indexOf('{', start); + const close = css.indexOf('}', open); + const map: Record = {}; + for (const line of css.slice(open + 1, close).split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('--')) continue; + const colonIdx = trimmed.indexOf(':'); + map[trimmed.slice(0, colonIdx)] = trimmed + .slice(colonIdx + 1) + .replace(/;$/, '') + .trim(); + } + return map; + } + + /** + * A moded theme whose color values are REFERENCES into another scale, with + * the colors scale's emission flipped — under the pre-inc-04 emitter the + * mode blocks carried the raw `{palette.…}` strings verbatim. + */ + function buildModedEmitFlipped(emit: boolean) { + return createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'palette', + values: { fire: '#ff2800', ice: '#0044ff' }, + }) + .addScale({ + name: 'colors', + emit, + values: { ember: '{palette.fire}', frost: '{palette.ice}' }, + }) + .addColorModes('warm', { + warm: { primary: 'ember' }, + cool: { primary: 'frost' }, + }) + .build(); + } + + it('G1: mode-block declarations chase to identical values under an emit flip', () => { + const emitted = buildModedEmitFlipped(true); + const inlined = buildModedEmitFlipped(false); + for (const mode of ['warm', 'cool']) { + const chase = (theme: ReturnType) => { + const css = theme.serialize().variableCss; + const scope = { + ...rootVariables(css), + ...modeBlockVariables(css, mode), + }; + return Object.fromEntries( + Object.entries(modeBlockVariables(css, mode)).map(([name, value]) => [ + name, + chaseVars(value, scope), + ]) + ); + }; + expect(chase(emitted)).toEqual(chase(inlined)); + } + // The manifest's mode value maps agree across the flip as well. + expect(emitted.manifest.modes).toEqual(inlined.manifest.modes); + }); +}); + +// ─── G2: references inside emitted scales resolve into CSS ─── + +describe('emitted-scale reference resolution (G2)', () => { + it('G2: cross-scale reference in an emitted scale resolves to a var() chain', () => { + const theme = createTheme() + .addBreakpoints(breakpoints) + .addColors({ ember: '#ff2800' }) + .addScale({ + name: 'shadows', + emit: true, + values: { glow: '0 0 12px {colors.ember}' }, + }) + .build(); + const css = theme.serialize().variableCss; + expect(css).toContain('--shadows-glow: 0 0 12px var(--color-ember);'); + expect(css).not.toMatch(/\{[a-zA-Z0-9_.]+\}/); + // The tokenMap keeps the emitted indirection. + expect(theme.manifest.tokenMap['shadows.glow']).toBe('var(--shadows-glow)'); + }); + + it('omits emitted declarations that transitively depend on a missing token', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'missing', + emit: true, + values: { base: '{ghost.x}' }, + }) + .addScale({ + name: 'consumer', + emit: true, + values: { use: '{missing.base}' }, + }) + .build(); + + expect(theme.serialize().variableCss).not.toContain('--missing-base:'); + expect(theme.serialize().variableCss).not.toContain('--consumer-use:'); + warnSpy.mockRestore(); + }); +}); + +// ─── Same-scale references are legal DAG edges ─────────────── + +describe('same-scale references', () => { + it('resolves same-scale references in a non-emitted scale without warning', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'space', + values: { gutter: '16px', page: 'calc({space.gutter} * 2)' }, + }) + .build(); + expect(theme.manifest.tokenMap['space.page']).toBe('calc(16px * 2)'); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('resolves same-scale references in an emitted scale to a var() chain', () => { + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'palette', + emit: true, + values: { ink: '#101014', accent: '{palette.ink}' }, + }) + .build(); + expect(theme.serialize().variableCss).toContain( + '--palette-accent: var(--palette-ink);' + ); + }); +}); + +// ─── Chains through emitted AND inlined targets ────────────── + +describe('mixed-emission reference chains', () => { + it('resolves chains that pass through both emitted and inlined targets', () => { + const theme = createTheme() + .addBreakpoints(breakpoints) + .addColors({ ember: '#ff2800' }) + .addScale({ + name: 'edges', + values: { hot: '1px solid {colors.ember}' }, + }) + .addScale({ name: 'frames', emit: true, values: { card: '{edges.hot}' } }) + .addScale({ name: 'composed', values: { hero: '{frames.card}' } }) + .build(); + + // inlined → emitted target: var() substitution + expect(theme.manifest.tokenMap['edges.hot']).toBe( + '1px solid var(--color-ember)' + ); + // emitted → inlined target: the resolved literal lands in the declaration + expect(theme.serialize().variableCss).toContain( + '--frames-card: 1px solid var(--color-ember);' + ); + // inlined → emitted target again: the var() chain, never the raw ref + expect(theme.manifest.tokenMap['composed.hero']).toBe('var(--frames-card)'); + }); + + it('resolves opacity chains through non-emitted intermediates', () => { + const theme = createTheme() + .addBreakpoints(breakpoints) + .addColors({ ember: '#ff2800' }) + .addScale({ name: 'tints', values: { soft: '{colors.ember/20}' } }) + .addScale({ name: 'overlays', values: { dim: '{tints.soft/50}' } }) + .build(); + expect(theme.manifest.tokenMap['tints.soft']).toBe( + 'color-mix(in srgb, var(--color-ember) 20%, transparent)' + ); + expect(theme.manifest.tokenMap['overlays.dim']).toBe( + 'color-mix(in srgb, color-mix(in srgb, var(--color-ember) 20%, transparent) 50%, transparent)' + ); + }); + + it('degrades empty or non-numeric opacity modifiers to the unmodified base', () => { + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'p', values: { base: '#123456' } }) + .addScale({ + name: 'q', + values: { odd: '{p.base/}', bad: '{p.base/abc}' }, + }) + .build(); + expect(theme.manifest.tokenMap['q.odd']).toBe('#123456'); + expect(theme.manifest.tokenMap['q.bad']).toBe('#123456'); + }); +}); + +// ─── Cycles: hard error naming the cycle ───────────────────── + +describe('reference cycles', () => { + it('fails the build naming both tokens of a reference cycle', () => { + expect(() => + createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'loop', values: { a: '{loop.b}', b: '{loop.a}' } }) + .build() + ).toThrow(/token reference cycle — 'loop\.a' → 'loop\.b' → 'loop\.a'/); + }); + + it('fails the build on a self-referential token', () => { + expect(() => + createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'loop', values: { a: '{loop.a}' } }) + .build() + ).toThrow(/'loop\.a' → 'loop\.a'/); + }); + + it('fails a cyclic theme identically when the scale is emitted (emission-invariant)', () => { + expect(() => + createTheme() + .addBreakpoints(breakpoints) + .addScale({ + name: 'loop', + emit: true, + values: { a: '{loop.b}', b: '{loop.a}' }, + }) + .build() + ).toThrow(/token reference cycle/); + }); +}); + +// ─── Unresolvable references: warn once, keep literal ──────── + +describe('unresolvable references (supported kit pattern)', () => { + it('warns once per missing path and keeps the literal', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const theme = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'a', values: { one: '{ghost.token}' } }) + .addScale({ name: 'b', values: { two: 'solid {ghost.token}' } }) + .build(); + expect(theme.manifest.tokenMap['a.one']).toBe('{ghost.token}'); + expect(theme.manifest.tokenMap['b.two']).toBe('solid {ghost.token}'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + "[animus] Token ref {ghost.token} — path 'ghost.token' not found in token map" + ); + warnSpy.mockRestore(); + }); +}); + +// ─── Late binding over the merged map ──────────────────────── + +describe('late binding over the composed theme', () => { + it('re-resolves kit-authored references against later overrides', () => { + const kit = createTheme() + .addBreakpoints(breakpoints) + .addScale({ name: 'palette', values: { ember: '#ff2800' } }) + .addScale({ + name: 'shadows', + values: { glow: '0 0 12px {palette.ember}' }, + }) + .build(); + const composed = createTheme() + .from(kit) + .addScale({ name: 'palette', values: { ember: '#7c3aed' } }) + .build(); + // The kit's authored reference survives composition raw and resolves + // against the FINAL merged map — the consumer's override wins. + expect(composed.manifest.tokenMap['shadows.glow']).toBe('0 0 12px #7c3aed'); + }); +}); diff --git a/packages/system/__tests__/theme-state-isolation.test.ts b/packages/system/__tests__/theme-state-isolation.test.ts new file mode 100644 index 00000000..92d9f34f --- /dev/null +++ b/packages/system/__tests__/theme-state-isolation.test.ts @@ -0,0 +1,147 @@ +/** + * Builder-state isolation for ThemeBuilder: `merge` adopts nested source + * objects by reference and mutates them on later folds, so every builder + * step must deep-copy before merging. Without that, branching a builder + * cross-contaminates the branches AND the parent, build() outputs keep + * mutating after the fact, and from() corrupts the consumed kit's exported + * theme for every other consumer in the process (an SSR worker, a + * multi-environment build, a second ds.ts in one test file). + * + * Also pins the composition-boundary halves of the same review family: the + * canonical `theme` bundle spelling on from(), structural (not reference) + * equality for array-valued tokens in the sibling-conflict gate, and the + * transitive withholding of a synthesized mode-alias `var()` whose target + * declaration was itself withheld. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { createTheme } from '../src'; + +type NestedColors = Record>; + +describe('ThemeBuilder state isolation', () => { + it('branching a builder never cross-contaminates branches or the parent', () => { + const base = createTheme().addColors({ brand: { primary: '#111111' } }); + const branchA = base.addColors({ onlyA: { x: '#222222' } }); + const branchB = base.addColors({ onlyB: { x: '#333333' } }); + + const builtA = branchA.build(); + const builtB = branchB.build(); + const builtBase = base.build(); + + expect(Object.keys(builtA.colors).sort()).toEqual(['brand', 'onlyA']); + expect(Object.keys(builtB.colors).sort()).toEqual(['brand', 'onlyB']); + expect(Object.keys(builtBase.colors)).toEqual(['brand']); + }); + + it('build() output is a snapshot — later builder calls never mutate it', () => { + const builder = createTheme().addColors({ z: { a: '#333333' } }); + const built = builder.build(); + + builder.addColors({ z: { b: '#444444' } }); + + expect((built.colors as unknown as NestedColors).z).toEqual({ + a: '#333333', + }); + }); + + it('from() never mutates the consumed built theme', () => { + const kit = createTheme() + .addColors({ kitc: { a: '#111111' } }) + .build(); + + createTheme() + .from(kit) + .addColors({ kitc: { b: '#999999' } }); + + expect((kit.colors as unknown as NestedColors).kitc).toEqual({ + a: '#111111', + }); + }); + + it('extend() never mutates the consumed built theme through later augmentation', () => { + const kit = createTheme() + .addColors({ kitc: { a: '#111111' } }) + .build(); + + createTheme() + .extend(kit) + .addColors({ kitc: { b: '#999999' } }); + + expect((kit.colors as unknown as NestedColors).kitc).toEqual({ + a: '#111111', + }); + }); +}); + +describe('from() bundle-half resolution', () => { + it('consumes the canonical `theme` spelling of a library bundle', () => { + const kitTheme = createTheme() + .addColors({ kitc: { a: '#111111' } }) + .build(); + const bundle = { system: { toConfig: () => ({}) }, theme: kitTheme }; + + const built = createTheme().from(bundle).build(); + + expect((built.colors as unknown as NestedColors | undefined)?.kitc).toEqual( + { a: '#111111' } + ); + expect(built.serialize().scalesJson).toContain('kitc'); + }); +}); + +describe('extend() array-valued token coalescing', () => { + it('repeated extension of one kit with an array-valued token coalesces', () => { + const kitLike = { fonts: { stack: ['Inter', 'sans-serif'] } }; + + expect(() => createTheme().extend(kitLike).extend(kitLike)).not.toThrow(); + }); + + it('structurally equal arrays from two sibling kits coalesce', () => { + const kitA = { fonts: { stack: ['Inter', 'sans-serif'] } }; + const kitB = { fonts: { stack: ['Inter', 'sans-serif'] } }; + + expect(() => createTheme().extend(kitA).extend(kitB)).not.toThrow(); + }); + + it('divergent array-valued tokens still fail loud naming the path', () => { + expect(() => + createTheme() + .extend({ fonts: { stack: ['Inter', 'sans-serif'] } }) + .extend({ fonts: { stack: ['Roboto', 'sans-serif'] } }) + ).toThrow(/fonts\.stack/); + }); +}); + +describe('mode-alias declarations with withheld targets', () => { + it('withholds a synthesized alias var() whose target was itself withheld', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + try { + const built = createTheme() + .addScale({ + name: 'colors', + values: { accent: '{colors.missing}', ink: '#000000' }, + emit: true, + }) + .addColorModes('light', { light: { primary: 'accent' } }) + .build(); + + const css = built.serialize().variableCss; + // The unresolvable target is withheld (pre-existing behavior)… + expect(css).not.toContain('--color-accent:'); + // …and the alias pointing at it must not ship a dangling var(). + expect(css).not.toContain('var(--color-accent)'); + expect(css).not.toContain('--color-primary:'); + // Healthy declarations still emit. + expect(css).toContain('--color-ink:'); + // Both drops are named in the aggregated omission warning. + const warned = warn.mock.calls.map((call) => String(call[0])).join('\n'); + expect(warned).toContain('--color-accent'); + expect(warned).toContain('--color-primary'); + } finally { + warn.mockRestore(); + info.mockRestore(); + } + }); +}); diff --git a/packages/system/__tests__/theme.test.ts b/packages/system/__tests__/theme.test.ts index 0426a583..bfde488d 100644 --- a/packages/system/__tests__/theme.test.ts +++ b/packages/system/__tests__/theme.test.ts @@ -482,6 +482,23 @@ describe('theme composition via from()', () => { // No space scale (not spread) expect((consumer as Record).space).toBeUndefined(); }); + + it('library bundle feeds the tokens half identically to the direct form', () => { + const kitSystem = { toConfig: () => ({}) }; + const viaBundle = createTheme() + .from({ system: kitSystem, tokens: libTokens }) + .addColors({ brand: { 500: '#3b82f6' } }) + .build(); + const direct = createTheme() + .from(libTokens) + .addColors({ brand: { 500: '#3b82f6' } }) + .build(); + + expect(viaBundle.serialize()).toEqual(direct.serialize()); + // The bundle's other halves never leak into the theme + expect((viaBundle as Record).system).toBeUndefined(); + expect((viaBundle as Record).tokens).toBeUndefined(); + }); }); // ─── Tests: declareContextualVars ─────────────────────────── diff --git a/packages/system/__tests__/transform-identity.test.ts b/packages/system/__tests__/transform-identity.test.ts new file mode 100644 index 00000000..807ea005 --- /dev/null +++ b/packages/system/__tests__/transform-identity.test.ts @@ -0,0 +1,151 @@ +/** + * Transform identity across the snapshot boundary (design D12/D7): the + * registry snapshot wraps every transform in a forwarding arrow whose OWN + * `toString()` is byte-identical for all transforms, so equality must see + * through the wrapper to the original source text — otherwise divergent + * anonymous transforms coalesce silently (first registered wins) and the + * serialization duplicate-name guard is blind. Also pins the producer-side + * variant: `createTransform(name, fn)` capturing a WRAPPER's text when `fn` + * is itself a createTransform product. + * + * Plus the structural-scale half of the same comparison family: the + * snapshot replaces object/array scales with frozen copies, so + * addGroup/addProps re-registration checks must compare scales + * structurally, as extend() already does. + */ +import { describe, expect, it } from 'vitest'; + +import { + areTransformsEqual, + createSystem, + createTransform, + type Prop, + type TransformFn, +} from '../src'; + +function prop(overrides: Partial = {}): Prop { + return { property: 'margin', ...overrides }; +} + +describe('anonymous transform identity across extend()', () => { + it('divergent anonymous transforms from two kits fail loud, never coalesce', () => { + const kitA = createSystem() + .addGroup('a', { + gap: prop({ property: 'gap', transform: (v) => `${v}px` }), + }) + .build().system; + const kitB = createSystem() + .addGroup('b', { + gap: prop({ property: 'gap', transform: (v) => `${v}rem` }), + }) + .build().system; + + expect(() => createSystem().extend(kitA).extend(kitB)).toThrow(/gap/); + }); + + it('repeated extension of one kit instance still coalesces', () => { + const kit = createSystem() + .addGroup('a', { + gap: prop({ property: 'gap', transform: (v) => `${v}px` }), + }) + .build().system; + + expect(() => createSystem().extend(kit).extend(kit)).not.toThrow(); + }); + + it('re-registering the kit prop with the same transform instance coalesces', () => { + const shared: TransformFn = (v) => `${v}px`; + const kit = createSystem() + .addGroup('a', { gap: prop({ property: 'gap', transform: shared }) }) + .build().system; + + expect(() => + createSystem() + .extend(kit) + .addProps({ gap: prop({ property: 'gap', transform: shared }) }) + ).not.toThrow(); + }); +}); + +describe('serialization duplicate-name guard', () => { + it('two different anonymous transforms sharing an inferred name fail loud', () => { + // Both inline arrows infer the property name `transform`, so they land + // on one key in the serialized transforms map — silently letting the + // last one win would apply pad's transform to gap's values. + const { system } = createSystem() + .addGroup('layout', { + gap: prop({ property: 'gap', transform: (v) => `${v}px` }), + pad: prop({ property: 'padding', transform: (v) => `${v}rem` }), + }) + .build(); + + expect(() => system.toConfig()).toThrow(/Transform name "transform"/); + }); + + it('one shared anonymous transform across two props serializes fine', () => { + const shared: TransformFn = (v) => `${v}px`; + const { system } = createSystem() + .addGroup('layout', { + gap: prop({ property: 'gap', transform: shared }), + pad: prop({ property: 'padding', transform: shared }), + }) + .build(); + + expect(() => system.toConfig()).not.toThrow(); + }); +}); + +describe('createTransform over a createTransform product', () => { + it('inherits the innermost captured source, not the wrapper text', () => { + const px = createTransform('px', (v) => `${v}px`); + const rem = createTransform('rem', (v) => `${v}rem`); + + expect( + areTransformsEqual( + createTransform('size', px), + createTransform('size', rem) + ) + ).toBe(false); + expect( + areTransformsEqual( + createTransform('size', px), + createTransform('size', px) + ) + ).toBe(true); + }); +}); + +describe('structural scale comparison in addGroup/addProps', () => { + const gridKit = () => + createSystem() + .addGroup('grid', { + flow: prop({ property: 'gridAutoFlow', scale: [] }), + }) + .build().system; + + it('re-registering an identical object-scaled prop after extend() coalesces', () => { + expect(() => + createSystem() + .extend(gridKit()) + .addGroup('g2', { + flow: prop({ property: 'gridAutoFlow', scale: [] }), + }) + ).not.toThrow(); + + expect(() => + createSystem() + .extend(gridKit()) + .addProps({ flow: prop({ property: 'gridAutoFlow', scale: [] }) }) + ).not.toThrow(); + }); + + it('a genuinely divergent scale still fails loud', () => { + expect(() => + createSystem() + .extend(gridKit()) + .addProps({ + flow: prop({ property: 'gridAutoFlow', scale: ['dense'] }), + }) + ).toThrow(/flow/); + }); +}); diff --git a/packages/system/__tests__/types.test-d.tsx b/packages/system/__tests__/types.test-d.tsx index cf3963bd..7a74b948 100644 --- a/packages/system/__tests__/types.test-d.tsx +++ b/packages/system/__tests__/types.test-d.tsx @@ -19,6 +19,7 @@ import { Component, forwardRef, useRef } from 'react'; import { compose, createSystem, createTheme, createTransform } from '../src'; import { createGlobalStyles, createKeyframes, ds, tokens } from './test-system'; +import type { LibraryBundle } from '../src'; import type { AnyBrandedComponent, SharedConfig, @@ -680,6 +681,32 @@ function TypeTests() { : false >; + type _StructuralKeysAreNotScales = Assert< + Extract< + | 'systemPreference' + | 'browserColorScheme' + | 'modeBases' + | 'manifest' + | 'serialize' + | 'varRef' + | '__emitted', + keyof TokenScales + > extends never + ? true + : false + >; + + // ❌ Builder/boundary keys cannot be authored or augmented as scales. + createTheme() + // @ts-expect-error — manifest is installed by build(), not a token scale + .addScale({ name: 'manifest', values: { entry: 'x' } }); + createTheme() + // @ts-expect-error — breakpoints are structural, not a token scale + .extendScale('breakpoints', () => ({ wide: 1440 })); + createTheme() + // @ts-expect-error — contextual vars can only attach to token scales + .declareContextualVars({ breakpoints: ['wide'] }); + // ✅ Scale values are raw in the type (var() mapping is in the manifest, not the type) type Builder2Theme = ReturnType<(typeof _scaleBuilder2)['build']>; type SizesType = Builder2Theme['sizes']; @@ -754,7 +781,7 @@ function TypeTests() { // Token ref validation (❌ cases) removed — type-level ValidateScaleRef was // removed to prevent TS2589 depth explosion (see createTheme.ts L269). - // Token refs are validated at runtime in resolveTokenRefs() during build(). + // Token refs are validated at runtime in resolveReferences() during build(). // ✅ Token ref to emitted scale with valid key compiles createTheme() @@ -1647,4 +1674,224 @@ const ExtendedBadge = StyledBadge.extend() .asComponent(Badge); void (); +// ── 15. createSystem().from() — inherit-first type state + admission ───────── +// (system-builder §"from() is the system inheritance entry point") +{ + const kitBuild = createSystem() + .addGroup('kitSurface', { + kitGlow: { property: 'boxShadow' }, + }) + .build(); + const kitDs = kitBuild.system; + const kitBundle = { + system: kitDs, + tokens: { colors: { externalAccent: '#f0f' } }, + }; + + // Positive: from() is chainable, repeatable, and precedes extension calls + void createSystem() + .from(kitDs) + .from(kitDs) + .addGroup('space', { m: { property: 'margin' } }) + .build(); + + // Positive: a library bundle feeds the system half; the source's group and + // prop TYPES are admitted on the consumer instance (compose/extend interop) + const { system: fromBundle } = createSystem().from(kitBundle).build(); + void fromBundle.styles({ kitGlow: '0 0 4px' }).system({ kitSurface: true }); + + // Positive: the canonical theme spelling does not erase system admission. + const { system: fromThemeBundle } = createSystem() + .from({ system: kitDs, theme: { colors: { accent: '#f0f' } } }) + .build(); + void fromThemeBundle + .styles({ kitGlow: '0 0 4px' }) + .system({ kitSurface: true }); + + // Positive: admission composes with the consumer's own extensions + const { system: consumer } = createSystem() + .from(kitDs) + .addGroup('space', { m: { property: 'margin', scale: 'space' } }) + .build(); + void consumer.styles({}).system({ kitSurface: true, space: true }); + + // Negative: inherit-first — from() is unavailable after an extension call + // @ts-expect-error — 'extend'-stage builder has no callable from() + void createSystem() + .addGroup('space', { m: { property: 'margin' } }) + .from(kitDs); + + // Negative: the deprecated includes alias does not admit the source's types + // (the alias consumer registers its own group so the picked-keys constraint + // is non-degenerate — an empty registry accepts any literal via Record) + const { system: aliased } = createSystem({ includes: [kitDs] }) + .addGroup('space', { m: { property: 'margin' } }) + .build(); + // @ts-expect-error — 'kitSurface' is not a group on the alias consumer + void aliased.styles({}).system({ kitSurface: true, space: true }); + + // Negative: from() requires a built system instance or a library bundle + // @ts-expect-error — plain object is neither shape + void createSystem().from({ notASystem: true }); + + // Positive: a kit export ANNOTATED as the public LibraryBundle interface is + // accepted at BOTH from() surfaces — the exact use its doc comment + // describes. The annotation erases the system half's generics, so the + // system builder admits no source types (its own type state passes + // through), and the theme builder consumes the tokens half as usual. + const publishedBundle: LibraryBundle = kitBundle; + const { system: fromPublished } = createSystem() + .from(publishedBundle) + .addGroup('space', { m: { property: 'margin' } }) + .build(); + void fromPublished.styles({}).system({ space: true }); + // @ts-expect-error — annotated bundle admits no source types + void fromPublished.styles({}).system({ kitSurface: true }); + void createTheme().from(publishedBundle).addColors({ ink: '#111' }).build(); +} + +// ── 16. createSystem().extend() — inherit-first type state + admission ─────── +// (system-builder §"extend() is the system extension entry point"; admission +// mirrors from() admission, G5 type half) +{ + const kitBuild = createSystem() + .addGroup('kitSurface', { + kitGlow: { property: 'boxShadow' }, + }) + .build(); + const kitDs = kitBuild.system; + const kitBundle = { + system: kitDs, + theme: { colors: { externalAccent: '#f0f' } }, + }; + + // Positive: extend() is chainable, repeatable, and precedes extension calls + void createSystem() + .extend(kitDs) + .extend(kitDs) + .addGroup('space', { m: { property: 'margin' } }) + .build(); + + // Positive: a library bundle feeds the system half; the source's group and + // prop TYPES are admitted on the consumer instance — backed by the runtime + // merge (extend.test.ts holds the runtime half of the same fact) + const { system: extendBundle } = createSystem().extend(kitBundle).build(); + void extendBundle.styles({ kitGlow: '0 0 4px' }).system({ kitSurface: true }); + + // Positive: admission composes with the consumer's own extensions + const { system: consumer } = createSystem() + .extend(kitDs) + .addGroup('space', { m: { property: 'margin', scale: 'space' } }) + .build(); + void consumer.styles({}).system({ kitSurface: true, space: true }); + + // Negative: inherit-first — extend() is unavailable after an extension call + // @ts-expect-error — 'extend'-stage builder has no callable extend() + void createSystem() + .addProps({ m: { property: 'margin' } }) + .extend(kitDs); + + // Negative: extend() requires a built system instance or a library bundle + // @ts-expect-error — plain object is neither shape + void createSystem().extend({ notASystem: true }); + + // Positive: a kit export ANNOTATED as the public LibraryBundle interface is + // accepted at the erased extend() overload — the annotation erases the + // system half's generics, so no source types are admitted and the + // builder's own type state passes through unchanged. + const publishedBundle: LibraryBundle = kitBundle; + const { system: extendPublished } = createSystem() + .extend(publishedBundle) + .addGroup('space', { m: { property: 'margin' } }) + .build(); + void extendPublished.styles({}).system({ space: true }); + // @ts-expect-error — annotated bundle admits no source types + void extendPublished.styles({}).system({ kitSurface: true }); +} + +// ── 17. createTheme().extend() — inherit-first type state + theme-half admission ── +// (theme-composition §"extend() composition entry point": "Inherit-first is +// type-enforced" scenario lives HERE; runtime halves in theme-extend.test.ts) +{ + const kitTheme = createTheme() + .addBreakpoints({ sm: 768 }) + .addColors({ ember: '#ff2800' }) + .addScale({ name: 'kitSpace', values: { 4: '0.25rem' } }) + .build(); + const kitDs = createSystem() + .addGroup('kitSurface', { kitGlow: { property: 'boxShadow' } }) + .build().system; + + // Positive: extend() admits the source theme's scales — the admitted key + // is usable by key-constrained augmentation (extendScale's keyof T bound) + void createTheme() + .extend(kitTheme) + .extendScale('kitSpace', () => ({ 8: '0.5rem' })) + .build(); + + const extendedKitTheme = createTheme().extend(kitTheme).build(); + type _ExtendedThemeKeepsEmittedColors = Assert< + 'colors' extends EmittedScales ? true : false + >; + + // Positive: extend() is chainable, repeatable, and precedes augmentation + void createTheme() + .extend(kitTheme) + .extend(kitTheme) + .addColors({ ink: '#111111' }) + .build(); + + // Positive: a bundle feeds the THEME half (D9) — admitted identically + void createTheme() + .extend({ system: kitDs, theme: kitTheme }) + .extendScale('kitSpace', () => ({ 8: '0.5rem' })) + .build(); + + // Positive: the pre-D9 `tokens` spelling still feeds the theme half + void createTheme() + .extend({ system: kitDs, tokens: kitTheme }) + .extendScale('kitSpace', () => ({ 8: '0.5rem' })) + .build(); + + // Negative: inherit-first — extend() is unavailable after an augmentation + // call ("Inherit-first is type-enforced") + // @ts-expect-error — 'extend'-stage builder has no callable extend() + void createTheme().addColors({ ink: '#111111' }).extend(kitTheme); + + // Positive: from() stays callable at ANY stage (frozen, stage-polymorphic + // passthrough — never gated during the deprecation window) + void createTheme().addColors({ ink: '#111111' }).from(kitTheme).build(); + void createTheme().extend(kitTheme).from(kitTheme).build(); + + // Positive: a kit export ANNOTATED as the public LibraryBundle interface + // is accepted — its theme half erases to `unknown`, so no keys are + // admitted and the chain continues on the builder's own type state. + const publishedBundle: LibraryBundle = { + system: kitDs, + theme: kitTheme, + }; + void createTheme() + .extend(publishedBundle) + .addColors({ ink: '#111111' }) + .build(); + + class ThemeWithMethod { + spacing = { sm: '4px' }; + ghostMethod() {} + } + const extendedClassTheme = createTheme() + .extend(new ThemeWithMethod()) + .build(); + void extendedClassTheme.spacing.sm; + // @ts-expect-error — runtime composition skips function-valued members + extendedClassTheme.ghostMethod(); + + const maybeCallable: { slot: string | (() => string) } = { + slot: () => 'runtime skips this value', + }; + const extendedMaybeCallable = createTheme().extend(maybeCallable).build(); + // @ts-expect-error — maybe-callable values cannot be promised as copied data + extendedMaybeCallable.slot; +} + void TypeTests; diff --git a/packages/system/src/SystemBuilder.ts b/packages/system/src/SystemBuilder.ts index d67137ce..e347e44a 100644 --- a/packages/system/src/SystemBuilder.ts +++ b/packages/system/src/SystemBuilder.ts @@ -1,4 +1,5 @@ import { Animus } from './Animus'; +import { type AssetRef } from './asset.js'; import { BUILT_IN_CONDITIONS, type ConditionAliasMap, @@ -17,7 +18,11 @@ import { type SelectorAliasMap, serializeSelectorMap, } from './selectors'; -import { NamedTransform } from './transforms/createTransform'; +import { + areTransformsEqual, + NamedTransform, + TransformFn, +} from './transforms/createTransform'; import { Prop, ThemedCSSProps } from './types/config'; import { AbstractProps } from './types/props'; @@ -35,10 +40,13 @@ export type GlobalStyleMap = Record>; /** One `src` descriptor of a font-face resource. */ export interface FontFaceSrc { /** - * Emitted byte-exact as authored — asset resolution and rewriting belong - * to the host bundler's CSS asset pipeline, not to extraction. + * A literal string is emitted byte-exact as authored — asset resolution + * and rewriting belong to the host bundler's CSS asset pipeline, not to + * extraction. An `AssetRef` (from `asset(specifier)`) rides through + * evaluation and emission as its placeholder string; the host plugin + * substitutes the bundler-resolved URL after extraction. */ - url: string; + url: string | AssetRef; /** Format hint (`woff2`, `woff`, …), rendered as `format('…')`. */ format?: string; } @@ -84,12 +92,120 @@ export type CreateKeyframesFactory< readonly [N in keyof Frames]: KeyframeFrameMap; }>; -type IncludableSystem = { toConfig(): SerializedConfig }; +type IncludableSystem = { + toConfig(): SerializedConfig; + /** + * Present on every system built by this version (attached non-enumerably + * next to `toConfig` — see `build()`). Optional in the type so systems + * built by an older @animus-ui/system remain structurally acceptable + * during the deprecation window; `extend()` fails loud at runtime when it + * is absent (design D7 — no `SerializedConfig` reconstruction). + */ + getRegistrySnapshot?(): RegistrySnapshot; +}; + +/** + * The frozen registry state captured at `build()` (design D7). `toConfig()` + * serializes from it and `extend()` merges from it, so post-build mutation of + * the public `propRegistry`/`groupRegistry` fields affects neither. Containers + * and per-entry objects are frozen shallow copies. Transforms are immutable, + * cached forwarding wrappers so later mutation of function metadata cannot + * alter serialization while anonymous transform behavior is retained. + */ +export interface RegistrySnapshot { + props: Record; + groups: Record; + selectors: SelectorAliasMap; + conditions: ConditionAliasMap; +} + +const snapshotTransformBySource = new WeakMap(); + +function snapshotTransform(source: TransformFn): TransformFn { + const cached = snapshotTransformBySource.get(source); + if (cached) return cached; + + const wrapper: TransformFn = (value, property, props) => + source(value, property, props); + Object.defineProperty(wrapper, 'name', { value: source.name }); + // The wrapper's own source text is byte-identical for EVERY transform, so + // it must present the wrapped function's text instead: bare-function + // equality (design D12) and the QuickJS transform capture both go through + // `toString()`, and the generic forwarder body would make all anonymous + // transforms compare equal. `source.toString()` (not + // Function.prototype.toString) so re-snapshotting a wrapper across extend + // generations keeps yielding the ORIGINAL text. + const sourceText = source.toString(); + Object.defineProperty(wrapper, 'toString', { + value: () => sourceText, + }); + const named = source as Partial; + if (named.transformName !== undefined) { + Object.defineProperty(wrapper, 'transformName', { + value: named.transformName, + enumerable: true, + }); + } + if (named.transformSource !== undefined) { + Object.defineProperty(wrapper, 'transformSource', { + value: named.transformSource, + enumerable: true, + }); + } + Object.freeze(wrapper); + snapshotTransformBySource.set(source, wrapper); + return wrapper; +} + +/** + * A library bundle groups one export for both builders: the system half is + * consumed by `createSystem().extend()`, the theme half by + * `createTheme().extend()`; each builder takes its half and ignores the rest. + * `tokens` is the pre-D9 name for the theme half — both spellings are + * accepted (design D9; removal horizon is DEF-8). + */ +export interface LibraryBundle { + system: IncludableSystem; + theme?: unknown; + tokens?: unknown; +} + +/** + * The one runtime discriminator for a library bundle: `system.toConfig` + * being callable. A built system instance also has a `.system()` CHAIN + * METHOD, so presence of a `system` key alone cannot discriminate the two + * shapes. Both builders' `from()` use this guard; the QuickJS capture + * script in the Rust system-loader mirrors it by necessity (it cannot + * import TS) and points back here. + */ +export function isLibraryBundle(value: unknown): value is LibraryBundle { + const system = (value as { system?: { toConfig?: unknown } } | null)?.system; + return Boolean(system) && typeof system?.toConfig === 'function'; +} export interface CreateSystemConfig { + /** + * @deprecated Use `createSystem().extend(source)` — the single extension + * verb on both builders, which actually merges the source's registries. + * The alias keeps its frozen pre-existing semantics (discovery membership + * via the same source list, NO registry merge, no type-surface admission) + * for at least one minor release after `extend()` ships. + */ includes?: readonly IncludableSystem[]; } +declare const STAGE_BRAND: unique symbol; + +/** + * Builder type-state for the inherit-first rule: `extend()` (and the + * deprecated `from()`) is only callable while the builder is in the + * `'inherit'` stage; every extension call (`addGroup`, `addProps`, + * `addSelectors`, `addConditions`) advances to `'extend'`, making "inherit + * first, then extend" a compile error rather than a lint. Phantom — never + * present at runtime. + */ +export type SystemBuilderStage = 'inherit' | 'extend'; + function orderedPropertiesEqual( existing: Prop['properties'], incoming: Prop['properties'] @@ -105,16 +221,82 @@ function orderedPropertiesEqual( return existing.every((property, index) => property === incoming[index]); } -function arePropDefinitionsEqual(existing: Prop, incoming: Prop): boolean { +function scalesEqual( + existing: Prop['scale'], + incoming: Prop['scale'] +): boolean { + if (existing === incoming) return true; + if (!existing || !incoming || typeof existing !== typeof incoming) { + return false; + } + if (typeof existing === 'string' || typeof incoming === 'string') { + return false; + } + if (Array.isArray(existing) || Array.isArray(incoming)) { + return ( + Array.isArray(existing) && + Array.isArray(incoming) && + existing.length === incoming.length && + existing.every((value, index) => value === incoming[index]) + ); + } + const existingMap = existing as Record; + const incomingMap = incoming as Record; + const existingKeys = Object.keys(existingMap).sort(); + const incomingKeys = Object.keys(incomingMap).sort(); + return ( + orderedMembersEqual(existingKeys, incomingKeys) && + existingKeys.every((key) => existingMap[key] === incomingMap[key]) + ); +} + +function arePropDefinitionsEqual( + existing: Prop, + incoming: Prop, + structuralScale = false +): boolean { return ( existing.property === incoming.property && orderedPropertiesEqual(existing.properties, incoming.properties) && - existing.scale === incoming.scale && + (structuralScale + ? scalesEqual(existing.scale, incoming.scale) + : existing.scale === incoming.scale) && existing.variable === incoming.variable && existing.negative === incoming.negative && existing.strict === incoming.strict && existing.currentVar === incoming.currentVar && - existing.transform === incoming.transform + areTransformsEqual(existing.transform, incoming.transform) + ); +} + +function orderedMembersEqual( + existing: readonly string[], + incoming: readonly string[] +): boolean { + return ( + existing.length === incoming.length && + existing.every((member, index) => member === incoming[index]) + ); +} + +/** + * Divergent-prop error naming both definitions AND both origins — used by the + * `extend()` merge (sibling/dual-version conflicts, design D3/G4) and by + * `addGroup`/`addProps` when the colliding entry arrived through `extend()` + * (origin labels "extended source #n" / "builder state"). When no extension + * provenance exists, the pre-existing origin-less messages are kept verbatim. + */ +function divergentPropError( + key: string, + existing: Prop, + incoming: Prop, + existingOrigin: string, + incomingOrigin: string +): Error { + return new Error( + `Prop "${key}" already registered with a different definition. ` + + `Existing (${existingOrigin}): property="${existing.property}", scale="${String(existing.scale)}". ` + + `Incoming (${incomingOrigin}): property="${incoming.property}", scale="${String(incoming.scale)}".` ); } @@ -123,30 +305,364 @@ export class SystemBuilder< GroupReg extends Record = {}, Conds extends string = never, Sels extends string = never, + Stage extends SystemBuilderStage = 'inherit', > { + // Structural anchor for the phantom Stage parameter — without a member + // referencing it, 'inherit' and 'extend' builders would be mutually + // assignable and the `this`-typed `from()` gate would never fire. + declare readonly [STAGE_BRAND]?: Stage; + #propRegistry: PropReg; #groupRegistry: GroupReg; #selectorRegistry: SelectorAliasMap; #includesRegistry: readonly IncludableSystem[]; #conditionRegistry: ConditionAliasMap; + // Per-name extension provenance (design D3): registry-prefixed name + // (`prop:gap`, `group:space`, `selector:_hover`, `condition:_cardSm`) → + // 1-based index of the `extend()` call that introduced it. Sibling and + // dual-version conflicts name both origins from this map; entries the + // builder registered itself have no key ("builder state"). + #extendProvenance: ReadonlyMap; + // Number of `extend()` calls made so far — the label index for the next + // extended source. Distinct from the provenance map's max value: an extend + // whose entries all coalesce still consumes an index. + #extendCount: number; constructor( propRegistry?: PropReg, groupRegistry?: GroupReg, selectorRegistry?: SelectorAliasMap, includesRegistry?: readonly IncludableSystem[], - conditionRegistry?: ConditionAliasMap + conditionRegistry?: ConditionAliasMap, + extendProvenance?: ReadonlyMap, + extendCount?: number ) { this.#propRegistry = propRegistry || ({} as PropReg); this.#groupRegistry = groupRegistry || ({} as GroupReg); this.#selectorRegistry = selectorRegistry || { ...BUILT_IN_SELECTORS }; this.#includesRegistry = includesRegistry || []; this.#conditionRegistry = conditionRegistry || { ...BUILT_IN_CONDITIONS }; + this.#extendProvenance = extendProvenance || new Map(); + this.#extendCount = extendCount || 0; + } + + // Origin label for divergence errors: where did the existing entry for + // `provenanceKey` come from? + #originOf(provenanceKey: string): string { + const index = this.#extendProvenance.get(provenanceKey); + return index === undefined ? 'builder state' : `extended source #${index}`; + } + + /** + * Declare inheritance from a consumed library: the source's TYPE surface is + * admitted (prop/component types for compose/extend interop) and the source + * joins extraction discovery membership. NO registry merge — consumer + * configuration remains the singular authority, so props, groups, + * selectors, and conditions the source registered do not enter this + * builder's runtime registries. Chainable and repeatable, but only before + * extension calls ("inherit first, then extend" — enforced by the phantom + * builder stage). Accepts a built system instance or a library bundle + * (`{ system, tokens }`), taking the system half and ignoring the rest. + * + * @deprecated Use `extend(source)` — the single extension verb on both + * builders, whose type admission is backed by a real registry merge. + * `from()` keeps these frozen semantics (type admission + discovery + * membership, no merge) for at least one minor release. + */ + from< + SrcProps extends Record, + SrcGroups extends Record, + SrcConds extends string = never, + SrcSels extends string = never, + >( + this: SystemBuilder, + source: + | SystemInstance + | { + system: SystemInstance; + theme?: unknown; + tokens?: unknown; + } + ): SystemBuilder< + PropReg & SrcProps, + GroupReg & SrcGroups, + Conds | SrcConds, + Sels | SrcSels, + 'inherit' + >; + /** + * A value annotated as the exported {@link LibraryBundle} interface has + * already erased its system half's generics (`system: IncludableSystem`), + * so there is no type surface to admit — discovery and runtime semantics + * are identical, and the builder's own type state passes through unchanged. + * + * @deprecated Use `extend(source)` — the single extension verb on both + * builders, whose type admission is backed by a real registry merge. + * `from()` keeps these frozen semantics (type admission + discovery + * membership, no merge) for at least one minor release. + */ + from( + this: SystemBuilder, + source: LibraryBundle + ): SystemBuilder; + from( + this: SystemBuilder, + source: IncludableSystem | { system?: unknown; tokens?: unknown } + ): SystemBuilder { + const instance = isLibraryBundle(source) + ? source.system + : (source as IncludableSystem); + return new SystemBuilder( + this.#propRegistry, + this.#groupRegistry, + this.#selectorRegistry, + [...this.#includesRegistry, instance], + this.#conditionRegistry, + this.#extendProvenance, + this.#extendCount + ); + } + + /** + * Extend this system from a consumed library: the source's prop, group, + * selector, and condition registries MERGE into the builder (design D1), so + * the built system's type surface, `toConfig()` output, and extraction + * reachability describe the same configuration. Identical definitions + * coalesce; divergent definitions fail loud naming the entry and both + * origins (design D3), including a post-extend attempt to redefine an + * inherited prop. Local calls may add new entries and may replace inherited + * group membership, selectors, or conditions; prop definitions never rebind + * silently. Chainable and repeatable, but only before extension calls ("inherit first, then + * extend" — enforced by the phantom builder stage). Accepts a built system + * instance or a library bundle (`{ system, theme }`), taking the system + * half and ignoring the rest. The merge consumes the source's registry + * snapshot captured at its `build()` (design D7), never a serialized + * round-trip. + */ + extend< + SrcProps extends Record, + SrcGroups extends Record, + SrcConds extends string = never, + SrcSels extends string = never, + >( + this: SystemBuilder, + source: + | SystemInstance + | { + system: SystemInstance; + theme?: unknown; + tokens?: unknown; + } + ): SystemBuilder< + PropReg & SrcProps, + GroupReg & SrcGroups, + Conds | SrcConds, + Sels | SrcSels, + 'inherit' + >; + /** + * A value annotated as the exported {@link LibraryBundle} interface has + * already erased its system half's generics (`system: IncludableSystem`), + * so no source types are admitted — the runtime merge is identical, and + * the builder's own type state passes through unchanged. + */ + extend( + this: SystemBuilder, + source: LibraryBundle + ): SystemBuilder; + extend( + this: SystemBuilder, + source: IncludableSystem | { system?: unknown; theme?: unknown } + ): SystemBuilder { + const instance = isLibraryBundle(source) + ? source.system + : (source as IncludableSystem); + const snapshot = instance.getRegistrySnapshot?.(); + if (!snapshot) { + throw new Error( + 'extend: source system carries no registry snapshot — it was built ' + + 'by an older @animus-ui/system. Rebuild the source against this ' + + 'version (a lossy toConfig() reconstruction is never substituted).' + ); + } + + const sourceIndex = this.#extendCount + 1; + const incomingOrigin = `extended source #${sourceIndex}`; + const provenance = new Map(this.#extendProvenance); + + // ── Props: absent → add; equal → coalesce; divergent → loud, both + // origins named (design D3; sibling/dual-version conflicts are G4). + const nextProps: Record = { ...this.#propRegistry }; + for (const [name, incoming] of Object.entries(snapshot.props)) { + if (name in this.#groupRegistry) { + throw new Error( + `extend: prop "${name}" (${incomingOrigin}) collides with an ` + + `existing group name (${this.#originOf(`group:${name}`)}). ` + + `Group names and prop names must be disjoint.` + ); + } + const existing = nextProps[name]; + if (!existing) { + nextProps[name] = incoming; + provenance.set(`prop:${name}`, sourceIndex); + } else if (!arePropDefinitionsEqual(existing, incoming, true)) { + throw divergentPropError( + name, + existing, + incoming, + this.#originOf(`prop:${name}`), + incomingOrigin + ); + } + // Equal → coalesce: keep the existing entry and its first provenance. + } + + // ── Groups: ordered-membership equality → coalesce; divergent → loud; + // group-name-vs-prop-name cross-collision mirrors addGroup. + const nextGroups: Record = { + ...(this.#groupRegistry as Record), + }; + for (const [name, incoming] of Object.entries(snapshot.groups)) { + const existing = nextGroups[name]; + if (!existing) { + if (name in nextProps) { + throw new Error( + `extend: group name "${name}" (${incomingOrigin}) collides with ` + + `an existing prop name (${this.#originOf(`prop:${name}`)}). ` + + `Group names and prop names must be disjoint.` + ); + } + nextGroups[name] = [...incoming]; + provenance.set(`group:${name}`, sourceIndex); + } else if (!orderedMembersEqual(existing, incoming)) { + throw new Error( + `extend: group "${name}" already registered with different ` + + `membership. ` + + `Existing (${this.#originOf(`group:${name}`)}): [${existing.join(', ')}]. ` + + `Incoming (${incomingOrigin}): [${incoming.join(', ')}].` + ); + } + } + + // ── Selectors: entries identical to the built-in default are inert + // (every source carries the seeded built-ins — they must coalesce + // silently). A deliberate registration coalesces on string equality + // keeping the existing order, overrides a pristine built-in (source + // seeds the base, design D2), and conflicts loud with a deliberate + // registration from another extended source. + const selectorOverrides: SelectorAliasMap = {}; + const newSelectors: Record = {}; + for (const [name, incoming] of Object.entries(snapshot.selectors)) { + const builtIn = BUILT_IN_SELECTORS[name]; + if (builtIn && builtIn.selector === incoming.selector) { + continue; + } + if (name in this.#conditionRegistry) { + throw new Error( + `extend: selector alias "${name}" (${incomingOrigin}) is already ` + + `registered as a condition alias ` + + `(${this.#originOf(`condition:${name}`)}); a name resolves ` + + `through exactly one registry. Pick a distinct name.` + ); + } + const existing = this.#selectorRegistry[name]; + if (!existing) { + newSelectors[name] = incoming.selector; + provenance.set(`selector:${name}`, sourceIndex); + } else if (existing.selector !== incoming.selector) { + const existingIndex = provenance.get(`selector:${name}`); + if (existingIndex === undefined) { + // Pristine built-in: the source's deliberate override wins, + // preserving the built-in order (mirrors mergeSelectors). + selectorOverrides[name] = { + selector: incoming.selector, + order: existing.order, + }; + provenance.set(`selector:${name}`, sourceIndex); + } else { + throw new Error( + `extend: selector alias "${name}" already registered with a ` + + `different selector. ` + + `Existing (extended source #${existingIndex}): "${existing.selector}". ` + + `Incoming (${incomingOrigin}): "${incoming.selector}".` + ); + } + } + } + const nextSelectors = mergeSelectors( + { ...this.#selectorRegistry, ...selectorOverrides }, + newSelectors + ); + + // ── Conditions: same policy keyed on `value` (kind derives from it, + // `order` is a per-registry accident — existing order wins on coalesce); + // new entries number through mergeConditions. + const conditionOverrides: ConditionAliasMap = {}; + const newConditions: Record = {}; + for (const [name, incoming] of Object.entries(snapshot.conditions)) { + const builtIn = BUILT_IN_CONDITIONS[name]; + if (builtIn && builtIn.value === incoming.value) { + continue; + } + if (name in nextSelectors) { + throw new Error( + `extend: condition alias "${name}" (${incomingOrigin}) is already ` + + `registered as a selector alias ` + + `(${this.#originOf(`selector:${name}`)}); a name resolves ` + + `through exactly one registry. Pick a distinct name.` + ); + } + const existing = this.#conditionRegistry[name]; + if (!existing) { + newConditions[name] = incoming.value; + provenance.set(`condition:${name}`, sourceIndex); + } else if (existing.value !== incoming.value) { + const existingIndex = provenance.get(`condition:${name}`); + if (existingIndex === undefined) { + conditionOverrides[name] = { + value: incoming.value, + order: existing.order, + kind: incoming.kind, + }; + provenance.set(`condition:${name}`, sourceIndex); + } else { + throw new Error( + `extend: condition alias "${name}" already registered with a ` + + `different condition. ` + + `Existing (extended source #${existingIndex}): "${existing.value}". ` + + `Incoming (${incomingOrigin}): "${incoming.value}".` + ); + } + } + } + const nextConditions = mergeConditions( + { ...this.#conditionRegistry, ...conditionOverrides }, + newConditions, + new Set(Object.keys(nextSelectors)) + ); + + return new SystemBuilder( + nextProps as PropReg, + nextGroups as GroupReg, + nextSelectors, + // Runtime parity with from(): the source instance stays discovery- and + // includes-visible (the tracer's extend() form lands in increment 06). + [...this.#includesRegistry, instance], + nextConditions, + provenance, + sourceIndex + ); } addSelectors>( selectors: S - ): SystemBuilder> { + ): SystemBuilder< + PropReg, + GroupReg, + Conds, + Sels | Extract, + 'extend' + > { // Cross-registry clash guard, REVERSE direction (inc-11 full-pass F-1.4): // a name already registered as a CONDITION alias must not be re-registered // as a selector — Rust dispatch prefers selector aliases, so the condition @@ -160,21 +676,24 @@ export class SystemBuilder< } } const merged = mergeSelectors(this.#selectorRegistry, selectors); - // Conds/Sels are phantom type-state (no runtime constructor slot); the - // constructor infers them as `never`, so the accumulated union is applied - // by this cast. - return new SystemBuilder( + // Conds/Sels/Stage are phantom type-state (no runtime constructor slot); + // the accumulated union and the 'extend' stage advance are applied via + // explicit constructor type arguments. + return new SystemBuilder< + PropReg, + GroupReg, + Conds, + Sels | Extract, + 'extend' + >( this.#propRegistry, this.#groupRegistry, merged, this.#includesRegistry, - this.#conditionRegistry - ) as SystemBuilder< - PropReg, - GroupReg, - Conds, - Sels | Extract - >; + this.#conditionRegistry, + this.#extendProvenance, + this.#extendCount + ); } /** @@ -194,24 +713,33 @@ export class SystemBuilder< >, >( conditions: C - ): SystemBuilder, Sels> { + ): SystemBuilder< + PropReg, + GroupReg, + Conds | Extract, + Sels, + 'extend' + > { const merged = mergeConditions( this.#conditionRegistry, conditions, new Set(Object.keys(this.#selectorRegistry)) ); - return new SystemBuilder( + return new SystemBuilder< + PropReg, + GroupReg, + Conds | Extract, + Sels, + 'extend' + >( this.#propRegistry, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, - merged - ) as SystemBuilder< - PropReg, - GroupReg, - Conds | Extract, - Sels - >; + merged, + this.#extendProvenance, + this.#extendCount + ); } addGroup>( @@ -221,7 +749,8 @@ export class SystemBuilder< PropReg & Conf, GroupReg & Record, Conds, - Sels + Sels, + 'extend' > { // Collision check: group name must not collide with any registered prop name if (name in this.#propRegistry) { @@ -236,7 +765,26 @@ export class SystemBuilder< if (key in this.#propRegistry) { const existing = (this.#propRegistry as Record)[key]; const incoming = config[key]; - if (!arePropDefinitionsEqual(existing, incoming)) { + // structuralScale only for entries that arrived through extend(): + // those carry a frozen COPY of their object/array scale (registry + // snapshot), so identity comparison would false-conflict a + // byte-identical re-registration. Direct builder-vs-builder overlap + // keeps identity semantics — in one file, sharing the reference is + // the correct authoring. + const viaExtend = this.#extendProvenance.has(`prop:${key}`); + if (!arePropDefinitionsEqual(existing, incoming, viaExtend)) { + // Divergence against an entry that arrived through extend() names + // both origins (design D3); builder-vs-builder keeps the + // pre-existing message. + if (this.#extendProvenance.has(`prop:${key}`)) { + throw divergentPropError( + key, + existing, + incoming, + this.#originOf(`prop:${key}`), + 'builder state' + ); + } throw new Error( `Prop "${key}" already registered with a different definition. ` + `Existing: property="${existing.property}", scale="${String(existing.scale)}". ` + @@ -252,24 +800,29 @@ export class SystemBuilder< } as Record; const nextGroups = { ...this.#groupRegistry, ...newGroup }; - return new SystemBuilder( + return new SystemBuilder< + PropReg & Conf, + GroupReg & Record, + Conds, + Sels, + 'extend' + >( nextProps, nextGroups, this.#selectorRegistry, this.#includesRegistry, - this.#conditionRegistry - ) as SystemBuilder< - PropReg & Conf, - GroupReg & Record, - Conds, - Sels - >; + this.#conditionRegistry, + this.#extendProvenance, + this.#extendCount + ); } addProps< Conf extends Record & Partial, never>>, - >(config: Conf): SystemBuilder { + >( + config: Conf + ): SystemBuilder { // Collision check: prop names must not collide with any registered group name for (const key of Object.keys(config)) { if (key in this.#groupRegistry) { @@ -285,7 +838,21 @@ export class SystemBuilder< if (key in this.#propRegistry) { const existing = (this.#propRegistry as Record)[key]; const incoming = (config as Record)[key]; - if (!arePropDefinitionsEqual(existing, incoming)) { + // structuralScale for extended entries — same rationale as addGroup. + const viaExtend = this.#extendProvenance.has(`prop:${key}`); + if (!arePropDefinitionsEqual(existing, incoming, viaExtend)) { + // Divergence against an entry that arrived through extend() names + // both origins (design D3); builder-vs-builder keeps the + // pre-existing message. + if (this.#extendProvenance.has(`prop:${key}`)) { + throw divergentPropError( + key, + existing, + incoming, + this.#originOf(`prop:${key}`), + 'builder state' + ); + } throw new Error( `Prop "${key}" already registered with a different definition.` ); @@ -294,13 +861,15 @@ export class SystemBuilder< } const nextProps = { ...this.#propRegistry, ...config }; - return new SystemBuilder( + return new SystemBuilder( nextProps, this.#groupRegistry, this.#selectorRegistry, this.#includesRegistry, - this.#conditionRegistry - ) as SystemBuilder; + this.#conditionRegistry, + this.#extendProvenance, + this.#extendCount + ); } build(): { @@ -308,27 +877,56 @@ export class SystemBuilder< createGlobalStyles: GlobalStylesFactory; createKeyframes: CreateKeyframesFactory; } { + // Copied containers AND entries (review probe P9, both depths): the + // instance's public mutable propRegistry/groupRegistry fields must not + // alias the builder's private state at any level, or mutating a built + // instance (a key, or a field inside an entry) would bake into a LATER + // build()'s snapshot on the same builder. The current build's snapshot + // deep-copies its own view separately below. const animus = new Animus( - this.#propRegistry, - this.#groupRegistry + Object.fromEntries( + Object.entries(this.#propRegistry).map(([key, entry]) => [ + key, + { ...entry }, + ]) + ) as PropReg, + Object.fromEntries( + Object.entries(this.#groupRegistry).map(([key, members]) => [ + key, + [...(members as readonly string[])], + ]) + ) as GroupReg ); - const propRegistry = this.#propRegistry; - const groupRegistry = this.#groupRegistry; - const selectorRegistry = this.#selectorRegistry; - const conditionRegistry = this.#conditionRegistry; + // Immutable registry snapshot (design D7): toConfig() and extend() both + // read from it, so post-build mutation of the public mutable + // propRegistry/groupRegistry fields affects neither. + const snapshot = createRegistrySnapshot( + this.#propRegistry, + this.#groupRegistry as Record, + this.#selectorRegistry, + this.#conditionRegistry + ); const system = Object.assign(animus, { toConfig: (): SerializedConfig => { return serializeInstance( - propRegistry, - groupRegistry, - selectorRegistry, - conditionRegistry + snapshot.props, + snapshot.groups, + snapshot.selectors, + snapshot.conditions ); }, }) as SystemInstance; + // Non-enumerable next to toConfig: additive on the built instance, so + // the QuickJS capture script's bundle discriminator (keyed on + // `system.toConfig` being callable) is untouched. + Object.defineProperty(system, 'getRegistrySnapshot', { + value: (): RegistrySnapshot => snapshot, + enumerable: false, + }); + const createGlobalStyles = (( styles: GlobalStyleMap, options?: { fontFaces?: readonly FontFace[] } @@ -354,6 +952,14 @@ export type SystemInstance< Sels extends string = never, > = Animus & { toConfig(): SerializedConfig; + /** + * Frozen registry state captured at `build()` (design D7) — what + * `extend()` merges from. Always present on instances built by this + * version; optional in the type so systems built by an older + * @animus-ui/system stay structurally acceptable to `from()` during the + * deprecation window. + */ + getRegistrySnapshot?(): RegistrySnapshot; } & RegistryBrand; export interface SerializedConfig { @@ -370,9 +976,62 @@ export interface SerializedConfig { conditionAliases: string; } +/** + * Freeze the builder's registries into the build-time snapshot (design D7): + * containers, per-entry objects, and the mutable values nested inside a prop + * (`properties` arrays, object/array scales) are copies, so neither the + * builder's onward chaining nor post-build mutation of the instance's public + * registry fields reaches serialized or merged output. Transform functions + * are cached immutable forwarding wrappers: behavior survives without keeping + * mutable serialization metadata live. + */ +function createRegistrySnapshot( + propRegistry: Record, + groupRegistry: Record, + selectorRegistry: SelectorAliasMap, + conditionRegistry: ConditionAliasMap +): RegistrySnapshot { + const props: Record = {}; + for (const [name, entry] of Object.entries(propRegistry)) { + const copy: Prop = { ...entry }; + if (copy.properties) { + copy.properties = Object.freeze([ + ...copy.properties, + ]) as unknown as Prop['properties']; + } + if (copy.scale && typeof copy.scale === 'object') { + copy.scale = Object.freeze( + Array.isArray(copy.scale) ? [...copy.scale] : { ...copy.scale } + ) as unknown as Prop['scale']; + } + if (copy.transform) { + copy.transform = snapshotTransform(copy.transform); + } + props[name] = Object.freeze(copy); + } + const groups: Record = {}; + for (const [name, members] of Object.entries(groupRegistry)) { + groups[name] = Object.freeze([...members]); + } + const selectors: SelectorAliasMap = {}; + for (const [name, entry] of Object.entries(selectorRegistry)) { + selectors[name] = Object.freeze({ ...entry }); + } + const conditions: ConditionAliasMap = {}; + for (const [name, entry] of Object.entries(conditionRegistry)) { + conditions[name] = Object.freeze({ ...entry }); + } + return Object.freeze({ + props: Object.freeze(props), + groups: Object.freeze(groups), + selectors: Object.freeze(selectors), + conditions: Object.freeze(conditions), + }); +} + function serializeInstance< PropReg extends Record, - GroupReg extends Record, + GroupReg extends Record, >( propRegistry: PropReg, groupRegistry: GroupReg, @@ -381,6 +1040,7 @@ function serializeInstance< ): SerializedConfig { const serialized: Record = {}; const transforms: Record = {}; + const transformOwners: Record = {}; for (const [propName, entry] of Object.entries(propRegistry)) { const s: SerializedPropEntry = { property: (entry as any).property }; @@ -404,8 +1064,18 @@ function serializeInstance< const fn = (entry as any).transform; const name = fn.transformName ?? fn.name; if (name) { + const existing = transforms[name]; + if (existing && !areTransformsEqual(existing, fn)) { + throw new Error( + `Transform name "${name}" is registered by both props ` + + `"${transformOwners[name]}" and "${propName}" with different ` + + `function instances. Share one cached transform instance or ` + + `give the transforms distinct names.` + ); + } s.transform = name; transforms[name] = fn; + transformOwners[name] = propName; } } diff --git a/packages/system/src/asset.ts b/packages/system/src/asset.ts new file mode 100644 index 00000000..0cc0e673 --- /dev/null +++ b/packages/system/src/asset.ts @@ -0,0 +1,26 @@ +declare const ASSET_REF_BRAND: unique symbol; + +/** + * A branded package-asset reference for `fontFaces[].src[].url` + * (global-styles-system). The VALUE is the deterministic placeholder + * `animus-asset:` — a plain string, so it survives the QuickJS + * sandbox's JSON serialization and the extraction emitter's byte-exact url + * pass-through untouched. The HOST plugin substitutes the placeholder with + * the bundler-resolved asset URL after extraction; the sandbox never + * resolves anything. + */ +export type AssetRef = string & { readonly [ASSET_REF_BRAND]: true }; + +/** The reserved scheme marking an unsubstituted asset reference. */ +export const ASSET_PLACEHOLDER_PREFIX = 'animus-asset:'; + +/** + * Reference a package-owned asset (e.g. a font file) by module specifier: + * `asset('@acme/tokens/fonts/inter.woff2')`. Resolution — aliases, exports + * maps, `base`, content hashing — belongs to the host bundler; this function + * only brands the specifier into its placeholder form. A literal URL string + * (`'/fonts/inter.woff2'`) remains the pass-through alternative. + */ +export function asset(specifier: string): AssetRef { + return (ASSET_PLACEHOLDER_PREFIX + specifier) as AssetRef; +} diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index c253f685..606cda3c 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -9,6 +9,8 @@ export { AnimusExtended, AnimusExtendedWithAll } from './AnimusExtended'; export { compose } from './compose'; // Keyframes primitive — types for annotating return values; factory is `createKeyframes` on build() return export type { KeyframeFrameMap, KeyframeRef, Keyframes } from './keyframes'; +// Package-asset references for font-face sources (global-styles-system) +export { asset, ASSET_PLACEHOLDER_PREFIX, type AssetRef } from './asset.js'; // Runtime shims (extracted component + class resolver + composed family factories) export { createComponent } from './runtime'; export { createClassResolver } from './runtime/createClassResolver'; @@ -20,7 +22,10 @@ export type { GlobalStyleBlock, GlobalStyleMap, GlobalStylesFactory, + LibraryBundle, + RegistrySnapshot, SerializedConfig, + SystemBuilderStage, SystemInstance, } from './SystemBuilder'; export { createSystem, SystemBuilder } from './SystemBuilder'; @@ -69,10 +74,16 @@ export type { SanitizeKey, } from './theme'; // Theme builder -export { createTheme, ThemeBuilder } from './theme'; +export { + createTheme, + type Flatten, + ThemeBuilder, + type ThemeBuilderStage, +} from './theme'; export { borderShorthand } from './transforms/border'; // Transforms export { + areTransformsEqual, createTransform, type NamedTransform, type TransformFn, @@ -134,5 +145,6 @@ export type { SystemPreferenceConfig, Theme, ThemeManifest, + ThemeStructuralKey, TokenScales, } from './types/theme'; diff --git a/packages/system/src/selectors.ts b/packages/system/src/selectors.ts index 24cdde9a..0013177d 100644 --- a/packages/system/src/selectors.ts +++ b/packages/system/src/selectors.ts @@ -107,14 +107,21 @@ export const BUILT_IN_SELECTORS: SelectorAliasMap = { /** * Merge user-provided selectors with built-in defaults. * User selectors override built-in aliases of the same name. - * New aliases get an order value based on their position (500+). + * New aliases allocate orders CONTINUING from the highest existing order + * (floored at 490, so the first user alias lands at 500) rather than + * restarting at 500 each call (mirrors `mergeConditions`) — aliases from + * successive `.addSelectors()`/`extend()` merges must not collide on + * order 500. */ export function mergeSelectors( base: SelectorAliasMap, custom: Record ): SelectorAliasMap { const merged = { ...base }; - let nextOrder = 500; + // Continue order allocation past every existing entry (floor 490 → first + // user alias is 500), instead of restarting at 500 per call. + let nextOrder = + Math.max(490, ...Object.values(merged).map((s) => s.order)) + 10; for (const [key, selector] of Object.entries(custom)) { if (key in merged) { diff --git a/packages/system/src/theme/createTheme.ts b/packages/system/src/theme/createTheme.ts index 624d63a5..63b6f125 100644 --- a/packages/system/src/theme/createTheme.ts +++ b/packages/system/src/theme/createTheme.ts @@ -1,3 +1,4 @@ +import { isLibraryBundle } from '../SystemBuilder'; import { BrowserColorSchemeConfig, ColorModeOptions, @@ -8,10 +9,12 @@ import { SystemPreferenceConfig, ThemeCssFragment, ThemeManifest, + ThemeStructuralKey, TokenDefinition, TokenReference, } from '../types/theme'; import { LiteralPaths } from './flattenScale'; +import { resolveReferences } from './resolveReferences'; import { dotToDash, flattenToDotPaths, @@ -119,15 +122,26 @@ function validateModeAliases( const RESERVED_MODE_NAME = 'system'; /** - * Theme keys owned by the color-mode options. A scale may not claim them — - * they are skipped by `flattenTheme` and read back as option objects in - * `build()`, so a same-named scale would emit zero tokens AND fabricate a - * manifest field from its values. - * - * NOTE: the pre-existing `mode` / `modes` / `breakpoints` structural keys have - * the same hole and are deliberately NOT covered here (out of scope). + * Theme keys owned by builder structure or the built-theme boundary. A scale + * may not claim them: structural keys are skipped by `flattenTheme`, while + * boundary keys are replaced by non-enumerable methods/metadata at build(). + * Keep this runtime set aligned with `ThemeStructuralKey`. */ -const RESERVED_THEME_KEYS = new Set(['systemPreference', 'browserColorScheme']); +const RESERVED_THEME_KEY_LIST = [ + 'breakpoints', + 'modes', + 'mode', + 'systemPreference', + 'browserColorScheme', + 'modeBases', + '__emitted', + 'manifest', + 'serialize', + 'varRef', +] as const satisfies readonly ThemeStructuralKey[]; +const RESERVED_THEME_KEYS: ReadonlySet = new Set( + RESERVED_THEME_KEY_LIST +); const COLOR_SCHEME_VALUES = new Set(['light', 'dark', 'normal']); @@ -271,6 +285,55 @@ function resolveColorModeOptions( return browserColorScheme; } +/** + * Validate the D6 `basedOn` mode-base map against the MERGED mode set: every + * key and every base must name a declared mode, self-bases are rejected, and + * base chains must terminate (a cycle can never fill coverage). Runs at both + * gates like the other mode options — `addColorModes` (fail fast) and + * `build()` (authoritative: extend/from composition merges modes without + * passing through `addColorModes`). + */ +function validateModeBases( + modeNames: string[], + modeBases: Record | undefined +): void { + if (!modeBases) return; + const declared = new Set(modeNames); + const available = `Available modes: ${modeNames.join(', ')}`; + for (const [modeName, base] of Object.entries(modeBases)) { + if (!declared.has(modeName)) { + throw new Error( + `addColorModes: basedOn names unknown mode '${modeName}'. ${available}` + ); + } + if (typeof base !== 'string' || !declared.has(base)) { + throw new Error( + `addColorModes: basedOn['${modeName}'] references unknown base mode '${String(base)}'. ${available}` + ); + } + if (base === modeName) { + throw new Error( + `addColorModes: basedOn['${modeName}'] cannot base a mode on itself.` + ); + } + } + for (const start of Object.keys(modeBases)) { + const seen = new Set(); + let cursor: string | undefined = start; + while (cursor !== undefined) { + if (seen.has(cursor)) { + throw new Error( + `addColorModes: basedOn chain cycles — ${[...seen, cursor] + .map((mode) => `'${mode}'`) + .join(' → ')}. Give one mode in the chain a covered literal base.` + ); + } + seen.add(cursor); + cursor = modeBases[cursor]; + } + } +} + /** Validate all color entries, throwing on invalid values. */ function validateColors(colors: Record): void { for (const [key, value] of Object.entries(colors)) { @@ -288,7 +351,7 @@ function validateColors(colors: Record): void { // Token ref validation types (ValidateScaleRef, ValidateScaleValues) removed // to prevent TS2589 depth explosion. Token refs are validated at runtime in -// resolveTokenRefs() during build(). Type-level validation can be restored +// resolveReferences() during build(). Type-level validation can be restored // when the type-state chain depth is optimized. // ─── Type Helpers ─────────────────────────────────────────── @@ -296,6 +359,9 @@ function validateColors(colors: Record): void { /** Flatten a type to prevent MergeTheme depth accumulation (TS2589). Exported for use in consumer themes. */ export type Flatten = { [K in keyof T]: T[K] }; +/** Right-biased object merge used where runtime composition is also right-biased. */ +type MergeRecord = Omit & Incoming; + /** * The union of contextual var NAMES declared across all scales in a * `declareContextualVars` config. Read only for the optional registration @@ -308,6 +374,8 @@ type ContextualVarNames = { : never; }[keyof Vars]; +type ThemeScaleKeys = Exclude; + /** The built theme: nested raw data + non-enumerable boundary methods */ type BuiltTheme = { [K in keyof T]: T[K]; @@ -337,6 +405,7 @@ type BuiltTheme = { */ interface CarriedManifestV2 { tokenDefinitions?: Record; + emittedScales?: string[]; modeAliasDefinitions?: ModeAliasDefinition; registrations?: Record; emitterVersion?: number; @@ -366,6 +435,34 @@ interface BuilderState { * `createThemeVariants` rejection keys off exactly that absence. */ hasLegacyManifestSource: boolean; + /** + * Per-leaf-path provenance of `extend()` sources: flattened dot-path → + * 1-based index of the extend call that first defined it (D3/G4 — sibling + * conflicts error naming both sources positionally; positional labels are + * the accepted form until DEF-4's provenance artifact). + */ + extendProvenance: Map; + /** Number of `extend()` calls made so far — the next source's label index. */ + extendCount: number; + /** + * Mode names carried in by `extend()` sources (D6). Inherited modes are + * EXEMPT from the coverage gate — a kit's own alias asymmetry is + * pre-existing behavior, not consumer breakage, and must round-trip. + */ + inheritedModes: Set; + /** + * Alias dot-paths declared by any extended source's modes (D6). A + * consumer-declared mode leaving any of these uncovered needs a `basedOn` + * entry or the build fails listing the uncovered set. + */ + inheritedModeAliases: Set; + /** + * Token paths dropped by an explicit `addScale({ replace: true })` → + * replaced scale name (D5). Consulted at `build()`: a reference whose + * target is absent from the merged map AND present here is a hard error; + * a later re-add simply makes the target known again. + */ + droppedTokenPaths: Map; } function createState(theme?: Record): BuilderState { @@ -375,6 +472,11 @@ function createState(theme?: Record): BuilderState { contextualVars: new Map(), contextualVarRegistrations: new Map(), hasLegacyManifestSource: false, + extendProvenance: new Map(), + extendCount: 0, + inheritedModes: new Set(), + inheritedModeAliases: new Set(), + droppedTokenPaths: new Map(), }; } @@ -393,6 +495,11 @@ function copyState( ? { carriedManifestV2: { ...state.carriedManifestV2 } } : {}), hasLegacyManifestSource: state.hasLegacyManifestSource, + extendProvenance: new Map(state.extendProvenance), + extendCount: state.extendCount, + inheritedModes: new Set(state.inheritedModes), + inheritedModeAliases: new Set(state.inheritedModeAliases), + droppedTokenPaths: new Map(state.droppedTokenPaths), }; for (const [scale, vars] of state.contextualVars) { next.contextualVars.set(scale, [...vars]); @@ -400,6 +507,237 @@ function copyState( return next; } +/** + * Exact leaf-path flatten for extend() provenance: like `flattenToDotPaths` + * but WITHOUT the `_` identity-key collapse, so a leaf and a branch can + * never share a path spelling. A prefix relation between two tracked paths + * is then a GENUINE structural divergence (one sibling authored a leaf + * value where the other authored a nested branch) — the review-F1 case that + * per-leaf value comparison alone cannot see. + */ +function flattenLeafPathsExact( + object: Record, + path?: string +): Record { + const result: Record = {}; + for (const key of Object.keys(object)) { + const nextKey = path ? `${path}.${key}` : key; + const current = object[key]; + if (isObject(current)) { + Object.assign( + result, + flattenLeafPathsExact(current as Record, nextKey) + ); + } else { + result[nextKey] = current; + } + } + return result; +} + +/** + * Deep copy of plain theme data (records, arrays, primitives). `merge` + * adopts and MUTATES nested source objects in place, so EVERY builder step + * copies before folding: a consumed kit's built theme must never be + * corrupted by composition, and prior builder state must never be shared by + * reference — a one-level copy lets branching a builder cross-contaminate + * both branches and lets build() outputs mutate after the fact. + */ +function deepCopyPlain(value: Value): Value { + if (Array.isArray(value)) { + return value.map(deepCopyPlain) as unknown as Value; + } + if (isObject(value)) { + const record = value as Record; + const copy: Record = {}; + for (const key of Object.keys(record)) { + copy[key] = deepCopyPlain(record[key]); + } + return copy as unknown as Value; + } + return value; +} + +/** Structural equality over plain theme data (records, arrays, primitives). */ +function plainDataEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return ( + a.length === b.length && + a.every((item, index) => plainDataEqual(item, b[index])) + ); + } + if (isObject(a) && isObject(b)) { + const aRecord = a as Record; + const bRecord = b as Record; + const aKeys = Object.keys(aRecord); + return ( + aKeys.length === Object.keys(bRecord).length && + aKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(bRecord, key) && + plainDataEqual(aRecord[key], bRecord[key]) + ) + ); + } + return false; +} + +/** + * What `from()` actually inherits from its argument: a library bundle + * contributes its theme half — `theme ?? tokens` (D9 naming; `tokens` + * accepted until DEF-8 resolves) — anything else is treated as a built + * theme and contributes itself. Mirrors the runtime bundle detection inside + * `from()`. + */ +type ThemeSourceOf = Source extends { + system: { toConfig(...args: never[]): unknown }; + theme?: infer ThemeHalf; + tokens?: infer TokensHalf; +} + ? ThemeHalf extends Record + ? ThemeHalf + : TokensHalf extends Record + ? TokensHalf + : Record + : Source; + +type ThemeBoundaryKey = '__emitted' | 'manifest' | 'serialize' | 'varRef'; + +/** Runtime composition copies enumerable data and deliberately skips methods. */ +type ThemeDataOf = { + [Key in keyof Source as Key extends ThemeBoundaryKey + ? never + : Extract unknown> extends never + ? Key + : never]: Source[Key]; +}; + +type MergeThemeData = Flatten< + MergeRecord> +>; + +/** Preserve the built source's exact emitted-scale phantom without exposing it as data. */ +type EmittedThemeScalesOf = Source extends { + readonly __emitted: [infer Emitted extends string]; +} + ? Emitted + : never; + +/** + * What `extend()` inherits from its argument: a library bundle contributes + * its THEME half — `theme ?? tokens` (D9 naming; `tokens` accepted until + * DEF-8 resolves) — anything else is treated as a built theme and + * contributes itself. Mirrors the runtime bundle-half resolution inside + * `extend()`. + */ +type ExtendedThemeSourceOf = Source extends { + system: { toConfig(...args: never[]): unknown }; + theme?: infer ThemeHalf; + tokens?: infer TokensHalf; +} + ? ThemeHalf extends Record + ? ThemeHalf + : TokensHalf extends Record + ? TokensHalf + : Record + : Source; + +declare const THEME_STAGE_BRAND: unique symbol; + +/** + * Builder type-state for the inherit-first rule (D2), mirroring the system + * builder's `SystemBuilderStage`: `extend()` is only callable while the + * builder is in the `'inherit'` stage; every augmentation method advances to + * `'extend'`, making "inherit first, then extend" a compile error rather + * than a lint. `from()` is deliberately NOT stage-gated — its call-anywhere + * semantics are frozen for the deprecation window, so it passes the stage + * through unchanged. Phantom — never present at runtime. + */ +export type ThemeBuilderStage = 'inherit' | 'extend'; + +/** + * Re-seed builder state from a source theme's manifest. This is `from()`'s + * manifest read, factored out verbatim so `extend()` inherits the SAME + * emitted-scale/contextual-var/manifest-v2 carry semantics: the manifest is + * non-enumerable, so the ordinary key-copy loop never sees it (D6/D8). + */ +function reseedStateFromManifest( + state: BuilderState, + manifest: ThemeManifest | undefined, + mergeExtensionState = false +): void { + if (manifest?.emittedScales) { + for (const scale of manifest.emittedScales) { + state.emittedScales.add(scale); + } + } else if (manifest?.variableMap) { + // A v2 manifest distinguishes authored token definitions from synthetic + // color-mode aliases. Treating an alias path as emission evidence would + // flip a non-emitted colors scale to emitted during a no-op extension. + const emittedPaths = + manifest.manifestVersion === 2 && manifest.tokenDefinitions + ? Object.keys(manifest.variableMap).filter( + (tokenPath) => manifest.tokenDefinitions?.[tokenPath] !== undefined + ) + : Object.keys(manifest.variableMap); + for (const tokenPath of emittedPaths) { + const scale = tokenPath.split('.')[0]; + state.emittedScales.add(scale === 'colors' ? 'colors' : scale); + } + } + if (manifest?.contextualVars) { + for (const [scale, vars] of Object.entries(manifest.contextualVars)) { + const existing = mergeExtensionState + ? state.contextualVars.get(scale) + : undefined; + state.contextualVars.set( + scale, + existing ? [...new Set([...existing, ...vars])] : [...vars] + ); + } + } + if (manifest) { + if (manifest.manifestVersion === 2) { + state.carriedManifestV2 = { + tokenDefinitions: manifest.tokenDefinitions, + emittedScales: manifest.emittedScales, + modeAliasDefinitions: manifest.modeAliasDefinitions, + registrations: manifest.registrations, + emitterVersion: manifest.emitterVersion, + contractHash: manifest.contractHash, + cssFragments: manifest.cssFragments, + }; + // Re-seed the registration metadata the CLOSED DROP note on `from()` + // records: carried registrations become live builder state again, so + // an unmutated rebuild re-emits identical @property rules. + if (manifest.registrations) { + for (const [name, registration] of Object.entries( + manifest.registrations + )) { + const existing = state.contextualVarRegistrations.get(name); + if ( + mergeExtensionState && + existing && + (existing.syntax !== registration.syntax || + existing.inherits !== registration.inherits || + existing.initialValue !== registration.initialValue) + ) { + throw new Error( + `extend: contextual variable '${name}' has divergent ` + + `@property registrations across extended themes` + ); + } + state.contextualVarRegistrations.set(name, registration); + } + } + } else { + // v1 manifest: authored structure unknowable — fail closed (D8). + state.hasLegacyManifestSource = true; + } + } +} + /** * ThemeScales — the final phase. Has addScale, extendScale, declareContextualVars, build. * Also allows addColors and addColorModes for augmentation. @@ -407,7 +745,14 @@ function copyState( export class ThemeBuilder< T extends Record = Record, Emitted extends string = never, + Stage extends ThemeBuilderStage = 'inherit', > { + // Structural anchor for the phantom Stage parameter — without a member + // referencing it, 'inherit' and 'extend' builders would be mutually + // assignable and the `this`-typed `extend()` gate would never fire (see + // the system builder's identical comment). + declare readonly [THEME_STAGE_BRAND]?: Stage; + /** @internal */ _state: BuilderState; constructor(state: BuilderState) { @@ -422,13 +767,15 @@ export class ThemeBuilder< ); } } - const nextTheme = merge({}, this._state.theme, { breakpoints }); + const nextTheme = merge(deepCopyPlain(this._state.theme), { breakpoints }); // Omit replaces the Record from EmptyTheme // with literal keys, preventing index signature from widening keyof breakpoints to string type Merged = Omit & Record<'breakpoints', { [K in keyof BP]: BP[K] }>; type Next = { [K in keyof Merged]: Merged[K] }; - return new ThemeBuilder(copyState(this._state, nextTheme)); + return new ThemeBuilder( + copyState(this._state, nextTheme) + ); } // CLOSED DROP (was DEF-6, openspec change modern-css-surface; closed by @@ -438,60 +785,212 @@ export class ThemeBuilder< // from them. Residual, by design (D8): a source carrying only a v1 // (names-only) manifest still composes without registration metadata — // v1 round-trips unchanged and gains no fabricated v2 fields. - from>(builtTheme: Source) { + // `Source extends object` (not `Record`): interface-typed + // values — e.g. a kit export annotated as the public `LibraryBundle` — have + // no implicit index signature and must still be accepted; the runtime only + // ever copies enumerable non-function keys. + // + /** + * FROZEN for the deprecation window (D1/G6): source-WINS precedence over + * prior builder state, callable at ANY stage — the phantom Stage passes + * through unchanged. Do not add a stage gate; do not flip the merge + * direction. Those semantics ship under the new verb only. + * + * @deprecated Use `extend(source)` — the single extension verb on both + * builders: the extended source seeds the base and later local calls win. + * `from()` keeps these frozen source-wins semantics for at least one minor + * release after `extend()` ships. + */ + from(builtTheme: Source) { + // Library-bundle acceptance: a `{ system, theme }` bundle groups one + // export for both builders — this builder consumes the theme half + // (`theme ?? tokens`, the same D9 resolution as `extend()`) exactly as + // if the built theme had been passed directly and ignores the rest. The + // shared guard keys on `system.toConfig` being callable (theme token + // values are strings/numbers/records, never objects carrying + // functions), so a theme that happens to define a scale named `system` + // cannot match. + const source: Record = isLibraryBundle(builtTheme) + ? (((builtTheme as { theme?: unknown }).theme ?? + (builtTheme as { tokens?: unknown }).tokens ?? + {}) as Record) + : (builtTheme as Record); + const raw: Record = {}; - for (const key of Object.keys(builtTheme)) { - const val = builtTheme[key]; + for (const key of Object.keys(source)) { + const val = source[key]; if (typeof val !== 'function') { raw[key] = val; } } - const nextTheme = merge({}, this._state.theme, raw); - const next = new ThemeBuilder( - copyState(this._state, nextTheme) + const nextTheme = merge( + deepCopyPlain(this._state.theme), + deepCopyPlain(raw) ); + const next = new ThemeBuilder< + MergeThemeData>, + Emitted | EmittedThemeScalesOf>, + Stage + >(copyState(this._state, nextTheme)); - const manifest = (builtTheme as Record).manifest as - | ThemeManifest - | undefined; - if (manifest?.variableMap) { - for (const tokenPath of Object.keys(manifest.variableMap)) { - const scale = tokenPath.split('.')[0]; - next._state.emittedScales.add(scale === 'colors' ? 'colors' : scale); + // Manifest v2 carry (D6/D8) — through THIS explicit read only: the + // manifest is non-enumerable, so the key-copy loop above never sees it. + reseedStateFromManifest( + next._state, + (source as { manifest?: ThemeManifest }).manifest + ); + return next; + } + + /** + * Extend this theme from a consumed library (D1/D2): the source's complete + * configuration — tokens, modes, preferences, registrations, emitted-scale + * set — seeds the builder as the BASE, and local calls made after + * `extend()` win on conflict (the mirror of `from()`'s source-wins). + * Chainable and repeatable, but only before augmentation methods ("inherit + * first, then extend" — enforced by the phantom builder stage). Accepts a + * built theme or a library bundle, consuming the bundle's theme half + * (`theme ?? tokens`, D9) and ignoring the rest. + * + * Sibling conflicts fail loud (D3/G4): a leaf path defined divergently by + * two extended sources throws naming the path and both sources + * positionally; equal values coalesce silently, and the consumer's own + * post-extend `add*` calls override silently (NS-4). + */ + extend( + this: ThemeBuilder, + source: Source + ): ThemeBuilder< + MergeThemeData>, + Emitted | EmittedThemeScalesOf>, + 'inherit' + > { + // Bundle-half resolution: the theme half under the D9 name, falling back + // to the pre-D9 `tokens` spelling; a built theme contributes itself. + const themeHalf: Record = isLibraryBundle(source) + ? (((source as { theme?: unknown }).theme ?? + (source as { tokens?: unknown }).tokens ?? + {}) as Record) + : (source as Record); + + const raw: Record = {}; + for (const key of Object.keys(themeHalf)) { + const val = themeHalf[key]; + if (typeof val !== 'function') { + raw[key] = val; } } - if (manifest?.contextualVars) { - for (const [scale, vars] of Object.entries(manifest.contextualVars)) { - next._state.contextualVars.set(scale, [...vars]); + + // ── Sibling-conflict detection (D3/G4) ──────────────────── + // Inherit-first guarantees the current state is exactly the fold of the + // prior extends (plus the empty seed), so a provenance hit means another + // extended source owns the leaf: equal → coalesce; divergent → loud. + // Paths use the EXACT flatten (no `_` collapse — see + // {@link flattenLeafPathsExact}) so branch-vs-leaf structural divergence + // between siblings (review F1: one kit authors `colors.primary` as a + // leaf, another as a nested object) is a prefix relation between + // tracked paths and errors loudly instead of letting `merge` pick an + // order-dependent winner. Consumer-authored branches after extends stay + // silent-override (NS-4) — only kit-vs-kit collisions error. + const sourceIndex = this._state.extendCount + 1; + const provenance = new Map(this._state.extendProvenance); + const existingLeaves = flattenLeafPathsExact(this._state.theme); + const incomingLeaves = flattenLeafPathsExact(raw); + // Strict-prefix index of already-tracked leaves: every strict dot-prefix + // of a tracked path → that leaf's source index. Built BEFORE this + // source's paths are admitted, so one source's own leaf set (which can + // never self-prefix) is exempt. + const trackedPrefixes = new Map(); + for (const [trackedPath, index] of provenance) { + for ( + let dot = trackedPath.lastIndexOf('.'); + dot !== -1; + dot = trackedPath.lastIndexOf('.', dot - 1) + ) { + const prefix = trackedPath.slice(0, dot); + if (!trackedPrefixes.has(prefix)) trackedPrefixes.set(prefix, index); } } - // Manifest v2 carry (D6/D8) — through THIS explicit read only: the - // manifest is non-enumerable, so the key-copy loop above never sees it. - if (manifest) { - if (manifest.manifestVersion === 2) { - next._state.carriedManifestV2 = { - tokenDefinitions: manifest.tokenDefinitions, - modeAliasDefinitions: manifest.modeAliasDefinitions, - registrations: manifest.registrations, - emitterVersion: manifest.emitterVersion, - contractHash: manifest.contractHash, - cssFragments: manifest.cssFragments, - }; - // Re-seed the registration metadata the CLOSED DROP note above - // records: carried registrations become live builder state again, so - // an unmutated rebuild re-emits identical @property rules. - if (manifest.registrations) { - for (const [name, registration] of Object.entries( - manifest.registrations - )) { - next._state.contextualVarRegistrations.set(name, registration); - } + for (const [path, value] of Object.entries(incomingLeaves)) { + const priorIndex = provenance.get(path); + if (priorIndex !== undefined) { + const existing = existingLeaves[path]; + // Structural, not reference, equality: flattenLeafPathsExact + // classifies ARRAYS as leaves, and builder state stores deep copies + // — under `!==` no array-valued token could ever coalesce, not even + // extend(kit).extend(kit) of one cached kit instance. + if (!plainDataEqual(existing, value)) { + throw new Error( + `extend: path '${path}' is defined divergently by extended theme #${priorIndex} (${JSON.stringify(existing)}) and extended theme #${sourceIndex} (${JSON.stringify(value)}). Sibling themes must agree — override intentionally with an add* call after extend().` + ); + } + continue; + } + // Incoming LEAF where a prior source authored a BRANCH beneath it. + const branchIndex = trackedPrefixes.get(path); + if (branchIndex !== undefined) { + throw new Error( + `extend: path '${path}' is defined divergently by extended theme #${branchIndex} (a nested branch) and extended theme #${sourceIndex} (a leaf value). Sibling themes must agree — override intentionally with an add* call after extend().` + ); + } + // Incoming BRANCH (this leaf sits beneath it) where a prior source + // authored a LEAF at one of its ancestors. + for ( + let dot = path.lastIndexOf('.'); + dot !== -1; + dot = path.lastIndexOf('.', dot - 1) + ) { + const ancestor = path.slice(0, dot); + const ancestorIndex = provenance.get(ancestor); + if (ancestorIndex !== undefined && ancestorIndex !== sourceIndex) { + throw new Error( + `extend: path '${ancestor}' is defined divergently by extended theme #${ancestorIndex} (a leaf value) and extended theme #${sourceIndex} (a nested branch). Sibling themes must agree — override intentionally with an add* call after extend().` + ); } - } else { - // v1 manifest: authored structure unknowable — fail closed (D8). - next._state.hasLegacyManifestSource = true; } + provenance.set(path, sourceIndex); } + + // Base-then-local-wins: the DEEP-COPIED source raw config is the merge + // target (so `merge` never mutates the consumed kit's built theme) and + // prior builder state folds over it — itself deep-copied, so adopted + // subtrees are never shared with the parent builder either. + const nextTheme = merge( + deepCopyPlain(raw), + deepCopyPlain(this._state.theme) + ); + const next = new ThemeBuilder< + MergeThemeData>, + Emitted | EmittedThemeScalesOf>, + 'inherit' + >(copyState(this._state, nextTheme)); + next._state.extendProvenance = provenance; + next._state.extendCount = sourceIndex; + + // D6 bookkeeping: inherited modes are exempt from the coverage gate; + // inherited alias paths are what a NEW consumer mode must cover (or + // declare a base for). + if (isObject(raw.modes)) { + for (const [modeName, modeAliases] of Object.entries( + raw.modes as Record + )) { + next._state.inheritedModes.add(modeName); + if (!isObject(modeAliases)) continue; + for (const aliasPath of Object.keys( + flattenToDotPaths(modeAliases as Record) + )) { + next._state.inheritedModeAliases.add(aliasPath); + } + } + } + + // Manifest re-seed — shared verbatim with `from()` (emitted scales, + // contextual vars, manifest-v2 carry, v1 fail-closed taint). + reseedStateFromManifest( + next._state, + (themeHalf as { manifest?: ThemeManifest }).manifest, + true + ); return next; } @@ -505,12 +1004,15 @@ export class ThemeBuilder< NextColors extends LiteralPaths = LiteralPaths, >(colors: Colors) { validateColors(colors as Record); - const nextTheme = merge({}, this._state.theme, { colors }); + const nextTheme = merge(deepCopyPlain(this._state.theme), { colors }); // NextColors is RESOLVED — a flat Record<'gray.50', '#fafafa'>. // The flatten pattern commits the intersection to a concrete shape. - type Merged = T & Record<'colors', NextColors>; - type Next = { [K in keyof Merged]: Merged[K] }; - const next = new ThemeBuilder( + type ExistingColors = T extends { colors: infer Existing } ? Existing : {}; + type Next = Flatten< + Omit & + Record<'colors', Flatten>> + >; + const next = new ThemeBuilder( copyState(this._state, nextTheme) ); next._state.emittedScales.add('colors'); @@ -570,14 +1072,22 @@ export class ThemeBuilder< options?.browserColorScheme ); resolveColorModeOptions(modeNames, systemPreference, browserColorScheme); + // D6 mode bases follow the same discipline: merged over carried state, + // validated fail-fast here and authoritatively at build(). + const modeBases = mergeOptionObject>( + this._state.theme.modeBases, + options?.basedOn + ); + validateModeBases(modeNames, modeBases); - const nextTheme = merge({}, this._state.theme, { + const nextTheme = merge(deepCopyPlain(this._state.theme), { modes: modeConfig, mode: initialMode, // Only stored when supplied — an unconfigured theme keeps exactly its // current enumerable key set (byte-parity precondition, G4). ...(systemPreference ? { systemPreference } : {}), ...(browserColorScheme ? { browserColorScheme } : {}), + ...(modeBases ? { modeBases } : {}), }); // Colors type = existing palette keys + mode alias keys (superset) @@ -586,7 +1096,9 @@ export class ThemeBuilder< AliasKeys; type Merged = Omit & Record<'colors', ColorsWithModes>; type Next = { [K in keyof Merged]: Merged[K] }; - return new ThemeBuilder(copyState(this._state, nextTheme)); + return new ThemeBuilder( + copyState(this._state, nextTheme) + ); } addScale< @@ -596,33 +1108,69 @@ export class ThemeBuilder< string | number | Record >, Emit extends boolean = false, + Replace extends boolean = false, // Generic default forces TS to resolve LiteralPaths ONCE and bind the result. NewScale extends LiteralPaths = LiteralPaths, - >(config: { name: Key; values: Values; emit?: Emit }) { - const { name, values, emit } = config; + >(config: { + name: Key & (Key extends ThemeStructuralKey ? never : unknown); + values: Values; + emit?: Emit; + replace?: Replace; + }) { + const { name, values, emit, replace } = config; if (RESERVED_THEME_KEYS.has(name)) { throw new Error( - `addScale: '${name}' is a reserved theme key owned by addColorModes options — it is skipped by the token flatten pass and read back as an option object, so a scale by this name would emit no tokens. Choose another scale name.` + `addScale: '${name}' is a reserved theme key owned by the builder or built-theme boundary, so a scale by this name cannot survive build(). Choose another scale name.` ); } - const nextTheme = merge({}, this._state.theme, { [name]: values }); + const prior = this._state.theme[name]; + let nextTheme: Record; + if (replace) { + // Explicit wholesale replacement (D5): the scale becomes EXACTLY the + // supplied values. Implicit deletion stays impossible — the default + // form below merges by key. + nextTheme = deepCopyPlain(this._state.theme); + nextTheme[name] = values; + } else { + nextTheme = merge(deepCopyPlain(this._state.theme), { + [name]: values, + }); + } // NewScale is RESOLVED — a flat Record. Downstream sees concrete keys. type NextEmitted = Emit extends true ? Emitted | Key : Emitted; - type Merged = T & Record; - type Next = { [K in keyof Merged]: Merged[K] }; - const next = new ThemeBuilder( + type ExistingScale = Key extends keyof T ? T[Key] : {}; + type NextScale = Replace extends true + ? NewScale + : Flatten>; + type Next = Flatten & Record>; + const next = new ThemeBuilder( copyState(this._state, nextTheme) ); if (emit) next._state.emittedScales.add(name); + if (replace && isObject(prior)) { + // Track keys the replacement DROPPED: a reference whose target is + // among them fails build() unconditionally (D5) — unless a later call + // re-adds the key, which makes the target known again. + const kept = new Set( + Object.keys(flattenToDotPaths(values as Record)) + ); + for (const dotKey of Object.keys( + flattenToDotPaths(prior as Record) + )) { + if (!kept.has(dotKey)) { + next._state.droppedTokenPaths.set(`${name}.${dotKey}`, name); + } + } + } return next; } declareContextualVars< const Vars extends Partial<{ - [K in keyof T & string]: readonly string[]; + [K in ThemeScaleKeys]: readonly string[]; }>, >( - vars: Vars, + vars: Vars & Record>, never>, // Optional `@property` registration metadata keyed by declared var name. // A SEPARATE parameter (not folded into `vars`) so the literal-key // narrowing of `Vars` above is byte-identical whether or not it is passed — @@ -648,7 +1196,7 @@ export class ThemeBuilder< : T[K]; }; - const next = new ThemeBuilder( + const next = new ThemeBuilder( copyState(this._state, this._state.theme) ); for (const [scale, names] of Object.entries(vars)) { @@ -672,16 +1220,17 @@ export class ThemeBuilder< } extendScale< - Key extends keyof T, + Key extends Exclude, Fn extends (tokens: T[Key]) => Record, >(key: Key, updateFn: Fn) { - const nextTheme = merge({}, this._state.theme, { + const nextTheme = merge(deepCopyPlain(this._state.theme), { [key]: updateFn(this._state.theme[key as string] as T[Key]), }); - // Flatten the intersection to prevent depth accumulation - type Extended = T & Record>; - type Next = { [K in keyof Extended]: Extended[K] }; - return new ThemeBuilder(copyState(this._state, nextTheme)); + type NextScale = Flatten>>; + type Next = Flatten & Record>; + return new ThemeBuilder( + copyState(this._state, nextTheme) + ); } /** @@ -689,7 +1238,9 @@ export class ThemeBuilder< * Flattens nested data at the boundary — produces manifest and serialize(). */ build(): BuiltTheme { - const theme = merge({}, this._state.theme) as Record; + // A full snapshot, not a one-level copy: the built theme must never + // change when the builder (or a branch of it) keeps being augmented. + const theme = deepCopyPlain(this._state.theme) as Record; const emittedScales = this._state.emittedScales; const contextualVars = this._state.contextualVars; @@ -714,28 +1265,102 @@ export class ThemeBuilder< ? (theme.browserColorScheme as unknown as BrowserColorSchemeConfig) : undefined ); + const modeBases = isObject(theme.modeBases) + ? (theme.modeBases as unknown as Record) + : undefined; + validateModeBases(mergedModeNames, modeBases); + + // ── Build-time mode-alias re-validation (D6) ─────────── + // `addColorModes` validates eagerly against the colors present at call + // time; extend()/from() composition merges modes and colors without + // passing through it, and an explicit colors replacement can drop alias + // targets — only the merged map here is final, so re-validate every + // mode against it. + if (isObject(theme.modes) && isObject(theme.colors)) { + const nestedColors = theme.colors as Record; + const flatColorKeys = Object.keys(flattenToDotPaths(nestedColors)); + for (const [modeName, modeAliases] of Object.entries( + theme.modes as Record + )) { + if (!isObject(modeAliases)) continue; + validateModeAliases( + modeName, + modeAliases as Record, + nestedColors, + flatColorKeys, + '' + ); + } + } + + // ── D6 coverage: authored aliases + base-chain fills ─── + const modeAliasDefinitions = collectAuthoredModeAliases(theme); + const { effectiveModes, coverageFills } = resolveModeCoverage( + modeAliasDefinitions, + modeBases, + this._state.inheritedModes, + this._state.inheritedModeAliases + ); + for (const fill of coverageFills) { + // ONE aggregated diagnostic per mode (D6) — never per-token spam. + // oxlint-disable-next-line no-console -- intentional runtime diagnostic + console.info( + `[animus] Mode '${fill.mode}': ${fill.count} alias(es) inherit from '${fill.base}'` + ); + } // ── Build-time flatten pass ──────────────────────────── const { - tokenMap, + tokenMap: flatTokenMap, variableMap, - variables, - modeVariables, - modeTokens, + variables: flatVariables, tokenDefinitions, - modeAliasDefinitions, - } = flattenTheme(theme, emittedScales); + } = flattenTheme(theme, emittedScales, effectiveModes); + + // ── D5: replacement-dropped reference targets fail loud ─ + assertNoDroppedReferences( + tokenDefinitions, + flatTokenMap, + this._state.droppedTokenPaths, + this._state.extendProvenance + ); - // Resolve token refs in the flattened token map - resolveTokenRefs(tokenMap, variableMap, emittedScales); + // Late-binding reference resolution over the COMPLETE flattened maps + // (D4, first-class-extension): deterministic DAG traversal replaces the + // old single-pass rewrite — references inside emitted scales resolve + // into the variable declarations instead of leaking into CSS, and both + // returned maps are in sorted token-path order so declaration order is + // never observable in the serialized wire. + const { tokenMap, variables } = resolveReferences( + flatTokenMap, + variableMap, + flatVariables + ); + + // ── Mode value maps THROUGH the resolver (G2 closure) ── + // Mode-override declarations previously carried RAW flattened color + // values verbatim, so a reference-valued color leaked a literal `{…}` + // into every [data-color-mode] block. Values now come from the resolved + // maps; modes and lines are sorted so declaration order is never + // observable (G3). + const { modeVariables, modeTokens } = resolveModeValueMaps( + effectiveModes, + variableMap, + variables, + tokenMap + ); - // Serialize breakpoints + // Serialize breakpoints — sorted by property name so reversed + // declarations emit byte-identically (G3). const bpVariables: Record = {}; if (theme.breakpoints && isObject(theme.breakpoints)) { - for (const [key, value] of Object.entries( + const breakpointEntries = Object.entries( theme.breakpoints as Record - )) { - bpVariables[`--breakpoint-${key}`] = `${value}px`; + ) + .map(([key, value]) => [`--breakpoint-${key}`, `${value}px`] as const) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + for (const [varName, value] of breakpointEntries) { + bpVariables[varName] = value; } } @@ -758,10 +1383,24 @@ export class ThemeBuilder< contextualVars, this._state.contextualVarRegistrations ); + // ── G2: emitted CSS never carries an unresolved `{…}` ── + // A declaration whose resolved value still contains a reference (target + // never defined anywhere — the supported warn-and-literal kit pattern) + // is OMITTED from the emitted CSS with ONE aggregated warning: a literal + // `{…}` in shipped CSS is worse than an absent declaration. The token + // map keeps the literal (the manifest surface is unchanged). + const { emittableVariables, emittableModeVariables, omitted } = + omitUnresolvedDeclarations(variables, modeVariables); + if (omitted.length > 0) { + // oxlint-disable-next-line no-console -- intentional runtime diagnostic + console.warn( + `[animus] Omitted ${omitted.length} CSS declaration(s) whose token references never resolved: ${omitted.join(', ')}` + ); + } const baseVariableCss = buildVariableCss( - variables, + emittableVariables, bpVariables, - modeVariables, + emittableModeVariables, { initialMode: typeof theme.mode === 'string' ? theme.mode : undefined, systemPreference, @@ -804,33 +1443,44 @@ export class ThemeBuilder< manifestV2Fields = { manifestVersion: 2, tokenDefinitions, + emittedScales: [...emittedScales].sort(), modeAliasDefinitions, registrations, emitterVersion: EMITTER_VERSION, contractHash: computeContractHash({ tokenDefinitions, + emittedScales: [...emittedScales].sort(), modeAliasDefinitions, initialMode: typeof theme.mode === 'string' ? theme.mode : undefined, registrations, systemPreference, browserColorScheme, + // D6: mode bases change emitted coverage — part of the authored + // contract. `undefined` is dropped by JSON.stringify, so themes + // without bases keep their pre-increment hashes. + modeBases, }), cssFragments, }; } + // Sorted-key wire maps (G3): `variableMapJson` and the breakpoint tail + // of `scalesJson` must be byte-identical under reversed declarations. + const sortedVariableMap = sortRecordByKey(variableMap); const manifest: ThemeManifest = { tokenMap: { ...tokenMap, // Include breakpoints in tokenMap for Rust crate compatibility - ...Object.fromEntries( - Object.entries(theme.breakpoints || {}).map(([k, v]) => [ - `breakpoints.${k}`, - String(v), - ]) + ...sortRecordByKey( + Object.fromEntries( + Object.entries(theme.breakpoints || {}).map(([k, v]) => [ + `breakpoints.${k}`, + String(v), + ]) + ) ), }, - variableMap, + variableMap: sortedVariableMap, modes: modeTokens, variableCss, ...(contextualVarsSerialized @@ -901,30 +1551,28 @@ const TOKEN_REF_RE = /\{([^}]+)\}/g; /** * Flatten the nested theme into dot-path keyed token map and CSS variable declarations. - * This is the ONLY place where flattening happens. + * This is the ONLY place where flattening happens. Mode VALUE maps are no + * longer computed here — they resolve AFTER `resolveReferences` (see + * {@link resolveModeValueMaps}), closing the G2 gap where mode-override + * declarations bypassed the resolver. */ function flattenTheme( theme: Record, - emittedScales: Set + emittedScales: Set, + effectiveModes: ModeAliasDefinition ): { tokenMap: Record; variableMap: Record; variables: Record; - modeVariables: Record>; - modeTokens: Record>; tokenDefinitions: Record; - modeAliasDefinitions: ModeAliasDefinition; } { const tokenMap: Record = {}; const variableMap: Record = {}; const variables: Record = {}; - const modeVariables: Record> = {}; - const modeTokens: Record> = {}; // Manifest v2 (D6): the authored graph, captured HERE — before - // resolveTokenRefs mutates tokenMap and before the mode-alias pass discards - // its colorRef strings. Inference from resolved CSS is unsound (D8). + // resolveReferences rewrites the values. Inference from resolved CSS is + // unsound (D8). const tokenDefinitions: Record = {}; - const modeAliasDefinitions: ModeAliasDefinition = {}; // Flatten scales and colors for (const [scaleName, scaleValue] of Object.entries(theme)) { @@ -936,7 +1584,8 @@ function flattenTheme( // Emission options are structural, not token scales — flattening them // would mint phantom `systemPreference.light` tokens. scaleName === 'systemPreference' || - scaleName === 'browserColorScheme' + scaleName === 'browserColorScheme' || + scaleName === 'modeBases' ) continue; if (typeof scaleValue === 'function') continue; @@ -963,93 +1612,344 @@ function flattenTheme( } } - // Flatten color modes - if ( - theme.modes && - isObject(theme.modes) && - theme.colors && - isObject(theme.colors) - ) { - const flatColors = flattenToDotPaths( - theme.colors as Record - ); + // Merge the initial mode's semantic aliases into the main variables and + // tokenMap. The EFFECTIVE alias set (authored + D6 base-chain fills) is + // used, so a partially covered initial mode still declares every alias. + const initialMode = theme.mode as string; + const initialAliases = + typeof initialMode === 'string' ? effectiveModes[initialMode] : undefined; + if (initialAliases) { + for (const [aliasDotKey, colorRef] of Object.entries(initialAliases)) { + const dashAlias = dotToDash(aliasDotKey); + const varName = `--color-${dashAlias}`; + // Semantic aliases point to the palette var, not the raw value + const paletteVarName = variableMap[`colors.${colorRef}`]; + if (paletteVarName) { + // A semantic alias may intentionally have the same path as its + // palette target. Never replace that declaration with a self-reference. + if (paletteVarName !== varName) { + variables[varName] = `var(${paletteVarName})`; + } + } else { + // Non-emitted palettes still need a concrete semantic declaration. + const literal = tokenMap[`colors.${colorRef}`]; + if (literal !== undefined) variables[varName] = literal; + } + // Add semantic aliases to tokenMap and variableMap + tokenMap[`colors.${aliasDotKey}`] = `var(${varName})`; + variableMap[`colors.${aliasDotKey}`] = varName; + } + } - for (const [modeName, modeAliases] of Object.entries( - theme.modes as Record + return { tokenMap, variableMap, variables, tokenDefinitions }; +} + +/** Rebuild a record with lexicographically sorted keys (wire determinism, G3). */ +function sortRecordByKey( + record: Record +): Record { + const sorted: Record = {}; + for (const key of Object.keys(record).sort()) { + sorted[key] = record[key]; + } + return sorted; +} + +/** + * Collect the AUTHORED mode alias graph: mode name → alias dot-path → the + * authored color dot-path (manifest v2, D6 — never a resolved value). + * Empty when the theme has no modes or no colors, mirroring the original + * flatten-pass guard. + */ +function collectAuthoredModeAliases( + theme: Record +): ModeAliasDefinition { + const authored: ModeAliasDefinition = {}; + if (!isObject(theme.modes) || !isObject(theme.colors)) return authored; + for (const [modeName, modeAliases] of Object.entries( + theme.modes as Record + )) { + if (!isObject(modeAliases)) continue; + const defs: Record = {}; + for (const [aliasDotKey, colorRef] of Object.entries( + flattenToDotPaths(modeAliases as Record) )) { - if (!isObject(modeAliases)) continue; - const flatAliases = flattenToDotPaths( - modeAliases as Record - ); - const modeVars: Record = {}; - const modeVals: Record = {}; - const modeAliasDefs: Record = {}; - - for (const [aliasDotKey, colorRef] of Object.entries(flatAliases)) { - if (typeof colorRef !== 'string') continue; - const dashAlias = dotToDash(aliasDotKey); - const varName = `--color-${dashAlias}`; - - // Manifest v2 (D6): record the AUTHORED colorRef dot-path — the - // resolution below is exactly where it used to be discarded. - modeAliasDefs[aliasDotKey] = colorRef; - - // Resolve color ref to raw value via dot-path - const rawValue = flatColors[colorRef as string]; - modeVals[`colors.${aliasDotKey}`] = - rawValue !== undefined ? String(rawValue) : String(colorRef); - modeVars[varName] = - rawValue !== undefined ? String(rawValue) : String(colorRef); + if (typeof colorRef !== 'string') continue; + defs[aliasDotKey] = colorRef; + } + authored[modeName] = defs; + } + return authored; +} + +interface ModeCoverageFill { + mode: string; + base: string; + count: number; +} + +/** + * D6 coverage over the merged mode set: a CONSUMER-declared mode leaving + * inherited aliases uncovered must name a base (`basedOn`) whose chain + * covers them — otherwise the build fails listing the uncovered set. + * Inherited modes are exempt (a kit's own asymmetry is pre-existing + * behavior and must round-trip byte-identically). Returns the effective + * alias map per mode (base-chain fills + authored, authored winning) and + * one aggregated fill report per mode for the build diagnostic. + */ +function resolveModeCoverage( + authoredModeAliases: ModeAliasDefinition, + modeBases: Record | undefined, + inheritedModes: Set, + inheritedModeAliases: Set +): { effectiveModes: ModeAliasDefinition; coverageFills: ModeCoverageFill[] } { + const effectiveModes: ModeAliasDefinition = {}; + const coverageFills: ModeCoverageFill[] = []; + for (const modeName of Object.keys(authoredModeAliases)) { + const authored = authoredModeAliases[modeName]; + const fills: Record = {}; + if (!inheritedModes.has(modeName)) { + const uncovered = [...inheritedModeAliases] + .filter((alias) => !(alias in authored)) + .sort(); + if (uncovered.length > 0) { + const base = modeBases?.[modeName]; + if (base === undefined) { + throw new Error( + `build: mode '${modeName}' leaves ${uncovered.length} inherited alias(es) uncovered and declares no base — uncovered: ${uncovered.join(', ')}. Add basedOn: { '${modeName}': '' } to addColorModes options or override every inherited alias.` + ); + } + const stillUncovered: string[] = []; + for (const alias of uncovered) { + let cursor: string | undefined = base; + const seen = new Set([modeName]); + let resolved: string | undefined; + while (cursor !== undefined && !seen.has(cursor)) { + seen.add(cursor); + resolved = authoredModeAliases[cursor]?.[alias]; + if (resolved !== undefined) break; + cursor = modeBases?.[cursor]; + } + if (resolved === undefined) { + stillUncovered.push(alias); + } else { + fills[alias] = resolved; + } + } + if (stillUncovered.length > 0) { + throw new Error( + `build: mode '${modeName}' resolves through base '${base}' but the chain never covers: ${stillUncovered.join(', ')}. Cover them in a chained mode or override them directly.` + ); + } + coverageFills.push({ + mode: modeName, + base, + count: Object.keys(fills).length, + }); } + } + effectiveModes[modeName] = { ...fills, ...authored }; + } + return { effectiveModes, coverageFills }; +} - modeVariables[modeName] = modeVars; - modeTokens[modeName] = modeVals; - modeAliasDefinitions[modeName] = modeAliasDefs; +/** + * Resolve the per-mode value maps AFTER reference resolution: every mode + * declaration carries the RESOLVED value of its target color (emitted → + * the resolved declaration value; inlined → the resolved literal), never + * the raw flattened string — the G2 closure for `[data-color-mode]` blocks. + * Modes iterate in sorted name order and lines in sorted property-name + * order, so mode declaration/insertion order is never observable (G3). + */ +function resolveModeValueMaps( + effectiveModes: ModeAliasDefinition, + variableMap: Record, + variables: Record, + tokenMap: Record +): { + modeVariables: Record>; + modeTokens: Record>; +} { + const modeVariables: Record> = {}; + const modeTokens: Record> = {}; + const resolvedColorValue = (colorRef: string): string => { + const path = `colors.${colorRef}`; + const varName = variableMap[path]; + if (varName !== undefined) { + const declared = variables[varName]; + if (declared !== undefined) return declared; + } else if (tokenMap[path] !== undefined) { + return tokenMap[path]; + } + // Unknown target: keep the authored ref string (legacy fallback; the + // build-time alias validation rejects this for object-mode themes). + return String(colorRef); + }; + for (const modeName of Object.keys(effectiveModes).sort()) { + const aliases = effectiveModes[modeName]; + const entries = Object.keys(aliases) + .map( + (aliasDotKey) => + [`--color-${dotToDash(aliasDotKey)}`, aliasDotKey] as const + ) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + const modeVars: Record = {}; + const modeVals: Record = {}; + for (const [varName, aliasDotKey] of entries) { + const value = resolvedColorValue(aliases[aliasDotKey]); + modeVars[varName] = value; + modeVals[`colors.${aliasDotKey}`] = value; } + modeVariables[modeName] = modeVars; + modeTokens[modeName] = modeVals; + } + return { modeVariables, modeTokens }; +} - // Merge initial mode's semantic aliases into the main variables and tokenMap - const initialMode = theme.mode as string; - if (initialMode && modeVariables[initialMode]) { - const initialModeVars: Record = {}; - const flatInitialAliases = flattenToDotPaths( - (theme.modes as Record)[initialMode] as Record< - string, - unknown - > +/** + * D5 enforcement: a reference whose target is absent from the merged map + * AND was dropped by an explicit `addScale({ replace: true })` fails the + * build — unconditional on usage — naming the referencing token (with its + * positional origin), the replacement call's scale, and the dropped keys. + * Targets never defined ANYWHERE stay warn-and-literal (supported kit + * pattern); a re-added key is simply known again and passes. + */ +function assertNoDroppedReferences( + tokenDefinitions: Record, + flatTokenMap: Record, + droppedTokenPaths: Map, + extendProvenance: Map +): void { + if (droppedTokenPaths.size === 0) return; + const known = new Set(Object.keys(flatTokenMap)); + const violations: string[] = []; + const droppedNamed = new Set(); + for (const [tokenPath, definition] of Object.entries(tokenDefinitions)) { + if (definition.kind !== 'reference') continue; + for (const reference of definition.references) { + if (known.has(reference.path)) continue; + const scale = droppedTokenPaths.get(reference.path); + if (scale === undefined) continue; + const provenanceIndex = extendProvenance.get(tokenPath); + const origin = + provenanceIndex === undefined + ? 'builder state' + : `extended theme #${provenanceIndex}`; + violations.push( + `'${tokenPath}' (${origin}) references '{${reference.path}}', dropped by addScale({ name: '${scale}', replace: true })` ); - for (const [aliasDotKey, colorRef] of Object.entries( - flatInitialAliases - )) { - if (typeof colorRef !== 'string') continue; - const dashAlias = dotToDash(aliasDotKey); - const varName = `--color-${dashAlias}`; - // Semantic aliases point to the palette var, not the raw value - const paletteVarName = variableMap[`colors.${colorRef}`]; - if (paletteVarName) { - initialModeVars[varName] = `var(${paletteVarName})`; - } - // Add semantic aliases to tokenMap and variableMap - tokenMap[`colors.${aliasDotKey}`] = `var(${varName})`; - variableMap[`colors.${aliasDotKey}`] = varName; + droppedNamed.add(reference.path); + } + } + if (violations.length > 0) { + throw new Error( + `build: dangling token reference(s) after explicit scale replacement — ${violations.join( + '; ' + )}. Dropped keys: ${[...droppedNamed].sort().join(', ')}.` + ); + } +} + +/** A resolved value still carrying a `{…}` reference — never shippable (G2). */ +const UNRESOLVED_REF_RE = /\{[^}]+\}/; + +/** Custom-property names referenced through `var(--name)` in a value. */ +const VAR_REF_NAME_RE = /var\(\s*(--[\w-]+)/g; + +function varRefNames(value: string): string[] { + const names: string[] = []; + for (const match of value.matchAll(VAR_REF_NAME_RE)) { + names.push(match[1]); + } + return names; +} + +/** + * Split root and mode variable maps into shippable declarations and omitted + * var names (G2): any value still containing `{…}` after resolution — a + * direct or TRANSITIVE never-defined target — is withheld from emitted CSS. + * + * Withholding must follow `var()` indirection too: flattenTheme synthesizes + * the initial mode's aliases as `var(--target)` BEFORE resolution, so a + * withheld target would otherwise leave an emitted alias pointing at a + * declaration that exists nowhere. A declaration is dangling when a var() + * target of its value was DECLARED in this build but withheld everywhere + * the declaration can see (:root for root declarations; the same mode + * block or :root for mode declarations); chains drop to a fixpoint. Var + * names never declared here (breakpoints, contextual vars) are exempt. + */ +function omitUnresolvedDeclarations( + variables: Record, + modeVariables: Record> +): { + emittableVariables: Record; + emittableModeVariables: Record>; + omitted: string[]; +} { + const omitted: string[] = []; + const emittableVariables: Record = {}; + const droppedRoot = new Set(); + for (const [varName, value] of Object.entries(variables)) { + if (UNRESOLVED_REF_RE.test(value)) { + omitted.push(varName); + droppedRoot.add(varName); + } else { + emittableVariables[varName] = value; + } + } + let rootChanged = true; + while (rootChanged) { + rootChanged = false; + for (const [varName, value] of Object.entries(emittableVariables)) { + if (varRefNames(value).some((name) => droppedRoot.has(name))) { + delete emittableVariables[varName]; + droppedRoot.add(varName); + omitted.push(varName); + rootChanged = true; } - Object.assign(variables, initialModeVars); } } - return { - tokenMap, - variableMap, - variables, - modeVariables, - modeTokens, - tokenDefinitions, - modeAliasDefinitions, - }; + const emittableModeVariables: Record> = {}; + for (const [modeName, modeVars] of Object.entries(modeVariables)) { + const kept: Record = {}; + const droppedInMode = new Set(); + const dropFromMode = (varName: string): void => { + omitted.push(`${varName} ([data-color-mode="${modeName}"])`); + droppedInMode.add(varName); + }; + for (const [varName, value] of Object.entries(modeVars)) { + if (UNRESOLVED_REF_RE.test(value)) { + dropFromMode(varName); + } else { + kept[varName] = value; + } + } + // A mode declaration's var() target resolves through the same block or + // :root; withheld from both means dangling for this mode. + const dangling = (name: string): boolean => + (droppedInMode.has(name) || droppedRoot.has(name)) && + !(name in kept) && + !(name in emittableVariables); + let modeChanged = true; + while (modeChanged) { + modeChanged = false; + for (const [varName, value] of Object.entries(kept)) { + if (varRefNames(value).some(dangling)) { + delete kept[varName]; + dropFromMode(varName); + modeChanged = true; + } + } + } + emittableModeVariables[modeName] = kept; + } + return { emittableVariables, emittableModeVariables, omitted }; } /** * Classify a RAW token value into its authored form (manifest v2, D6). MUST - * run before `resolveTokenRefs` — resolution rewrites the string, and the + * run before `resolveReferences` — resolution rewrites the string, and the * authored graph cannot be reconstructed from resolved CSS (D8). Uses * `matchAll` so the shared global {@link TOKEN_REF_RE} never carries a stale * `lastIndex` between callers. @@ -1251,11 +2151,14 @@ function sha256HexFallback(input: string): string { /** The canonical authored inputs the contract hash digests (D6). */ interface ContractHashInput { tokenDefinitions: Record; + emittedScales: string[]; modeAliasDefinitions: ModeAliasDefinition; initialMode: string | undefined; registrations: Record; systemPreference: SystemPreferenceConfig | undefined; browserColorScheme: BrowserColorSchemeConfig | undefined; + /** D6 mode bases — absent (dropped by JSON) for themes without them. */ + modeBases: Record | undefined; } /** @@ -1267,72 +2170,6 @@ function computeContractHash(input: ContractHashInput): string { return sha256Hex(JSON.stringify(canonicalize(input))); } -/** - * Resolve token refs ({scale.key}) in all flattened token values. - * Operates on the flattened tokenMap — does NOT mutate the nested theme. - */ -function resolveTokenRefs( - tokenMap: Record, - _variableMap: Record, - _emittedScales: Set -): void { - for (const [tokenPath, value] of Object.entries(tokenMap)) { - if (typeof value !== 'string') continue; - if (!value.includes('{')) continue; - - // Don't resolve var() references — they're already resolved - if (value.startsWith('var(')) continue; - - const scaleName = tokenPath.split('.')[0]; - - const resolved = value.replace(TOKEN_REF_RE, (match, ref: string) => { - // Check self-reference (same scale) - const refScale = ref.split('.')[0]; - if (refScale === scaleName) { - // oxlint-disable-next-line no-console -- intentional runtime diagnostic - console.warn( - `[animus] Self-referential token ref {${ref}} in scale '${scaleName}' — skipped` - ); - return match; - } - - // Handle opacity syntax: {colors.key/opacity} - let lookupPath = ref; - let opacity: string | undefined; - const slashIdx = ref.indexOf('/'); - if (slashIdx !== -1) { - lookupPath = ref.slice(0, slashIdx); - opacity = ref.slice(slashIdx + 1); - } - - // Look up the referenced token - const refValue = tokenMap[lookupPath]; - if (refValue === undefined) { - // oxlint-disable-next-line no-console -- intentional runtime diagnostic - console.warn( - `[animus] Token ref {${ref}} — path '${lookupPath}' not found in token map` - ); - return match; - } - - // Apply opacity modifier via color-mix - if (opacity) { - const alpha = Number.parseInt(opacity, 10); - if (alpha === 0) return 'transparent'; - if (alpha !== 100) { - return `color-mix(in srgb, ${refValue} ${alpha}%, transparent)`; - } - } - - return refValue; - }); - - if (resolved !== value) { - tokenMap[tokenPath] = resolved; - } - } -} - /** * Build `@property` registration rules for registered contextual vars. * The emitted custom property is `--${name}` — the same name the Rust resolver diff --git a/packages/system/src/theme/index.ts b/packages/system/src/theme/index.ts index bdf7fd9d..cb32a8a9 100644 --- a/packages/system/src/theme/index.ts +++ b/packages/system/src/theme/index.ts @@ -1,4 +1,9 @@ -export { createTheme, ThemeBuilder } from './createTheme'; +export { + createTheme, + type Flatten, + ThemeBuilder, + type ThemeBuilderStage, +} from './createTheme'; export type { FindPath, LiteralPaths, diff --git a/packages/system/src/theme/resolveReferences.ts b/packages/system/src/theme/resolveReferences.ts new file mode 100644 index 00000000..e31beec7 --- /dev/null +++ b/packages/system/src/theme/resolveReferences.ts @@ -0,0 +1,220 @@ +/** + * Late-binding token-reference resolution over the flattened theme (D4, + * openspec change first-class-extension). + * + * Replaces the old single-pass, insertion-order-dependent rewrite: references + * resolve against the COMPLETE flattened map via depth-first traversal of the + * reference DAG, so declaration order is never observable (G3), references + * inside emitted scales resolve into the emitted variable declarations + * instead of leaking literally into CSS (G2), and a token resolves to the + * same value whether its scale is emitted or inlined (G1 — an emitted target + * substitutes as `var()`, which the cascade late-binds to exactly the value + * the inlined form substitutes directly). + * + * Pure module: consumes the flatten pass's maps, returns fresh maps, mutates + * nothing. Runs in QuickJS — ES built-ins only, no Node/WHATWG APIs. + */ + +/** Token ref pattern: {scale.key}, {scale.key.sub}, or {path/NN} (opacity). */ +const TOKEN_REF_RE = /\{([^}]+)\}/g; + +/** One parsed `{...}` occurrence of a token value, in source order. */ +interface ParsedReference { + /** Full inner text including any opacity suffix — for diagnostics. */ + text: string; + /** Referenced token path (opacity suffix stripped). */ + path: string; + /** Opacity modifier digits when the `{path/NN}` form was authored. */ + opacity?: string; +} + +/** DFS node states for cycle detection. */ +const UNVISITED = 0; +const RESOLVING = 1; +const RESOLVED = 2; + +/** Parse every `{...}` occurrence via matchAll — no shared lastIndex. */ +function parseReferences(value: string): ParsedReference[] { + const references: ParsedReference[] = []; + for (const match of value.matchAll(TOKEN_REF_RE)) { + const text = match[1]; + const slashIdx = text.indexOf('/'); + references.push( + slashIdx === -1 + ? { text, path: text } + : { + text, + path: text.slice(0, slashIdx), + opacity: text.slice(slashIdx + 1), + } + ); + } + return references; +} + +export interface ResolvedReferences { + /** + * Token path → resolved value: emitted paths keep their `var()` + * indirection; every other path carries its fully resolved literal. + * Keys are in sorted token-path order (deterministic serialization). + */ + tokenMap: Record; + /** + * CSS var name → resolved declaration value for every emitted path, in + * sorted token-path order — `buildVariableCss` emits in iteration order, + * so reversed-declaration builds produce byte-identical CSS. + */ + variables: Record; +} + +/** + * Resolve token references over the complete flattened maps. + * + * - `tokenMap`/`variableMap`/`variables` are `flattenTheme`'s outputs: an + * emitted path's tokenMap entry is its `var()` indirection and its RAW + * authored value is parked in `variables`; a non-emitted path's tokenMap + * entry IS its raw value. + * - Substitution per reference: emitted target → `var(--target)`; non-emitted + * target → its resolved literal; `{path/NN}` keeps the existing + * `color-mix` output shape (`/0` → `transparent`, `/100` → the base). + * - Unresolvable target: warn once per missing path, keep the literal — a + * SUPPORTED pattern (a kit theme may reference tokens its consumer + * provides later). Dangling-reference errors arrive with the explicit + * replacement form (D5, increment 04). + * - Reference cycle: hard error naming the cycle's token paths in traversal + * order, regardless of emission flags (emission-invariant failure, G1). + */ +export function resolveReferences( + tokenMap: Record, + variableMap: Record, + variables: Record +): ResolvedReferences { + // Lexicographically sorted roots: the sorted path list drives traversal + // AND output assembly, so neither resolution nor key order can observe + // declaration/insertion order (G3). + const paths = Object.keys(tokenMap).sort(); + const known = new Set(paths); + + /** The authored (raw) value behind a path — see the contract note above. */ + const rawValueOf = (path: string): string => { + const varName = variableMap[path]; + if (varName !== undefined && variables[varName] !== undefined) { + return variables[varName]; + } + return tokenMap[path]; + }; + + const referencesByPath = new Map(); + for (const path of paths) { + const raw = rawValueOf(path); + if (typeof raw === 'string' && raw.includes('{')) { + const references = parseReferences(raw); + if (references.length > 0) referencesByPath.set(path, references); + } + } + + const state = new Map(); + const resolved = new Map(); + /** Active DFS trail — the cycle report slices it from the revisited node. */ + const trail: string[] = []; + const warnedMissing = new Set(); + + const substitute = (reference: ParsedReference, match: string): string => { + if (!known.has(reference.path)) { + if (!warnedMissing.has(reference.path)) { + warnedMissing.add(reference.path); + // oxlint-disable-next-line no-console -- intentional runtime diagnostic + console.warn( + `[animus] Token ref {${reference.text}} — path '${reference.path}' not found in token map` + ); + } + return match; + } + const targetVar = variableMap[reference.path]; + const targetValue = resolved.get(reference.path)!; + const base = + targetVar !== undefined && !targetValue.includes('{') + ? `var(${targetVar})` + : // Non-emitted targets resolve depth-first before their referrers, + // and unresolvedness must propagate through emitted targets so a + // declaration never survives while pointing at an omitted var. + targetValue; + if (reference.opacity !== undefined) { + const alpha = Number.parseInt(reference.opacity, 10); + // Empty or non-numeric modifiers ('{path/}', '{path/abc}') degrade to + // the unmodified base — never a NaN% color-mix. + if (Number.isNaN(alpha)) return base; + if (alpha === 0) return 'transparent'; + if (alpha !== 100) { + return `color-mix(in srgb, ${base} ${alpha}%, transparent)`; + } + } + return base; + }; + + const resolvePath = (path: string): void => { + const status = state.get(path) ?? UNVISITED; + if (status === RESOLVED) return; + if (status === RESOLVING) { + const cycle = [...trail.slice(trail.indexOf(path)), path]; + throw new Error( + `build: token reference cycle — ${cycle + .map((cyclePath) => `'${cyclePath}'`) + .join( + ' → ' + )}. Reference cycles cannot be resolved; give one of these tokens a literal value.` + ); + } + state.set(path, RESOLVING); + trail.push(path); + const references = referencesByPath.get(path); + if (references) { + // Dependencies resolve first — including emitted targets, which do not + // need their literal but must still participate in cycle detection so + // a cyclic theme fails identically under any emission flags. + for (const reference of references) { + if (known.has(reference.path)) resolvePath(reference.path); + } + } + let value = rawValueOf(path); + if (references) { + let index = 0; + value = value.replace(TOKEN_REF_RE, (match) => + substitute(references[index++], match) + ); + } + trail.pop(); + state.set(path, RESOLVED); + resolved.set(path, value); + }; + + for (const path of paths) resolvePath(path); + + // ── Deterministic output assembly ───────────────────────── + const outTokenMap: Record = {}; + const outVariables: Record = {}; + for (const path of paths) { + const varName = variableMap[path]; + if (varName === undefined) { + outTokenMap[path] = resolved.get(path)!; + } else { + // Emitted path: the tokenMap keeps the var() indirection; the resolved + // literal becomes the variable DECLARATION value. + outTokenMap[path] = tokenMap[path]; + if (variables[varName] !== undefined) { + outVariables[varName] = resolved.get(path)!; + } + } + } + // Defensive pass-through for variables not owned by any token path (none + // are produced today); appended AFTER the owned entries in sorted var-name + // order, so even this dead path can never make insertion order observable + // (G3 — inc 04 closure of the review-registered latent note). + for (const varName of Object.keys(variables).sort()) { + if (outVariables[varName] === undefined) { + outVariables[varName] = variables[varName]; + } + } + + return { tokenMap: outTokenMap, variables: outVariables }; +} diff --git a/packages/system/src/transforms/createTransform.ts b/packages/system/src/transforms/createTransform.ts index 8449dd2d..18b5f42c 100644 --- a/packages/system/src/transforms/createTransform.ts +++ b/packages/system/src/transforms/createTransform.ts @@ -7,11 +7,65 @@ export type TransformFn = ( props?: AbstractProps ) => string | number | CSSObject; -export type NamedTransform = TransformFn & { transformName: string }; +export type NamedTransform = TransformFn & { + transformName: string; + /** + * The user function's source text, captured at creation (design D12). + * The wrapper's own `toString()` is byte-identical for every transform + * (the body is closure-captured), so cross-system equality must compare + * the original source. Optional in the type: instances built by an older + * @animus-ui/system lack it, and a name match with a missing source is a + * loud conflict, never a silent coalesce. + */ + transformSource?: string; +}; export function createTransform(name: string, fn: TransformFn): NamedTransform { const wrapper: TransformFn = (value, property, props) => fn(value, property, props); Object.defineProperty(wrapper, 'name', { value: name }); - return Object.assign(wrapper, { transformName: name }) as NamedTransform; + // When `fn` is itself a createTransform product, its own text is the + // generic forwarder — byte-identical for every wrapper — so the captured + // source must be inherited from it, or two renamings of DIFFERENT + // transforms would compare equal under the same name. + const inherited = (fn as Partial).transformSource; + return Object.assign(wrapper, { + transformName: name, + transformSource: inherited ?? fn.toString(), + }) as NamedTransform; +} + +/** + * Cross-system transform equality (design D12): identity fast path, then — + * when both carry `transformName` — equal names AND equal captured + * `transformSource`. A name match with the captured source missing on either + * side is a loud conflict (older-instance safety), as is a named/bare mix. + * Bare-function pairs compare by their own `toString()` (their body IS their + * source; only `createTransform` wrappers hide it). Known accepted residual: + * two functions with byte-identical source can close over different values + * and would coalesce — the loud-conflict alternative (identity-only) was + * measured to false-conflict every transform-bearing prop under duplicate + * module instances (inc-01 spike) and to forbid re-registering kit props. + */ +export function areTransformsEqual( + a: TransformFn | undefined, + b: TransformFn | undefined +): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + const aNamed = a as Partial; + const bNamed = b as Partial; + const aName = aNamed.transformName; + const bName = bNamed.transformName; + if (aName !== undefined && bName !== undefined) { + return ( + aName === bName && + aNamed.transformSource !== undefined && + aNamed.transformSource === bNamed.transformSource + ); + } + if (aName === undefined && bName === undefined) { + return a.toString() === b.toString(); + } + return false; } diff --git a/packages/system/src/types/theme.ts b/packages/system/src/types/theme.ts index fc731ffc..64f64471 100644 --- a/packages/system/src/types/theme.ts +++ b/packages/system/src/types/theme.ts @@ -11,9 +11,22 @@ export interface AbstractTheme extends BaseTheme { /** * Filter non-scale keys from T so only user-defined scales appear. - * breakpoints, modes, mode are structural — not token scales. + * Builder/runtime metadata and color-mode options are structural — not token + * scales. */ -export type TokenScales = Omit; +export type ThemeStructuralKey = + | 'breakpoints' + | 'modes' + | 'mode' + | 'systemPreference' + | 'browserColorScheme' + | 'modeBases' + | '__emitted' + | 'manifest' + | 'serialize' + | 'varRef'; + +export type TokenScales = Omit; /** * Augmentable Theme interface. Consumers extend this via module augmentation @@ -174,6 +187,20 @@ export interface ColorModeOptions { browserColorScheme?: Partial< Record >; + /** + * NEW mode → the declared mode its uncovered INHERITED aliases resolve + * through (D6, first-class-extension). A consumer-declared mode over an + * extended theme that leaves inherited aliases uncovered must name a base + * here — the build fails otherwise, listing the uncovered set; with a + * base, uncovered aliases resolve through the declared chain and the + * build reports ONE aggregated inherited-alias count per mode. + * + * The base value is a plain `string` (not `keyof Config`) on purpose: the + * base is typically a mode declared by the EXTENDED source, which this + * call's local config type cannot see. Runtime validation covers both + * sides against the merged mode set. + */ + basedOn?: Partial>; } /** Pipeline-ready JSON strings returned by `.serialize()` on a built theme. */ @@ -190,7 +217,7 @@ export interface SerializedTheme { /** * One authored `{scale.key}` reference occurrence inside a token value - * (manifest v2, D6). Captured BEFORE `resolveTokenRefs` rewrites the value — + * (manifest v2, D6). Captured BEFORE `resolveReferences` rewrites the value — * the authored graph is unrecoverable from resolved CSS (D8). */ export interface TokenReference { @@ -245,7 +272,13 @@ export interface ThemeManifest { tokenMap: Record; /** Flat token key → CSS variable name without var() wrapper (e.g. 'colors.ember' → '--color-ember') */ variableMap: Record; - /** Mode name → flat key → resolved raw value (e.g. { dark: { 'colors.primary': '#FF2800' } }) */ + /** + * Mode name → flat key → RESOLVED value (e.g. { dark: { 'colors.primary': + * '#FF2800' } }). Since first-class-extension inc 04 the values pass + * through the reference resolver (a reference-valued color contributes its + * resolved value, never a raw `{…}` string), and both mode keys and inner + * keys are in sorted order (G3). + */ modes: Record>; /** Pre-built CSS string with :root and [data-color-mode] blocks */ variableCss: string; @@ -264,6 +297,8 @@ export interface ThemeManifest { manifestVersion?: 2; /** Authored literal-vs-reference structure per flattened token path. */ tokenDefinitions?: Record; + /** Exact scale names configured for CSS-variable emission, including empty scales. */ + emittedScales?: string[]; /** Mode name → alias dot-path → AUTHORED color dot-path (pre-resolution). */ modeAliasDefinitions?: ModeAliasDefinition; /** `@property` registrations by contextual var name, in declaration order. */ diff --git a/packages/test-ds/assets/test-font.woff2 b/packages/test-ds/assets/test-font.woff2 new file mode 100644 index 00000000..56cda6fe Binary files /dev/null and b/packages/test-ds/assets/test-font.woff2 differ diff --git a/packages/test-ds/package.json b/packages/test-ds/package.json index 0a77524c..aeb81666 100644 --- a/packages/test-ds/package.json +++ b/packages/test-ds/package.json @@ -6,9 +6,22 @@ "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./definition": { + "types": "./dist/definition.d.ts", + "import": "./dist/definition.mjs", + "default": "./dist/definition.mjs" + }, + "./assets/*": "./assets/*" + }, "scripts": { "build": "bun run build:ts", - "build:ts": "tsdown && tsc -p tsconfig.build.json", + "build:ts": "tsdown && tsc -p tsconfig.build.json && rm -f dist/dev-types.d.ts dist/dev-types.d.ts.map", "compile": "tsc --noEmit" }, "dependencies": { diff --git a/packages/test-ds/src/definition.ts b/packages/test-ds/src/definition.ts new file mode 100644 index 00000000..81f7b65c --- /dev/null +++ b/packages/test-ds/src/definition.ts @@ -0,0 +1,7 @@ +/** + * Side-effect-free definition entry consumed by createSystem/createTheme + * extension. Components remain on the package root so the extraction sandbox + * evaluates only the two built configuration values here. + */ +export { ds as system } from './system'; +export { referenceTokens as theme } from './theme'; diff --git a/packages/test-ds/src/dev-types.ts b/packages/test-ds/src/dev-types.ts index 49df2864..fb446a6d 100644 --- a/packages/test-ds/src/dev-types.ts +++ b/packages/test-ds/src/dev-types.ts @@ -1,11 +1,19 @@ /** - * Theme type augmentation for library development only. + * Theme type augmentation for library development only (the reference + * dev-only augmentation convention documented by the + * library-definition-contract capability, openspec: first-class-extension + * D11). * - * This file is included in the dev tsconfig so library authors get - * type-checked token names (bg: 'primary', etc.), but it is excluded - * from tsconfig.build.json so consumers never receive this augmentation - * in the published .d.ts files — preventing intersection narrowing of - * the consumer's own Theme declaration. + * This file is part of the library's own compilation so authors get + * type-checked token names (bg: 'primary', numeric scale literals, etc.). + * It stays out of the published declaration surface two ways: nothing + * reachable from the definition entry references it, and `build:ts` + * removes the emitted `dist/dev-types.d.ts` after declaration emit — so + * consumers never receive this augmentation and their own compilation- + * global `Theme` cannot be intersection-narrowed by the library. + * (It cannot simply be excluded from tsconfig.build.json: declaration + * emit type-checks the components, and their token literals only check + * against an augmented Theme.) */ import type { referenceTokens } from './theme'; diff --git a/packages/test-ds/src/index.ts b/packages/test-ds/src/index.ts index e621ab37..78bd39a5 100644 --- a/packages/test-ds/src/index.ts +++ b/packages/test-ds/src/index.ts @@ -3,5 +3,7 @@ export { Badge } from './components/Badge'; export { Button } from './components/Button'; export { Card } from './components/Card'; export { ContainerCard } from './components/ContainerCard'; +// The side-effect-free `system` / `theme` pair lives at `./definition`. +// Root keeps the historical names beside the component exports. export { ds } from './system'; export { referenceTokens } from './theme'; diff --git a/packages/test-ds/tsdown.config.ts b/packages/test-ds/tsdown.config.ts index 0d24cf9c..3ea3e881 100644 --- a/packages/test-ds/tsdown.config.ts +++ b/packages/test-ds/tsdown.config.ts @@ -1,3 +1,5 @@ import { createConfig } from '../../tsdown.config.base.ts'; -export default createConfig(); +export default createConfig({ + entry: ['./src/index.ts', './src/definition.ts'], +}); diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 6f6b0b72..e056e44a 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -50,9 +50,9 @@ import { createAppearanceBootstrap } from '@animus-ui/system/bootstrap'; import { animusExtract } from '@animus-ui/vite-plugin'; import { defineConfig } from 'vite'; -import { tokens } from './src/ds'; +import { theme } from './src/ds'; -const appearanceBootstrap = createAppearanceBootstrap(tokens); +const appearanceBootstrap = createAppearanceBootstrap(theme); export default defineConfig({ plugins: [animusExtract({ system: './src/ds.ts', appearanceBootstrap })], diff --git a/packages/vite-plugin/src/build-start.ts b/packages/vite-plugin/src/build-start.ts index e98f2814..42733622 100644 --- a/packages/vite-plugin/src/build-start.ts +++ b/packages/vite-plugin/src/build-start.ts @@ -6,11 +6,13 @@ import { DEFAULT_EXTENSIONS, discoverFiles, extractSystemFilePackages, + findAssetSpecifiers, preprocessMdx, + substituteAssetPlaceholders, validateLayerOrder, } from '@animus-ui/extract/pipeline'; import { readFileSync } from 'fs'; -import { extname, relative } from 'path'; +import { basename, extname, relative } from 'path'; import { DEFAULT_EXCLUDE } from './constants'; @@ -20,16 +22,27 @@ import type { PluginContext } from './context'; * buildStart: load the system, discover and ingest sources (local + * external packages), run whole-project analysis, and log the report. * `resolveSpecifier` is the bundler seam — Vite's `this.resolve` mapped to - * an absolute id. + * an absolute id. `emitAsset` (build mode only) is Rollup's `this.emitFile` + * for `asset()` placeholder substitution; absent in dev, where resolved + * files are served via base + `/@fs/` instead. */ export async function runBuildStart( ctx: PluginContext, - resolveSpecifier: (specifier: string) => Promise + resolveSpecifier: (specifier: string) => Promise, + emitAsset?: (fileName: string, source: Uint8Array) => string ): Promise { // Clear Rust-side per-file cache so stale results from a prior // server lifecycle never bleed into a fresh build/dev start. clearEngineCache(ctx.engineApi); + // Reset the asset() pass BEFORE step 6's runAnalysis: its substitution + // pass consults this state, and Rollup reference ids are scoped to one + // build — a second buildStart on the same context (another environment, + // a --watch rebuild) must not splice build #1's ids into build #2's CSS. + ctx.assetPassComplete = false; + ctx.assetUrlBySpecifier.clear(); + ctx.assetResolutionFailures.clear(); + // 1. Load system: config, theme, transforms, global styles let t0 = performance.now(); ctx.loadSystem(); @@ -146,6 +159,8 @@ export async function runBuildStart( ctx.packageMap = collected.packageMap; ctx.externalPackageOutcomes = collected.outcomes; + ctx.externalDirOwners = collected.dirOwners; + ctx.externalFileOwners = collected.fileOwners; ctx.enforceIncludeResolution(); for (const [specifier, srcEntry] of collected.sourceEntries) { ctx.externalSourceEntries.set(specifier, srcEntry); @@ -169,10 +184,60 @@ export async function runBuildStart( `Discovered ${fileEntries.length} files (${packageFileCount} from packages) (${Math.round(performance.now() - t0)}ms)` ); - // 6. Run project-wide analysis to produce the manifest + // 6. Run project-wide analysis to produce the manifest. The cross-source + // token-contract gate (extraction-diagnostics) runs inside runAnalysis — + // on this pass and on every HMR re-analysis alike. t0 = performance.now(); ctx.runAnalysis(fileEntries); + // 6c. asset() placeholder resolution (global-styles-system): resolve each + // referenced specifier through the bundler. Dev serves the resolved file + // via base + /@fs/. Build emits it as a Rollup asset and substitutes + // Vite's own `__VITE_ASSET____` marker: Vite's CSS pipeline + // resolves the marker to the hashed file name (with base / relative-base + // handling) BEFORE the stylesheet asset is itself hashed and emitted, so + // the CSS `[hash]` reflects the final URL — and markers landing in JS + // chunks (inlined/code-split CSS) are resolved by the same machinery. An + // unsubstitutable specifier warns and emits literally in non-strict mode, + // fails the build under strict. + const assetSpecifiers = findAssetSpecifiers(ctx.globalCss); + for (const specifier of assetSpecifiers) { + const resolvedPath = await resolveSpecifier(specifier); + if (!resolvedPath) { + ctx.assetFallback( + specifier, + `[animus-extract] unresolvable asset() specifier: ${specifier}` + ); + continue; + } + if (emitAsset) { + try { + const referenceId = emitAsset( + basename(resolvedPath), + readFileSync(resolvedPath) + ); + ctx.assetUrlBySpecifier.set( + specifier, + `__VITE_ASSET__${referenceId}__` + ); + } catch (err) { + ctx.assetFallback( + specifier, + `[animus-extract] failed to emit asset() specifier ${specifier}: ${String(err)}`, + err + ); + } + } else { + ctx.assetUrlBySpecifier.set(specifier, ctx.devFsUrl(resolvedPath)); + } + } + ctx.globalCss = substituteAssetPlaceholders( + ctx.globalCss, + ctx.assetUrlBySpecifier + ); + // From here on, runAnalysis owns late-appearing specifiers (dev resets). + ctx.assetPassComplete = true; + // 7. Surface diagnostics from the manifest if (ctx.storedManifest) { const report = ctx.storedManifest.report; diff --git a/packages/vite-plugin/src/config.ts b/packages/vite-plugin/src/config.ts index 8425b07a..d8d84c72 100644 --- a/packages/vite-plugin/src/config.ts +++ b/packages/vite-plugin/src/config.ts @@ -17,6 +17,9 @@ export function applyResolvedConfig( ctx.isProd = config.command === 'build'; ctx.rootDir = config.root; ctx.logger = config.logger; + // Public base for dev /@fs asset URLs (build URLs are resolved by Vite's + // own asset pipeline, which applies base itself). + ctx.base = config.base ?? '/'; // Resolve Lightning CSS browser targets once ctx.lcssTargets = resolveLightningTargets(ctx.options.targets, ctx.rootDir); diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index be045d85..c20757dd 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -3,10 +3,15 @@ import { createV2EngineApi, DEFAULT_EXTENSIONS, clearEngineCache, + enforceExternalTokenContracts, + findAssetSpecifiers, formatRustTimingWaterfall, loadSystemConfig, + resolveAssetFile, runProjectAnalysis, serializeStaticCss, + staleDistIncludesMessage, + substituteAssetPlaceholders, toWatchKeys, unresolvableIncludesMessage, } from '@animus-ui/extract/pipeline'; @@ -172,9 +177,34 @@ export class PluginContext { // Package resolution map built at buildStart (reused during HMR) packageMap: Record = {}; + // Public base path from the resolved Vite config (dev /@fs asset URLs) + base = '/'; + + // asset() placeholder substitutions resolved at buildStart. Dev entries + // map specifier → base-prefixed /@fs URL; build entries map specifier → + // Vite `__VITE_ASSET____` marker, which Vite's CSS/asset + // pipeline resolves to the hashed file name before the stylesheet asset + // is itself hashed and emitted. + assetUrlBySpecifier = new Map(); + + // Non-strict failures are substituted literally for the current pass but + // are not successes: a later system epoch must retry them. + assetResolutionFailures = new Set(); + + // Set once buildStart's bundler-resolved asset pass has run; gates the + // dev-only late-specifier resolution in runAnalysis (before the pass, + // buildStart owns resolution and the map is deliberately empty). + assetPassComplete = false; + // Absolute directory prefixes for external DS packages externalPackageDirs: string[] = []; + // Absolute package dir → owning specifier (cross-source correlation) + externalDirOwners: Record = {}; + + // rootDir-relative external file → owning specifier (correlation join) + externalFileOwners: Record = {}; + // External package specifier → absolute source entry (resolveId redirect) externalSourceEntries = new Map(); @@ -210,7 +240,13 @@ export class PluginContext { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly engineApi: () => any; - constructor(options: AnimusExtractOptions) { + constructor( + options: AnimusExtractOptions, + // Injected-fn test seam (vi.mock is a no-op in this repo's setup): lets + // behavioral tests feed a canned engine without loading the NAPI binary. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engineApiOverride?: () => any + ) { this.options = options; this.staticCssJson = serializeStaticCss(options.staticCss); this.verbose = @@ -219,6 +255,10 @@ export class PluginContext { process.env.ANIMUS_DEBUG === 'true'; this.extensionsSet = new Set(options.extensions ?? DEFAULT_EXTENSIONS); + if (engineApiOverride) { + this.engineApi = engineApiOverride; + return; + } // Adapt the function API onto the stateful v2 handle via the single // authoritative factory in @animus-ui/extract/pipeline (shared with // next-plugin). The package root IS the v2 engine since retire-extract-v1. @@ -342,6 +382,9 @@ export class PluginContext { }, pathAliasesJson: this.pathAliasesJson, staticCssJson: this.staticCssJson, + externalDirs: this.externalPackageDirs.map((dir) => + relative(this.rootDir, dir) + ), devMode: !this.isProd, warn: (m) => this.warn(m), }); @@ -384,7 +427,76 @@ export class PluginContext { }); } console.warn('[animus-extract] analyzeProject failed:', e); + return; + } + + // A system edit can INTRODUCE an asset() specifier after buildStart — + // substitution alone only knows buildStart's map, so a new placeholder + // would otherwise survive verbatim (and bypass strict). + this.applyAssetSubstitutions(); + + // Cross-source token contracts run on EVERY analysis pass — buildStart, + // HMR re-analysis, new-file detection, and the geological reset alike — + // so a dev edit that references an uninherited kit token surfaces + // immediately (next-plugin parity: both hosts share one gate). + this.enforceExternalTokenContracts(); + } + + /** Base-prefixed dev URL for an absolute file (Vite mounts /@fs under base). */ + devFsUrl(absPath: string): string { + const base = this.base.endsWith('/') ? this.base.slice(0, -1) : this.base; + return `${base}/@fs${absPath}`; + } + + /** + * The shared unresolvable-asset policy: fail the build under `strict`, + * else warn and record the identity mapping so the specifier emits + * literally. + */ + assetFallback(specifier: string, message: string, cause?: unknown): void { + if (this.options.strict) { + throw new Error(message, cause === undefined ? undefined : { cause }); } + this.warn(message); + this.assetUrlBySpecifier.set(specifier, specifier); + this.assetResolutionFailures.add(specifier); + } + + /** + * The one asset-substitution pass per analysis. In dev, a specifier not + * already mapped by buildStart's bundler-resolved pass (a geological + * reset regenerates globalCss from an edited system, which may reference + * NEW assets) is resolved Node-side first — host aliases, then Node, + * then package root; bundler-only resolutions can differ, since the + * plugin hook context is unavailable on this path. Strict semantics + * match buildStart. + */ + private applyAssetSubstitutions(): void { + if (!this.isProd && this.assetPassComplete) { + for (const specifier of findAssetSpecifiers(this.globalCss)) { + if (this.assetResolutionFailures.delete(specifier)) { + this.assetUrlBySpecifier.delete(specifier); + } + if (this.assetUrlBySpecifier.has(specifier)) continue; + const resolved = resolveAssetFile( + specifier, + this.rootDir, + this.pathAliasesJson + ); + if (resolved) { + this.assetUrlBySpecifier.set(specifier, this.devFsUrl(resolved)); + } else { + this.assetFallback( + specifier, + `[animus-extract] unresolvable asset() specifier: ${specifier}` + ); + } + } + } + this.globalCss = substituteAssetPlaceholders( + this.globalCss, + this.assetUrlBySpecifier + ); } // Burst-coalescing scheduler for geological resets (lazy; per instance). @@ -397,12 +509,31 @@ export class PluginContext { */ requestGeologicalReset(trigger: string): void { this.log(`HMR geological reset scheduled: ${trigger}`); - this.resetCoalescer ??= new ResetCoalescer(() => - this.performGeologicalReset() + this.resetCoalescer ??= new ResetCoalescer( + () => this.performGeologicalReset(), + (err) => this.geologicalResetFailed(err) ); this.resetCoalescer.request(); } + /** + * A failed reset must surface without killing the server: the coalescer + * fires from a bare timer, OUTSIDE Vite's handleHMRUpdate catch, so a + * strict-mode throw (asset resolution, token contracts, system load) + * would otherwise be an unhandled exception that exits the process. + * Strict-in-dev means the error overlay, matching the transform/HMR + * strict paths that Vite itself catches. + */ + private geologicalResetFailed(err: unknown): void { + this.warn(`[animus-extract] geological reset failed: ${err}`); + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? (err.stack ?? '') : ''; + this.devServer?.hot?.send({ + type: 'error', + err: { message, stack, plugin: 'animus-extract' }, + }); + } + /** * The geological reset: reload the system (refreshing the dependency * set), clear the Rust per-file cache, re-analyze everything with full @@ -514,15 +645,42 @@ export class PluginContext { * (external-package-file-discovery: silence is never an outcome). An * unresolvable `.includes()` specifier warns in non-strict mode and FAILS * the build under `strict: true`, naming every offending specifier — - * a typo'd include must not ship a build missing its component CSS. + * a typo'd include must not ship a build missing its component CSS. A + * stale dist entry under an extended package (first-class-extension D13) + * rides the same seam: a merge against it would silently skew registry + * content the discovered sources no longer match. */ enforceIncludeResolution(): void { - const message = unresolvableIncludesMessage(this.externalPackageOutcomes); - if (message === null) return; - if (this.options.strict) { - throw new Error(message); + for (const message of [ + unresolvableIncludesMessage(this.externalPackageOutcomes), + staleDistIncludesMessage(this.externalPackageOutcomes), + ]) { + if (message === null) continue; + if (this.options.strict) { + throw new Error(message); + } + this.warn(message); } - this.warn(message); + } + + /** + * The post-analysis gate over cross-source token contracts + * (extraction-diagnostics): a discovered component referencing a token its + * OWN package defines but the consumer theme does not gets the teaching + * error naming the token, component, source package, and the missing + * `createTheme().extend(...)`. Wiring and severity routing live in the + * shared pipeline gate (next-plugin parity by construction). + */ + enforceExternalTokenContracts(): void { + enforceExternalTokenContracts({ + diagnostics: this.storedManifest?.diagnostics, + fileOwners: this.externalFileOwners, + dirOwners: this.externalDirOwners, + sourceThemeManifestsJson: this.system.sourceThemeManifestsJson, + strict: this.options.strict, + prefix: '[animus-extract]', + warn: (message: string) => this.warn(message), + }); } runSelfVerify(): void { diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 3b9e195d..20d0a36d 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -147,10 +147,19 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { }, async buildStart() { - await runBuildStart(ctx, async (specifier) => { - const resolved = await this.resolve(specifier); - return resolved?.id ?? null; - }); + await runBuildStart( + ctx, + async (specifier) => { + const resolved = await this.resolve(specifier); + return resolved?.id ?? null; + }, + // Rollup asset emission exists in build only; dev serves resolved + // asset() files via /@fs/ instead. + ctx.isProd + ? (fileName, source) => + this.emitFile({ type: 'asset', name: fileName, source }) + : undefined + ); }, resolveId(id) { diff --git a/packages/vite-plugin/src/reset-coalescer.ts b/packages/vite-plugin/src/reset-coalescer.ts index e90e4b1e..c4095bc9 100644 --- a/packages/vite-plugin/src/reset-coalescer.ts +++ b/packages/vite-plugin/src/reset-coalescer.ts @@ -14,6 +14,11 @@ export class ResetCoalescer { constructor( private readonly run: () => void, + // Required: the timer callback is a bare scheduler entry point — a + // throw escaping it (e.g. a strict-mode gate inside the reset) is an + // unhandled exception that kills the dev server, so every caller must + // decide where contained errors go. + private readonly onError: (err: unknown) => void, private readonly quietMs = 60, private readonly schedule: (fn: () => void, ms: number) => unknown = ( fn, @@ -35,6 +40,8 @@ export class ResetCoalescer { this.running = true; try { this.run(); + } catch (err) { + this.onError(err); } finally { this.running = false; if (this.dirty) { diff --git a/packages/vite-plugin/src/transform.ts b/packages/vite-plugin/src/transform.ts index a87af241..d0e3d1db 100644 --- a/packages/vite-plugin/src/transform.ts +++ b/packages/vite-plugin/src/transform.ts @@ -38,6 +38,17 @@ export function transformSource( // New file detection: if this file isn't in the cache, it was created // after buildStart. Register it and re-run analysis to pick it up. if (!ctx.isProd && !ctx.fileCache.has(relativePath)) { + // A newly created EXTERNAL package file needs its ownership recorded + // before re-analysis: the token-contract correlation joins on + // `fileOwners[diagnostic.file]`, and an unowned file's diagnostics + // would silently drop until the next server restart. Gated on the + // boundary-safe membership already computed above. + if (isExternalPkg) { + const owner = Object.entries(ctx.externalDirOwners).find( + ([dir]) => id === dir || id.startsWith(dir + sep) + ); + if (owner) ctx.externalFileOwners[relativePath] = owner[1]; + } const hash = contentHash(code); ctx.fileCache.set(relativePath, { hash, source: code }); const fileEntries = buildFileEntriesFromCache( diff --git a/packages/vite-plugin/tests/analyze-project-args.test.ts b/packages/vite-plugin/tests/analyze-project-args.test.ts index d08cc43e..2c72256a 100644 --- a/packages/vite-plugin/tests/analyze-project-args.test.ts +++ b/packages/vite-plugin/tests/analyze-project-args.test.ts @@ -2,7 +2,7 @@ import { buildAnalyzeProjectArgs } from '@animus-ui/extract/pipeline'; import { describe, expect, test } from 'vitest'; describe('Vite analyzeProject argument construction', () => { - test('pins all 16 production NAPI slots', () => { + test('pins all 17 production NAPI slots', () => { expect( buildAnalyzeProjectArgs({ filesJson: 'vite-production-files', @@ -20,6 +20,7 @@ describe('Vite analyzeProject argument construction', () => { keyframesJson: 'vite-production-keyframes', staticCssJson: 'vite-production-static-css', conditionAliasesJson: 'vite-production-condition-aliases', + externalDirsJson: 'vite-production-external-dirs', }) ).toEqual([ 'vite-production-files', @@ -38,10 +39,11 @@ describe('Vite analyzeProject argument construction', () => { 'vite-production-keyframes', 'vite-production-static-css', 'vite-production-condition-aliases', + 'vite-production-external-dirs', ]); }); - test('pins all 16 dev NAPI slots', () => { + test('pins all 17 dev NAPI slots', () => { expect( buildAnalyzeProjectArgs({ filesJson: 'vite-dev-files', @@ -59,6 +61,7 @@ describe('Vite analyzeProject argument construction', () => { keyframesJson: 'vite-dev-keyframes', staticCssJson: null, conditionAliasesJson: null, + externalDirsJson: null, }) ).toEqual([ 'vite-dev-files', @@ -77,6 +80,7 @@ describe('Vite analyzeProject argument construction', () => { 'vite-dev-keyframes', null, null, + null, ]); }); }); diff --git a/packages/vite-plugin/tests/build-start-assets.test.ts b/packages/vite-plugin/tests/build-start-assets.test.ts new file mode 100644 index 00000000..6077559d --- /dev/null +++ b/packages/vite-plugin/tests/build-start-assets.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, test } from 'vitest'; + +import { runBuildStart } from '../src/build-start'; +import { PluginContext } from '../src/context'; + +/** + * asset() lifecycle across REPEATED buildStarts on one PluginContext + * (global-styles-system): Vite calls buildStart once per environment (and + * once per rebuild under --watch), so the bundler-resolved asset pass must + * start from a clean slate every time. A stale `assetPassComplete` + + * `assetUrlBySpecifier` from build #1 would let runAnalysis splice build + * #1's Rollup reference ids into build #2's CSS before the resolution loop + * runs — leaving nothing to emit and a dangling reference id. + */ + +const scratch = mkdtempSync(join(tmpdir(), 'animus-build-start-assets-')); + +afterAll(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +const FONT_SPECIFIER = '@acme/fonts/inter.woff2'; + +function makeContext(): { + ctx: PluginContext; + emitted: string[]; + resolveSpecifier: (specifier: string) => Promise; + emitAsset: (fileName: string, source: Uint8Array) => string; +} { + mkdirSync(join(scratch, 'src'), { recursive: true }); + writeFileSync(join(scratch, 'src', 'ds.ts'), 'export const ds = {};\n'); + const fontPath = join(scratch, 'inter.woff2'); + writeFileSync(fontPath, 'font-bytes'); + + const manifest = { + components: {}, + sheets: { + global: `@font-face { font-family: Inter; src: url('animus-asset:${FONT_SPECIFIER}'); }`, + }, + css: '', + }; + const engine = { + loadSystemModule: () => ({ + propConfig: '{}', + groupRegistry: '{}', + scalesJson: '{}', + variableMapJson: '{}', + variableCss: '', + dependencies: [], + }), + analyzeProject: () => JSON.stringify(manifest), + }; + + const ctx = new PluginContext({ system: 'src/ds.ts' }, () => engine); + ctx.rootDir = scratch; + ctx.isProd = true; + + const emitted: string[] = []; + return { + ctx, + emitted, + resolveSpecifier: async (specifier) => + specifier === FONT_SPECIFIER ? fontPath : null, + emitAsset: (_fileName, _source) => { + const referenceId = `ref${emitted.length + 1}`; + emitted.push(referenceId); + return referenceId; + }, + }; +} + +describe('runBuildStart asset pass across environments/rebuilds', () => { + test('a second buildStart re-emits and substitutes fresh reference ids', async () => { + const { ctx, emitted, resolveSpecifier, emitAsset } = makeContext(); + + await runBuildStart(ctx, resolveSpecifier, emitAsset); + expect(ctx.globalCss).toContain('__VITE_ASSET__ref1__'); + expect(emitted).toEqual(['ref1']); + + // Same context, fresh Rollup output scope: e.g. the ssr environment of + // a multi-environment build, or the next `vite build --watch` pass. + await runBuildStart(ctx, resolveSpecifier, emitAsset); + expect(emitted).toEqual(['ref1', 'ref2']); + expect(ctx.globalCss).toContain('__VITE_ASSET__ref2__'); + expect(ctx.globalCss).not.toContain('__VITE_ASSET__ref1__'); + }); +}); diff --git a/packages/vite-plugin/tests/external-token-contracts.test.ts b/packages/vite-plugin/tests/external-token-contracts.test.ts new file mode 100644 index 00000000..105bbd2e --- /dev/null +++ b/packages/vite-plugin/tests/external-token-contracts.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'vitest'; + +import { PluginContext } from '../src/context'; + +/** + * The post-analysis gate over cross-source token contracts + * (extraction-diagnostics): a discovered component referencing a token its + * OWN package defines but the consumer theme does not warns in non-strict + * mode and FAILS the build under `strict: true`; witness misses and + * consumer-local files stay silent. + */ + +const KIT_DIR = '/repo/packages/kit/src'; + +function makeContext(strict: boolean): PluginContext { + const ctx = new PluginContext({ system: './src/ds.ts', strict }); + ctx.storedManifest = { + diagnostics: [ + { + file: 'packages/kit/src/Card.tsx', + component: 'KitCard', + kind: 'external-token-candidate', + message: "'colors.externalAccent' did not resolve", + token: 'colors.externalAccent', + }, + ], + }; + ctx.externalFileOwners = { 'packages/kit/src/Card.tsx': '@acme/ui-kit' }; + ctx.externalDirOwners = { [KIT_DIR]: '@acme/ui-kit' }; + ctx.system.sourceThemeManifestsJson = JSON.stringify({ + [`${KIT_DIR}/theme.ts`]: { referenceTokens: ['colors.externalAccent'] }, + }); + return ctx; +} + +describe('enforceExternalTokenContracts', () => { + test('strict mode fails the build naming token, component, and source', () => { + const ctx = makeContext(true); + + expect(() => ctx.enforceExternalTokenContracts()).toThrow( + /KitCard \(from '@acme\/ui-kit'\) references token 'colors\.externalAccent'.*createTheme\(\)\.extend\(/ + ); + }); + + test('non-strict mode warns with the same teaching error and continues', () => { + const ctx = makeContext(false); + const warnings: string[] = []; + ctx.logger = { + warn: (message: string) => warnings.push(message), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + expect(() => ctx.enforceExternalTokenContracts()).not.toThrow(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('KitCard'); + expect(warnings[0]).toContain("'@acme/ui-kit'"); + expect(warnings[0]).toContain('createTheme().extend('); + }); + + test('a fulfilled contract stays silent (no candidates in the manifest)', () => { + const ctx = makeContext(true); + ctx.storedManifest = { diagnostics: [] }; + + expect(() => ctx.enforceExternalTokenContracts()).not.toThrow(); + }); + + test('a witness miss stays silent even under strict', () => { + const ctx = makeContext(true); + ctx.system.sourceThemeManifestsJson = JSON.stringify({ + [`${KIT_DIR}/theme.ts`]: { referenceTokens: ['colors.somethingElse'] }, + }); + + expect(() => ctx.enforceExternalTokenContracts()).not.toThrow(); + }); + + test('a consumer-local candidate file stays silent even under strict', () => { + const ctx = makeContext(true); + ctx.externalFileOwners = {}; + + expect(() => ctx.enforceExternalTokenContracts()).not.toThrow(); + }); + + // Every analysis pass — buildStart, HMR re-analysis, new-file detection, + // geological reset — flows through PluginContext.runAnalysis, so the gate + // must fire there (next-plugin parity: both hosts share one pipeline + // gate). Driven through the real method via the injected engine seam so + // the pin is behavioral, not source-text layout. + function makeAnalysisContext(strict: boolean): PluginContext { + const manifest = { + diagnostics: [ + { + file: 'packages/kit/src/Card.tsx', + component: 'KitCard', + kind: 'external-token-candidate', + message: "'colors.externalAccent' did not resolve", + token: 'colors.externalAccent', + }, + ], + sheets: { global: '' }, + css: '', + }; + const ctx = new PluginContext({ system: './src/ds.ts', strict }, () => ({ + analyzeProject: () => JSON.stringify(manifest), + })); + ctx.externalFileOwners = { 'packages/kit/src/Card.tsx': '@acme/ui-kit' }; + ctx.externalDirOwners = { [KIT_DIR]: '@acme/ui-kit' }; + ctx.system.sourceThemeManifestsJson = JSON.stringify({ + [`${KIT_DIR}/theme.ts`]: { referenceTokens: ['colors.externalAccent'] }, + }); + return ctx; + } + + test('runAnalysis enforces the gate on every pass — strict throws through the analysis path', () => { + expect(() => makeAnalysisContext(true).runAnalysis([])).toThrow( + /references token 'colors\.externalAccent'/ + ); + }); + + test('runAnalysis warns and continues in non-strict mode', () => { + const ctx = makeAnalysisContext(false); + const warnings: string[] = []; + ctx.logger = { + warn: (message: string) => warnings.push(message), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + expect(() => ctx.runAnalysis([])).not.toThrow(); + expect(warnings.some((w) => w.includes('KitCard'))).toBe(true); + }); +}); diff --git a/packages/vite-plugin/tests/reset-coalescer.test.ts b/packages/vite-plugin/tests/reset-coalescer.test.ts index d66c8f35..c306af32 100644 --- a/packages/vite-plugin/tests/reset-coalescer.test.ts +++ b/packages/vite-plugin/tests/reset-coalescer.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest'; +import { PluginContext } from '../src/context'; import { ResetCoalescer } from '../src/reset-coalescer'; /** Manual timer harness — injected seams, no builtin mocking. */ function harness(run: () => void, quietMs = 60) { const pending: Array<{ id: number; fn: () => void }> = []; + const errors: unknown[] = []; let nextId = 1; const coalescer = new ResetCoalescer( run, + (err) => errors.push(err), quietMs, (fn) => { const id = nextId++; @@ -23,7 +26,7 @@ function harness(run: () => void, quietMs = 60) { const timer = pending.shift(); timer?.fn(); }; - return { coalescer, pending, fire }; + return { coalescer, pending, errors, fire }; } describe('ResetCoalescer', () => { @@ -64,6 +67,52 @@ describe('ResetCoalescer', () => { expect(h.pending.length).toBe(0); }); + it('contains a throwing run: routed to onError, never propagates', () => { + const boom = new Error('strict gate'); + const { coalescer, errors, fire } = harness(() => { + throw boom; + }); + + coalescer.request(); + // The schedule callback runs on a bare timer in production — anything + // escaping it is an unhandled exception that kills the dev server. + expect(() => fire()).not.toThrow(); + expect(errors).toEqual([boom]); + }); + + it('recovers after a throwing run: later requests still reset', () => { + let runs = 0; + const { coalescer, errors, fire } = harness(() => { + runs++; + if (runs === 1) throw new Error('transient'); + }); + + coalescer.request(); + fire(); + coalescer.request(); + fire(); + + expect(runs).toBe(2); + expect(errors).toHaveLength(1); + }); + + it('a throwing run still honors the mid-run follow-up', () => { + let runs = 0; + const h = harness(() => { + runs++; + if (runs === 1) { + h.coalescer.request(); + throw new Error('transient'); + } + }); + + h.coalescer.request(); + h.fire(); + expect(h.pending.length).toBe(1); + h.fire(); + expect(runs).toBe(2); + }); + it('schedules again after a completed quiet cycle', () => { let runs = 0; const { coalescer, fire } = harness(() => runs++); @@ -75,3 +124,35 @@ describe('ResetCoalescer', () => { expect(runs).toBe(2); }); }); + +describe('PluginContext geological-reset error wiring', () => { + it('a strict reset failure surfaces as warn + overlay, not a process kill', async () => { + const ctx = new PluginContext({ system: './src/ds.ts', strict: true }); + const warnings: string[] = []; + ctx.logger = { + warn: (message: string) => warnings.push(message), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + const sent: Array> = []; + ctx.devServer = { + hot: { send: (p: Record) => sent.push(p) }, + }; + ctx.performGeologicalReset = () => { + throw new Error( + '[animus-extract] unresolvable asset() specifier: @acme/typo.woff2' + ); + }; + + ctx.requestGeologicalReset('test'); + await new Promise((resolve) => setTimeout(resolve, 120)); + + expect(warnings.some((w) => w.includes('geological reset failed'))).toBe( + true + ); + expect(sent).toHaveLength(1); + expect(sent[0].type).toBe('error'); + expect((sent[0].err as { message: string }).message).toContain( + '@acme/typo.woff2' + ); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index f98fb3e5..3549d83d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,12 +14,16 @@ const typescriptTestTargets = [ // (canary.test.ts and static-css-overrides.test.ts) and run via `bun test` // in verify:canary instead; this tier's only prerequisite is `bun install`. // A new extract test goes HERE unless it loads the native engine. + 'packages/extract/tests/asset-placeholders.test.ts', 'packages/extract/tests/collect-external-packages.test.ts', + 'packages/extract/tests/correlate-external-tokens.test.ts', 'packages/extract/tests/discover-packages.test.ts', 'packages/extract/tests/path-aliases.test.ts', 'packages/extract/tests/post-process-css.test.ts', + 'packages/extract/tests/resolve-asset.test.ts', 'packages/extract/tests/timing-waterfall.test.ts', 'packages/extract/tests/tsconfig-paths.test.ts', + 'packages/extract/tests/watch-keys.test.ts', 'scripts/verify/packed-graph.test.ts', 'scripts/verify/owner-graph.test.ts', 'scripts/verify/ci-graph.test.ts',