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']); // Pre-existing, quarantined prototype entry points; modern code must not import them. const LEGACY_SCENE_SCRIPT = /scripts[\\/](?:LoginFlow|RoomEventProbe|RoomSceneStart)\.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 publicDirs = (options.publicDirs ?? []).map(path=>resolve(path)); const appDir = options.appDir ? resolve(options.appDir) : null; 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), ...publicDirs.flatMap(dir=>[...typescriptFiles(dir)]), ...(appDir?[...typescriptFiles(appDir)]:[])]) { 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, publicDirs, appDir, }); 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.isImportEqualsDeclaration(node)) { const specifier = externalImportEqualsSpecifier(node); if (specifier !== null) edges.push({ kind: 'static', specifier }); } 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 (isModuleLoaderCall(node)) { const specifier = literalCallSpecifier(node); if (specifier !== null) { edges.push({ kind: 'static', specifier }); } 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, true, 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) : ts.isExportDeclaration(node) ? node.exportClause?.getText(sourceFile) ?? '' : '', }); } else if (ts.isImportEqualsDeclaration(node)) { const specifier = externalImportEqualsSpecifier(node); if (specifier !== null) { imports.push({ specifier, bindings: node.name.getText(sourceFile) }); } } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteralLike(node.argument.literal)) { imports.push({ specifier: node.argument.literal.text, bindings: node.qualifier?.getText(sourceFile) ?? '' }); } else if (isModuleLoaderCall(node)) { const specifier = literalCallSpecifier(node); if (specifier !== null) imports.push({ specifier, bindings: moduleLoaderBindings(node, sourceFile) }); } ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); return imports; } function moduleLoaderBindings(node, sourceFile) { let expression = node; while (expression.parent && ( ts.isAwaitExpression(expression.parent) || ts.isParenthesizedExpression(expression.parent) || ts.isAsExpression(expression.parent) )) expression = expression.parent; const parent = expression.parent; if (parent && ts.isVariableDeclaration(parent) && ts.isObjectBindingPattern(parent.name)) { return parent.name.getText(sourceFile); } if (parent && ts.isPropertyAccessExpression(parent)) return parent.name.getText(sourceFile); if (parent && ts.isElementAccessExpression(parent)) return parent.argumentExpression.getText(sourceFile); return ''; } function externalImportEqualsSpecifier(node) { if (!ts.isImportEqualsDeclaration(node) || !ts.isExternalModuleReference(node.moduleReference) || !node.moduleReference.expression || !ts.isStringLiteralLike(node.moduleReference.expression)) { return null; } return node.moduleReference.expression.text; } function isModuleLoaderCall(node) { return ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require')); } function literalCallSpecifier(node) { const argument = node.arguments[0]; return node.arguments.length === 1 && argument && ts.isStringLiteralLike(argument) ? argument.text : null; } function findViolation(context) { const { file, specifier, bindings, resolved, frameworkDir, gamesDir, publicDirs, appDir } = context; if(appDir && isInside(file,appDir) && resolved && isInside(resolved,gamesDir))return violation(file.replaceAll('\\','/'),specifier,'app composition cannot import concrete games'); 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); const isPublic = publicDirs.some(dir=>isInside(file,dir)); if ((isFramework || isPublic) && resolved && (isInside(resolved,gamesDir) || (appDir && isInside(resolved,appDir)))) { return violation(filePath,specifier,'public framework/platform code cannot import games or app composition'); } 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)) { 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 (isGame && resolved && isInside(resolved,gamesDir) && relative(gamesDir,file).split(/[\\/]/)[0] !== relative(gamesDir,resolved).split(/[\\/]/)[0]) { return violation(filePath,specifier,'game code cannot import another game'); } if (importsMigrationOnlyName(bindings)) { 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)||LEGACY_SCENE_SCRIPT.test(resolvedPath)) && !LEGACY_RUNTIME.test(filePath)&&!LEGACY_SCENE_SCRIPT.test(filePath)) { 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 isCocosSpecifier(specifier) { return specifier === 'cc' || specifier.startsWith('cc/'); } 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 };