import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { builtinModules } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { analyzeModule } from './module-analysis.mjs'; const builtins = new Set( builtinModules.flatMap((name) => [name, `node:${name}`]), ); const code = /\.[mc]?js$/; const entry = (file) => file === 'src/main.js' || file.startsWith('src/standalone/'); const tests = (file) => file.endsWith('.test.mjs') || file.startsWith('src/testSupport/') || file.startsWith('src/tooling/') || file === 'src/overlays/worldOverlayAllocation.worker.mjs'; const source = (file) => file.startsWith('src/sources/') || /^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file); // Engine-neutral geometry ported from Cesium; every runtime file is a portable root. const geo = (file) => file.startsWith('src/geo/'); // The app clock (src/time): plain numbers, no engine, no DOM. const time = (file) => file.startsWith('src/time/'); // The engine-neutral globe contract and its test support; engines live below it. const globeContract = (file) => /^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/'); const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file); const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/; // Navara renders through three.js and its postprocessing peer. const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; // Phase 3: the layers moved onto layer renderers (the *a tasks) keep each // engine in their renderers: cesium only in render/cesium*.js, three and // Navara only in render/navara*.js. The list grows with each moved layer. const RENDERED_LAYER_DIRS = [ 'src/layers/flights/', 'src/layers/military/', 'src/layers/localAdsb/', 'src/layers/aircraft/', 'src/layers/shared/', ]; const renderedLayer = (file) => RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir)); const cesiumLayerRenderer = (file) => /\/render\/cesium[^/]*\.js$/.test(file); const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file); const renderer = (file) => /^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( file, ); // Two compatibility composition entries intentionally select standalone defaults. const compatibilityEdge = (from, to) => (from === 'server/providers/local.js' && to === 'server/standalone/key-setup.js') || (from === 'src/ui.js' && to === 'src/standalone/catalog.js'); const portableExport = (key) => key.startsWith('./sources/') || /\/source$/.test(key) || /^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test( key, ) || [ './director', './tools', './tools/mcp', './tools/services', './view', './voice/action-schemas', './voice/session', './data/lifecycle', ].includes(key); /** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */ export function checkImportDirections(root) { root = realpathSync(root); const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); const files = [ ...new Set( execFileSync( 'git', [ 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', 'src', 'server', ], { cwd: root, encoding: 'utf8' }, ).split('\0'), ), ].filter( (file) => code.test(file) && !tests(file) && existsSync(path.join(root, file)), ); const errors = new Set(); const modules = new Map(); const relative = (file) => path.relative(root, file).split(path.sep).join('/'); const report = (file, message) => errors.add(`${file}: ${message}`); function load(file) { if (modules.has(file)) return modules.get(file); const resolved = realpathSync(path.join(root, file)); if (relative(resolved) !== file) { report( file, 'Runtime modules must not alias other owners through symlinks', ); return null; } let analysis; try { analysis = analyzeModule(readFileSync(resolved, 'utf8'), { dynamic: true, }); } catch (error) { report(file, error.message); return null; } const record = { ...analysis, edges: [], external: [] }; modules.set(file, record); for (const specifier of analysis.imports) { let target; if (specifier.startsWith('.')) target = path.resolve(path.dirname(resolved), specifier.split('?')[0]); else if (specifier === pkg.name || specifier.startsWith(`${pkg.name}/`)) { const key = specifier === pkg.name ? '.' : '.' + specifier.slice(pkg.name.length); const exported = pkg.exports?.[key]; if (typeof exported !== 'string') { report(file, `Unsupported self import: ${specifier}`); continue; } target = path.resolve(root, exported); } else { record.external.push(specifier); if (renderedLayer(file) && !tests(file)) { if (cesiumPackage.test(specifier) && !cesiumLayerRenderer(file)) report( file, `cesium only in the layer's render/cesium*.js: ${specifier}`, ); if (navaraPackage.test(specifier) && !navaraLayerRenderer(file)) report( file, `three and Navara only in the layer's render/navara*.js: ${specifier}`, ); } if ( file.startsWith('src/') && (builtins.has(specifier) || specifier.startsWith('node:')) ) report(file, `Node builtin in browser module: ${specifier}`); if ( specifier.startsWith('/') || (/^[a-z]+:/i.test(specifier) && !specifier.startsWith('node:')) ) report(file, `Use repository or package imports: ${specifier}`); continue; } if (!existsSync(target)) { report(file, `Missing import: ${specifier}`); continue; } const to = relative(realpathSync(target)); if (to.startsWith('../') || path.isAbsolute(to)) { report(file, `Import escapes repository: ${specifier}`); continue; } record.edges.push(to); // Navara stays out of the Cesium-mode bundle: outside its own directory // it is reached only through import() (src/app/scene.js), which the // bundler splits into a chunk loaded on ?engine=navara. if ( to.startsWith('src/globe/navara/') && !file.startsWith('src/globe/navara/') && !analysis.dynamicImports.includes(specifier) ) report( file, `The Navara engine loads only through a dynamic import(): ${to}`, ); // Like the engine, a layer's Navara renderer reaches the app only // through the Navara mode's import() (src/app/navaraMode.js). if ( navaraLayerRenderer(to) && !navaraLayerRenderer(file) && !( file === 'src/app/navaraMode.js' && analysis.dynamicImports.includes(specifier) ) ) report( file, `A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`, ); if (file.startsWith('src/') && to.startsWith('server/')) report(file, `Browser imports server: ${to}`); if ( !compatibilityEdge(file, to) && file.startsWith('server/providers/') && (to.startsWith('server/standalone/') || renderer(to)) ) report(file, `Provider imports application/rendering: ${to}`); if ( !compatibilityEdge(file, to) && file.startsWith('src/') && !entry(file) && entry(to) ) report(file, `Reusable module imports standalone setup: ${to}`); if (source(file) && renderer(to)) report(file, `Source imports rendering/application: ${to}`); if (file === 'src/voice/gevActions.js' && to === 'src/data/manager.js') report( file, 'Actions must consume feed state without the manager facade', ); if ( file === 'src/app/constructCatalog.js' && to === 'src/data/localGeojson.js' ) report( file, 'Catalog must use the services owner without compatibility layer construction', ); } return record; } for (const file of files) load(file); // A helper outside src must not smuggle Node or server code into a browser graph. const browserSeen = new Set(); function browser(file) { if (browserSeen.has(file) || !code.test(file)) return; browserSeen.add(file); const info = load(file); if (!info) return; for (const specifier of info.external) { if (builtins.has(specifier) || specifier.startsWith('node:')) report(file, `Browser graph reaches Node builtin: ${specifier}`); } for (const to of info.edges) { if (to.startsWith('server/') || tests(to)) report(file, `Browser graph reaches non-runtime owner: ${to}`); else browser(to); } } for (const file of files.filter((file) => file.startsWith('src/'))) browser(file); const roots = new Map(files.filter(source).map((file) => [file, 'source'])); for (const file of files.filter(geo)) roots.set(file, 'geo'); for (const file of files.filter(time)) roots.set(file, 'time'); for (const [key, value] of Object.entries(pkg.exports || {})) { if (portableExport(key) && typeof value === 'string') roots.set(value.replace(/^\.\//, ''), key); } roots.set('src/data/feedState.js', 'feed state'); for (const [start, label] of roots) { if (!existsSync(path.join(root, start))) { report(start, `Missing portable entry: ${label}`); continue; } const seen = new Set(); function visit(file) { if (seen.has(file) || !code.test(file)) return; seen.add(file); const info = load(file); if (!info) return; if (info.browser.length) report( file, `${label} reaches browser globals: ${info.browser.join(', ')}`, ); for (const specifier of info.external) { if ( builtins.has(specifier) || specifier.startsWith('node:') || /^(?:cesium|vite)(?:\/|$)/.test(specifier) || // Geo must not reach Cesium through its workspace packages either. (label === 'geo' && /^@cesium\//.test(specifier)) || // Neither engine reaches the app clock. (label === 'time' && (cesiumPackage.test(specifier) || navaraPackage.test(specifier))) ) report( file, `${label} reaches platform/rendering dependency: ${specifier}`, ); } for (const to of info.edges) { if (renderer(to) || to.startsWith('server/')) report(file, `${label} reaches application/rendering: ${to}`); else visit(to); } } visit(start); } // The globe contract reaches no engine, directly or through helpers; each // engine implementation imports only its own engine's packages. const globeSeen = new Set(); function globe(file) { if (globeSeen.has(file) || !code.test(file)) return; globeSeen.add(file); const info = load(file); if (!info) return; for (const specifier of info.external) { if (cesiumPackage.test(specifier) || navaraPackage.test(specifier)) report(file, `globe contract reaches engine dependency: ${specifier}`); } for (const to of info.edges) { if (globeEngine(to)) report(file, `globe contract imports engine implementation: ${to}`); else globe(to); } } for (const file of files.filter(globeContract)) globe(file); // Each engine graph stays on its own engine, through helpers too. for (const start of files.filter(globeEngine)) { const navara = start.startsWith('src/globe/navara/'); const label = navara ? 'Navara globe engine' : 'Cesium globe engine'; const foreignPackage = navara ? cesiumPackage : navaraPackage; const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/'; const seen = new Set(); const visit = (file) => { if (seen.has(file) || !code.test(file)) return; seen.add(file); const info = load(file); if (!info) return; for (const specifier of info.external) if (foreignPackage.test(specifier)) report(file, `${label} reaches ${specifier}`); for (const to of info.edges) { if (to.startsWith(foreignEngine)) report(file, `${label} reaches ${to}`); else visit(to); } }; visit(start); } // Common voice controls may use their DOM view, but never a protocol implementation. const voiceSeen = new Set(); function voice(file) { if (voiceSeen.has(file)) return; voiceSeen.add(file); const info = load(file); for (const to of info?.edges || []) { if (/^src\/voice\/realtime/.test(to)) report(file, `Common voice controls import protocol: ${to}`); else if (code.test(to)) voice(to); } } if (existsSync(path.join(root, 'src/voice/sessionCommands.js'))) voice('src/voice/sessionCommands.js'); if (errors.size) throw new Error([...errors].join('\n')); return { modules: files.length, portableEntries: roots.size }; } if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { try { console.log( JSON.stringify( checkImportDirections(fileURLToPath(new URL('../', import.meta.url))), ), ); } catch (error) { console.error(error.message); process.exitCode = 1; } }