From 3f80c07548c67a9cb353052e6670c031beddbbdf Mon Sep 17 00:00:00 2001 From: codecaaron Date: Wed, 22 Jul 2026 01:02:58 -0400 Subject: [PATCH 1/6] Cool --- .vscode/extensions.json | 4 +- AGENTS.md | 61 +- bun.lock | 2 +- e2e/next-app/scripts/assert-build.ts | 25 +- e2e/react-router-app/scripts/assert-build.ts | 23 +- e2e/vinext-app/scripts/assert-build.ts | 23 +- .../specs/arch-workspace-topology/spec.md | 39 + openspec/specs/code-hygiene/spec.md | 17 +- .../specs/e2e-workspace-convention/spec.md | 12 +- .../packed-consumer-verification/spec.md | 46 +- .../react-router-extraction-canary/spec.md | 17 +- openspec/specs/release-workflow/spec.md | 31 +- .../specs/verification-tier-policy/spec.md | 56 ++ .../specs/vinext-extraction-canary/spec.md | 17 +- .../__tests__/served-client-css.test.ts | 73 ++ packages/_assertions/src/find-build-assets.ts | 24 + .../__tests__/mdx-preprocessing.test.ts | 116 +++ packages/extract/package.json | 2 +- packages/extract/pipeline/content-hash.ts | 10 + .../extract/pipeline/discover-packages.ts | 174 +++- packages/extract/pipeline/index.ts | 9 +- packages/extract/pipeline/path-aliases.ts | 61 ++ packages/extract/pipeline/timing-waterfall.ts | 45 ++ .../tests/collect-external-packages.test.ts | 201 +++++ packages/extract/tests/path-aliases.test.ts | 58 ++ .../extract/tests/timing-waterfall.test.ts | 30 + .../next-plugin/src/analyze-project-args.ts | 30 - packages/next-plugin/src/loader.ts | 32 +- packages/next-plugin/src/plugin.ts | 414 +++++----- packages/next-plugin/src/types.ts | 18 +- packages/next-plugin/src/with-animus.ts | 43 +- .../tests/analyze-project-args.test.ts | 10 +- .../tests/loader-css-import.test.ts | 108 +++ .../next-plugin/tests/plugin-pipeline.test.ts | 49 +- .../next-plugin/tests/with-animus.test.ts | 21 +- packages/system/__tests__/transforms.test.ts | 167 ++++ packages/vite-plugin/src/index.ts | 233 ++---- scripts/hygiene/CLAUDE.md | 6 +- scripts/hygiene/presenter.test.ts | 98 ++- scripts/hygiene/presenter.ts | 132 ++- scripts/hygiene/reconcile-after-knip.ts | 26 +- scripts/verify/_preconditions.sh | 15 +- scripts/verify/attw-def5.test.ts | 139 ++++ scripts/verify/attw-def5.ts | 265 ++++++ scripts/verify/clippy.sh | 11 + scripts/verify/coverage-e2e.sh | 11 +- scripts/verify/hygiene-rust.sh | 11 + scripts/verify/napi-target.test.ts | 125 +++ scripts/verify/napi-target.ts | 110 +++ scripts/verify/owner-graph.test.ts | 1 + scripts/verify/packed.sh | 30 +- scripts/verify/preconditions.test.ts | 30 +- scripts/verify/rust-policy.test.ts | 132 +++ scripts/verify/rust-policy.ts | 235 ++++++ scripts/verify/topology.test.ts | 668 +++++++++++++++ scripts/verify/topology.ts | 761 ++++++++++++++++++ vite.config.ts | 19 +- 57 files changed, 4494 insertions(+), 632 deletions(-) create mode 100644 openspec/specs/arch-workspace-topology/spec.md create mode 100644 packages/_assertions/__tests__/served-client-css.test.ts create mode 100644 packages/_integration/__tests__/mdx-preprocessing.test.ts create mode 100644 packages/extract/pipeline/content-hash.ts create mode 100644 packages/extract/pipeline/path-aliases.ts create mode 100644 packages/extract/pipeline/timing-waterfall.ts create mode 100644 packages/extract/tests/collect-external-packages.test.ts create mode 100644 packages/extract/tests/path-aliases.test.ts create mode 100644 packages/extract/tests/timing-waterfall.test.ts delete mode 100644 packages/next-plugin/src/analyze-project-args.ts create mode 100644 packages/next-plugin/tests/loader-css-import.test.ts create mode 100644 packages/system/__tests__/transforms.test.ts create mode 100644 scripts/verify/attw-def5.test.ts create mode 100644 scripts/verify/attw-def5.ts create mode 100644 scripts/verify/napi-target.test.ts create mode 100644 scripts/verify/napi-target.ts create mode 100644 scripts/verify/rust-policy.test.ts create mode 100644 scripts/verify/rust-policy.ts create mode 100644 scripts/verify/topology.test.ts create mode 100644 scripts/verify/topology.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 4fe5b682..4077b13a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,5 @@ { - "recommendations": ["repowise-dev.repowise"] + "recommendations": [ + "repowise-dev.repowise" + ] } diff --git a/AGENTS.md b/AGENTS.md index e49955cf..c84bc796 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Dependencies flow top-down (consumer direction): Assertion utilities that need to be importable by both `packages/*` post-build scripts and `e2e/*` fixtures live in `packages/_assertions/` (a `packages/`-resident private package), so imports always flow top-down without crossing the `packages/ ← e2e/` boundary. -No automated enforcement yet — candidate for a future CI grep or lint rule. +Enforced by `scripts/verify/topology.ts` (run in `vp run verify:lint`), which fails loud across all three vectors — source imports (relative paths and e2e workspace-name specifiers), tsconfig `paths` aliases, and `packages/* → e2e/*` `package.json` dependencies — listing every offending file. ## Monorepo Build System @@ -58,35 +58,35 @@ Every workspace-filtered command must include `--fail-if-no-match`. Do not combi Atomic diagnostics are narrow, actionable leaves. They never materialize missing upstream artifacts. If one fails with `ERROR: X missing` or a `PREPARE:` line, run the exact remediation it prints, then rerun the diagnostic. Owner claims follow the same fail-loud rule and do not rebuild shared Rust or TS prerequisites. -| Command | What it covers | Upstream requires | Fails loud when | Typical runtime | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------- | -| `vp run verify:lint` | `vp lint` + `vp fmt --check` (oxlint + oxfmt) | `bun install` | lint rule violation or formatter drift | fast | -| `vp run verify:compile` | `tsgo --noEmit` across all packages | `bun install` | type error in any package `src/` | fast | -| `vp run verify:types` | type-contract tests via `tsconfig.test-d.json` | `bun install` | compile-time contract assertion fails | medium | -| `vp run verify:unit:rust` | `cargo test --lib` across the shared loader and v2 crates (debug profile) | Rust toolchain | Rust unit test fails | medium | -| `vp run verify:clippy` | `cargo clippy --workspace --all-targets --all-features -- -D warnings` across all active Rust crates | Rust toolchain with Clippy | any Rust or Clippy warning | medium | -| `vp run verify:unit:ts` | `bunx vp test run` (Vitest) on `system/__tests__`, `vite-plugin/tests`, `next-plugin/tests`, `properties/__tests__`, `_assertions/__tests__`, `_parity/__tests__` | `bun install` | TS unit test fails | fast | -| `vp run verify:coverage:ts` | aggregate per-file V8 coverage for the same TS targets as `verify:unit:ts`; emits text and lcov reports under `coverage/ts` (not per-test impact mapping) | `bun install` | TS unit test or coverage report generation fails | medium | -| `vp run verify:coverage:e2e` | V8 line coverage of `packages/*/src` code exercised by the next-app and vite-app production build+assert lanes; rebuilds TS dists with sourcemaps, emits `coverage/e2e/lcov.info`, then restores sourcemap-free dists | fresh v2 NAPI (fails loud with `PREPARE:`) | NAPI missing/stale, consumer build/assert fails, or report generation fails | slow | -| `vp run verify:workers:contracts` | structural, Worker behavior, and hydration contracts for all four Worker fixtures without production builds | `bun install` | root command/task contract or fixture test fails | fast | -| `vp run verify:hygiene:rust` | `cargo machete` dep-hygiene check across all active extraction Rust crates | `cargo-machete` binary on PATH | unused dep found (or machete missing) | fast | -| `vp run verify:canary` | v2 NAPI boundary snapshot tests | fresh v2 NAPI `.node` binary (mtime > Rust src) | NAPI binary missing or stale | medium | -| `vp run verify:parity` | v2 fresh-process/thread self-check, seam battery, and committed production/development oracle | fresh v2 NAPI `.node` + committed parity baselines | v2 input/baseline missing, stale, or divergent | medium | -| `vp run verify:integration` | full pipeline E2E in `packages/_integration/__tests__` | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` | NAPI or any upstream dist missing/stale | medium | -| `vp run @animus-ui/next-app#verify:build` | Next consumer fixture build | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` + fresh `next-plugin/dist/` | NAPI or any upstream dist missing/stale | slow | -| `vp run @animus-ui/showcase#verify:build` | showcase vite build | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` + fresh `vite-plugin/dist/` + fresh `properties/dist/` | NAPI or any upstream dist missing/stale | slow | -| `vp run @animus-ui/vite-app#verify:build` | Vite consumer fixture build (`e2e/vite-app`) | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` + fresh `vite-plugin/dist/` + fresh `properties/dist/` | NAPI or any upstream dist missing/stale | slow | -| `vp run @animus-ui/vinext-app#verify:build` | Vinext App+Pages Worker production build | fresh v2 NAPI + fresh `extract/dist/`, `system/dist/`, `vite-plugin/dist/`, `properties/dist/`, and `test-ds/dist/` | NAPI or any upstream dist missing/stale | slow | -| `vp run @animus-ui/react-router-app#verify:build` | React Router v8 SSR Worker production build | fresh v2 NAPI + fresh `extract/dist/`, `system/dist/`, `vite-plugin/dist/`, `properties/dist/`, and `test-ds/dist/` | NAPI or any upstream dist missing/stale | slow | -| `vp run @animus-ui/next-app#verify:assert` | positional assertions on Next build output (TS, via `@animus-ui/assertions`) | `e2e/next-app/.next/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | -| `vp run @animus-ui/showcase#verify:assert` | positional assertions on showcase dist (TS, via `@animus-ui/assertions`) | `packages/showcase/dist/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | -| `vp run @animus-ui/vite-app#verify:assert` | positional assertions on Vite fixture dist (TS, via `@animus-ui/assertions`) | `e2e/vite-app/dist/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | -| `vp run @animus-ui/vinext-app#verify:assert` | structural assertions on Vinext Worker output (TS, via `@animus-ui/assertions`) | `e2e/vinext-app/dist/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | -| `vp run @animus-ui/react-router-app#verify:assert` | structural assertions on React Router Worker output (TS, via `@animus-ui/assertions`) | `e2e/react-router-app/build/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | -| `vp run @animus-ui/showcase#verify:dry-run` | credential-free Wrangler validation for Worker `animus` | `packages/showcase/dist/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | -| `vp run @animus-ui/vite-app#verify:dry-run` | credential-free Wrangler validation for Worker `animus-vite-canary` | `e2e/vite-app/dist/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | -| `vp run @animus-ui/vinext-app#verify:dry-run` | credential-free Wrangler validation for Worker `animus-vinext-canary` | `e2e/vinext-app/dist/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | -| `vp run @animus-ui/react-router-app#verify:dry-run` | credential-free Wrangler validation for Worker `animus-react-router-canary` | `e2e/react-router-app/build/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | +| Command | What it covers | Upstream requires | Fails loud when | Typical runtime | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------- | +| `vp run verify:lint` | `vp lint` + `vp fmt --check` (oxlint + oxfmt) + the workspace-topology boundary scan (`scripts/verify/topology.ts`) | `bun install` | lint rule violation, formatter drift, or a forbidden cross-boundary dependency | fast | +| `vp run verify:compile` | `tsgo --noEmit` across all packages | `bun install` | type error in any package `src/` | fast | +| `vp run verify:types` | type-contract tests via `tsconfig.test-d.json` | `bun install` | compile-time contract assertion fails | medium | +| `vp run verify:unit:rust` | `cargo test --lib` across the shared loader and v2 crates (debug profile) | Rust toolchain | Rust unit test fails | medium | +| `vp run verify:clippy` | `cargo clippy --workspace --all-targets --all-features -- -D warnings` across all active Rust crates | Rust toolchain with Clippy | any Rust or Clippy warning | medium | +| `vp run verify:unit:ts` | `bunx vp test run` (Vitest) on `system/__tests__`, `vite-plugin/tests`, `next-plugin/tests`, `properties/__tests__`, `_assertions/__tests__`, `_parity/__tests__` | `bun install` | TS unit test fails | fast | +| `vp run verify:coverage:ts` | aggregate per-file V8 coverage for the same TS targets as `verify:unit:ts`; emits text and lcov reports under `coverage/ts` (not per-test impact mapping) | `bun install` | TS unit test or coverage report generation fails | medium | +| `vp run verify:coverage:e2e` | V8 line coverage of `packages/*/src` code exercised by the next-app and vite-app production build+assert lanes; rebuilds TS dists with sourcemaps, emits `coverage/e2e/lcov.info`, then restores sourcemap-free dists | fresh v2 NAPI (fails loud with `PREPARE:`) | NAPI missing/stale, consumer build/assert fails, or report generation fails | slow | +| `vp run verify:workers:contracts` | structural, Worker behavior, and hydration contracts for all four Worker fixtures without production builds | `bun install` | root command/task contract or fixture test fails | fast | +| `vp run verify:hygiene:rust` | `cargo machete` dep-hygiene check across all active extraction Rust crates | `cargo-machete` binary on PATH | unused dep found (or machete missing) | fast | +| `vp run verify:canary` | v2 NAPI boundary snapshot tests | fresh v2 NAPI `.node` binary (mtime > Rust src) | NAPI binary missing or stale | medium | +| `vp run verify:parity` | v2 fresh-process/thread self-check, seam battery, and committed production/development oracle | fresh v2 NAPI `.node` + committed parity baselines | v2 input/baseline missing, stale, or divergent | medium | +| `vp run verify:integration` | full pipeline E2E in `packages/_integration/__tests__` | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` | NAPI or any upstream dist missing/stale | medium | +| `vp run @animus-ui/next-app#verify:build` | Next consumer fixture build | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` + fresh `next-plugin/dist/` | NAPI or any upstream dist missing/stale | slow | +| `vp run @animus-ui/showcase#verify:build` | showcase vite build | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` + fresh `vite-plugin/dist/` + fresh `properties/dist/` | NAPI or any upstream dist missing/stale | slow | +| `vp run @animus-ui/vite-app#verify:build` | Vite consumer fixture build (`e2e/vite-app`) | fresh NAPI + fresh `extract/dist/` + fresh `system/dist/` + fresh `vite-plugin/dist/` + fresh `properties/dist/` | NAPI or any upstream dist missing/stale | slow | +| `vp run @animus-ui/vinext-app#verify:build` | Vinext App+Pages Worker production build | fresh v2 NAPI + fresh `extract/dist/`, `system/dist/`, `vite-plugin/dist/`, `properties/dist/`, and `test-ds/dist/` | NAPI or any upstream dist missing/stale | slow | +| `vp run @animus-ui/react-router-app#verify:build` | React Router v8 SSR Worker production build | fresh v2 NAPI + fresh `extract/dist/`, `system/dist/`, `vite-plugin/dist/`, `properties/dist/`, and `test-ds/dist/` | NAPI or any upstream dist missing/stale | slow | +| `vp run @animus-ui/next-app#verify:assert` | positional assertions on Next build output (TS, via `@animus-ui/assertions`) | `e2e/next-app/.next/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | +| `vp run @animus-ui/showcase#verify:assert` | positional assertions on showcase dist (TS, via `@animus-ui/assertions`) | `packages/showcase/dist/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | +| `vp run @animus-ui/vite-app#verify:assert` | positional assertions on Vite fixture dist (TS, via `@animus-ui/assertions`) | `e2e/vite-app/dist/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | +| `vp run @animus-ui/vinext-app#verify:assert` | structural assertions on Vinext Worker output (TS, via `@animus-ui/assertions`) | `e2e/vinext-app/dist/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | +| `vp run @animus-ui/react-router-app#verify:assert` | structural assertions on React Router Worker output (TS, via `@animus-ui/assertions`) | `e2e/react-router-app/build/` + fresh `_assertions/dist/` | build output missing or assertions dist stale | fast | +| `vp run @animus-ui/showcase#verify:dry-run` | credential-free Wrangler validation for Worker `animus` | `packages/showcase/dist/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | +| `vp run @animus-ui/vite-app#verify:dry-run` | credential-free Wrangler validation for Worker `animus-vite-canary` | `e2e/vite-app/dist/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | +| `vp run @animus-ui/vinext-app#verify:dry-run` | credential-free Wrangler validation for Worker `animus-vinext-canary` | `e2e/vinext-app/dist/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | +| `vp run @animus-ui/react-router-app#verify:dry-run` | credential-free Wrangler validation for Worker `animus-react-router-canary` | `e2e/react-router-app/build/` + matching app-owned Worker config | build output missing, Worker identity mismatch, or Wrangler rejects output | fast | For domain-specific failure guidance, drill into `packages//AGENTS.md` after selecting a workflow or diagnostic here. @@ -117,6 +117,7 @@ This map routes an edit to the smallest sufficient claim plus any source-owned d | `packages/_integration/__tests__/**` | `vp run verify:integration` | | `packages/test-ds/src/**` | `vp run verify:unit:ts && vp run --fail-if-no-match -F '...@animus-ui/test-ds' verify` | | `e2e/packed-app/**` or `scripts/verify/packed.sh` | `vp run verify:packed` | +| `scripts/verify/topology.*` | `bunx vp test run scripts/verify/topology.test.ts && vp run verify:lint` | | `packages/{properties,system,extract,vite-plugin,next-plugin}/package.json` (deps, peers, exports, files) | `vp run verify:packed` | | `.github/workflows/ci.yaml`, `scripts/**`, `.tool-versions` | `vp run verify:full` | | Worker orchestration (`vite.config.ts`, `scripts/verify/**`, root deploy scripts, Worker ignores) | `vp run verify:full` | diff --git a/bun.lock b/bun.lock index 2524d972..2c1ca6e5 100644 --- a/bun.lock +++ b/bun.lock @@ -132,7 +132,7 @@ }, "devDependencies": { "@animus-ui/system": "workspace:*", - "@mdx-js/mdx": "^3.0.0", + "@mdx-js/mdx": "3.1.1", "@napi-rs/cli": "^3.7.3", }, "peerDependencies": { diff --git a/e2e/next-app/scripts/assert-build.ts b/e2e/next-app/scripts/assert-build.ts index 92ef67e2..33914b87 100644 --- a/e2e/next-app/scripts/assert-build.ts +++ b/e2e/next-app/scripts/assert-build.ts @@ -17,7 +17,6 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const REPO_ROOT = resolve(APP_ROOT, '..', '..'); const NEXT_DIR = resolve(APP_ROOT, '.next'); const STATIC_JS = resolve(NEXT_DIR, 'static'); @@ -44,26 +43,12 @@ function emitLaneReceipt(): void { ); } - // Default engine is still MEASURED from the workspace plugin source so a - // future default change is reflected without touching this script. Symbol: - // `getSharedEngine()` fallback `... || 'v2'` in - // packages/next-plugin/src/singleton.ts. - const pluginSrc = readFileSync( - resolve(REPO_ROOT, 'packages', 'next-plugin', 'src', 'singleton.ts'), - 'utf8' - ); - const defaultMatch = pluginSrc.match(/\|\|\s*['"](v[12])['"]/); - if (!defaultMatch) { - throw new AssertionError( - 'Cannot determine default engine from packages/next-plugin/src/singleton.ts — update the receipt probe' - ); - } - const engineDefault = defaultMatch[1] as 'v2'; - - // v1 is retired: no override path remains, so the loaded engine is always the - // workspace default and no ANIMUS_ENGINE override is possible. + // v1 is retired (openspec: retire-extract-v1): v2 is the only engine, so the + // receipt records v2 as both default and loaded, with no override. Engine + // identity is never inferred from plugin/config source (guardrail G3). + const engineDefault = 'v2' as const; + const engineLoaded = 'v2' as const; const engineOverride = false; - const engineLoaded: 'v2' = engineDefault; // hostVersion from the fixture's installed host, not the manifest range. const hostVersion = ( diff --git a/e2e/react-router-app/scripts/assert-build.ts b/e2e/react-router-app/scripts/assert-build.ts index 5e25dfc8..749aa0da 100644 --- a/e2e/react-router-app/scripts/assert-build.ts +++ b/e2e/react-router-app/scripts/assert-build.ts @@ -4,30 +4,37 @@ import { assertLayerOrder, assertNoEmotionImports, assertNoPlaceholders, - findCssFiles, findJsFiles, layerBlock, readAllConcat, + readRequiredCss, } from '@animus-ui/assertions'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; const BUILD = resolve(import.meta.dirname, '..', 'build'); +// Wrangler serves build/client; semantic CSS must be proven there independently +// from build/server (canary delta: React Router served-client CSS proof). +const CLIENT_ROOT = resolve(BUILD, 'client'); async function main(): Promise { - const cssFiles = await findCssFiles(BUILD); - if (cssFiles.length === 0) - throw new AssertionError(`No CSS file found under ${BUILD}`); - const css = await readAllConcat(cssFiles); + const css = await readRequiredCss( + CLIENT_ROOT, + 'react-router served-client CSS (build/client)' + ); assertLayerOrder(css, { layers: [layerBlock('anm-base'), layerBlock('anm-variants')], }); if (!css.includes(':root')) - throw new AssertionError('Expected a :root variable block'); + throw new AssertionError( + 'Expected a :root variable block in React Router served-client CSS (build/client)' + ); assertNoPlaceholders(css); + assertClassNameFormat(css, { prefix: 'animus-' }); + + // JS/hydration discovery keeps its own scope over the whole build root. const jsFiles = await findJsFiles(BUILD); const js = await readAllConcat(jsFiles); - assertClassNameFormat(`${css}\n${js}`, { prefix: 'animus-' }); if (!js.includes('React Router v8 SSR canary')) throw new AssertionError('SSR marker missing'); if (!js.includes('React Router v8 client canary')) @@ -35,7 +42,7 @@ async function main(): Promise { for (const file of jsFiles) assertNoEmotionImports(await readFile(file, 'utf8')); console.log( - `[react-router-app:assert] ${cssFiles.length} CSS, ${jsFiles.length} JS — all assertions passed` + `[react-router-app:assert] served-client CSS (build/client) + ${jsFiles.length} JS — all assertions passed` ); } diff --git a/e2e/vinext-app/scripts/assert-build.ts b/e2e/vinext-app/scripts/assert-build.ts index 3920a788..4237af69 100644 --- a/e2e/vinext-app/scripts/assert-build.ts +++ b/e2e/vinext-app/scripts/assert-build.ts @@ -4,33 +4,38 @@ import { assertLayerOrder, assertNoEmotionImports, assertNoPlaceholders, - findCssFiles, findJsFiles, layerBlock, readAllConcat, + readRequiredCss, } from '@animus-ui/assertions'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; const DIST = resolve(import.meta.dirname, '..', 'dist'); +// Wrangler serves dist/client; semantic CSS must be proven there independently +// from dist/server (canary delta: Vinext served-client CSS proof). +const CLIENT_ROOT = resolve(DIST, 'client'); async function main(): Promise { - const cssFiles = await findCssFiles(DIST); - if (cssFiles.length === 0) { - throw new AssertionError(`No CSS file found under ${DIST}`); - } - const css = await readAllConcat(cssFiles); + const css = await readRequiredCss( + CLIENT_ROOT, + 'vinext served-client CSS (dist/client)' + ); assertLayerOrder(css, { layers: [layerBlock('anm-base'), layerBlock('anm-variants')], }); if (!css.includes(':root')) { - throw new AssertionError('Expected a :root variable block in Vinext CSS'); + throw new AssertionError( + 'Expected a :root variable block in Vinext served-client CSS (dist/client)' + ); } assertNoPlaceholders(css); + assertClassNameFormat(css, { prefix: 'animus-' }); + // JS/hydration discovery keeps its own scope over the whole build root. const jsFiles = await findJsFiles(DIST); const js = await readAllConcat(jsFiles); - assertClassNameFormat(`${css}\n${js}`, { prefix: 'animus-' }); if (!js.includes('Vinext RSC canary')) { throw new AssertionError('App Router RSC marker missing from build'); } @@ -42,7 +47,7 @@ async function main(): Promise { } console.log( - `[vinext-app:assert] ${cssFiles.length} CSS file(s), ${jsFiles.length} JS file(s), App+Pages routers present — all assertions passed` + `[vinext-app:assert] served-client CSS (dist/client) + ${jsFiles.length} JS file(s), App+Pages routers present — all assertions passed` ); } diff --git a/openspec/specs/arch-workspace-topology/spec.md b/openspec/specs/arch-workspace-topology/spec.md new file mode 100644 index 00000000..a1cbe9d1 --- /dev/null +++ b/openspec/specs/arch-workspace-topology/spec.md @@ -0,0 +1,39 @@ +# arch-workspace-topology Specification + +## Purpose +TBD - created by archiving change enforce-workspace-topology. Update Purpose after archive. +## Requirements +### Requirement: Workspace dependency boundaries have an executable check + +The repository SHALL provide a named executable check that rejects source imports, +TypeScript path aliases, and workspace package dependencies that violate the documented +dependency direction among `packages/`, `e2e/`, and `legacy/`. + +#### Scenario: Forbidden package-to-e2e dependency fails + +- **WHEN** a package source file, TypeScript alias, or workspace dependency points from `packages/*` to `e2e/*` +- **THEN** the workspace-topology check SHALL exit non-zero +- **AND** its output SHALL identify the offending file + +#### Scenario: Active code cannot depend on legacy + +- **WHEN** source under `packages/*` or `e2e/*` references a path under `legacy/*` +- **THEN** the workspace-topology check SHALL exit non-zero +- **AND** its output SHALL identify the offending file + +#### Scenario: Current dependency direction passes + +- **WHEN** `e2e/*` consumes `packages/*` without any reverse or legacy dependency +- **THEN** the workspace-topology check SHALL exit zero + +### Requirement: Workspace topology enforcement joins repository verification + +The workspace-topology check SHALL be reachable through a named repository verification +command and SHALL fail loud without silently repairing or rebuilding inputs. + +#### Scenario: Maintainer runs the topology verifier + +- **WHEN** a maintainer invokes the named verification command +- **THEN** it SHALL run the workspace boundary scan +- **AND** any violation SHALL make the command exit non-zero with an actionable path list + diff --git a/openspec/specs/code-hygiene/spec.md b/openspec/specs/code-hygiene/spec.md index f605e244..00a04a60 100644 --- a/openspec/specs/code-hygiene/spec.md +++ b/openspec/specs/code-hygiene/spec.md @@ -3,9 +3,7 @@ ## Purpose TBD - created by archiving change add-code-hygiene-protocol. Update Purpose after archive. - ## Requirements - ### Requirement: Single flag-driven hygiene entrypoint The repository SHALL provide a single code-hygiene entrypoint invokable as `bun run hygiene` (or equivalently `bash scripts/hygiene/run.sh`) that accepts `--mode`, `--scope`, `--base`, and `--iterations` flags. The entrypoint SHALL be usable identically by humans and by agents through the Bash tool — no agent-specific wrapper SHALL be required. @@ -435,3 +433,18 @@ The end-of-work-only contract is invariant under any dispatch surface — `vp ru - **WHEN** the repo's `.github/workflows/*.yaml` files are inspected - **THEN** no step invokes `vp run hygiene`, `bun run hygiene`, or `bash scripts/hygiene/run.sh` - **AND** the end-of-work-only contract holds regardless of which dispatch surface a workflow author might attempt + +### Requirement: Risky file deletion blocks completion + +The hygiene orchestrator SHALL exit non-zero for a run containing a Layer D whole-file deletion unless the same run records the required behavior-build proof. + +#### Scenario: Whole file is deleted without build proof + +- **WHEN** the receipt stream contains `layer="D"`, `verb="delete"`, and `kind="file"` and no behavior-build proof is recorded +- **THEN** the verdict requires manual review and the orchestrator exits non-zero after its safety envelope + +#### Scenario: Export-only cleanup completes + +- **WHEN** the receipt stream contains no whole-file deletion and the cascade converges with a passing safety envelope +- **THEN** the whole-file deletion policy does not change the successful exit status + diff --git a/openspec/specs/e2e-workspace-convention/spec.md b/openspec/specs/e2e-workspace-convention/spec.md index 7b24c483..0afeb6d0 100644 --- a/openspec/specs/e2e-workspace-convention/spec.md +++ b/openspec/specs/e2e-workspace-convention/spec.md @@ -1,9 +1,7 @@ ## Purpose Defines the repository's `e2e/` directory convention: a top-level sibling to `packages/` and `legacy/` that holds consumer fixture applications whose test surface is a whole built app asserted against positional output. This capability separates three purposes that were previously conflated under `packages/`: what ships (publishable libraries), what verifies the pipeline (deep-internal private packages), and what proves consumer-bundler behavior (fixture apps). It also establishes a one-way dependency rule across the three top-level code directories (`packages/`, `e2e/`, `legacy/`) that preserves the publish boundary and keeps imports flowing in a single, predictable direction. - ## Requirements - ### Requirement: e2e Directory Location The repository SHALL contain an `e2e/` directory at the repository root, sibling to `packages/`, `legacy/`, `scripts/`, and `openspec/`. The `e2e/` directory SHALL hold consumer fixture applications whose test surface is a whole built app asserted against positional output. @@ -64,7 +62,7 @@ The naming convention avoids redundancy with the `e2e/` parent (no `e2e/next-tes Source code in `e2e/*` MAY import from `packages/*`. Source code in `packages/*` MUST NOT import from `e2e/*`. Source code in `packages/*` and `e2e/*` MUST NOT import from `legacy/*`. -This rule mirrors the publish boundary: `packages/*` is the publish graph (plus deep-internal private packages); `e2e/*` is downstream verification infrastructure. Dependencies flow top-down (consumer direction). +This rule mirrors the publish boundary: `packages/*` is the publish graph (plus deep-internal private packages); `e2e/*` is downstream verification infrastructure. Dependencies flow top-down (consumer direction). A named executable workspace-topology check SHALL enforce the rule across source imports, TypeScript path aliases, and workspace package dependencies. #### Scenario: e2e imports packages @@ -74,13 +72,14 @@ This rule mirrors the publish boundary: `packages/*` is the publish graph (plus #### Scenario: packages does NOT import e2e - **WHEN** a file in `packages/system/src/` attempts to import from `e2e/next-app/` -- **THEN** the import is forbidden by this rule -- **AND** review SHOULD reject the change +- **THEN** the workspace-topology check SHALL reject the change +- **AND** the check output SHALL identify the offending file #### Scenario: Neither imports legacy - **WHEN** a file in `packages/` or `e2e/` imports from any `legacy/` path or any `@animus-ui/` -- **THEN** the import is forbidden by the `legacy-directory-topology` spec's one-way independence rule +- **THEN** the workspace-topology check SHALL reject the change +- **AND** the check output SHALL identify the offending file ### Requirement: Root CLAUDE.md Documents Workspace Topology @@ -132,3 +131,4 @@ Each new framework fixture SHALL build without resolving source files from anoth - **WHEN** each new fixture's focused build runs after shared packages are available - **THEN** the build completes using only its own source plus active `packages/*` workspace dependencies + diff --git a/openspec/specs/packed-consumer-verification/spec.md b/openspec/specs/packed-consumer-verification/spec.md index f98ed9a2..1d8b7a0c 100644 --- a/openspec/specs/packed-consumer-verification/spec.md +++ b/openspec/specs/packed-consumer-verification/spec.md @@ -1,9 +1,7 @@ ## Purpose Defines requirements for the `packed-consumer-verification` capability. - ## Requirements - ### Requirement: Packed tarball production The packed verification lane SHALL produce npm tarballs for all five publishable packages (`properties`, `system`, `extract`, `vite-plugin`, `next-plugin`) and SHALL fail if any produced tarball's manifest retains a `workspace:` dependency specifier. @@ -111,3 +109,47 @@ Both extractor engine entrypoints — the default engine export and the `engine- - **WHEN** either engine's native binary is absent for the runner platform in the packed install - **THEN** the lane fails naming the missing engine and platform + +### Requirement: Exact publish artifact verification + +The packed consumer lane SHALL accept a prebuilt tarball set and exercise those tarballs without repacking their source directories. + +#### Scenario: Prebuilt tarballs are supplied + +- **WHEN** the packed consumer lane receives paths for all publishable package tarballs +- **THEN** every lint, install, load, type-check, build, assertion, and receipt step operates on those supplied files +- **AND** the lane does not create replacement tarballs + +#### Scenario: Supplied tarball is missing + +- **WHEN** a required publishable package tarball is absent from the supplied set +- **THEN** the lane exits non-zero and identifies the missing package + +### Requirement: Unmasked internal dependency closure + +The packed consumer SHALL install local publishable tarballs without an internal-package override and SHALL validate every installed `@animus-ui/*` package instance against the corresponding tested tarball. + +#### Scenario: Embedded internal edge is stale + +- **WHEN** a tested tarball embeds an internal dependency version different from the corresponding sibling tarball +- **THEN** packed verification exits non-zero and reports the dependent package, dependency name, expected version, and installed or declared version + +#### Scenario: Nested internal package is substituted + +- **WHEN** installation produces a nested or registry-sourced internal package instance that does not match the tested tarball version +- **THEN** packed verification exits non-zero and identifies the mismatched installed path + +### Requirement: Suppressed type diagnostics remain bounded + +The packed type-resolution gate SHALL fail when a new `internal-resolution-error` appears outside the exact accepted DEF-5 diagnostic set. + +#### Scenario: Additional declaration resolution failure appears + +- **WHEN** properties or system produces an additional internal-resolution diagnostic +- **THEN** the packed lane exits non-zero and reports the additional diagnostic + +#### Scenario: Known DEF-5 diagnostics are removed + +- **WHEN** declaration output no longer produces the accepted DEF-5 diagnostics +- **THEN** the packed lane exits non-zero until the obsolete exemption is removed + diff --git a/openspec/specs/react-router-extraction-canary/spec.md b/openspec/specs/react-router-extraction-canary/spec.md index 766723fd..cbe08f78 100644 --- a/openspec/specs/react-router-extraction-canary/spec.md +++ b/openspec/specs/react-router-extraction-canary/spec.md @@ -1,9 +1,7 @@ ## Purpose Defines requirements for the `react-router-extraction-canary` capability. - ## Requirements - ### Requirement: React Router SSR Worker The React Router v8 canary SHALL build route modules into a Cloudflare Worker that server-renders its document response. @@ -39,3 +37,18 @@ The React Router canary SHALL produce Worker metadata accepted by a credential-f - **WHEN** the focused React Router deployment dry run follows a successful build - **THEN** it exits successfully and identifies `animus-react-router-canary` as the target + +### Requirement: React Router served-client CSS proof + +The React Router assertion tier SHALL validate non-empty Animus semantic CSS beneath the Wrangler-served client asset root independently from server CSS. + +#### Scenario: CSS exists only in server output + +- **WHEN** valid Animus CSS exists under React Router server output and no valid Animus CSS exists under `build/client` +- **THEN** `verify:assert:react-router` exits non-zero and identifies the missing served-client CSS + +#### Scenario: Client CSS contains extracted output + +- **WHEN** `build/client` contains the required layers, variables, and Animus class output +- **THEN** the served-client CSS assertion succeeds without reading server CSS + diff --git a/openspec/specs/release-workflow/spec.md b/openspec/specs/release-workflow/spec.md index 590475ce..22fcec4c 100644 --- a/openspec/specs/release-workflow/spec.md +++ b/openspec/specs/release-workflow/spec.md @@ -1,9 +1,7 @@ ## Purpose Requirements for the `release-workflow` capability: Fixed versioning across all publishable packages; Semver bump types; Version derived from git tags; and 5 more. - ## Requirements - ### Requirement: Fixed versioning across all publishable packages All publishable packages SHALL share a single version derived from the git tag. The release script SHALL stamp the same version into every publishable package's `package.json`. @@ -149,3 +147,32 @@ The Next, Vite, supported Worker, and packed consumer jobs SHALL remain schedule - **WHEN** any configured CI event starts a run - **THEN** the standing consumer jobs are scheduled without a release-only condition - **AND** each job retains its existing receipt artifact path + +### Requirement: Verified tarballs are published unchanged + +The release job SHALL materialize package versions and native payloads before packed verification and SHALL publish the same tarball files that pass that verification. + +#### Scenario: Release artifacts pass packed verification + +- **WHEN** release-time tarballs complete packed verification +- **THEN** each npm publish command receives one of those verified tarball paths + +#### Scenario: Release content changes after verification + +- **WHEN** a package would need to be repacked or mutated after packed verification +- **THEN** the release job exits non-zero before publishing any affected package + +### Requirement: Worker consumer release gate + +The release job SHALL require successful showcase, Vite, Vinext, and React Router Worker build, assertion, and credential-free dry-run checks from the same workflow run. + +#### Scenario: Supported Worker consumer fails + +- **WHEN** any supported Worker consumer build, assertion, or dry-run check fails +- **THEN** the release job does not start and no package is published + +#### Scenario: Worker matrix succeeds + +- **WHEN** every supported Worker consumer check succeeds +- **THEN** the Worker matrix requirement permits the release job to start + diff --git a/openspec/specs/verification-tier-policy/spec.md b/openspec/specs/verification-tier-policy/spec.md index 951d54af..6a644b6e 100644 --- a/openspec/specs/verification-tier-policy/spec.md +++ b/openspec/specs/verification-tier-policy/spec.md @@ -641,3 +641,59 @@ The complete verification graph SHALL keep lint, compile, type-contract, TypeScr - **THEN** every required proof category has at least one executable reachable owner - **AND** an omitted category fails the test with the missing category name +### Requirement: Active TypeScript test discovery + +The TypeScript unit tier SHALL execute every active unit test under its owned package roots, including colocated system runtime tests and non-canary extractor tests. + +#### Scenario: Colocated system test fails + +- **WHEN** a test under `packages/system/src/` fails +- **THEN** `verify:unit:ts` and every composite containing it exit non-zero + +#### Scenario: Non-canary extractor test fails + +- **WHEN** a test under `packages/extract/tests/` other than the native canary fails +- **THEN** `verify:unit:ts` and every composite containing it exit non-zero + +### Requirement: Host-native NAPI freshness + +The NAPI freshness precondition SHALL compare Rust inputs against the exact binary selected for the current platform, architecture, and Linux libc. + +#### Scenario: Foreign binary is fresh and host binary is stale + +- **WHEN** a foreign-target binary is newer than Rust inputs and the current host binary is older +- **THEN** the freshness precondition exits non-zero and identifies the stale host binary + +#### Scenario: Host binary is fresh + +- **WHEN** the current host binary exists and is newer than every owned Rust input +- **THEN** the freshness precondition succeeds regardless of foreign binary timestamps + +### Requirement: Rust lint suppression policy + +The strict Clippy tier SHALL reject authored active Rust source containing crate-wide or module-wide `allow(warnings)` or `allow(clippy::all)` suppression. + +#### Scenario: Blanket suppression is introduced + +- **WHEN** an active Rust source contains blanket warning or Clippy suppression +- **THEN** `verify:clippy` exits non-zero and reports the source location + +#### Scenario: Narrow justified suppression remains + +- **WHEN** an active Rust source contains an allow for a named lint other than the blanket groups +- **THEN** the suppression policy does not fail that source before Clippy evaluates it + +### Requirement: Rust dependency ignore policy + +The Rust dependency hygiene tier SHALL reject a non-empty cargo-machete ignore list before running the unused-dependency detector. + +#### Scenario: Ignored dependency is added + +- **WHEN** parsed Cargo metadata exposes one or more cargo-machete ignored dependencies +- **THEN** `verify:hygiene:rust` exits non-zero and lists the package and ignored dependency names + +#### Scenario: Ignore lists are empty + +- **WHEN** parsed Cargo metadata contains no ignored dependency +- **THEN** `verify:hygiene:rust` proceeds to cargo-machete detection + diff --git a/openspec/specs/vinext-extraction-canary/spec.md b/openspec/specs/vinext-extraction-canary/spec.md index 02178eb7..d20ba144 100644 --- a/openspec/specs/vinext-extraction-canary/spec.md +++ b/openspec/specs/vinext-extraction-canary/spec.md @@ -1,9 +1,7 @@ ## Purpose Defines requirements for the `vinext-extraction-canary` capability. - ## Requirements - ### Requirement: Vinext consumer build The Vinext canary SHALL build a Next-compatible application containing an App Router server component, an App Router client boundary, and a Pages Router route into Cloudflare Worker output. @@ -44,3 +42,18 @@ Adding Vinext SHALL NOT prevent the existing native Next build and structural as - **WHEN** the native Next and Vinext focused verification commands run on the same installed workspace - **THEN** both complete successfully and report their framework-specific output assertions + +### Requirement: Vinext served-client CSS proof + +The Vinext assertion tier SHALL validate non-empty Animus semantic CSS beneath the Wrangler-served client asset root independently from server CSS. + +#### Scenario: CSS exists only in server output + +- **WHEN** valid Animus CSS exists under Vinext server output and no valid Animus CSS exists under `dist/client` +- **THEN** `verify:assert:vinext` exits non-zero and identifies the missing served-client CSS + +#### Scenario: Client CSS contains extracted output + +- **WHEN** `dist/client` contains the required layers, variables, and Animus class output +- **THEN** the served-client CSS assertion succeeds without reading server CSS + diff --git a/packages/_assertions/__tests__/served-client-css.test.ts b/packages/_assertions/__tests__/served-client-css.test.ts new file mode 100644 index 00000000..dc4fdc5e --- /dev/null +++ b/packages/_assertions/__tests__/served-client-css.test.ts @@ -0,0 +1,73 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AssertionError } from '../src/assert-css'; +import { readRequiredCss } from '../src/find-build-assets'; + +const temporaryDirectories: string[] = []; + +function scaffold(): { root: string; clientRoot: string; serverRoot: string } { + const root = mkdtempSync(resolve(tmpdir(), 'animus-served-client-css-')); + temporaryDirectories.push(root); + const clientRoot = join(root, 'client'); + const serverRoot = join(root, 'server'); + mkdirSync(clientRoot, { recursive: true }); + mkdirSync(serverRoot, { recursive: true }); + return { root, clientRoot, serverRoot }; +} + +const SEMANTIC_CSS = `@layer anm-base, anm-variants; +:root { --color-text: #111; } +@layer anm-base { .animus-card { padding: 8px; } } +@layer anm-variants { .animus-card--primary { color: red; } } +`; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('readRequiredCss', () => { + it('throws naming served-client CSS when only server output has CSS', async () => { + const { clientRoot, serverRoot } = scaffold(); + // Valid CSS exists only under the server root; the client root is empty. + writeFileSync(join(serverRoot, 'server.css'), SEMANTIC_CSS); + + await expect( + readRequiredCss(clientRoot, 'vinext served-client CSS') + ).rejects.toThrow(AssertionError); + await expect( + readRequiredCss(clientRoot, 'vinext served-client CSS') + ).rejects.toThrow(/served-client/); + }); + + it('treats an empty CSS file under the client root as missing', async () => { + const { clientRoot } = scaffold(); + writeFileSync(join(clientRoot, 'empty.css'), ' \n'); + + await expect( + readRequiredCss(clientRoot, 'vinext served-client CSS') + ).rejects.toThrow(AssertionError); + }); + + it('returns only the client root CSS when it is non-empty', async () => { + const { clientRoot, serverRoot } = scaffold(); + mkdirSync(join(clientRoot, 'assets'), { recursive: true }); + writeFileSync(join(clientRoot, 'assets', 'styles.css'), SEMANTIC_CSS); + // Server CSS must never leak into the returned client CSS. + writeFileSync( + join(serverRoot, 'server.css'), + '.server-only { color: hotpink; }' + ); + + const css = await readRequiredCss(clientRoot, 'vinext served-client CSS'); + + expect(css).toContain('@layer anm-base'); + expect(css).toContain(':root'); + expect(css).toContain('animus-card'); + expect(css).not.toContain('server-only'); + }); +}); diff --git a/packages/_assertions/src/find-build-assets.ts b/packages/_assertions/src/find-build-assets.ts index a84e28ed..e2df2afb 100644 --- a/packages/_assertions/src/find-build-assets.ts +++ b/packages/_assertions/src/find-build-assets.ts @@ -1,6 +1,8 @@ import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { AssertionError } from './assert-css'; + export interface FindAssetsOptions { dir: string; extensions: readonly string[]; @@ -61,3 +63,25 @@ export async function readAllConcat(paths: string[]): Promise { const contents = await Promise.all(paths.map((p) => readFile(p, 'utf8'))); return contents.join('\n'); } + +// Require at least one non-empty CSS file beneath `root` and return only that +// root's concatenated CSS. Used to assert semantic CSS beneath a Wrangler- +// served client asset root (dist/client, build/client) independently from +// server output — the served client is what a browser actually receives. +export async function readRequiredCss( + root: string, + label: string +): Promise { + const cssFiles = await findCssFiles(root); + const contents = await Promise.all( + cssFiles.map((path) => readFile(path, 'utf8')) + ); + const nonEmpty = contents.filter((css) => css.trim().length > 0); + if (nonEmpty.length === 0) { + throw new AssertionError( + `${label}: no non-empty CSS file found beneath served-client root ${root}`, + { root, label, cssFileCount: cssFiles.length } + ); + } + return nonEmpty.join('\n'); +} diff --git a/packages/_integration/__tests__/mdx-preprocessing.test.ts b/packages/_integration/__tests__/mdx-preprocessing.test.ts new file mode 100644 index 00000000..647dc23b --- /dev/null +++ b/packages/_integration/__tests__/mdx-preprocessing.test.ts @@ -0,0 +1,116 @@ +import { + DEFAULT_EXTENSIONS, + preprocessMdx, + type PreprocessMdxResult, +} from '@animus-ui/extract/pipeline'; +import { describe, expect, test } from 'vitest'; + +/** + * Direct unit/characterization tests for the MDX preprocessor. + * + * `preprocessMdx` (packages/extract/pipeline/mdx-preprocessor.ts) compiles an + * MDX source string to scanner-consumable JSX via `@mdx-js/mdx`. Unlike + * mdx-rendering.test.ts (which drives the OUTPUT through the full extraction + * pipeline), this file exercises the preprocessor in isolation: its result + * shape, the `ok` / `error` branches, and the (deliberately noted) unreachable + * `missing-dep` branch. + * + * @mdx-js/mdx is an in-repo devDependency of both @animus-ui/extract and this + * integration package, so the dynamic `import('@mdx-js/mdx')` resolves under + * the real (vitest) runner. + * + * Placement: this lives beside the other pipeline-export consumers in + * packages/_integration/__tests__ (the integration tier). packages/extract has + * no vitest target in vite.config.ts's typescriptTestTargets, so this cannot + * live under packages/extract. + */ + +const MDX_WITH_COMPONENT = '# Hello\n\nhi there\n'; + +describe('preprocessMdx — successful compile (kind: "ok")', () => { + test('compiles MDX-with-JSX to a JSX source string', async () => { + const result = await preprocessMdx(MDX_WITH_COMPONENT, 'usage.mdx'); + expect(result.kind).toBe('ok'); + expect(typeof result.source).toBe('string'); + expect((result.source ?? '').length).toBeGreaterThan(0); + // The `error` field is absent on success. + expect(result.error).toBeUndefined(); + }); + + test('prepends the "@mdx-source" provenance comment with the filename', async () => { + const result = await preprocessMdx(MDX_WITH_COMPONENT, 'dir/deep/page.mdx'); + // The wrapper prefixes `/* @mdx-source: */\n` verbatim — the + // filename is interpolated, not resolved/normalized. + expect(result.source).toMatch( + /^\/\* @mdx-source: dir\/deep\/page\.mdx \*\// + ); + }); + + test('emits an MDXContent component and keeps JSX (outputFormat "program", jsx: true)', async () => { + const result = await preprocessMdx(MDX_WITH_COMPONENT, 'usage.mdx'); + // outputFormat: 'program' emits a full ESM module exporting MDXContent. + expect(result.source).toContain('MDXContent'); + // jsx: true preserves element syntax rather than compiling to _jsx() calls, + // so the referenced component binding stays visible as JSX. + expect(result.source).toContain('Box'); + }); + + test('plain markdown (no JSX) also compiles to "ok"', async () => { + const result = await preprocessMdx( + '# Title\n\nSome **bold** copy.', + 'md.mdx' + ); + expect(result.kind).toBe('ok'); + expect(typeof result.source).toBe('string'); + }); + + test('there is NO non-mdx passthrough: plain prose still runs through the compiler', async () => { + // The module has no early-return passthrough branch; markdown/plaintext is + // valid MDX, so even bare prose is compiled (not returned verbatim). + const result = await preprocessMdx('just some plain prose', 'plain.mdx'); + expect(result.kind).toBe('ok'); + expect(result.source).not.toBe('just some plain prose'); + expect(result.source).toContain('MDXContent'); + }); +}); + +describe('preprocessMdx — compile failure (kind: "error")', () => { + test.each([ + ['unclosed JSX tag', '# X\n\n\n'], + ['unterminated expression brace', '{'], + ['unparseable expression', '{ 1 + }'], + ])('%s -> { kind: "error", error: }', async (_label, source) => { + const result = await preprocessMdx(source, 'broken.mdx'); + expect(result.kind).toBe('error'); + expect(typeof result.error).toBe('string'); + expect((result.error ?? '').length).toBeGreaterThan(0); + // On failure there is no compiled source. + expect(result.source).toBeUndefined(); + }); +}); + +describe('preprocessMdx — result shape contract (PreprocessMdxResult)', () => { + test('kind is always one of the documented union members', async () => { + const result: PreprocessMdxResult = await preprocessMdx('# ok\n', 'x.mdx'); + expect(['ok', 'missing-dep', 'error']).toContain(result.kind); + }); + + /** + * Honestly-unreachable branch — DOCUMENTED GAP. + * + * The `{ kind: 'missing-dep' }` branch fires only when + * `import('@mdx-js/mdx')` rejects. `preprocessMdx(source, filename)` takes no + * injection seam — the specifier is hardcoded — and @mdx-js/mdx is installed + * in-repo, so under the real runner the import always resolves. Forcing this + * branch would require mocking the native dynamic import (which the dist keeps + * as a real `import(...)`, so vi.mock cannot reliably intercept it without + * destabilizing the other cases). Left uncovered on purpose. + */ + test.todo('returns { kind: "missing-dep" } when @mdx-js/mdx is unresolvable'); +}); + +describe('DEFAULT_EXTENSIONS (shared source of truth)', () => { + test('is the frozen tuple of scanner extensions including .mdx', () => { + expect(DEFAULT_EXTENSIONS).toEqual(['.ts', '.tsx', '.js', '.jsx', '.mdx']); + }); +}); diff --git a/packages/extract/package.json b/packages/extract/package.json index 8e1a14f2..c556b2d4 100644 --- a/packages/extract/package.json +++ b/packages/extract/package.json @@ -55,7 +55,7 @@ }, "devDependencies": { "@animus-ui/system": "workspace:*", - "@mdx-js/mdx": "^3.0.0", + "@mdx-js/mdx": "3.1.1", "@napi-rs/cli": "^3.7.3" }, "peerDependencies": { diff --git a/packages/extract/pipeline/content-hash.ts b/packages/extract/pipeline/content-hash.ts new file mode 100644 index 00000000..b4731dcb --- /dev/null +++ b/packages/extract/pipeline/content-hash.ts @@ -0,0 +1,10 @@ +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. + */ +export function contentHash(source: string): string { + return createHash('md5').update(source).digest('hex'); +} diff --git a/packages/extract/pipeline/discover-packages.ts b/packages/extract/pipeline/discover-packages.ts index 77063de1..7070d41e 100644 --- a/packages/extract/pipeline/discover-packages.ts +++ b/packages/extract/pipeline/discover-packages.ts @@ -1,4 +1,176 @@ -import { readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; +import { dirname, join, relative } from 'path'; + +import { discoverFiles } from './discover-files'; + +/** + * Walk up from a resolved package entry file to the nearest directory + * containing a package.json (the package root). Stops at the filesystem + * root and returns it if no package.json is found on the way up. + */ +export function findPackageRoot(absEntryPath: string): string { + let pkgRoot = dirname(absEntryPath); + while ( + pkgRoot !== dirname(pkgRoot) && + !existsSync(join(pkgRoot, 'package.json')) + ) { + pkgRoot = dirname(pkgRoot); + } + return pkgRoot; +} + +/** + * Exclusion fragments for discovery inside an external package's src/, + * matched against paths RELATIVE TO the src dir. Per spec + * external-package-file-discovery, the filters apply "within the package" — + * an npm-installed package whose own location is under node_modules (or a + * repo path containing 'dist') must still have its sources discovered. + */ +export const PACKAGE_SRC_EXCLUDES = [ + 'dist', + 'node_modules', + '.test.', + '.spec.', +]; + +export interface CollectedExternalPackages { + /** New file entries (rootDir-relative, preprocessed) for the analysis set. */ + entries: Array<{ path: string; source: string }>; + /** specifier → rootDir-relative module-resolution entry (src/index.ts when present). */ + packageMap: Record; + /** specifier → absolute src/index.ts path, only for packages with one. */ + sourceEntries: Map; + /** Absolute directories for bundler loader allowlisting (src/ or dist entry dir). */ + packageDirs: string[]; +} + +/** + * Shared external-package source collection (spec: + * external-package-file-discovery), consumed by both extraction plugins. + * For each specifier: resolve to an absolute entry (null/throw → silently + * skip), walk up to the package root, then either discover sources under + * src/ (redirecting module resolution to src/index.ts when present) or fall + * back to ingesting the resolved entry file itself. + * + * The only bundler-specific seams are callbacks: specifier resolution, + * dedup against already-ingested files, per-file preprocessing (MDX), and + * the unreadable-file warning. Hashing and caching stay in the plugins — + * their cache policies legitimately differ. + */ +export async function collectExternalPackageSources(opts: { + specifiers: string[]; + /** Resolve a specifier to an absolute entry path; null (or a throw) skips it. */ + resolveSpecifier: ( + specifier: string + ) => string | null | Promise; + rootDir: string; + extensionsSet: ReadonlySet; + /** Does the caller's file set already contain this rootDir-relative path? */ + hasEntry: (relPath: string) => boolean; + /** + * Preprocess a discovered source (e.g. MDX→tsx with a path rewrite). + * Return null to skip the file; return the input unchanged to pass through. + */ + preprocessFile: ( + source: string, + relPath: string, + absPath: string + ) => Promise<{ source: string; relPath: string } | null>; + /** Called when a discovered file cannot be read; the file is skipped. */ + onUnreadable: (relPath: string, error: unknown) => void; +}): Promise { + const { + specifiers, + resolveSpecifier, + rootDir, + extensionsSet, + hasEntry, + preprocessFile, + onUnreadable, + } = opts; + + const entries: Array<{ path: string; source: string }> = []; + const pushed = new Set(); + const packageMap: Record = {}; + const sourceEntries = new Map(); + const packageDirs: string[] = []; + + const alreadyIngested = (relPath: string): boolean => + hasEntry(relPath) || pushed.has(relPath); + + for (const specifier of specifiers) { + let absEntry: string | null; + try { + absEntry = await resolveSpecifier(specifier); + } catch { + absEntry = null; + } + if (!absEntry) continue; // unresolvable → silently skip (spec) + + const pkgRoot = findPackageRoot(absEntry); + const srcDir = join(pkgRoot, 'src'); + + if (existsSync(srcDir)) { + packageDirs.push(srcDir); + + // Redirect module resolution to the source entry when present + const srcEntry = join(srcDir, 'index.ts'); + if (existsSync(srcEntry)) { + packageMap[specifier] = relative(rootDir, srcEntry); + sourceEntries.set(specifier, srcEntry); + } else { + packageMap[specifier] = relative(rootDir, absEntry); + } + + // 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( + (absPath) => { + const inPkg = relative(srcDir, absPath); + return !PACKAGE_SRC_EXCLUDES.some((pattern) => + inPkg.includes(pattern) + ); + } + ); + + for (const pkgFile of pkgFiles) { + const relPath = relative(rootDir, pkgFile); + if (alreadyIngested(relPath)) continue; + + let source: string; + try { + source = readFileSync(pkgFile, 'utf-8'); + } catch (err) { + onUnreadable(relPath, err); + continue; + } + + const processed = await preprocessFile(source, relPath, pkgFile); + if (!processed) continue; + entries.push({ path: processed.relPath, source: processed.source }); + pushed.add(processed.relPath); + } + } else { + // No src/ — fall back to the resolved (dist) entry file itself, + // exempt from extension filters (spec: npm-installed scenario). + packageDirs.push(dirname(absEntry)); + const relPath = relative(rootDir, absEntry); + packageMap[specifier] = relPath; + + if (!alreadyIngested(relPath)) { + try { + const source = readFileSync(absEntry, 'utf-8'); + entries.push({ path: relPath, source }); + pushed.add(relPath); + } catch (err) { + onUnreadable(relPath, err); + } + } + } + } + + return { entries, packageMap, sourceEntries, packageDirs }; +} /** * Extract external DS package names from `includes` declarations in the system file. diff --git a/packages/extract/pipeline/index.ts b/packages/extract/pipeline/index.ts index da565302..1699046e 100644 --- a/packages/extract/pipeline/index.ts +++ b/packages/extract/pipeline/index.ts @@ -25,8 +25,15 @@ export { assertNoRetiredEngineSelection, RETIRED_ENGINE_MESSAGE, } from './engine-retirement'; +export { contentHash } from './content-hash'; export { discoverFiles } from './discover-files'; -export { extractSystemFilePackages } from './discover-packages'; +export { + collectExternalPackageSources, + extractSystemFilePackages, + findPackageRoot, +} from './discover-packages'; +export { buildPathAliasesJson } from './path-aliases'; +export { formatRustTimingWaterfall } from './timing-waterfall'; export type { DynamicPropConfigEntry, DynamicPropMeta, diff --git a/packages/extract/pipeline/path-aliases.ts b/packages/extract/pipeline/path-aliases.ts new file mode 100644 index 00000000..84dbfbae --- /dev/null +++ b/packages/extract/pipeline/path-aliases.ts @@ -0,0 +1,61 @@ +/** + * Path-alias normalization for the Rust-facing `pathAliasesJson` contract. + * + * This is the single authoritative encoder of the wire format + * `{ aliases: [{ pattern, replacement, type }] }` consumed by + * `analyzeProject` — both the Vite and Next.js plugins must build their + * alias JSON through here (same convention as buildAnalyzeProjectArgs). + * Each plugin only flattens its bundler's alias config into pairs. + */ + +export interface PathAliasEntry { + pattern: string; + replacement: string; + type: 'prefix' | 'exact'; +} + +export interface PathAliasPair { + pattern: string; + target: string; + /** + * Force a classification instead of sniffing the target for a file + * extension. Vite's array-form aliases are always prefix matches. + */ + kind?: 'prefix' | 'exact'; +} + +/** + * Normalize alias pairs: strip a `rootDir` prefix from targets, classify + * exact (file) vs prefix (directory) matches, force trailing slashes on + * prefix entries, and sort longest pattern first for correct matching + * priority. Returns null when no entries survive. + */ +export function buildPathAliasesJson( + pairs: PathAliasPair[], + rootDir: string +): { json: string; count: number } | null { + const entries: PathAliasEntry[] = []; + + for (const { pattern, target, kind } of pairs) { + const replacement = target.startsWith(rootDir) + ? target.slice(rootDir.length + 1) + : target; + const isExact = kind ? kind === 'exact' : /\.\w+$/.test(replacement); + if (isExact) { + entries.push({ pattern, replacement, type: 'exact' }); + } else { + entries.push({ + pattern: pattern.endsWith('/') ? pattern : pattern + '/', + replacement: replacement.endsWith('/') + ? replacement + : replacement + '/', + type: 'prefix', + }); + } + } + + entries.sort((a, b) => b.pattern.length - a.pattern.length); + + if (entries.length === 0) return null; + return { json: JSON.stringify({ aliases: entries }), count: entries.length }; +} diff --git a/packages/extract/pipeline/timing-waterfall.ts b/packages/extract/pipeline/timing-waterfall.ts new file mode 100644 index 00000000..a2dad117 --- /dev/null +++ b/packages/extract/pipeline/timing-waterfall.ts @@ -0,0 +1,45 @@ +/** + * Rust-side timing phases as reported in `manifest.timing`, in waterfall + * order, with their display labels. Mirrors the timing struct in the + * extract-v2 crate — a renamed or added Rust phase is updated here once, + * not per plugin. + */ +export const RUST_TIMING_PHASES: ReadonlyArray< + readonly [key: string, label: string] +> = [ + ['parseAndWalk', 'parse+walk'], + ['importResolution', 'imports'], + ['extensionProvenance', 'provenance'], + ['topologicalSort', 'topo-sort'], + ['chainEvaluation', 'chains'], + ['jsxScanning', 'jsx-scan'], + ['systemPropAggregation', 'sys-props'], + ['usageLedger', 'usage'], + ['reconciliation', 'reconcile'], + ['cssGeneration', 'css-gen'], + ['manifestSerialization', 'serialize'], +]; + +/** + * Format the Rust timing waterfall as log lines. `indent` and `labelWidth` + * let each plugin keep its established column layout; the phase table and + * the `(N files, M cached)` suffix are shared. + */ +export function formatRustTimingWaterfall( + timing: Record, + opts: { indent: string; labelWidth: number } +): string[] { + const lines: string[] = []; + for (const [key, label] of RUST_TIMING_PHASES) { + const ms = timing[key] ?? 0; + const pad = ' '.repeat(Math.max(0, opts.labelWidth - label.length)); + const extra = + key === 'parseAndWalk' + ? ` (${timing.fileCount ?? 0} files, ${timing.cacheHits ?? 0} cached)` + : ''; + lines.push( + `${opts.indent}${label}${pad}${String(ms).padStart(5)}ms${extra}` + ); + } + return lines; +} diff --git a/packages/extract/tests/collect-external-packages.test.ts b/packages/extract/tests/collect-external-packages.test.ts new file mode 100644 index 00000000..9d828bae --- /dev/null +++ b/packages/extract/tests/collect-external-packages.test.ts @@ -0,0 +1,201 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, relative } from 'path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { collectExternalPackageSources } from '../pipeline/discover-packages'; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'animus-collect-pkgs-')); + tempRoots.push(root); + return root; +} + +/** Create a package dir with package.json and the given files. */ +function makePackage( + base: string, + files: Record +): string { + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, 'package.json'), '{"name":"pkg"}'); + for (const [rel, content] of Object.entries(files)) { + const abs = join(base, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, content); + } + return base; +} + +const EXTENSIONS: ReadonlySet = new Set(['.ts', '.tsx', '.mdx']); + +const identity = async ( + source: string, + relPath: string +): Promise<{ source: string; relPath: string }> => ({ source, relPath }); + +function collect( + rootDir: string, + specifierEntries: Record, + overrides: Partial< + Parameters[0] + > = {} +) { + return collectExternalPackageSources({ + specifiers: Object.keys(specifierEntries), + resolveSpecifier: (spec) => specifierEntries[spec], + rootDir, + extensionsSet: EXTENSIONS, + hasEntry: () => false, + preprocessFile: identity, + onUnreadable: () => {}, + ...overrides, + }); +} + +describe('collectExternalPackageSources', () => { + test('discovers src/ files, redirects to src/index.ts, and applies package-scoped excludes', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/index.ts': 'export * from "./Button";', + 'src/Button.tsx': 'export const Button = 1;', + 'src/Button.test.tsx': 'test file', + 'src/nested/node_modules/dep.ts': 'nested dep', + }); + + const result = await collect(root, { + '@x/ds': join(pkg, 'dist', 'index.mjs'), + }); + + expect(result.entries.map((e) => e.path).sort()).toEqual([ + 'packages/ds/src/Button.tsx', + 'packages/ds/src/index.ts', + ]); + expect(result.packageMap).toEqual({ + '@x/ds': 'packages/ds/src/index.ts', + }); + expect(result.sourceEntries.get('@x/ds')).toBe( + join(pkg, 'src', 'index.ts') + ); + expect(result.packageDirs).toEqual([join(pkg, 'src')]); + }); + + test('an npm-installed package under node_modules still has its sources discovered', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'node_modules', '@x', 'ds'), { + 'src/index.ts': 'export const ds = 1;', + }); + + const result = await collect(root, { + '@x/ds': join(pkg, 'dist', 'index.mjs'), + }); + + expect(result.entries.map((e) => e.path)).toEqual([ + relative(root, join(pkg, 'src', 'index.ts')), + ]); + expect(result.sourceEntries.size).toBe(1); + }); + + 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'), { + 'index.mjs': 'export const flat = 1;', + }); + const entry = join(pkg, 'index.mjs'); + + const result = await collect(root, { 'flat-pkg': entry }); + + expect(result.entries).toEqual([ + { path: relative(root, entry), source: 'export const flat = 1;' }, + ]); + expect(result.packageMap).toEqual({ + 'flat-pkg': relative(root, entry), + }); + expect(result.sourceEntries.size).toBe(0); + expect(result.packageDirs).toEqual([pkg]); + }); + + test('src/ without index.ts falls back to the resolved entry in packageMap', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/Button.tsx': 'export const Button = 1;', + 'main.ts': 'export {};', + }); + + const result = await collect(root, { '@x/ds': join(pkg, 'main.ts') }); + + expect(result.packageMap).toEqual({ '@x/ds': 'packages/ds/main.ts' }); + expect(result.sourceEntries.size).toBe(0); + }); + + test('unresolvable specifiers (null or throw) are silently skipped', async () => { + const root = makeRoot(); + const result = await collectExternalPackageSources({ + specifiers: ['nope', 'boom'], + resolveSpecifier: (spec) => { + if (spec === 'boom') throw new Error('resolver exploded'); + return null; + }, + rootDir: root, + extensionsSet: EXTENSIONS, + hasEntry: () => false, + preprocessFile: identity, + onUnreadable: () => {}, + }); + + expect(result.entries).toEqual([]); + expect(result.packageMap).toEqual({}); + expect(result.packageDirs).toEqual([]); + }); + + test('hasEntry dedups against the caller file set', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/index.ts': 'export const a = 1;', + 'src/Button.tsx': 'export const Button = 1;', + }); + + const result = await collect( + root, + { '@x/ds': join(pkg, 'dist', 'index.mjs') }, + { hasEntry: (relPath) => relPath === 'packages/ds/src/Button.tsx' } + ); + + expect(result.entries.map((e) => e.path)).toEqual([ + 'packages/ds/src/index.ts', + ]); + }); + + test('preprocessFile can rewrite paths (MDX) or skip files entirely', async () => { + const root = makeRoot(); + const pkg = makePackage(join(root, 'packages', 'ds'), { + 'src/index.ts': 'export {};', + 'src/Doc.mdx': '# doc', + }); + + const result = await collect( + root, + { '@x/ds': join(pkg, 'dist', 'index.mjs') }, + { + preprocessFile: async (source, relPath, absPath) => { + if (absPath.endsWith('.mdx')) { + return { source: 'compiled', relPath: relPath + '.tsx' }; + } + if (relPath.endsWith('index.ts')) return null; + return { source, relPath }; + }, + } + ); + + expect(result.entries).toEqual([ + { path: 'packages/ds/src/Doc.mdx.tsx', source: 'compiled' }, + ]); + }); +}); diff --git a/packages/extract/tests/path-aliases.test.ts b/packages/extract/tests/path-aliases.test.ts new file mode 100644 index 00000000..3674bc3e --- /dev/null +++ b/packages/extract/tests/path-aliases.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'vitest'; + +import { buildPathAliasesJson } from '../pipeline/path-aliases'; + +const ROOT = '/proj'; + +describe('buildPathAliasesJson', () => { + test('returns null when no pairs survive', () => { + expect(buildPathAliasesJson([], ROOT)).toBeNull(); + }); + + test('strips rootDir, classifies exact vs prefix, and normalizes slashes', () => { + const built = buildPathAliasesJson( + [ + { pattern: '@components', target: '/proj/src/components' }, + { pattern: '@sys', target: '/proj/src/system.ts' }, + { pattern: '~external', target: '/elsewhere/lib' }, + ], + ROOT + ); + expect(built?.count).toBe(3); + expect(JSON.parse(built!.json)).toEqual({ + aliases: [ + { + pattern: '@components/', + replacement: 'src/components/', + type: 'prefix', + }, + { pattern: '~external/', replacement: '/elsewhere/lib/', type: 'prefix' }, + { pattern: '@sys', replacement: 'src/system.ts', type: 'exact' }, + ], + }); + }); + + test('sorts longest pattern first for matching priority', () => { + const built = buildPathAliasesJson( + [ + { pattern: '@a', target: '/proj/a' }, + { pattern: '@a/deep', target: '/proj/a/deep' }, + ], + ROOT + ); + const patterns = JSON.parse(built!.json).aliases.map( + (e: { pattern: string }) => e.pattern + ); + expect(patterns).toEqual(['@a/deep/', '@a/']); + }); + + test('kind: prefix forces prefix classification for file-like targets', () => { + const built = buildPathAliasesJson( + [{ pattern: 'lib', target: '/proj/src/lib.ts', kind: 'prefix' }], + ROOT + ); + expect(JSON.parse(built!.json)).toEqual({ + aliases: [{ pattern: 'lib/', replacement: 'src/lib.ts/', type: 'prefix' }], + }); + }); +}); diff --git a/packages/extract/tests/timing-waterfall.test.ts b/packages/extract/tests/timing-waterfall.test.ts new file mode 100644 index 00000000..05300032 --- /dev/null +++ b/packages/extract/tests/timing-waterfall.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest'; + +import { + formatRustTimingWaterfall, + RUST_TIMING_PHASES, +} from '../pipeline/timing-waterfall'; + +describe('formatRustTimingWaterfall', () => { + test('emits one line per phase with the file/cache suffix on parse+walk', () => { + const lines = formatRustTimingWaterfall( + { parseAndWalk: 12, cssGeneration: 3, fileCount: 7, cacheHits: 2 }, + { indent: ' ', labelWidth: 13 } + ); + expect(lines).toHaveLength(RUST_TIMING_PHASES.length); + expect(lines[0]).toBe(' parse+walk 12ms (7 files, 2 cached)'); + expect(lines[lines.length - 2]).toBe(' css-gen 3ms'); + // Missing phases render as 0ms rather than being dropped + expect(lines[1]).toBe(' imports 0ms'); + }); + + test('honors the caller column layout (vite: indent 9, width 15)', () => { + const lines = formatRustTimingWaterfall( + { parseAndWalk: 5 }, + { indent: ' ', labelWidth: 15 } + ); + expect(lines[0]).toBe( + ' parse+walk 5ms (0 files, 0 cached)' + ); + }); +}); diff --git a/packages/next-plugin/src/analyze-project-args.ts b/packages/next-plugin/src/analyze-project-args.ts deleted file mode 100644 index 94878e9c..00000000 --- a/packages/next-plugin/src/analyze-project-args.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { buildAnalyzeProjectArgs } from '@animus-ui/extract/pipeline'; - -import type { - AnalyzeProjectArgs, - AnalyzeProjectInputs as PipelineAnalyzeProjectInputs, -} from '@animus-ui/extract/pipeline'; - -export type { AnalyzeProjectArgs } from '@animus-ui/extract/pipeline'; - -/** - * Next-plugin call sites carry devMode implicitly (production vs HMR entry - * point), so the local input shape omits it; the authoritative 14-slot tuple - * lives in @animus-ui/extract/pipeline. - */ -export type AnalyzeProjectInputs = Omit< - PipelineAnalyzeProjectInputs, - 'devMode' ->; - -export function buildProductionAnalyzeProjectArgs( - inputs: AnalyzeProjectInputs -): AnalyzeProjectArgs { - return buildAnalyzeProjectArgs({ ...inputs, devMode: false }); -} - -export function buildHmrAnalyzeProjectArgs( - inputs: AnalyzeProjectInputs -): AnalyzeProjectArgs { - return buildAnalyzeProjectArgs({ ...inputs, devMode: true }); -} diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 68b290f8..0d7806db 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -5,7 +5,7 @@ import { getManifestJson, engineApi } from './singleton'; type LoaderContext = { resourcePath: string; rootContext: string; - getOptions: () => { strict?: boolean }; + getOptions: () => { strict?: boolean; cssImportTarget?: string }; }; /** @@ -19,16 +19,38 @@ const CSS_IMPORT_RE = /import\s+['"](?:[^'"]*\.animus\/styles\.css|virtual:animus\/styles\.css)['"];\n?/g; /** - * Root entry file patterns. CSS is imported ONLY here to prevent - * per-chunk duplication in Next.js builds. + * Default root entry file patterns. CSS is imported ONLY in the root entry + * to prevent per-chunk duplication in Next.js builds. * * - App Router: `app/layout.tsx` (root layout wraps all routes) * - Pages Router: `pages/_app.tsx` (custom App wraps all pages) * * The `(src\/)?` prefix handles projects that use a `src/` directory. + * Projects whose root entry doesn't match (nested layouts, monorepo roots) + * set the `cssImportTarget` plugin option, which replaces this detection. */ const ROOT_ENTRY_RE = /^(src\/)?(?:app\/layout|pages\/_app)\.[tj]sx?$/; +/** Normalize separators and strip a leading './' for path comparison. */ +function normalizePath(p: string): string { + return p.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +/** + * Decide whether `filename` (project-root-relative) is the file that + * receives the single CSS import. An explicit `cssImportTarget` replaces + * the default filename-convention detection. + */ +function isCssImportTarget( + filename: string, + cssImportTarget: string | undefined +): boolean { + if (cssImportTarget) { + return normalizePath(filename) === normalizePath(cssImportTarget); + } + return ROOT_ENTRY_RE.test(filename); +} + /** * Webpack loader for Animus source transformation. * Runs with enforce: 'pre' to see original source before Babel/SWC. @@ -47,7 +69,8 @@ export default function animusLoader( if (!manifestJson) return source; const filename = relative(this.rootContext, this.resourcePath); - const isRootEntry = ROOT_ENTRY_RE.test(filename); + const opts = this.getOptions?.() ?? {}; + const isRootEntry = isCssImportTarget(filename, opts.cssImportTarget); try { const { transformFile } = engineApi(); @@ -72,7 +95,6 @@ export default function animusLoader( return code; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - const opts = this.getOptions?.() ?? {}; if (opts.strict) { throw new Error( diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 1c095a46..74ce9582 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -3,21 +3,21 @@ import { applyUnitFallback, assembleStylesheet, assertNoRetiredEngineSelection, + buildAnalyzeProjectArgs, buildDynamicPropConfig, + buildPathAliasesJson, + collectExternalPackageSources, + contentHash, DEFAULT_EXTENSIONS, discoverFiles, extractSystemFilePackages, + formatRustTimingWaterfall, preprocessMdx, surfaceManifestDiagnostics, } from '@animus-ui/extract/pipeline'; -import { createHash } from 'crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { dirname, extname, join, relative, resolve } from 'path'; +import { extname, join, relative, resolve } from 'path'; -import { - buildHmrAnalyzeProjectArgs, - buildProductionAnalyzeProjectArgs, -} from './analyze-project-args'; import { getAnalysisPromise, getSharedCss, @@ -68,6 +68,9 @@ type Compiler = { }; }; context: string; + /** Present on watchRun compilers after the first compilation (webpack 5). */ + modifiedFiles?: ReadonlySet; + removedFiles?: ReadonlySet; options?: { name?: string; resolve?: { @@ -86,6 +89,25 @@ type Compiler = { const PLUGIN_NAME = 'AnimusWebpackPlugin'; +/** + * Module id the Rust emitter injects for the extracted stylesheet — also the + * exact resolve.alias key with-animus registers for it, which extractAliases + * must skip when harvesting consumer aliases. + */ +export const ANIMUS_CSS_MODULE_ID = '.animus/styles.css'; + +/** Default path fragments excluded from source discovery (full + watch). */ +const DEFAULT_EXCLUDE = [ + 'node_modules', + 'dist', + '.test.', + '.spec.', + '.next', + '.animus', +]; + +type FileEntry = { path: string; source: string; hash: string }; + export class AnimusWebpackPlugin { private options: AnimusNextOptions; private rootDir: string | null = null; @@ -155,6 +177,8 @@ export class AnimusWebpackPlugin { rustTiming: Record | undefined ): void { if (!this.verbose) return; + const pad = (label: string, width: number): string => + label + ' '.repeat(Math.max(0, width - label.length)); const jsPhases: [string, string][] = [ ['systemLoad', 'system-load'], ['fileDiscovery', 'file-discovery'], @@ -165,8 +189,7 @@ export class AnimusWebpackPlugin { for (const [key, label] of jsPhases) { const ms = bt[key] ?? 0; if (ms === 0 && !Object.hasOwn(bt, key)) continue; - const pad = ' '.repeat(Math.max(0, 17 - label.length)); - this.log(` ${label}${pad}${String(ms).padStart(5)}ms`); + this.log(` ${pad(label, 17)}${String(ms).padStart(5)}ms`); if (key === 'analysis') { for (const [sk, sl] of [ @@ -175,33 +198,14 @@ export class AnimusWebpackPlugin { ['jsonParse', 'json-parse'], ] as const) { const sms = bt[sk] ?? 0; - const spad = ' '.repeat(Math.max(0, 15 - sl.length)); - this.log(` ${sl}${spad}${String(sms).padStart(5)}ms`); + this.log(` ${pad(sl, 15)}${String(sms).padStart(5)}ms`); if (sk === 'rustExtract' && rustTiming) { - const rustPhases: [string, string][] = [ - ['parseAndWalk', 'parse+walk'], - ['importResolution', 'imports'], - ['extensionProvenance', 'provenance'], - ['topologicalSort', 'topo-sort'], - ['chainEvaluation', 'chains'], - ['jsxScanning', 'jsx-scan'], - ['systemPropAggregation', 'sys-props'], - ['usageLedger', 'usage'], - ['reconciliation', 'reconcile'], - ['cssGeneration', 'css-gen'], - ['manifestSerialization', 'serialize'], - ]; - for (const [rk, rl] of rustPhases) { - const rms = rustTiming[rk] ?? 0; - const rpad = ' '.repeat(Math.max(0, 13 - rl.length)); - const rextra = - rk === 'parseAndWalk' - ? ` (${rustTiming.fileCount ?? 0} files, ${rustTiming.cacheHits ?? 0} cached)` - : ''; - this.log( - ` ${rl}${rpad}${String(rms).padStart(5)}ms${rextra}` - ); + for (const line of formatRustTimingWaterfall(rustTiming, { + indent: ' ', + labelWidth: 13, + })) { + this.log(line); } } } @@ -224,48 +228,48 @@ export class AnimusWebpackPlugin { } private initialized = false; + private aliasesExtracted = false; + + /** Resolve the scan configuration from options — the single source of the + * exclude/extension policy shared by the full and incremental pipelines. */ + private resolveScanConfig(): { + excludePatterns: string[]; + extensionsSet: ReadonlySet; + shouldHandleMdx: boolean; + } { + const extensionsSet: ReadonlySet = new Set( + this.options.extensions ?? DEFAULT_EXTENSIONS + ); + return { + excludePatterns: this.options.exclude ?? DEFAULT_EXCLUDE, + extensionsSet, + shouldHandleMdx: extensionsSet.has('.mdx'), + }; + } - /** Extract path aliases from webpack's resolve.alias config. */ + /** Extract path aliases from webpack's resolve.alias config. Runs once per + * plugin instance — the resolve config is immutable after apply. */ private extractAliases(compiler: Compiler): void { + if (this.aliasesExtracted) return; + this.aliasesExtracted = true; const rootDir = compiler.context; const rawAlias = compiler.options?.resolve?.alias; if (!rawAlias || typeof rawAlias !== 'object') return; - type AliasEntry = { - pattern: string; - replacement: string; - type: 'prefix' | 'exact'; - }; - const entries: AliasEntry[] = []; - // Webpack alias is Record + const pairs: Array<{ pattern: string; target: string }> = []; for (const [key, value] of Object.entries(rawAlias)) { const target = Array.isArray(value) ? value[0] : value; if (typeof target !== 'string') continue; - // Skip our own .animus/styles.css alias - if (key.includes('.animus')) continue; - - const replacement = target.startsWith(rootDir) - ? target.slice(rootDir.length + 1) - : target; - const isExact = /\.\w+$/.test(replacement); - if (isExact) { - entries.push({ pattern: key, replacement, type: 'exact' }); - } else { - entries.push({ - pattern: key.endsWith('/') ? key : key + '/', - replacement: replacement.endsWith('/') - ? replacement - : replacement + '/', - type: 'prefix', - }); - } + // Skip exactly the alias with-animus injected for the emitter's CSS + // import — a consumer alias merely containing '.animus' must survive. + if (key === ANIMUS_CSS_MODULE_ID) continue; + pairs.push({ pattern: key, target }); } - entries.sort((a, b) => b.pattern.length - a.pattern.length); - - if (entries.length > 0) { - this.pathAliasesJson = JSON.stringify({ aliases: entries }); + const built = buildPathAliasesJson(pairs, rootDir); + if (built) { + this.pathAliasesJson = built.json; } } @@ -292,9 +296,8 @@ export class AnimusWebpackPlugin { compilation.updateAsset(cssPath, new RawSource(css)); return; } - const relPath = '.animus/styles.css'; - if (compilation.getAsset(relPath)) { - compilation.updateAsset(relPath, new RawSource(css)); + if (compilation.getAsset(ANIMUS_CSS_MODULE_ID)) { + compilation.updateAsset(ANIMUS_CSS_MODULE_ID, new RawSource(css)); } }); }); @@ -338,12 +341,12 @@ export class AnimusWebpackPlugin { } // Incremental: detect changes and re-analyze if needed - await this.handleWatchUpdate(); + await this.handleWatchUpdate(_compiler); } ); } - private async handleWatchUpdate(): Promise { + private async handleWatchUpdate(compiler: Compiler): Promise { // Guard: if system state was never loaded (non-owning instance that // skipped runFullPipeline), skip — processAssets reads from shared variable if (this.configJson === '') return; @@ -356,7 +359,7 @@ export class AnimusWebpackPlugin { const systemSource = readFileSync(resolvedSystemPath, 'utf-8'); const systemRelPath = relative(rootDir, resolvedSystemPath); const cached = this.fileCache.get(systemRelPath); - const currentHash = createHash('md5').update(systemSource).digest('hex'); + const currentHash = contentHash(systemSource); if (cached && cached.hash !== currentHash) { // Geological reset: system file changed @@ -376,29 +379,59 @@ export class AnimusWebpackPlugin { } // Check for component file changes using content-hash diffing - const excludePatterns = this.options.exclude ?? [ - 'node_modules', - 'dist', - '.test.', - '.spec.', - '.next', - '.animus', - ]; - const extensionsSet: ReadonlySet = new Set( - this.options.extensions ?? DEFAULT_EXTENSIONS - ); - const shouldHandleMdx = extensionsSet.has('.mdx'); - const files = discoverFiles( - rootDir, - rootDir, - excludePatterns, - extensionsSet - ); + const { excludePatterns, extensionsSet, shouldHandleMdx } = + this.resolveScanConfig(); + + // Prune deleted/renamed files so their last-known source stops riding + // along as a ghost entry on every subsequent incremental analysis. + let removedAny = false; + if (compiler.removedFiles) { + for (const removedPath of compiler.removedFiles) { + const rel = relative(rootDir, removedPath); + // MDX cache keys carry the preprocessed `.tsx` suffix. + if (this.fileCache.delete(rel) || this.fileCache.delete(rel + '.tsx')) { + removedAny = true; + } + } + } + + // Restrict the read+hash pass to webpack's modified set when available + // (watchRun provides it after the first compilation); fall back to a full + // discovery walk otherwise. Filters mirror discoverFiles: extension + // allowlist plus substring exclude patterns on both path forms. + let files: string[]; + if (compiler.modifiedFiles) { + files = []; + for (const modifiedPath of compiler.modifiedFiles) { + if (!extensionsSet.has(extname(modifiedPath))) continue; + const rel = relative(rootDir, modifiedPath); + if (rel.startsWith('..')) continue; + if ( + excludePatterns.some( + (pattern) => + modifiedPath.includes(pattern) || rel.includes(pattern) + ) + ) { + continue; + } + files.push(modifiedPath); + } + } else { + files = discoverFiles(rootDir, rootDir, excludePatterns, extensionsSet); + } + const changedPaths: string[] = []; for (const filePath of files) { let relPath = relative(rootDir, filePath); - let source = readFileSync(filePath, 'utf-8'); + let source: string; + try { + source = readFileSync(filePath, 'utf-8'); + } catch { + // Benign race: the file vanished between the watch event and this + // read — it will surface in removedFiles on the next watchRun. + continue; + } if (shouldHandleMdx && extname(filePath) === '.mdx') { // Watch pass stays silent on failure — the full pipeline already @@ -412,7 +445,7 @@ export class AnimusWebpackPlugin { } const cached = this.fileCache.get(relPath); - const hash = createHash('md5').update(source).digest('hex'); + const hash = contentHash(source); if (!cached || cached.hash !== hash) { changedPaths.push(relPath); @@ -420,7 +453,7 @@ export class AnimusWebpackPlugin { } } - if (changedPaths.length > 0) { + if (changedPaths.length > 0 || removedAny) { // Every cached file rides with full source (v2 has no Rust-side cache). const fileEntries = this.buildFileEntriesFromCache(); @@ -492,18 +525,8 @@ export class AnimusWebpackPlugin { // Step 2: Discover source files t = this.now(); - const excludePatterns = this.options.exclude ?? [ - 'node_modules', - 'dist', - '.test.', - '.spec.', - '.next', - '.animus', - ]; - const extensionsSet: ReadonlySet = new Set( - this.options.extensions ?? DEFAULT_EXTENSIONS - ); - const shouldHandleMdx = extensionsSet.has('.mdx'); + const { excludePatterns, extensionsSet, shouldHandleMdx } = + this.resolveScanConfig(); const missingDepFlag = { warned: false }; const files = discoverFiles( rootDir, @@ -516,8 +539,7 @@ export class AnimusWebpackPlugin { // Step 3: Read file sources and build entries (preprocessing MDX as we go) t = this.now(); - const fileEntries: Array<{ path: string; source: string; hash?: string }> = - []; + const fileEntries: FileEntry[] = []; for (const filePath of files) { let source = readFileSync(filePath, 'utf-8'); let relPath = relative(rootDir, filePath); @@ -532,7 +554,7 @@ export class AnimusWebpackPlugin { relPath = processed.relPath; } - const hash = createHash('md5').update(source).digest('hex'); + const hash = contentHash(source); this.fileCache.set(relPath, { hash, source }); fileEntries.push({ path: relPath, source, hash }); } @@ -540,94 +562,49 @@ export class AnimusWebpackPlugin { bt.fileRead = this.elapsed(t); bt.fileCount = fileEntries.length; - // Step 4: Resolve external packages from system file imports + // Step 4: Resolve external packages from system file imports. Workspace + // walk + require.resolve stays here (the bundler-specific seam); the + // traversal/ingest below is the shared collector + // (spec: external-package-file-discovery). t = this.now(); const packageNames = extractSystemFilePackages(resolvedSystemPath); - const packageMap = this.resolvePackagesByName(rootDir, packageNames); - - // Step 4b: Discover and read external package source files - const excludePatternsForPkgs = ['dist', '.test.', '.spec.']; - const collectedPkgDirs: string[] = []; - this.externalSourceEntries.clear(); - - for (const [specifier, entryRelPath] of Object.entries(packageMap)) { - const absEntry = resolve(rootDir, entryRelPath); - - // Find the package root (directory containing package.json) - let pkgRoot = dirname(absEntry); - while ( - pkgRoot !== dirname(pkgRoot) && - !existsSync(join(pkgRoot, 'package.json')) - ) { - pkgRoot = dirname(pkgRoot); - } - - // Discover source files from src/ (builder chains live in source, not dist) - const srcDir = join(pkgRoot, 'src'); - if (existsSync(srcDir)) { - collectedPkgDirs.push(srcDir); + const preResolved = this.resolvePackagesByName(rootDir, packageNames); - // Update packageMap to point to source entry for import resolution - const srcEntry = join(srcDir, 'index.ts'); - if (existsSync(srcEntry)) { - packageMap[specifier] = relative(rootDir, srcEntry); - this.externalSourceEntries.set(specifier, srcEntry); + const collected = await collectExternalPackageSources({ + specifiers: packageNames, + resolveSpecifier: (name) => + preResolved[name] ? resolve(rootDir, preResolved[name]) : null, + rootDir, + extensionsSet, + hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath), + preprocessFile: async (source, relPath, absPath) => { + if (shouldHandleMdx && extname(absPath) === '.mdx') { + return this.preprocessMdxEntry(source, relPath, { + warn: true, + missingDepFlag, + }); } + return { source, relPath }; + }, + onUnreadable: (relPath, err) => + this.warn( + `skipped unreadable package file ${relPath}: ${String(err)}` + ), + }); - const pkgFiles = discoverFiles( - srcDir, - rootDir, - excludePatternsForPkgs, - extensionsSet - ); - for (const pkgFile of pkgFiles) { - let pkgRelPath = relative(rootDir, pkgFile); - if (!fileEntries.some((e) => e.path === pkgRelPath)) { - try { - let pkgSource = readFileSync(pkgFile, 'utf-8'); - - if (shouldHandleMdx && extname(pkgFile) === '.mdx') { - const processed = await this.preprocessMdxEntry( - pkgSource, - pkgRelPath, - { warn: true, missingDepFlag } - ); - if (!processed) continue; - pkgSource = processed.source; - pkgRelPath = processed.relPath; - } - - const pkgHash = createHash('md5').update(pkgSource).digest('hex'); - this.fileCache.set(pkgRelPath, { - hash: pkgHash, - source: pkgSource, - }); - fileEntries.push({ - path: pkgRelPath, - source: pkgSource, - hash: pkgHash, - }); - } catch (err) { - // Not a benign probe: best-effort read of a discovered package - // source file. Skip on failure but surface enough context to - // diagnose an unexpected I/O error. - this.warn( - `skipped unreadable package file ${pkgRelPath}: ${String(err)}` - ); - } - } - } - } else { - // No src/ — fall back to resolved entry directory - collectedPkgDirs.push(dirname(absEntry)); - } + const packageMap = collected.packageMap; + this.externalSourceEntries = collected.sourceEntries; + for (const entry of collected.entries) { + const hash = contentHash(entry.source); + this.fileCache.set(entry.path, { hash, source: entry.source }); + fileEntries.push({ path: entry.path, source: entry.source, hash }); } - this.externalPackageDirs = collectedPkgDirs; + this.externalPackageDirs = collected.packageDirs; // Publish external package state for non-owning compiler instances - setSharedExternalDirs(collectedPkgDirs); - setSharedExternalEntries(this.externalSourceEntries); + setSharedExternalDirs(collected.packageDirs); + setSharedExternalEntries(collected.sourceEntries); bt.packageResolve = this.elapsed(t); @@ -757,12 +734,8 @@ export class AnimusWebpackPlugin { * re-analysis beats a cache-hit path), so it must always receive full sources * (openspec: retire-extract-v1 removed the v1 empty-source cache contract). */ - private buildFileEntriesFromCache(): Array<{ - path: string; - source: string; - hash: string; - }> { - const entries: Array<{ path: string; source: string; hash: string }> = []; + private buildFileEntriesFromCache(): FileEntry[] { + const entries: FileEntry[] = []; for (const [path, { hash, source }] of this.fileCache) { entries.push({ path, source, hash }); } @@ -773,9 +746,7 @@ export class AnimusWebpackPlugin { * Run incremental pipeline with cache-aware file entries. * Reuses system config from the last full pipeline run. */ - private async runIncrementalPipeline( - fileEntries: Array<{ path: string; source: string; hash: string }> - ): Promise { + private async runIncrementalPipeline(fileEntries: FileEntry[]): Promise { const rootDir = this.rootDir!; const bt: Record = {}; const pipelineStart = this.now(); @@ -803,29 +774,26 @@ export class AnimusWebpackPlugin { * styles.css write guard, dynamic-prop config, system-props module emit, * and timing log. The `devMode` flag is the ONLY behavioral fork: * - * - `false` (production): builds args via buildProductionAnalyzeProjectArgs, - * computes bt.analysis + logs the extraction report, and writes - * system-props.js UNCONDITIONALLY (no lastSystemPropsHash guard). - * - `true` (HMR): builds args via buildHmrAnalyzeProjectArgs, skips the - * report log, and guards the system-props.js write by lastSystemPropsHash. + * - `false` (production): computes bt.analysis + logs the extraction + * report, and writes system-props.js UNCONDITIONALLY (no + * lastSystemPropsHash guard). + * - `true` (HMR): skips the report log, and guards the system-props.js + * write by lastSystemPropsHash. * * The styles.css write guard (lastCssHash) is identical on both paths. */ private async analyzeAndEmit( - fileEntries: Array<{ path: string; source: string; hash?: string }>, + fileEntries: FileEntry[], packageMap: Record, devMode: boolean, bt: Record, pipelineStart: number ): Promise { - const rootDir = this.rootDir!; - const { analyzeProject } = engineApi(); - const animusDirPath = join(rootDir, '.animus'); const emitterConfig = JSON.stringify({ runtime_import: '@animus-ui/system/runtime', - css_module_id: '.animus/styles.css', - system_props_module_id: join(animusDirPath, 'system-props.js'), + css_module_id: ANIMUS_CSS_MODULE_ID, + system_props_module_id: join(this.rootDir!, '.animus', 'system-props.js'), }); // Sub-phase: JSON serialize @@ -836,12 +804,10 @@ export class AnimusWebpackPlugin { // Sub-phase: NAPI call. Production vs HMR differ only in the devMode // flag baked into the positional arg tuple. - const buildAnalyzeProjectArgs = devMode - ? buildHmrAnalyzeProjectArgs - : buildProductionAnalyzeProjectArgs; t = this.now(); const manifestJson: string = analyzeProject( ...buildAnalyzeProjectArgs({ + devMode, filesJson: fileEntriesJson, scalesJson: this.themeJson, variableMapJson: this.variableMapJson, @@ -895,13 +861,9 @@ export class AnimusWebpackPlugin { setSharedCss(fullCss); // Disk write serves as HMR trigger only — processAssets replaces content in-memory - const cssHash = createHash('md5').update(fullCss).digest('hex'); + const cssHash = contentHash(fullCss); if (cssHash !== this.lastCssHash) { - const animusDir = join(rootDir, '.animus'); - if (!existsSync(animusDir)) { - mkdirSync(animusDir, { recursive: true }); - } - writeFileSync(join(animusDir, 'styles.css'), fullCss); + this.writeAnimusFile('styles.css', fullCss); this.lastCssHash = cssHash; } @@ -925,24 +887,14 @@ export class AnimusWebpackPlugin { if (devMode) { // HMR: skip the disk write when byte-identical to the last one written. - const systemPropsHash = createHash('md5') - .update(systemPropsContent) - .digest('hex'); + const systemPropsHash = contentHash(systemPropsContent); if (systemPropsHash !== this.lastSystemPropsHash) { - const spDir = join(rootDir, '.animus'); - if (!existsSync(spDir)) { - mkdirSync(spDir, { recursive: true }); - } - writeFileSync(join(spDir, 'system-props.js'), systemPropsContent); + this.writeAnimusFile('system-props.js', systemPropsContent); this.lastSystemPropsHash = systemPropsHash; } } else { // Production: write unconditionally (no lastSystemPropsHash guard). - const animusDir = join(rootDir, '.animus'); - if (!existsSync(animusDir)) { - mkdirSync(animusDir, { recursive: true }); - } - writeFileSync(join(animusDir, 'system-props.js'), systemPropsContent); + this.writeAnimusFile('system-props.js', systemPropsContent); } // Store manifest for loader @@ -952,9 +904,13 @@ export class AnimusWebpackPlugin { this.logBuildTimings(bt, manifest?.timing); } - /** Expose file cache for HMR change detection */ - getFileCache(): Map { - return this.fileCache; + /** Ensure `.animus/` exists and write one generated artifact into it. */ + private writeAnimusFile(name: string, content: string): void { + const dir = join(this.rootDir!, '.animus'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(join(dir, name), content); } /** Expose options for the loader */ diff --git a/packages/next-plugin/src/types.ts b/packages/next-plugin/src/types.ts index af994dbd..e682b8a4 100644 --- a/packages/next-plugin/src/types.ts +++ b/packages/next-plugin/src/types.ts @@ -15,6 +15,15 @@ export interface AnimusNextOptions { extensions?: string[]; /** When true, extraction failures throw instead of warning. */ strict?: boolean; + /** + * Project-root-relative path of the single file that receives the + * `import '.animus/styles.css'` injection (the stylesheet import is + * stripped from every other file to prevent per-chunk CSS duplication). + * Set this when your root entry doesn't match the default detection of + * `app/layout.*` / `pages/_app.*`, optionally under `src/` — e.g. a + * nested App Router root layout like `'src/app/[locale]/layout.tsx'`. + */ + cssImportTarget?: string; /** Enable verbose logging. */ verbose?: boolean; /** Namespace prefix for CSS variables and class names. */ @@ -30,11 +39,12 @@ export interface AnimusNextOptions { */ engine?: 'v2'; /** - * Full `@layer` declaration order. Must include all 7 Animus layers - * (`global`, `base`, `variants`, `compounds`, `states`, `system`, `custom`) - * as a subsequence. Consumer layers may be interleaved. + * Full `@layer` declaration order. Must include all 7 Animus `anm-*` + * layers (`anm-global`, `anm-base`, `anm-variants`, `anm-compounds`, + * `anm-states`, `anm-system`, `anm-custom`) as a subsequence. Consumer + * layers may be interleaved. Names are emitted as-is. * - * @example ['reset', 'global', 'base', 'variants', 'compounds', 'states', 'system', 'custom', 'overrides'] + * @example ['reset', 'anm-global', 'anm-base', 'anm-variants', 'anm-compounds', 'anm-states', 'anm-system', 'anm-custom', 'overrides'] */ layers?: string[]; } diff --git a/packages/next-plugin/src/with-animus.ts b/packages/next-plugin/src/with-animus.ts index 63ebd5c6..5d35e50e 100644 --- a/packages/next-plugin/src/with-animus.ts +++ b/packages/next-plugin/src/with-animus.ts @@ -1,8 +1,9 @@ +import { assembleStylesheet } from '@animus-ui/extract/pipeline'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname, join, resolve, sep } from 'path'; import { fileURLToPath } from 'url'; -import { AnimusWebpackPlugin } from './plugin'; +import { ANIMUS_CSS_MODULE_ID, AnimusWebpackPlugin } from './plugin'; import type { AnimusNextOptions } from './types'; @@ -74,10 +75,15 @@ export function withAnimus( } const stubCssPath = join(animusDir, 'styles.css'); if (!existsSync(stubCssPath)) { - writeFileSync( - stubCssPath, - '@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n' - ); + // Derive the @layer declaration from the shared assembler so the + // stub honors custom `layers` and never drifts from the pipeline. + const { declaration } = assembleStylesheet({ + layers: options.layers, + variableCss: '', + globalCss: '', + split: true, + }); + writeFileSync(stubCssPath, declaration); } // One-time .gitignore check @@ -99,6 +105,13 @@ export function withAnimus( // Inject AnimusWebpackPlugin const plugin = new AnimusWebpackPlugin(options); + // Does this path belong to a collected external DS package dir? + // Single definition so the loader's test/exclude can't drift. + const isExternalPackageFile = (filePath: string): boolean => + plugin + .getExternalPackageDirs() + .some((dir) => filePath.startsWith(dir + sep) || filePath === dir); + config.plugins = config.plugins || []; config.plugins.push(plugin); @@ -106,7 +119,7 @@ export function withAnimus( // relative to each source file. Map it to the absolute path at project root. config.resolve = config.resolve || {}; config.resolve.alias = config.resolve.alias || {}; - config.resolve.alias['.animus/styles.css'] = join( + config.resolve.alias[ANIMUS_CSS_MODULE_ID] = join( rootDir, '.animus', 'styles.css' @@ -183,27 +196,21 @@ export function withAnimus( test: (filePath: string) => { if (/\.[jt]sx?$/.test(filePath)) return true; // Allow .mjs for external DS packages (published dist with builder chains) - if (/\.mjs$/.test(filePath)) { - const pkgDirs = plugin.getExternalPackageDirs(); - return pkgDirs.some( - (dir) => filePath.startsWith(dir + sep) || filePath === dir - ); - } - return false; + return /\.mjs$/.test(filePath) && isExternalPackageFile(filePath); }, exclude: (filePath: string) => { if (!filePath.includes('node_modules')) return false; // Allow external DS packages through - const pkgDirs = plugin.getExternalPackageDirs(); - return !pkgDirs.some( - (dir) => filePath.startsWith(dir + sep) || filePath === dir - ); + return !isExternalPackageFile(filePath); }, enforce: 'pre', use: [ { loader: actualLoaderPath, - options: { strict: options.strict }, + options: { + strict: options.strict, + cssImportTarget: options.cssImportTarget, + }, }, ], }); diff --git a/packages/next-plugin/tests/analyze-project-args.test.ts b/packages/next-plugin/tests/analyze-project-args.test.ts index 44ad2be2..443ccad1 100644 --- a/packages/next-plugin/tests/analyze-project-args.test.ts +++ b/packages/next-plugin/tests/analyze-project-args.test.ts @@ -1,10 +1,6 @@ +import { buildAnalyzeProjectArgs } from '@animus-ui/extract/pipeline'; import { describe, expect, test } from 'vitest'; -import { - buildHmrAnalyzeProjectArgs, - buildProductionAnalyzeProjectArgs, -} from '../src/analyze-project-args'; - const inputs = { filesJson: 'next-files', scalesJson: 'next-scales', @@ -22,7 +18,7 @@ const inputs = { describe('Next analyzeProject argument construction', () => { test('pins all 14 production NAPI slots', () => { - expect(buildProductionAnalyzeProjectArgs(inputs)).toEqual([ + expect(buildAnalyzeProjectArgs({ ...inputs, devMode: false })).toEqual([ 'next-files', 'next-scales', 'next-variable-map', @@ -41,7 +37,7 @@ describe('Next analyzeProject argument construction', () => { }); test('pins all 14 HMR NAPI slots', () => { - expect(buildHmrAnalyzeProjectArgs(inputs)).toEqual([ + expect(buildAnalyzeProjectArgs({ ...inputs, devMode: true })).toEqual([ 'next-files', 'next-scales', 'next-variable-map', diff --git a/packages/next-plugin/tests/loader-css-import.test.ts b/packages/next-plugin/tests/loader-css-import.test.ts new file mode 100644 index 00000000..c93a9432 --- /dev/null +++ b/packages/next-plugin/tests/loader-css-import.test.ts @@ -0,0 +1,108 @@ +/** + * Behavior pins for the loader's single-CSS-import policy: strip the + * emitter-injected stylesheet import everywhere, re-inject it only in the + * root entry — detected by convention (ROOT_ENTRY_RE) or set explicitly via + * the `cssImportTarget` option, which replaces the convention. + * + * The v2 engine adapter passes through paths absent from the last analyze() + * set, so with an empty sent-sources map the loader's transform leg is a + * no-op and only the CSS handling is exercised — no native engine involved. + */ +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import animusLoader from '../src/loader'; + +const MANIFEST_KEY = '__animus_manifest_json__'; +const ENGINE_KEY = '__animus_engine__'; +const V2_ENGINE_KEY = '__animus_v2_engine__'; +const V2_SENT_SOURCES_KEY = '__animus_v2_sent_sources__'; + +const g = globalThis as Record; +let saved: Record; + +const ROOT = '/proj'; +const CSS_IMPORT = "import '.animus/styles.css';\n"; + +beforeEach(() => { + saved = { + [MANIFEST_KEY]: g[MANIFEST_KEY], + [ENGINE_KEY]: g[ENGINE_KEY], + [V2_ENGINE_KEY]: g[V2_ENGINE_KEY], + [V2_SENT_SOURCES_KEY]: g[V2_SENT_SOURCES_KEY], + }; + g[MANIFEST_KEY] = '{}'; + g[ENGINE_KEY] = 'v2'; + g[V2_ENGINE_KEY] = { + transformFile: () => { + throw new Error('engine must not be called for unknown paths'); + }, + }; + g[V2_SENT_SOURCES_KEY] = new Map(); +}); + +afterEach(() => { + Object.assign(g, saved); +}); + +function runLoader( + relPath: string, + source: string, + options: { strict?: boolean; cssImportTarget?: string } = {} +): string { + const ctx = { + resourcePath: join(ROOT, relPath), + rootContext: ROOT, + getOptions: () => options, + }; + return animusLoader.call(ctx, source); +} + +describe('default root-entry detection', () => { + test('injects the CSS import in app/layout.tsx', () => { + const out = runLoader('app/layout.tsx', 'export default function L() {}\n'); + expect(out.startsWith(CSS_IMPORT)).toBe(true); + }); + + test('injects after a "use client" directive', () => { + const out = runLoader( + 'src/app/layout.tsx', + "'use client'\nexport default function L() {}\n" + ); + expect(out).toBe( + "'use client'\nimport '.animus/styles.css';\nexport default function L() {}\n" + ); + }); + + test('strips the emitter CSS import from non-root files', () => { + const out = runLoader( + 'app/page.tsx', + `${CSS_IMPORT}export default function P() {}\n` + ); + expect(out).toBe('export default function P() {}\n'); + }); +}); + +describe('cssImportTarget option', () => { + test('injects only in the configured target', () => { + const target = 'src/app/[locale]/layout.tsx'; + const out = runLoader('src/app/[locale]/layout.tsx', 'export {};\n', { + cssImportTarget: target, + }); + expect(out.startsWith(CSS_IMPORT)).toBe(true); + }); + + test('replaces the default detection — app/layout.tsx no longer injects', () => { + const out = runLoader('app/layout.tsx', 'export {};\n', { + cssImportTarget: 'src/app/[locale]/layout.tsx', + }); + expect(out).toBe('export {};\n'); + }); + + test('tolerates a leading ./ in the configured path', () => { + const out = runLoader('app/root.tsx', 'export {};\n', { + cssImportTarget: './app/root.tsx', + }); + expect(out.startsWith(CSS_IMPORT)).toBe(true); + }); +}); diff --git a/packages/next-plugin/tests/plugin-pipeline.test.ts b/packages/next-plugin/tests/plugin-pipeline.test.ts index 6effaf84..b136219a 100644 --- a/packages/next-plugin/tests/plugin-pipeline.test.ts +++ b/packages/next-plugin/tests/plugin-pipeline.test.ts @@ -280,7 +280,7 @@ describe('production run (full pipeline)', () => { expect(mocks.analyzeProject).toHaveBeenCalledTimes(1); const args = analyzeCall(0); - // Positional NAPI contract (analyze-project-args.ts) + // Positional NAPI contract (buildAnalyzeProjectArgs, @animus-ui/extract/pipeline) expect(args[1]).toBe(SYSTEM_CONFIG.scalesJson); expect(args[2]).toBe(SYSTEM_CONFIG.variableMapJson); expect(args[3]).toBeNull(); @@ -531,6 +531,53 @@ describe('watch mode (dev/HMR)', () => { expect(mocks.analyzeProject).toHaveBeenCalledTimes(2); }); + test('with modifiedFiles present, only listed files are re-read; others replay from cache', async () => { + const root = createProject(); + const { compiler, watchRunHandlers } = createCompiler(root); + applyPlugin(new AnimusWebpackPlugin(OPTIONS), compiler); + + await watchRunHandlers[0](compiler); + + // Both files change on disk, but webpack only reports Button.tsx + writeFileSync(join(root, 'src', 'Button.tsx'), BUTTON_SOURCE_CHANGED); + writeFileSync( + join(root, 'src', 'Other.tsx'), + 'export const Other = 1;\n' + ); + await watchRunHandlers[0]({ + ...compiler, + modifiedFiles: new Set([join(root, 'src', 'Button.tsx')]), + removedFiles: new Set(), + }); + + expect(mocks.analyzeProject).toHaveBeenCalledTimes(2); + const files = parseFiles(analyzeCall(1)); + // The listed file was re-read; the unlisted new file was never scanned + expect(files.find((f) => f.path === 'src/Button.tsx')?.source).toBe( + BUTTON_SOURCE_CHANGED + ); + expect(files.find((f) => f.path === 'src/Other.tsx')).toBeUndefined(); + }); + + test('removedFiles prunes cache entries and triggers re-analysis without ghosts', async () => { + const root = createProject(); + const { compiler, watchRunHandlers } = createCompiler(root); + applyPlugin(new AnimusWebpackPlugin(OPTIONS), compiler); + + await watchRunHandlers[0](compiler); + rmSync(join(root, 'src', 'Button.tsx')); + await watchRunHandlers[0]({ + ...compiler, + modifiedFiles: new Set(), + removedFiles: new Set([join(root, 'src', 'Button.tsx')]), + }); + + expect(mocks.analyzeProject).toHaveBeenCalledTimes(2); + const files = parseFiles(analyzeCall(1)); + expect(files.find((f) => f.path === 'src/Button.tsx')).toBeUndefined(); + expect(files.find((f) => f.path === 'src/system.ts')).toBeDefined(); + }); + test('a non-owning watch instance never re-analyzes, even after file changes', async () => { const root = createProject(); const owner = createCompiler(root); diff --git a/packages/next-plugin/tests/with-animus.test.ts b/packages/next-plugin/tests/with-animus.test.ts index 98594cc1..90a89887 100644 --- a/packages/next-plugin/tests/with-animus.test.ts +++ b/packages/next-plugin/tests/with-animus.test.ts @@ -76,15 +76,16 @@ describe('withAnimus', () => { verbose: true, prefix: 'acme', engine: 'v2', + cssImportTarget: 'src/app/[locale]/layout.tsx', layers: [ 'reset', - 'global', - 'base', - 'variants', - 'compounds', - 'states', - 'system', - 'custom', + 'anm-global', + 'anm-base', + 'anm-variants', + 'anm-compounds', + 'anm-states', + 'anm-system', + 'anm-custom', 'overrides', ], }; @@ -96,5 +97,11 @@ describe('withAnimus', () => { ) as AnimusWebpackPlugin | undefined; expect(plugin?.getOptions()).toEqual(options); + + // Loader-facing subset rides on the rule options + expect(config?.module?.rules?.[0]?.use?.[0]?.options).toEqual({ + strict: true, + cssImportTarget: 'src/app/[locale]/layout.tsx', + }); }); }); diff --git a/packages/system/__tests__/transforms.test.ts b/packages/system/__tests__/transforms.test.ts new file mode 100644 index 00000000..7f82aa54 --- /dev/null +++ b/packages/system/__tests__/transforms.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; + +import { borderShorthand } from '../src/transforms/border'; +import { percentageOrAbsolute, size } from '../src/transforms/size'; + +/** + * Characterization tests for the size + border transform surfaces. + * + * These pin ACTUAL observed behavior (not desired behavior) of two pure + * transform callbacks that had no direct coverage: + * - `percentageOrAbsolute` (exported helper, never called by the transform) + * - the `size` transform callback (calc passthrough, regex parse, toSize) + * - the `borderShorthand` transform callback + * + * Transforms are plain callables (see createTransform.ts: the returned + * NamedTransform IS the callback wrapper), so they are invoked directly. + */ + +describe('percentageOrAbsolute', () => { + it('returns the number 0 (not a string) for 0', () => { + const result = percentageOrAbsolute(0); + expect(result).toBe(0); + expect(typeof result).toBe('number'); + }); + + it.each([ + [0.5, '50%'], + [0.25, '25%'], + [0.75, '75%'], + [1, '100%'], // boundary is inclusive (<= 1) + [-0.5, '-50%'], + [-1, '-100%'], // boundary is inclusive (>= -1) + ] as const)( + 'treats magnitude <= 1 as a percentage: %p -> %p', + (input, expected) => { + expect(percentageOrAbsolute(input)).toBe(expected); + } + ); + + it.each([ + [2, '2px'], + [-2, '-2px'], + [1.5, '1.5px'], + [-1.5, '-1.5px'], + ] as const)( + 'treats magnitude > 1 as absolute px: %p -> %p', + (input, expected) => { + expect(percentageOrAbsolute(input)).toBe(expected); + } + ); +}); + +describe('size transform', () => { + it('exposes its transformName', () => { + expect(size.transformName).toBe('size'); + }); + + describe('numeric input (same toSize semantics as percentageOrAbsolute)', () => { + it('returns the number 0 (not a string) for 0', () => { + const result = size(0); + expect(result).toBe(0); + expect(typeof result).toBe('number'); + }); + + it.each([ + [0.5, '50%'], + [0.25, '25%'], + [1, '100%'], + [-0.5, '-50%'], + [-1, '-100%'], + ] as const)('magnitude <= 1 -> percentage: %p -> %p', (input, expected) => { + expect(size(input)).toBe(expected); + }); + + it.each([ + [2, '2px'], + [-2, '-2px'], + [1.5, '1.5px'], + ] as const)('magnitude > 1 -> px: %p -> %p', (input, expected) => { + expect(size(input)).toBe(expected); + }); + }); + + describe('calc() string passthrough', () => { + it.each(['calc(100% - 10px)', 'calc(50vh + 2rem)'])( + 'returns calc strings unchanged: %p', + (input) => { + expect(size(input)).toBe(input); + } + ); + + it('passes through any string merely CONTAINING "calc" (substring match)', () => { + // Documents the .includes('calc') quirk: non-calc strings that happen + // to contain the substring bypass the numeric parse entirely. + expect(size('calculate-this')).toBe('calculate-this'); + }); + }); + + describe('unitless numeric strings (run through toSize)', () => { + it.each([ + ['10', '10px'], // > 1 -> px + ['2', '2px'], + ['1.5', '1.5px'], + ['1', '100%'], // <= 1 -> percentage + ['0.5', '50%'], + ['-0.5', '-50%'], + ] as const)('%p -> %p', (input, expected) => { + expect(size(input)).toBe(expected); + }); + + it('returns the number 0 (not a string) for the string "0"', () => { + const result = size('0'); + expect(result).toBe(0); + expect(typeof result).toBe('number'); + }); + }); + + describe('unit-bearing strings (unit preserved, NO percentage/px coercion)', () => { + it.each([ + ['10px', '10px'], + ['-10px', '-10px'], + ['10%', '10%'], + ['50%', '50%'], + ['1.5rem', '1.5rem'], + // Key distinction: a sub-1 magnitude WITH a unit is NOT converted to a + // percentage — the unit branch keeps the raw number + unit. + ['0.5rem', '0.5rem'], + ] as const)('%p -> %p', (input, expected) => { + expect(size(input)).toBe(expected); + }); + }); + + describe('non-numeric strings (no regex match -> passthrough)', () => { + it.each(['auto', 'inherit', ''])('returns unchanged: %p', (input) => { + expect(size(input)).toBe(input); + }); + }); +}); + +describe('borderShorthand transform', () => { + it('exposes its transformName', () => { + expect(borderShorthand.transformName).toBe('borderShorthand'); + }); + + it.each([ + [1, '1px solid currentColor'], + [0, '0px solid currentColor'], + [-2, '-2px solid currentColor'], + [1.5, '1.5px solid currentColor'], + [4, '4px solid currentColor'], + ] as const)( + 'composes a number into a "px solid currentColor" shorthand: %p -> %p', + (input, expected) => { + expect(borderShorthand(input)).toBe(expected); + } + ); + + it.each([ + '1px dashed red', + 'thin solid blue', + 'none', + '2', // numeric STRING is passed through, NOT composed + '', + ])('passes non-number values through unchanged: %p', (input) => { + expect(borderShorthand(input)).toBe(input); + }); +}); diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index f0960623..538dedac 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -4,10 +4,14 @@ import { assertNoRetiredEngineSelection, buildAnalyzeProjectArgs, buildDynamicPropConfig, + buildPathAliasesJson, + collectExternalPackageSources, + contentHash, createV2EngineApi, DEFAULT_EXTENSIONS, discoverFiles, extractSystemFilePackages, + formatRustTimingWaterfall, preprocessMdx, stripLeadingLayerDeclaration, surfaceManifestDiagnostics, @@ -15,13 +19,13 @@ import { } from '@animus-ui/extract/pipeline'; import browserslist from 'browserslist'; import { createHash } from 'crypto'; -import { existsSync, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; // Lightning CSS: CSS post-processing (minification + autoprefixing) import { browserslistToTargets, transform as lcssTransform, } from 'lightningcss'; -import { dirname, extname, join, relative, resolve } from 'path'; +import { extname, relative, resolve } from 'path'; import type { V2ExtractEngine } from '@animus-ui/extract/pipeline'; import type { Logger, Plugin } from 'vite'; @@ -165,11 +169,6 @@ function postProcessCss( } } -/** Compute MD5 content hash for a string. */ -function contentHash(source: string): string { - return createHash('md5').update(source).digest('hex'); -} - /** * Reconstruct file entries from cache, including content hashes. * For unchanged files (hash matches changedPath), sends empty source @@ -281,27 +280,11 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { function logTimingWaterfall(timing: Record): void { if (!verbose) return; - const phases: [string, string][] = [ - ['parseAndWalk', 'parse+walk'], - ['importResolution', 'imports'], - ['extensionProvenance', 'provenance'], - ['topologicalSort', 'topo-sort'], - ['chainEvaluation', 'chains'], - ['jsxScanning', 'jsx-scan'], - ['systemPropAggregation', 'sys-props'], - ['usageLedger', 'usage'], - ['reconciliation', 'reconcile'], - ['cssGeneration', 'css-gen'], - ['manifestSerialization', 'serialize'], - ]; - for (const [key, label] of phases) { - const ms = timing[key] ?? 0; - const pad = ' '.repeat(Math.max(0, 15 - label.length)); - const extra = - key === 'parseAndWalk' - ? ` (${timing.fileCount ?? 0} files, ${timing.cacheHits ?? 0} cached)` - : ''; - log(` ${label}${pad}${String(ms).padStart(5)}ms${extra}`); + for (const line of formatRustTimingWaterfall(timing, { + indent: ' ', + labelWidth: 15, + })) { + log(line); } } @@ -629,31 +612,24 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { // This includes aliases from vite-tsconfig-paths, manual resolve.alias, etc. const rawAlias = config.resolve?.alias; if (rawAlias) { - type AliasEntry = { + const pairs: Array<{ pattern: string; - replacement: string; - type: 'prefix' | 'exact'; - }; - const entries: AliasEntry[] = []; + target: string; + kind?: 'prefix'; + }> = []; if (Array.isArray(rawAlias)) { // Array format: [{ find: string | RegExp, replacement: string }] + // String finds are always prefix matches — no extension sniffing. for (const entry of rawAlias) { if ( typeof entry.find === 'string' && typeof entry.replacement === 'string' ) { - const replacement = entry.replacement.startsWith(rootDir) - ? entry.replacement.slice(rootDir.length + 1) - : entry.replacement; - entries.push({ - pattern: entry.find.endsWith('/') - ? entry.find - : entry.find + '/', - replacement: replacement.endsWith('/') - ? replacement - : replacement + '/', - type: 'prefix', + pairs.push({ + pattern: entry.find, + target: entry.replacement, + kind: 'prefix', }); } } @@ -661,32 +637,15 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { // Record format: { '@admin': '/abs/path/to/src' } for (const [key, value] of Object.entries(rawAlias)) { if (typeof value === 'string') { - const replacement = value.startsWith(rootDir) - ? value.slice(rootDir.length + 1) - : value; - // Detect if the alias points to a specific file (exact) or directory (prefix) - const isExact = /\.\w+$/.test(replacement); - if (isExact) { - entries.push({ pattern: key, replacement, type: 'exact' }); - } else { - entries.push({ - pattern: key.endsWith('/') ? key : key + '/', - replacement: replacement.endsWith('/') - ? replacement - : replacement + '/', - type: 'prefix', - }); - } + pairs.push({ pattern: key, target: value }); } } } - // Sort longest prefix first for correct matching priority - entries.sort((a, b) => b.pattern.length - a.pattern.length); - - if (entries.length > 0) { - pathAliasesJson = JSON.stringify({ aliases: entries }); - log(`Path aliases forwarded: ${entries.length} entries`); + const built = buildPathAliasesJson(pairs, rootDir); + if (built) { + pathAliasesJson = built.json; + log(`Path aliases forwarded: ${built.count} entries`); } } }, @@ -785,115 +744,59 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { const localFileCount = fileEntries.length; const packageSpecifiers = extractSystemFilePackages(resolvedSystemPath!); - packageMap = {}; externalSourceEntries.clear(); - const collectedPkgDirs: string[] = []; - for (const specifier of packageSpecifiers) { - try { + // Shared traversal/ingest (spec: external-package-file-discovery); + // only specifier resolution, MDX handling, and the hash/cache policy + // below stay bundler-specific. + const collected = await collectExternalPackageSources({ + specifiers: packageSpecifiers, + resolveSpecifier: async (specifier) => { const resolved = await this.resolve(specifier); - if (resolved && resolved.id) { - const absPath = resolved.id; - - // Find the package root (directory containing package.json) - // by walking up from the resolved entry point. - let pkgRoot = dirname(absPath); - while ( - pkgRoot !== dirname(pkgRoot) && - !existsSync(join(pkgRoot, 'package.json')) - ) { - pkgRoot = dirname(pkgRoot); + return resolved?.id ?? null; + }, + rootDir, + extensionsSet, + hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath), + preprocessFile: async (source, relPath, absPath) => { + if (shouldHandleMdx && extname(absPath) === '.mdx') { + const result = await preprocessMdx(source, relPath); + if (result.kind === 'missing-dep') { + if (!mdxMissingDepWarned) { + warn( + '⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped' + ); + mdxMissingDepWarned = true; + } + return null; } - - // Discover source files from src/ (builder chains live in source, not dist) - const srcDir = join(pkgRoot, 'src'); - if (existsSync(srcDir)) { - collectedPkgDirs.push(srcDir); - const pkgFiles = discoverFiles( - srcDir, - rootDir, - excludePatterns, - extensionsSet + if (result.kind === 'error') { + warn( + `⚠ MDX preprocessing failed for ${relPath}: ${result.error}` ); - for (const pkgFile of pkgFiles) { - let pkgRelPath = relative(rootDir, pkgFile); - if (!fileEntries.some((e) => e.path === pkgRelPath)) { - let pkgSource = readFileSync(pkgFile, 'utf-8'); - - if (shouldHandleMdx && extname(pkgFile) === '.mdx') { - const result = await preprocessMdx(pkgSource, pkgRelPath); - if (result.kind === 'missing-dep') { - if (!mdxMissingDepWarned) { - warn( - '⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped' - ); - mdxMissingDepWarned = true; - } - continue; - } - if (result.kind === 'error') { - warn( - `⚠ MDX preprocessing failed for ${pkgRelPath}: ${result.error}` - ); - continue; - } - pkgSource = result.source!; - pkgRelPath = pkgRelPath + '.tsx'; - } - - const pkgHash = !isProd ? contentHash(pkgSource) : undefined; - fileEntries.push({ - path: pkgRelPath, - source: pkgSource, - hash: pkgHash, - }); - if (!isProd && pkgHash) { - fileCache.set(pkgRelPath, { - hash: pkgHash, - source: pkgSource, - }); - } - } - } - - // Package map entry points to the source entry (for import resolution) - const srcEntry = join(srcDir, 'index.ts'); - if (existsSync(srcEntry)) { - packageMap[specifier] = relative(rootDir, srcEntry); - externalSourceEntries.set(specifier, srcEntry); - } else { - packageMap[specifier] = relative(rootDir, absPath); - } - } else { - // No src/ directory — fall back to the resolved entry - const relPath = relative(rootDir, absPath); - if (!fileEntries.some((e) => e.path === relPath)) { - const entrySource = readFileSync(absPath, 'utf-8'); - const entryHash = !isProd - ? contentHash(entrySource) - : undefined; - fileEntries.push({ - path: relPath, - source: entrySource, - hash: entryHash, - }); - if (!isProd && entryHash) { - fileCache.set(relPath, { - hash: entryHash, - source: entrySource, - }); - } - } - collectedPkgDirs.push(dirname(absPath)); - packageMap[specifier] = relPath; + return null; } + return { source: result.source!, relPath: relPath + '.tsx' }; } - } catch { - // Resolution failed — specifier is truly external, skip + return { source, relPath }; + }, + onUnreadable: (relPath, err) => + warn(`skipped unreadable package file ${relPath}: ${String(err)}`), + }); + + packageMap = collected.packageMap; + for (const [specifier, srcEntry] of collected.sourceEntries) { + externalSourceEntries.set(specifier, srcEntry); + } + for (const entry of collected.entries) { + const hash = !isProd ? contentHash(entry.source) : undefined; + fileEntries.push({ path: entry.path, source: entry.source, hash }); + if (!isProd && hash) { + fileCache.set(entry.path, { hash, source: entry.source }); } } - externalPackageDirs = collectedPkgDirs; + externalPackageDirs = collected.packageDirs; const packageFileCount = fileEntries.length - localFileCount; log( diff --git a/scripts/hygiene/CLAUDE.md b/scripts/hygiene/CLAUDE.md index 48685375..599f4e36 100644 --- a/scripts/hygiene/CLAUDE.md +++ b/scripts/hygiene/CLAUDE.md @@ -25,9 +25,13 @@ The orchestrator's exit code and summary are computed from `.hygiene/receipts.js - `cap-hit-clean` — cap hit with zero deletes in the final iteration. Emits `INFO: cascade settled at iteration cap (idempotent A/B churn caused fingerprint drift)`. Exit 0. - `cap-hit-divergent` — final iteration still has deletes from Layer C/D/D1. Emits `WARN: cascade did not converge — Layer still deleting at iteration N`. Exit non-zero. +Independently of convergence, a **risky whole-file deletion** forces exit non-zero: if any `layer="D" verb="delete" kind="file"` record is present and the run recorded no behavior-build proof (`extras.behaviorBuildProof`), the verdict sets `riskyDeletion=true` and `suggestedExitCode=1`. `run.sh` runs the compile+lint safety envelope and then exits with `suggestedExitCode`, so a whole-file deletion cannot finish green on compile+lint alone (design D5, guardrail G7). The behavior-build-proof marker is the seam for lazy row 04 / DEF-3. + ## Layer-Specific Notes -**Layer D NOTE**: when receipts include ≥1 `layer="D" kind="file"` OR ≥5 `layer="D" kind∈{"export-clause","export-default"}` records, the summary appends a `NOTE` recommending `vp run verify:full` before committing. Build-time-only consumers (vite virtual modules, MDX, Rust extractor) are invisible to knip; the NOTE is a nudge, not a precondition. Does NOT change exit code. +**Layer D whole-file deletion (blocking)**: when receipts include ≥1 `layer="D" kind="file"` record without a behavior-build proof, the summary appends a `MANUAL REVIEW REQUIRED:` line and the run exits non-zero. Build-time-only consumers (vite virtual modules, MDX, Rust extractor) are invisible to knip, so compile+lint cannot vouch for a deleted file. + +**Layer D export NOTE**: when receipts include ≥5 `layer="D" kind∈{"export-clause","export-default"}` records, the summary appends an informational `NOTE: Layer D removed exports` recommending `vp run verify:full` before committing. Export-only cleanup is a nudge, not a precondition — it does NOT change the exit code. **Layer C code-drift WARN**: if oxlint reports diagnostics but ZERO of them match Layer C's `TARGET_CODES` after the `eslint(...)` wrapper is unwrapped, a `WARN: oxlint diagnostics present but none matched known codes — oxlint may have renamed.` line surfaces with the codes seen. Closes the session-89 silent-no-op regression class. diff --git a/scripts/hygiene/presenter.test.ts b/scripts/hygiene/presenter.test.ts index f73ad575..5ad8e5f6 100644 --- a/scripts/hygiene/presenter.test.ts +++ b/scripts/hygiene/presenter.test.ts @@ -15,7 +15,7 @@ import { LAYER_D_FILE_THRESHOLD, parseReceipts, type Verdict, -} from './presenter.ts'; +} from './presenter'; import type { Receipt } from './_receipts'; @@ -186,7 +186,7 @@ describe('analyze: convergence verdict', () => { }); describe('analyze: Layer D volume NOTE', () => { - test('triggers on 1 file removal', () => { + test('1 file removal blocks with manual-review (fail-closed, G7)', () => { const records = [ rec({ iter: 1, @@ -198,9 +198,15 @@ describe('analyze: Layer D volume NOTE', () => { ]; const v = analyze(records, 5); expect(v.layerDVolume.files).toBe(1); + expect(v.riskyDeletion).toBe(true); + expect(v.suggestedExitCode).toBe(1); expect( - v.summaryLines.some((l) => l.startsWith('NOTE: Layer D removed')) + v.summaryLines.some((l) => l.startsWith('MANUAL REVIEW REQUIRED')) ).toBe(true); + // The informational export NOTE must NOT fire for a file-only deletion. + expect( + v.summaryLines.some((l) => l.startsWith('NOTE: Layer D removed')) + ).toBe(false); }); test('triggers on 5 export removals', () => { @@ -298,7 +304,34 @@ describe('analyze: code-drift', () => { }); describe('analyze: combined signals', () => { - test('Layer D NOTE on cap-hit-clean does not change exit code', () => { + test('export-only nudge on cap-hit-clean does not change exit code', () => { + // Export-only cleanup (no whole-file deletion) keeps the informational + // nudge and the successful exit — the spec's "export-only cleanup + // completes" scenario. + const records: Receipt[] = []; + for (let i = 1; i <= 4; i++) { + records.push( + rec({ iter: i, layer: 'D', verb: 'delete', kind: 'export-clause' }) + ); + records.push( + rec({ iter: i, layer: 'D', verb: 'delete', kind: 'export-clause' }) + ); + } + records.push( + rec({ iter: 5, layer: 'A', verb: 'format', kind: 'format-only' }) + ); + const v: Verdict = analyze(records, 5); + expect(v.convergence).toBe('cap-hit-clean'); + expect(v.layerDVolume.files).toBe(0); + expect(v.layerDVolume.exports).toBe(8); + expect(v.riskyDeletion).toBe(false); + expect(v.suggestedExitCode).toBe(0); + expect(v.summaryLines.length).toBe(2); // INFO + NOTE + }); + + test('whole-file deletion on cap-hit-clean forces manual-review exit (G7)', () => { + // The same clean-convergence shape but with whole-file deletions: the + // fail-closed policy overrides the otherwise-successful exit. const records: Receipt[] = []; for (let i = 1; i <= 4; i++) { records.push( @@ -317,7 +350,62 @@ describe('analyze: combined signals', () => { const v: Verdict = analyze(records, 5); expect(v.convergence).toBe('cap-hit-clean'); expect(v.layerDVolume.files).toBe(4); + expect(v.riskyDeletion).toBe(true); + expect(v.suggestedExitCode).toBe(1); + expect( + v.summaryLines.some((l) => l.startsWith('MANUAL REVIEW REQUIRED')) + ).toBe(true); + }); +}); + +describe('analyze: risky whole-file deletion (G7)', () => { + test('requires manual review after whole-file deletion', () => { + // A converged receipt stream (no divergence) containing one Layer D + // verb=delete kind=file record must return a non-zero suggested exit and + // an explicit manual-review summary line. + const records = [ + rec({ iter: 1, layer: 'C', verb: 'delete', kind: 'const-decl' }), + rec({ + iter: 1, + layer: 'D', + verb: 'delete', + kind: 'file', + target: 'orphan-module.ts', + }), + rec({ iter: 2, layer: 'A', verb: 'format', kind: 'format-only' }), + ]; + const v = analyze(records, 5); + expect(v.convergence).toBe('converged'); + expect(v.finalIterationDeletes).toBe(0); + expect(v.riskyDeletion).toBe(true); + expect(v.suggestedExitCode).toBe(1); + expect( + v.summaryLines.some((l) => l.startsWith('MANUAL REVIEW REQUIRED')) + ).toBe(true); + }); + + test('behavior-build proof suppresses the block', () => { + // The explicit seam for DEF-3 / row 04: a recorded proof marker clears the + // risky-deletion block without reshaping the verdict. A trailing clean + // iteration keeps convergence out of cap-hit-divergent so the proof effect + // is isolated. + const records = [ + rec({ + iter: 1, + layer: 'D', + verb: 'delete', + kind: 'file', + target: 'orphan.ts', + extras: { behaviorBuildProof: true }, + }), + rec({ iter: 2, layer: 'A', verb: 'format', kind: 'format-only' }), + ]; + const v = analyze(records, 5); + expect(v.convergence).toBe('converged'); + expect(v.riskyDeletion).toBe(false); expect(v.suggestedExitCode).toBe(0); - expect(v.summaryLines.length).toBe(2); // INFO + NOTE + expect( + v.summaryLines.some((l) => l.startsWith('MANUAL REVIEW REQUIRED')) + ).toBe(false); }); }); diff --git a/scripts/hygiene/presenter.ts b/scripts/hygiene/presenter.ts index 7b975dcc..289c4560 100644 --- a/scripts/hygiene/presenter.ts +++ b/scripts/hygiene/presenter.ts @@ -36,6 +36,11 @@ export interface Verdict { iterationCap: number; finalIterationDeletes: number; layerDVolume: LayerDVolume; + // Fail-closed whole-file deletion signal (design D5, guardrail G7): true when + // Layer D removed ≥1 whole file and the run recorded no behavior-build proof. + // Forces suggestedExitCode=1 so a risky deletion cannot finish green on the + // compile+lint safety envelope alone. + riskyDeletion: boolean; codeDrift?: string[]; suggestedExitCode: 0 | 1; summaryLines: string[]; @@ -123,6 +128,16 @@ function layerDVolume(records: Receipt[]): LayerDVolume { return { files, exports: exports_ }; } +// A behavior-build proof attests that every whole-file deletion in the run was +// validated against its behavior consumers (not just compile+lint). No cascade +// layer emits one today — that automatic build selection is DEF-3 / lazy row 04 +// — so this is currently always false and any whole-file deletion is risky. The +// seam is explicit (an `extras.behaviorBuildProof` marker) so row 04 can attach +// proof later without reshaping the verdict. +function hasBehaviorBuildProof(records: Receipt[]): boolean { + return records.some((r) => r.extras?.behaviorBuildProof === true); +} + function codeDrift(records: Receipt[]): string[] | undefined { const seen = new Set(); for (const r of records) { @@ -137,42 +152,34 @@ function codeDrift(records: Receipt[]): string[] | undefined { return seen.size > 0 ? [...seen].sort() : undefined; } -export function analyze( - records: Receipt[], - cap: number, - ranIters?: number -): Verdict { - const byIter = partitionByIter(records); - const iters = [...byIter.keys()].sort((a, b) => a - b); - const lastReceiptIter = iters.length > 0 ? iters[iters.length - 1] : 0; - // The cascade may have run iterations beyond the last one that produced any - // receipts (e.g., a clean iteration 2 with zero diagnostics emits no - // records). Trust the orchestrator-supplied ranIters when it exceeds the - // receipt-derived final iter — those silent iterations are convergence - // evidence, not absence of evidence. - const finalIteration = - ranIters !== undefined && ranIters > lastReceiptIter - ? ranIters - : lastReceiptIter; - const finalRecords = - finalIteration > 0 ? (byIter.get(finalIteration) ?? []) : []; - const finalIterationDeletes = deleteCount(finalRecords); - - let convergence: Convergence; +// Maps the final-iteration signal to the three-way cascade verdict. Kept +// separate from analyze so the branch ladder reads as one decision. +function classifyConvergence( + finalIterationDeletes: number, + finalIteration: number, + cap: number +): Convergence { if (finalIterationDeletes > 0) { // Divergent regardless of whether cap was actually hit — the spec frames // both as "cap-hit-divergent" because the user-facing semantics are the // same: cascade did not settle, manual review needed. - convergence = 'cap-hit-divergent'; - } else if (finalIteration < cap) { - convergence = 'converged'; - } else { - convergence = 'cap-hit-clean'; + return 'cap-hit-divergent'; } + if (finalIteration < cap) return 'converged'; + return 'cap-hit-clean'; +} - const volume = layerDVolume(records); - const drift = codeDrift(records); - +// Renders the human-facing summary lines from the already-computed verdict +// dimensions (convergence, Layer D volume, code-drift). Pure string assembly: +// one convergence line, then optional volume NOTE and drift WARN. +function buildSummaryLines( + convergence: Convergence, + finalIteration: number, + volume: LayerDVolume, + riskyDeletion: boolean, + drift: string[] | undefined, + finalRecords: Receipt[] +): string[] { const summaryLines: string[] = []; if (convergence === 'converged') { @@ -192,12 +199,20 @@ export function analyze( ); } - if ( - volume.files >= LAYER_D_FILE_THRESHOLD || - volume.exports >= LAYER_D_EXPORT_THRESHOLD - ) { + // Whole-file deletion is fail-closed (design D5, G7): it now BLOCKS with a + // manual-review message (exit non-zero) rather than the prior informational + // NOTE, because compile+lint cannot see build-time-only consumers. + if (riskyDeletion) { + summaryLines.push( + `MANUAL REVIEW REQUIRED: Layer D deleted ${volume.files} whole file(s) without behavior-build proof. Build-time consumers (vite virtual modules, MDX, Rust extractor) are invisible to knip — run \`vp run verify:full\`, confirm nothing broke, then re-run before committing.` + ); + } + + // Export-volume cleanup stays an informational nudge (does not change the + // exit code); retained from the prior NOTE. + if (volume.exports >= LAYER_D_EXPORT_THRESHOLD) { summaryLines.push( - `NOTE: Layer D removed ${volume.exports} exports / ${volume.files} files. Build-time consumers (vite virtual modules, MDX, custom plugins) are invisible to knip — run \`vp run verify:full\` before committing.` + `NOTE: Layer D removed ${volume.exports} exports. Build-time consumers (vite virtual modules, MDX, custom plugins) are invisible to knip — run \`vp run verify:full\` before committing.` ); } @@ -207,7 +222,51 @@ export function analyze( ); } - const suggestedExitCode: 0 | 1 = convergence === 'cap-hit-divergent' ? 1 : 0; + return summaryLines; +} + +export function analyze( + records: Receipt[], + cap: number, + ranIters?: number +): Verdict { + const byIter = partitionByIter(records); + const iters = [...byIter.keys()].sort((a, b) => a - b); + const lastReceiptIter = iters.length > 0 ? iters[iters.length - 1] : 0; + // The cascade may have run iterations beyond the last one that produced any + // receipts (e.g., a clean iteration 2 with zero diagnostics emits no + // records). Trust the orchestrator-supplied ranIters when it exceeds the + // receipt-derived final iter — those silent iterations are convergence + // evidence, not absence of evidence. + const finalIteration = + ranIters !== undefined && ranIters > lastReceiptIter + ? ranIters + : lastReceiptIter; + const finalRecords = + finalIteration > 0 ? (byIter.get(finalIteration) ?? []) : []; + const finalIterationDeletes = deleteCount(finalRecords); + + const convergence = classifyConvergence( + finalIterationDeletes, + finalIteration, + cap + ); + + const volume = layerDVolume(records); + const drift = codeDrift(records); + const riskyDeletion = volume.files > 0 && !hasBehaviorBuildProof(records); + + const summaryLines = buildSummaryLines( + convergence, + finalIteration, + volume, + riskyDeletion, + drift, + finalRecords + ); + + const suggestedExitCode: 0 | 1 = + convergence === 'cap-hit-divergent' || riskyDeletion ? 1 : 0; const verdict: Verdict = { convergence, @@ -215,6 +274,7 @@ export function analyze( iterationCap: cap, finalIterationDeletes, layerDVolume: volume, + riskyDeletion, suggestedExitCode, summaryLines, }; diff --git a/scripts/hygiene/reconcile-after-knip.ts b/scripts/hygiene/reconcile-after-knip.ts index dc03c0b9..b1ecdeec 100644 --- a/scripts/hygiene/reconcile-after-knip.ts +++ b/scripts/hygiene/reconcile-after-knip.ts @@ -164,6 +164,23 @@ function collectBindingNames(name: Node, out: Set): void { } } +// True for the declaration forms that introduce a single named binding we can +// register as an export directly (`export function/class/interface/type/enum +// X`). VariableDeclaration is handled separately (it may bind many names via +// destructuring — see collectBindingNames). Narrows `id` from the Node index +// signature's `any` to a present Node, so callers can read `decl.id.name` +// without a further null check. Mirrors the isReceipt() type-guard precedent. +function isNamedDeclaration(decl: Node): decl is Node & { id: Node } { + return ( + (decl.type === 'FunctionDeclaration' || + decl.type === 'ClassDeclaration' || + decl.type === 'TSInterfaceDeclaration' || + decl.type === 'TSTypeAliasDeclaration' || + decl.type === 'TSEnumDeclaration') && + Boolean(decl.id) + ); +} + export function getExportsOfFile(filePath: string): Set { const source = readFileSync(filePath, 'utf-8'); const program = parseSync(filePath, source, { lang: langFor(filePath) }) @@ -188,14 +205,7 @@ export function getExportsOfFile(filePath: string): Set { for (const d of decl.declarations as Node[]) { collectBindingNames(d.id, exports); } - } else if ( - (decl.type === 'FunctionDeclaration' || - decl.type === 'ClassDeclaration' || - decl.type === 'TSInterfaceDeclaration' || - decl.type === 'TSTypeAliasDeclaration' || - decl.type === 'TSEnumDeclaration') && - decl.id - ) { + } else if (isNamedDeclaration(decl)) { exports.add(decl.id.name); } return; diff --git a/scripts/verify/_preconditions.sh b/scripts/verify/_preconditions.sh index ac1e80a4..e8924843 100755 --- a/scripts/verify/_preconditions.sh +++ b/scripts/verify/_preconditions.sh @@ -39,9 +39,18 @@ require_bun_install() { } require_fresh_napi_v2() { - local napi_binary - napi_binary=$(ls packages/extract/crates/extract-v2/*.node 2>/dev/null | head -n1 || true) + # Resolve the EXACT binary this host's loader executes (design decision D7), + # never the lexically-first *.node — a foreign-target artifact's timestamp + # says nothing about the code the host runs. napi-target.ts prints the + # host-native repo-relative path (or fails loud on an unreleased host). + local script_dir napi_binary + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + napi_binary=$(bun "$script_dir/napi-target.ts" 2>/dev/null || true) if [ -z "$napi_binary" ]; then + echo "ERROR: unsupported host for v2 NAPI freshness (no released target for this platform/arch/libc). Run: vp run build:extract-v2" >&2 + return 1 + fi + if [ ! -f "$napi_binary" ]; then echo "ERROR: v2 NAPI binary missing. Run: vp run build:extract-v2" >&2 return 1 fi @@ -58,7 +67,7 @@ require_fresh_napi_v2() { packages/extract/crates/system-loader/rust-toolchain.toml \ -type f -newer "$napi_binary" -print -quit 2>/dev/null || true) if [ -n "$newest_input" ]; then - echo "ERROR: v2 NAPI binary stale (Rust input newer: $newest_input). Run: vp run build:extract-v2" >&2 + echo "ERROR: host v2 NAPI binary stale: $napi_binary (Rust input newer: $newest_input). Run: vp run build:extract-v2" >&2 return 1 fi } diff --git a/scripts/verify/attw-def5.test.ts b/scripts/verify/attw-def5.test.ts new file mode 100644 index 00000000..3b330d60 --- /dev/null +++ b/scripts/verify/attw-def5.test.ts @@ -0,0 +1,139 @@ +// scripts/verify/attw-def5.test.ts +// +// Behavior tests for the bounded DEF-5 type-resolution gate (design D5/DEF-2, +// G6, spec "Suppressed type diagnostics remain bounded"). Fixtures are minimal +// attw-shaped analysis objects; the validator runs against them, not against a +// script's source string (design D6). + +import { describe, expect, test } from 'vitest'; + +import { + DEF5_BASELINE, + evaluateDef5, + isEsmOnlyVisible, + resolutionTag, +} from './attw-def5'; + +const PKG = '@animus-ui/properties'; + +// Builds an esm-only-visible internal-resolution-error problem for the +// properties package. +function ire(file: string, specifier: string) { + return { + kind: 'InternalResolutionError', + resolutionOption: 'node16', + resolutionMode: 99, + fileName: `/node_modules/${PKG}/${file}`, + moduleSpecifier: specifier, + }; +} + +// An ignored-mode problem (node10 / node16-cjs) — invisible to the gate. +const NODE10_NO_RESOLUTION = { + kind: 'NoResolution', + entrypoint: '.', + resolutionKind: 'node10', +}; +const CJS_RESOLVES_TO_ESM = { + kind: 'CJSResolvesToESM', + entrypoint: '.', + resolutionKind: 'node16-cjs', +}; + +function analysisWith(problems: unknown[]) { + return { packageName: PKG, problems }; +} + +// The exact baseline for properties, rendered as attw problems. +function baselineProblems() { + return DEF5_BASELINE[PKG].map((t) => ire(t.file, t.specifier)); +} + +describe('resolutionTag / isEsmOnlyVisible', () => { + test('node16 + mode 99 → node16-esm, visible', () => { + const p = ire('dist/index.d.ts', './shorthands'); + expect(resolutionTag(p)).toBe('node16-esm'); + expect(isEsmOnlyVisible(p)).toBe(true); + }); + + test('node10 NoResolution is ignored', () => { + expect(resolutionTag(NODE10_NO_RESOLUTION)).toBe('node10'); + expect(isEsmOnlyVisible(NODE10_NO_RESOLUTION)).toBe(false); + }); + + test('node16-cjs CJSResolvesToESM is ignored', () => { + expect(resolutionTag(CJS_RESOLVES_TO_ESM)).toBe('node16-cjs'); + expect(isEsmOnlyVisible(CJS_RESOLVES_TO_ESM)).toBe(false); + }); +}); + +describe('evaluateDef5: exact-set acceptance', () => { + test('baseline-exact set passes, ignored-mode noise does not matter', () => { + const analysis = analysisWith([ + ...baselineProblems(), + NODE10_NO_RESOLUTION, + CJS_RESOLVES_TO_ESM, + ]); + const r = evaluateDef5(analysis, PKG); + expect(r.ok).toBe(true); + expect(r.messages[0]).toMatch(/bounded DEF-5 diagnostic/); + }); + + test('duplicate occurrences of the same tuple collapse (still exact)', () => { + const analysis = analysisWith([ + ...baselineProblems(), + ire('dist/index.d.ts', './shorthands'), // duplicate of a baseline tuple + ]); + expect(evaluateDef5(analysis, PKG).ok).toBe(true); + }); +}); + +describe('evaluateDef5: fail-closed on drift', () => { + test('an ADDITIONAL internal-resolution-error fails and is reported', () => { + const analysis = analysisWith([ + ...baselineProblems(), + ire('dist/index.d.ts', './brand-new-module'), + ]); + const r = evaluateDef5(analysis, PKG); + expect(r.ok).toBe(false); + expect( + r.messages.some( + (m) => m.includes('ADDED') && m.includes('./brand-new-module') + ) + ).toBe(true); + }); + + test('a REMOVED (now-resolving) baseline diagnostic fails', () => { + // Drop one baseline tuple: the accepted diagnostic now resolves, so the + // exemption is obsolete and must be trimmed. + const kept = baselineProblems().slice(1); + const r = evaluateDef5(analysisWith(kept), PKG); + expect(r.ok).toBe(false); + expect(r.messages.some((m) => m.startsWith('REMOVED'))).toBe(true); + }); + + test('a NEW esm-only-visible non-IRE problem fails', () => { + const analysis = analysisWith([ + ...baselineProblems(), + { + kind: 'NoResolution', + entrypoint: './new-entry', + resolutionKind: 'node16-esm', // visible under esm-only + }, + ]); + const r = evaluateDef5(analysis, PKG); + expect(r.ok).toBe(false); + expect( + r.messages.some( + (m) => + m.includes('ADDED non-resolution problem') && + m.includes('NoResolution') + ) + ).toBe(true); + }); + + test('malformed analysis fails closed', () => { + expect(evaluateDef5(null, PKG).ok).toBe(false); + expect(evaluateDef5(undefined, PKG).ok).toBe(false); + }); +}); diff --git a/scripts/verify/attw-def5.ts b/scripts/verify/attw-def5.ts new file mode 100644 index 00000000..edcf941c --- /dev/null +++ b/scripts/verify/attw-def5.ts @@ -0,0 +1,265 @@ +#!/usr/bin/env bun +// scripts/verify/attw-def5.ts +// +// Bounded DEF-5 type-resolution gate (design D5/DEF-2, guardrail G6, spec +// "Suppressed type diagnostics remain bounded"). Replaces the broad +// `attw --ignore-rules internal-resolution-error` allowlist with an exact-set +// validator: the ESM-only-visible InternalResolutionError diagnostics for +// @animus-ui/properties and @animus-ui/system must match the captured DEF-5 set +// EXACTLY. It fails closed on: +// - an ADDITIONAL internal-resolution-error (a new declaration import that +// fails node16-ESM resolution), OR +// - a REMOVAL (an accepted DEF-5 diagnostic that now resolves — the exemption +// is obsolete and must be trimmed), OR +// - any OTHER esm-only-visible problem kind (a new failure of a different +// shape must not slip through the narrowed gate). +// +// Empirical basis (attw 0.18.5, TypeScript 7.0.2 declaration emit): the DEF-5 +// gap still reproduces — properties/system source uses extensionless relative +// imports under `moduleResolution: bundler`, which the compiler emits verbatim; +// those specifiers fail node16-ESM resolution. Migrating every specifier to +// explicit `.js` extensions is a cross-cutting source change out of this +// increment's scope, so the diagnostics are bounded exactly rather than removed. +// +// Usage (packed.sh pipes attw JSON in; attw exits non-zero on findings, so the +// caller captures its output with `|| true` and this validator owns the verdict): +// bunx attw --profile esm-only -f json | \ +// bun scripts/verify/attw-def5.ts check @animus-ui/ + +import { readFileSync } from 'node:fs'; + +export interface Def5Tuple { + file: string; // path relative to the package root, e.g. dist/index.d.ts + specifier: string; // the unresolved module specifier, e.g. ./shorthands +} + +// The exact accepted DEF-5 diagnostic set, captured from a fresh `build:ts` +// declaration emit + `bunx attw --profile esm-only -f json` (no ignored rule). +// Regenerate ONLY with a recorded rationale: any drift here is a real change in +// the published declaration surface. Keyed by published package name. +export const DEF5_BASELINE: Record = { + '@animus-ui/properties': [ + { file: 'dist/index.d.ts', specifier: './shorthands' }, + { file: 'dist/index.d.ts', specifier: './unitless' }, + ], + '@animus-ui/system': [ + { file: 'dist/compose.d.ts', specifier: './types/component' }, + { file: 'dist/composeWithContext.d.ts', specifier: './types/component' }, + { file: 'dist/groups/index.d.ts', specifier: '..' }, + { file: 'dist/index.d.ts', specifier: './Animus' }, + { file: 'dist/index.d.ts', specifier: './AnimusExtended' }, + { file: 'dist/index.d.ts', specifier: './SystemBuilder' }, + { file: 'dist/index.d.ts', specifier: './compose' }, + { file: 'dist/index.d.ts', specifier: './keyframes' }, + { file: 'dist/index.d.ts', specifier: './runtime' }, + { file: 'dist/index.d.ts', specifier: './runtime/createClassResolver' }, + { file: 'dist/index.d.ts', specifier: './runtime/createComposedFamily' }, + { file: 'dist/index.d.ts', specifier: './scales/createScale' }, + { file: 'dist/index.d.ts', specifier: './selectors' }, + { file: 'dist/index.d.ts', specifier: './theme' }, + { file: 'dist/index.d.ts', specifier: './transforms/border' }, + { file: 'dist/index.d.ts', specifier: './transforms/createTransform' }, + { file: 'dist/index.d.ts', specifier: './transforms/grid' }, + { file: 'dist/index.d.ts', specifier: './transforms/size' }, + { file: 'dist/index.d.ts', specifier: './types/component' }, + { file: 'dist/index.d.ts', specifier: './types/config' }, + { file: 'dist/index.d.ts', specifier: './types/props' }, + { file: 'dist/index.d.ts', specifier: './types/scales' }, + { file: 'dist/index.d.ts', specifier: './types/shared' }, + { file: 'dist/index.d.ts', specifier: './types/theme' }, + { file: 'dist/runtime-entry.d.ts', specifier: './runtime' }, + { + file: 'dist/runtime-entry.d.ts', + specifier: './runtime/createClassResolver', + }, + { + file: 'dist/runtime-entry.d.ts', + specifier: './runtime/createComposedFamily', + }, + ], +}; + +// esm-only profile ignores exactly these two resolution kinds (attw 0.18.5). A +// problem tagged with either is invisible to the gate; everything else counts. +export const ESM_ONLY_IGNORED_RESOLUTIONS = new Set(['node10', 'node16-cjs']); + +// TypeScript ModuleKind: 99 = ESNext (ESM), 1 = CommonJS. attw reports the mode +// a fine-grained problem was observed under. +const MODULE_KIND_ESM = 99; + +interface AttwProblem { + kind?: unknown; + resolutionKind?: unknown; // entrypoint-level problems: node10 | node16-cjs | ... + resolutionOption?: unknown; // fine-grained problems: node10 | node16 | bundler + resolutionMode?: unknown; // fine-grained problems: 99 (ESM) | 1 (CJS) + fileName?: unknown; + moduleSpecifier?: unknown; + entrypoint?: unknown; +} + +// Normalizes a problem's resolution to the tag esm-only filters on. Entrypoint +// problems carry `resolutionKind` directly; fine-grained ones carry +// option+mode, where node16 splits into esm/cjs by module kind. +export function resolutionTag(p: AttwProblem): string { + if (typeof p.resolutionKind === 'string') return p.resolutionKind; + const opt = p.resolutionOption; + if (opt === 'node16') { + return p.resolutionMode === MODULE_KIND_ESM ? 'node16-esm' : 'node16-cjs'; + } + if (typeof opt === 'string') return opt; + return 'unknown'; +} + +export function isEsmOnlyVisible(p: AttwProblem): boolean { + return !ESM_ONLY_IGNORED_RESOLUTIONS.has(resolutionTag(p)); +} + +function stripPackagePrefix(fileName: string, packageName: string): string { + return fileName.replace(new RegExp(`^/node_modules/${packageName}/`), ''); +} + +function tupleKey(t: Def5Tuple): string { + return `${t.file}\t${t.specifier}`; +} + +export interface Def5Result { + ok: boolean; + messages: string[]; +} + +// Evaluates one package's attw analysis against its DEF-5 baseline. `analysis` +// is the `.analysis` object from `attw -f json`. +export function evaluateDef5( + analysis: unknown, + packageName: string +): Def5Result { + const messages: string[] = []; + if (analysis === null || typeof analysis !== 'object') { + return { + ok: false, + messages: [ + `ERROR: attw analysis for ${packageName} is missing or malformed.`, + ' Run: bunx attw --profile esm-only -f json (from repo root).', + ], + }; + } + const problemsRaw = (analysis as Record).problems; + const problems: AttwProblem[] = Array.isArray(problemsRaw) + ? (problemsRaw as AttwProblem[]) + : []; + + const baseline = DEF5_BASELINE[packageName] ?? []; + const baselineKeys = new Set(baseline.map(tupleKey)); + + const visible = problems.filter(isEsmOnlyVisible); + + // 1. esm-only-visible problems that are NOT internal-resolution-error: the + // narrowed gate must not let a differently-shaped failure through. + const otherVisible = visible.filter( + (p) => p.kind !== 'InternalResolutionError' + ); + for (const p of otherVisible) { + const where = + typeof p.entrypoint === 'string' + ? p.entrypoint + : typeof p.fileName === 'string' + ? p.fileName + : ''; + messages.push( + `ADDED non-resolution problem: ${String(p.kind)} at ${where} (${resolutionTag(p)})` + ); + } + + // 2. Observed internal-resolution-error tuples (distinct file+specifier). + const observedKeys = new Set(); + for (const p of visible) { + if (p.kind !== 'InternalResolutionError') continue; + if (typeof p.fileName !== 'string' || typeof p.moduleSpecifier !== 'string') + continue; + observedKeys.add( + tupleKey({ + file: stripPackagePrefix(p.fileName, packageName), + specifier: p.moduleSpecifier, + }) + ); + } + + const additions = [...observedKeys] + .filter((k) => !baselineKeys.has(k)) + .sort(); + const removals = [...baselineKeys].filter((k) => !observedKeys.has(k)).sort(); + + for (const k of additions) { + const [file, specifier] = k.split('\t'); + messages.push( + `ADDED internal-resolution-error: ${file} imports '${specifier}' (fails node16-ESM resolution)` + ); + } + for (const k of removals) { + const [file, specifier] = k.split('\t'); + messages.push( + `REMOVED (now resolves): ${file} imports '${specifier}' — trim the DEF-5 baseline` + ); + } + + const ok = messages.length === 0; + if (ok) { + messages.push( + `[attw-def5] ${packageName}: ${observedKeys.size} bounded DEF-5 diagnostic(s), exact match` + ); + } + return { ok, messages }; +} + +function main(argv: string[]): number { + const [mode, packageName] = argv; + if (mode !== 'check' || !packageName) { + console.error( + 'Usage: bun scripts/verify/attw-def5.ts check @animus-ui/ (attw JSON on stdin)' + ); + return 2; + } + if (!(packageName in DEF5_BASELINE)) { + console.error( + `ERROR: no DEF-5 baseline for ${packageName}. Known: ${Object.keys(DEF5_BASELINE).join(', ')}` + ); + return 2; + } + const raw = readFileSync(0, 'utf8'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.error( + `ERROR: could not parse attw JSON for ${packageName} on stdin.` + ); + console.error( + ' attw exits non-zero on findings — capture its output with `|| true` and still pipe the JSON.' + ); + return 2; + } + const analysis = + parsed !== null && typeof parsed === 'object' + ? (parsed as Record).analysis + : undefined; + const result = evaluateDef5(analysis, packageName); + if (result.ok) { + for (const line of result.messages) console.log(line); + return 0; + } + console.error( + `ERROR: DEF-5 bounded type-resolution gate failed for ${packageName} (G6 / bounded diagnostics).` + ); + for (const line of result.messages) console.error(` ${line}`); + console.error( + ' The published declarations changed the accepted internal-resolution-error set.' + ); + console.error( + ' Fix the declaration imports, or regenerate DEF5_BASELINE in scripts/verify/attw-def5.ts with a recorded rationale.' + ); + return 1; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/verify/clippy.sh b/scripts/verify/clippy.sh index 930c5e24..315f6643 100755 --- a/scripts/verify/clippy.sh +++ b/scripts/verify/clippy.sh @@ -7,6 +7,17 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" unset RUSTUP_TOOLCHAIN +# Fail-closed authored-source suppression guard (design D5 / guardrail G4): +# reject crate/module/cfg_attr allow|expect(warnings|clippy::all) before Clippy +# runs, so a blanket suppression cannot absorb unrelated future failures while +# `clippy -D warnings` still reports green. Narrow named-lint allows pass. +# Crate ROOTS (not just src/): --all-targets also compiles build.rs, tests/, +# examples/, and benches/ — a blanket allow there would bypass a src/-only +# scan. rust-policy recurses and prunes target/ itself. +bun "$ROOT/scripts/verify/rust-policy.ts" source \ + "$ROOT/packages/extract/crates/extract-v2" \ + "$ROOT/packages/extract/crates/system-loader" + cd "$ROOT/packages/extract/crates/system-loader" cargo clippy --workspace --all-targets --all-features -- -D warnings diff --git a/scripts/verify/coverage-e2e.sh b/scripts/verify/coverage-e2e.sh index d6233421..642bc6c2 100755 --- a/scripts/verify/coverage-e2e.sh +++ b/scripts/verify/coverage-e2e.sh @@ -3,10 +3,11 @@ set -euo pipefail # verify:coverage:e2e — V8 line coverage for packages/*/src code exercised by # consumer production builds (the lanes the vitest unit runner never executes: -# next-plugin/vite-plugin pipelines, extract TS pipeline helpers). +# next-plugin/vite-plugin pipelines, extract TS pipeline helpers). Covers all +# five consumers: next-app, vite-app, showcase, vinext-app, react-router-app. # # Flow: rebuild TS dists with sourcemaps (ANIMUS_BUILD_SOURCEMAP=1) → run the -# next-app and vite-app build+assert lanes under NODE_V8_COVERAGE → c8 remaps +# consumer build+assert lanes under NODE_V8_COVERAGE → v8-to-lcov.mjs remaps # dist/ coverage back to src/ and writes coverage/e2e/lcov.info → rebuild # sourcemap-free dists so packed/publish artifact shape is unchanged. # @@ -55,6 +56,12 @@ NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/next-app#verify:build' NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/next-app#verify:assert' NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/vite-app#verify:build' NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/vite-app#verify:assert' +NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/showcase#verify:build' +NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/showcase#verify:assert' +NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/vinext-app#verify:build' +NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/vinext-app#verify:assert' +NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/react-router-app#verify:build' +NODE_V8_COVERAGE="$RAW_DIR" vp run '@animus-ui/react-router-app#verify:assert' # Custom remapper (not c8): scripts loaded through Next's next.config.ts # require hook execute as SWC-recompiled copies under their original dist diff --git a/scripts/verify/hygiene-rust.sh b/scripts/verify/hygiene-rust.sh index d1c65989..be3d83d2 100755 --- a/scripts/verify/hygiene-rust.sh +++ b/scripts/verify/hygiene-rust.sh @@ -10,6 +10,17 @@ source "$ROOT/scripts/verify/_preconditions.sh" require_cargo_machete +# Fail-closed cargo-machete ignore guard (design D5 / guardrail G5): reject any +# non-empty [package.metadata.cargo-machete].ignored list before the detector +# runs, so an ignore entry cannot silence a genuinely-unused dependency. The two +# crates are independent (no Cargo workspace since retire-extract-v1), so each is +# checked from its own manifest with --no-deps. +for crate in system-loader extract-v2; do + (cd "$ROOT/packages/extract/crates/$crate" \ + && cargo metadata --no-deps --format-version 1) \ + | bun "$ROOT/scripts/verify/rust-policy.ts" metadata +done + cd "$ROOT/packages/extract/crates/system-loader" cargo machete diff --git a/scripts/verify/napi-target.test.ts b/scripts/verify/napi-target.test.ts new file mode 100644 index 00000000..35c17e49 --- /dev/null +++ b/scripts/verify/napi-target.test.ts @@ -0,0 +1,125 @@ +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + UnsupportedHostError, + resolveHostV2BinaryPath, + resolveNapiTarget, + resolveV2BinaryFilename, +} from './napi-target'; + +const ROOT = resolve(import.meta.dirname, '../..'); +const temporaryDirectories: string[] = []; + +// Controlled mtimes for -newer freshness probes (mirrors preconditions.test.ts): +// OLD < MID < NEW so `find -newer` decisions never hinge on timestamp +// granularity. +const OLD = new Date(1_000_000); +const NEW = new Date(2_000_000); + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('resolveNapiTarget (pure host → target map)', () => { + it('maps darwin-arm64', () => { + expect(resolveNapiTarget({ platform: 'darwin', arch: 'arm64' })).toBe( + 'darwin-arm64' + ); + }); + + it('maps linux x64 gnu', () => { + expect( + resolveNapiTarget({ platform: 'linux', arch: 'x64', libc: 'gnu' }) + ).toBe('linux-x64-gnu'); + }); + + it('maps linux arm64 gnu', () => { + expect( + resolveNapiTarget({ platform: 'linux', arch: 'arm64', libc: 'gnu' }) + ).toBe('linux-arm64-gnu'); + }); + + it('fails loud for an unsupported platform/arch', () => { + expect(() => + resolveNapiTarget({ platform: 'win32', arch: 'x64', libc: 'gnu' }) + ).toThrow(UnsupportedHostError); + }); + + it('fails loud for Linux musl (never released)', () => { + expect(() => + resolveNapiTarget({ platform: 'linux', arch: 'x64', libc: 'musl' }) + ).toThrow(UnsupportedHostError); + }); + + it('composes the v2 binary filename from the resolved target', () => { + expect(resolveV2BinaryFilename({ platform: 'darwin', arch: 'arm64' })).toBe( + 'animus-extract-v2.darwin-arm64.node' + ); + }); +}); + +// Behavioral guard: a fresh FOREIGN-target binary must not mask a stale +// host-native binary. The retired `ls *.node | head -n1` selection would pick +// the lexically-first artifact (`aaaa-foreign` sorts before every real target +// token) and pass falsely; host-native selection fails and names the host +// binary. +describe('require_fresh_napi_v2 host-native selection', () => { + function scaffold(): string { + const root = mkdtempSync(resolve(tmpdir(), 'animus-napi-target-')); + temporaryDirectories.push(root); + mkdirSync(join(root, 'scripts/verify'), { recursive: true }); + for (const file of ['_preconditions.sh', 'napi-target.ts']) { + copyFileSync( + join(ROOT, 'scripts/verify', file), + join(root, 'scripts/verify', file) + ); + } + return root; + } + + function writeAt(root: string, relativePath: string, mtime: Date): void { + const absolute = join(root, relativePath); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, 'binary\n'); + utimesSync(absolute, mtime, mtime); + } + + it('fails and names the stale host binary when a foreign binary is fresh', () => { + const root = scaffold(); + const crate = 'packages/extract/crates/extract-v2'; + const hostBinary = resolveHostV2BinaryPath(); + + // Host-native binary is OLD; a foreign-target binary and a Rust source are + // both NEW. Only host-native selection catches the staleness. + writeAt(root, hostBinary, OLD); + writeAt(root, `${crate}/animus-extract-v2.aaaa-foreign-target.node`, NEW); + writeAt(root, `${crate}/src/lib.rs`, NEW); + + const result = spawnSync( + 'bash', + [ + '-c', + 'set -euo pipefail; source scripts/verify/_preconditions.sh; require_fresh_napi_v2', + ], + { cwd: root, encoding: 'utf8' } + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('host v2 NAPI binary stale'); + expect(result.stderr).toContain(hostBinary); + expect(result.stderr).toContain(`${crate}/src/lib.rs`); + }); +}); diff --git a/scripts/verify/napi-target.ts b/scripts/verify/napi-target.ts new file mode 100644 index 00000000..be73fc6d --- /dev/null +++ b/scripts/verify/napi-target.ts @@ -0,0 +1,110 @@ +// scripts/verify/napi-target.ts +// +// Host-native NAPI target resolver (design decision D7). The v2 loader +// (packages/extract/crates/extract-v2/index.js) selects exactly one platform +// binary at runtime; freshness MUST be compared against that same binary and +// never the lexically-first `*.node` on disk — a foreign-target artifact's +// timestamp says nothing about the code this host's loader executes. +// +// Supported release matrix mirrors the CI napi build targets +// (.github/workflows/ci.yaml) and the dual-engine-build "supported target" +// requirement: +// aarch64-apple-darwin -> darwin-arm64 +// x86_64-unknown-linux-gnu -> linux-x64-gnu +// aarch64-unknown-linux-gnu -> linux-arm64-gnu +// Linux libc detection matches the loader's supported GNU targets and fails +// loud for anything outside the released matrix (including Linux musl). v1 is +// retired (retire-extract-v1), so only the v2 binary is resolved. + +export type HostLibc = 'gnu' | 'musl' | null; + +export interface HostTargetInput { + platform: string; + arch: string; + libc?: HostLibc; +} + +export const V2_BINARY_PREFIX = 'animus-extract-v2'; +export const V2_CRATE_DIR = 'packages/extract/crates/extract-v2'; + +export class UnsupportedHostError extends Error { + constructor(input: HostTargetInput) { + super( + `Unsupported host for v2 NAPI freshness: platform=${input.platform} ` + + `arch=${input.arch} libc=${input.libc ?? 'n/a'}. Supported release ` + + 'targets: darwin-arm64, linux-x64-gnu, linux-arm64-gnu.' + ); + this.name = 'UnsupportedHostError'; + } +} + +// Pure map from {platform, arch, libc} to the napi target token embedded in the +// binary filename. Throws UnsupportedHostError for any host outside the +// supported release matrix (Linux musl included — it is never released). +export function resolveNapiTarget(input: HostTargetInput): string { + const { platform, arch, libc } = input; + if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64'; + if (platform === 'linux' && arch === 'x64' && libc === 'gnu') { + return 'linux-x64-gnu'; + } + if (platform === 'linux' && arch === 'arm64' && libc === 'gnu') { + return 'linux-arm64-gnu'; + } + throw new UnsupportedHostError(input); +} + +export function resolveV2BinaryFilename(input: HostTargetInput): string { + return `${V2_BINARY_PREFIX}.${resolveNapiTarget(input)}.node`; +} + +export function resolveV2BinaryPath(input: HostTargetInput): string { + return `${V2_CRATE_DIR}/${resolveV2BinaryFilename(input)}`; +} + +// Host libc detection for Linux, mirroring the loader's musl probe: glibc hosts +// expose header.glibcVersionRuntime in the process report; musl hosts surface +// an ld-musl shared object. Anything indeterminate returns null so the resolver +// fails loud rather than guessing a released target. +export function detectHostLibc(platform: string = process.platform): HostLibc { + if (platform !== 'linux') return null; + try { + const report = + typeof process.report?.getReport === 'function' + ? (process.report.getReport() as { + header?: { glibcVersionRuntime?: string }; + sharedObjects?: string[]; + }) + : null; + if (report?.header?.glibcVersionRuntime) return 'gnu'; + const shared = report?.sharedObjects ?? []; + if ( + shared.some((f) => f.includes('libc.musl-') || f.includes('ld-musl-')) + ) { + return 'musl'; + } + } catch { + // Fall through to null → UnsupportedHostError (fail loud on unknown libc). + } + return null; +} + +// Repo-relative path of the v2 binary THIS host loads. Used by both the CLI and +// the freshness precondition so they agree on a single selected binary. +export function resolveHostV2BinaryPath(): string { + return resolveV2BinaryPath({ + platform: process.platform, + arch: process.arch, + libc: detectHostLibc(), + }); +} + +// CLI: print the repo-relative path of the host-native v2 binary, or fail loud +// (non-zero) with the unsupported-host reason on stderr. +if (import.meta.main) { + try { + process.stdout.write(`${resolveHostV2BinaryPath()}\n`); + } catch (error) { + process.stderr.write(`${(error as Error).message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/verify/owner-graph.test.ts b/scripts/verify/owner-graph.test.ts index 6ca54709..f34bc9ac 100644 --- a/scripts/verify/owner-graph.test.ts +++ b/scripts/verify/owner-graph.test.ts @@ -610,6 +610,7 @@ fi mkdirSync(join(root, 'scripts/verify'), { recursive: true }); for (const script of [ '_preconditions.sh', + 'napi-target.ts', 'build-consumer.sh', 'workspace-graph.ts', ]) { diff --git a/scripts/verify/packed.sh b/scripts/verify/packed.sh index ef8ce23b..1e7e6218 100755 --- a/scripts/verify/packed.sh +++ b/scripts/verify/packed.sh @@ -82,12 +82,22 @@ done for p in "${PKGS[@]}"; do case "$p" in properties|system) - # DEF-5 allowlist: extensionless specifiers in the tsgo bundler-mode - # declaration emit fail node16-ESM resolution. Bundler mode must stay - # green; remove --ignore-rules when DEF-5's toolchain fix lands. - echo "[verify:packed] attw @animus-ui/$p (--profile esm-only, DEF-5 allowlist)" + # Bounded DEF-5 gate (guardrail G6, replaces the broad rule-wide attw + # allowlist that ignored every internal resolution error). properties/system + # source uses extensionless relative imports under bundler resolution; + # stable-TS (7.0.2) emits them verbatim, so their declarations fail + # node16-ESM resolution. attw runs with NO ignored rule and its JSON is + # checked against the exact captured DEF-5 diagnostic set: a new resolution + # error, an obsolete one, or any other esm-only-visible problem fails + # closed. See scripts/verify/attw-def5.ts. + echo "[verify:packed] attw @animus-ui/$p (--profile esm-only, bounded DEF-5 gate)" + # attw JSON exceeds the 64KiB command-substitution/pipe cap for system, so + # stage it to a file (no truncation) and feed it to the validator on stdin. + # attw exits non-zero on findings (|| true); the validator owns the verdict. + attw_out="$STAGING/attw-$p.json" bunx attw "$STAGING/tarballs/animus-ui-$p.tgz" \ - --profile esm-only --ignore-rules internal-resolution-error + --profile esm-only -f json >"$attw_out" 2>/dev/null || true + bun scripts/verify/attw-def5.ts check "@animus-ui/$p" <"$attw_out" ;; *) echo "[verify:packed] attw @animus-ui/$p (--profile node16)" @@ -140,11 +150,11 @@ echo "[verify:packed] stable-TS declaration check ok" (cd "$STAGING" && npm run build:next) # ── 8. Receipt (engine + package-form dimensions) ─────────────────── -# Engine facts are MEASURED from the staged artifacts, not asserted. -# retire-extract-v1: v2 is the only engine, so the measurement inverts — -# the consumer configs must contain NO engine selection and the installed -# plugin code must carry the retirement guard; any 'v1' reference in the -# staged configs is a loud regression. +# Engine facts are STRUCTURAL GUARDS over the staged artifacts, never inferred +# from plugin/config source (guardrail G3). retire-extract-v1: v2 is the only +# engine, so the consumer configs must contain NO engine selection and the +# installed plugin code must carry the retirement guard; any 'v1' reference in +# the staged configs is a loud regression. mkdir -p "$STAGING/receipts" node -e " const fs = require('fs'); diff --git a/scripts/verify/preconditions.test.ts b/scripts/verify/preconditions.test.ts index b49c108f..f04d0949 100644 --- a/scripts/verify/preconditions.test.ts +++ b/scripts/verify/preconditions.test.ts @@ -12,6 +12,8 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import { resolveHostV2BinaryPath } from './napi-target'; + const ROOT = resolve(import.meta.dirname, '../..'); const temporaryDirectories: string[] = []; @@ -28,10 +30,14 @@ function scaffold(prefix: string): string { const root = mkdtempSync(resolve(tmpdir(), prefix)); temporaryDirectories.push(root); mkdirSync(join(root, 'scripts/verify'), { recursive: true }); - copyFileSync( - join(ROOT, 'scripts/verify/_preconditions.sh'), - join(root, 'scripts/verify/_preconditions.sh') - ); + // require_fresh_napi_v2 now shells out to napi-target.ts for host-native + // binary selection, so the fixture tree must own a copy of the resolver too. + for (const file of ['_preconditions.sh', 'napi-target.ts']) { + copyFileSync( + join(ROOT, 'scripts/verify', file), + join(root, 'scripts/verify', file) + ); + } return root; } @@ -103,6 +109,9 @@ describe('require_dir', () => { describe('require_fresh_napi_v2', () => { const crate = 'packages/extract/crates/extract-v2'; + // The freshness comparison is against the exact binary THIS host loads, not + // the lexically-first *.node, so fixtures must write the host-native name. + const hostBinary = resolveHostV2BinaryPath(); it('fails with the build:extract-v2 remediation when the binary is missing', () => { const root = scaffold('animus-preconditions-napi2-missing-'); @@ -116,25 +125,24 @@ describe('require_fresh_napi_v2', () => { ); }); - it('fails as stale when a Rust source is newer than the binary', () => { + it('fails as stale when a Rust source is newer than the host binary', () => { const root = scaffold('animus-preconditions-napi2-stale-'); - writeAt(root, `${crate}/extract-v2.node`, 'binary\n', OLDER); + writeAt(root, hostBinary, 'binary\n', OLDER); writeAt(root, `${crate}/src/lib.rs`, 'fn main() {}\n', NEWER); const result = run(root, 'require_fresh_napi_v2'); expect(result.status).not.toBe(0); - expect(result.stderr).toContain( - 'ERROR: v2 NAPI binary stale (Rust input newer:' - ); + expect(result.stderr).toContain('ERROR: host v2 NAPI binary stale:'); + expect(result.stderr).toContain(hostBinary); expect(result.stderr).toContain(`${crate}/src/lib.rs`); expect(result.stderr).toContain('Run: vp run build:extract-v2'); }); - it('passes when the binary is newer than every Rust source', () => { + it('passes when the host binary is newer than every Rust source', () => { const root = scaffold('animus-preconditions-napi2-fresh-'); writeAt(root, `${crate}/src/lib.rs`, 'fn main() {}\n', OLDER); - writeAt(root, `${crate}/extract-v2.node`, 'binary\n', NEWER); + writeAt(root, hostBinary, 'binary\n', NEWER); const result = run(root, 'require_fresh_napi_v2'); diff --git a/scripts/verify/rust-policy.test.ts b/scripts/verify/rust-policy.test.ts new file mode 100644 index 00000000..639810b0 --- /dev/null +++ b/scripts/verify/rust-policy.test.ts @@ -0,0 +1,132 @@ +// scripts/verify/rust-policy.test.ts +// +// Behavior tests for the fail-closed Rust suppression policy (design D5, G4/G5). +// Pure-function level: blanket suppression detection over authored source tokens +// and cargo-machete ignore detection over parsed metadata. No test asserts that +// a script/config merely contains a command string (design D6). + +import { describe, expect, test } from 'vitest'; + +import { + findBlanketSuppressions, + findIgnoredDeps, + stripComments, +} from './rust-policy'; + +describe('findBlanketSuppressions: blanket allow/expect fails', () => { + test('multiline crate-wide #![allow(warnings)]', () => { + const src = `#![allow(\n warnings\n)]\n\npub fn f() {}\n`; + const findings = findBlanketSuppressions(src, 'lib.rs'); + expect(findings).toHaveLength(1); + expect(findings[0].lint).toBe('warnings'); + expect(findings[0].file).toBe('lib.rs'); + }); + + test('module-level #[allow(clippy::all)]', () => { + const src = `#[allow(clippy::all)]\nmod inner {}\n`; + const findings = findBlanketSuppressions(src, 'x.rs'); + expect(findings).toHaveLength(1); + expect(findings[0].lint).toBe('clippy::all'); + }); + + test('cfg_attr(..., allow(warnings)) wrapper', () => { + const src = `#[cfg_attr(feature = "dev", allow(warnings))]\nfn g() {}\n`; + const findings = findBlanketSuppressions(src, 'y.rs'); + expect(findings).toHaveLength(1); + expect(findings[0].lint).toBe('warnings'); + }); + + test('expect(clippy::all) is also blanket', () => { + const src = `#[expect(clippy::all)]\nfn h() {}\n`; + const findings = findBlanketSuppressions(src, 'z.rs'); + expect(findings).toHaveLength(1); + expect(findings[0].lint).toBe('clippy::all'); + }); + + test('blanket lint mixed with a narrow lint in one group still fails', () => { + const src = `#[allow(clippy::too_many_arguments, warnings)]\nfn h() {}\n`; + const findings = findBlanketSuppressions(src, 'z.rs'); + expect(findings).toHaveLength(1); + expect(findings[0].lint).toBe('warnings'); + }); +}); + +describe('findBlanketSuppressions: narrow allow passes', () => { + test('#[allow(clippy::too_many_arguments)] is preserved', () => { + const src = `#[allow(clippy::too_many_arguments)]\nfn wide() {}\n`; + expect(findBlanketSuppressions(src, 'a.rs')).toHaveLength(0); + }); + + test('#[allow(dead_code)] is preserved', () => { + const src = `#[allow(dead_code)]\nfn unused() {}\n`; + expect(findBlanketSuppressions(src, 'b.rs')).toHaveLength(0); + }); + + test('#[allow(clippy::new_without_default)] is preserved', () => { + const src = `#[allow(clippy::new_without_default)]\nimpl Foo {}\n`; + expect(findBlanketSuppressions(src, 'c.rs')).toHaveLength(0); + }); +}); + +describe('findBlanketSuppressions: comment stripping', () => { + test('commented-out blanket suppression does not trip', () => { + const src = `// #![allow(warnings)]\n/* #[allow(clippy::all)] */\nfn f() {}\n`; + expect(findBlanketSuppressions(src, 'd.rs')).toHaveLength(0); + }); + + test('stripComments removes line and block comments', () => { + expect(stripComments('a // b\nc')).toBe('a \nc'); + expect(stripComments('a /* b */ c')).toBe('a c'); + }); +}); + +describe('findIgnoredDeps: cargo-machete ignore policy', () => { + test('non-empty ignore list fails and names package + deps', () => { + const metadata = { + packages: [ + { + name: 'animus-extract-v2', + metadata: { 'cargo-machete': { ignored: ['napi', 'serde_json'] } }, + }, + ], + }; + const findings = findIgnoredDeps(metadata); + expect(findings).toHaveLength(1); + expect(findings[0].package).toBe('animus-extract-v2'); + expect(findings[0].ignored).toEqual(['napi', 'serde_json']); + }); + + test('empty ignore list passes', () => { + const metadata = { + packages: [ + { + name: 'animus-system-loader', + metadata: { 'cargo-machete': { ignored: [] } }, + }, + ], + }; + expect(findIgnoredDeps(metadata)).toHaveLength(0); + }); + + test('absent package.metadata passes', () => { + const metadata = { + packages: [{ name: 'animus-extract-v2', metadata: null }], + }; + expect(findIgnoredDeps(metadata)).toHaveLength(0); + }); + + test('multiple packages: only the offending one is reported', () => { + const metadata = { + packages: [ + { name: 'clean-crate', metadata: null }, + { + name: 'dirty-crate', + metadata: { 'cargo-machete': { ignored: ['unused_dep'] } }, + }, + ], + }; + const findings = findIgnoredDeps(metadata); + expect(findings).toHaveLength(1); + expect(findings[0].package).toBe('dirty-crate'); + }); +}); diff --git a/scripts/verify/rust-policy.ts b/scripts/verify/rust-policy.ts new file mode 100644 index 00000000..2c1cc2c6 --- /dev/null +++ b/scripts/verify/rust-policy.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env bun +// scripts/verify/rust-policy.ts +// +// Pure fail-closed policy validator for authored Rust suppression surfaces +// (design D5, guardrails G4/G5). Two CLI modes, both fail non-zero on a finding +// so a broad suppression cannot silently absorb unrelated future failures while +// `cargo clippy -D warnings` / `cargo machete` still report green. +// +// source ... Scan authored `.rs` files (dirs recursed, `target/` +// skipped) for crate-wide / module-wide / cfg_attr-wrapped +// `allow|expect(warnings)` and `allow|expect(clippy::all)`. +// Narrow named-lint allows (e.g. clippy::too_many_arguments, +// dead_code) are preserved so Clippy still evaluates them. +// metadata Read `cargo metadata --no-deps --format-version 1` JSON +// from stdin; reject any non-empty +// `[package.metadata.cargo-machete].ignored` list. +// +// The token scan operates on comment-stripped source only; it deliberately does +// not interpret generated macro output (design trade-off: authored crate/module +// attributes are the plausible bypass; the blind spot is explicit — G4). + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +// The two blanket lint groups a suppression must never silence wholesale. +export const BLANKET_LINTS = new Set(['warnings', 'clippy::all']); + +export interface SuppressionFinding { + file: string; + attribute: string; // the offending `allow(...)`/`expect(...)` group, verbatim-ish + lint: string; // which blanket lint tripped it +} + +export interface IgnoredDepFinding { + package: string; + ignored: string[]; +} + +// Removes Rust line (`//`, `///`, `//!`) and block (`/* */`, `/** */`) comments +// so a commented-out `#![allow(warnings)]` cannot trip the scan and, conversely, +// so a real attribute trailing an inline comment still tokenizes. String-literal +// contents are intentionally left in place: attribute macros do not live inside +// string literals, and stripping strings correctly would require a full lexer +// the fail-closed policy does not warrant. +export function stripComments(source: string): string { + let out = ''; + let i = 0; + const n = source.length; + while (i < n) { + const two = source.slice(i, i + 2); + if (two === '//') { + const nl = source.indexOf('\n', i); + if (nl === -1) break; + i = nl; // keep the newline so line numbers/whitespace boundaries survive + } else if (two === '/*') { + const end = source.indexOf('*/', i + 2); + i = end === -1 ? n : end + 2; + out += ' '; + } else { + out += source[i]; + i += 1; + } + } + return out; +} + +// Scans one comment-stripped source for blanket suppressions. Every +// `allow(...)`/`expect(...)` group is inspected, including groups nested inside +// `cfg_attr(, allow(...))` — the nested `allow(warnings)` substring is +// matched directly, so no cfg_attr-specific parsing is required. A group trips +// only when one of its comma-separated lint tokens is exactly a blanket lint. +export function findBlanketSuppressions( + source: string, + file: string +): SuppressionFinding[] { + const stripped = stripComments(source); + const findings: SuppressionFinding[] = []; + // `[^()]*` keeps each group to a single non-nested lint list. cfg_attr's outer + // parens are skipped over; its inner allow/expect group is matched on its own. + const groupRe = /\b(allow|expect)\s*\(\s*([^()]*?)\s*\)/g; + let m: RegExpExecArray | null; + while ((m = groupRe.exec(stripped)) !== null) { + const lints = m[2] + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + for (const lint of lints) { + if (BLANKET_LINTS.has(lint)) { + findings.push({ + file, + attribute: m[0].replace(/\s+/g, ' ').trim(), + lint, + }); + break; + } + } + } + return findings; +} + +function collectRustFiles(path: string, acc: string[]): void { + let st; + try { + st = statSync(path); + } catch { + return; + } + if (st.isDirectory()) { + if (path.endsWith('/target') || path === 'target') return; + for (const entry of readdirSync(path)) { + if (entry === 'target' || entry === 'node_modules') continue; + collectRustFiles(join(path, entry), acc); + } + } else if (path.endsWith('.rs')) { + acc.push(path); + } +} + +export function scanSourcePaths(paths: string[]): SuppressionFinding[] { + const files: string[] = []; + for (const p of paths) collectRustFiles(p, files); + files.sort(); + const findings: SuppressionFinding[] = []; + for (const file of files) { + findings.push(...findBlanketSuppressions(readFileSync(file, 'utf8'), file)); + } + return findings; +} + +// Reads parsed `cargo metadata` JSON and reports every package that declares a +// non-empty cargo-machete ignore list. Absent `[package.metadata]` (null) or an +// empty `ignored` array is compliant. +export function findIgnoredDeps(metadata: unknown): IgnoredDepFinding[] { + const findings: IgnoredDepFinding[] = []; + if (metadata === null || typeof metadata !== 'object') return findings; + const packages = (metadata as Record).packages; + if (!Array.isArray(packages)) return findings; + for (const pkg of packages) { + if (pkg === null || typeof pkg !== 'object') continue; + const p = pkg as Record; + const meta = p.metadata; + if (meta === null || typeof meta !== 'object') continue; + const machete = (meta as Record)['cargo-machete']; + if (machete === null || typeof machete !== 'object') continue; + const ignored = (machete as Record).ignored; + if (Array.isArray(ignored) && ignored.length > 0) { + findings.push({ + package: typeof p.name === 'string' ? p.name : '', + ignored: ignored.map((x) => String(x)), + }); + } + } + return findings; +} + +function runSource(paths: string[]): number { + if (paths.length === 0) { + console.error( + 'ERROR: rust-policy source requires at least one path. Run: bun scripts/verify/rust-policy.ts source ...' + ); + return 2; + } + const findings = scanSourcePaths(paths); + if (findings.length === 0) { + console.log('[rust-policy] no blanket allow/expect(warnings|clippy::all)'); + return 0; + } + console.error( + 'ERROR: blanket Rust lint suppression is prohibited (design D5 / G4).' + ); + console.error( + ' A crate/module/cfg_attr `allow|expect(warnings|clippy::all)` can absorb' + ); + console.error( + ' unrelated future failures while `clippy -D warnings` stays green.' + ); + for (const f of findings) { + console.error(` ${f.file}: ${f.attribute} (blanket lint: ${f.lint})`); + } + console.error( + ' Fix: scope the allow to the specific named lint, or remove it.' + ); + return 1; +} + +function runMetadata(): number { + const raw = readFileSync(0, 'utf8'); // stdin + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.error( + 'ERROR: rust-policy metadata could not parse stdin as cargo-metadata JSON.' + ); + console.error( + ' Run: cargo metadata --no-deps --format-version 1 | bun scripts/verify/rust-policy.ts metadata' + ); + return 2; + } + const findings = findIgnoredDeps(parsed); + if (findings.length === 0) { + console.log('[rust-policy] cargo-machete ignore lists empty'); + return 0; + } + console.error( + 'ERROR: non-empty cargo-machete ignore list is prohibited (design D5 / G5).' + ); + for (const f of findings) { + console.error(` ${f.package}: ignored = [${f.ignored.join(', ')}]`); + } + console.error( + ' Fix: remove the [package.metadata.cargo-machete] ignored entries and' + ); + console.error(' address the flagged dependency directly.'); + return 1; +} + +function main(argv: string[]): number { + const [mode, ...rest] = argv; + switch (mode) { + case 'source': + return runSource(rest); + case 'metadata': + return runMetadata(); + default: + console.error( + `ERROR: unknown mode '${mode ?? ''}'. Usage: rust-policy.ts source ... | metadata` + ); + return 2; + } +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/verify/topology.test.ts b/scripts/verify/topology.test.ts new file mode 100644 index 00000000..4208344e --- /dev/null +++ b/scripts/verify/topology.test.ts @@ -0,0 +1,668 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + classifyTree, + collectViolations, + extractSpecifiers, + isForbidden, + main, + readE2ePackageNames, + resolveSpecifierTree, + scanPackageDependencies, + scanSourceImports, + scanTsconfigPaths, +} from './topology'; + +const ROOT = resolve(import.meta.dirname, '../..'); +const SCRIPT = join(ROOT, 'scripts/verify/topology.ts'); +const temporaryDirectories: string[] = []; + +function temporaryDirectory(prefix: string): string { + const directory = mkdtempSync(resolve(tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +function write(root: string, relPath: string, contents: string): void { + const path = join(root, relPath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +function manifest(name: string, extra: Record = {}): string { + return `${JSON.stringify({ name, ...extra }, null, 2)}\n`; +} + +// A minimally-populated workspace with the three top-level trees and one e2e +// member that owns a workspace name. Callers layer violations on top. +function scaffold(prefix: string): string { + const root = temporaryDirectory(prefix); + write(root, 'package.json', manifest('root', { private: true })); + write(root, 'e2e/next-app/package.json', manifest('@animus-ui/next-app')); + write(root, 'e2e/next-app/src/index.ts', 'export const app = 1;\n'); + write(root, 'packages/system/package.json', manifest('@animus-ui/system')); + write(root, 'packages/system/src/index.ts', 'export const system = 1;\n'); + write(root, 'legacy/core/src/index.ts', 'export const core = 1;\n'); + return root; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('topology pure predicates', () => { + it('encodes exactly the three forbidden edges', () => { + expect(isForbidden('packages', 'e2e')).toBe(true); + expect(isForbidden('packages', 'legacy')).toBe(true); + expect(isForbidden('e2e', 'legacy')).toBe(true); + + // Permitted / irrelevant edges. + expect(isForbidden('e2e', 'packages')).toBe(false); + expect(isForbidden('packages', 'packages')).toBe(false); + expect(isForbidden('e2e', 'e2e')).toBe(false); + expect(isForbidden('legacy', 'packages')).toBe(false); + expect(isForbidden('other', 'legacy')).toBe(false); + }); + + it('classifies paths by their top-level tree', () => { + expect(classifyTree('/repo', '/repo/packages/system/src/a.ts')).toBe( + 'packages' + ); + expect(classifyTree('/repo', '/repo/e2e/next-app/app/page.tsx')).toBe( + 'e2e' + ); + expect(classifyTree('/repo', '/repo/legacy/core/src/a.ts')).toBe('legacy'); + expect(classifyTree('/repo', '/repo/tsconfig.json')).toBe('other'); + expect(classifyTree('/repo', '/elsewhere/x.ts')).toBe('other'); + }); +}); + +describe('specifier extraction', () => { + it('captures every import form and ignores comments and unrelated strings', () => { + const source = [ + "import a from '../../e2e/next-app/x';", + "export { b } from '../../legacy/core/y';", + "import '../side-effect';", + "const c = require('../../e2e/next-app/z');", + "const d = await import('../../legacy/core/w');", + "// import ignored from '../../e2e/should-not-count';", + "/* export skip from '../../legacy/should-not-count'; */", + "const url = 'https://example.com/e2e/not-an-import';", + ].join('\n'); + + const values = extractSpecifiers(source).map((s) => s.value); + expect(values).toContain('../../e2e/next-app/x'); + expect(values).toContain('../../legacy/core/y'); + expect(values).toContain('../side-effect'); + expect(values).toContain('../../e2e/next-app/z'); + expect(values).toContain('../../legacy/core/w'); + expect(values).not.toContain('../../e2e/should-not-count'); + expect(values).not.toContain('../../legacy/should-not-count'); + expect(values).not.toContain('https://example.com/e2e/not-an-import'); + }); + + it('resolves relative paths against the file and e2e names against manifests', () => { + const fileAbs = '/repo/packages/system/src/index.ts'; + expect( + resolveSpecifierTree('/repo', fileAbs, '../../../e2e/next-app/x', [ + '@animus-ui/next-app', + ]) + ).toBe('e2e'); + expect( + resolveSpecifierTree('/repo', fileAbs, '../../../legacy/core/y', []) + ).toBe('legacy'); + expect(resolveSpecifierTree('/repo', fileAbs, './sibling', [])).toBe( + 'packages' + ); + expect( + resolveSpecifierTree('/repo', fileAbs, '@animus-ui/next-app/sub', [ + '@animus-ui/next-app', + ]) + ).toBe('e2e'); + // External/untracked dependency. + expect(resolveSpecifierTree('/repo', fileAbs, 'react', [])).toBeNull(); + }); +}); + +describe('source-import vector', () => { + it('flags packages/* -> e2e/* via relative path', () => { + const root = scaffold('animus-topology-pkg-e2e-rel-'); + write( + root, + 'packages/system/src/bad.ts', + "export { app } from '../../../e2e/next-app/src/index';\n" + ); + + const violations = scanSourceImports(root); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ + vector: 'import', + file: join('packages', 'system', 'src', 'bad.ts'), + from: 'packages', + to: 'e2e', + }); + }); + + it('flags packages/* -> e2e/* via workspace package name', () => { + const root = scaffold('animus-topology-pkg-e2e-name-'); + write( + root, + 'packages/system/src/bad.ts', + "import { app } from '@animus-ui/next-app';\n" + ); + + const violations = scanSourceImports(root); + expect(violations.map((v) => v.to)).toContain('e2e'); + expect(violations[0].detail).toContain('@animus-ui/next-app'); + }); + + it('flags packages/* -> legacy/* and e2e/* -> legacy/*', () => { + const root = scaffold('animus-topology-legacy-'); + write( + root, + 'packages/system/src/bad.ts', + "import '../../../legacy/core/src/index';\n" + ); + write( + root, + 'e2e/next-app/src/bad.ts', + "const x = require('../../../legacy/core/src/index');\n" + ); + + const violations = scanSourceImports(root); + const edges = violations.map((v) => `${v.from}->${v.to}`).sort(); + expect(edges).toEqual(['e2e->legacy', 'packages->legacy']); + }); + + it('permits e2e/* -> packages/* and intra-tree imports', () => { + const root = scaffold('animus-topology-permitted-'); + write( + root, + 'e2e/next-app/src/ok.ts', + [ + "import { system } from '@animus-ui/system';", + "import { other } from '../../../packages/system/src/index';", + "import { local } from './index';", + ].join('\n') + ); + write( + root, + 'packages/system/src/ok.ts', + "import { local } from './index';\n" + ); + + expect(scanSourceImports(root)).toEqual([]); + }); + + it('excludes the byte-precise parity corpus from scanning', () => { + const root = scaffold('animus-topology-corpus-'); + write(root, 'packages/_parity/package.json', manifest('@animus-ui/parity')); + write( + root, + 'packages/_parity/corpus/adversarial.tsx', + "import '../../../legacy/core/src/index';\n" + ); + + expect(scanSourceImports(root)).toEqual([]); + }); +}); + +describe('tsconfig-path vector', () => { + it('flags forbidden path targets and permits sibling packages targets', () => { + const root = scaffold('animus-topology-tsconfig-'); + write( + root, + 'packages/system/tsconfig.json', + JSON.stringify({ + compilerOptions: { + // JSONC comment tolerated by the reader. + paths: { + '@x/e2e': ['../../e2e/next-app/src/index.ts'], + '@x/legacy': ['../../legacy/core/src/index.ts'], + '@x/ok': ['../properties/src/index.ts'], + }, + }, + }) + ); + + const violations = scanTsconfigPaths(root); + const targets = violations.map((v) => `${v.from}->${v.to}`).sort(); + expect(targets).toEqual(['packages->e2e', 'packages->legacy']); + }); + + it('ignores an e2e tsconfig aliasing into packages (permitted direction)', () => { + const root = scaffold('animus-topology-tsconfig-ok-'); + write( + root, + 'e2e/next-app/tsconfig.json', + JSON.stringify({ + compilerOptions: { + paths: { + '@animus-ui/system': ['../../packages/system/src/index.ts'], + }, + }, + }) + ); + + expect(scanTsconfigPaths(root)).toEqual([]); + }); +}); + +describe('package-dependency vector', () => { + it('flags a packages/* manifest depending on an e2e workspace name', () => { + const root = scaffold('animus-topology-dep-'); + write( + root, + 'packages/system/package.json', + manifest('@animus-ui/system', { + dependencies: { '@animus-ui/next-app': 'workspace:*' }, + }) + ); + + const violations = scanPackageDependencies(root); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ + vector: 'package-dependency', + from: 'packages', + to: 'e2e', + }); + expect(violations[0].detail).toContain('@animus-ui/next-app'); + }); + + it('permits an e2e manifest depending on a packages workspace name', () => { + const root = scaffold('animus-topology-dep-ok-'); + write( + root, + 'e2e/next-app/package.json', + manifest('@animus-ui/next-app', { + dependencies: { '@animus-ui/system': 'workspace:*' }, + }) + ); + + expect(scanPackageDependencies(root)).toEqual([]); + expect(collectViolations(root)).toEqual([]); + }); + + it('reads e2e package names from manifests', () => { + const root = scaffold('animus-topology-names-'); + write(root, 'e2e/vite-app/package.json', manifest('@animus-ui/vite-app')); + expect(readE2ePackageNames(root)).toEqual([ + '@animus-ui/next-app', + '@animus-ui/vite-app', + ]); + }); +}); + +describe('CLI main', () => { + it('exits non-zero and prints the offending file on a violation fixture', () => { + const root = scaffold('animus-topology-cli-fail-'); + write( + root, + 'packages/system/src/bad.ts', + "export { app } from '../../../e2e/next-app/src/index';\n" + ); + + const result = spawnSync('bun', [SCRIPT, root], { encoding: 'utf8' }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('ERROR: workspace topology violation'); + expect(result.stderr).toContain( + join('packages', 'system', 'src', 'bad.ts') + ); + expect(result.stderr).toContain('Run:'); + }); + + it('exits zero on a clean fixture', () => { + const root = scaffold('animus-topology-cli-ok-'); + const result = spawnSync('bun', [SCRIPT, root], { encoding: 'utf8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('[topology] workspace boundaries clean'); + }); +}); + +// --------------------------------------------------------------------------- +// Review-driven fixtures (2026-07-20). Four verified findings against the +// freshly-landed checker; each block writes temp-dir fixtures only. +// --------------------------------------------------------------------------- + +describe('finding 1 — source-extension breadth', () => { + it('flags a .js file importing legacy via require()', () => { + const root = scaffold('animus-topology-ext-js-'); + write( + root, + 'packages/system/src/bad.js', + "const x = require('../../../legacy/core/src/index');\n" + ); + + const violations = scanSourceImports(root); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ + file: join('packages', 'system', 'src', 'bad.js'), + from: 'packages', + to: 'legacy', + }); + }); + + it('flags a .jsx / .mjs / .cjs file crossing a forbidden boundary', () => { + const root = scaffold('animus-topology-ext-fam-'); + write( + root, + 'packages/system/src/a.jsx', + "import '../../../legacy/core/src/index';\n" + ); + write( + root, + 'packages/system/src/b.mjs', + "export { app } from '../../../e2e/next-app/src/index';\n" + ); + write( + root, + 'e2e/next-app/src/c.cjs', + "const y = require('../../../legacy/core/src/index');\n" + ); + + const edges = scanSourceImports(root) + .map((v) => `${v.from}->${v.to}`) + .sort(); + expect(edges).toEqual(['e2e->legacy', 'packages->e2e', 'packages->legacy']); + }); + + it('flags an .mdx file whose top-level ESM import crosses a boundary', () => { + const root = scaffold('animus-topology-ext-mdx-'); + write( + root, + 'packages/system/src/bad.mdx', + [ + "import { Callout } from '../components/Callout';", + "import legacy from '../../../legacy/core/src/index';", + '', + '# Heading', + ].join('\n') + ); + + const violations = scanSourceImports(root); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ + file: join('packages', 'system', 'src', 'bad.mdx'), + from: 'packages', + to: 'legacy', + }); + }); + + it('scans showcase MDX content (docs imports are real imports)', () => { + const root = scaffold('animus-topology-ext-showcase-'); + write( + root, + 'packages/showcase/package.json', + manifest('@animus-ui/showcase') + ); + write( + root, + 'packages/showcase/src/content/guide.mdx', + "import legacy from '../../../../legacy/core/src/index';\n" + ); + + const violations = scanSourceImports(root); + expect(violations.map((v) => `${v.from}->${v.to}`)).toContain( + 'packages->legacy' + ); + }); + + it('does not flag legacy-looking text inside an MDX fenced code block', () => { + const root = scaffold('animus-topology-ext-mdx-fence-'); + write( + root, + 'packages/system/src/doc.mdx', + [ + '# Config example', + '', + '```ts', + "import legacy from '../../../legacy/core/src/index';", + "export default { exclude: ['**/legacy/**'] };", + '```', + '', + "import { Callout } from '../components/Callout';", + ].join('\n') + ); + + expect(scanSourceImports(root)).toEqual([]); + }); + + it('excludes the napi-generated extract-v2 loader from scanning', () => { + const root = scaffold('animus-topology-ext-generated-'); + write( + root, + 'packages/extract/crates/extract-v2/index.js', + "const x = require('../../../../legacy/core/src/index');\n" + ); + write( + root, + 'packages/extract/crates/extract-v2/index.d.ts', + "export { app } from '../../../../e2e/next-app/src/index';\n" + ); + + expect(scanSourceImports(root)).toEqual([]); + }); +}); + +describe('finding 2 — tsconfig extends chains', () => { + it('flags a forbidden path inherited from an extended parent', () => { + const root = scaffold('animus-topology-extends-inherit-'); + write( + root, + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + paths: { '@x/legacy': ['./legacy/core/src/index.ts'] }, + }, + }) + ); + write( + root, + 'packages/system/tsconfig.json', + JSON.stringify({ extends: '../../tsconfig.base.json' }) + ); + + const violations = scanTsconfigPaths(root); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ + file: join('packages', 'system', 'tsconfig.json'), + from: 'packages', + to: 'legacy', + }); + }); + + it('does not flag when a child overrides the parent paths with clean ones', () => { + const root = scaffold('animus-topology-extends-override-'); + write( + root, + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + paths: { '@x/legacy': ['./legacy/core/src/index.ts'] }, + }, + }) + ); + write( + root, + 'packages/system/tsconfig.json', + JSON.stringify({ + extends: '../../tsconfig.base.json', + compilerOptions: { paths: { '@x/ok': ['./src/index.ts'] } }, + }) + ); + + expect(scanTsconfigPaths(root)).toEqual([]); + }); + + it('resolves an extends chain of two or more parents', () => { + const root = scaffold('animus-topology-extends-chain-'); + write( + root, + 'tsconfig.a.json', + JSON.stringify({ + compilerOptions: { + paths: { '@x/e2e': ['./e2e/next-app/src/index.ts'] }, + }, + }) + ); + write( + root, + 'tsconfig.b.json', + JSON.stringify({ extends: './tsconfig.a.json' }) + ); + write( + root, + 'packages/system/tsconfig.json', + JSON.stringify({ extends: '../../tsconfig.b.json' }) + ); + + const violations = scanTsconfigPaths(root); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ from: 'packages', to: 'e2e' }); + }); + + it('silently skips a missing extended parent', () => { + const root = scaffold('animus-topology-extends-missing-'); + write( + root, + 'packages/system/tsconfig.json', + JSON.stringify({ extends: './does-not-exist.json' }) + ); + + expect(scanTsconfigPaths(root)).toEqual([]); + }); +}); + +describe('finding 3 — archived package names', () => { + it('flags a bare archived @animus-ui name imported from packages/*', () => { + const root = scaffold('animus-topology-archived-pkg-'); + write( + root, + 'packages/system/src/bad.ts', + "import { core } from '@animus-ui/core';\n" + ); + + const violations = scanSourceImports(root); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ from: 'packages', to: 'legacy' }); + expect(violations[0].detail).toContain('@animus-ui/core'); + }); + + it('flags a bare archived @animus-ui name imported from e2e/*', () => { + const root = scaffold('animus-topology-archived-e2e-'); + write( + root, + 'e2e/next-app/src/bad.ts', + "const c = await import('@animus-ui/core');\n" + ); + + const violations = scanSourceImports(root); + expect(violations.map((v) => `${v.from}->${v.to}`)).toContain( + 'e2e->legacy' + ); + }); + + it('does not flag the active @animus-ui/system package name', () => { + const root = scaffold('animus-topology-archived-active-'); + write( + root, + 'packages/properties/package.json', + manifest('@animus-ui/properties') + ); + write( + root, + 'packages/properties/src/ok.ts', + "import { system } from '@animus-ui/system';\n" + ); + + expect(scanSourceImports(root)).toEqual([]); + }); +}); + +describe('finding 4 — syntactic specifiers only (AST)', () => { + it('does not flag a forbidden path inside a string literal', () => { + const root = scaffold('animus-topology-ast-string-'); + write( + root, + 'packages/system/src/decoy.ts', + 'const s = "require(\'../../../legacy/core/src/index\')";\nexport { s };\n' + ); + + expect(scanSourceImports(root)).toEqual([]); + }); + + it('does not flag a forbidden path inside a template literal', () => { + const root = scaffold('animus-topology-ast-template-'); + write( + root, + 'packages/system/src/decoy.ts', + "const t = `import('../../../legacy/core/src/index')`;\nexport { t };\n" + ); + + expect(scanSourceImports(root)).toEqual([]); + }); + + it('still flags a genuine static import', () => { + const root = scaffold('animus-topology-ast-import-'); + write( + root, + 'packages/system/src/bad.ts', + "import x from '../../../legacy/core/src/index';\n" + ); + + expect(scanSourceImports(root)).toHaveLength(1); + }); + + it('flags a genuine dynamic import()', () => { + const root = scaffold('animus-topology-ast-dynamic-'); + write( + root, + 'packages/system/src/bad.ts', + "const x = import('../../../legacy/core/src/index');\n" + ); + + const violations = scanSourceImports(root); + expect(violations).toHaveLength(1); + expect(violations[0].detail).toContain('dynamic-import'); + }); + + it('flags a genuine export-from re-export', () => { + const root = scaffold('animus-topology-ast-export-'); + write( + root, + 'packages/system/src/bad.ts', + "export * from '../../../legacy/core/src/index';\n" + ); + + const violations = scanSourceImports(root); + expect(violations).toHaveLength(1); + expect(violations[0].detail).toContain('export'); + }); + + it('fails loud when an in-scope source file cannot be parsed', () => { + const root = scaffold('animus-topology-ast-parsefail-'); + write( + root, + 'packages/system/src/broken.ts', + 'const x = ; ) } @@@ import from garbage\n' + ); + + expect(() => scanSourceImports(root)).toThrow(/parse/i); + }); +}); + +describe('real repository tree', () => { + it('has zero workspace-topology violations', () => { + expect(collectViolations(ROOT)).toEqual([]); + }); + + it('main returns 0 against the live tree', () => { + expect(main(ROOT)).toBe(0); + }); +}); diff --git a/scripts/verify/topology.ts b/scripts/verify/topology.ts new file mode 100644 index 00000000..85c5e650 --- /dev/null +++ b/scripts/verify/topology.ts @@ -0,0 +1,761 @@ +#!/usr/bin/env bun +// scripts/verify/topology.ts +// +// Pure executable checker for the One-Way Dependency Rule (AGENTS.md § +// Workspace Topology). Dependencies flow top-down only: +// +// packages/* MUST NOT import e2e/* or legacy/* +// e2e/* MUST NOT import legacy/* +// e2e/* MAY import packages/* (permitted consumer direction) +// +// legacy/* has no package.json anywhere in the tree, so it is unreachable by a +// live workspace name; but its historically-published names (@animus-ui/core, +// theming, runtime, ui) still resolve on the public registry, so a bare import +// of one is a real vector into archived code. The rule is enforced across three +// real vectors: +// +// 1. source imports — import/export-from/require/dynamic-import specifiers +// in packages/* and e2e/* sources that resolve across a +// forbidden boundary (relative paths escaping the tree, a +// bare specifier naming an e2e workspace package, or a +// bare specifier naming an archived legacy package). +// 2. tsconfig paths — compilerOptions.paths targets (own or inherited via the +// `extends` chain) that resolve across a forbidden +// boundary, keyed by the owning tsconfig's tree. +// 3. package deps — a packages/* manifest declaring an e2e workspace +// package in any dependency map. +// +// REVISION 2026-07-20 (review-driven): specifier extraction is now an oxc-parser +// AST walk for the TS/JS family, replacing the archived change's design note D4 +// ("regex, not AST", zero-dependency). Two review findings forced the change: a +// code-looking string literal (`const s = "require('../legacy/core')"`) was +// flagged as a false positive, and the comment-stripping blind spots were +// fragile. oxc gives us genuine syntactic specifiers (ImportDeclaration / +// ExportNamedDeclaration / ExportAllDeclaration `.source`, ImportExpression +// string args, and `require(...)` / `import x = require(...)` calls) with no +// string/comment ambiguity. Parse failures FAIL LOUD — a file the checker +// cannot parse is a file it cannot clear. The regex fallback survives only for +// .mdx, whose top-level ESM import lines oxc does not parse. The archived design +// record stays as-is; this comment is the dated correction of record. + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +// oxc-parser is the repo's pinned in-process AST surface (same devDep and +// TS-ESTree `parseSync` → `{ program, errors, comments }` contract used by +// scripts/hygiene/delete-unused.ts). See langForParser() for the dialect pin. +import { parseSync } from 'oxc-parser'; + +export type Tree = 'packages' | 'e2e' | 'legacy' | 'other'; +export type Vector = 'import' | 'tsconfig-path' | 'package-dependency'; + +export interface Violation { + vector: Vector; + file: string; // repo-relative + from: Tree; + to: Tree; + detail: string; +} + +// Directories that never hold authored, boundary-relevant source: build +// outputs, vendored code, and generated staging trees. Pruned during the walk. +const PRUNE_DIRS = new Set([ + '.animus', + '.git', + '.next', + '.react-router', + '.staging', + '.turbo', + '.vite', + '.wrangler', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'target', +]); + +// Excluded wholesale (dir prefixes) or by exact path (files). Preserved with +// prefix semantics: `rel === entry` matches a single file, `rel.startsWith(entry +// + sep)` matches a subtree. +// - packages/_parity/corpus: byte-precise adversarial extraction/formatting +// fixtures, not code subject to the topology rule. +// - the extract-v2 napi loader + its typings are authored by `napi build`, not +// by hand; excluded on the same rationale as the vite.config.ts fmt +// ignorePatterns precedent (a generated file is not a topology source). +// (Build outputs — dist/build/target/node_modules/.staging — are pruned by +// PRUNE_DIRS during the walk, so they need no entry here.) +const EXCLUDE_PREFIXES = [ + 'packages/_parity/corpus', + 'packages/extract/crates/extract-v2/index.js', + 'packages/extract/crates/extract-v2/index.d.ts', +]; + +// Authored source across the whole ESM-import surface: TS family (ts/tsx/mts/ +// cts), JS family (js/jsx/mjs/cjs — packages/extract/index-v2.js is hand-written +// and MDX carries ESM imports), and MDX. .d.ts is included (it ends in .ts) so +// generated typings must be excluded by path above, not by extension. +const SOURCE_EXT = /\.(?:tsx?|mts|cts|jsx?|mjs|cjs|mdx)$/; +const TSCONFIG_NAME = /^tsconfig.*\.json$/; +// Bound on `extends` chain traversal — cycle/pathological-depth guard. +const MAX_EXTENDS_DEPTH = 32; +const DEPENDENCY_MAPS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +// The forbidden edges of the one-way rule. Every other edge — notably +// e2e -> packages, and anything from/to 'other' (repo root, tooling) — is +// permitted. +export function isForbidden(from: Tree, to: Tree): boolean { + if (from === 'packages') return to === 'e2e' || to === 'legacy'; + if (from === 'e2e') return to === 'legacy'; + return false; +} + +// Classifies an absolute path by its top-level segment relative to the repo +// root. Paths outside the repo (or at its root) are 'other' and cannot +// participate in a forbidden edge. +export function classifyTree(repoRoot: string, absPath: string): Tree { + const rel = relative(repoRoot, absPath); + if (rel === '' || rel.startsWith('..')) return 'other'; + const top = rel.split(sep)[0]; + if (top === 'packages') return 'packages'; + if (top === 'e2e') return 'e2e'; + if (top === 'legacy') return 'legacy'; + return 'other'; +} + +// Removes JS/TS line and block comments while preserving string-literal +// contents (import specifiers live inside strings). String scanning honours +// backslash escapes so a quote inside a string cannot terminate it early. +export function stripComments(source: string): string { + let out = ''; + let i = 0; + const n = source.length; + while (i < n) { + const c = source[i]; + if (c === '"' || c === "'" || c === '`') { + out += c; + i += 1; + while (i < n) { + if (source[i] === '\\') { + out += source.slice(i, i + 2); + i += 2; + continue; + } + out += source[i]; + if (source[i] === c) { + i += 1; + break; + } + i += 1; + } + continue; + } + const two = source.slice(i, i + 2); + if (two === '//') { + const nl = source.indexOf('\n', i); + if (nl === -1) break; + i = nl; + continue; + } + if (two === '/*') { + const end = source.indexOf('*/', i + 2); + i = end === -1 ? n : end + 2; + out += ' '; + continue; + } + out += c; + i += 1; + } + return out; +} + +export interface Specifier { + kind: 'import' | 'export' | 'require' | 'dynamic-import'; + value: string; +} + +// Thrown when oxc cannot parse an in-scope source file. FAIL LOUD: a file the +// checker cannot parse is a file it cannot clear (it may hide a real edge). +export class TopologyParseError extends Error { + constructor(filename: string, detail: string) { + super(`topology: failed to parse ${filename}: ${detail}`); + this.name = 'TopologyParseError'; + } +} + +// oxc deduces the dialect from the filename, but fixtures and the broadened +// scan hand it non-canonical names, so we pin `lang` (mirrors delete-unused.ts's +// langFor). Deviation from that precedent: authored `.js` in this React design +// system can carry JSX, and oxc's `js` dialect rejects JSX — so the whole JS +// family parses as `jsx` (a superset that also accepts plain JS/CJS) to avoid a +// fail-loud false positive on a legitimate JSX-bearing `.js`. +function langForParser(filename: string): 'ts' | 'tsx' | 'jsx' { + if (filename.endsWith('.tsx')) return 'tsx'; + if ( + filename.endsWith('.jsx') || + filename.endsWith('.js') || + filename.endsWith('.mjs') || + filename.endsWith('.cjs') + ) { + return 'jsx'; + } + return 'ts'; +} + +// A minimal structural node view — oxc emits a TS-ESTree AST whose nodes carry a +// string `type`; children are reached by walking own enumerable values. +type OxcNode = { type?: unknown; [key: string]: unknown }; + +function isOxcNode(value: unknown): value is OxcNode { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' + ); +} + +function literalStringValue(node: unknown): string | undefined { + if (isOxcNode(node) && node.type === 'Literal') { + const { value } = node as { value?: unknown }; + if (typeof value === 'string') return value; + } + return undefined; +} + +// Collects every genuine syntactic module specifier from an oxc AST. Only real +// specifier positions count — a code-looking string or template literal never +// does, because it is a Literal/TemplateLiteral, not one of these nodes. +function collectSpecifiers(node: unknown, out: Specifier[]): void { + if (Array.isArray(node)) { + for (const el of node) collectSpecifiers(el, out); + return; + } + if (!isOxcNode(node)) return; + + switch (node.type) { + case 'ImportDeclaration': { + // Covers `import x from 'y'` and side-effect `import 'y'` alike. + const v = literalStringValue(node.source); + if (v !== undefined) out.push({ kind: 'import', value: v }); + break; + } + case 'ExportNamedDeclaration': + case 'ExportAllDeclaration': { + // `export … from 'y'` / `export * from 'y'`; a bare export has no source. + const v = literalStringValue(node.source); + if (v !== undefined) out.push({ kind: 'export', value: v }); + break; + } + case 'ImportExpression': { + // `import('y')` — only a static string arg is a resolvable specifier; + // `import(expr)` / `import(`…`)` cannot be, and are skipped. + const v = literalStringValue(node.source); + if (v !== undefined) out.push({ kind: 'dynamic-import', value: v }); + break; + } + case 'TSImportEqualsDeclaration': { + // `import x = require('y')` — CJS via a TS external module reference. + const mr = node.moduleReference; + if (isOxcNode(mr) && mr.type === 'TSExternalModuleReference') { + const v = literalStringValue( + (mr as { expression?: unknown }).expression + ); + if (v !== undefined) out.push({ kind: 'require', value: v }); + } + break; + } + case 'CallExpression': { + // `require('y')` — a call to the bare `require` identifier. + const callee = node.callee; + const args = node.arguments; + if ( + isOxcNode(callee) && + callee.type === 'Identifier' && + (callee as { name?: unknown }).name === 'require' && + Array.isArray(args) && + args.length >= 1 + ) { + const v = literalStringValue(args[0]); + if (v !== undefined) out.push({ kind: 'require', value: v }); + } + break; + } + default: + break; + } + + for (const key of Object.keys(node)) { + if (key === 'type') continue; + collectSpecifiers(node[key], out); + } +} + +// MDX is not JS — oxc does not parse it — so its top-level ESM import/export +// lines are matched with a narrow regex. Only column-0 statements outside fenced +// code blocks count: real MDX imports (rendered components) live at the module +// top level, while `import`/`export` lines inside ``` / ~~~ fences are +// illustrative examples, not this document's dependencies. +function extractMdxSpecifiers(source: string): Specifier[] { + const out: Specifier[] = []; + let inFence = false; + const fromClause = /^(import|export)\b.*?\bfrom\s*['"]([^'"]+)['"]/; + const sideEffect = /^import\s+['"]([^'"]+)['"]/; + for (const line of source.split(/\r?\n/)) { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + const fm = fromClause.exec(line); + if (fm) { + out.push({ + kind: fm[1] === 'export' ? 'export' : 'import', + value: fm[2], + }); + continue; + } + const sm = sideEffect.exec(line); + if (sm) out.push({ kind: 'import', value: sm[1] }); + } + return out; +} + +// Extracts every module specifier from a source file. `filename` selects the +// extraction strategy and pins the oxc dialect; it defaults to a TS dialect so +// callers holding only a source string (e.g. unit tests) parse as TypeScript. +// FAIL LOUD on a parse error for the AST family; MDX stays regex. +export function extractSpecifiers( + source: string, + filename = 'inline.ts' +): Specifier[] { + if (filename.endsWith('.mdx')) return extractMdxSpecifiers(source); + + const result = parseSync(filename, source, { lang: langForParser(filename) }); + const errors = (result.errors ?? []).filter( + (e) => (e as { severity?: unknown }).severity === 'Error' + ); + if (errors.length > 0) { + const detail = + (errors[0] as { message?: unknown }).message?.toString() ?? + 'unknown parse error'; + throw new TopologyParseError(filename, detail); + } + const out: Specifier[] = []; + collectSpecifiers((result as { program?: unknown }).program, out); + return out; +} + +// Resolves a specifier to the tree it targets, or null when it is an external +// dependency the rule does not track. Relative specifiers resolve against the +// importing file; a bare specifier matters when it names an e2e workspace +// package (resolves to e2e) or an archived legacy package (resolves to legacy). +export function resolveSpecifierTree( + repoRoot: string, + fileAbs: string, + spec: string, + e2eNames: readonly string[], + // Archived legacy package names (see deriveArchivedNames). Defaults empty so + // callers that predate the archived-name vector keep their old behavior. + legacyNames: readonly string[] = [] +): Tree | null { + if (spec.startsWith('.')) { + return classifyTree(repoRoot, resolve(dirname(fileAbs), spec)); + } + for (const name of e2eNames) { + if (spec === name || spec.startsWith(`${name}/`)) return 'e2e'; + } + for (const name of legacyNames) { + if (spec === name || spec.startsWith(`${name}/`)) return 'legacy'; + } + return null; +} + +// Derives the archived-package name set from the legacy/* directory layout. +// +// Rationale (registry resolution, not workspace resolution): legacy/* has no +// package.json, so these names are unreachable as *workspace* packages — but +// @animus-ui/core, @animus-ui/theming, @animus-ui/runtime, and @animus-ui/ui +// were historically published and still resolve on the public registry. A bare +// import of one pulls archived code back into the active graph, which the +// One-Way Rule forbids ("the active graph must not depend on archived code"). +// The set is derived dynamically from the on-disk legacy/* directory names +// (prefixed @animus-ui/) rather than hardcoded: the arch-workspace-topology spec +// does not literally enumerate the archived packages, so the directory layout is +// the authoritative source. legacy/** itself is never scanned as a source (the +// walk roots are packages/* and e2e/* only), so it cannot self-trigger. +export function deriveArchivedNames(repoRoot: string): string[] { + const base = join(repoRoot, 'legacy'); + if (!existsSync(base)) return []; + let entries; + try { + entries = readdirSync(base, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => `@animus-ui/${entry.name}`) + .sort(); +} + +function isExcludedPath(rel: string): boolean { + return EXCLUDE_PREFIXES.some( + (prefix) => rel === prefix || rel.startsWith(`${prefix}${sep}`) + ); +} + +function walk( + dir: string, + repoRoot: string, + match: RegExp, + acc: string[] +): void { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (isExcludedPath(relative(repoRoot, full))) continue; + if (entry.isDirectory()) { + if (PRUNE_DIRS.has(entry.name)) continue; + walk(full, repoRoot, match, acc); + } else if (entry.isFile() && match.test(entry.name)) { + acc.push(full); + } + } +} + +function topLevelDirs(repoRoot: string, tree: 'packages' | 'e2e'): string[] { + const base = join(repoRoot, tree); + if (!existsSync(base)) return []; + return readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(base, entry.name)); +} + +function readJson(path: string): unknown { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return undefined; + } +} + +// tsconfig files are JSONC: comment-strip, then drop trailing commas before +// JSON.parse. Good enough to reach compilerOptions.paths without a full parser. +function readJsonc(path: string): unknown { + try { + const stripped = stripComments(readFileSync(path, 'utf8')).replace( + /,(\s*[}\]])/g, + '$1' + ); + return JSON.parse(stripped); + } catch { + return undefined; + } +} + +// Reads the workspace package name of every e2e/* member that has a manifest. +export function readE2ePackageNames(repoRoot: string): string[] { + const base = join(repoRoot, 'e2e'); + if (!existsSync(base)) return []; + const names: string[] = []; + for (const dir of topLevelDirs(repoRoot, 'e2e')) { + const manifest = join(dir, 'package.json'); + if (!existsSync(manifest)) continue; + const parsed = readJson(manifest) as { name?: unknown } | undefined; + if (parsed && typeof parsed.name === 'string' && parsed.name) { + names.push(parsed.name); + } + } + return names.sort(); +} + +// Vector 1 — source imports across a forbidden boundary. +export function scanSourceImports(repoRoot: string): Violation[] { + const e2eNames = readE2ePackageNames(repoRoot); + const legacyNames = deriveArchivedNames(repoRoot); + const files: string[] = []; + for (const tree of ['packages', 'e2e'] as const) { + for (const dir of topLevelDirs(repoRoot, tree)) { + walk(dir, repoRoot, SOURCE_EXT, files); + } + } + files.sort(); + + const violations: Violation[] = []; + const seen = new Set(); + for (const file of files) { + const from = classifyTree(repoRoot, file); + if (from !== 'packages' && from !== 'e2e') continue; + // extractSpecifiers FAILs LOUD on an unparseable AST-family file; the throw + // propagates so the checker cannot silently clear a file it cannot read. + for (const spec of extractSpecifiers(readFileSync(file, 'utf8'), file)) { + const to = resolveSpecifierTree( + repoRoot, + file, + spec.value, + e2eNames, + legacyNames + ); + if (to === null || !isForbidden(from, to)) continue; + const rel = relative(repoRoot, file); + const key = `${rel}::${spec.value}`; + if (seen.has(key)) continue; + seen.add(key); + violations.push({ + vector: 'import', + file: rel, + from, + to, + detail: `${spec.kind} '${spec.value}'`, + }); + } + } + return violations; +} + +// Normalizes an `extends` target (or a walk-found tsconfig path) to a concrete +// file, applying tsc's resolution shortcuts: an exact file, a `.json`-appended +// file, or a directory's `tsconfig.json`. Returns undefined when nothing exists +// (a missing parent is skipped silently, as this checker treats it). +function resolveTsconfigFile(p: string): string | undefined { + if (existsSync(p)) { + if (statSync(p).isFile()) return p; + if (statSync(p).isDirectory()) { + const nested = join(p, 'tsconfig.json'); + return existsSync(nested) ? nested : undefined; + } + } + const withJson = p.endsWith('.json') ? p : `${p}.json`; + return existsSync(withJson) ? withJson : undefined; +} + +// Resolves one `extends` specifier to a concrete config file. Relative +// specifiers resolve against the extending config's directory; package-style +// specifiers resolve node_modules upward from that directory, bounded at +// repoRoot so resolution never escapes the repo. Missing → undefined (skipped). +function resolveExtendsSpecifier( + spec: string, + fromDir: string, + repoRoot: string +): string | undefined { + if (spec.startsWith('.')) { + return resolveTsconfigFile(resolve(fromDir, spec)); + } + let dir = fromDir; + for (;;) { + const candidate = resolveTsconfigFile(join(dir, 'node_modules', spec)); + if (candidate) return candidate; + if (dir === repoRoot) break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +// The subset of effective compilerOptions the topology rule needs, resolved +// down an `extends` chain with exact TypeScript semantics. +interface EffectivePaths { + // The winning `paths` map and the directory it was declared in (its anchor + // when no baseUrl overrides it). + targets?: { map: Record; dir: string }; + // The directory an explicit `baseUrl` resolves to, if any config set one. + baseUrlDir?: string; +} + +// Computes effective `paths`/`baseUrl` for a config file, following `extends` +// with TypeScript override semantics: +// - extends is applied first (arrays left-to-right, later entries override +// earlier), then the config's own options override the inherited result; +// - `paths` REPLACES wholesale (a config declaring its own `paths` discards +// the inherited map entirely — there is no per-alias merge); +// - `baseUrl` is tracked independently, so a `paths` inherited from one config +// and a `baseUrl` set in another compose the way tsc resolves them. +// Missing parents are skipped silently; depth and a per-branch visited set guard +// against cycles and pathological chains. +function computeEffectivePaths( + file: string, + repoRoot: string, + seen: Set, + depth: number +): EffectivePaths { + const resolved = resolveTsconfigFile(file); + if (!resolved || seen.has(resolved) || depth > MAX_EXTENDS_DEPTH) return {}; + seen.add(resolved); + + const config = readJsonc(resolved) as + | { + extends?: unknown; + compilerOptions?: { baseUrl?: unknown; paths?: unknown }; + } + | undefined; + if (!config || typeof config !== 'object') return {}; + + const eff: EffectivePaths = {}; + + // 1. Inherit from extends (arrays: later overrides earlier). + const ext = config.extends; + const parents = Array.isArray(ext) + ? ext + : typeof ext === 'string' + ? [ext] + : []; + for (const parent of parents) { + if (typeof parent !== 'string') continue; + const parentFile = resolveExtendsSpecifier( + parent, + dirname(resolved), + repoRoot + ); + if (!parentFile) continue; // missing parent skipped silently + const inherited = computeEffectivePaths( + parentFile, + repoRoot, + new Set(seen), + depth + 1 + ); + if (inherited.targets) eff.targets = inherited.targets; + if (inherited.baseUrlDir) eff.baseUrlDir = inherited.baseUrlDir; + } + + // 2. Own options override inherited. + const options = config.compilerOptions; + if (options && typeof options === 'object') { + if (typeof options.baseUrl === 'string') { + eff.baseUrlDir = resolve(dirname(resolved), options.baseUrl); + } + if (options.paths && typeof options.paths === 'object') { + eff.targets = { + map: options.paths as Record, + dir: dirname(resolved), + }; + } + } + + return eff; +} + +// Vector 2 — tsconfig compilerOptions.paths aliases across a forbidden boundary. +// Effective paths include those inherited via the `extends` chain, not just the +// physically-present ones (TypeScript override semantics — see +// computeEffectivePaths). +export function scanTsconfigPaths(repoRoot: string): Violation[] { + const files: string[] = []; + for (const tree of ['packages', 'e2e'] as const) { + for (const dir of topLevelDirs(repoRoot, tree)) { + walk(dir, repoRoot, TSCONFIG_NAME, files); + } + } + files.sort(); + + const violations: Violation[] = []; + for (const file of files) { + const owner = classifyTree(repoRoot, file); + if (owner !== 'packages' && owner !== 'e2e') continue; + const eff = computeEffectivePaths(file, repoRoot, new Set(), 0); + if (!eff.targets) continue; + // Path targets resolve against baseUrl when any config in the chain set one, + // otherwise against the directory of the config that declared `paths`. + const baseDir = eff.baseUrlDir ?? eff.targets.dir; + + for (const [alias, targets] of Object.entries(eff.targets.map)) { + if (!Array.isArray(targets)) continue; + for (const target of targets) { + if (typeof target !== 'string') continue; + const abs = resolve(baseDir, target.replace(/\*/g, '')); + const to = classifyTree(repoRoot, abs); + if (!isForbidden(owner, to)) continue; + violations.push({ + vector: 'tsconfig-path', + file: relative(repoRoot, file), + from: owner, + to, + detail: `paths["${alias}"] -> "${target}"`, + }); + } + } + } + return violations; +} + +// Vector 3 — a packages/* manifest declaring an e2e workspace dependency. +export function scanPackageDependencies(repoRoot: string): Violation[] { + const e2eNames = new Set(readE2ePackageNames(repoRoot)); + const violations: Violation[] = []; + for (const dir of topLevelDirs(repoRoot, 'packages')) { + const manifest = join(dir, 'package.json'); + if (!existsSync(manifest)) continue; + const parsed = readJson(manifest) as Record | undefined; + if (!parsed) continue; + for (const mapName of DEPENDENCY_MAPS) { + const map = parsed[mapName]; + if (!map || typeof map !== 'object') continue; + for (const dep of Object.keys(map as Record)) { + if (!e2eNames.has(dep)) continue; + violations.push({ + vector: 'package-dependency', + file: relative(repoRoot, manifest), + from: 'packages', + to: 'e2e', + detail: `${mapName}["${dep}"]`, + }); + } + } + } + return violations; +} + +export function collectViolations(repoRoot: string): Violation[] { + return [ + ...scanSourceImports(repoRoot), + ...scanTsconfigPaths(repoRoot), + ...scanPackageDependencies(repoRoot), + ]; +} + +export function formatReport(violations: Violation[]): string { + const lines = [ + 'ERROR: workspace topology violation(s) — forbidden cross-boundary dependency.', + ' One-Way Dependency Rule (AGENTS.md § Workspace Topology): packages/* must', + ' not import e2e/* or legacy/*; e2e/* must not import legacy/*.', + ]; + for (const v of violations) { + lines.push(` ${v.file}: [${v.vector}] ${v.from} -> ${v.to}: ${v.detail}`); + } + lines.push( + ' Run: remove the offending reference(s) so dependencies flow top-down only', + ' (e2e/* -> packages/*); shared helpers belong in packages/_assertions.' + ); + return lines.join('\n'); +} + +export function main(repoRoot: string): number { + const violations = collectViolations(repoRoot); + if (violations.length === 0) { + console.log( + '[topology] workspace boundaries clean — no packages->e2e, packages->legacy, or e2e->legacy edges' + ); + return 0; + } + console.error(formatReport(violations)); + return 1; +} + +if (import.meta.main) { + const arg = process.argv[2]; + const root = arg ? resolve(arg) : resolve(import.meta.dirname, '../..'); + if (!statSync(root).isDirectory()) { + console.error(`ERROR: not a directory: ${root}`); + process.exit(2); + } + process.exit(main(root)); +} diff --git a/vite.config.ts b/vite.config.ts index 64694a57..cdd9db63 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,26 @@ import { defineConfig } from 'vite-plus'; const typescriptTestTargets = [ - 'packages/system/__tests__', + // Owned-root discovery (design decision D8): the whole system package root so + // colocated src/ tests cannot be silently omitted from the tier. + 'packages/system', 'packages/vite-plugin/tests', 'packages/next-plugin/tests', 'packages/properties/__tests__', 'packages/_assertions/__tests__', 'packages/_parity/__tests__', + // Explicit non-canary extractor test — the native canary runs separately via + // `bun test` in verify:canary, so the tests/ dir is NOT globbed wholesale. + 'packages/extract/tests/discover-packages.test.ts', 'scripts/verify/packed-graph.test.ts', 'scripts/verify/owner-graph.test.ts', 'scripts/verify/ci-graph.test.ts', 'scripts/verify/preconditions.test.ts', + 'scripts/verify/napi-target.test.ts', + // Fail-closed suppression policy behavior tests (design D5/D6, G4/G5/G6). + 'scripts/verify/rust-policy.test.ts', + 'scripts/verify/attw-def5.test.ts', + 'scripts/verify/topology.test.ts', ] as const; const typescriptTestTargetArguments = typescriptTestTargets.join(' '); const typescriptTestCommand = `bunx vp test run ${typescriptTestTargetArguments}`; @@ -172,6 +182,10 @@ export default defineConfig({ // no-eof-newline.tsx); formatting would destroy their properties. 'packages/_parity/corpus/**', 'openspec/changes/archive/**/*.md', + // repowise update rewrites this file with its extension recommendation + // in its own formatting on every run; keep the formatter out of the + // tug-of-war. + '.vscode/extensions.json', ], }, test: { @@ -195,7 +209,8 @@ export default defineConfig({ run: { tasks: { 'verify:lint': { - command: 'bunx vp lint && bunx vp fmt --check', + command: + 'bunx vp lint && bunx vp fmt --check && bun scripts/verify/topology.ts', cache: false, }, 'verify:compile': { From 49d6b115de37340f30593b7794d8cf96276e3656 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Wed, 22 Jul 2026 02:38:33 -0400 Subject: [PATCH 2/6] Refactor --- packages/extract/pipeline/index.ts | 13 + packages/extract/pipeline/run-analysis.ts | 126 ++ packages/extract/pipeline/system-config.ts | 72 + .../extract/pipeline/system-props-module.ts | 28 + .../next-plugin/src/extraction-session.ts | 625 ++++++++ packages/next-plugin/src/loader-core.ts | 117 ++ packages/next-plugin/src/loader.ts | 103 +- packages/next-plugin/src/plugin.ts | 764 +--------- packages/next-plugin/src/resolve-packages.ts | 71 + packages/next-plugin/src/timing.ts | 63 + packages/next-plugin/src/turbopack-config.ts | 97 ++ packages/next-plugin/src/turbopack-loader.ts | 126 ++ .../next-plugin/src/turbopack-orchestrator.ts | 103 ++ packages/next-plugin/src/types.ts | 16 + packages/next-plugin/src/with-animus.ts | 69 +- .../tests/manifest-diagnostics.test.ts | 52 +- .../next-plugin/tests/plugin-pipeline.test.ts | 23 + .../tests/turbopack-config.test.ts | 91 ++ .../tests/turbopack-loader.test.ts | 146 ++ .../tests/turbopack-orchestrator.test.ts | 259 ++++ .../next-plugin/tests/with-animus.test.ts | 2 + packages/next-plugin/tsdown.config.ts | 2 +- packages/vite-plugin/src/build-start.ts | 238 ++++ packages/vite-plugin/src/config.ts | 69 + packages/vite-plugin/src/constants.ts | 13 + packages/vite-plugin/src/context.ts | 379 +++++ packages/vite-plugin/src/css.ts | 65 + packages/vite-plugin/src/hmr.ts | 252 ++++ packages/vite-plugin/src/index.ts | 1252 +---------------- packages/vite-plugin/src/transform.ts | 116 ++ packages/vite-plugin/src/virtual-modules.ts | 150 ++ .../tests/manifest-diagnostics.test.ts | 3 +- 32 files changed, 3432 insertions(+), 2073 deletions(-) create mode 100644 packages/extract/pipeline/run-analysis.ts create mode 100644 packages/extract/pipeline/system-config.ts create mode 100644 packages/extract/pipeline/system-props-module.ts create mode 100644 packages/next-plugin/src/extraction-session.ts create mode 100644 packages/next-plugin/src/loader-core.ts create mode 100644 packages/next-plugin/src/resolve-packages.ts create mode 100644 packages/next-plugin/src/timing.ts create mode 100644 packages/next-plugin/src/turbopack-config.ts create mode 100644 packages/next-plugin/src/turbopack-loader.ts create mode 100644 packages/next-plugin/src/turbopack-orchestrator.ts create mode 100644 packages/next-plugin/tests/turbopack-config.test.ts create mode 100644 packages/next-plugin/tests/turbopack-loader.test.ts create mode 100644 packages/next-plugin/tests/turbopack-orchestrator.test.ts create mode 100644 packages/vite-plugin/src/build-start.ts create mode 100644 packages/vite-plugin/src/config.ts create mode 100644 packages/vite-plugin/src/constants.ts create mode 100644 packages/vite-plugin/src/context.ts create mode 100644 packages/vite-plugin/src/css.ts create mode 100644 packages/vite-plugin/src/hmr.ts create mode 100644 packages/vite-plugin/src/transform.ts create mode 100644 packages/vite-plugin/src/virtual-modules.ts diff --git a/packages/extract/pipeline/index.ts b/packages/extract/pipeline/index.ts index 1699046e..bf42714a 100644 --- a/packages/extract/pipeline/index.ts +++ b/packages/extract/pipeline/index.ts @@ -33,6 +33,19 @@ export { findPackageRoot, } from './discover-packages'; export { buildPathAliasesJson } from './path-aliases'; +export type { + AnalysisOptions, + EmitterConfig, + ProjectAnalysisResult, +} from './run-analysis'; +export { + buildAnalysisInputs, + clearEngineCache, + runProjectAnalysis, +} from './run-analysis'; +export type { SystemConfig } from './system-config'; +export { loadSystemConfig } from './system-config'; +export { buildSystemPropsModule } from './system-props-module'; export { formatRustTimingWaterfall } from './timing-waterfall'; export type { DynamicPropConfigEntry, diff --git a/packages/extract/pipeline/run-analysis.ts b/packages/extract/pipeline/run-analysis.ts new file mode 100644 index 00000000..8cc93c21 --- /dev/null +++ b/packages/extract/pipeline/run-analysis.ts @@ -0,0 +1,126 @@ +import { buildAnalyzeProjectArgs } from './analyze-project-args'; +import { surfaceManifestDiagnostics } from './manifest-diagnostics'; +import { applyUnitFallback } from './unit-fallback'; + +import type { AnalyzeProjectInputs } from './analyze-project-args'; +import type { SystemConfig } from './system-config'; + +/** + * Per-bundler emitter identity: where the runtime import comes from and + * which module ids the Rust emitter injects into transformed sources. + * Vite uses virtual module ids; Next uses on-disk `.animus/` paths. + */ +export interface EmitterConfig { + runtimeImport: string; + cssModuleId: string; + systemPropsModuleId?: string; +} + +export interface ProjectAnalysisResult { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + manifest: any; + manifestJson: string; + /** `manifest.sheets.global` — Rust-resolved global CSS. */ + globalCss: string; + /** `manifest.css` with the shared unit fallback applied. */ + componentCss: string; + /** The exact analyze-time inputs (already-serialized filesJson included) — + * reusable for persistence without re-serializing the source corpus. */ + inputs: AnalyzeProjectInputs; + /** Sub-phase durations (ms) for verbose timing displays. */ + timings: { serializeMs: number; extractMs: number; parseMs: number }; +} + +export interface AnalysisOptions { + fileEntries: Array<{ path: string; source: string; hash?: string }>; + packageMap: Record; + system: SystemConfig; + emitter: EmitterConfig; + pathAliasesJson: string | null; + devMode: boolean; +} + +/** + * Build the named `analyzeProject` input set from analysis options. Also + * the persistence shape for `.animus/analysis-inputs.json` — an isolated + * process can replay the analysis from exactly this object + * (spec: next-turbopack-integration). + */ +export function buildAnalysisInputs(opts: AnalysisOptions): AnalyzeProjectInputs { + return { + filesJson: JSON.stringify(opts.fileEntries), + scalesJson: opts.system.scalesJson, + variableMapJson: opts.system.variableMapJson, + contextualVarsJson: opts.system.contextualVarsJson, + propConfigJson: opts.system.propConfigJson, + groupRegistryJson: opts.system.groupRegistryJson, + packageResolutionJson: JSON.stringify(opts.packageMap), + devMode: opts.devMode, + emitterConfigJson: JSON.stringify({ + runtime_import: opts.emitter.runtimeImport, + css_module_id: opts.emitter.cssModuleId, + ...(opts.emitter.systemPropsModuleId + ? { system_props_module_id: opts.emitter.systemPropsModuleId } + : {}), + }), + selectorAliasesJson: opts.system.selectorAliasesJson, + globalStyleBlocksJson: opts.system.globalStyleBlocksJson, + pathAliasesJson: opts.pathAliasesJson, + keyframesJson: opts.system.keyframesJson, + }; +} + +/** + * The one analysis invocation both plugins share: build the emitter + * config, serialize inputs, call the NAPI `analyzeProject`, parse the + * manifest, surface its diagnostics, and resolve the CSS outputs. + * + * Error handling stays at the call site (strict-mode throw vs warn). + */ +export function runProjectAnalysis( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engineApi: () => any, + opts: AnalysisOptions & { warn: (message: string) => void } +): ProjectAnalysisResult { + const { analyzeProject } = engineApi(); + + let t = performance.now(); + const inputs = buildAnalysisInputs(opts); + const serializeMs = Math.round(performance.now() - t); + + t = performance.now(); + const manifestJson: string = analyzeProject( + ...buildAnalyzeProjectArgs(inputs) + ); + const extractMs = Math.round(performance.now() - t); + + t = performance.now(); + const manifest = JSON.parse(manifestJson); + surfaceManifestDiagnostics(manifest, opts.warn); + const parseMs = Math.round(performance.now() - t); + + return { + manifest, + manifestJson, + globalCss: manifest?.sheets?.global || '', + componentCss: applyUnitFallback(manifest?.css || ''), + inputs, + timings: { serializeMs, extractMs, parseMs }, + }; +} + +/** + * Clear the engine's per-file analysis cache so stale results from a prior + * build never bleed into a fresh run. Tolerates engines without the + * capability (older builds) — the probe is benign. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function clearEngineCache(engineApi: () => any): void { + try { + const { clearAnalysisCache } = engineApi(); + clearAnalysisCache(); + } catch { + // Benign optional-capability probe: nothing to clear on engines that + // predate clearAnalysisCache. + } +} diff --git a/packages/extract/pipeline/system-config.ts b/packages/extract/pipeline/system-config.ts new file mode 100644 index 00000000..5e96826a --- /dev/null +++ b/packages/extract/pipeline/system-config.ts @@ -0,0 +1,72 @@ +import { applyPrefix } from './prefix'; + +/** + * The system-derived configuration bundle every extraction run needs — + * the deserialized result of the NAPI `loadSystemModule()` call, with the + * optional variable-prefix transformation already applied. Field names + * mirror `AnalyzeProjectInputs` so the bundle feeds `runProjectAnalysis` + * without renaming. + */ +export interface SystemConfig { + propConfigJson: string; + groupRegistryJson: string; + scalesJson: string; + variableMapJson: string; + variableCss: string; + contextualVarsJson: string | null; + selectorAliasesJson: string | null; + globalStyleBlocksJson: string | null; + keyframesJson: string | null; +} + +/** + * Load a SystemInstance via the engine's `loadSystemModule` and normalize + * it into a SystemConfig. When `prefix` is set, CSS variable names in the + * variable map/css (and theme + contextual vars when affected) are + * namespaced via the shared `applyPrefix`. + * + * Error handling stays at the call site — the Vite plugin warns (or throws + * in strict mode) and keeps its previous config, the Next plugin lets the + * failure propagate. + */ +export function loadSystemConfig( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engineApi: () => any, + opts: { systemPath: string; rootDir: string; prefix?: string } +): SystemConfig { + const { loadSystemModule } = engineApi(); + const config = loadSystemModule(opts.systemPath, opts.rootDir); + + let scalesJson: string = config.scalesJson; + let variableMapJson: string = config.variableMapJson; + let variableCss: string = config.variableCss; + let contextualVarsJson: string | null = config.contextualVarsJson || null; + + if (opts.prefix) { + const prefixed = applyPrefix( + opts.prefix, + variableMapJson, + variableCss, + scalesJson, + contextualVarsJson || undefined + ); + variableMapJson = prefixed.variableMapJson; + variableCss = prefixed.variableCss; + if (prefixed.themeJson) scalesJson = prefixed.themeJson; + if (prefixed.contextualVarsJson) { + contextualVarsJson = prefixed.contextualVarsJson; + } + } + + return { + propConfigJson: config.propConfig, + groupRegistryJson: config.groupRegistry, + scalesJson, + variableMapJson, + variableCss, + contextualVarsJson, + selectorAliasesJson: config.selectorAliases || null, + globalStyleBlocksJson: config.globalStyleBlocks || null, + keyframesJson: config.keyframesBlocks || null, + }; +} diff --git a/packages/extract/pipeline/system-props-module.ts b/packages/extract/pipeline/system-props-module.ts new file mode 100644 index 00000000..7156a58b --- /dev/null +++ b/packages/extract/pipeline/system-props-module.ts @@ -0,0 +1,28 @@ +import { buildDynamicPropConfig } from './dynamic-prop-config'; + +import type { DynamicPropMeta } from './dynamic-prop-config'; + +/** + * Build the source of the runtime system-props module — served as + * `virtual:animus/system-props` by the Vite plugin and written to + * `.animus/system-props.js` by the Next plugin. One generator so the two + * runtimes can never drift. + * + * Runtime transform functions for dynamic props are not yet supported + * (transforms are resolved at extraction time via boa_engine in Rust), so + * `transforms` defaults to an empty object literal. + */ +export function buildSystemPropsModule(opts: { + systemPropMapJson: string; + groupRegistryJson: string; + dynamicProps: Record; + transformsSource?: string; +}): string { + const dynamicPropConfig = buildDynamicPropConfig(opts.dynamicProps); + return ( + `export const systemPropMap = ${opts.systemPropMapJson};\n` + + `export const systemPropGroups = ${opts.groupRegistryJson};\n` + + `export const dynamicPropConfig = ${JSON.stringify(dynamicPropConfig)};\n` + + `export const transforms = ${opts.transformsSource ?? '{}'};\n` + ); +} diff --git a/packages/next-plugin/src/extraction-session.ts b/packages/next-plugin/src/extraction-session.ts new file mode 100644 index 00000000..4ada22d0 --- /dev/null +++ b/packages/next-plugin/src/extraction-session.ts @@ -0,0 +1,625 @@ +import { + assembleStylesheet, + buildSystemPropsModule, + clearEngineCache, + collectExternalPackageSources, + contentHash, + DEFAULT_EXTENSIONS, + discoverFiles, + extractSystemFilePackages, + loadSystemConfig, + preprocessMdx, + runProjectAnalysis, +} from '@animus-ui/extract/pipeline'; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'fs'; +import { extname, join, relative, resolve } from 'path'; + +import { resolvePackagesByName } from './resolve-packages'; +import { + engineApi, + resetAnalysisPromise, + setAnalysisPromise, + setManifestJson, + setSharedCss, + setSharedExternalDirs, + setSharedExternalEntries, + setSharedSystemProps, +} from './singleton'; +import { logBuildTimings } from './timing'; + +import type { AnimusNextOptions } from './types'; +import type { DynamicPropMeta, SystemConfig } from '@animus-ui/extract/pipeline'; + +/** + * Module id the Rust emitter injects for the extracted stylesheet — also the + * exact resolve.alias key with-animus registers for it, which the adapter's + * alias harvesting must skip. + */ +export const ANIMUS_CSS_MODULE_ID = '.animus/styles.css'; + +/** Default path fragments excluded from source discovery (full + watch). */ +const DEFAULT_EXCLUDE = [ + 'node_modules', + 'dist', + '.test.', + '.spec.', + '.next', + '.animus', +]; + +type FileEntry = { path: string; source: string; hash: string }; + +/** Watch-cycle change sets, as reported by the bundler's watcher. */ +export interface WatchChanges { + modifiedFiles?: ReadonlySet; + removedFiles?: ReadonlySet; +} + +/** + * Bundler-agnostic extraction pipeline: system loading, source discovery + * and ingestion, external-package collection, analysis, stylesheet and + * system-props emission, and watch-cycle diffing. + * + * The webpack adapter (plugin.ts) owns only bundler wiring: hook + * registration, alias harvesting from the compiler config, in-memory asset + * replacement, and translating watch events into `handleWatchUpdate` + * change sets. Keeping this class free of webpack types is deliberate — a + * future Turbopack integration must drive the same session from outside + * the bundler (Turbopack has no compiler-hook surface), reusing everything + * here unchanged. + * + * Outputs are published through the package singleton (shared CSS, + * manifest, system props) and written to `.animus/` on disk. + */ +export class ExtractionSession { + /** Set by the adapter before any pipeline run. */ + rootDir: string | null = null; + /** Serialized path aliases, harvested by the adapter from bundler config. */ + pathAliasesJson: string | null = null; + /** 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. */ + persistAnalysisInputs = false; + + /** Absolute directory prefixes for external DS packages (loader allowlisting). */ + externalPackageDirs: string[] = []; + /** External package specifier → absolute source entry path. */ + externalSourceEntries = new Map(); + + private readonly options: AnimusNextOptions; + private system: SystemConfig | null = null; + /** Full package-resolution map from the last full pipeline — replayed by + * incremental passes (sourceEntries alone omits dist-resolved packages). */ + private lastPackageMap: Record = {}; + + // File tracking for HMR + private fileCache = new Map(); + private lastCssHash: string | null = null; + private lastSystemPropsHash: string | null = null; + private lastManifestHash: string | null = null; + private lastAnalysisInputsHash: string | null = null; + + constructor(options: AnimusNextOptions) { + this.options = options; + } + + get verbose(): boolean { + return ( + this.options.verbose === true || + process.env.ANIMUS_DEBUG === '1' || + process.env.ANIMUS_DEBUG === 'true' + ); + } + + private log(msg: string): void { + if (this.verbose) { + console.info(`[animus] ${msg}`); + } + } + + private warn(msg: string): void { + console.warn(`[animus] ${msg}`); + } + + // Zero-cost timer gate + private now(): number { + return this.verbose ? performance.now() : 0; + } + private elapsed(t: number): number { + return this.verbose ? Math.round(performance.now() - t) : 0; + } + + /** Resolve the scan configuration from options — the single source of the + * exclude/extension policy shared by the full and incremental pipelines. */ + private resolveScanConfig(): { + excludePatterns: string[]; + extensionsSet: ReadonlySet; + shouldHandleMdx: boolean; + } { + const extensionsSet: ReadonlySet = new Set( + this.options.extensions ?? DEFAULT_EXTENSIONS + ); + return { + excludePatterns: this.options.exclude ?? DEFAULT_EXCLUDE, + extensionsSet, + shouldHandleMdx: extensionsSet.has('.mdx'), + }; + } + + /** + * Incremental watch pass: geological reset when the system file changed, + * otherwise content-hash diffing restricted to the watcher's change sets + * (falling back to a full discovery walk when no sets are provided). + */ + async handleWatchUpdate(changes: WatchChanges): Promise { + // Guard: if system state was never loaded (non-owning instance that + // skipped runFullPipeline), skip — processAssets reads from shared variable + if (!this.system) return; + + const rootDir = this.rootDir!; + const resolvedSystemPath = resolve(rootDir, this.options.system); + + // Check for geological reset: system file changed + try { + const systemSource = readFileSync(resolvedSystemPath, 'utf-8'); + const systemRelPath = relative(rootDir, resolvedSystemPath); + const cached = this.fileCache.get(systemRelPath); + const currentHash = contentHash(systemSource); + + if (cached && cached.hash !== currentHash) { + // Geological reset: system file changed + this.resetForHmr(); + const promise = this.runFullPipeline(); + setAnalysisPromise(promise); + await promise; + return; + } + } catch (err) { + // Not a benign probe: this wraps the system-file read and the + // geological-reset re-run. Swallowing keeps a transient read failure + // from crashing the watch loop, but a real fault must stay diagnosable. + this.warn(`HMR geological-reset check failed: ${String(err)}`); + } + + // Check for component file changes using content-hash diffing + const { excludePatterns, extensionsSet, shouldHandleMdx } = + this.resolveScanConfig(); + + // Prune deleted/renamed files so their last-known source stops riding + // along as a ghost entry on every subsequent incremental analysis. + let removedAny = false; + if (changes.removedFiles) { + for (const removedPath of changes.removedFiles) { + const rel = relative(rootDir, removedPath); + // MDX cache keys carry the preprocessed `.tsx` suffix. + if (this.fileCache.delete(rel) || this.fileCache.delete(rel + '.tsx')) { + removedAny = true; + } + } + } + + // Restrict the read+hash pass to the watcher's modified set when + // available; fall back to a full discovery walk otherwise. Filters + // mirror discoverFiles: extension allowlist plus substring exclude + // patterns on both path forms. + let files: string[]; + if (changes.modifiedFiles) { + files = []; + for (const modifiedPath of changes.modifiedFiles) { + if (!extensionsSet.has(extname(modifiedPath))) continue; + const rel = relative(rootDir, modifiedPath); + if (rel.startsWith('..')) continue; + if ( + excludePatterns.some( + (pattern) => + modifiedPath.includes(pattern) || rel.includes(pattern) + ) + ) { + continue; + } + files.push(modifiedPath); + } + } else { + files = discoverFiles(rootDir, rootDir, excludePatterns, extensionsSet); + } + + const changedPaths: string[] = []; + + for (const filePath of files) { + let relPath = relative(rootDir, filePath); + let source: string; + try { + source = readFileSync(filePath, 'utf-8'); + } catch { + // Benign race: the file vanished between the watch event and this + // read — it will surface in removedFiles on the next watch cycle. + continue; + } + + if (shouldHandleMdx && extname(filePath) === '.mdx') { + // Watch pass stays silent on failure — the full pipeline already + // surfaced any missing-dep / preprocessing warning. + const processed = await this.preprocessMdxEntry(source, relPath, { + warn: false, + }); + if (!processed) continue; + source = processed.source; + relPath = processed.relPath; + } + + const cached = this.fileCache.get(relPath); + const hash = contentHash(source); + + if (!cached || cached.hash !== hash) { + changedPaths.push(relPath); + this.fileCache.set(relPath, { hash, source }); + } + } + + if (changedPaths.length > 0 || removedAny) { + // Every cached file rides with full source (v2 has no Rust-side cache). + const fileEntries = this.buildFileEntriesFromCache(); + + resetAnalysisPromise(); + const promise = this.runIncrementalPipeline(fileEntries); + setAnalysisPromise(promise); + await promise; + } + } + + /** + * Preprocess one `.mdx` entry into scanner-consumable tsx. Returns the + * rewritten source plus the `relPath + '.tsx'` path on success, or null + * when the file must be skipped. + * + * `warn: false` (incremental watch pass) skips silently — the full + * pipeline already surfaced the warning. `warn: true` (full pipeline) + * warns ONCE for a missing @mdx-js/mdx dependency via the shared + * `missingDepFlag` holder, and every time for a preprocessing error. + */ + private async preprocessMdxEntry( + source: string, + relPath: string, + opts: { warn: boolean; missingDepFlag?: { warned: boolean } } + ): Promise<{ source: string; relPath: string } | null> { + const result = await preprocessMdx(source, relPath); + if (result.kind === 'missing-dep') { + if (opts.warn && opts.missingDepFlag && !opts.missingDepFlag.warned) { + console.warn( + '[animus] ⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped' + ); + opts.missingDepFlag.warned = true; + } + return null; + } + if (result.kind === 'error') { + if (opts.warn) { + console.warn( + `[animus] ⚠ MDX preprocessing failed for ${relPath}: ${result.error}` + ); + } + return null; + } + // Path rewrite so the Rust source-type helper parses as tsx. + return { source: result.source!, relPath: relPath + '.tsx' }; + } + + async runFullPipeline(): Promise { + const pipelineStart = this.now(); + const bt: Record = {}; + + // Clear Rust-side per-file cache so stale results from a prior + // build never bleed into a fresh pipeline run. + clearEngineCache(engineApi); + + const rootDir = this.rootDir!; + const resolvedSystemPath = resolve(rootDir, this.options.system); + + // Step 1: Load system via NAPI + let t = this.now(); + this.system = loadSystemConfig(engineApi, { + systemPath: resolvedSystemPath, + rootDir, + prefix: this.options.prefix, + }); + bt.systemLoad = this.elapsed(t); + + // Step 2: Discover source files + t = this.now(); + const { excludePatterns, extensionsSet, shouldHandleMdx } = + this.resolveScanConfig(); + const missingDepFlag = { warned: false }; + const files = discoverFiles( + rootDir, + rootDir, + excludePatterns, + extensionsSet + ); + + bt.fileDiscovery = this.elapsed(t); + + // Step 3: Read file sources and build entries (preprocessing MDX as we go) + t = this.now(); + const fileEntries: FileEntry[] = []; + for (const filePath of files) { + let source = readFileSync(filePath, 'utf-8'); + let relPath = relative(rootDir, filePath); + + if (shouldHandleMdx && extname(filePath) === '.mdx') { + const processed = await this.preprocessMdxEntry(source, relPath, { + warn: true, + missingDepFlag, + }); + if (!processed) continue; + source = processed.source; + relPath = processed.relPath; + } + + const hash = contentHash(source); + this.fileCache.set(relPath, { hash, source }); + fileEntries.push({ path: relPath, source, hash }); + } + + bt.fileRead = this.elapsed(t); + bt.fileCount = fileEntries.length; + + // Step 4: Resolve external packages from system file imports. Workspace + // walk + require.resolve stays local (the Node-resolution seam); the + // traversal/ingest below is the shared collector + // (spec: external-package-file-discovery). + t = this.now(); + const packageNames = extractSystemFilePackages(resolvedSystemPath); + const preResolved = resolvePackagesByName(rootDir, packageNames); + + const collected = await collectExternalPackageSources({ + specifiers: packageNames, + resolveSpecifier: (name) => + preResolved[name] ? resolve(rootDir, preResolved[name]) : null, + rootDir, + extensionsSet, + hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath), + preprocessFile: async (source, relPath, absPath) => { + if (shouldHandleMdx && extname(absPath) === '.mdx') { + return this.preprocessMdxEntry(source, relPath, { + warn: true, + missingDepFlag, + }); + } + return { source, relPath }; + }, + onUnreadable: (relPath, err) => + this.warn( + `skipped unreadable package file ${relPath}: ${String(err)}` + ), + }); + + const packageMap = collected.packageMap; + this.lastPackageMap = packageMap; + this.externalSourceEntries = collected.sourceEntries; + for (const entry of collected.entries) { + const hash = contentHash(entry.source); + this.fileCache.set(entry.path, { hash, source: entry.source }); + fileEntries.push({ path: entry.path, source: entry.source, hash }); + } + + this.externalPackageDirs = collected.packageDirs; + + // Publish external package state for non-owning compiler instances + setSharedExternalDirs(collected.packageDirs); + setSharedExternalEntries(collected.sourceEntries); + + bt.packageResolve = this.elapsed(t); + + // Step 5+: hand off to the shared analysis + emit core. Production pass + // (devMode=false) writes system-props.js unconditionally and logs the + // extraction report. + await this.analyzeAndEmit( + fileEntries, + packageMap, + false, + bt, + pipelineStart + ); + } + + /** + * Reset analysis state for HMR geological reset. + */ + resetForHmr(): void { + resetAnalysisPromise(); + this.lastCssHash = null; + this.lastSystemPropsHash = null; + this.lastManifestHash = null; + this.lastAnalysisInputsHash = null; + clearEngineCache(engineApi); + } + + /** + * Build file entries from cache: every cached file rides with full source. + * The v2 engine has NO Rust-side cache (extract-v2-spine DEF-7: uncached + * re-analysis beats a cache-hit path), so it must always receive full sources + * (openspec: retire-extract-v1 removed the v1 empty-source cache contract). + */ + private buildFileEntriesFromCache(): FileEntry[] { + const entries: FileEntry[] = []; + for (const [path, { hash, source }] of this.fileCache) { + entries.push({ path, source, hash }); + } + return entries; + } + + /** + * Run incremental pipeline with cache-aware file entries. + * Reuses system config from the last full pipeline run. + */ + private async runIncrementalPipeline(fileEntries: FileEntry[]): Promise { + const bt: Record = {}; + const pipelineStart = this.now(); + + // Replay the FULL package map resolved during the last full pipeline; + // the incremental pass never re-discovers external packages. Deriving it + // from externalSourceEntries would silently drop dist-resolved packages + // (those have no src/index.ts and live only in the package map). + await this.analyzeAndEmit( + fileEntries, + this.lastPackageMap, + true, + bt, + pipelineStart + ); + } + + /** + * Shared analysis + emit core for both pipelines — the single call site + * that routes every manifest through the shared `runProjectAnalysis`, + * reachable from both runFullPipeline (production) and + * runIncrementalPipeline (HMR). + * + * Owns diagnostic surfacing, CSS assembly + styles.css write guard, + * system-props module emit, and the timing log. The `devMode` flag is the + * ONLY behavioral fork: + * + * - `false` (production): computes bt.analysis + logs the extraction + * report, and writes system-props.js UNCONDITIONALLY (no + * lastSystemPropsHash guard). + * - `true` (HMR): skips the report log, and guards the system-props.js + * write by lastSystemPropsHash. + * + * The styles.css write guard (lastCssHash) is identical on both paths. + */ + private async analyzeAndEmit( + fileEntries: FileEntry[], + packageMap: Record, + devMode: boolean, + bt: Record, + pipelineStart: number + ): Promise { + const system = this.system!; + + const analysisOptions = { + fileEntries, + packageMap, + system, + emitter: { + runtimeImport: '@animus-ui/system/runtime', + cssModuleId: ANIMUS_CSS_MODULE_ID, + systemPropsModuleId: join(this.rootDir!, '.animus', 'system-props.js'), + }, + pathAliasesJson: this.pathAliasesJson, + devMode, + }; + + const result = runProjectAnalysis(engineApi, { + ...analysisOptions, + warn: (message) => this.warn(message), + }); + + bt.jsonSerialize = result.timings.serializeMs; + bt.rustExtract = result.timings.extractMs; + bt.jsonParse = result.timings.parseMs; + + const manifest = result.manifest; + + if (!devMode) { + bt.analysis = + (bt.jsonSerialize ?? 0) + (bt.rustExtract ?? 0) + (bt.jsonParse ?? 0); + if (manifest?.report) { + this.log( + `Extracted ${manifest.report.components_extracted ?? '?'}/${manifest.report.components_total ?? '?'} components (${bt.analysis}ms)` + ); + } + } + + // Assemble full stylesheet (canonical order via shared function) + const { declaration, variables, body } = assembleStylesheet({ + layers: this.options.layers, + variableCss: system.variableCss, + globalCss: result.globalCss, + componentCss: result.componentCss, + split: true, + }); + const fullCss = [declaration, variables, body].filter(Boolean).join('\n'); + + // Store CSS in shared variable (authoritative source for processAssets) + setSharedCss(fullCss); + + // Disk write serves as HMR trigger only — processAssets replaces content in-memory + const cssHash = contentHash(fullCss); + if (cssHash !== this.lastCssHash) { + this.writeAnimusFile('styles.css', fullCss); + this.lastCssHash = cssHash; + } + + // Build system-props module for runtime resolution via the shared + // generator (transforms resolve at extraction time in Rust). + const systemPropsContent = buildSystemPropsModule({ + systemPropMapJson: JSON.stringify(manifest?.system_prop_map ?? {}), + groupRegistryJson: system.groupRegistryJson, + dynamicProps: (manifest?.dynamic_props ?? {}) as Record< + string, + DynamicPropMeta + >, + }); + + setSharedSystemProps(systemPropsContent); + + if (devMode) { + // HMR: skip the disk write when byte-identical to the last one written. + const systemPropsHash = contentHash(systemPropsContent); + if (systemPropsHash !== this.lastSystemPropsHash) { + this.writeAnimusFile('system-props.js', systemPropsContent); + this.lastSystemPropsHash = systemPropsHash; + } + } else { + // Production: write unconditionally (no lastSystemPropsHash guard). + this.writeAnimusFile('system-props.js', systemPropsContent); + } + + // Store manifest for loader + setManifestJson(result.manifestJson); + + // Disk manifest artifact (spec: next-turbopack-integration) — the + // loader-visible contract for bundlers without shared process memory. + // Written in both modes, hash-guarded like system-props. + const manifestHash = contentHash(result.manifestJson); + if (manifestHash !== this.lastManifestHash) { + this.writeAnimusFile('manifest.json', result.manifestJson); + this.lastManifestHash = manifestHash; + } + + // Hydration artifact for isolated Turbopack loader workers — the exact + // analyze-time input set, replayable via buildAnalyzeProjectArgs. Reuses + // the inputs runProjectAnalysis already built (the filesJson inside them + // carries the whole source corpus — never serialize it twice). + if (this.persistAnalysisInputs) { + const inputsJson = JSON.stringify(result.inputs); + const inputsHash = contentHash(inputsJson); + if (inputsHash !== this.lastAnalysisInputsHash) { + this.writeAnimusFile('analysis-inputs.json', inputsJson); + this.lastAnalysisInputsHash = inputsHash; + } + } + + bt.total = this.elapsed(pipelineStart); + logBuildTimings(bt, manifest?.timing, (msg) => this.log(msg), this.verbose); + } + + /** Ensure `.animus/` exists and write one generated artifact into it. + * Write-then-rename so cross-process readers (Turbopack loader workers) + * can never observe a torn half-written file. */ + private writeAnimusFile(name: string, content: string): void { + const dir = join(this.rootDir!, '.animus'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const tmpPath = join(dir, `.${name}.tmp`); + writeFileSync(tmpPath, content); + renameSync(tmpPath, join(dir, name)); + } +} diff --git a/packages/next-plugin/src/loader-core.ts b/packages/next-plugin/src/loader-core.ts new file mode 100644 index 00000000..b0a099f4 --- /dev/null +++ b/packages/next-plugin/src/loader-core.ts @@ -0,0 +1,117 @@ +/** + * Bundler-neutral loader policy shared by the webpack loader (manifest from + * the process singleton) and the Turbopack loader (manifest hydrated from + * disk artifacts). Owns the transform invocation, strict-mode error + * handling, and the single-stylesheet-import policy. + */ + +export interface LoaderPolicyOptions { + strict?: boolean; + cssImportTarget?: string; +} + +/** + * Regex matching CSS import lines injected by the Rust emitter. + * Catches both the alias form (`import '.animus/styles.css'`), + * relative forms (`import '../.animus/styles.css'`), and the Vite + * virtual module form (`import 'virtual:animus/styles.css'`) which + * appears in pre-built external packages compiled with the Vite plugin. + */ +const CSS_IMPORT_RE = + /import\s+['"](?:[^'"]*\.animus\/styles\.css|virtual:animus\/styles\.css)['"];\n?/g; + +/** + * Default root entry file patterns. CSS is imported ONLY in the root entry + * to prevent per-chunk duplication in Next.js builds. + * + * - App Router: `app/layout.tsx` (root layout wraps all routes) + * - Pages Router: `pages/_app.tsx` (custom App wraps all pages) + * + * The `(src\/)?` prefix handles projects that use a `src/` directory. + * Projects whose root entry doesn't match (nested layouts, monorepo roots) + * set the `cssImportTarget` plugin option, which replaces this detection. + */ +const ROOT_ENTRY_RE = /^(src\/)?(?:app\/layout|pages\/_app)\.[tj]sx?$/; + +/** Normalize separators and strip a leading './' for path comparison. */ +function normalizePath(p: string): string { + return p.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +/** + * Decide whether `filename` (project-root-relative) is the file that + * receives the single CSS import. An explicit `cssImportTarget` replaces + * the default filename-convention detection. Separators are normalized + * first — Windows `relative()` yields backslashes the patterns would miss. + */ +function isCssImportTarget( + filename: string, + cssImportTarget: string | undefined +): boolean { + const normalized = normalizePath(filename); + if (cssImportTarget) { + return normalized === normalizePath(cssImportTarget); + } + return ROOT_ENTRY_RE.test(normalized); +} + +/** + * Transform one source file against a manifest: replace builder chains via + * the engine, strip emitter-injected stylesheet imports everywhere, and + * re-inject a single import at the root entry (after any leading + * `'use client'` directive). On failure: throw in strict mode, otherwise + * warn and return the source unchanged. + */ +export function transformWithManifest(args: { + source: string; + /** Project-root-relative path of the file being transformed. */ + filename: string; + manifestJson: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engineApi: () => any; + opts: LoaderPolicyOptions; +}): string { + const { source, filename, manifestJson, engineApi, opts } = args; + const isRootEntry = isCssImportTarget(filename, opts.cssImportTarget); + + try { + const { transformFile } = engineApi(); + + const result = transformFile(source, filename, manifestJson); + + let code = result.hasComponents ? result.code : source; + + // Strip CSS imports from all files — both Rust-injected and hand-written + code = code.replace(CSS_IMPORT_RE, ''); + + // Re-inject the CSS import in root entry files only + if (isRootEntry && !code.includes('.animus/styles.css')) { + if (code.startsWith("'use client'") || code.startsWith('"use client"')) { + // Keep the directive first; a directive-only file without a trailing + // newline appends after it instead of demoting it. + const nl = code.indexOf('\n'); + if (nl === -1) { + code = `${code}\nimport '.animus/styles.css';\n`; + } else { + code = `${code.slice(0, nl + 1)}import '.animus/styles.css';\n${code.slice(nl + 1)}`; + } + } else { + code = `import '.animus/styles.css';\n${code}`; + } + } + + return code; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + + if (opts.strict) { + throw new Error( + `[animus-extract] Transform failed for ${filename}: ${msg}`, + { cause: e } + ); + } + + console.warn(`[animus-extract] Transform failed for ${filename}:`, msg); + return source; + } +} diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 0d7806db..3fb241d2 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -1,65 +1,22 @@ import { relative } from 'path'; -import { getManifestJson, engineApi } from './singleton'; +import { transformWithManifest } from './loader-core'; +import { engineApi, getManifestJson } from './singleton'; + +import type { LoaderPolicyOptions } from './loader-core'; type LoaderContext = { resourcePath: string; rootContext: string; - getOptions: () => { strict?: boolean; cssImportTarget?: string }; + getOptions: () => LoaderPolicyOptions; }; -/** - * Regex matching CSS import lines injected by the Rust emitter. - * Catches both the alias form (`import '.animus/styles.css'`), - * relative forms (`import '../.animus/styles.css'`), and the Vite - * virtual module form (`import 'virtual:animus/styles.css'`) which - * appears in pre-built external packages compiled with the Vite plugin. - */ -const CSS_IMPORT_RE = - /import\s+['"](?:[^'"]*\.animus\/styles\.css|virtual:animus\/styles\.css)['"];\n?/g; - -/** - * Default root entry file patterns. CSS is imported ONLY in the root entry - * to prevent per-chunk duplication in Next.js builds. - * - * - App Router: `app/layout.tsx` (root layout wraps all routes) - * - Pages Router: `pages/_app.tsx` (custom App wraps all pages) - * - * The `(src\/)?` prefix handles projects that use a `src/` directory. - * Projects whose root entry doesn't match (nested layouts, monorepo roots) - * set the `cssImportTarget` plugin option, which replaces this detection. - */ -const ROOT_ENTRY_RE = /^(src\/)?(?:app\/layout|pages\/_app)\.[tj]sx?$/; - -/** Normalize separators and strip a leading './' for path comparison. */ -function normalizePath(p: string): string { - return p.replace(/\\/g, '/').replace(/^\.\//, ''); -} - -/** - * Decide whether `filename` (project-root-relative) is the file that - * receives the single CSS import. An explicit `cssImportTarget` replaces - * the default filename-convention detection. - */ -function isCssImportTarget( - filename: string, - cssImportTarget: string | undefined -): boolean { - if (cssImportTarget) { - return normalizePath(filename) === normalizePath(cssImportTarget); - } - return ROOT_ENTRY_RE.test(filename); -} - /** * Webpack loader for Animus source transformation. * Runs with enforce: 'pre' to see original source before Babel/SWC. - * Replaces builder chains with createComponent() calls using the pre-built manifest. - * - * CSS handling: the Rust emitter injects `import '.animus/styles.css'` into every - * file with extracted components. Without intervention, webpack duplicates the full - * stylesheet into every route chunk. This loader strips those imports and re-injects - * a single CSS import in the root layout/app file, ensuring one copy for the whole app. + * The manifest arrives via the process singleton (the webpack pipeline and + * this loader share one process); the transform + CSS-import policy lives + * in the shared loader-core. */ export default function animusLoader( this: LoaderContext, @@ -69,41 +26,11 @@ export default function animusLoader( if (!manifestJson) return source; const filename = relative(this.rootContext, this.resourcePath); - const opts = this.getOptions?.() ?? {}; - const isRootEntry = isCssImportTarget(filename, opts.cssImportTarget); - - try { - const { transformFile } = engineApi(); - - const result = transformFile(source, filename, manifestJson); - - let code = result.hasComponents ? result.code : source; - - // Strip CSS imports from all files — both Rust-injected and hand-written - code = code.replace(CSS_IMPORT_RE, ''); - - // Re-inject the CSS import in root entry files only - if (isRootEntry && !code.includes('.animus/styles.css')) { - if (code.startsWith("'use client'") || code.startsWith('"use client"')) { - const nl = code.indexOf('\n'); - code = `${code.slice(0, nl + 1)}import '.animus/styles.css';\n${code.slice(nl + 1)}`; - } else { - code = `import '.animus/styles.css';\n${code}`; - } - } - - return code; - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - - if (opts.strict) { - throw new Error( - `[animus-extract] Transform failed for ${filename}: ${msg}`, - { cause: e } - ); - } - - console.warn(`[animus-extract] Transform failed for ${filename}:`, msg); - return source; - } + return transformWithManifest({ + source, + filename, + manifestJson, + engineApi, + opts: this.getOptions?.() ?? {}, + }); } diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 74ce9582..8f38f044 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -1,41 +1,22 @@ import { - applyPrefix, - applyUnitFallback, - assembleStylesheet, assertNoRetiredEngineSelection, - buildAnalyzeProjectArgs, - buildDynamicPropConfig, buildPathAliasesJson, - collectExternalPackageSources, - contentHash, - DEFAULT_EXTENSIONS, - discoverFiles, - extractSystemFilePackages, - formatRustTimingWaterfall, - preprocessMdx, - surfaceManifestDiagnostics, } from '@animus-ui/extract/pipeline'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { extname, join, relative, resolve } from 'path'; +import { join } from 'path'; +import { ANIMUS_CSS_MODULE_ID, ExtractionSession } from './extraction-session'; import { getAnalysisPromise, getSharedCss, getSharedExternalDirs, getSharedExternalEntries, - resetAnalysisPromise, setAnalysisPromise, - setManifestJson, - setSharedCss, - setSharedExternalDirs, - setSharedExternalEntries, - setSharedSystemProps, - engineApi, setSharedEngine, } from './singleton'; import type { AnimusNextOptions } from './types'; -import type { DynamicPropMeta } from '@animus-ui/extract/pipeline'; + +export { ANIMUS_CSS_MODULE_ID } from './extraction-session'; type WebpackSource = { source(): string | Buffer; @@ -90,51 +71,17 @@ type Compiler = { const PLUGIN_NAME = 'AnimusWebpackPlugin'; /** - * Module id the Rust emitter injects for the extracted stylesheet — also the - * exact resolve.alias key with-animus registers for it, which extractAliases - * must skip when harvesting consumer aliases. + * Webpack adapter for the extraction pipeline. All pipeline logic lives in + * the bundler-agnostic ExtractionSession — this class owns only the webpack + * wiring: hook registration, cross-compiler analysis dedup, alias + * harvesting from the compiler config, watch-event translation, and + * in-memory CSS asset replacement. */ -export const ANIMUS_CSS_MODULE_ID = '.animus/styles.css'; - -/** Default path fragments excluded from source discovery (full + watch). */ -const DEFAULT_EXCLUDE = [ - 'node_modules', - 'dist', - '.test.', - '.spec.', - '.next', - '.animus', -]; - -type FileEntry = { path: string; source: string; hash: string }; - export class AnimusWebpackPlugin { private options: AnimusNextOptions; - private rootDir: string | null = null; - - // System config (loaded once via NAPI) - private configJson = ''; - private groupRegistryJson = ''; - private themeJson = ''; - private variableMapJson = ''; - private variableCss = ''; - private contextualVarsJson: string | null = null; - private selectorAliasesJson: string | null = null; - private globalCss = ''; - private globalStyleBlocksJson: string | null = null; - private keyframesBlocksJson: string | null = null; - private pathAliasesJson: string | null = null; - - // File tracking for HMR - private fileCache = new Map(); - private lastCssHash: string | null = null; - private lastSystemPropsHash: string | null = null; - - // Absolute directory prefixes for external DS packages (for loader allowlisting) - private externalPackageDirs: string[] = []; - - // Map of external package specifier → absolute source entry path (for webpack resolve alias) - private externalSourceEntries = new Map(); + private session: ExtractionSession; + private initialized = false; + private aliasesExtracted = false; constructor(options: AnimusNextOptions) { this.options = options; @@ -144,107 +91,7 @@ export class AnimusWebpackPlugin { // ANIMUS_ENGINE=v1 override at runtime. assertNoRetiredEngineSelection(options.engine as string | undefined); setSharedEngine(options.engine ?? 'v2'); - } - - private get verbose(): boolean { - return ( - this.options.verbose === true || - process.env.ANIMUS_DEBUG === '1' || - process.env.ANIMUS_DEBUG === 'true' - ); - } - - private log(msg: string): void { - if (this.verbose) { - console.info(`[animus] ${msg}`); - } - } - - private warn(msg: string): void { - console.warn(`[animus] ${msg}`); - } - - // Zero-cost timer gate - private now(): number { - return this.verbose ? performance.now() : 0; - } - private elapsed(t: number): number { - return this.verbose ? Math.round(performance.now() - t) : 0; - } - - private logBuildTimings( - bt: Record, - rustTiming: Record | undefined - ): void { - if (!this.verbose) return; - const pad = (label: string, width: number): string => - label + ' '.repeat(Math.max(0, width - label.length)); - const jsPhases: [string, string][] = [ - ['systemLoad', 'system-load'], - ['fileDiscovery', 'file-discovery'], - ['fileRead', 'file-read+hash'], - ['packageResolve', 'pkg-resolve'], - ['analysis', 'analysis'], - ]; - for (const [key, label] of jsPhases) { - const ms = bt[key] ?? 0; - if (ms === 0 && !Object.hasOwn(bt, key)) continue; - this.log(` ${pad(label, 17)}${String(ms).padStart(5)}ms`); - - if (key === 'analysis') { - for (const [sk, sl] of [ - ['jsonSerialize', 'json-serialize'], - ['rustExtract', 'rust-extract'], - ['jsonParse', 'json-parse'], - ] as const) { - const sms = bt[sk] ?? 0; - this.log(` ${pad(sl, 15)}${String(sms).padStart(5)}ms`); - - if (sk === 'rustExtract' && rustTiming) { - for (const line of formatRustTimingWaterfall(rustTiming, { - indent: ' ', - labelWidth: 13, - })) { - this.log(line); - } - } - } - } - } - this.log(` total ${String(bt.total).padStart(5)}ms`); - - if (process.env.ANIMUS_TIMING_JSON === '1') { - const merged: Record = {}; - for (const [k, v] of Object.entries(bt)) { - merged[`buildStart.${k}`] = v; - } - if (rustTiming) { - for (const [k, v] of Object.entries(rustTiming)) { - if (typeof v === 'number') merged[`rust.${k}`] = v; - } - } - console.info(`[animus:timing] ${JSON.stringify(merged)}`); - } - } - - private initialized = false; - private aliasesExtracted = false; - - /** Resolve the scan configuration from options — the single source of the - * exclude/extension policy shared by the full and incremental pipelines. */ - private resolveScanConfig(): { - excludePatterns: string[]; - extensionsSet: ReadonlySet; - shouldHandleMdx: boolean; - } { - const extensionsSet: ReadonlySet = new Set( - this.options.extensions ?? DEFAULT_EXTENSIONS - ); - return { - excludePatterns: this.options.exclude ?? DEFAULT_EXCLUDE, - extensionsSet, - shouldHandleMdx: extensionsSet.has('.mdx'), - }; + this.session = new ExtractionSession(options); } /** Extract path aliases from webpack's resolve.alias config. Runs once per @@ -269,7 +116,7 @@ export class AnimusWebpackPlugin { const built = buildPathAliasesJson(pairs, rootDir); if (built) { - this.pathAliasesJson = built.json; + this.session.pathAliasesJson = built.json; } } @@ -290,7 +137,7 @@ export class AnimusWebpackPlugin { // Try absolute path first, then relative — asset name depends on // how webpack resolved the .animus/styles.css import - const rootDir = this.rootDir || compiler.context; + const rootDir = this.session.rootDir || compiler.context; const cssPath = join(rootDir, '.animus', 'styles.css'); if (compilation.getAsset(cssPath)) { compilation.updateAsset(cssPath, new RawSource(css)); @@ -304,7 +151,7 @@ export class AnimusWebpackPlugin { // Production build: run once compiler.hooks.run.tapPromise(PLUGIN_NAME, async (_compiler: Compiler) => { - this.rootDir = _compiler.context; + this.session.rootDir = _compiler.context; this.extractAliases(_compiler); const existing = getAnalysisPromise(); @@ -313,7 +160,7 @@ export class AnimusWebpackPlugin { return; } - const promise = this.runFullPipeline(); + const promise = this.session.runFullPipeline(); setAnalysisPromise(promise); await promise; }); @@ -322,7 +169,7 @@ export class AnimusWebpackPlugin { compiler.hooks.watchRun.tapPromise( PLUGIN_NAME, async (_compiler: Compiler) => { - this.rootDir = _compiler.context; + this.session.rootDir = _compiler.context; this.extractAliases(_compiler); if (!this.initialized) { @@ -333,7 +180,7 @@ export class AnimusWebpackPlugin { return; } - const promise = this.runFullPipeline(); + const promise = this.session.runFullPipeline(); setAnalysisPromise(promise); await promise; this.initialized = true; @@ -341,576 +188,19 @@ export class AnimusWebpackPlugin { } // Incremental: detect changes and re-analyze if needed - await this.handleWatchUpdate(_compiler); - } - ); - } - - private async handleWatchUpdate(compiler: Compiler): Promise { - // Guard: if system state was never loaded (non-owning instance that - // skipped runFullPipeline), skip — processAssets reads from shared variable - if (this.configJson === '') return; - - const rootDir = this.rootDir!; - const resolvedSystemPath = resolve(rootDir, this.options.system); - - // Check for geological reset: system file changed - try { - const systemSource = readFileSync(resolvedSystemPath, 'utf-8'); - const systemRelPath = relative(rootDir, resolvedSystemPath); - const cached = this.fileCache.get(systemRelPath); - const currentHash = contentHash(systemSource); - - if (cached && cached.hash !== currentHash) { - // Geological reset: system file changed - this.resetForHmr(); - this.initialized = false; - const promise = this.runFullPipeline(); - setAnalysisPromise(promise); - await promise; - this.initialized = true; - return; - } - } catch (err) { - // Not a benign probe: this wraps the system-file read and the - // geological-reset re-run. Swallowing keeps a transient read failure - // from crashing the watch loop, but a real fault must stay diagnosable. - this.warn(`HMR geological-reset check failed: ${String(err)}`); - } - - // Check for component file changes using content-hash diffing - const { excludePatterns, extensionsSet, shouldHandleMdx } = - this.resolveScanConfig(); - - // Prune deleted/renamed files so their last-known source stops riding - // along as a ghost entry on every subsequent incremental analysis. - let removedAny = false; - if (compiler.removedFiles) { - for (const removedPath of compiler.removedFiles) { - const rel = relative(rootDir, removedPath); - // MDX cache keys carry the preprocessed `.tsx` suffix. - if (this.fileCache.delete(rel) || this.fileCache.delete(rel + '.tsx')) { - removedAny = true; - } - } - } - - // Restrict the read+hash pass to webpack's modified set when available - // (watchRun provides it after the first compilation); fall back to a full - // discovery walk otherwise. Filters mirror discoverFiles: extension - // allowlist plus substring exclude patterns on both path forms. - let files: string[]; - if (compiler.modifiedFiles) { - files = []; - for (const modifiedPath of compiler.modifiedFiles) { - if (!extensionsSet.has(extname(modifiedPath))) continue; - const rel = relative(rootDir, modifiedPath); - if (rel.startsWith('..')) continue; - if ( - excludePatterns.some( - (pattern) => - modifiedPath.includes(pattern) || rel.includes(pattern) - ) - ) { - continue; - } - files.push(modifiedPath); - } - } else { - files = discoverFiles(rootDir, rootDir, excludePatterns, extensionsSet); - } - - const changedPaths: string[] = []; - - for (const filePath of files) { - let relPath = relative(rootDir, filePath); - let source: string; - try { - source = readFileSync(filePath, 'utf-8'); - } catch { - // Benign race: the file vanished between the watch event and this - // read — it will surface in removedFiles on the next watchRun. - continue; - } - - if (shouldHandleMdx && extname(filePath) === '.mdx') { - // Watch pass stays silent on failure — the full pipeline already - // surfaced any missing-dep / preprocessing warning. - const processed = await this.preprocessMdxEntry(source, relPath, { - warn: false, + await this.session.handleWatchUpdate({ + modifiedFiles: _compiler.modifiedFiles, + removedFiles: _compiler.removedFiles, }); - if (!processed) continue; - source = processed.source; - relPath = processed.relPath; } - - const cached = this.fileCache.get(relPath); - const hash = contentHash(source); - - if (!cached || cached.hash !== hash) { - changedPaths.push(relPath); - this.fileCache.set(relPath, { hash, source }); - } - } - - if (changedPaths.length > 0 || removedAny) { - // Every cached file rides with full source (v2 has no Rust-side cache). - const fileEntries = this.buildFileEntriesFromCache(); - - resetAnalysisPromise(); - const promise = this.runIncrementalPipeline(fileEntries); - setAnalysisPromise(promise); - await promise; - } - } - - /** - * Preprocess one `.mdx` entry into scanner-consumable tsx. Returns the - * rewritten source plus the `relPath + '.tsx'` path on success, or null - * when the file must be skipped. - * - * `warn: false` (incremental watch pass) skips silently — the full - * pipeline already surfaced the warning. `warn: true` (full pipeline) - * warns ONCE for a missing @mdx-js/mdx dependency via the shared - * `missingDepFlag` holder, and every time for a preprocessing error. - */ - private async preprocessMdxEntry( - source: string, - relPath: string, - opts: { warn: boolean; missingDepFlag?: { warned: boolean } } - ): Promise<{ source: string; relPath: string } | null> { - const result = await preprocessMdx(source, relPath); - if (result.kind === 'missing-dep') { - if (opts.warn && opts.missingDepFlag && !opts.missingDepFlag.warned) { - console.warn( - '[animus] ⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped' - ); - opts.missingDepFlag.warned = true; - } - return null; - } - if (result.kind === 'error') { - if (opts.warn) { - console.warn( - `[animus] ⚠ MDX preprocessing failed for ${relPath}: ${result.error}` - ); - } - return null; - } - // Path rewrite so the Rust source-type helper parses as tsx. - return { source: result.source!, relPath: relPath + '.tsx' }; - } - - private async runFullPipeline(): Promise { - const pipelineStart = this.now(); - const bt: Record = {}; - - // Clear Rust-side per-file cache so stale results from a prior - // build never bleed into a fresh pipeline run. - try { - const { clearAnalysisCache } = engineApi(); - clearAnalysisCache(); - } catch { - // Benign optional-capability probe: clearAnalysisCache is absent on - // older engine builds — nothing to clear, so ignore. - } - - const rootDir = this.rootDir!; - const resolvedSystemPath = resolve(rootDir, this.options.system); - - // Step 1: Load system via NAPI - let t = this.now(); - this.loadSystem(rootDir, resolvedSystemPath); - bt.systemLoad = this.elapsed(t); - - // Step 2: Discover source files - t = this.now(); - const { excludePatterns, extensionsSet, shouldHandleMdx } = - this.resolveScanConfig(); - const missingDepFlag = { warned: false }; - const files = discoverFiles( - rootDir, - rootDir, - excludePatterns, - extensionsSet - ); - - bt.fileDiscovery = this.elapsed(t); - - // Step 3: Read file sources and build entries (preprocessing MDX as we go) - t = this.now(); - const fileEntries: FileEntry[] = []; - for (const filePath of files) { - let source = readFileSync(filePath, 'utf-8'); - let relPath = relative(rootDir, filePath); - - if (shouldHandleMdx && extname(filePath) === '.mdx') { - const processed = await this.preprocessMdxEntry(source, relPath, { - warn: true, - missingDepFlag, - }); - if (!processed) continue; - source = processed.source; - relPath = processed.relPath; - } - - const hash = contentHash(source); - this.fileCache.set(relPath, { hash, source }); - fileEntries.push({ path: relPath, source, hash }); - } - - bt.fileRead = this.elapsed(t); - bt.fileCount = fileEntries.length; - - // Step 4: Resolve external packages from system file imports. Workspace - // walk + require.resolve stays here (the bundler-specific seam); the - // traversal/ingest below is the shared collector - // (spec: external-package-file-discovery). - t = this.now(); - const packageNames = extractSystemFilePackages(resolvedSystemPath); - const preResolved = this.resolvePackagesByName(rootDir, packageNames); - - const collected = await collectExternalPackageSources({ - specifiers: packageNames, - resolveSpecifier: (name) => - preResolved[name] ? resolve(rootDir, preResolved[name]) : null, - rootDir, - extensionsSet, - hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath), - preprocessFile: async (source, relPath, absPath) => { - if (shouldHandleMdx && extname(absPath) === '.mdx') { - return this.preprocessMdxEntry(source, relPath, { - warn: true, - missingDepFlag, - }); - } - return { source, relPath }; - }, - onUnreadable: (relPath, err) => - this.warn( - `skipped unreadable package file ${relPath}: ${String(err)}` - ), - }); - - const packageMap = collected.packageMap; - this.externalSourceEntries = collected.sourceEntries; - for (const entry of collected.entries) { - const hash = contentHash(entry.source); - this.fileCache.set(entry.path, { hash, source: entry.source }); - fileEntries.push({ path: entry.path, source: entry.source, hash }); - } - - this.externalPackageDirs = collected.packageDirs; - - // Publish external package state for non-owning compiler instances - setSharedExternalDirs(collected.packageDirs); - setSharedExternalEntries(collected.sourceEntries); - - bt.packageResolve = this.elapsed(t); - - // Step 5+: hand off to the shared analysis + emit core. Production pass - // (devMode=false) writes system-props.js unconditionally and logs the - // extraction report. - await this.analyzeAndEmit( - fileEntries, - packageMap, - false, - bt, - pipelineStart ); } - private loadSystem(rootDir: string, systemPath: string): void { - const { loadSystemModule } = engineApi(); - const config = loadSystemModule(systemPath, rootDir); - - this.configJson = config.propConfig; - this.groupRegistryJson = config.groupRegistry; - this.themeJson = config.scalesJson; - this.variableMapJson = config.variableMapJson; - this.variableCss = config.variableCss; - this.contextualVarsJson = config.contextualVarsJson; - this.selectorAliasesJson = config.selectorAliases || null; - - if (this.options.prefix) { - const prefixed = applyPrefix( - this.options.prefix, - this.variableMapJson, - this.variableCss, - this.themeJson, - this.contextualVarsJson || undefined - ); - this.variableMapJson = prefixed.variableMapJson; - this.variableCss = prefixed.variableCss; - if (prefixed.themeJson) this.themeJson = prefixed.themeJson; - if (prefixed.contextualVarsJson) - this.contextualVarsJson = prefixed.contextualVarsJson; - } - - // Store raw global style blocks for Rust-side resolution in analyzeProject. - this.globalStyleBlocksJson = config.globalStyleBlocks || null; - this.keyframesBlocksJson = config.keyframesBlocks || null; - } - - private resolvePackagesByName( - rootDir: string, - names: string[] - ): Record { - if (names.length === 0) return {}; - - const nameSet = new Set(names); - const resolved = new Set(); - const packageMap: Record = {}; - - // Pass 1: workspace resolution - try { - const rootPkg = JSON.parse( - readFileSync(join(rootDir, 'package.json'), 'utf-8') - ); - const workspaces: string[] = rootPkg.workspaces || []; - - for (const ws of workspaces) { - const wsDir = resolve(rootDir, ws); - if (!existsSync(wsDir)) continue; - - try { - const pkg = JSON.parse( - readFileSync(join(wsDir, 'package.json'), 'utf-8') - ); - const name: string = pkg.name || ''; - - if (nameSet.has(name)) { - const main = pkg.main || pkg.module || 'index.ts'; - const entryPath = resolve(wsDir, main); - if (existsSync(entryPath)) { - packageMap[name] = relative(rootDir, entryPath); - resolved.add(name); - } - } - } catch { - // Benign existence probe: a workspace entry without a readable - // package.json simply contributes no package — skip it. - } - } - } catch { - // Benign existence probe: no readable root package.json means no - // workspace resolution; Pass 2 (require.resolve) still runs. - } - - // Pass 2: require.resolve fallback for non-workspace packages - for (const name of nameSet) { - if (resolved.has(name)) continue; - try { - const entryPath = require.resolve(name, { paths: [rootDir] }); - packageMap[name] = relative(rootDir, entryPath); - } catch { - // Benign resolution probe: require.resolve throws for an - // unresolvable specifier — that package just goes unmapped. - } - } - - return packageMap; - } - /** * Reset analysis state for HMR geological reset. */ resetForHmr(): void { - resetAnalysisPromise(); - this.lastCssHash = null; - this.lastSystemPropsHash = null; - try { - const { clearAnalysisCache } = engineApi(); - clearAnalysisCache(); - } catch { - // Benign optional-capability probe: clearAnalysisCache is absent on - // older engine builds — nothing to clear, so ignore. - } - } - - /** - * Build file entries from cache: every cached file rides with full source. - * The v2 engine has NO Rust-side cache (extract-v2-spine DEF-7: uncached - * re-analysis beats a cache-hit path), so it must always receive full sources - * (openspec: retire-extract-v1 removed the v1 empty-source cache contract). - */ - private buildFileEntriesFromCache(): FileEntry[] { - const entries: FileEntry[] = []; - for (const [path, { hash, source }] of this.fileCache) { - entries.push({ path, source, hash }); - } - return entries; - } - - /** - * Run incremental pipeline with cache-aware file entries. - * Reuses system config from the last full pipeline run. - */ - private async runIncrementalPipeline(fileEntries: FileEntry[]): Promise { - const rootDir = this.rootDir!; - const bt: Record = {}; - const pipelineStart = this.now(); - - // Replay the package map resolved during the last full pipeline (from - // cache); the incremental pass never re-discovers external packages. - const packageMap: Record = Object.fromEntries( - Array.from(this.externalSourceEntries).map(([spec, entry]) => [ - spec, - relative(rootDir, entry), - ]) - ); - - // Hand off to the shared analysis + emit core. HMR pass (devMode=true) - // guards the system-props.js write by lastSystemPropsHash. - await this.analyzeAndEmit(fileEntries, packageMap, true, bt, pipelineStart); - } - - /** - * Shared analysis + emit core for both pipelines — the single call site - * that routes every manifest through surfaceManifestDiagnostics, reachable - * from both runFullPipeline (production) and runIncrementalPipeline (HMR). - * - * Owns the NAPI serialize/call/parse, diagnostic surfacing, CSS assembly + - * styles.css write guard, dynamic-prop config, system-props module emit, - * and timing log. The `devMode` flag is the ONLY behavioral fork: - * - * - `false` (production): computes bt.analysis + logs the extraction - * report, and writes system-props.js UNCONDITIONALLY (no - * lastSystemPropsHash guard). - * - `true` (HMR): skips the report log, and guards the system-props.js - * write by lastSystemPropsHash. - * - * The styles.css write guard (lastCssHash) is identical on both paths. - */ - private async analyzeAndEmit( - fileEntries: FileEntry[], - packageMap: Record, - devMode: boolean, - bt: Record, - pipelineStart: number - ): Promise { - const { analyzeProject } = engineApi(); - const emitterConfig = JSON.stringify({ - runtime_import: '@animus-ui/system/runtime', - css_module_id: ANIMUS_CSS_MODULE_ID, - system_props_module_id: join(this.rootDir!, '.animus', 'system-props.js'), - }); - - // Sub-phase: JSON serialize - let t = this.now(); - const fileEntriesJson = JSON.stringify(fileEntries); - const packageMapJson = JSON.stringify(packageMap); - bt.jsonSerialize = this.elapsed(t); - - // Sub-phase: NAPI call. Production vs HMR differ only in the devMode - // flag baked into the positional arg tuple. - t = this.now(); - const manifestJson: string = analyzeProject( - ...buildAnalyzeProjectArgs({ - devMode, - filesJson: fileEntriesJson, - scalesJson: this.themeJson, - variableMapJson: this.variableMapJson, - contextualVarsJson: this.contextualVarsJson || null, - propConfigJson: this.configJson, - groupRegistryJson: this.groupRegistryJson, - packageResolutionJson: packageMapJson, - emitterConfigJson: emitterConfig, - selectorAliasesJson: this.selectorAliasesJson, - globalStyleBlocksJson: this.globalStyleBlocksJson, - pathAliasesJson: this.pathAliasesJson, - keyframesJson: this.keyframesBlocksJson, - }) - ); - bt.rustExtract = this.elapsed(t); - - // Sub-phase: JSON parse - t = this.now(); - const manifest = JSON.parse(manifestJson); - surfaceManifestDiagnostics(manifest, (message) => this.warn(message)); - bt.jsonParse = this.elapsed(t); - - if (!devMode) { - bt.analysis = - (bt.jsonSerialize ?? 0) + (bt.rustExtract ?? 0) + (bt.jsonParse ?? 0); - if (manifest?.report) { - this.log( - `Extracted ${manifest.report.components_extracted ?? '?'}/${manifest.report.components_total ?? '?'} components (${bt.analysis}ms)` - ); - } - } - - // Populate globalCss from Rust-resolved sheets - this.globalCss = manifest?.sheets?.global || ''; - - // CSS from Rust is fully resolved — transforms evaluated in-process via boa_engine. - let componentCss: string = manifest?.css || ''; - componentCss = applyUnitFallback(componentCss); - - // Assemble full stylesheet (canonical order via shared function) - const { declaration, variables, body } = assembleStylesheet({ - layers: this.options.layers, - variableCss: this.variableCss, - globalCss: this.globalCss, - componentCss, - split: true, - }); - const fullCss = [declaration, variables, body].filter(Boolean).join('\n'); - - // Store CSS in shared variable (authoritative source for processAssets) - setSharedCss(fullCss); - - // Disk write serves as HMR trigger only — processAssets replaces content in-memory - const cssHash = contentHash(fullCss); - if (cssHash !== this.lastCssHash) { - this.writeAnimusFile('styles.css', fullCss); - this.lastCssHash = cssHash; - } - - // Build system-props module for runtime resolution. - // Transforms are resolved at extraction time via boa_engine in Rust; - // runtime transform functions are not yet supported for dynamic props. - const systemPropMap = JSON.stringify(manifest?.system_prop_map ?? {}); - const dynamicProps = (manifest?.dynamic_props ?? {}) as Record< - string, - DynamicPropMeta - >; - const dynamicPropConfig = buildDynamicPropConfig(dynamicProps); - - const systemPropsContent = - `export const systemPropMap = ${systemPropMap};\n` + - `export const systemPropGroups = ${this.groupRegistryJson};\n` + - `export const dynamicPropConfig = ${JSON.stringify(dynamicPropConfig)};\n` + - `export const transforms = {};\n`; - - setSharedSystemProps(systemPropsContent); - - if (devMode) { - // HMR: skip the disk write when byte-identical to the last one written. - const systemPropsHash = contentHash(systemPropsContent); - if (systemPropsHash !== this.lastSystemPropsHash) { - this.writeAnimusFile('system-props.js', systemPropsContent); - this.lastSystemPropsHash = systemPropsHash; - } - } else { - // Production: write unconditionally (no lastSystemPropsHash guard). - this.writeAnimusFile('system-props.js', systemPropsContent); - } - - // Store manifest for loader - setManifestJson(manifestJson); - - bt.total = this.elapsed(pipelineStart); - this.logBuildTimings(bt, manifest?.timing); - } - - /** Ensure `.animus/` exists and write one generated artifact into it. */ - private writeAnimusFile(name: string, content: string): void { - const dir = join(this.rootDir!, '.animus'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(join(dir, name), content); + this.session.resetForHmr(); } /** Expose options for the loader */ @@ -921,16 +211,16 @@ export class AnimusWebpackPlugin { /** Expose external package directories for webpack loader allowlisting. * Falls back to shared globalThis state for non-owning compiler instances. */ getExternalPackageDirs(): string[] { - return this.externalPackageDirs.length > 0 - ? this.externalPackageDirs + return this.session.externalPackageDirs.length > 0 + ? this.session.externalPackageDirs : getSharedExternalDirs(); } /** Expose external package source entries for webpack resolve alias. * Falls back to shared globalThis state for non-owning compiler instances. */ getExternalSourceEntries(): Map { - return this.externalSourceEntries.size > 0 - ? this.externalSourceEntries + return this.session.externalSourceEntries.size > 0 + ? this.session.externalSourceEntries : getSharedExternalEntries(); } } diff --git a/packages/next-plugin/src/resolve-packages.ts b/packages/next-plugin/src/resolve-packages.ts new file mode 100644 index 00000000..1e60006b --- /dev/null +++ b/packages/next-plugin/src/resolve-packages.ts @@ -0,0 +1,71 @@ +import { existsSync, readFileSync } from 'fs'; +import { join, relative, resolve } from 'path'; + +/** + * Resolve external DS package specifiers to rootDir-relative entry paths. + * Node-generic (no webpack APIs): Pass 1 walks the consumer's workspaces for + * matching package names; Pass 2 falls back to `require.resolve`. Specifiers + * that resolve nowhere are simply omitted (spec: silent skip). + */ +export function resolvePackagesByName( + rootDir: string, + names: string[] +): Record { + if (names.length === 0) return {}; + + const nameSet = new Set(names); + const resolved = new Set(); + const packageMap: Record = {}; + + // Pass 1: workspace resolution + try { + const rootPkg = JSON.parse( + readFileSync(join(rootDir, 'package.json'), 'utf-8') + ); + // Both workspace forms: ["packages/*"] and { packages: ["packages/*"] }. + const workspaces: string[] = Array.isArray(rootPkg.workspaces) + ? rootPkg.workspaces + : (rootPkg.workspaces?.packages ?? []); + + for (const ws of workspaces) { + const wsDir = resolve(rootDir, ws); + if (!existsSync(wsDir)) continue; + + try { + const pkg = JSON.parse( + readFileSync(join(wsDir, 'package.json'), 'utf-8') + ); + const name: string = pkg.name || ''; + + if (nameSet.has(name)) { + const main = pkg.main || pkg.module || 'index.ts'; + const entryPath = resolve(wsDir, main); + if (existsSync(entryPath)) { + packageMap[name] = relative(rootDir, entryPath); + resolved.add(name); + } + } + } catch { + // Benign existence probe: a workspace entry without a readable + // package.json simply contributes no package — skip it. + } + } + } catch { + // Benign existence probe: no readable root package.json means no + // workspace resolution; Pass 2 (require.resolve) still runs. + } + + // Pass 2: require.resolve fallback for non-workspace packages + for (const name of nameSet) { + if (resolved.has(name)) continue; + try { + const entryPath = require.resolve(name, { paths: [rootDir] }); + packageMap[name] = relative(rootDir, entryPath); + } catch { + // Benign resolution probe: require.resolve throws for an + // unresolvable specifier — that package just goes unmapped. + } + } + + return packageMap; +} diff --git a/packages/next-plugin/src/timing.ts b/packages/next-plugin/src/timing.ts new file mode 100644 index 00000000..ca1d082c --- /dev/null +++ b/packages/next-plugin/src/timing.ts @@ -0,0 +1,63 @@ +import { formatRustTimingWaterfall } from '@animus-ui/extract/pipeline'; + +/** + * Verbose build-timing waterfall: JS phases, analysis sub-phases, and the + * shared Rust phase table. `ANIMUS_TIMING_JSON=1` additionally dumps a + * machine-readable merged record. + */ +export function logBuildTimings( + bt: Record, + rustTiming: Record | undefined, + log: (msg: string) => void, + verbose: boolean +): void { + if (!verbose) return; + const pad = (label: string, width: number): string => + label + ' '.repeat(Math.max(0, width - label.length)); + const jsPhases: [string, string][] = [ + ['systemLoad', 'system-load'], + ['fileDiscovery', 'file-discovery'], + ['fileRead', 'file-read+hash'], + ['packageResolve', 'pkg-resolve'], + ['analysis', 'analysis'], + ]; + for (const [key, label] of jsPhases) { + const ms = bt[key] ?? 0; + if (ms === 0 && !Object.hasOwn(bt, key)) continue; + log(` ${pad(label, 17)}${String(ms).padStart(5)}ms`); + + if (key === 'analysis') { + for (const [sk, sl] of [ + ['jsonSerialize', 'json-serialize'], + ['rustExtract', 'rust-extract'], + ['jsonParse', 'json-parse'], + ] as const) { + const sms = bt[sk] ?? 0; + log(` ${pad(sl, 15)}${String(sms).padStart(5)}ms`); + + if (sk === 'rustExtract' && rustTiming) { + for (const line of formatRustTimingWaterfall(rustTiming, { + indent: ' ', + labelWidth: 13, + })) { + log(line); + } + } + } + } + } + log(` total ${String(bt.total).padStart(5)}ms`); + + if (process.env.ANIMUS_TIMING_JSON === '1') { + const merged: Record = {}; + for (const [k, v] of Object.entries(bt)) { + merged[`buildStart.${k}`] = v; + } + if (rustTiming) { + for (const [k, v] of Object.entries(rustTiming)) { + if (typeof v === 'number') merged[`rust.${k}`] = v; + } + } + console.info(`[animus:timing] ${JSON.stringify(merged)}`); + } +} diff --git a/packages/next-plugin/src/turbopack-config.ts b/packages/next-plugin/src/turbopack-config.ts new file mode 100644 index 00000000..05c93263 --- /dev/null +++ b/packages/next-plugin/src/turbopack-config.ts @@ -0,0 +1,97 @@ +import { existsSync } from 'fs'; +import { relative, resolve as resolvePath } from 'path'; + +import type { AnimusNextOptions } from './types'; + +/** + * Turbopack config fragment generation (spec: next-turbopack-integration). + * Everything emitted here MUST be JSON-serializable — Turbopack forwards + * loader options across process boundaries and rejects live values. + */ + +export type TurbopackMode = 'off' | 'auto' | 'on'; + +export interface TurbopackRule { + loaders: Array<{ loader: string; options: Record }>; +} + +export interface TurbopackConfigFragment { + rules: Record; + resolveAlias: Record; +} + +/** The single glob the Animus loader registers under. */ +export const ANIMUS_TURBOPACK_RULE_GLOB = '*.{ts,tsx,js,jsx}'; + +/** + * Resolve whether Turbopack wiring is active for this process. `'auto'` + * follows the TURBOPACK environment variable Next sets for Turbopack runs. + */ +export function resolveTurbopackMode( + options: AnimusNextOptions, + env: Record = process.env +): boolean { + const mode: TurbopackMode = options.unstable_turbopack?.mode ?? 'off'; + if (mode === 'on') return true; + if (mode === 'auto') return env.TURBOPACK !== undefined; + return false; +} + +/** + * Build the `turbopack` config fragment: one glob-keyed loader rule + * (file-level allowlisting lives in the loader via manifest lookup — Next 15 + * rules have no condition algebra) plus resolve aliases for the virtual + * system-props id, the emitter's stylesheet id, and each collected external + * package specifier redirected to its source entry. + */ +export function buildTurbopackConfig(args: { + rootDir: string; + loaderPath: string; + options: AnimusNextOptions; + externalSourceEntries: ReadonlyMap; +}): TurbopackConfigFragment { + const { rootDir, loaderPath, options, externalSourceEntries } = args; + + const loaderOptions: Record = { + rootDir, + ...(options.strict !== undefined ? { strict: options.strict } : {}), + ...(options.cssImportTarget !== undefined + ? { cssImportTarget: options.cssImportTarget } + : {}), + }; + + const resolveAlias: Record = { + 'virtual:animus/system-props': './.animus/system-props.js', + '.animus/styles.css': './.animus/styles.css', + }; + for (const [specifier, srcEntry] of externalSourceEntries) { + // Alias values are module requests — always forward slashes, even when + // path.relative produced Windows separators. + resolveAlias[specifier] = + `./${relative(rootDir, srcEntry).replace(/\\/g, '/')}`; + } + + return { + rules: { + [ANIMUS_TURBOPACK_RULE_GLOB]: { + loaders: [{ loader: loaderPath, options: loaderOptions }], + }, + }, + resolveAlias, + }; +} + +/** + * Locate the Turbopack loader module: built dist first, source fallback for + * in-repo development. CJS preferred — Turbopack's loader-runner follows + * the webpack loader contract (`module.exports = fn`), and the .cjs build + * is loadable by every runner version; ESM default-export acceptance is + * not guaranteed. + */ +export function resolveTurbopackLoaderPath(pluginDir: string): string { + for (const candidate of ['turbopack-loader.cjs', 'turbopack-loader.mjs']) { + const distPath = resolvePath(pluginDir, candidate); + if (existsSync(distPath)) return distPath; + } + return resolvePath(pluginDir, 'turbopack-loader.ts'); +} diff --git a/packages/next-plugin/src/turbopack-loader.ts b/packages/next-plugin/src/turbopack-loader.ts new file mode 100644 index 00000000..3eaae597 --- /dev/null +++ b/packages/next-plugin/src/turbopack-loader.ts @@ -0,0 +1,126 @@ +import { + buildAnalyzeProjectArgs, + createV2EngineApi, +} from '@animus-ui/extract/pipeline'; +import { readFileSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +import { transformWithManifest } from './loader-core'; + +import type { LoaderPolicyOptions } from './loader-core'; +import type { + AnalyzeProjectInputs, + V2ExtractEngine, +} from '@animus-ui/extract/pipeline'; + +type LoaderContext = { + resourcePath: string; + rootContext: string; + getOptions: () => LoaderPolicyOptions & { rootDir?: string }; +}; + +// Worker-local engine state. Turbopack executes JS loaders in isolated, +// ephemeral worker processes — module scope IS worker scope, and nothing +// here is (or may be) shared across files beyond this process +// (spec: next-turbopack-integration / Stateless per-file transformation). +// The singleton module must never be imported from this graph. +let engine: V2ExtractEngine | null = null; +let sentSources: Map | null = null; +let driftWarned = false; +let hydratedKey: string | null = null; +let hydratedManifestJson: string | null = null; +let hydrateFailedWarned = false; + +// Indirect module id keeps the require dynamic under bundling. +const engineModuleId = '@animus-ui/extract'; +const engineApi = createV2EngineApi({ + label: 'animus-next-turbopack', + isV2: () => true, + loadNativeEngine: () => require(engineModuleId), + // Generated .animus/* modules and other files outside the analysis + // universe pass through unchanged, matching the webpack loader. + passThroughUnknownPaths: true, + store: { + getEngine: () => engine, + setEngine: (next) => { + engine = next; + }, + getSentSources: () => sentSources, + setSentSources: (sources) => { + sentSources = sources; + }, + getDriftWarned: () => driftWarned, + setDriftWarned: (value) => { + driftWarned = value; + }, + }, +}); + +/** + * Hydrate this worker's engine from `.animus/analysis-inputs.json` — the v2 + * engine serves transforms only from analyze-retained state, so a fresh + * worker replays the persisted analysis once (and again whenever the + * artifact changes; keyed by mtime+size). Returns the manifest JSON, or + * null when the artifact is absent/unreadable (caller passes through). + */ +function hydrate(rootDir: string): string | null { + const inputsPath = join(rootDir, '.animus', 'analysis-inputs.json'); + + let stat: { mtimeNs: bigint; size: bigint }; + try { + // bigint stat: nanosecond mtime closes the same-millisecond rewrite + // window that mtimeMs-granularity keys can miss. The path is part of + // the key so one worker serving two roots can never cross-serve. + stat = statSync(inputsPath, { bigint: true }); + } catch { + return null; // no analysis has run yet — passthrough + } + + const key = `${inputsPath}:${stat.mtimeNs}:${stat.size}`; + if (key === hydratedKey && hydratedManifestJson !== null) { + return hydratedManifestJson; + } + + try { + const inputs = JSON.parse( + readFileSync(inputsPath, 'utf-8') + ) as AnalyzeProjectInputs; + const { analyzeProject } = engineApi(); + hydratedManifestJson = analyzeProject(...buildAnalyzeProjectArgs(inputs)); + hydratedKey = key; + return hydratedManifestJson; + } catch (e: unknown) { + if (!hydrateFailedWarned) { + hydrateFailedWarned = true; + console.warn( + `[animus-extract] Turbopack worker hydration failed (${String(e)}); sources pass through untransformed` + ); + } + return null; + } +} + +/** + * Turbopack loader for Animus source transformation. Derives everything + * from the incoming source, its JSON-serializable options, and the + * `.animus/` disk artifacts written by the out-of-band orchestrator. + */ +export default function animusTurbopackLoader( + this: LoaderContext, + source: string +): string { + const opts = this.getOptions?.() ?? {}; + const rootDir = opts.rootDir ?? this.rootContext; + + const manifestJson = hydrate(rootDir); + if (!manifestJson) return source; + + const filename = relative(rootDir, this.resourcePath); + return transformWithManifest({ + source, + filename, + manifestJson, + engineApi, + opts, + }); +} diff --git a/packages/next-plugin/src/turbopack-orchestrator.ts b/packages/next-plugin/src/turbopack-orchestrator.ts new file mode 100644 index 00000000..c3516aec --- /dev/null +++ b/packages/next-plugin/src/turbopack-orchestrator.ts @@ -0,0 +1,103 @@ +import { existsSync, watch } from 'fs'; +import { join } from 'path'; + +import type { ExtractionSession } from './extraction-session'; + +/** + * Out-of-band pipeline orchestration for Turbopack (spec: + * next-turbopack-integration). Turbopack exposes no compiler hooks, so the + * pipeline runs during next.config resolution and, in dev, from a + * filesystem watcher — the bundler only ever sees the `.animus/` disk + * artifacts. + */ + +/** Run the full pipeline with hydration-artifact persistence enabled. */ +export async function runTurbopackPipeline( + session: ExtractionSession +): Promise { + session.persistAnalysisInputs = true; + await session.runFullPipeline(); +} + +const activeWatcherRoots = new Set(); + +/** + * Start the dev watcher: recursive fs.watch on the project root, debounced + * into existence-partitioned modified/removed sets feeding + * `session.handleWatchUpdate` (serialized — updates never overlap). + * Idempotent per project root; unref'd so it never holds the process open. + * Returns a close handle, or null when this root is already watched or the + * platform lacks recursive fs.watch (Linux before Node 20 — degrades to + * no-watch with a warning rather than crashing the dev server). + */ +export function startTurbopackWatcher( + session: ExtractionSession, + rootDir: string, + debounceMs = 75 +): { close(): void } | null { + if (activeWatcherRoots.has(rootDir)) return null; + activeWatcherRoots.add(rootDir); + + const pendingPaths = new Set(); + let timer: ReturnType | null = null; + let updateChain: Promise = Promise.resolve(); + + const flush = (): void => { + timer = null; + const modifiedFiles = new Set(); + const removedFiles = new Set(); + for (const path of pendingPaths) { + (existsSync(path) ? modifiedFiles : removedFiles).add(path); + } + pendingPaths.clear(); + + updateChain = updateChain.then(() => + session + .handleWatchUpdate({ modifiedFiles, removedFiles }) + .catch((err) => { + console.warn( + `[animus-extract] Turbopack watch update failed: ${String(err)}` + ); + }) + ); + }; + + const IGNORED_SEGMENTS = new Set(['.animus', '.next', 'node_modules']); + let watcher: ReturnType; + try { + watcher = watch(rootDir, { recursive: true }, (_event, filename) => { + if (!filename) return; + const rel = filename.toString(); + // Never react to generated or vendored trees — .animus writes would + // otherwise feed back into the watcher. Segment match so the directory + // entry itself (a bare `.animus` event) is ignored too. + if (rel.split(/[\\/]/).some((segment) => IGNORED_SEGMENTS.has(segment))) { + return; + } + const abs = join(rootDir, rel); + pendingPaths.add(abs); + if (!timer) { + timer = setTimeout(flush, debounceMs); + timer.unref?.(); + } + }); + } catch (err) { + // Recursive fs.watch is unavailable on Linux before Node 20 — degrade + // to no-watch (edits need a dev-server restart) instead of rejecting + // the config promise and killing the dev server. + activeWatcherRoots.delete(rootDir); + console.warn( + `[animus-extract] Turbopack dev watcher unavailable (${String(err)}); source edits require a dev-server restart` + ); + return null; + } + watcher.unref?.(); + + return { + close: () => { + if (timer) clearTimeout(timer); + watcher.close(); + activeWatcherRoots.delete(rootDir); + }, + }; +} diff --git a/packages/next-plugin/src/types.ts b/packages/next-plugin/src/types.ts index e682b8a4..69a24f5e 100644 --- a/packages/next-plugin/src/types.ts +++ b/packages/next-plugin/src/types.ts @@ -38,6 +38,22 @@ export interface AnimusNextOptions { * @default 'v2' */ engine?: 'v2'; + /** + * EXPERIMENTAL Turbopack integration — the surface may change in any + * release while the `unstable_` prefix stands. + * + * `mode`: + * - `'off'` (default): webpack-only; no Turbopack keys are generated. + * - `'auto'`: activate when the process runs under Turbopack (the + * `TURBOPACK` environment variable is set). + * - `'on'`: always generate Turbopack wiring. + * + * When active, extraction runs during next.config resolution (the wrapped + * config resolves asynchronously), a dev watcher re-runs analysis on + * source changes, and per-file transforms run in a stateless loader fed + * by `.animus/` disk artifacts. + */ + unstable_turbopack?: { mode?: 'off' | 'auto' | 'on' }; /** * Full `@layer` declaration order. Must include all 7 Animus `anm-*` * layers (`anm-global`, `anm-base`, `anm-variants`, `anm-compounds`, diff --git a/packages/next-plugin/src/with-animus.ts b/packages/next-plugin/src/with-animus.ts index 5d35e50e..f8baffba 100644 --- a/packages/next-plugin/src/with-animus.ts +++ b/packages/next-plugin/src/with-animus.ts @@ -3,7 +3,18 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname, join, resolve, sep } from 'path'; import { fileURLToPath } from 'url'; +import { ExtractionSession } from './extraction-session'; import { ANIMUS_CSS_MODULE_ID, AnimusWebpackPlugin } from './plugin'; +import { + ANIMUS_TURBOPACK_RULE_GLOB, + buildTurbopackConfig, + resolveTurbopackLoaderPath, + resolveTurbopackMode, +} from './turbopack-config'; +import { + runTurbopackPipeline, + startTurbopackWatcher, +} from './turbopack-orchestrator'; import type { AnimusNextOptions } from './types'; @@ -45,7 +56,7 @@ let warnedGitignore = false; */ export function withAnimus( options: AnimusNextOptions -): (nextConfig: NextConfig) => NextConfig { +): (nextConfig: NextConfig) => NextConfig | Promise { if (!options.system) { throw new Error( '[animus-extract] Missing required option `system`. ' + @@ -53,7 +64,14 @@ export function withAnimus( ); } - return (nextConfig: NextConfig): NextConfig => { + return (nextConfig: NextConfig): NextConfig | Promise => { + // EXPERIMENTAL Turbopack path: pipeline runs during config resolution + // (Turbopack has no compiler hooks); webpack wiring is skipped for the + // Turbopack-active process. + if (resolveTurbopackMode(options)) { + return wireTurbopack(nextConfig, options); + } + const existingWebpack = nextConfig.webpack; return { @@ -220,3 +238,50 @@ export function withAnimus( }; }; } + +/** + * Turbopack wiring: run the full extraction now (artifacts on disk before + * bundling), start the dev watcher, and merge the generated rules/aliases + * into `nextConfig.turbopack`. Consumer-managed rules for the same glob are + * a hard error — silently stacking loaders would be undebuggable. + */ +async function wireTurbopack( + nextConfig: NextConfig, + options: AnimusNextOptions +): Promise { + const rootDir = process.cwd(); + + const session = new ExtractionSession(options); + session.rootDir = rootDir; + await runTurbopackPipeline(session); + + if (process.env.NODE_ENV === 'development') { + startTurbopackWatcher(session, rootDir); + } + + const fragment = buildTurbopackConfig({ + rootDir, + loaderPath: resolveTurbopackLoaderPath(__dirname), + options, + externalSourceEntries: session.externalSourceEntries, + }); + + const existing = (nextConfig.turbopack ?? {}) as { + rules?: Record; + resolveAlias?: Record; + }; + if (existing.rules && ANIMUS_TURBOPACK_RULE_GLOB in existing.rules) { + throw new Error( + `[animus-extract] turbopack.rules['${ANIMUS_TURBOPACK_RULE_GLOB}'] is already configured — remove the consumer rule or disable unstable_turbopack` + ); + } + + return { + ...nextConfig, + turbopack: { + ...existing, + rules: { ...existing.rules, ...fragment.rules }, + resolveAlias: { ...existing.resolveAlias, ...fragment.resolveAlias }, + }, + }; +} diff --git a/packages/next-plugin/tests/manifest-diagnostics.test.ts b/packages/next-plugin/tests/manifest-diagnostics.test.ts index 1e050334..7616a198 100644 --- a/packages/next-plugin/tests/manifest-diagnostics.test.ts +++ b/packages/next-plugin/tests/manifest-diagnostics.test.ts @@ -65,39 +65,47 @@ describe('Next manifest diagnostic surfacing', () => { }); test('both pipelines route through a single shared diagnostic-surfacing core', () => { - const pluginSource = readFileSync( - resolve(process.cwd(), 'packages/next-plugin/src/plugin.ts'), + // The single surfacing call now lives in the shared pipeline's + // runProjectAnalysis (used by BOTH bundler plugins), immediately after + // the manifest parse. + const analysisSource = readFileSync( + resolve(process.cwd(), 'packages/extract/pipeline/run-analysis.ts'), 'utf8' ); - const parseAndSurface = - /const manifest = JSON\.parse\(manifestJson\);\s*surfaceManifestDiagnostics\(manifest,/; - - // There is exactly ONE surfaceManifestDiagnostics call site in the whole - // plugin after the two pipelines collapsed onto a shared core. + expect(analysisSource).toMatch( + /const manifest = JSON\.parse\(manifestJson\);\s*surfaceManifestDiagnostics\(manifest,/ + ); expect( - pluginSource.match(/surfaceManifestDiagnostics\(manifest,/g) ?? [] + analysisSource.match(/surfaceManifestDiagnostics\(manifest,/g) ?? [] ).toHaveLength(1); - // That single call lives in the shared analyzeAndEmit core, immediately - // after the manifest parse. - const coreSource = pluginSource.slice( - pluginSource.indexOf('private async analyzeAndEmit'), - pluginSource.indexOf('/** Expose file cache for HMR change detection */') + // The session performs NO local surfacing and calls the shared analysis + // exactly once — inside analyzeAndEmit. + const sessionSource = readFileSync( + resolve( + process.cwd(), + 'packages/next-plugin/src/extraction-session.ts' + ), + 'utf8' ); - expect(coreSource).toMatch(parseAndSurface); + expect(sessionSource).not.toContain('surfaceManifestDiagnostics('); expect( - coreSource.match(/surfaceManifestDiagnostics\(manifest,/g) ?? [] - ).toHaveLength(1); + sessionSource.match(/runProjectAnalysis\(/g) ?? [] + ).toHaveLength(1); // exactly one call site (the import is paren-free) + const coreSource = sessionSource.slice( + sessionSource.indexOf('private async analyzeAndEmit') + ); + expect(coreSource).toMatch(/runProjectAnalysis\(engineApi,/); // The core is reachable from both pipelines: production and HMR each // delegate to this.analyzeAndEmit(...). - const productionSource = pluginSource.slice( - pluginSource.indexOf('private async runFullPipeline'), - pluginSource.indexOf('private loadSystem') + const productionSource = sessionSource.slice( + sessionSource.indexOf('async runFullPipeline'), + sessionSource.indexOf('resetForHmr(): void') ); - const hmrSource = pluginSource.slice( - pluginSource.indexOf('private async runIncrementalPipeline'), - pluginSource.indexOf('private async analyzeAndEmit') + const hmrSource = sessionSource.slice( + sessionSource.indexOf('private async runIncrementalPipeline'), + sessionSource.indexOf('private async analyzeAndEmit') ); expect(productionSource).toMatch(/this\.analyzeAndEmit\(/); expect(hmrSource).toMatch(/this\.analyzeAndEmit\(/); diff --git a/packages/next-plugin/tests/plugin-pipeline.test.ts b/packages/next-plugin/tests/plugin-pipeline.test.ts index b136219a..79ede60d 100644 --- a/packages/next-plugin/tests/plugin-pipeline.test.ts +++ b/packages/next-plugin/tests/plugin-pipeline.test.ts @@ -15,6 +15,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; @@ -55,6 +56,7 @@ const GLOBAL_KEYS = [ '__animus_engine__', '__animus_v2_engine__', '__animus_v2_sent_sources__', + '__animus_v2_drift_warned__', ] as const; const g = globalThis as Record; @@ -362,6 +364,27 @@ describe('production run (full pipeline)', () => { ); }); + test('writes the manifest disk artifact verbatim and hash-guards rewrites', async () => { + const root = createProject(); + const { compiler, watchRunHandlers } = createCompiler(root); + applyPlugin(new AnimusWebpackPlugin(OPTIONS), compiler); + + await watchRunHandlers[0](compiler); + + const manifestPath = join(root, '.animus', 'manifest.json'); + const written = readFileSync(manifestPath, 'utf-8'); + expect(written).toBe(mocks.analyzeProject.mock.results[0].value as string); + expect(JSON.parse(written).system_prop_map).toEqual({ m: 'margin' }); + const mtimeAfterFull = statSync(manifestPath).mtimeMs; + + // A source change whose re-analysis yields a byte-identical manifest + // must not rewrite the artifact. + writeFileSync(join(root, 'src', 'Button.tsx'), BUTTON_SOURCE_CHANGED); + await watchRunHandlers[0](compiler); + expect(mocks.analyzeProject).toHaveBeenCalledTimes(2); + expect(statSync(manifestPath).mtimeMs).toBe(mtimeAfterFull); + }); + test('processAssets injects shared CSS into absolute- and relative-named assets', async () => { const root = createProject(); const { compiler, runHandlers, compilationHandlers } = createCompiler(root); diff --git a/packages/next-plugin/tests/turbopack-config.test.ts b/packages/next-plugin/tests/turbopack-config.test.ts new file mode 100644 index 00000000..afc3db26 --- /dev/null +++ b/packages/next-plugin/tests/turbopack-config.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'vitest'; + +import { + ANIMUS_TURBOPACK_RULE_GLOB, + buildTurbopackConfig, + resolveTurbopackMode, +} from '../src/turbopack-config'; + +import type { AnimusNextOptions } from '../src/types'; + +const BASE: AnimusNextOptions = { system: './src/ds.ts' }; + +describe('resolveTurbopackMode', () => { + test('defaults to off', () => { + expect(resolveTurbopackMode(BASE, {})).toBe(false); + expect(resolveTurbopackMode(BASE, { TURBOPACK: '1' })).toBe(false); + }); + + test('off stays off even under Turbopack', () => { + expect( + resolveTurbopackMode( + { ...BASE, unstable_turbopack: { mode: 'off' } }, + { TURBOPACK: '1' } + ) + ).toBe(false); + }); + + test('on is unconditional', () => { + expect( + resolveTurbopackMode({ ...BASE, unstable_turbopack: { mode: 'on' } }, {}) + ).toBe(true); + }); + + test('auto follows the TURBOPACK environment signal', () => { + const auto: AnimusNextOptions = { + ...BASE, + unstable_turbopack: { mode: 'auto' }, + }; + expect(resolveTurbopackMode(auto, {})).toBe(false); + expect(resolveTurbopackMode(auto, { TURBOPACK: '1' })).toBe(true); + }); +}); + +describe('buildTurbopackConfig', () => { + const build = (options: AnimusNextOptions, entries: Array<[string, string]> = []) => + buildTurbopackConfig({ + rootDir: '/proj', + loaderPath: '/plugin/dist/turbopack-loader.mjs', + options, + externalSourceEntries: new Map(entries), + }); + + test('emits one glob rule with JSON-round-trippable options', () => { + const fragment = build({ + ...BASE, + strict: true, + cssImportTarget: 'src/app/[locale]/layout.tsx', + }); + + const rule = fragment.rules[ANIMUS_TURBOPACK_RULE_GLOB]; + expect(rule.loaders).toHaveLength(1); + expect(rule.loaders[0].loader).toBe('/plugin/dist/turbopack-loader.mjs'); + expect(rule.loaders[0].options).toEqual({ + rootDir: '/proj', + strict: true, + cssImportTarget: 'src/app/[locale]/layout.tsx', + }); + // Spec: options survive JSON serialization unchanged (G3) + expect(JSON.parse(JSON.stringify(rule.loaders[0].options))).toEqual( + rule.loaders[0].options + ); + }); + + test('omits unset optional loader options', () => { + const fragment = build(BASE); + expect(fragment.rules[ANIMUS_TURBOPACK_RULE_GLOB].loaders[0].options).toEqual( + { rootDir: '/proj' } + ); + }); + + test('aliases virtual ids to disk artifacts and externals to source entries', () => { + const fragment = build(BASE, [ + ['@acme/ds', '/proj/packages/ds/src/index.ts'], + ]); + expect(fragment.resolveAlias).toEqual({ + 'virtual:animus/system-props': './.animus/system-props.js', + '.animus/styles.css': './.animus/styles.css', + '@acme/ds': './packages/ds/src/index.ts', + }); + }); +}); diff --git a/packages/next-plugin/tests/turbopack-loader.test.ts b/packages/next-plugin/tests/turbopack-loader.test.ts new file mode 100644 index 00000000..3685822d --- /dev/null +++ b/packages/next-plugin/tests/turbopack-loader.test.ts @@ -0,0 +1,146 @@ +/** + * Behavior pins for the Turbopack loader (spec: next-turbopack-integration / + * Stateless per-file transformation): everything derives from the incoming + * source, serializable options, and `.animus/` disk artifacts. The engine + * adapter is mocked at the pipeline factory seam; hydration replays + * analyzeProject from `.animus/analysis-inputs.json`, keyed on mtime+size. + */ +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import animusTurbopackLoader from '../src/turbopack-loader'; + +const mocks = vi.hoisted(() => ({ + analyzeProject: vi.fn(), + transformFile: vi.fn(), +})); + +vi.mock('@animus-ui/extract/pipeline', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createV2EngineApi: () => () => ({ + analyzeProject: mocks.analyzeProject, + transformFile: mocks.transformFile, + }), + }; +}); + +const tempRoots: string[] = []; + +function makeRoot(withInputs = true): string { + const root = mkdtempSync(join(tmpdir(), 'animus-turbo-loader-')); + tempRoots.push(root); + if (withInputs) { + mkdirSync(join(root, '.animus'), { recursive: true }); + writeFileSync( + join(root, '.animus', 'analysis-inputs.json'), + JSON.stringify({ + filesJson: '[]', + scalesJson: '{}', + variableMapJson: '{}', + contextualVarsJson: null, + propConfigJson: '{}', + groupRegistryJson: '{}', + packageResolutionJson: '{}', + devMode: true, + emitterConfigJson: '{}', + selectorAliasesJson: null, + globalStyleBlocksJson: null, + pathAliasesJson: null, + keyframesJson: null, + }) + ); + } + return root; +} + +function runLoader( + root: string, + relPath: string, + source: string, + options: Record = {} +): string { + const ctx = { + resourcePath: join(root, relPath), + rootContext: root, + getOptions: () => ({ rootDir: root, ...options }), + }; + return animusTurbopackLoader.call(ctx, source); +} + +beforeEach(() => { + mocks.analyzeProject.mockReset().mockReturnValue('{"files":{}}'); + mocks.transformFile + .mockReset() + .mockImplementation((source: string) => ({ code: source, hasComponents: false })); +}); + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('turbopack loader hydration', () => { + test('passes through untouched when no analysis artifact exists', () => { + const root = makeRoot(false); + const before = mocks.analyzeProject.mock.calls.length; + const out = runLoader(root, 'app/page.tsx', 'export const a = 1;\n'); + expect(out).toBe('export const a = 1;\n'); + expect(mocks.analyzeProject.mock.calls.length).toBe(before); + }); + + test('hydrates exactly once per artifact version across files', () => { + const root = makeRoot(); + const before = mocks.analyzeProject.mock.calls.length; + runLoader(root, 'app/a.tsx', 'export const a = 1;\n'); + runLoader(root, 'app/b.tsx', 'export const b = 2;\n'); + expect(mocks.analyzeProject.mock.calls.length).toBe(before + 1); + // Transform receives the hydrated manifest verbatim + expect(mocks.transformFile).toHaveBeenLastCalledWith( + 'export const b = 2;\n', + 'app/b.tsx', + '{"files":{}}' + ); + }); + + test('re-hydrates when the artifact changes on disk', () => { + const root = makeRoot(); + runLoader(root, 'app/a.tsx', 'export {};\n'); + const before = mocks.analyzeProject.mock.calls.length; + + const inputsPath = join(root, '.animus', 'analysis-inputs.json'); + const bumped = new Date(Date.now() + 5000); + utimesSync(inputsPath, bumped, bumped); + + runLoader(root, 'app/a.tsx', 'export {};\n'); + expect(mocks.analyzeProject.mock.calls.length).toBe(before + 1); + }); +}); + +describe('turbopack loader CSS policy (shared loader-core)', () => { + test('strips emitter CSS imports from non-root files', () => { + const root = makeRoot(); + mocks.transformFile.mockImplementation((source: string) => ({ + code: `import '.animus/styles.css';\n${source}`, + hasComponents: true, + })); + const out = runLoader(root, 'app/page.tsx', 'export const P = 1;\n'); + expect(out).toBe('export const P = 1;\n'); + }); + + test('injects the single CSS import at the configured target', () => { + const root = makeRoot(); + const out = runLoader( + root, + 'src/app/[locale]/layout.tsx', + 'export {};\n', + { cssImportTarget: 'src/app/[locale]/layout.tsx' } + ); + expect(out.startsWith("import '.animus/styles.css';\n")).toBe(true); + }); +}); diff --git a/packages/next-plugin/tests/turbopack-orchestrator.test.ts b/packages/next-plugin/tests/turbopack-orchestrator.test.ts new file mode 100644 index 00000000..ca8e94d6 --- /dev/null +++ b/packages/next-plugin/tests/turbopack-orchestrator.test.ts @@ -0,0 +1,259 @@ +/** + * Behavior pins for the Turbopack orchestration path (spec: + * next-turbopack-integration): config resolution completes the extraction + * and leaves the full artifact set on disk; the dev watcher feeds + * existence-partitioned change sets into the session. Engine mocked at the + * singleton seam, same harness as plugin-pipeline.test.ts. + */ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { startTurbopackWatcher } from '../src/turbopack-orchestrator'; +import { ANIMUS_TURBOPACK_RULE_GLOB } from '../src/turbopack-config'; +import { withAnimus } from '../src/with-animus'; + +import type { ExtractionSession } from '../src/extraction-session'; + +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, + }), + }; +}); + +/** globalThis keys owned by src/singleton.ts — saved/cleared per test. */ +const GLOBAL_KEYS = [ + '__animus_manifest_json__', + '__animus_analysis_promise__', + '__animus_shared_css__', + '__animus_shared_system_props__', + '__animus_external_pkg_dirs__', + '__animus_external_source_entries__', + '__animus_engine__', + '__animus_v2_engine__', + '__animus_v2_sent_sources__', + '__animus_v2_drift_warned__', +] as const; + +const g = globalThis as Record; +let savedGlobals: Record; +const tempRoots: string[] = []; +let savedCwd: string; + +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 MANIFEST = JSON.stringify({ + css: '.btn{margin:8;}', + sheets: { global: '' }, + system_prop_map: {}, + dynamic_props: {}, + diagnostics: [], +}); + +const BUTTON_SOURCE = + "export const Button = animus.styles({ margin: 8 }).asElement('button');\n"; + +function createProject(): string { + const root = mkdtempSync(join(tmpdir(), 'animus-turbo-orch-')); + tempRoots.push(root); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'src', 'system.ts'), 'export const system = {};\n'); + writeFileSync(join(root, 'src', 'Button.tsx'), BUTTON_SOURCE); + return root; +} + +beforeEach(() => { + savedGlobals = {}; + for (const key of GLOBAL_KEYS) { + savedGlobals[key] = g[key]; + g[key] = undefined; + } + savedCwd = process.cwd(); + mocks.loadSystemModule.mockReset().mockReturnValue({ ...SYSTEM_CONFIG }); + mocks.analyzeProject.mockReset().mockReturnValue(MANIFEST); + mocks.clearAnalysisCache.mockReset(); +}); + +afterEach(() => { + process.chdir(savedCwd); + Object.assign(g, savedGlobals); + vi.restoreAllMocks(); + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('withAnimus Turbopack wiring', () => { + test('inactive mode returns synchronously with no turbopack keys', () => { + const root = createProject(); + process.chdir(root); + const config = withAnimus({ system: './src/system.ts' })({}); + expect(config).not.toBeInstanceOf(Promise); + expect((config as Record).turbopack).toBeUndefined(); + }); + + test('active mode resolves after the artifact set exists and merges config', async () => { + const root = createProject(); + process.chdir(root); + + const pending = withAnimus({ + system: './src/system.ts', + unstable_turbopack: { mode: 'on' }, + })({}); + expect(pending).toBeInstanceOf(Promise); + const config = await pending; + + for (const artifact of [ + 'styles.css', + 'system-props.js', + 'manifest.json', + 'analysis-inputs.json', + ]) { + expect(existsSync(join(root, '.animus', artifact))).toBe(true); + } + + // The hydration artifact replays the exact analyze-time inputs + const inputs = JSON.parse( + readFileSync(join(root, '.animus', 'analysis-inputs.json'), 'utf-8') + ); + const files = JSON.parse(inputs.filesJson) as Array<{ + path: string; + source: string; + }>; + expect(files.find((f) => f.path === 'src/Button.tsx')?.source).toBe( + BUTTON_SOURCE + ); + expect(inputs.devMode).toBe(false); + expect(readFileSync(join(root, '.animus', 'manifest.json'), 'utf-8')).toBe( + MANIFEST + ); + + const turbopack = (config as Record).turbopack as { + rules: Record }>; + resolveAlias: Record; + }; + expect(turbopack.rules[ANIMUS_TURBOPACK_RULE_GLOB]).toBeDefined(); + // process.cwd() resolves the macOS /var → /private/var symlink + expect( + turbopack.rules[ANIMUS_TURBOPACK_RULE_GLOB].loaders[0].options + ).toMatchObject({ rootDir: realpathSync(root) }); + expect(turbopack.resolveAlias['virtual:animus/system-props']).toBe( + './.animus/system-props.js' + ); + }); + + test('a consumer rule on the Animus glob is a hard error', async () => { + const root = createProject(); + process.chdir(root); + + await expect( + withAnimus({ + system: './src/system.ts', + unstable_turbopack: { mode: 'on' }, + })({ + turbopack: { + rules: { [ANIMUS_TURBOPACK_RULE_GLOB]: { loaders: [] } }, + }, + }) + ).rejects.toThrow('already configured'); + }); +}); + +type WatchChanges = { + modifiedFiles: Set; + removedFiles: Set; +}; + +describe('startTurbopackWatcher', () => { + test('feeds debounced, existence-partitioned change sets to the session', async () => { + const root = createProject(); + const handleWatchUpdate = vi.fn<(changes: WatchChanges) => Promise>( + async () => {} + ); + const session = { handleWatchUpdate } as unknown as ExtractionSession; + + const watcher = startTurbopackWatcher(session, root, 20); + expect(watcher).not.toBeNull(); + try { + writeFileSync(join(root, 'src', 'New.tsx'), 'export const N = 1;\n'); + await vi.waitFor( + () => + expect( + handleWatchUpdate.mock.calls.some((c) => + c[0].modifiedFiles.has(join(root, 'src', 'New.tsx')) + ) + ).toBe(true), + { timeout: 5000 } + ); + + rmSync(join(root, 'src', 'New.tsx')); + await vi.waitFor( + () => + expect( + handleWatchUpdate.mock.calls.some((c) => + c[0].removedFiles.has(join(root, 'src', 'New.tsx')) + ) + ).toBe(true), + { timeout: 5000 } + ); + } finally { + watcher!.close(); + } + }); + + test('is idempotent per process and ignores .animus writes', async () => { + const root = createProject(); + const handleWatchUpdate = vi.fn<(changes: WatchChanges) => Promise>( + async () => {} + ); + const session = { handleWatchUpdate } as unknown as ExtractionSession; + + const first = startTurbopackWatcher(session, root, 20); + const second = startTurbopackWatcher(session, root, 20); + expect(second).toBeNull(); + try { + // FSEvents may replay events from just before the watcher started — + // let those flush, then measure only the .animus write. + await new Promise((resolve) => setTimeout(resolve, 150)); + handleWatchUpdate.mockClear(); + + mkdirSync(join(root, '.animus'), { recursive: true }); + writeFileSync(join(root, '.animus', 'styles.css'), '/* generated */'); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(handleWatchUpdate).not.toHaveBeenCalled(); + } finally { + first!.close(); + } + }); +}); diff --git a/packages/next-plugin/tests/with-animus.test.ts b/packages/next-plugin/tests/with-animus.test.ts index 90a89887..8e5f9eb7 100644 --- a/packages/next-plugin/tests/with-animus.test.ts +++ b/packages/next-plugin/tests/with-animus.test.ts @@ -45,6 +45,7 @@ describe('withAnimus', () => { const wrapped = withAnimus({ system: './src/ds.ts' })({ webpack: consumerWebpack, }); + if (wrapped instanceof Promise) throw new Error('unexpected async config'); const incomingConfig = {}; const context = {}; @@ -91,6 +92,7 @@ describe('withAnimus', () => { }; const wrapped = withAnimus(options)({}); + if (wrapped instanceof Promise) throw new Error('unexpected async config'); const config = wrapped.webpack?.({}, {}); const plugin = config?.plugins?.find( (candidate) => candidate instanceof AnimusWebpackPlugin diff --git a/packages/next-plugin/tsdown.config.ts b/packages/next-plugin/tsdown.config.ts index 22a65dec..61c8d4c6 100644 --- a/packages/next-plugin/tsdown.config.ts +++ b/packages/next-plugin/tsdown.config.ts @@ -3,5 +3,5 @@ import { createConfig } from '../../tsdown.config.base.ts'; export default createConfig({ platform: 'node', format: ['esm', 'cjs'], - entry: ['./src/index.ts', './src/loader.ts'], + entry: ['./src/index.ts', './src/loader.ts', './src/turbopack-loader.ts'], }); diff --git a/packages/vite-plugin/src/build-start.ts b/packages/vite-plugin/src/build-start.ts new file mode 100644 index 00000000..1d263e89 --- /dev/null +++ b/packages/vite-plugin/src/build-start.ts @@ -0,0 +1,238 @@ +import { + assembleStylesheet, + clearEngineCache, + collectExternalPackageSources, + contentHash, + DEFAULT_EXTENSIONS, + discoverFiles, + extractSystemFilePackages, + preprocessMdx, + validateLayerOrder, +} from '@animus-ui/extract/pipeline'; +import { readFileSync } from 'fs'; +import { extname, relative } from 'path'; + +import { DEFAULT_EXCLUDE } from './constants'; + +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. + */ +export async function runBuildStart( + ctx: PluginContext, + resolveSpecifier: (specifier: string) => Promise +): 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); + + // 1. Load system: config, theme, transforms, global styles + let t0 = performance.now(); + ctx.loadSystem(); + + // Validate layer ordering + if (ctx.options.layers) { + validateLayerOrder(ctx.options.layers); + ctx.log(`Custom layers: [${ctx.options.layers.join(', ')}]`); + } + + if (ctx.verbose) { + const propCount = Object.keys(JSON.parse(ctx.system.propConfigJson)).length; + const groupCount = Object.keys( + JSON.parse(ctx.system.groupRegistryJson) + ).length; + ctx.log( + `System loaded: ${propCount} props, ${groupCount} groups (${Math.round(performance.now() - t0)}ms)` + ); + } + + // 3. Discover source files via recursive directory walk + t0 = performance.now(); + const excludePatterns = ctx.options.exclude ?? DEFAULT_EXCLUDE; + // Refresh the hoisted `extensionsSet` in case `options` was mutated between + // server lifecycles. Source of truth remains `options.extensions ?? DEFAULT_EXTENSIONS`. + ctx.extensionsSet = new Set(ctx.options.extensions ?? DEFAULT_EXTENSIONS); + const shouldHandleMdx = ctx.extensionsSet.has('.mdx'); + let mdxMissingDepWarned = false; + + // One MDX preprocess policy for the local and package ingest paths: + // missing-dep warns once, errors warn per file, success rewrites to `.tsx` + // so the Rust source-type helper parses the output as tsx. + const preprocessMdxEntry = async ( + source: string, + relPath: string + ): Promise<{ source: string; relPath: string } | null> => { + const result = await preprocessMdx(source, relPath); + if (result.kind === 'missing-dep') { + if (!mdxMissingDepWarned) { + ctx.warn( + '⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped' + ); + mdxMissingDepWarned = true; + } + return null; + } + if (result.kind === 'error') { + ctx.warn(`⚠ MDX preprocessing failed for ${relPath}: ${result.error}`); + return null; + } + return { source: result.source!, relPath: relPath + '.tsx' }; + }; + const filePaths = discoverFiles( + ctx.rootDir, + ctx.rootDir, + excludePatterns, + ctx.extensionsSet + ); + + // 4. Read all file sources and build file entries (preprocessing MDX as we go) + const fileEntries: Array<{ + path: string; + source: string; + hash?: string; + }> = []; + for (const filePath of filePaths) { + try { + let source = readFileSync(filePath, 'utf-8'); + let relPath = relative(ctx.rootDir, filePath); + + if (shouldHandleMdx && extname(filePath) === '.mdx') { + const processed = await preprocessMdxEntry(source, relPath); + if (!processed) continue; + source = processed.source; + relPath = processed.relPath; + } + + const hash = !ctx.isProd ? contentHash(source) : undefined; + fileEntries.push({ path: relPath, source, hash }); + + // Populate file cache for dev HMR + if (!ctx.isProd && hash) { + ctx.fileCache.set(relPath, { hash, source }); + } + } catch { + // Skip unreadable files silently + } + } + + // 5. Discover external packages from system entry file imports and resolve them + const localFileCount = fileEntries.length; + const packageSpecifiers = extractSystemFilePackages(ctx.resolvedSystemPath!); + + ctx.externalSourceEntries.clear(); + + // Shared traversal/ingest (spec: external-package-file-discovery); + // only specifier resolution, MDX handling, and the hash/cache policy + // below stay bundler-specific. + const collected = await collectExternalPackageSources({ + specifiers: packageSpecifiers, + resolveSpecifier, + rootDir: ctx.rootDir, + extensionsSet: ctx.extensionsSet, + hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath), + preprocessFile: async (source, relPath, absPath) => { + if (shouldHandleMdx && extname(absPath) === '.mdx') { + return preprocessMdxEntry(source, relPath); + } + return { source, relPath }; + }, + onUnreadable: (relPath, err) => + ctx.warn(`skipped unreadable package file ${relPath}: ${String(err)}`), + }); + + ctx.packageMap = collected.packageMap; + for (const [specifier, srcEntry] of collected.sourceEntries) { + ctx.externalSourceEntries.set(specifier, srcEntry); + } + for (const entry of collected.entries) { + const hash = !ctx.isProd ? contentHash(entry.source) : undefined; + fileEntries.push({ path: entry.path, source: entry.source, hash }); + if (!ctx.isProd && hash) { + ctx.fileCache.set(entry.path, { hash, source: entry.source }); + } + } + + ctx.externalPackageDirs = collected.packageDirs; + + const packageFileCount = fileEntries.length - localFileCount; + ctx.log( + `Discovered ${fileEntries.length} files (${packageFileCount} from packages) (${Math.round(performance.now() - t0)}ms)` + ); + + // 6. Run project-wide analysis to produce the manifest + t0 = performance.now(); + ctx.runAnalysis(fileEntries); + + // 7. Surface diagnostics from the manifest + if (ctx.storedManifest) { + const report = ctx.storedManifest.report; + if (report) { + ctx.log( + `Extracted ${report.components_extracted}/${report.components_total} components (${Math.round(performance.now() - t0)}ms)` + ); + ctx.logTimingWaterfall(ctx.storedManifest.timing ?? {}); + ctx.log( + `Reconciliation: ${report.components_extracted} kept, ${report.variants_eliminated} variants pruned, ${report.states_eliminated} states pruned` + ); + + // Always-on elimination warnings (not gated by verbose) + const details: Array<{ + component: string; + kind: string; + name?: string; + reason: string; + }> = report.eliminated_details || []; + for (const d of details) { + if (d.kind === 'component') { + ctx.warn(`⚠ ${d.component} eliminated: ${d.reason}`); + } else if (d.kind === 'prospective_component') { + ctx.warn( + `⚠ ${d.component} would be eliminated in production: ${d.reason}` + ); + } else if (d.kind === 'variant') { + ctx.warn( + `⚠ ${d.component} variant '${d.name}' pruned: ${d.reason}` + ); + } else if (d.kind === 'state') { + ctx.warn(`⚠ ${d.component} state '${d.name}' pruned: ${d.reason}`); + } + } + } + + ctx.log( + `CSS: ${ctx.resolvedComponentCss.length} bytes (${Object.keys(ctx.storedManifest.components || {}).length} components)` + ); + + if (!ctx.isProd && ctx.storedSheets) { + const staticCss = assembleStylesheet({ + layers: ctx.options.layers, + variableCss: ctx.system.variableCss, + globalCss: ctx.globalCss, + }); + const staticSize = staticCss.length; + const componentSize = ctx.resolvedComponentCss.length; + ctx.log( + `Delivery: split mode — static ${staticSize} bytes, components ${componentSize} bytes (adopted stylesheet)` + ); + } else { + ctx.log('Delivery: single file mode (production)'); + } + } + + // Compute @layer declaration for HTML injection (config-time, static). + const { declaration } = assembleStylesheet({ + layers: ctx.options.layers, + variableCss: '', + globalCss: '', + split: true, + }); + ctx.layerDeclaration = declaration; + + if (ctx.options.verify) { + ctx.runSelfVerify(); + } +} diff --git a/packages/vite-plugin/src/config.ts b/packages/vite-plugin/src/config.ts new file mode 100644 index 00000000..8425b07a --- /dev/null +++ b/packages/vite-plugin/src/config.ts @@ -0,0 +1,69 @@ +import { buildPathAliasesJson } from '@animus-ui/extract/pipeline'; + +import { resolveLightningTargets } from './css'; + +import type { PluginContext } from './context'; +import type { ResolvedConfig } from 'vite'; + +/** + * configResolved: capture build mode, root, and logger; resolve Lightning + * CSS targets; harvest resolve.alias into the Rust `pathAliasesJson` + * contract via the shared encoder. + */ +export function applyResolvedConfig( + ctx: PluginContext, + config: ResolvedConfig +): void { + ctx.isProd = config.command === 'build'; + ctx.rootDir = config.root; + ctx.logger = config.logger; + + // Resolve Lightning CSS browser targets once + ctx.lcssTargets = resolveLightningTargets(ctx.options.targets, ctx.rootDir); + ctx.log( + `Lightning CSS targets resolved (${Object.keys(ctx.lcssTargets).length} browsers)` + ); + + // Extract path aliases from Vite's resolved config. + // This includes aliases from vite-tsconfig-paths, manual resolve.alias, etc. + const rawAlias = config.resolve?.alias; + if (rawAlias) { + const pairs: Array<{ + pattern: string; + target: string; + kind?: 'prefix'; + }> = []; + + if (Array.isArray(rawAlias)) { + // Array format: [{ find: string | RegExp, replacement: string }] + // String finds are always prefix matches — no extension sniffing. + for (const entry of rawAlias) { + if ( + typeof entry.find === 'string' && + typeof entry.replacement === 'string' + ) { + pairs.push({ + pattern: entry.find, + target: entry.replacement, + kind: 'prefix', + }); + } + } + } else if (typeof rawAlias === 'object' && rawAlias !== null) { + // Record format: { '@admin': '/abs/path/to/src' } + for (const [key, value] of Object.entries( + rawAlias as Record + )) { + if (typeof value === 'string') { + pairs.push({ pattern: key, target: value }); + } + } + } + + const built = buildPathAliasesJson(pairs, ctx.rootDir); + if (built) { + ctx.pathAliasesJson = built.json; + ctx.log(`Path aliases forwarded: ${built.count} entries`); + } + } +} diff --git a/packages/vite-plugin/src/constants.ts b/packages/vite-plugin/src/constants.ts new file mode 100644 index 00000000..3eb01cd5 --- /dev/null +++ b/packages/vite-plugin/src/constants.ts @@ -0,0 +1,13 @@ +export const VIRTUAL_CSS_ID = 'virtual:animus/styles.css'; +export const RESOLVED_CSS_ID = '\0virtual:animus/styles.css'; + +export const VIRTUAL_COMPONENTS_ID = 'virtual:animus/components.js'; +export const RESOLVED_COMPONENTS_ID = '\0virtual:animus/components.js'; + +export const VIRTUAL_BRIDGE_ID = 'virtual:animus/hmr-bridge.js'; +export const RESOLVED_BRIDGE_ID = '\0virtual:animus/hmr-bridge.js'; + +export const VIRTUAL_SYSTEM_PROPS_ID = 'virtual:animus/system-props'; +export const RESOLVED_SYSTEM_PROPS_ID = '\0virtual:animus/system-props'; + +export const DEFAULT_EXCLUDE = ['node_modules', 'dist', '.test.', '.spec.']; diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts new file mode 100644 index 00000000..fe92ed4e --- /dev/null +++ b/packages/vite-plugin/src/context.ts @@ -0,0 +1,379 @@ +import { + assembleStylesheet, + createV2EngineApi, + DEFAULT_EXTENSIONS, + formatRustTimingWaterfall, + loadSystemConfig, + runProjectAnalysis, +} from '@animus-ui/extract/pipeline'; +import { resolve } from 'path'; + +import { VIRTUAL_CSS_ID } from './constants'; + +import type { AnimusExtractOptions } from './index'; +import type { LightningTargets } from './css'; +import type { + SystemConfig, + V2ExtractEngine, +} from '@animus-ui/extract/pipeline'; +import type { Logger } from 'vite'; + +/** + * Structured per-layer CSS sheets from the Rust crate (dev split delivery). + * Mirrors the `CssSheets` struct in packages/extract/src/css_generator.rs — + * keep these fields in sync. + */ +export interface CssSheets { + declaration: string; + global: string; + base: string; + variants: string; + compounds: string; + states: string; + system: string; + custom: string; +} + +/** Pre-load / failed-load defaults — the plugin's historical initial state. */ +function emptySystemConfig(): SystemConfig { + return { + propConfigJson: '{}', + groupRegistryJson: '{}', + scalesJson: '{}', + variableMapJson: '{}', + variableCss: '', + contextualVarsJson: '{}', + selectorAliasesJson: null, + globalStyleBlocksJson: null, + keyframesJson: null, + }; +} + +/** + * Reconstruct file entries from cache, including content hashes. + * For unchanged files (hash matches changedPath), sends empty source + * to avoid serializing full source text across the NAPI boundary. + * The engine adapter's `rehydrateFilesJson` refills empty sources from + * this same cache before analyze. + */ +export function buildFileEntriesFromCache( + cache: Map, + changedPath?: string +): Array<{ path: string; source: string; hash: string }> { + const entries: Array<{ path: string; source: string; hash: string }> = []; + for (const [path, { hash, source }] of cache) { + entries.push({ + path, + source: path === changedPath ? source : '', + hash, + }); + } + return entries; +} + +/** + * Per-plugin-instance state and the pipeline operations over it. Hook + * bodies live in their own modules (build-start, virtual-modules, + * transform, hmr) and receive this context — the plugin factory in + * index.ts only wires Vite hooks to those functions. + * + * A class rather than closure variables so each hook module names exactly + * the state it touches, and the engine store (DEF-1: per-instance, never + * module-level) is explicit. + */ +export class PluginContext { + readonly options: AnimusExtractOptions; + readonly verbose: boolean; + + isProd = false; + rootDir = ''; + logger: Logger | null = null; + + /** System-derived config (shared SystemConfig shape). */ + system: SystemConfig = emptySystemConfig(); + + // Lightning CSS: resolved browser targets (computed once at configResolved) + lcssTargets: LightningTargets = {}; + + // Serialized path aliases from the host bundler's resolve.alias config. + pathAliasesJson: string | null = null; + + // File extensions — refreshed at buildStart; HMR uses the same Set. + extensionsSet: ReadonlySet; + + // Manifest state — populated at buildStart, consumed during transform/load + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storedManifest: any = null; + storedManifestJson = ''; + + // Resolved CSS from .withGlobalStyles({ reset, global }) — @layer anm-global + globalCss = ''; + // Pre-resolved component CSS with transforms + unit fallback applied + resolvedComponentCss = ''; + storedSheets: CssSheets | null = null; + + // @layer declaration for HTML injection via transformIndexHtml. + layerDeclaration = ''; + + // Per-component CSS fragment cache for incremental HMR + fragmentCache = new Map< + string, + { base?: string; variants?: string; compounds?: string; states?: string } + >(); + + // Reverse provenance: parent_id → [child_ids] for transitive invalidation + reverseProvenance: Record = {}; + + // System-props module inputs (served as virtual:animus/system-props) + storedSystemPropMapJson = '{}'; + storedDynamicPropsJson = '{}'; + // Runtime transform functions for dynamic props are not supported — + // transforms resolve at extraction time via boa_engine in Rust. + storedTransformsSource = '{}'; + + // Content-hash file cache for dev HMR (path → { hash, source }) + fileCache = new Map(); + + // Package resolution map built at buildStart (reused during HMR) + packageMap: Record = {}; + + // Absolute directory prefixes for external DS packages + externalPackageDirs: string[] = []; + + // External package specifier → absolute source entry (resolveId redirect) + externalSourceEntries = new Map(); + + // Dev server reference for programmatic module invalidation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + devServer: any; + + // Whether the HMR bridge import has been injected (dev only, one-time) + bridgeInjected = false; + + // Resolved system module path for geological reset detection + resolvedSystemPath: string | null = null; + + // Per-PLUGIN-INSTANCE v2 engine state (DEF-1: no module-level engine — + // two differently-configured plugins in one process must not share state). + private v2Engine: V2ExtractEngine | null = null; + private v2SentSources: Map | null = null; + private v2DriftWarned = false; + + /** Single engine choke-point for every native extraction call. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly engineApi: () => any; + + constructor(options: AnimusExtractOptions) { + this.options = options; + this.verbose = + options.verbose || + process.env.ANIMUS_DEBUG === '1' || + process.env.ANIMUS_DEBUG === 'true'; + this.extensionsSet = new Set(options.extensions ?? DEFAULT_EXTENSIONS); + + // 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. + // Indirect module id keeps the require dynamic under bundling. + const engineModuleId = '@animus-ui/extract'; + this.engineApi = createV2EngineApi({ + label: 'animus-extract', + isV2: () => true, + loadNativeEngine: () => require(engineModuleId), + // A cache-aware caller (buildFileEntriesFromCache) may send EMPTY + // sources for unchanged files. v2 has NO Rust-side cache (DEF-7), so + // re-hydrate empty sources from the file cache before analyze. + rehydrateFilesJson: (filesJsonRaw) => { + if (!filesJsonRaw.includes('"source":""')) return filesJsonRaw; + const entries = JSON.parse(filesJsonRaw) as Array<{ + path: string; + source: string; + hash?: string; + }>; + for (const entry of entries) { + if (entry.source === '') { + entry.source = this.fileCache.get(entry.path)?.source ?? ''; + } + } + return JSON.stringify(entries); + }, + store: { + getEngine: () => this.v2Engine, + setEngine: (engine) => { + this.v2Engine = engine; + }, + getSentSources: () => this.v2SentSources, + setSentSources: (sources) => { + this.v2SentSources = sources; + }, + getDriftWarned: () => this.v2DriftWarned, + setDriftWarned: (value) => { + this.v2DriftWarned = value; + }, + }, + }); + } + + log(msg: string): void { + if (this.verbose) { + (this.logger ?? console).info(`[animus] ${msg}`); + } + } + + warn(msg: string): void { + (this.logger ?? console).warn(`[animus] ${msg}`); + } + + logTimingWaterfall(timing: Record): void { + if (!this.verbose) return; + for (const line of formatRustTimingWaterfall(timing, { + indent: ' ', + labelWidth: 15, + })) { + this.log(line); + } + } + + /** + * Load a SystemInstance via Rust NAPI (rquickjs bundled eval) into + * `this.system`. On failure the previous config is kept (strict mode + * throws instead). + */ + loadSystem(): void { + this.resolvedSystemPath = resolve(this.rootDir, this.options.system); + + try { + this.system = loadSystemConfig(this.engineApi, { + systemPath: this.resolvedSystemPath, + rootDir: this.rootDir, + prefix: this.options.prefix, + }); + } catch (e) { + if (this.options.strict) { + throw new Error( + `[animus-extract] Failed to load system from ${this.resolvedSystemPath}: ${e}`, + { cause: e } + ); + } + console.warn( + `[animus-extract] Failed to load system from ${this.resolvedSystemPath}:`, + e + ); + } + } + + /** + * Run project analysis via the shared `runProjectAnalysis` and update + * all manifest-derived state. + */ + runAnalysis( + fileEntries: Array<{ path: string; source: string; hash?: string }> + ): void { + try { + const result = runProjectAnalysis(this.engineApi, { + fileEntries, + packageMap: this.packageMap, + system: this.system, + emitter: { + runtimeImport: '@animus-ui/system', + cssModuleId: VIRTUAL_CSS_ID, + }, + pathAliasesJson: this.pathAliasesJson, + devMode: !this.isProd, + warn: (m) => this.warn(m), + }); + + this.storedManifest = result.manifest; + this.storedManifestJson = result.manifestJson; + + this.storedSystemPropMapJson = JSON.stringify( + result.manifest?.system_prop_map ?? {} + ); + this.storedDynamicPropsJson = JSON.stringify( + result.manifest?.dynamic_props ?? {} + ); + + // Reset bridge injection so the next transform pass re-injects it. + this.bridgeInjected = false; + + // Update per-component fragment cache from manifest + const newFragments = result.manifest?.component_fragments; + if (newFragments && typeof newFragments === 'object') { + this.fragmentCache.clear(); + for (const [id, sheets] of Object.entries(newFragments)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.fragmentCache.set(id, sheets as any); + } + } + + // Update reverse provenance for transitive invalidation + this.reverseProvenance = result.manifest?.reverse_provenance ?? {}; + + // Store structured sheets for dev split delivery + this.storedSheets = result.manifest?.sheets ?? null; + + this.globalCss = result.globalCss; + this.resolvedComponentCss = result.componentCss; + } catch (e) { + if (this.options.strict) { + throw new Error(`[animus-extract] analyzeProject failed: ${e}`, { + cause: e, + }); + } + console.warn('[animus-extract] analyzeProject failed:', e); + } + } + + runSelfVerify(): void { + const failures: string[] = []; + + if (Object.keys(this.storedManifest?.components ?? {}).length === 0) { + failures.push( + 'No component CSS produced — check system file and include patterns' + ); + } + + if (!this.system.variableCss.includes(':root')) { + failures.push('No :root variable block found in variable CSS'); + } + + const combined = `${this.system.variableCss}\n${this.globalCss}\n${this.resolvedComponentCss}`; + if (combined.includes('__TRANSFORM__')) { + failures.push( + 'Unresolved __TRANSFORM__ placeholders found in CSS output' + ); + } + + if (this.storedManifest && this.resolvedComponentCss.length > 0) { + const assembled = assembleStylesheet({ + layers: this.options.layers, + variableCss: this.system.variableCss, + globalCss: this.globalCss, + componentCss: this.resolvedComponentCss, + }); + const baseIdx = assembled.search(/@layer\s+anm-base\s*\{/); + const variantsIdx = assembled.search(/@layer\s+anm-variants\s*\{/); + if (baseIdx !== -1 && variantsIdx !== -1 && baseIdx >= variantsIdx) { + failures.push( + `CSS layer ordering violated — @layer anm-base (offset ${baseIdx}) must precede @layer anm-variants (offset ${variantsIdx})` + ); + } + } + + for (const message of failures) { + const line = `[animus:verify] ${message}`; + if (this.options.strict) { + throw new Error(line); + } + if (this.logger) { + this.logger.warn(line, { timestamp: true }); + } else { + console.warn(line); + } + } + + if (failures.length === 0) { + this.log('[animus:verify] structural self-check passed'); + } + } +} diff --git a/packages/vite-plugin/src/css.ts b/packages/vite-plugin/src/css.ts new file mode 100644 index 00000000..87dffefa --- /dev/null +++ b/packages/vite-plugin/src/css.ts @@ -0,0 +1,65 @@ +import browserslist from 'browserslist'; +// Lightning CSS: CSS post-processing (minification + autoprefixing) +import { + browserslistToTargets, + transform as lcssTransform, +} from 'lightningcss'; + +export type LightningTargets = ReturnType; + +/** + * Resolve browser targets for Lightning CSS. + * Priority: explicit config → project browserslist → 'defaults' fallback. + */ +export function resolveLightningTargets( + explicitTargets: string | string[] | undefined, + rootDir: string +): LightningTargets { + let queries: string[]; + if (explicitTargets) { + queries = Array.isArray(explicitTargets) + ? explicitTargets + : [explicitTargets]; + } else { + // Auto-detect from project's browserslist config + const detected = browserslist(undefined, { path: rootDir }); + // browserslist() with undefined query uses the project's config or defaults + queries = detected.length > 0 ? detected : browserslist('defaults'); + } + return browserslistToTargets( + Array.isArray(queries) && + typeof queries[0] === 'string' && + queries[0].includes(' ') + ? browserslist(queries) + : (queries as ReturnType) + ); +} + +/** + * Post-process CSS with Lightning CSS: autoprefixing + optional minification. + * On failure, returns the original CSS and logs a warning. + */ +export function postProcessCss( + css: string, + opts: { + minify: boolean; + targets: LightningTargets; + warnFn?: (msg: string) => void; + } +): string { + if (!css) return css; + try { + const result = lcssTransform({ + filename: 'animus-extracted.css', + code: Buffer.from(css), + minify: opts.minify, + targets: opts.targets, + }); + return result.code.toString(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + const warnFn = opts.warnFn ?? console.warn; + warnFn(`[animus] Lightning CSS post-processing failed: ${msg}`); + return css; + } +} diff --git a/packages/vite-plugin/src/hmr.ts b/packages/vite-plugin/src/hmr.ts new file mode 100644 index 00000000..66f6b412 --- /dev/null +++ b/packages/vite-plugin/src/hmr.ts @@ -0,0 +1,252 @@ +import { + clearEngineCache, + contentHash, + preprocessMdx, +} from '@animus-ui/extract/pipeline'; +import { readFileSync } from 'fs'; +import { extname, relative, resolve, sep } from 'path'; + +import { + DEFAULT_EXCLUDE, + RESOLVED_COMPONENTS_ID, + RESOLVED_CSS_ID, + RESOLVED_SYSTEM_PROPS_ID, +} from './constants'; +import { buildFileEntriesFromCache } from './context'; + +import type { PluginContext } from './context'; +import type { HmrContext, ModuleNode } from 'vite'; + +/** + * handleHotUpdate: content-hash diffing, geological reset on system-file + * change, incremental re-analysis, and targeted module invalidation + * (component CSS, system props, and definition files whose replacement + * changed). + */ +export async function handleHotUpdate( + ctx: PluginContext, + { file, server: hmrServer, modules }: HmrContext +): Promise { + // Only active in dev mode + if (ctx.isProd) return; + + const ext = extname(file); + if (!ctx.extensionsSet.has(ext)) return; + + const excludePatterns = ctx.options.exclude ?? DEFAULT_EXCLUDE; + // Boundary-safe match: `/pkgs/ui` must not claim `/pkgs/ui-icons/*`. + const isExternalPkg = ctx.externalPackageDirs.some( + (dir) => file.startsWith(dir + sep) || file === dir + ); + if ( + !isExternalPkg && + excludePatterns.some( + (pattern) => + file.includes(pattern) || + relative(ctx.rootDir, file).includes(pattern) + ) + ) { + return; + } + + const absFile = resolve(file); + const relPath = relative(ctx.rootDir, absFile); + + // Geological reset: system file changed + const isSystemChange = + ctx.resolvedSystemPath && absFile === resolve(ctx.resolvedSystemPath); + + if (isSystemChange) { + const resetStart = performance.now(); + ctx.log(`HMR geological reset: ${relPath}`); + ctx.loadSystem(); + + // Clear Rust-side per-file cache before full re-analysis + clearEngineCache(ctx.engineApi); + + // Full re-extraction with all cached files. + // Must send full sources — Rust cache was just cleared, so all files + // are cache misses and need real source text for OXC parsing. + const fileEntries: Array<{ + path: string; + source: string; + hash: string; + }> = []; + for (const [path, { hash, source }] of ctx.fileCache) { + fileEntries.push({ path, source, hash }); + } + ctx.runAnalysis(fileEntries); + + ctx.log( + `HMR geological reset complete: ${Math.round(performance.now() - resetStart)}ms` + ); + + // Geological reset: invalidate BOTH static CSS (vars/globals changed) AND + // component CSS (bridge needs fresh component CSS too) + const geologicalModules = [...modules]; + const cssModule = hmrServer.moduleGraph.getModuleById(RESOLVED_CSS_ID); + if (cssModule) { + hmrServer.moduleGraph.invalidateModule(cssModule); + geologicalModules.push(cssModule); + } + const compModule = hmrServer.moduleGraph.getModuleById( + RESOLVED_COMPONENTS_ID + ); + if (compModule) { + hmrServer.moduleGraph.invalidateModule(compModule); + geologicalModules.push(compModule); + } + const sysPropModule = hmrServer.moduleGraph.getModuleById( + RESOLVED_SYSTEM_PROPS_ID + ); + if (sysPropModule) { + hmrServer.moduleGraph.invalidateModule(sysPropModule); + geologicalModules.push(sysPropModule); + } + return geologicalModules; + } + + // Content-hash check: skip if unchanged + let source: string; + try { + source = readFileSync(absFile, 'utf-8'); + } catch { + return; + } + + // Preprocess MDX sources on HMR the same way buildStart does. + // Note: `relPath` is rewritten to end with `.tsx` so the Rust source-type + // helper parses the preprocessed output as tsx — matching buildStart. + let scannerRelPath = relPath; + if (ext === '.mdx') { + const result = await preprocessMdx(source, relPath); + if (result.kind === 'missing-dep') { + ctx.warn( + '⚠ .mdx HMR skipped: @mdx-js/mdx not installed; restart dev server after installing' + ); + return; + } + if (result.kind === 'error') { + ctx.warn(`⚠ MDX preprocessing failed for ${relPath}: ${result.error}`); + return; + } + source = result.source!; + scannerRelPath = relPath + '.tsx'; + } + + const hash = contentHash(source); + const cached = ctx.fileCache.get(scannerRelPath); + if (cached && cached.hash === hash) { + ctx.log(`HMR skip: ${scannerRelPath} (unchanged)`); + return []; + } + + // Update cache entry + ctx.fileCache.set(scannerRelPath, { hash, source }); + + const hmrStart = performance.now(); + + // Snapshot previous replacements for invalidation diffing + const prevReplacements = new Map(); + if (ctx.storedManifest?.components) { + for (const [id, desc] of Object.entries(ctx.storedManifest.components)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prevReplacements.set(id, (desc as any).replacement ?? ''); + } + } + + // Identify directly affected component_ids from the changed file + const directComponentIds: string[] = + ctx.storedManifest?.files?.[scannerRelPath] ?? []; + // Compute transitive invalidation set via reverse_provenance BFS + const invalidatedIds = new Set(directComponentIds); + const queue = [...directComponentIds]; + while (queue.length > 0) { + const parentId = queue.shift()!; + const children = ctx.reverseProvenance[parentId]; + if (children) { + for (const childId of children) { + if (!invalidatedIds.has(childId)) { + invalidatedIds.add(childId); + queue.push(childId); + } + } + } + } + + if (ctx.verbose && invalidatedIds.size > directComponentIds.length) { + ctx.log( + `HMR: ${directComponentIds.length} direct + ${invalidatedIds.size - directComponentIds.length} transitive components invalidated` + ); + } + + // Rebuild file entries from cache and re-run analysis. + // Pass changedPath so unchanged files send empty source (skip JSON serialization). + const analysisStart = performance.now(); + const fileEntries = buildFileEntriesFromCache(ctx.fileCache, relPath); + ctx.runAnalysis(fileEntries); + const analysisMs = Math.round(performance.now() - analysisStart); + + const modulesToUpdate = [...modules]; + + // Invalidate component CSS module (adopted stylesheet in dev, CSS in prod) + // Static CSS (virtual:animus/styles.css) is NOT invalidated here — + // it only changes on geological reset (vars/globals are stable during dev) + const compModule = hmrServer.moduleGraph.getModuleById( + RESOLVED_COMPONENTS_ID + ); + if (compModule) { + hmrServer.moduleGraph.invalidateModule(compModule); + modulesToUpdate.push(compModule); + } + + // Invalidate shared system prop map when utility classes change + const sysPropModule = hmrServer.moduleGraph.getModuleById( + RESOLVED_SYSTEM_PROPS_ID + ); + if (sysPropModule) { + hmrServer.moduleGraph.invalidateModule(sysPropModule); + modulesToUpdate.push(sysPropModule); + } + + // Invalidate definition files where component replacement changed. + // Simple string comparison — if the replacement string differs at all + // (including systemProps), the definition file needs re-transforming. + if (ctx.storedManifest?.components) { + const staleFiles = new Set(); + for (const [id, desc] of Object.entries(ctx.storedManifest.components)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newReplacement = (desc as any).replacement ?? ''; + const oldReplacement = prevReplacements.get(id) ?? ''; + if (newReplacement !== oldReplacement) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + staleFiles.add((desc as any).file); + } + } + + for (const defFile of staleFiles) { + const absDefPath = resolve(ctx.rootDir, defFile); + if (absDefPath === absFile) continue; + const defModule = + hmrServer.moduleGraph.getModuleById(absDefPath) ?? + hmrServer.moduleGraph.getModulesByFile(absDefPath)?.values().next() + .value; + if (defModule) { + ctx.log(`HMR invalidate: ${defFile} (replacement changed)`); + hmrServer.moduleGraph.invalidateModule(defModule); + modulesToUpdate.push(defModule); + } + } + } + + const hmrMs = Math.round(performance.now() - hmrStart); + const invalidated = modulesToUpdate.length - modules.length; + ctx.log( + `HMR update: ${relPath} — analysis ${analysisMs}ms, ${invalidated} modules invalidated, total ${hmrMs}ms` + ); + ctx.logTimingWaterfall(ctx.storedManifest?.timing ?? {}); + + if (modulesToUpdate.length > modules.length) { + return modulesToUpdate; + } +} diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 538dedac..51ab1135 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -1,34 +1,13 @@ -import { - applyUnitFallback, - assembleStylesheet, - assertNoRetiredEngineSelection, - buildAnalyzeProjectArgs, - buildDynamicPropConfig, - buildPathAliasesJson, - collectExternalPackageSources, - contentHash, - createV2EngineApi, - DEFAULT_EXTENSIONS, - discoverFiles, - extractSystemFilePackages, - formatRustTimingWaterfall, - preprocessMdx, - stripLeadingLayerDeclaration, - surfaceManifestDiagnostics, - validateLayerOrder, -} from '@animus-ui/extract/pipeline'; -import browserslist from 'browserslist'; -import { createHash } from 'crypto'; -import { readFileSync } from 'fs'; -// Lightning CSS: CSS post-processing (minification + autoprefixing) -import { - browserslistToTargets, - transform as lcssTransform, -} from 'lightningcss'; -import { extname, relative, resolve } from 'path'; +import { assertNoRetiredEngineSelection } from '@animus-ui/extract/pipeline'; -import type { V2ExtractEngine } from '@animus-ui/extract/pipeline'; -import type { Logger, Plugin } from 'vite'; +import { runBuildStart } from './build-start'; +import { applyResolvedConfig } from './config'; +import { PluginContext } from './context'; +import { handleHotUpdate } from './hmr'; +import { transformSource } from './transform'; +import { loadVirtualModule, resolveVirtualId } from './virtual-modules'; + +import type { Plugin } from 'vite'; export { discoverFiles } from '@animus-ui/extract/pipeline'; @@ -79,6 +58,11 @@ export interface AnimusExtractOptions { * - `undefined` (default): minify in prod only */ minify?: boolean; + /** + * Namespace prefix for CSS variables and class names, applied to the + * variable map/css (and theme + contextual vars) at system load. + */ + prefix?: string; /** * Full `@layer` declaration order. Must include all 7 Animus `anm-*` layers * as a subsequence in their required order. Consumer layers may be @@ -98,1243 +82,67 @@ export interface AnimusExtractOptions { engine?: 'v2'; } -const VIRTUAL_CSS_ID = 'virtual:animus/styles.css'; -const RESOLVED_CSS_ID = '\0virtual:animus/styles.css'; - -const VIRTUAL_COMPONENTS_ID = 'virtual:animus/components.js'; -const RESOLVED_COMPONENTS_ID = '\0virtual:animus/components.js'; - -const VIRTUAL_BRIDGE_ID = 'virtual:animus/hmr-bridge.js'; -const RESOLVED_BRIDGE_ID = '\0virtual:animus/hmr-bridge.js'; - -const VIRTUAL_SYSTEM_PROPS_ID = 'virtual:animus/system-props'; -const RESOLVED_SYSTEM_PROPS_ID = '\0virtual:animus/system-props'; - -const DEFAULT_EXCLUDE = ['node_modules', 'dist', '.test.', '.spec.']; - -/** - * Resolve browser targets for Lightning CSS. - * Priority: explicit config → project browserslist → 'defaults' fallback. - */ -function resolveLightningTargets( - explicitTargets: string | string[] | undefined, - rootDir: string -): ReturnType { - let queries: string[]; - if (explicitTargets) { - queries = Array.isArray(explicitTargets) - ? explicitTargets - : [explicitTargets]; - } else { - // Auto-detect from project's browserslist config - const detected = browserslist(undefined, { path: rootDir }); - // browserslist() with undefined query uses the project's config or defaults - queries = detected.length > 0 ? detected : browserslist('defaults'); - } - return browserslistToTargets( - Array.isArray(queries) && - typeof queries[0] === 'string' && - queries[0].includes(' ') - ? browserslist(queries) - : (queries as ReturnType) - ); -} - -/** - * Post-process CSS with Lightning CSS: autoprefixing + optional minification. - * On failure, returns the original CSS and logs a warning. - */ -function postProcessCss( - css: string, - opts: { - minify: boolean; - targets: ReturnType; - warnFn?: (msg: string) => void; - } -): string { - if (!css) return css; - try { - const result = lcssTransform({ - filename: 'animus-extracted.css', - code: Buffer.from(css), - minify: opts.minify, - targets: opts.targets, - }); - return result.code.toString(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - const warnFn = opts.warnFn ?? console.warn; - warnFn(`[animus] Lightning CSS post-processing failed: ${msg}`); - return css; - } -} - /** - * Reconstruct file entries from cache, including content hashes. - * For unchanged files (hash matches changedPath), sends empty source - * to avoid serializing full source text across the NAPI boundary. - * Rust cache-hit path never reads file.source, so empty string is safe. + * Vite adapter for the extraction pipeline. State and pipeline operations + * live in PluginContext; hook bodies live in their own modules — this + * factory only validates options and wires Vite hooks to those functions. */ -function buildFileEntriesFromCache( - cache: Map, - changedPath?: string -): Array<{ path: string; source: string; hash: string }> { - const entries: Array<{ path: string; source: string; hash: string }> = []; - for (const [path, { hash, source }] of cache) { - entries.push({ - path, - source: path === changedPath ? source : '', - hash, - }); - } - return entries; -} - export function animusExtract(options: AnimusExtractOptions): Plugin { // v2 is the only engine (openspec: retire-extract-v1). Reject a retired v1 // selection loudly before any engine work — the option type no longer admits // 'v1', so cast to string to still catch a stale config at runtime. assertNoRetiredEngineSelection(options.engine as string | undefined); - // Single engine choke-point: every native extraction call resolves its - // module through here. - // The package root IS the v2 engine since retire-extract-v1. - const engineModuleId = '@animus-ui/extract'; - const requireEngine = () => require(engineModuleId); - // Per-PLUGIN-INSTANCE v2 engine state (DEF-1: no module-level engine — - // two differently-configured plugins in one process must not share state). - // The engine handle is rebuilt on every runAnalysis and nulled on cache - // clears; the analyze-time sources feed drift detection; the one-shot drift - // flag lives alongside them. All three stay in closure variables (vite is - // not process-singleton, unlike next which backs the same store with - // globalThis for the ESM/CJS double-load). - let v2Engine: V2ExtractEngine | null = null; - let v2SentSources: Map | null = null; - let v2DriftWarned = false; - // Adapt the function API onto the stateful v2 handle via the single - // authoritative factory in @animus-ui/extract/pipeline (shared with - // next-plugin). loadSystemModule is exported by the engine-neutral Rust - // crate. - const engineApi = createV2EngineApi({ - label: 'animus-extract', - isV2: () => true, - loadNativeEngine: requireEngine, - // A cache-aware caller (buildFileEntriesFromCache) may send EMPTY sources - // for unchanged files. v2 has NO Rust-side cache (DEF-7: uncached - // re-analysis beats a cache-hit path), so re-hydrate empty sources from the - // plugin's own file cache before analyze. - rehydrateFilesJson: (filesJsonRaw) => { - if (!filesJsonRaw.includes('"source":""')) return filesJsonRaw; - const entries = JSON.parse(filesJsonRaw) as Array<{ - path: string; - source: string; - hash?: string; - }>; - for (const entry of entries) { - if (entry.source === '') { - entry.source = fileCache.get(entry.path)?.source ?? ''; - } - } - return JSON.stringify(entries); - }, - store: { - getEngine: () => v2Engine, - setEngine: (engine) => { - v2Engine = engine; - }, - getSentSources: () => v2SentSources, - setSentSources: (sources) => { - v2SentSources = sources; - }, - getDriftWarned: () => v2DriftWarned, - setDriftWarned: (value) => { - v2DriftWarned = value; - }, - }, - }); - - let themeJson = '{}'; - let variableMapJson = '{}'; - let contextualVarsJson = '{}'; - let configJson = '{}'; - let groupRegistryJson = '{}'; - let selectorAliasesJson: string | null = null; - let isProd = false; - let rootDir = ''; - - // Diagnostics - const verbose = - options.verbose || - process.env.ANIMUS_DEBUG === '1' || - process.env.ANIMUS_DEBUG === 'true'; - let logger: Logger | null = null; - - function log(msg: string): void { - if (verbose) { - (logger ?? console).info(`[animus] ${msg}`); - } - } - - function warn(msg: string): void { - (logger ?? console).warn(`[animus] ${msg}`); - } - - function logTimingWaterfall(timing: Record): void { - if (!verbose) return; - for (const line of formatRustTimingWaterfall(timing, { - indent: ' ', - labelWidth: 15, - })) { - log(line); - } - } - - // Lightning CSS: resolved browser targets (computed once at configResolved) - let lcssTargets: ReturnType = {}; - - // CSS variable declarations derived from the theme (emitted before component CSS) - let variableCss = ''; - - // Resolved CSS from .withGlobalStyles({ reset, global }) — emitted in @layer anm-global { } - // Populated from manifest.sheets.global after Rust-side resolution. - let globalCss = ''; - - // Raw global style blocks JSON — passed to analyzeProject for Rust-side resolution. - let globalStyleBlocksJson: string | null = null; - - // Raw keyframes blocks JSON — keyframes() factory exports discovered via __brand. - let keyframesBlocksJson: string | null = null; - - // Serialized path aliases from host bundler's resolve.alias config. - // Forwarded to Rust for cross-file binding resolution of aliased imports. - let pathAliasesJson: string | null = null; - - // File extensions — computed once at buildStart from options.extensions. - // HMR filter uses this same Set so that consumer-configured extensions - // apply consistently across buildStart discovery AND per-file HMR. - let extensionsSet: ReadonlySet = new Set( - options.extensions ?? DEFAULT_EXTENSIONS - ); - - // Manifest state — populated at buildStart, consumed during transform and load - let storedManifest: any = null; - let storedManifestJson = ''; - - // Pre-resolved CSS with transforms applied (avoids re-resolving in load hook) - let resolvedComponentCss = ''; - - // Structured per-layer CSS sheets from the Rust crate (dev split delivery). - // Mirrors the `CssSheets` struct in packages/extract/src/css_generator.rs — - // keep these fields in sync. The `compounds` field was previously missing - // from this type (fixed during integration-test-infrastructure §7 investigation); - // compound CSS was still delivered via `resolvedComponentCss` but the per-layer - // split representation was incomplete. - let storedSheets: { - declaration: string; - global: string; - base: string; - variants: string; - compounds: string; - states: string; - system: string; - custom: string; - } | null = null; - - // @layer declaration for HTML injection via transformIndexHtml. - // Computed once at buildStart from options.layers (config-time, static). - let layerDeclaration = ''; - - // Per-component CSS fragment cache for incremental HMR - // component_id → { base?, variants?, compounds?, states? } - let fragmentCache = new Map< - string, - { base?: string; variants?: string; compounds?: string; states?: string } - >(); - - // Reverse provenance: parent_id → [child_ids] for transitive invalidation - let reverseProvenance: Record = {}; - - // Shared system prop map JSON (group props only, served as virtual module) - let storedSystemPropMapJson = '{}'; - - // Dynamic prop config JSON (props with detected dynamic usage) - let storedDynamicPropsJson = '{}'; - - // Serialized transform functions for dynamic props (only transforms used by dynamic props) - let storedTransformsSource = '{}'; - - // Content-hash file cache for dev HMR (path → { hash, source }) - const fileCache = new Map(); - - // Package resolution map built at buildStart (reused during HMR) - let packageMap: Record = {}; - - // Absolute directory prefixes for external DS packages (for transform/HMR allowlisting) - let externalPackageDirs: string[] = []; - - // Map of external package specifier → absolute source entry path (for resolveId redirection) - const externalSourceEntries = new Map(); - - // Dev server reference for programmatic module invalidation (set via configureServer) - let devServer: any; - - // Whether the HMR bridge import has been injected into a transformed file (dev only, one-time) - let bridgeInjected = false; - - // Resolved system module path for geological reset detection - let resolvedSystemPath: string | null = null; - - /** - * Load a SystemInstance via Rust NAPI (rquickjs bundled eval). - * The Rust crate reads the system file, strips TS types via OXC, resolves - * workspace package dependencies, bundles all modules, evaluates with - * rquickjs, and returns serialized config directly — no subprocess needed. - */ - function loadSystem(): void { - resolvedSystemPath = resolve(rootDir, options.system); - - try { - const { loadSystemModule } = engineApi(); - const config = loadSystemModule(resolvedSystemPath, rootDir); - - configJson = config.propConfig; - groupRegistryJson = config.groupRegistry; - selectorAliasesJson = config.selectorAliases || null; - themeJson = config.scalesJson; - variableMapJson = config.variableMapJson; - variableCss = config.variableCss; - contextualVarsJson = config.contextualVarsJson; - globalStyleBlocksJson = config.globalStyleBlocks || null; - keyframesBlocksJson = config.keyframesBlocks || null; - } catch (e) { - if (options.strict) { - throw new Error( - `[animus-extract] Failed to load system from ${resolvedSystemPath}: ${e}`, - { cause: e } - ); - } - console.warn( - `[animus-extract] Failed to load system from ${resolvedSystemPath}:`, - e - ); - } - } - - /** - * Run project analysis and update the manifest. - * - * Pipeline: NAPI analyzeProject → unit fallback. - * Transforms are resolved in-process via boa_engine during Rust extraction. - * No subprocess needed. - */ - function runAnalysis( - fileEntries: Array<{ path: string; source: string; hash?: string }> - ): void { - try { - const { analyzeProject } = engineApi(); - const emitterConfig = JSON.stringify({ - runtime_import: '@animus-ui/system', - css_module_id: 'virtual:animus/styles.css', - }); - const manifestJson = analyzeProject( - ...buildAnalyzeProjectArgs({ - filesJson: JSON.stringify(fileEntries), - scalesJson: themeJson, - variableMapJson, - contextualVarsJson: contextualVarsJson || null, - propConfigJson: configJson, - groupRegistryJson, - packageResolutionJson: JSON.stringify(packageMap), - devMode: !isProd, - emitterConfigJson: emitterConfig, - selectorAliasesJson, - globalStyleBlocksJson, - pathAliasesJson, - keyframesJson: keyframesBlocksJson, - }) - ); - - storedManifest = JSON.parse(manifestJson); - storedManifestJson = manifestJson; - surfaceManifestDiagnostics(storedManifest, warn); - - // Extract shared system prop map from manifest - const newSystemPropMapJson = JSON.stringify( - storedManifest?.system_prop_map ?? {} - ); - storedSystemPropMapJson = newSystemPropMapJson; - - // Extract dynamic prop config from manifest - const dynamicProps = storedManifest?.dynamic_props ?? {}; - const newDynamicPropsJson = JSON.stringify(dynamicProps); - - if (newDynamicPropsJson !== storedDynamicPropsJson) { - storedDynamicPropsJson = newDynamicPropsJson; - - // Build dynamicPropConfig with transformName strings (not bound functions) - // Also serialize only the transforms used by dynamic props - const usedTransformNames = new Set(); - for (const meta of Object.values(dynamicProps) as any[]) { - if (meta.transform_name) { - usedTransformNames.add(meta.transform_name); - } - } - - // Also discover transforms used by custom prop dynamic configs - // These are embedded as transforms.{name} in component replacement strings - if (storedManifest?.components) { - for (const comp of Object.values( - storedManifest.components - ) as any[]) { - if (comp.replacement) { - const matches = comp.replacement.matchAll(/transforms\.(\w+)/g); - for (const match of matches) { - usedTransformNames.add(match[1]); - } - } - } - } - - // Transform function serialization for dynamic props is not yet - // supported — transforms are resolved at extraction time via boa_engine - // in Rust, not at runtime. Dynamic props with transforms will use raw values. - storedTransformsSource = '{}'; - } - - // Reset bridge injection flag so the next transform pass re-injects it. - bridgeInjected = false; - - // Update per-component fragment cache from manifest - const newFragments = storedManifest?.component_fragments; - if (newFragments && typeof newFragments === 'object') { - fragmentCache.clear(); - for (const [id, sheets] of Object.entries(newFragments)) { - fragmentCache.set(id, sheets as any); - } - } - - // Update reverse provenance for transitive invalidation - reverseProvenance = storedManifest?.reverse_provenance ?? {}; - - // Store structured sheets for dev split delivery - storedSheets = storedManifest?.sheets ?? null; - - // Populate globalCss from Rust-resolved sheets - globalCss = storedManifest?.sheets?.global || ''; - - // CSS from Rust is fully resolved — transforms evaluated in-process via boa_engine. - resolvedComponentCss = storedManifest?.css || ''; - - // Apply unit fallback: bare numerics on length properties get `px` - resolvedComponentCss = applyUnitFallback(resolvedComponentCss); - } catch (e) { - if (options.strict) { - throw new Error(`[animus-extract] analyzeProject failed: ${e}`, { - cause: e, - }); - } - console.warn('[animus-extract] analyzeProject failed:', e); - } - } - - function runSelfVerify(): void { - const failures: string[] = []; - - if (Object.keys(storedManifest?.components ?? {}).length === 0) { - failures.push( - 'No component CSS produced — check system file and include patterns' - ); - } - - if (!variableCss.includes(':root')) { - failures.push('No :root variable block found in variable CSS'); - } - const combined = `${variableCss}\n${globalCss}\n${resolvedComponentCss}`; - if (combined.includes('__TRANSFORM__')) { - failures.push( - 'Unresolved __TRANSFORM__ placeholders found in CSS output' - ); - } - - if (storedManifest && resolvedComponentCss.length > 0) { - const assembled = assembleStylesheet({ - layers: options.layers, - variableCss, - globalCss, - componentCss: resolvedComponentCss, - }); - const baseIdx = assembled.search(/@layer\s+anm-base\s*\{/); - const variantsIdx = assembled.search(/@layer\s+anm-variants\s*\{/); - if (baseIdx !== -1 && variantsIdx !== -1 && baseIdx >= variantsIdx) { - failures.push( - `CSS layer ordering violated — @layer anm-base (offset ${baseIdx}) must precede @layer anm-variants (offset ${variantsIdx})` - ); - } - } - - for (const message of failures) { - const line = `[animus:verify] ${message}`; - if (options.strict) { - throw new Error(line); - } - if (logger) { - logger.warn(line, { timestamp: true }); - } else { - console.warn(line); - } - } - - if (failures.length === 0) { - log('[animus:verify] structural self-check passed'); - } - } + const ctx = new PluginContext(options); return { name: 'animus-extract', enforce: 'pre', configureServer(server) { - devServer = server; + ctx.devServer = server; }, configResolved(config) { - isProd = config.command === 'build'; - rootDir = config.root; - logger = config.logger; - - // Resolve Lightning CSS browser targets once - lcssTargets = resolveLightningTargets(options.targets, rootDir); - log( - `Lightning CSS targets resolved (${Object.keys(lcssTargets).length} browsers)` - ); - - // Extract path aliases from Vite's resolved config. - // This includes aliases from vite-tsconfig-paths, manual resolve.alias, etc. - const rawAlias = config.resolve?.alias; - if (rawAlias) { - const pairs: Array<{ - pattern: string; - target: string; - kind?: 'prefix'; - }> = []; - - if (Array.isArray(rawAlias)) { - // Array format: [{ find: string | RegExp, replacement: string }] - // String finds are always prefix matches — no extension sniffing. - for (const entry of rawAlias) { - if ( - typeof entry.find === 'string' && - typeof entry.replacement === 'string' - ) { - pairs.push({ - pattern: entry.find, - target: entry.replacement, - kind: 'prefix', - }); - } - } - } else if (typeof rawAlias === 'object' && rawAlias !== null) { - // Record format: { '@admin': '/abs/path/to/src' } - for (const [key, value] of Object.entries(rawAlias)) { - if (typeof value === 'string') { - pairs.push({ pattern: key, target: value }); - } - } - } - - const built = buildPathAliasesJson(pairs, rootDir); - if (built) { - pathAliasesJson = built.json; - log(`Path aliases forwarded: ${built.count} entries`); - } - } + applyResolvedConfig(ctx, config); }, async buildStart() { - // Clear Rust-side per-file cache so stale results from a prior - // server lifecycle never bleed into a fresh build/dev start. - try { - const { clearAnalysisCache } = engineApi(); - clearAnalysisCache(); - } catch { - // clearAnalysisCache may not exist in older builds - } - - // 1. Load system: config, theme, transforms, global styles - let t0 = performance.now(); - loadSystem(); - - // Validate layer ordering - if (options.layers) { - validateLayerOrder(options.layers); - log(`Custom layers: [${options.layers.join(', ')}]`); - } - - if (verbose) { - const propCount = Object.keys(JSON.parse(configJson)).length; - const groupCount = Object.keys(JSON.parse(groupRegistryJson)).length; - log( - `System loaded: ${propCount} props, ${groupCount} groups (${Math.round(performance.now() - t0)}ms)` - ); - } - - // 3. Discover source files via recursive directory walk - t0 = performance.now(); - const excludePatterns = options.exclude ?? DEFAULT_EXCLUDE; - // Refresh the hoisted `extensionsSet` in case `options` was mutated between - // server lifecycles. Source of truth remains `options.extensions ?? DEFAULT_EXTENSIONS`. - extensionsSet = new Set(options.extensions ?? DEFAULT_EXTENSIONS); - const shouldHandleMdx = extensionsSet.has('.mdx'); - let mdxMissingDepWarned = false; - const filePaths = discoverFiles( - rootDir, - rootDir, - excludePatterns, - extensionsSet - ); - - // 4. Read all file sources and build file entries (preprocessing MDX as we go) - const fileEntries: Array<{ - path: string; - source: string; - hash?: string; - }> = []; - for (const filePath of filePaths) { - try { - let source = readFileSync(filePath, 'utf-8'); - let relPath = relative(rootDir, filePath); - - if (shouldHandleMdx && extname(filePath) === '.mdx') { - const result = await preprocessMdx(source, relPath); - if (result.kind === 'missing-dep') { - if (!mdxMissingDepWarned) { - warn( - '⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped' - ); - mdxMissingDepWarned = true; - } - continue; - } - if (result.kind === 'error') { - warn( - `⚠ MDX preprocessing failed for ${relPath}: ${result.error}` - ); - continue; - } - source = result.source!; - // Append `.tsx` so the Rust source-type helper treats the - // MDX-preprocessed output as tsx for parsing. The `.mdx.tsx` - // tail preserves the original extension in diagnostic output. - relPath = relPath + '.tsx'; - } - - const hash = !isProd ? contentHash(source) : undefined; - fileEntries.push({ path: relPath, source, hash }); - - // Populate file cache for dev HMR - if (!isProd && hash) { - fileCache.set(relPath, { hash, source }); - } - } catch { - // Skip unreadable files silently - } - } - - // 5. Discover external packages from system entry file imports and resolve them - const localFileCount = fileEntries.length; - const packageSpecifiers = extractSystemFilePackages(resolvedSystemPath!); - - externalSourceEntries.clear(); - - // Shared traversal/ingest (spec: external-package-file-discovery); - // only specifier resolution, MDX handling, and the hash/cache policy - // below stay bundler-specific. - const collected = await collectExternalPackageSources({ - specifiers: packageSpecifiers, - resolveSpecifier: async (specifier) => { - const resolved = await this.resolve(specifier); - return resolved?.id ?? null; - }, - rootDir, - extensionsSet, - hasEntry: (relPath) => fileEntries.some((e) => e.path === relPath), - preprocessFile: async (source, relPath, absPath) => { - if (shouldHandleMdx && extname(absPath) === '.mdx') { - const result = await preprocessMdx(source, relPath); - if (result.kind === 'missing-dep') { - if (!mdxMissingDepWarned) { - warn( - '⚠ .mdx in extensions but @mdx-js/mdx not installed; MDX files skipped' - ); - mdxMissingDepWarned = true; - } - return null; - } - if (result.kind === 'error') { - warn( - `⚠ MDX preprocessing failed for ${relPath}: ${result.error}` - ); - return null; - } - return { source: result.source!, relPath: relPath + '.tsx' }; - } - return { source, relPath }; - }, - onUnreadable: (relPath, err) => - warn(`skipped unreadable package file ${relPath}: ${String(err)}`), + await runBuildStart(ctx, async (specifier) => { + const resolved = await this.resolve(specifier); + return resolved?.id ?? null; }); - - packageMap = collected.packageMap; - for (const [specifier, srcEntry] of collected.sourceEntries) { - externalSourceEntries.set(specifier, srcEntry); - } - for (const entry of collected.entries) { - const hash = !isProd ? contentHash(entry.source) : undefined; - fileEntries.push({ path: entry.path, source: entry.source, hash }); - if (!isProd && hash) { - fileCache.set(entry.path, { hash, source: entry.source }); - } - } - - externalPackageDirs = collected.packageDirs; - - const packageFileCount = fileEntries.length - localFileCount; - log( - `Discovered ${fileEntries.length} files (${packageFileCount} from packages) (${Math.round(performance.now() - t0)}ms)` - ); - - // 6. Run project-wide analysis to produce the manifest - t0 = performance.now(); - runAnalysis(fileEntries); - - // 7. Surface diagnostics from the manifest - if (storedManifest) { - const report = storedManifest.report; - if (report) { - log( - `Extracted ${report.components_extracted}/${report.components_total} components (${Math.round(performance.now() - t0)}ms)` - ); - logTimingWaterfall(storedManifest.timing ?? {}); - log( - `Reconciliation: ${report.components_extracted} kept, ${report.variants_eliminated} variants pruned, ${report.states_eliminated} states pruned` - ); - - // Always-on elimination warnings (not gated by verbose) - const details: Array<{ - component: string; - kind: string; - name?: string; - reason: string; - }> = report.eliminated_details || []; - for (const d of details) { - if (d.kind === 'component') { - warn(`⚠ ${d.component} eliminated: ${d.reason}`); - } else if (d.kind === 'prospective_component') { - warn( - `⚠ ${d.component} would be eliminated in production: ${d.reason}` - ); - } else if (d.kind === 'variant') { - warn(`⚠ ${d.component} variant '${d.name}' pruned: ${d.reason}`); - } else if (d.kind === 'state') { - warn(`⚠ ${d.component} state '${d.name}' pruned: ${d.reason}`); - } - } - } - - log( - `CSS: ${resolvedComponentCss.length} bytes (${Object.keys(storedManifest.components || {}).length} components)` - ); - - if (!isProd && storedSheets) { - const staticCss = assembleStylesheet({ - layers: options.layers, - variableCss, - globalCss, - }); - const staticSize = staticCss.length; - const componentSize = resolvedComponentCss.length; - log( - `Delivery: split mode — static ${staticSize} bytes, components ${componentSize} bytes (adopted stylesheet)` - ); - } else { - log('Delivery: single file mode (production)'); - } - } - - // Compute @layer declaration for HTML injection (config-time, static). - const { declaration } = assembleStylesheet({ - layers: options.layers, - variableCss: '', - globalCss: '', - split: true, - }); - layerDeclaration = declaration; - - if (options.verify) { - runSelfVerify(); - } }, resolveId(id) { - if (id === VIRTUAL_CSS_ID) return RESOLVED_CSS_ID; - if (id === VIRTUAL_COMPONENTS_ID) return RESOLVED_COMPONENTS_ID; - if (id === VIRTUAL_BRIDGE_ID) return RESOLVED_BRIDGE_ID; - if (id === VIRTUAL_SYSTEM_PROPS_ID) return RESOLVED_SYSTEM_PROPS_ID; - - // Redirect external DS package imports to their source entry - // so Vite serves .ts files (transformable) instead of .mjs dist files - const srcEntry = externalSourceEntries.get(id); - if (srcEntry) return srcEntry; - - return null; + return resolveVirtualId(ctx, id); }, load(id) { - const shouldMinify = options.minify ?? isProd; - const lcssOpts = { - minify: shouldMinify, - targets: lcssTargets, - warnFn: warn, - }; - - if (id === RESOLVED_CSS_ID) { - if (!isProd && storedSheets) { - const { variables, body } = assembleStylesheet({ - layers: options.layers, - variableCss, - globalCss, - split: true, - }); - const processedBody = postProcessCss(body, { - ...lcssOpts, - minify: false, - }); - return [variables, processedBody].filter(Boolean).join('\n'); - } - const { variables, body } = assembleStylesheet({ - layers: options.layers, - variableCss, - globalCss, - componentCss: resolvedComponentCss, - split: true, - }); - const processedBody = postProcessCss(body, lcssOpts); - return [variables, processedBody].filter(Boolean).join('\n'); - } - - if (id === RESOLVED_COMPONENTS_ID) { - const strippedCss = stripLeadingLayerDeclaration( - resolvedComponentCss || '' - ); - const css = postProcessCss(strippedCss, { - ...lcssOpts, - minify: false, - }); - const escaped = css - .replace(/\\/g, '\\\\') - .replace(/`/g, '\\`') - .replace(/\$/g, '\\$'); - return `export default \`${escaped}\`;`; - } - - if (id === RESOLVED_BRIDGE_ID) { - // HMR bridge: manages adopted stylesheet with replaceSync() - // Uses a global reference so re-execution (HMR module re-eval) reuses - // the existing CSSStyleSheet instead of appending duplicates. - const sheetHash = createHash('md5') - .update(options.system) - .digest('hex') - .slice(0, 8); - return ` -import css from '${VIRTUAL_COMPONENTS_ID}'; - -const GLOBAL_KEY = '__animus_sheet_${sheetHash}__'; -let sheet = globalThis[GLOBAL_KEY] || null; - -if (typeof CSSStyleSheet !== 'undefined' && 'adoptedStyleSheets' in document) { - if (!sheet) { - sheet = new CSSStyleSheet(); - globalThis[GLOBAL_KEY] = sheet; - document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; - } - sheet.replaceSync(css); -} else { - // Fallback: inject or update