339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
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.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 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 };
|