fix(sdk): close boundary alias bypasses
This commit is contained in:
@@ -28,10 +28,11 @@ async function writeFixture(root, relativePath, source) {
|
||||
await writeFile(file, source, 'utf8');
|
||||
}
|
||||
|
||||
function scan(root) {
|
||||
function scan(root, tsconfigPath) {
|
||||
return scanImportBoundaries({
|
||||
frameworkDir: join(root, 'framework'),
|
||||
gamesDir: join(root, 'games'),
|
||||
tsconfigPath: tsconfigPath ? join(root, tsconfigPath) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,6 +218,84 @@ test('scanner rejects every sdk runtime dependency on framework implementation l
|
||||
}
|
||||
});
|
||||
|
||||
test('scanner resolves and rejects an sdk runtime 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/bad.ts',
|
||||
"import { value } from '@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 rejects Cocos imports throughout sdk runtime', async () => {
|
||||
for (const specifier of ['cc', 'cc/env']) {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(
|
||||
root,
|
||||
'framework/sdk/runtime/bad.ts',
|
||||
`import { Node } from '${specifier}'; void Node\n`,
|
||||
);
|
||||
|
||||
const violations = scan(root);
|
||||
assert.equal(violations.length, 1, specifier);
|
||||
assert.equal(violations[0].specifier, specifier);
|
||||
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 [
|
||||
'framework/core/events.ts',
|
||||
'framework/core/reactive.ts',
|
||||
'framework/platform/readonly.ts',
|
||||
'framework/platform/stores/types.ts',
|
||||
'framework/core/constants.ts',
|
||||
]) {
|
||||
await writeFixture(root, file, 'export interface Value {}\n');
|
||||
}
|
||||
await writeFixture(root, 'framework/sdk/index.ts', [
|
||||
"import type { Value as Events } from '../core/events.ts'; void (0 as unknown as Events);",
|
||||
"import type { Value as Reactive } from '../core/reactive.ts'; void (0 as unknown as Reactive);",
|
||||
"import type { Value as ReadonlyStore } from '../platform/readonly.ts'; void (0 as unknown as ReadonlyStore);",
|
||||
"import type { Value as StoreTypes } from '../platform/stores/types.ts'; void (0 as unknown as StoreTypes);",
|
||||
"import type { Value as Constants } from '../core/constants.ts'; void (0 as unknown as Constants);",
|
||||
].join('\n'));
|
||||
|
||||
const violations = scan(root);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].specifier, '../core/constants.ts');
|
||||
assert.match(violations[0].message, /sdk.*framework implementation/);
|
||||
});
|
||||
|
||||
test('scanner permits sdk-internal relatives and ordinary external pure dependencies', async () => {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(root, 'framework/sdk/contracts/index.ts', 'export interface Contract {}\n');
|
||||
await writeFixture(root, 'framework/sdk/runtime/helper.ts', 'export const helper = true\n');
|
||||
await writeFixture(root, 'framework/sdk/runtime/allowed.ts', [
|
||||
"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);",
|
||||
].join('\n'));
|
||||
|
||||
assert.deepEqual(scan(root), []);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -6,6 +6,7 @@ const projectDir = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const violations = scanImportBoundaries({
|
||||
frameworkDir: resolve(projectDir, 'YouleNexus/assets/framework'),
|
||||
gamesDir: resolve(projectDir, 'games'),
|
||||
tsconfigPath: resolve(projectDir, 'tsconfig.framework.json'),
|
||||
});
|
||||
|
||||
if (violations.length > 0) {
|
||||
|
||||
@@ -7,6 +7,12 @@ const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|d
|
||||
const LEGACY_RUNTIME = /framework[\\/](?:net[\\/]net-client|platform[\\/](?:session|startup|room-rpc-bus|readonly|stores[\\/](?:app-store|player-store|room-store|types))|protocol[\\/]room-handlers)\.ts$/;
|
||||
const CONTRACTS_PATH = /framework[\\/]sdk[\\/]contracts(?:[\\/]|$)/;
|
||||
const MIGRATION_ONLY_NAMES = new Set(['GameContext', 'IGameModule']);
|
||||
const SDK_INDEX_MIGRATION_DEPENDENCIES = new Set([
|
||||
'core/events.ts',
|
||||
'core/reactive.ts',
|
||||
'platform/readonly.ts',
|
||||
'platform/stores/types.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Recursively scans TypeScript imports for framework/game layering violations.
|
||||
@@ -15,14 +21,28 @@ const MIGRATION_ONLY_NAMES = new Set(['GameContext', 'IGameModule']);
|
||||
export function scanImportBoundaries(options) {
|
||||
const frameworkDir = resolve(options.frameworkDir);
|
||||
const gamesDir = resolve(options.gamesDir);
|
||||
const compilerOptions = readCompilerOptions(options.tsconfigPath);
|
||||
const resolutionBase = options.tsconfigPath
|
||||
? dirname(resolve(options.tsconfigPath))
|
||||
: frameworkDir;
|
||||
const moduleCache = ts.createModuleResolutionCache(
|
||||
resolutionBase,
|
||||
(file) => pathKey(canonicalPath(file)),
|
||||
compilerOptions,
|
||||
);
|
||||
const violations = [];
|
||||
|
||||
for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const imported of extractImports(source, file)) {
|
||||
const resolved = imported.specifier.startsWith('.')
|
||||
? resolve(dirname(file), imported.specifier)
|
||||
: null;
|
||||
const resolved = resolveDependency(
|
||||
imported.specifier,
|
||||
file,
|
||||
compilerOptions,
|
||||
moduleCache,
|
||||
) ?? (imported.specifier.startsWith('.')
|
||||
? canonicalPath(resolve(dirname(file), imported.specifier))
|
||||
: null);
|
||||
const violation = findViolation({
|
||||
file,
|
||||
specifier: imported.specifier,
|
||||
@@ -233,11 +253,15 @@ function findViolation(context) {
|
||||
return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; sdk path resolved to ${resolvedPath}`);
|
||||
}
|
||||
|
||||
if (isSdk && isCocosSpecifier(specifier)) {
|
||||
return violation(filePath, specifier, `sdk cannot import Cocos module ${specifier}`);
|
||||
}
|
||||
|
||||
if (isSdk
|
||||
&& !isSdkMigrationBarrel(filePath, frameworkDir)
|
||||
&& resolved
|
||||
&& isInside(resolved, frameworkDir)
|
||||
&& !SDK_ALLOWED.test(resolvedPath)) {
|
||||
&& !SDK_ALLOWED.test(resolvedPath)
|
||||
&& !isSdkIndexMigrationDependency(filePath, resolved, frameworkDir)) {
|
||||
return violation(filePath, specifier, `sdk cannot import framework implementation ${resolvedPath}`);
|
||||
}
|
||||
|
||||
@@ -273,8 +297,13 @@ function isLegacyRuntimeImporter(filePath, frameworkDir) {
|
||||
return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath);
|
||||
}
|
||||
|
||||
function isSdkMigrationBarrel(filePath, frameworkDir) {
|
||||
return filePath === `${displayPath(frameworkDir)}/sdk/index.ts`;
|
||||
function isSdkIndexMigrationDependency(filePath, resolved, frameworkDir) {
|
||||
if (filePath !== `${displayPath(frameworkDir)}/sdk/index.ts`) return false;
|
||||
return SDK_INDEX_MIGRATION_DEPENDENCIES.has(displayPath(relative(frameworkDir, resolved)));
|
||||
}
|
||||
|
||||
function isCocosSpecifier(specifier) {
|
||||
return specifier === 'cc' || specifier.startsWith('cc/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user