From c0c6a7c800b28e5e9291395882c4495980813214 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 00:49:26 +0800 Subject: [PATCH] fix(sdk): harden import boundary scanner --- .../architecture/import-boundaries.test.mjs | 64 +++++++++++++++++++ .../scripts/lib/import-boundaries.mjs | 59 ++++++++++++----- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs index 8d66805..386ebd9 100644 --- a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs +++ b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs @@ -42,6 +42,22 @@ test('scanner rejects game imports of framework net internals', async () => { assert.match(scan(root)[0].message, /game.*framework\/net/); }); +test('scanner rejects multiline game imports of framework net internals', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'games/a/assets/game/bad.ts', "import {\n NetClient,\n} from '../../../../framework/net/net-client.ts'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /game.*framework\/net/); +}); + +test('scanner rejects multiline sdk contract imports outside their sibling directory', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import {\n PlatformSession,\n} from '../../platform/session.ts'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /sdk\/contracts.*sibling contracts/); +}); + test('scanner permits a game import of the public sdk barrel', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'games/a/assets/game/allowed.ts', "import '../../../../framework/sdk/index.ts'\n"); @@ -60,6 +76,38 @@ test('scanner rejects cc imports in sdk contracts', async () => { assert.match(scan(root)[0].message, /sdk\/contracts.*cc/); }); +test('scanner rejects non-relative imports in sdk contracts', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import 'node:fs'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /sdk\/contracts.*non-relative/); +}); + +test('scanner rejects import-type leaks from sdk contracts', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/sdk/contracts/bad.ts', "type ReadonlyRoom = import('../../platform/readonly.ts').ReadonlyRoomStore\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /sdk\/contracts.*sibling contracts/); +}); + +test('scanner rejects export-star leaks from sdk contracts', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/sdk/contracts/index.ts', "export * from '../../platform/readonly.ts'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /sdk\/contracts.*sibling contracts/); +}); + +test('scanner rejects named re-export leaks from sdk contracts', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/sdk/contracts/index.ts', "export { ReadonlyRoomStore } from '../../platform/readonly.ts'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /sdk\/contracts.*sibling contracts/); +}); + test('scanner permits cc imports in game code', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'games/a/assets/game/allowed.ts', "import { Node } from 'cc'\n"); @@ -72,6 +120,22 @@ test('scanner rejects framework imports resolving under games', async () => { assert.match(scan(root)[0].message, /framework.*games/); }); +test('scanner rejects framework production imports of migration-only sdk declarations', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/application/bad.ts', "import type { GameContext } from '../sdk/index.ts'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /GameContext.*migration-only/); +}); + +test('scanner rejects alias imports of migration-only sdk declarations', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/application/bad.ts', "import type { IGameModule } from '@internal/sdk'\n"); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /IGameModule.*migration-only/); +}); + test('scanner rejects new production imports of a quarantined legacy runtime', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'framework/application/bad.ts', "import '../net/net-client.ts'\n"); diff --git a/cocoscreator_projects/scripts/lib/import-boundaries.mjs b/cocoscreator_projects/scripts/lib/import-boundaries.mjs index d7be542..c293fc5 100644 --- a/cocoscreator_projects/scripts/lib/import-boundaries.mjs +++ b/cocoscreator_projects/scripts/lib/import-boundaries.mjs @@ -1,5 +1,6 @@ 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)(?:[\\/]|$)/; @@ -18,7 +19,7 @@ export function scanImportBoundaries(options) { for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) { const source = readFileSync(file, 'utf8'); - for (const imported of extractImports(source)) { + for (const imported of extractImports(source, file)) { const resolved = imported.specifier.startsWith('.') ? resolve(dirname(file), imported.specifier) : null; @@ -52,16 +53,35 @@ function* typescriptFiles(directory) { } } -function extractImports(source) { +function extractImports(source, file) { const imports = []; - const staticImport = /\bimport\s+(?!\()(?:(?:type\s+)?([^;\n]+?)\s+from\s+)?(['"])([^'"\n]+)\2/g; - for (const match of source.matchAll(staticImport)) { - imports.push({ specifier: match[3], bindings: match[1] ?? '' }); - } - const dynamicImport = /\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g; - for (const match of source.matchAll(dynamicImport)) { - imports.push({ specifier: match[2], bindings: '' }); - } + 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; } @@ -73,11 +93,11 @@ function findViolation(context) { const isGame = isInside(file, gamesDir); const isFramework = isInside(file, frameworkDir); - if (isContract && specifier === 'cc') { - return violation(filePath, specifier, `sdk/contracts cannot import cc (${filePath})`); + if (isContract && !resolved) { + return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; non-relative specifier ${specifier} is forbidden`); } - if (isContract && resolved && dirname(resolved) !== dirname(file)) { + if (isContract && dirname(resolved) !== dirname(file)) { return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; sdk path resolved to ${resolvedPath}`); } @@ -85,8 +105,9 @@ function findViolation(context) { return violation(filePath, specifier, `game code may import only framework/sdk; game import resolved to ${resolvedPath}`); } - if (isGame && resolved && SDK_ALLOWED.test(resolvedPath) && importsMigrationOnlyName(bindings)) { - return violation(filePath, specifier, `game code cannot import ${migrationOnlyName(bindings)}; it is migration-only`); + 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)) { @@ -112,6 +133,14 @@ function isLegacyRuntimeImporter(filePath, 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)); }