import { readdirSync, readFileSync, 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']); /** * 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 violations = []; for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) { const source = readFileSync(file, 'utf8'); for (const imported of extractImports(source, file)) { const resolved = imported.specifier.startsWith('.') ? 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; } 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.isStringLiteral(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.isStringLiteral(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.isStringLiteral(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 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 (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); } /** * 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 };