234 lines
9.7 KiB
JavaScript
234 lines
9.7 KiB
JavaScript
import { afterEach, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import ts from 'typescript';
|
|
import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs';
|
|
|
|
const roots = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
async function createFixtureRoot() {
|
|
const root = await mkdtemp(join(tmpdir(), 'youle-import-boundaries-'));
|
|
roots.push(root);
|
|
return root;
|
|
}
|
|
|
|
async function writeFixture(root, relativePath, source) {
|
|
const file = join(root, relativePath);
|
|
await mkdir(dirname(file), { recursive: true });
|
|
await writeFile(file, source, 'utf8');
|
|
}
|
|
|
|
function scan(root) {
|
|
return scanImportBoundaries({
|
|
frameworkDir: join(root, 'framework'),
|
|
gamesDir: join(root, 'games'),
|
|
});
|
|
}
|
|
|
|
const frameworkRoot = fileURLToPath(new URL('../../YouleNexus/assets/framework/', import.meta.url));
|
|
const modernRuntimeEntries = [
|
|
'net/wire-client.ts',
|
|
'platform/runtime-session.ts',
|
|
'protocol/platform-handlers.ts',
|
|
'protocol/router.ts',
|
|
// Task 10 creates this entry; the same assertion starts covering it as soon as it exists.
|
|
'platform/runtime.ts',
|
|
];
|
|
const legacyRuntimeFiles = new Set([
|
|
'net/net-client.ts',
|
|
'platform/session.ts',
|
|
'platform/startup.ts',
|
|
'platform/room-rpc-bus.ts',
|
|
'platform/readonly.ts',
|
|
'platform/stores/app-store.ts',
|
|
'platform/stores/player-store.ts',
|
|
'platform/stores/room-store.ts',
|
|
'platform/stores/types.ts',
|
|
'protocol/room-handlers.ts',
|
|
].map((file) => resolve(frameworkRoot, file)));
|
|
|
|
function relativeImports(file) {
|
|
const source = readFileSync(file, 'utf8');
|
|
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
|
|
const imports = [];
|
|
const visit = (node) => {
|
|
let specifier;
|
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
|
|
&& node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
specifier = node.moduleSpecifier.text;
|
|
} else if (ts.isImportTypeNode(node)
|
|
&& ts.isLiteralTypeNode(node.argument)
|
|
&& ts.isStringLiteral(node.argument.literal)) {
|
|
specifier = node.argument.literal.text;
|
|
} else if (ts.isCallExpression(node)
|
|
&& node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
&& node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) {
|
|
specifier = node.arguments[0].text;
|
|
}
|
|
if (specifier?.startsWith('.')) {
|
|
const imported = resolve(dirname(file), specifier);
|
|
const candidates = [imported, `${imported}.ts`, join(imported, 'index.ts')];
|
|
const resolvedImport = candidates.find((candidate) => existsSync(candidate));
|
|
if (resolvedImport) imports.push(resolvedImport);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
ts.forEachChild(sourceFile, visit);
|
|
return imports;
|
|
}
|
|
|
|
function findLegacyDependency(entry, path = [], visited = new Set()) {
|
|
if (legacyRuntimeFiles.has(entry)) return [...path, entry];
|
|
if (visited.has(entry)) return null;
|
|
visited.add(entry);
|
|
for (const imported of relativeImports(entry)) {
|
|
const found = findLegacyDependency(imported, [...path, entry], visited);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => {
|
|
const entries = modernRuntimeEntries
|
|
.map((file) => resolve(frameworkRoot, file))
|
|
.filter((file) => existsSync(file));
|
|
assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`)));
|
|
|
|
for (const entry of entries) {
|
|
const dependencyPath = findLegacyDependency(entry);
|
|
assert.equal(
|
|
dependencyPath,
|
|
null,
|
|
dependencyPath?.map((file) => file.slice(frameworkRoot.length)).join(' -> '),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('scanner rejects sdk contracts importing a framework platform path', async () => {
|
|
const root = await createFixtureRoot();
|
|
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
|
|
assert.match(scan(root)[0].message, /sdk.*platform/);
|
|
});
|
|
|
|
test('scanner rejects game imports of framework net internals', async () => {
|
|
const root = await createFixtureRoot();
|
|
await writeFixture(root, 'games/a/assets/game/bad.ts', "import '../../../framework/net/net-client.ts'\n");
|
|
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");
|
|
assert.deepEqual(scan(root), []);
|
|
});
|
|
|
|
test('scanner rejects games importing migration-only sdk declarations', async () => {
|
|
const root = await createFixtureRoot();
|
|
await writeFixture(root, 'games/a/assets/game/bad.ts', "import type { GameContext } from '../../../../framework/sdk/index.ts'\n");
|
|
assert.match(scan(root)[0].message, /game.*GameContext.*migration-only/);
|
|
});
|
|
|
|
test('scanner rejects cc imports in sdk contracts', async () => {
|
|
const root = await createFixtureRoot();
|
|
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import { Node } from 'cc'\n");
|
|
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");
|
|
assert.deepEqual(scan(root), []);
|
|
});
|
|
|
|
test('scanner rejects framework imports resolving under games', async () => {
|
|
const root = await createFixtureRoot();
|
|
await writeFixture(root, 'framework/ui/bad.ts', "import '../../games/a/assets/game/entry.ts'\n");
|
|
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");
|
|
const violations = scan(root);
|
|
assert.equal(violations.length, 1);
|
|
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");
|
|
assert.match(scan(root)[0].message, /game.*framework\/net/);
|
|
});
|