fix(sdk): scan CommonJS imports
This commit is contained in:
@@ -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 () => {
|
test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
"import '../platform/session.ts'\n",
|
"import '../platform/session.ts'\n",
|
||||||
|
"import Legacy = require('../platform/session.ts')\n",
|
||||||
"export * from '../platform/session.ts'\n",
|
"export * from '../platform/session.ts'\n",
|
||||||
"export type { LegacySession } from '../platform/session.ts'\n",
|
"export type { LegacySession } from '../platform/session.ts'\n",
|
||||||
"type Legacy = import('../platform/session.ts').LegacySession\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 () => {
|
test('compatibility graph resolves tsconfig path aliases to canonical files', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
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'));
|
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 () => {
|
test('compatibility graph applies the same isolation to a future platform/runtime.ts entry', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
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/);
|
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 () => {
|
test('scanner resolves and rejects a no-substitution-template dynamic sdk path alias', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'framework/core/internal.ts', 'export const value = true\n');
|
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/);
|
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 () => {
|
test('sdk migration barrel exception permits only its four retained legacy dependencies', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
for (const file of [
|
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 type { Contract } from '../contracts/index.ts'; void (0 as unknown as Contract);",
|
||||||
"import { helper } from './helper.ts'; void helper;",
|
"import { helper } from './helper.ts'; void helper;",
|
||||||
"import type { PureValue } from 'pure-external'; void (0 as unknown as PureValue);",
|
"import type { PureValue } from 'pure-external'; void (0 as unknown as PureValue);",
|
||||||
|
"const pure = require('pure-commonjs-external'); void pure;",
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
assert.deepEqual(scan(root), []);
|
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/);
|
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 () => {
|
test('scanner rejects string-literal dynamic imports that cross a boundary', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'games/a/assets/game/bad.ts', "await import('../../../framework/net/net-client.ts')\n");
|
await writeFixture(root, 'games/a/assets/game/bad.ts', "await import('../../../framework/net/net-client.ts')\n");
|
||||||
|
|||||||
@@ -140,15 +140,17 @@ function extractDependencyEdges(source, file) {
|
|||||||
&& node.moduleSpecifier
|
&& node.moduleSpecifier
|
||||||
&& ts.isStringLiteralLike(node.moduleSpecifier)) {
|
&& ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||||
edges.push({ kind: 'static', specifier: node.moduleSpecifier.text });
|
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)
|
} else if (ts.isImportTypeNode(node)
|
||||||
&& ts.isLiteralTypeNode(node.argument)
|
&& ts.isLiteralTypeNode(node.argument)
|
||||||
&& ts.isStringLiteralLike(node.argument.literal)) {
|
&& ts.isStringLiteralLike(node.argument.literal)) {
|
||||||
edges.push({ kind: 'static', specifier: node.argument.literal.text });
|
edges.push({ kind: 'static', specifier: node.argument.literal.text });
|
||||||
} else if (ts.isCallExpression(node)
|
} else if (isModuleLoaderCall(node)) {
|
||||||
&& node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
const specifier = literalCallSpecifier(node);
|
||||||
const argument = node.arguments[0];
|
if (specifier !== null) {
|
||||||
if (node.arguments.length === 1 && argument && ts.isStringLiteralLike(argument)) {
|
edges.push({ kind: 'static', specifier });
|
||||||
edges.push({ kind: 'static', specifier: argument.text });
|
|
||||||
} else {
|
} else {
|
||||||
edges.push({ kind: 'dynamic' });
|
edges.push({ kind: 'dynamic' });
|
||||||
}
|
}
|
||||||
@@ -218,17 +220,18 @@ function extractImports(source, file) {
|
|||||||
? node.importClause.getText(sourceFile)
|
? node.importClause.getText(sourceFile)
|
||||||
: '',
|
: '',
|
||||||
});
|
});
|
||||||
}
|
} else if (ts.isImportEqualsDeclaration(node)) {
|
||||||
if (ts.isImportTypeNode(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.isLiteralTypeNode(node.argument)
|
||||||
&& ts.isStringLiteralLike(node.argument.literal)) {
|
&& ts.isStringLiteralLike(node.argument.literal)) {
|
||||||
imports.push({ specifier: node.argument.literal.text, bindings: '' });
|
imports.push({ specifier: node.argument.literal.text, bindings: '' });
|
||||||
}
|
} else if (isModuleLoaderCall(node)) {
|
||||||
if (ts.isCallExpression(node)
|
const specifier = literalCallSpecifier(node);
|
||||||
&& node.expression.kind === ts.SyntaxKind.ImportKeyword
|
if (specifier !== null) imports.push({ specifier, bindings: '' });
|
||||||
&& node.arguments.length === 1
|
|
||||||
&& ts.isStringLiteralLike(node.arguments[0])) {
|
|
||||||
imports.push({ specifier: node.arguments[0].text, bindings: '' });
|
|
||||||
}
|
}
|
||||||
ts.forEachChild(node, visit);
|
ts.forEachChild(node, visit);
|
||||||
};
|
};
|
||||||
@@ -236,6 +239,29 @@ function extractImports(source, file) {
|
|||||||
return imports;
|
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) {
|
function findViolation(context) {
|
||||||
const { file, specifier, bindings, resolved, frameworkDir, gamesDir } = context;
|
const { file, specifier, bindings, resolved, frameworkDir, gamesDir } = context;
|
||||||
const filePath = displayPath(file);
|
const filePath = displayPath(file);
|
||||||
|
|||||||
Reference in New Issue
Block a user