import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; import ts from 'typescript'; const SDK_ALLOWED = /framework[\\/]sdk(?:[\\/]|$)/; const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|domain|presentation|ui|core|compat)(?:[\\/]|$)/; const LEGACY_RUNTIME = /framework[\\/](?:net[\\/]net-client|platform[\\/](?:session|startup|room-rpc-bus|readonly|stores[\\/](?:app-store|player-store|room-store|types))|protocol[\\/]room-handlers)\.ts$/; const CONTRACTS_PATH = /framework[\\/]sdk[\\/]contracts(?:[\\/]|$)/; const MIGRATION_ONLY_NAMES = new Set(['GameContext', 'IGameModule']); const SDK_INDEX_MIGRATION_DEPENDENCIES = new Set([ 'core/events.ts', 'core/reactive.ts', 'platform/readonly.ts', 'platform/stores/types.ts', ]); /** * Recursively scans TypeScript imports for framework/game layering violations. * Paths in violations are normalized for useful, cross-platform diagnostics. */ export function scanImportBoundaries(options) { const frameworkDir = resolve(options.frameworkDir); const gamesDir = resolve(options.gamesDir); const compilerOptions = readCompilerOptions(options.tsconfigPath); const resolutionBase = options.tsconfigPath ? dirname(resolve(options.tsconfigPath)) : frameworkDir; const moduleCache = ts.createModuleResolutionCache( resolutionBase, (file) => pathKey(canonicalPath(file)), compilerOptions, ); const violations = []; for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) { const source = readFileSync(file, 'utf8'); for (const imported of extractImports(source, file)) { const resolved = resolveDependency( imported.specifier, file, compilerOptions, moduleCache, ) ?? (imported.specifier.startsWith('.') ? canonicalPath(resolve(dirname(file), imported.specifier)) : null); const violation = findViolation({ file, specifier: imported.specifier, bindings: imported.bindings, resolved, frameworkDir, gamesDir, }); if (violation) violations.push(violation); } } return violations; } /** * Finds the first dependency path from one modern entry into a forbidden * compatibility file. Reachable non-static dynamic imports fail closed. */ export function findCompatibilityDependency(options) { const entry = canonicalPath(options.entry); const traversalRoot = canonicalPath(options.traversalRoot); const forbiddenFiles = new Set(options.forbiddenFiles.map((file) => pathKey(canonicalPath(file)))); const compilerOptions = readCompilerOptions(options.tsconfigPath); const moduleCache = ts.createModuleResolutionCache( options.tsconfigPath ? dirname(resolve(options.tsconfigPath)) : traversalRoot, (file) => pathKey(canonicalPath(file)), compilerOptions, ); const visited = new Set(); const visit = (file, path) => { const canonicalFile = canonicalPath(file); const key = pathKey(canonicalFile); const nextPath = [...path, canonicalFile]; if (forbiddenFiles.has(key)) { return { kind: 'forbidden', path: nextPath, file: canonicalFile }; } if (visited.has(key) || !isInsideOrEqual(canonicalFile, traversalRoot)) return null; visited.add(key); const source = readFileSync(canonicalFile, 'utf8'); for (const edge of extractDependencyEdges(source, canonicalFile)) { if (edge.kind === 'dynamic') { return { kind: 'dynamic', path: nextPath, file: canonicalFile }; } const imported = resolveDependency(edge.specifier, canonicalFile, compilerOptions, moduleCache); if (imported === null) continue; const found = visit(imported, nextPath); if (found !== null) return found; } return null; }; return visit(entry, []); } function readCompilerOptions(tsconfigPath) { if (!tsconfigPath) { return { allowImportingTsExtensions: true, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, target: ts.ScriptTarget.ES2020, }; } const configFile = resolve(tsconfigPath); const loaded = ts.readConfigFile(configFile, ts.sys.readFile); if (loaded.error) throw new Error(formatTsDiagnostics([loaded.error])); const parsed = ts.parseJsonConfigFileContent( loaded.config, ts.sys, dirname(configFile), undefined, configFile, ); if (parsed.errors.length > 0) throw new Error(formatTsDiagnostics(parsed.errors)); return parsed.options; } function formatTsDiagnostics(diagnostics) { return ts.formatDiagnostics(diagnostics, { getCanonicalFileName: (file) => file, getCurrentDirectory: () => process.cwd(), getNewLine: () => '\n', }); } function extractDependencyEdges(source, file) { const edges = []; const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); const visit = (node) => { if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { edges.push({ kind: 'static', specifier: node.moduleSpecifier.text }); } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteralLike(node.argument.literal)) { edges.push({ kind: 'static', specifier: node.argument.literal.text }); } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { const argument = node.arguments[0]; if (node.arguments.length === 1 && argument && ts.isStringLiteralLike(argument)) { edges.push({ kind: 'static', specifier: argument.text }); } else { edges.push({ kind: 'dynamic' }); } } ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); return edges; } function resolveDependency(specifier, containingFile, compilerOptions, moduleCache) { const result = ts.resolveModuleName( specifier, containingFile, compilerOptions, ts.sys, moduleCache, ); return result.resolvedModule ? canonicalPath(result.resolvedModule.resolvedFileName) : null; } function canonicalPath(file) { const absolute = resolve(file); try { return realpathSync.native(absolute); } catch (error) { if (error && error.code === 'ENOENT') return absolute; throw error; } } function pathKey(file) { return process.platform === 'win32' ? file.toLowerCase() : file; } function isInsideOrEqual(file, directory) { if (pathKey(file) === pathKey(directory)) return true; return isInside(file, directory); } function* typescriptFiles(directory) { let entries; try { entries = readdirSync(directory); } catch (error) { if (error && error.code === 'ENOENT') return; throw error; } for (const entry of entries) { const file = resolve(directory, entry); if (statSync(file).isDirectory()) yield* typescriptFiles(file); else if (file.endsWith('.ts')) yield file; } } function extractImports(source, file) { const imports = []; const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); const visit = (node) => { if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { imports.push({ specifier: node.moduleSpecifier.text, bindings: ts.isImportDeclaration(node) && node.importClause ? node.importClause.getText(sourceFile) : '', }); } if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteralLike(node.argument.literal)) { imports.push({ specifier: node.argument.literal.text, bindings: '' }); } if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])) { imports.push({ specifier: node.arguments[0].text, bindings: '' }); } ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); return imports; } function findViolation(context) { const { file, specifier, bindings, resolved, frameworkDir, gamesDir } = context; const filePath = displayPath(file); const resolvedPath = resolved ? displayPath(resolved) : specifier; const isContract = CONTRACTS_PATH.test(filePath); const isSdk = SDK_ALLOWED.test(filePath); const isGame = isInside(file, gamesDir); const isFramework = isInside(file, frameworkDir); if (isContract && !resolved) { return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; non-relative specifier ${specifier} is forbidden`); } if (isContract && dirname(resolved) !== dirname(file)) { return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; sdk path resolved to ${resolvedPath}`); } if (isSdk && isCocosSpecifier(specifier)) { return violation(filePath, specifier, `sdk cannot import Cocos module ${specifier}`); } if (isSdk && resolved && isInside(resolved, frameworkDir) && !SDK_ALLOWED.test(resolvedPath) && !isSdkIndexMigrationDependency(filePath, resolved, frameworkDir)) { return violation(filePath, specifier, `sdk cannot import framework implementation ${resolvedPath}`); } if (isGame && resolved && isFrameworkReference(resolved, frameworkDir) && !SDK_ALLOWED.test(resolvedPath)) { return violation(filePath, specifier, `game code may import only framework/sdk; game import resolved to ${resolvedPath}`); } if (importsMigrationOnlyName(bindings) && !isMigrationOnlyImporter(filePath, frameworkDir)) { const importer = isGame ? 'game code' : 'production code'; return violation(filePath, specifier, `${importer} cannot import ${migrationOnlyName(bindings)}; it is migration-only`); } if (isFramework && resolved && isInside(resolved, gamesDir)) { return violation(filePath, specifier, `framework code cannot import games; framework import resolved to ${resolvedPath}`); } if (resolved && LEGACY_RUNTIME.test(resolvedPath) && !isLegacyRuntimeImporter(filePath, frameworkDir)) { return violation(filePath, specifier, `new production code cannot import quarantined legacy runtime ${resolvedPath}`); } return null; } function isFrameworkReference(path, frameworkDir) { return isInside(path, frameworkDir) || SDK_ALLOWED.test(path) || FRAMEWORK_INTERNAL.test(path) || /(?:^|[\\/])framework(?:[\\/]|$)/.test(path); } function isLegacyRuntimeImporter(filePath, frameworkDir) { const normalizedFrameworkDir = displayPath(frameworkDir); return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath); } function isSdkIndexMigrationDependency(filePath, resolved, frameworkDir) { if (filePath !== `${displayPath(frameworkDir)}/sdk/index.ts`) return false; return SDK_INDEX_MIGRATION_DEPENDENCIES.has(displayPath(relative(frameworkDir, resolved))); } function isCocosSpecifier(specifier) { return specifier === 'cc' || specifier.startsWith('cc/'); } /** * ActiveGame is the retained compatibility owner of IGameModule until Task 11 * removes both it and the migration-only declarations from sdk/index.ts. */ function isMigrationOnlyImporter(filePath, frameworkDir) { return filePath === `${displayPath(frameworkDir)}/protocol/active-game.ts`; } function importsMigrationOnlyName(bindings) { return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings)); } function migrationOnlyName(bindings) { return [...MIGRATION_ONLY_NAMES].find((name) => new RegExp(`\\b${name}\\b`).test(bindings)); } function isInside(file, directory) { const pathFromDirectory = relative(directory, file); return pathFromDirectory !== '' && !pathFromDirectory.startsWith('..') && !isAbsolute(pathFromDirectory); } function displayPath(file) { return file.replaceAll('\\', '/'); } function violation(file, specifier, message) { return { file, specifier, message }; } export { FRAMEWORK_INTERNAL, LEGACY_RUNTIME, SDK_ALLOWED };