diff --git a/e2e/next-app/next.config.ts b/e2e/next-app/next.config.ts index d8fbfc60..756cb22d 100644 --- a/e2e/next-app/next.config.ts +++ b/e2e/next-app/next.config.ts @@ -3,4 +3,14 @@ import { withAnimus } from '@animus-ui/next-plugin'; export default withAnimus({ verbose: true, system: './src/ds.ts', -})({}); +})({ + // Required for TS-only syntax in external DS package SOURCE files: the kit + // entry exports an `as const` variant map, and the animus webpack loader + // passes componentless files through unchanged (next-webpack-integration › + // "Files with no extractable components SHALL pass through unchanged") — + // without SWC transpilation of the workspace package, webpack's ES parser + // rejects the raw TS. The animus loader still runs `enforce: 'pre'`, so it + // sees original source before SWC either way. Turbopack (next16-app) needs + // no equivalent. + transpilePackages: ['@animus-ui/test-ds'], +}); diff --git a/e2e/next-app/scripts/assert-build.ts b/e2e/next-app/scripts/assert-build.ts index 967c64e8..3372fe09 100644 --- a/e2e/next-app/scripts/assert-build.ts +++ b/e2e/next-app/scripts/assert-build.ts @@ -115,9 +115,10 @@ async function main(): Promise { assertNoPlaceholders(css); - // Guardrail G2 (modern-css-surface): condition at-rules must nest inside a - // named @layer block. Non-vacuous here — the imported test-ds Card emits raw - // @container / @media / @supports rules into this build's CSS. + // arch-css-structural-gates › "Condition at-rules gated inside layer blocks": + // condition at-rules must nest inside a named @layer block. Non-vacuous + // here — the imported test-ds Card emits raw @container / @media / + // @supports rules into this build's CSS. // // Exempt: the theme's variable-level system fallback blocks (openspec: // system-color-scheme), which live in the UNLAYERED variables part beside @@ -137,9 +138,9 @@ async function main(): Promise { minReferences: 2, }); - // ── System color scheme (openspec: system-color-scheme, D2/D6) ────────── + // ── System color scheme (openspec: system-color-scheme) ───────────────── // - // Guardrail G2: every root-targeting rule inside a prefers-color-scheme + // Every root-targeting rule inside a prefers-color-scheme // block carries the `:root:not([data-color-mode])` guard, and both guarded // blocks actually exist with custom properties (non-vacuous). assertSystemSchemeGuard(css, { expectSchemes: ['light', 'dark'] }); diff --git a/e2e/next-app/src/ds.ts b/e2e/next-app/src/ds.ts index 0d01213b..4dd4864d 100644 --- a/e2e/next-app/src/ds.ts +++ b/e2e/next-app/src/ds.ts @@ -74,7 +74,7 @@ export const tokens = createTheme() 900: '#78350f', }, }) - // System participation (openspec: system-color-scheme, D2). App-LOCAL theme — + // System participation (openspec: system-color-scheme). App-LOCAL theme — // nothing else in the workspace consumes it, so opting in here cannot move a // shared parity baseline (the parity harness builds // `packages/extract/tests/test-system.ts`). diff --git a/e2e/vite-app/scripts/assert-build.ts b/e2e/vite-app/scripts/assert-build.ts index 5523eb57..9a58a90a 100644 --- a/e2e/vite-app/scripts/assert-build.ts +++ b/e2e/vite-app/scripts/assert-build.ts @@ -5,12 +5,16 @@ import { assertConditionsInsideLayers, assertHeadInjectionContract, assertKeyframesExtracted, + assertKeyframesUniqueBodies, assertLayerOrder, assertNoDevDiagnostics, assertNoEmotionImports, + assertNoLiteralAmpersand, assertNoPlaceholders, + assertSelectorEmitted, assertSystemFallbackParity, assertSystemSchemeGuard, + assertVariantDeclarationParity, findCssFiles, findJsFiles, layerBlock, @@ -200,11 +204,11 @@ async function main(): Promise { ); }); - // 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/ - // @supports) and the app Card (registered `_motionReduce` alias) both emit - // condition rules into this dist. + // arch-css-structural-gates › "Condition at-rules gated inside layer blocks": + // 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/@supports) and the app Card (registered + // `_motionReduce` alias) both emit condition rules into this dist. // // The one exemption is the theme's variable-level system fallback blocks // (openspec: system-color-scheme): they belong to the UNLAYERED variables @@ -217,9 +221,9 @@ async function main(): Promise { exemptSpans: systemSchemeVariableSpans(css), }); - // Container-unit emission pin (inc 11, spec "Container-relative units on - // scale-typed properties"): the test-ds Card authors `gap: '2cqi'` on a - // strict space-scale prop inside a nested @container block — the unit + // Container-unit emission pin (container-query-support › "Container-relative + // units on scale-typed properties"): the test-ds Card authors `gap: '2cqi'` + // on a strict space-scale prop inside a nested @container block — the unit // string must ship verbatim (the resolver emits it; minifiers may reformat // the prelude but not the declaration value). if (!css.includes('gap:2cqi') && !css.includes('gap: 2cqi')) { @@ -229,14 +233,14 @@ 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` + // Merged-config extraction witness (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). + // snapshot merge (no serialized round-trip). for (const probe of [ ['top:12px', 'top: 12px'], ['z-index:10', 'z-index: 10'], @@ -249,7 +253,7 @@ async function main(): Promise { } } - // Built-in condition composite witness (inc 06): the app Card authors + // Built-in condition composite witness: 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. if ( @@ -262,15 +266,16 @@ async function main(): Promise { ); } - // ── System color scheme (openspec: system-color-scheme, D2/D6) ────────── + // ── System color scheme (openspec: system-color-scheme) ───────────────── // // This lane is the VITE delivery witness: the theme opts in via // `systemPreference` + `browserColorScheme` (src/ds.ts) and the plugin // injects the bootstrap via the `appearanceBootstrap` option (vite.config.ts). // - // Guardrail G2. Non-vacuous in BOTH directions here: `expectSchemes` demands - // the two guarded theme blocks exist and assign custom properties, while the - // app Card's unregistered `_osDark` condition puts an UNGUARDED + // The layer gate above runs non-vacuously in BOTH directions here: + // `expectSchemes` demands the two guarded theme blocks exist and assign + // custom properties, while the app Card's unregistered `_osDark` condition + // puts an UNGUARDED // `@media (prefers-color-scheme: dark) { .animus-Card-… { … } }` block in the // same sheet — which must not trip the gate. Only ROOT-targeting rules owe // the guard. @@ -307,15 +312,61 @@ async function main(): Promise { }); // Keyframes extracted through the rollup (Vite) adapter — fixture declares - // `animations = keyframes({ fadeIn, pulse })` in src/ds.ts; the assertion - // proves both blocks land in @layer anm-global, both animation-name refs - // resolve to a matching block, and neither got px-mangled by unit-fallback. + // `animations = keyframes({ fadeIn, pulse })` in src/ds.ts, and KitPulse + // consumes `kitMotion.pulse` from the test-ds package ENTRY; the assertion + // proves all three blocks land in @layer anm-global, all three + // animation-name refs resolve to a matching block, and none got px-mangled + // by unit-fallback. assertKeyframesExtracted(css, { insideLayer: 'anm-global', - minBlocks: 2, - minReferences: 2, + minBlocks: 3, + minReferences: 3, }); + // Exactly one @keyframes block per unique frame body: the kit collection + // must emit ONCE — not once via the external-entry scan and again via the + // consumer reference — and no app body may collide. + assertKeyframesUniqueBodies(css); + + // Binding-backed vs inline parity (semantic-const-resolution): KitSized + // consumes the kit's `as const` variant map through a named import; + // InlineSized authors the identical map inline. Base + every option class + // must carry byte-equal declaration lists — a mismatch here is STOP + // evidence, never paper over it. + assertVariantDeclarationParity(css, { + components: ['KitSized', 'InlineSized'], + optionSuffixes: ['size-sm', 'size-md', 'size-lg'], + }); + + // Ancestor/repeated/alias subject witnesses (nested-selector-resolution): + // the composed class must sit at the SUBJECT position with the ancestor + // prefix preserved. Two `[data-active]` + // producers exist (app ActiveItem + kit GroupItem); the alias patterns pin + // the kit's registered `_groupHover` / `_dark` ancestor aliases arriving + // through the `.extend(testDs)` registry merge. Patterns tolerate the + // minifier stripping attribute-value quotes. + assertSelectorEmitted(css, { + pattern: /\[data-active="?true"?\]\s*\.animus-[\w-]+/, + label: 'raw ancestor subject ([data-active="true"] &)', + minMatches: 2, + }); + assertSelectorEmitted(css, { + pattern: + /\.animus-ActiveItem-[0-9a-f]+\s*\+\s*\.animus-ActiveItem-[0-9a-f]+/, + label: 'repeated subject (& + &)', + }); + assertSelectorEmitted(css, { + pattern: /\.group:hover\s*\.animus-[\w-]+/, + label: 'registered ancestor alias (_groupHover: .group:hover &)', + }); + assertSelectorEmitted(css, { + pattern: /\[data-color-mode="?dark"?\]\s*\.animus-[\w-]+/, + label: 'registered ancestor alias (_dark: [data-color-mode="dark"] &)', + }); + + // Zero literal `&` in produced CSS. + assertNoLiteralAmpersand(css); + const jsFiles = await findJsFiles(DIST); const jsSources: string[] = []; for (const jsFile of jsFiles) { diff --git a/e2e/vite-app/src/App.tsx b/e2e/vite-app/src/App.tsx index 75551f27..d60a99b8 100644 --- a/e2e/vite-app/src/App.tsx +++ b/e2e/vite-app/src/App.tsx @@ -1,11 +1,15 @@ -import { Card as TestDsCard } from '@animus-ui/test-ds'; +import { Card as TestDsCard, GroupItem } from '@animus-ui/test-ds'; import { + ActiveItem, Box, Button, Card, Fade, Family, + InlineSized, + KitPulse, + KitSized, Pulse, Stack, StackItem, @@ -56,8 +60,8 @@ export function App() { {/* - Merged-config extraction witness (openspec: first-class-extension, - NS-1 / rust-system-loader › "Merged configuration is the extraction + Merged-config extraction witness (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 @@ -72,7 +76,43 @@ export function App() { Pulse Fade In + {/* + External keyframe-collection witness: KitPulse + animates with `kitMotion.pulse` from the test-ds package ENTRY — + rendered beside the app-local Pulse/Fade siblings so usage + reconciliation keeps all three animation references. + */} + Kit Pulse + + {/* + Binding-backed vs inline variant-map siblings: every option of BOTH + components renders so usage reconciliation keeps + the full option set on each — assertVariantDeclarationParity compares + the two per class. + */} + + Kit sm + Kit md + Kit lg + Inline sm + Inline md + Inline lg + + + {/* + Ancestor-subject witnesses: the wrapper carries + BOTH ancestor contexts — `data-active="true"` for the raw ancestor + keys (app ActiveItem + adjacent-sibling `& + &` pair) and the `group` + class for the kit GroupItem's registered `_groupHover` alias + (`.group:hover &`). GroupItem's `_dark` alias matches the + `[data-color-mode]` attribute the appearance bootstrap sets on :root. + */} +
+ Active A + Active B + Kit group item +
); } diff --git a/e2e/vite-app/src/components/ActiveItem.tsx b/e2e/vite-app/src/components/ActiveItem.tsx new file mode 100644 index 00000000..9aca56fd --- /dev/null +++ b/e2e/vite-app/src/components/ActiveItem.tsx @@ -0,0 +1,26 @@ +import { ds } from '../ds'; + +// App-local ancestor/repeated-subject fixture (nested-selector-resolution): +// the raw ancestor key must emit with the composed class at the subject +// position (`[data-active="true"] .animus-ActiveItem-…`), and the +// repeated-subject key must substitute the class at EVERY unquoted `&` +// (`.animus-ActiveItem-… + .animus-ActiveItem-…`). The test-ds GroupItem +// covers the same family cross-package plus the registered-alias path; this +// sibling proves the raw keys work authored directly in the consumer app. No +// literal `&` may survive into the produced stylesheet. +export const ActiveItem = ds + .styles({ + display: 'inline-flex', + alignItems: 'center', + px: 8, + py: 4, + borderRadius: '4px', + bg: 'surface', + color: 'text', + '[data-active="true"] &': { + bg: 'primary', + color: 'background', + }, + '& + &': { ml: 8 }, + }) + .asElement('span'); diff --git a/e2e/vite-app/src/components/Card.tsx b/e2e/vite-app/src/components/Card.tsx index 7d1bc856..f05657d6 100644 --- a/e2e/vite-app/src/components/Card.tsx +++ b/e2e/vite-app/src/components/Card.tsx @@ -14,7 +14,7 @@ export const Card = ds _motionReduce: { transition: 'none', }, - // UNREGISTERED built-in (inc 06 composite witness): this app never + // UNREGISTERED built-in (composite witness): this app never // registers `_osDark` — it resolves through the DEFAULT built-in set via // the full SystemBuilder → manifest → plugin glue → engine path. The // assert lane pins its emission. diff --git a/e2e/vite-app/src/components/KitPulse.tsx b/e2e/vite-app/src/components/KitPulse.tsx new file mode 100644 index 00000000..39e7fb17 --- /dev/null +++ b/e2e/vite-app/src/components/KitPulse.tsx @@ -0,0 +1,26 @@ +import { kitMotion } from '@animus-ui/test-ds'; + +import { ds } from '../ds'; + +// External keyframe-collection consumer (rust-extraction-pipeline › "External +// package collection discovered from its entry"): `kitMotion` is created and +// exported by the test-ds package ENTRY module, and this component references +// it through a plain named import — the app's ds.ts does NOT re-export it. The +// extractor's keyframes scan must discover the collection from the external +// entry, resolve `kitMotion.pulse` to its `animus-kf-` name, and emit the +// matching @keyframes block exactly once. Pulse.tsx (app-local +// `animations.pulse` from ds.ts) is the inline sibling in the same sheet; +// assertKeyframesUniqueBodies pins that no frame body is ever emitted twice. +export const KitPulse = ds + .styles({ + bg: 'secondary', + color: 'background', + px: 16, + py: 8, + borderRadius: '4px', + animationName: kitMotion.pulse, + animationDuration: '2s', + animationTimingFunction: 'ease-in-out', + animationIterationCount: 'infinite', + }) + .asElement('span'); diff --git a/e2e/vite-app/src/components/Sized.tsx b/e2e/vite-app/src/components/Sized.tsx new file mode 100644 index 00000000..cf27a2cc --- /dev/null +++ b/e2e/vite-app/src/components/Sized.tsx @@ -0,0 +1,35 @@ +import { kitSizes } from '@animus-ui/test-ds'; + +import { ds } from '../ds'; + +// Binding-backed vs inline variant-map siblings (semantic-const-resolution › +// "Imported variant map across a package boundary"): KitSized references the +// kit's `as const` map through a plain named import; InlineSized authors the +// IDENTICAL map inline. The assert lane (assertVariantDeclarationParity) pins +// per-class declaration equality between the two — base class and every variant +// option class — so any resolver drift between the binding-backed and inline +// paths is a hard STOP. +const sizedBase = { + display: 'inline-flex', + alignItems: 'center', + borderRadius: '4px', + bg: 'surface', + color: 'text', +} as const; + +export const KitSized = ds + .styles(sizedBase) + .variant({ prop: 'size', variants: kitSizes }) + .asElement('span'); + +export const InlineSized = ds + .styles(sizedBase) + .variant({ + prop: 'size', + variants: { + sm: { fontSize: 14, px: 8, py: 4 }, + md: { fontSize: 16, px: 16, py: 8 }, + lg: { fontSize: 20, px: 24, py: 12 }, + }, + }) + .asElement('span'); diff --git a/e2e/vite-app/src/components/index.ts b/e2e/vite-app/src/components/index.ts index a8cbbac2..3948738d 100644 --- a/e2e/vite-app/src/components/index.ts +++ b/e2e/vite-app/src/components/index.ts @@ -1,7 +1,10 @@ +export { ActiveItem } from './ActiveItem'; export { Box } from './Box'; export { Button } from './Button'; export { Card } from './Card'; export { Family } from './Family'; +export { KitPulse } from './KitPulse'; export { Fade, Pulse } from './Pulse'; +export { InlineSized, KitSized } from './Sized'; export { Stack } from './Stack'; export { StackItem } from './StackItem'; diff --git a/e2e/vite-app/src/ds.ts b/e2e/vite-app/src/ds.ts index 3184d317..85fb789a 100644 --- a/e2e/vite-app/src/ds.ts +++ b/e2e/vite-app/src/ds.ts @@ -9,7 +9,7 @@ export const theme = createTheme() red: { 500: '#ef4444', 700: '#b91c1c' }, green: { 500: '#22c55e' }, }) - // System participation (openspec: system-color-scheme, D2). App-LOCAL theme, + // System participation (openspec: system-color-scheme). App-LOCAL theme, // shared with nothing — the parity harness builds // `packages/extract/tests/test-system.ts`, not this module. // diff --git a/packages/_assertions/__tests__/conditions-inside-layers.test.ts b/packages/_assertions/__tests__/conditions-inside-layers.test.ts index c2192dea..c903ee44 100644 --- a/packages/_assertions/__tests__/conditions-inside-layers.test.ts +++ b/packages/_assertions/__tests__/conditions-inside-layers.test.ts @@ -6,10 +6,10 @@ import { } from '../src/assert-css'; /** - * Guardrail G2 (modern-css-surface) — FIRST ARMED RUN. + * arch-css-structural-gates › "Condition at-rules gated inside layer blocks". * - * The structural check `assertConditionsInsideLayers` lands with the first - * condition-emitting increment (03). These cases arm it: a passing shape that + * These cases arm the structural check `assertConditionsInsideLayers`: a + * passing shape that * mirrors the extractor's real emission (condition at-rules nested inside the * owning @layer block), a failing shape (a condition at-rule hoisted to the * top level), and a vacuous shape (no conditions) that must stay green. diff --git a/packages/_assertions/src/assert-appearance.ts b/packages/_assertions/src/assert-appearance.ts index d5241fb2..c466f3cf 100644 --- a/packages/_assertions/src/assert-appearance.ts +++ b/packages/_assertions/src/assert-appearance.ts @@ -228,7 +228,7 @@ export interface SystemSchemeGuardConfig { } /** - * Guardrail G2 — the system fallback never fights an explicit mode. + * The system fallback never fights an explicit mode. * * Spec contract ("Guarded system fallback emission"): the mapped modes' * variable assignments apply under the OS preference *only while the document diff --git a/packages/_assertions/src/assert-css.ts b/packages/_assertions/src/assert-css.ts index 2224362c..7c5deaa4 100644 --- a/packages/_assertions/src/assert-css.ts +++ b/packages/_assertions/src/assert-css.ts @@ -265,8 +265,9 @@ export interface ConditionsInsideLayersConfig { } /** - * Assert Guardrail G2 (modern-css-surface): new condition at-rules SHALL NOT - * appear outside a named `@layer` block in any emitted sheet. Every + * Assert arch-css-structural-gates › "Condition at-rules gated inside layer + * blocks": new condition at-rules SHALL NOT appear outside a named `@layer` + * block in any emitted sheet. Every * `@container` / `@supports` / `@media` at-rule occurrence must fall inside a * `@layer { … }` span. Position-aware (character-index containment), so * a correctly-named-but-misplaced at-rule fails fast — the whole reason this @@ -468,3 +469,214 @@ export function assertKeyframesExtracted( } } } + +export interface KeyframesUniqueBodiesConfig { + namePrefix?: string; +} + +/** + * Assert exactly one `@keyframes` block per unique frame body + * (rust-extraction-pipeline external-collection scenario): the FNV name + * derives from the frame body, so a body emitted under two names, or the same + * block emitted twice, means the single + * `keyframes_blocks` emission path duplicated work (e.g. an external-package + * collection emitted once by the kit scan and again by the consumer). + * + * Whitespace-normalized body comparison; vacuously green on output with no + * prefixed `@keyframes` blocks (presence is assertKeyframesExtracted's job). + * Pure over the CSS string; no I/O. + */ +export function assertKeyframesUniqueBodies( + css: string, + config?: KeyframesUniqueBodiesConfig +): void { + const namePrefix = config?.namePrefix ?? 'animus-kf-'; + const openRe = new RegExp( + `@keyframes\\s+(${escapeForRegExp(namePrefix)}[\\w-]+)\\s*\\{`, + 'g' + ); + + const byBody = new Map(); + for (const m of css.matchAll(openRe)) { + if (m.index === undefined) continue; + const afterOpen = m.index + m[0].length; + let depth = 1; + let cursor = afterOpen; + while (cursor < css.length && depth > 0) { + const ch = css[cursor]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + if (depth > 0) cursor++; + } + const body = css.slice(afterOpen, cursor).replace(/\s+/g, ''); + const names = byBody.get(body) ?? []; + names.push(m[1]); + byBody.set(body, names); + } + + const duplicated = [...byBody.entries()].filter( + ([, names]) => names.length > 1 + ); + if (duplicated.length > 0) { + throw new AssertionError( + `assertKeyframesUniqueBodies: frame body emitted more than once: ${duplicated + .map(([, names]) => names.join(' / ')) + .join('; ')}`, + { + duplicated: duplicated.map(([body, names]) => ({ names, body })), + } + ); + } +} + +export interface SelectorEmissionConfig { + /** Tested against each innermost rule prelude in the sheet. */ + pattern: RegExp; + /** Names the witness in the failure message. */ + label: string; + /** Minimum number of matching rule preludes (default 1). */ + minMatches?: number; +} + +/** + * Assert that at least `minMatches` innermost rule preludes match `pattern` + * (nested-selector-resolution): the ancestor/repeated/alias subject witnesses + * check the COMPOSED selector text — e.g. + * `[data-active="true"] .animus-…` with the class at the subject position — + * which plain substring probes cannot pin to a selector position. Preludes + * are matched after minification, so patterns must tolerate optional + * attribute-value quotes and collapsed whitespace. Pure over the CSS string; + * no I/O. + */ +export function assertSelectorEmitted( + css: string, + config: SelectorEmissionConfig +): void { + const minMatches = config.minMatches ?? 1; + const matches: string[] = []; + for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const prelude = m[1].trim(); + if (config.pattern.test(prelude)) matches.push(prelude); + } + if (matches.length < minMatches) { + throw new AssertionError( + `assertSelectorEmitted: expected at least ${minMatches} rule prelude(s) matching ${config.label} (/${config.pattern.source}/), found ${matches.length}`, + { label: config.label, pattern: config.pattern.source, matches } + ); + } +} + +/** + * Assert that no literal `&` survives into a produced stylesheet + * (nested-selector-resolution). Every unquoted `&` in an authored selector + * must have been substituted with the composed class; ANY remaining + * ampersand — even + * inside quoted attribute text, which no current fixture emits — fails loud + * with its offset and context so the sheet stays byte-auditable with + * `grep -c '&'` → 0. Pure over the CSS string; no I/O. + */ +export function assertNoLiteralAmpersand(css: string): void { + const idx = css.indexOf('&'); + if (idx !== -1) { + const start = Math.max(0, idx - 60); + const end = Math.min(css.length, idx + 60); + throw new AssertionError( + `assertNoLiteralAmpersand: found literal '&' at offset ${idx}`, + { offset: idx, context: css.slice(start, end) } + ); + } +} + +export interface VariantDeclarationParityConfig { + /** Component display names as they appear in emitted class tokens. */ + components: readonly [string, string]; + /** Variant option suffixes that must exist on BOTH (e.g. 'size-sm'). */ + optionSuffixes: readonly string[]; + /** Also compare the bare base classes (default true). */ + includeBase?: boolean; + /** Class name prefix (default 'animus-'). */ + prefix?: string; +} + +function componentClassBase( + css: string, + prefix: string, + component: string +): string { + const hashRe = new RegExp( + `${escapeForRegExp(prefix + component)}-([0-9a-f]+)`, + 'g' + ); + const hashes = new Set(); + for (const m of css.matchAll(hashRe)) hashes.add(m[1]); + if (hashes.size !== 1) { + throw new AssertionError( + `assertVariantDeclarationParity: expected exactly one class hash for component '${component}', found ${hashes.size}`, + { component, hashes: [...hashes] } + ); + } + return `${prefix}${component}-${[...hashes][0]}`; +} + +function tokenDeclarations(css: string, token: string): string[] { + // Word-ish boundary: the base token must not swallow its own variant + // tokens (`token--size-sm`), and a suffix token must not match a longer + // suffix it happens to prefix. + const tokenRe = new RegExp(`${escapeForRegExp(token)}(?![\\w-])`); + const declarations: string[] = []; + for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (!tokenRe.test(m[1])) continue; + for (const declaration of m[2].split(';')) { + const compact = declaration.trim(); + if (compact) declarations.push(compact); + } + } + return declarations.sort(); +} + +/** + * Assert per-class declaration equality between a binding-backed component + * and its inline-authored sibling (semantic-const-resolution): a variant map + * imported across a package boundary must produce the SAME declarations as + * inlining the literal — + * base class and every option class. Classes are paired by variant-option + * suffix; hashes and display names differ by construction, declaration lists + * may not. A divergence is STOP evidence: the error carries both full + * declaration lists for the byte diff. Pure over the CSS string; no I/O. + */ +export function assertVariantDeclarationParity( + css: string, + config: VariantDeclarationParityConfig +): void { + const prefix = config.prefix ?? 'animus-'; + const includeBase = config.includeBase ?? true; + const [left, right] = config.components; + const leftBase = componentClassBase(css, prefix, left); + const rightBase = componentClassBase(css, prefix, right); + + const suffixes = [ + ...(includeBase ? [''] : []), + ...config.optionSuffixes.map((s) => `--${s}`), + ]; + for (const suffix of suffixes) { + const leftDecls = tokenDeclarations(css, `${leftBase}${suffix}`); + const rightDecls = tokenDeclarations(css, `${rightBase}${suffix}`); + const label = suffix === '' ? '' : suffix; + if (leftDecls.length === 0 || rightDecls.length === 0) { + throw new AssertionError( + `assertVariantDeclarationParity: no declarations found for ${label} on ${leftDecls.length === 0 ? left : right} — expected both siblings to emit this class`, + { suffix: label, leftDecls, rightDecls } + ); + } + if (leftDecls.join(';') !== rightDecls.join(';')) { + throw new AssertionError( + `assertVariantDeclarationParity: declaration mismatch for ${label} between ${left} and ${right}`, + { + suffix: label, + [left]: leftDecls.join(';'), + [right]: rightDecls.join(';'), + } + ); + } + } +} diff --git a/packages/_parity/src/engine-run.ts b/packages/_parity/src/engine-run.ts index 7562fa71..d7af8582 100644 --- a/packages/_parity/src/engine-run.ts +++ b/packages/_parity/src/engine-run.ts @@ -124,7 +124,7 @@ const HARNESS_CONDITION_ALIASES = JSON.stringify({ order: 500, kind: 'media', }, - // Built-in condition aliases (inc 06, design D8) at their reserved-band + // Built-in condition aliases (media-condition-aliases) at their reserved-band // orders — the `condition-builtin-*` fixtures prove built-ins resolve with no // user registration, and `condition-builtin-order` proves the built-in band // (370) emits before the user band (500). diff --git a/packages/extract/crates/extract-v2/index.d.ts b/packages/extract/crates/extract-v2/index.d.ts index 06cef1b0..28afc8d4 100644 --- a/packages/extract/crates/extract-v2/index.d.ts +++ b/packages/extract/crates/extract-v2/index.d.ts @@ -58,7 +58,7 @@ export interface EngineOptions { /** Selector aliases JSON (v1 `selector_aliases_json`). */ selectorAliasesJson?: string /** - * Condition aliases JSON (inc 03 — `conditionAliases` manifest field): + * Condition aliases JSON (the `conditionAliases` manifest field): * `{ "_motionReduce": { "value": "@media …", "order": 500, "kind": * "media" } }`. Absent = no registrations. */ @@ -98,10 +98,10 @@ export interface EngineOptions { export declare function engineVersion(): string /** - * Full per-file fact extraction (increment 11): chains + eagerly evaluated + * Full per-file fact extraction: chains + eagerly evaluated * stages + statics + raw usage facts + compose families — one parse per * file. The store (and every AST) is dropped when this call returns; the - * D4-relevant invariant is that no program() read happens after + * invariant is that no program() read happens after * cross-file facts resolve. */ export declare function extractFacts(fileEntriesJson: string): string @@ -118,8 +118,8 @@ export interface NapiSystemConfig { selectorAliases?: string selectorOrder?: string /** - * Condition alias map JSON (inc 03 — `conditionAliases`): alias → - * `{ value, order, kind }`. Absent when the system registers none. + * Condition alias map JSON (the `conditionAliases` manifest field): + * alias → `{ value, order, kind }`. Absent when the system registers none. */ conditionAliases?: string globalStyleBlocks?: string @@ -138,3 +138,14 @@ export interface NapiSystemConfig { */ sourceThemeManifests?: string } + +/** + * Scan one module entry for named `Keyframes` collection exports — the + * keyframes-only carve-out for external package entries. The + * entry evaluates through the same loader pipeline as a system module, but + * nothing except `__brand === 'Keyframes'` exports is read from it; the + * consumer's configured system remains the singular config authority. + * Returns the `{ exportName: { keyName: { name, frames } } }` JSON, or None + * when the entry exports no collections. + */ +export declare function scanKeyframesExports(entryPath: string, rootDir: string): string | null diff --git a/packages/extract/crates/extract-v2/index.js b/packages/extract/crates/extract-v2/index.js index ca96089a..1d9c4d98 100644 --- a/packages/extract/crates/extract-v2/index.js +++ b/packages/extract/crates/extract-v2/index.js @@ -592,3 +592,4 @@ module.exports.discoverChains = nativeBinding.discoverChains module.exports.engineVersion = nativeBinding.engineVersion module.exports.extractFacts = nativeBinding.extractFacts module.exports.loadSystemModule = nativeBinding.loadSystemModule +module.exports.scanKeyframesExports = nativeBinding.scanKeyframesExports diff --git a/packages/extract/crates/extract-v2/src/analyze_css.rs b/packages/extract/crates/extract-v2/src/analyze_css.rs index a12ea7be..0b7fff07 100644 --- a/packages/extract/crates/extract-v2/src/analyze_css.rs +++ b/packages/extract/crates/extract-v2/src/analyze_css.rs @@ -1,12 +1,12 @@ -//! Project-level CSS orchestration (row 07 Task 07.6): v1 -//! `project_analyzer::analyze` Phases 3–6 reimplemented over retained -//! FACTS — no AST access, no re-parse (v1 re-parses every file for JSX -//! scanning; v2 filters the usage facts collected at parse time). +//! Project-level CSS orchestration: v1 `project_analyzer::analyze` +//! Phases 3–6 reimplemented over retained FACTS — no AST access, no +//! re-parse (v1 re-parses every file for JSX scanning; v2 filters the +//! usage facts collected at parse time). //! //! Bug-compat mirrors (v1 project_analyzer line refs): //! - eval-failed chains still DROP from the manifest and the source file -//! stays untransformed (967-969), but the drop now bails LOUD (quirk -//! shed inc 02 — v1 emits no diagnostic; divergence licensed); +//! stays untransformed (967-969), but the drop now bails LOUD (v1 +//! emits no diagnostic; divergence licensed); //! - cycle in extension provenance ⇒ the ordering degrades to the //! lexically-sorted non-cyclic set (700-712) — not a re-topo; //! - usage configs only track variant props WITH a default (982-1001), @@ -19,10 +19,10 @@ //! - dev_mode retains all components and reports prospective //! eliminations only (1584-1602). //! -//! Input surface (completed at row 13): global style blocks + keyframes -//! feed `sheets.global`; extension parents resolve through relative -//! imports, path aliases, the package map, AND re-export chains -//! (follow_reexports) — mirroring v1's import_resolver. +//! Input surface: global style blocks + keyframes feed `sheets.global`; +//! extension parents resolve through relative imports, path aliases, the +//! package map, AND re-export chains (follow_reexports) — mirroring v1's +//! import_resolver. use std::collections::{BTreeMap, HashMap}; use std::fmt::Write as _; @@ -101,7 +101,7 @@ pub struct CssInputs { pub config: PropConfigMap, pub group_registry: FxHashMap>, pub selector_aliases: SelectorAliasesMap, - /// Condition alias registry (`conditionAliases` manifest field, inc 03): + /// Condition alias registry (`conditionAliases` manifest field): /// `_motionReduce` → { value, order, kind }. Empty = no registrations. pub condition_aliases: ConditionAliasesMap, /// v1 `global_style_blocks_json` (resolved into sheets.global). @@ -160,7 +160,7 @@ impl CssInputs { } } // v1 lib.rs 877-888: `{aliases: [...]}` wrapper, silently-empty on - // parse failure in v1 — v2 fails loud instead (G5). + // parse failure in v1 — v2 fails loud instead. let path_aliases = match path_aliases_json { None => Vec::new(), Some(s) if s.trim().is_empty() || s.trim() == "null" => Vec::new(), @@ -236,6 +236,35 @@ pub struct CssDiagnostic { /// every existing diagnostic serializes byte-identically. #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, + /// Stable diagnostic code (`animus..`). Absent fields + /// keep existing diagnostics serializing byte-identically. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + /// `"error"` diagnostics fail strict builds at the plugin policy point; + /// `"warn"` and absent never do. + #[serde(skip_serializing_if = "Option::is_none")] + pub severity: Option, +} + +/// Extract a trailing `(animus..)` marker from a diagnostic message. +pub(crate) fn diagnostic_code_from_message(message: &str) -> Option { + let start = message.rfind("(animus.")?; + let rest = &message[start + 1..]; + let end = rest.find(')')?; + let code = &rest[..end]; + code.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') + .then(|| code.to_string()) +} + +/// Severity assignment for coded diagnostics: unrepresentable-selector codes +/// are error-severity (strict builds fail); everything else stays warn. +pub(crate) fn diagnostic_severity_for_code(code: &str) -> &'static str { + if code == crate::eval::SELECTOR_UNSUPPORTED_SUBJECT { + "error" + } else { + "warn" + } } pub struct CssOutput { @@ -329,7 +358,7 @@ pub fn resolve_import_source( /// v1 probe_known_files order EXACTLY (project_analyzer 2027-2047): /// bare, .ts, .tsx, .js, .jsx, /index.ts, /index.tsx, /index.js, /// /index.jsx — a sibling .ts/.tsx pair must resolve to the SAME parent -/// v1 picks (inc-07 review F3). +/// v1 picks. fn probe_files(base: &str, files: &BTreeMap) -> Option { let candidates = [ base.to_string(), @@ -529,13 +558,13 @@ fn unresolved_alias_spans(value: &str) -> Vec { spans } -/// extract-quirk-shed increment 01 (resolves DEF-4): an unresolvable token -/// alias SHALL NOT leak raw into emitted CSS (deterministic-extraction); -/// the carrying declaration is DROPPED and a warn diagnostic names the -/// component, CSS property, and unresolved alias (extraction-diagnostics). -/// v1 retains the raw passthrough until retirement — the resulting -/// v1-vs-v2 divergence is licensed in packages/_parity/register.json -/// (intentional-correctness entries for the css-validity witnesses). +/// An unresolvable token alias SHALL NOT leak raw into emitted CSS +/// (deterministic-extraction); the carrying declaration is DROPPED and a +/// warn diagnostic names the component, CSS property, and unresolved alias +/// (extraction-diagnostics). v1 retains the raw passthrough until +/// retirement — the resulting v1-vs-v2 divergence is licensed in +/// packages/_parity/register.json (intentional-correctness entries for the +/// css-validity witnesses). fn shed_unresolved_alias_decls( decls: &mut Vec, scale_family: &FxHashSet, @@ -561,6 +590,8 @@ fn shed_unresolved_alias_decls( spans.join(", "), d.property ), + code: None, + severity: None, }); false }); @@ -670,6 +701,8 @@ fn warn_token_shaped_value( will be ignored by browsers.", decl.value, decl.property ), + code: None, + severity: None, }); } @@ -827,6 +860,8 @@ fn record_external_candidates_in_decls( "'{}' in '{}' did not resolve against the consumer theme", token, d.property ), + code: None, + severity: None, }); } } @@ -922,14 +957,14 @@ fn shed_unresolved_aliases_in_styles( } } -/// extract-quirk-shed increment 02: a builder chain dropped because stage -/// evaluation failed emits a bail diagnostic naming the file, binding, and -/// failing stage (extraction-diagnostics) — silent disappearance from the -/// manifest no longer occurs. The chain still drops and its source file -/// stays untransformed for that chain (existing behavior; only the -/// diagnostic is new). v1 keeps the empty Err arm (project_analyzer -/// 967-969) until retirement — the resulting diagnostics divergence is -/// licensed in packages/_parity/register.json. +/// A builder chain dropped because stage evaluation failed emits a bail +/// diagnostic naming the file, binding, and failing stage +/// (extraction-diagnostics) — silent disappearance from the manifest no +/// longer occurs. The chain still drops and its source file stays +/// untransformed for that chain (existing behavior; only the diagnostic is +/// new). v1 keeps the empty Err arm (project_analyzer 967-969) until +/// retirement — the resulting diagnostics divergence is licensed in +/// packages/_parity/register.json. fn emit_eval_drop_bail( diagnostics: &mut Vec, file: &str, @@ -946,14 +981,16 @@ fn emit_eval_drop_bail( "chain dropped: stage '{}' evaluation failed — {}", stage, detail ), + code: None, + severity: None, }); } -/// Resolve one compose slot's LOCAL binding to the class of the component it -/// names (ANI-004). Slot values are identifiers at the compose() callsite, so -/// the owning file decides what they mean: the file's own component first, -/// then whatever its import (following re-exports) brought the name in from. -/// An aliased import — `import { Root as CardRoot }` — resolves through the +/// Resolve one compose slot's LOCAL binding to the class of the component +/// it names. Slot values are identifiers at the compose() callsite, so the +/// owning file decides what they mean: the file's own component first, then +/// whatever its import (following re-exports) brought the name in from. An +/// aliased import — `import { Root as CardRoot }` — resolves through the /// same path, which bare-name matching could never do. fn resolve_compose_slot_class<'a>( family_file: &str, @@ -978,12 +1015,12 @@ fn resolve_compose_slot_class<'a>( id_to_class.get(defining_id.as_str()).copied() } -/// extract ANI-004: a compose slot whose binding names no extracted component -/// — neither in the composing file nor through its imports — no longer -/// disappears silently. The slot still drops from the composed variant CSS -/// (existing behavior); only the diagnostic is new. Under the retired -/// bare-name scheme this case could also resolve to the WRONG component when -/// two files shared a local recipe name. +/// A compose slot whose binding names no extracted component — neither in +/// the composing file nor through its imports — no longer disappears +/// silently. The slot still drops from the composed variant CSS (existing +/// behavior); only the diagnostic is new. Under the retired bare-name +/// scheme this case could also resolve to the WRONG component when two +/// files shared a local recipe name. fn emit_compose_slot_bail( diagnostics: &mut Vec, file: &str, @@ -1001,6 +1038,8 @@ fn emit_compose_slot_bail( component in this file or through its imports — composed variant CSS dropped", slot_name, binding ), + code: None, + severity: None, }); } @@ -1488,6 +1527,8 @@ fn run_with_system_floor( component: format!("createTransform('{}')", t.name), kind: "warn".to_string(), message: format!("Failed to register transform in evaluator: {}", err), + code: None, + severity: None, }); } } @@ -1508,6 +1549,8 @@ fn run_with_system_floor( component: format!("createTransform('{}')", t.name), kind: "bail".to_string(), message: diag.clone(), + code: None, + severity: None, }); } } @@ -1539,6 +1582,8 @@ fn run_with_system_floor( component: d.binding.clone(), kind: "bail".to_string(), message: reason.clone(), + code: None, + severity: None, }); } continue; @@ -1566,6 +1611,8 @@ fn run_with_system_floor( component: d.binding.clone(), kind: "bail".to_string(), message: reason, + code: None, + severity: None, }); unresolvable_extensions.insert(component_id); } @@ -1643,12 +1690,18 @@ fn run_with_system_floor( let active_group_names = out.active_group_names; let custom_configs = out.custom_prop_configs; for warning in &out.skip_warnings { + let code = diagnostic_code_from_message(warning); + let severity = code + .as_deref() + .map(|c| diagnostic_severity_for_code(c).to_string()); diagnostics.push(CssDiagnostic { token: None, file: file_path.to_string(), component: chain.descriptor.binding.clone(), kind: "skip".to_string(), message: warning.clone(), + code, + severity, }); } @@ -1733,16 +1786,19 @@ fn run_with_system_floor( } } - // Extension merge: start from the parent's condition - // groups, then let the child's groups replace-by-key. The - // legacy two-bucket bug-compat only ever licensed dropping - // the child's SELECTOR-BEARING groups (nested selectors are - // inc 05); the child's selectorless breakpoint AND - // non-breakpoint (Media/Container/Supports) groups both - // carry through — breakpoints by name, conditions by - // (conditions, selector). Byte-safe: pre-inc-03 fixtures - // have no non-breakpoint groups, so this loop is a no-op - // for them (G1). + // Extension merge: start from the parent's + // condition groups, then let the child's + // groups replace-by-key. The legacy + // two-bucket bug-compat only ever licensed + // dropping the child's SELECTOR-BEARING + // groups; the child's selectorless + // breakpoint AND non-breakpoint + // (Media/Container/Supports) groups both + // carry through — breakpoints by name, + // conditions by (conditions, selector). + // Byte-safe for fixtures that declare no + // non-breakpoint groups: the loop is a + // no-op for them. let mut merged = ResolvedStyles { declarations: merged_decls, pseudo_selectors: merged_pseudos, @@ -1753,10 +1809,11 @@ fn run_with_system_floor( *slot = decls.clone(); } for child_group in &child_base.conditioned { - // Selectorless single-breakpoint groups merged via - // breakpoint_decls_mut above; every other shape — - // incl. [Breakpoint]+selector (inc 05 review F2) — - // replaces-by-(conditions, selector) or appends. + // Selectorless single-breakpoint groups + // merged via breakpoint_decls_mut + // above; every other shape — incl. + // [Breakpoint]+selector — replaces-by- + // (conditions, selector) or appends. let plain_breakpoint = matches!( child_group.emit_order, crate::theme::ConditionEmitOrder::Breakpoint @@ -1809,9 +1866,9 @@ fn run_with_system_floor( // v1 908-913: inherit compound configs, parent first. // - // ANI-008: the inherited entries still carry the - // PARENT's class prefix and their original indices, - // while the emitter enumerates the merged + // The inherited entries still carry the PARENT's + // class prefix and their original indices, while + // the emitter enumerates the merged // `component_css.compounds` positionally under the // CHILD's class (css.rs `generate_css_sheets_ordered` // / `generate_layer_content`). Renumbering the whole @@ -1972,7 +2029,7 @@ fn run_with_system_floor( // Families carry the path of the file whose compose() call declared them. // Slot names are the compose callsite's LOCAL identifiers, so the owning - // file is what turns them into qualified component ids (ANI-004). + // file is what turns them into qualified component ids. let mut compose_families: Vec<(&String, &ComposeFamilyInfo)> = Vec::new(); for path in order { if let Some(ff) = files.get(path) { @@ -2693,9 +2750,9 @@ fn run_with_system_floor( let mut composed_variant_css = String::new(); let mut composed_compound_css = String::new(); if !compose_families.is_empty() { - // Keyed by component_id, not by bare binding: two files may define the - // same local recipe name (ANI-004), and a bare-name map let whichever - // one hashed last win for every family in the universe. + // Keyed by component_id, not by bare binding: two files may define + // the same local recipe name, and a bare-name map let whichever one + // hashed last win for every family in the universe. let id_to_class: FxHashMap<&str, &str> = evaluated .iter() .map(|(id, (css, _, _, _, _, _, _))| (id.as_str(), css.class_name.as_str())) @@ -2930,7 +2987,7 @@ fn run_with_system_floor( let mut reverse_provenance: BTreeMap> = BTreeMap::new(); for component_id in &sorted_ids { // v1 builds provenance only for EVALUATED survivors (Phase 7 - // components_map gate; inc-07 review F8). + // components_map gate). if !evaluated.contains_key(component_id) { continue; } @@ -3301,9 +3358,8 @@ mod tests { #[test] fn unresolvable_alias_declaration_dropped_with_warn_diagnostic() { - // extract-quirk-shed inc 01: raw `{scale.path}` leaks are shed, not - // emitted; each dropped declaration gets a warn naming component, - // property, and alias. + // Raw `{scale.path}` leaks are shed, not emitted; each dropped + // declaration gets a warn naming component, property, and alias. let out = analyze( &[( "a.tsx", @@ -3739,8 +3795,8 @@ mod tests { #[test] fn brace_leak_shed_still_wins_over_token_warn() { - // A `{...}` leak is still DROPPED (inc 01 behavior is untouched) and - // reports exactly one warn, not two. + // A `{...}` leak is still DROPPED (the shed behavior is untouched) + // and reports exactly one warn, not two. let out = analyze( &[( "a.tsx", @@ -3760,10 +3816,10 @@ mod tests { #[test] fn serde_rejected_props_chain_emits_bail_diagnostic() { - // extract-quirk-shed inc 02: a props() config that evaluates - // statically but fails PropConfigMap deserialization no longer - // vanishes silently — a bail names file, binding, and stage. - // Mirrors packages/_parity/corpus/props-serde-reject.tsx. + // A props() config that evaluates statically but fails + // PropConfigMap deserialization no longer vanishes silently — a + // bail names file, binding, and stage. Mirrors + // packages/_parity/corpus/props-serde-reject.tsx. let out = analyze( &[( "a.tsx", @@ -3791,9 +3847,9 @@ mod tests { #[test] fn fatal_stage_eval_error_emits_bail_diagnostic() { - // extract-quirk-shed inc 02, fatal_error leg: a stage whose - // evaluation failed at fact extraction (chain-fatal in v1 via `?`) - // also bails loud with the failing stage named. + // fatal_error leg: a stage whose evaluation failed at fact + // extraction (chain-fatal in v1 via `?`) also bails loud with the + // failing stage named. let out = analyze( &[( "a.tsx", @@ -4148,32 +4204,93 @@ mod tests { } #[test] - fn identifier_variant_map_surfaces_a_skip_diagnostic() { - // `variants: ` produced options:[] with a surviving - // default and ZERO diagnostics — an emitted class carrying no CSS and - // no witness (per-property-bail spec: every skipped property SHALL - // warn). Outcomes are unchanged; what is now guaranteed is that a - // variant map the parser could not READ — a non-object value, or a - // spread inside the object — always leaves a skip behind. An absent - // or genuinely empty `variants` is not a loss and records nothing. + fn identifier_variant_map_resolves_through_statics() { + // Intentional departure from v1 parity (semantic-const-resolution, + // variant stage): `variants: ` bound to a top-level + // const resolves through the same extraction-time statics as + // `.styles()` arguments — the manifest is identical to inlining the + // literal, with zero skips. (v1 was statics-blind here: options:[] + // + a surviving default.) let out = analyze( &[( "a.tsx", - "const sizes = { sm: { p: 8 } };\nexport const Button = ds.styles({ display: 'flex' }).variant({ prop: 'size', defaultVariant: 'sm', variants: sizes }).asElement('button');\nexport const App = () =>