fix(sdk): harden import boundary scanner
This commit is contained in:
@@ -42,6 +42,22 @@ test('scanner rejects game imports of framework net internals', async () => {
|
|||||||
assert.match(scan(root)[0].message, /game.*framework\/net/);
|
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 () => {
|
test('scanner permits a game import of the public sdk barrel', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'games/a/assets/game/allowed.ts', "import '../../../../framework/sdk/index.ts'\n");
|
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/);
|
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 () => {
|
test('scanner permits cc imports in game code', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'games/a/assets/game/allowed.ts', "import { Node } from 'cc'\n");
|
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/);
|
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 () => {
|
test('scanner rejects new production imports of a quarantined legacy runtime', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'framework/application/bad.ts', "import '../net/net-client.ts'\n");
|
await writeFixture(root, 'framework/application/bad.ts', "import '../net/net-client.ts'\n");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
const SDK_ALLOWED = /framework[\\/]sdk(?:[\\/]|$)/;
|
const SDK_ALLOWED = /framework[\\/]sdk(?:[\\/]|$)/;
|
||||||
const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|domain|presentation|ui|core|compat)(?:[\\/]|$)/;
|
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)]) {
|
for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) {
|
||||||
const source = readFileSync(file, 'utf8');
|
const source = readFileSync(file, 'utf8');
|
||||||
for (const imported of extractImports(source)) {
|
for (const imported of extractImports(source, file)) {
|
||||||
const resolved = imported.specifier.startsWith('.')
|
const resolved = imported.specifier.startsWith('.')
|
||||||
? resolve(dirname(file), imported.specifier)
|
? resolve(dirname(file), imported.specifier)
|
||||||
: null;
|
: null;
|
||||||
@@ -52,16 +53,35 @@ function* typescriptFiles(directory) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractImports(source) {
|
function extractImports(source, file) {
|
||||||
const imports = [];
|
const imports = [];
|
||||||
const staticImport = /\bimport\s+(?!\()(?:(?:type\s+)?([^;\n]+?)\s+from\s+)?(['"])([^'"\n]+)\2/g;
|
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
|
||||||
for (const match of source.matchAll(staticImport)) {
|
|
||||||
imports.push({ specifier: match[3], bindings: match[1] ?? '' });
|
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)
|
||||||
|
: '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const dynamicImport = /\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g;
|
if (ts.isImportTypeNode(node)
|
||||||
for (const match of source.matchAll(dynamicImport)) {
|
&& ts.isLiteralTypeNode(node.argument)
|
||||||
imports.push({ specifier: match[2], bindings: '' });
|
&& 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;
|
return imports;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +93,11 @@ function findViolation(context) {
|
|||||||
const isGame = isInside(file, gamesDir);
|
const isGame = isInside(file, gamesDir);
|
||||||
const isFramework = isInside(file, frameworkDir);
|
const isFramework = isInside(file, frameworkDir);
|
||||||
|
|
||||||
if (isContract && specifier === 'cc') {
|
if (isContract && !resolved) {
|
||||||
return violation(filePath, specifier, `sdk/contracts cannot import cc (${filePath})`);
|
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}`);
|
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}`);
|
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)) {
|
if (importsMigrationOnlyName(bindings) && !isMigrationOnlyImporter(filePath, frameworkDir)) {
|
||||||
return violation(filePath, specifier, `game code cannot import ${migrationOnlyName(bindings)}; it is migration-only`);
|
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)) {
|
if (isFramework && resolved && isInside(resolved, gamesDir)) {
|
||||||
@@ -112,6 +133,14 @@ function isLegacyRuntimeImporter(filePath, frameworkDir) {
|
|||||||
return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath);
|
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) {
|
function importsMigrationOnlyName(bindings) {
|
||||||
return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings));
|
return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user