Files
youle_cocos/cocoscreator_projects/scripts/lib/import-boundaries.mjs
T

297 lines
10 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']);
/**
* 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;
}
/**
* 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 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 };