From 4cc83ea7136247e5607e6328fbced56b0cbcd4b1 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 08:38:04 +0800 Subject: [PATCH] fix(sdk): scan CommonJS imports --- .../architecture/import-boundaries.test.mjs | 115 ++++++++++++++++++ .../scripts/lib/import-boundaries.mjs | 52 ++++++-- 2 files changed, 154 insertions(+), 13 deletions(-) diff --git a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs index cdf7d01..1da578c 100644 --- a/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs +++ b/cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs @@ -91,6 +91,7 @@ test('every existing modern runtime entry is transitively isolated from the lega test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => { const cases = [ "import '../platform/session.ts'\n", + "import Legacy = require('../platform/session.ts')\n", "export * from '../platform/session.ts'\n", "export type { LegacySession } from '../platform/session.ts'\n", "type Legacy = import('../platform/session.ts').LegacySession\n", @@ -138,6 +139,25 @@ test('compatibility graph catches string and no-substitution-template dynamic im } }); +test('compatibility graph treats literal require calls as static dependencies', async () => { + for (const source of [ + "require('../platform/session.ts')\n", + 'require(`../platform/session.ts`)\n', + ]) { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture(root, 'framework/protocol/router.ts', source); + + const found = scanCompatibilityFixture(root); + assert.equal(found?.kind, 'forbidden', source); + assert.deepEqual( + found?.path.map((file) => file.replaceAll('\\', '/').split('/framework/')[1]), + ['protocol/router.ts', 'platform/session.ts'], + source, + ); + } +}); + test('compatibility graph resolves tsconfig path aliases to canonical files', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); @@ -171,6 +191,24 @@ test('compatibility graph fails closed on a reachable non-static dynamic import' assert.ok(found?.path[0]?.replaceAll('\\', '/').endsWith('/framework/protocol/router.ts')); }); +test('compatibility graph fails closed on reachable non-static require calls', async () => { + for (const source of [ + "const target = '../platform/session.ts'; require(target)\n", + "const name = 'session'; require(`../platform/${name}.ts`)\n", + ]) { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); + await writeFixture(root, 'framework/protocol/router.ts', source); + + const found = scanCompatibilityFixture(root); + assert.equal(found?.kind, 'dynamic', source); + assert.ok( + found?.path[0]?.replaceAll('\\', '/').endsWith('/framework/protocol/router.ts'), + source, + ); + } +}); + test('compatibility graph applies the same isolation to a future platform/runtime.ts entry', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n'); @@ -242,6 +280,54 @@ test('scanner resolves and rejects an sdk runtime path alias into framework impl assert.match(violations[0].message, /sdk.*framework implementation.*framework\/core/); }); +test('scanner resolves and rejects a nested sdk CommonJS path alias into framework implementation', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/core/internal.ts', 'export const value = true\n'); + await writeFixture( + root, + 'framework/sdk/runtime/nested/bad.ts', + "const value = require('@framework/core/internal'); void value\n", + ); + await writeFixture(root, 'tsconfig.json', JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@framework/*': ['framework/*'] }, + module: 'ESNext', + moduleResolution: 'Bundler', + allowImportingTsExtensions: true, + }, + })); + + const violations = scan(root, 'tsconfig.json'); + assert.equal(violations.length, 1); + assert.equal(violations[0].specifier, '@framework/core/internal'); + assert.match(violations[0].message, /sdk.*framework implementation.*framework\/core/); +}); + +test('scanner resolves and rejects a TypeScript import-equals sdk path alias', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/core/internal.ts', 'export const value = true\n'); + await writeFixture( + root, + 'framework/sdk/runtime/nested/bad.ts', + "import alias = require('@framework/core/internal'); void alias\n", + ); + await writeFixture(root, 'tsconfig.json', JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@framework/*': ['framework/*'] }, + module: 'ESNext', + moduleResolution: 'Bundler', + allowImportingTsExtensions: true, + }, + })); + + const violations = scan(root, 'tsconfig.json'); + assert.equal(violations.length, 1); + assert.equal(violations[0].specifier, '@framework/core/internal'); + assert.match(violations[0].message, /sdk.*framework implementation.*framework\/core/); +}); + test('scanner resolves and rejects a no-substitution-template dynamic sdk path alias', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'framework/core/internal.ts', 'export const value = true\n'); @@ -296,6 +382,20 @@ test('scanner rejects a no-substitution-template dynamic Cocos import in nested assert.match(violations[0].message, /sdk.*Cocos/); }); +test('scanner rejects a CommonJS Cocos import in nested sdk code', async () => { + const root = await createFixtureRoot(); + await writeFixture( + root, + 'framework/sdk/testing/nested/bad.ts', + "const env = require('cc/env'); void env\n", + ); + + const violations = scan(root); + assert.equal(violations.length, 1); + assert.equal(violations[0].specifier, 'cc/env'); + assert.match(violations[0].message, /sdk.*Cocos/); +}); + test('sdk migration barrel exception permits only its four retained legacy dependencies', async () => { const root = await createFixtureRoot(); for (const file of [ @@ -329,6 +429,7 @@ test('scanner permits sdk-internal relatives and ordinary external pure dependen "import type { Contract } from '../contracts/index.ts'; void (0 as unknown as Contract);", "import { helper } from './helper.ts'; void helper;", "import type { PureValue } from 'pure-external'; void (0 as unknown as PureValue);", + "const pure = require('pure-commonjs-external'); void pure;", ].join('\n')); assert.deepEqual(scan(root), []); @@ -442,6 +543,20 @@ test('scanner rejects new production imports of a quarantined legacy runtime', a assert.match(violations[0].message, /new production code.*legacy runtime/); }); +test('scanner rejects CommonJS imports of a quarantined legacy runtime', async () => { + const root = await createFixtureRoot(); + await writeFixture(root, 'framework/net/net-client.ts', 'export const legacy = true\n'); + await writeFixture( + root, + 'framework/application/nested/bad.ts', + "const legacy = require('../../net/net-client.ts'); void legacy\n", + ); + const violations = scan(root); + assert.equal(violations.length, 1); + assert.equal(violations[0].specifier, '../../net/net-client.ts'); + assert.match(violations[0].message, /new production code.*legacy runtime/); +}); + test('scanner rejects string-literal dynamic imports that cross a boundary', async () => { const root = await createFixtureRoot(); await writeFixture(root, 'games/a/assets/game/bad.ts', "await import('../../../framework/net/net-client.ts')\n"); diff --git a/cocoscreator_projects/scripts/lib/import-boundaries.mjs b/cocoscreator_projects/scripts/lib/import-boundaries.mjs index e4cf665..82df1eb 100644 --- a/cocoscreator_projects/scripts/lib/import-boundaries.mjs +++ b/cocoscreator_projects/scripts/lib/import-boundaries.mjs @@ -140,15 +140,17 @@ function extractDependencyEdges(source, file) { && 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 (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 if (isModuleLoaderCall(node)) { + const specifier = literalCallSpecifier(node); + if (specifier !== null) { + edges.push({ kind: 'static', specifier }); } else { edges.push({ kind: 'dynamic' }); } @@ -218,17 +220,18 @@ function extractImports(source, file) { ? node.importClause.getText(sourceFile) : '', }); - } - if (ts.isImportTypeNode(node) + } 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: '' }); - } - if (ts.isCallExpression(node) - && node.expression.kind === ts.SyntaxKind.ImportKeyword - && node.arguments.length === 1 - && ts.isStringLiteralLike(node.arguments[0])) { - imports.push({ specifier: node.arguments[0].text, bindings: '' }); + } else if (isModuleLoaderCall(node)) { + const specifier = literalCallSpecifier(node); + if (specifier !== null) imports.push({ specifier, bindings: '' }); } ts.forEachChild(node, visit); }; @@ -236,6 +239,29 @@ function extractImports(source, file) { return imports; } +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 } = context; const filePath = displayPath(file);