diff --git a/packages/core/src/architecture.test.ts b/packages/core/src/architecture.test.ts new file mode 100644 index 0000000000..7a1b5e0199 --- /dev/null +++ b/packages/core/src/architecture.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test' +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +/** + * Layer rule (AGENTS.md): core is pure logic — no Three.js, no rendering. + * A runtime `three`/`@react-three/*` import in core evaluates R3F (and thus + * React client context) in every consumer of the barrel, which crashes + * Next.js route handlers under the RSC server condition (capture uploads + * 500'd this way once). Type-only imports are erased at build and allowed. + */ +const SRC = resolve(import.meta.dir) + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name) + if (entry.isDirectory()) return sourceFiles(full) + if (/\.test\.tsx?$/.test(entry.name)) return [] + return /\.tsx?$/.test(entry.name) ? [full] : [] + }) +} + +const BANNED_SPEC = String.raw`(?:three(?:\/[^'"]*)?|@react-three\/[^'"]*)` +// `import`/`export … from 'three…'` — group 1 captures a whole-clause `type` +// qualifier, the only form guaranteed to be erased by the compiler. +const FROM_RE = new RegExp( + String.raw`(?:import|export)\s+(type\s)?[\w*{}\s,$]*?from\s*['"]${BANNED_SPEC}['"]`, + 'g', +) +// Bare side-effect form: `import 'three…'` — always a runtime import. +const SIDE_EFFECT_RE = new RegExp(String.raw`import\s*['"]${BANNED_SPEC}['"]`, 'g') + +describe('architecture', () => { + test('core has no runtime three/@react-three imports', () => { + const files = sourceFiles(SRC) + const offenders: string[] = [] + + for (const file of files) { + const src = readFileSync(file, 'utf8') + for (const match of src.matchAll(FROM_RE)) { + if (!match[1]) offenders.push(`${relative(SRC, file)}: ${match[0].replaceAll('\n', ' ')}`) + } + for (const match of src.matchAll(SIDE_EFFECT_RE)) { + offenders.push(`${relative(SRC, file)}: ${match[0]}`) + } + } + + expect(offenders).toEqual([]) + // Guard against the walk passing vacuously. + expect(files.length).toBeGreaterThan(100) + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72ff0972fa..4839bd50dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -281,6 +281,10 @@ export type { FloorPlacedFootprintsResolver, } from './registry' export * from './registry' +// Exported here rather than from the registry barrel: that barrel is +// reachable from server-safe graphs (schema → spatial grid → registry) +// and must stay free of React imports. +export { useRegistryVersion } from './registry/use-registry-version' export * from './schema' export * from './services' export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' @@ -361,7 +365,6 @@ export { stepElevatorRuntimeState, stepElevatorRuntimes, } from './systems/elevator/elevator-runtime' -export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' export { type ElevatorLevelEntry, resolveElevatorBuildingLevels, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index da45fac0a6..83a1a7305c 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -162,4 +162,3 @@ export type { ToolHintChip, Vec2, } from './types' -export { useRegistryVersion } from './use-registry-version' diff --git a/packages/core/src/systems/elevator/elevator-runtime-system.tsx b/packages/core/src/systems/elevator/elevator-runtime-system.tsx deleted file mode 100644 index 91c897a23a..0000000000 --- a/packages/core/src/systems/elevator/elevator-runtime-system.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useFrame } from '@react-three/fiber' -import { stepElevatorRuntimes } from './elevator-runtime' - -export function ElevatorRuntimeSystem() { - useFrame(({ clock }, delta) => { - stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta) - }, 2) - - return null -} diff --git a/packages/nodes/src/elevator/system.tsx b/packages/nodes/src/elevator/system.tsx index 1a799b2514..e45853b982 100644 --- a/packages/nodes/src/elevator/system.tsx +++ b/packages/nodes/src/elevator/system.tsx @@ -1,7 +1,18 @@ 'use client' -import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core' +import { ElevatorOpeningSystem, stepElevatorRuntimes } from '@pascal-app/core' import { ElevatorInteractionSystem } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' + +/** Cab travel + door state machine, stepped once per frame. Lives here rather + * than in core so the core barrel stays free of runtime R3F imports. */ +function ElevatorRuntimeSystem() { + useFrame(({ clock }, delta) => { + stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta) + }, 2) + + return null +} /** * Composite system for elevator — bundles three per-frame systems: