fix(sdk): enforce runtime dependency boundary

This commit is contained in:
2026-09-05 08:01:07 +08:00
parent 471e2e9479
commit dfaa4f0ef4
4 changed files with 45 additions and 11 deletions
@@ -5,9 +5,8 @@ import type {
GameServerMessage, GameServerMessage,
PlatformToGameEvent, PlatformToGameEvent,
} from '../contracts/index.ts'; } from '../contracts/index.ts';
import { Route } from '../../core/constants.ts';
const RESERVED_ROUTES = new Set<string>(Object.values(Route)); const RESERVED_GAME_ENTRY_ROUTES = new Set(['platform', 'agent', 'room']);
const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for( const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for(
'youle.framework.sdk.GameSessionOpenFault', 'youle.framework.sdk.GameSessionOpenFault',
); );
@@ -104,7 +103,7 @@ export function assertGameEntry(entry: GameEntry): void {
/** The single GameEntry route admission check shared by runtime adapters. */ /** The single GameEntry route admission check shared by runtime adapters. */
export function assertGameRoute(route: unknown): asserts route is string { export function assertGameRoute(route: unknown): asserts route is string {
assertNonEmptyText(route, 'route'); assertNonEmptyText(route, 'route');
if (RESERVED_ROUTES.has(route)) { if (RESERVED_GAME_ENTRY_ROUTES.has(route)) {
throw new Error(`GameEntry reserved route: ${route}`); throw new Error(`GameEntry reserved route: ${route}`);
} }
} }
@@ -187,6 +187,36 @@ test('scanner rejects sdk contracts importing a framework platform path', async
assert.match(scan(root)[0].message, /sdk.*platform/); assert.match(scan(root)[0].message, /sdk.*platform/);
}); });
test('scanner rejects every sdk runtime dependency on framework implementation layers', async () => {
const layers = [
'core',
'platform',
'net',
'config',
'native',
'protocol',
'application',
'domain',
'presentation',
'ui',
'compat',
];
for (const layer of layers) {
const root = await createFixtureRoot();
await writeFixture(root, `framework/${layer}/internal.ts`, 'export const value = true\n');
await writeFixture(
root,
'framework/sdk/runtime/bad.ts',
`import { value } from '../../${layer}/internal.ts'; void value\n`,
);
const violations = scan(root);
assert.equal(violations.length, 1, layer);
assert.match(violations[0].message, /sdk.*framework implementation/, layer);
}
});
test('scanner rejects game imports of framework net internals', async () => { test('scanner rejects game imports of framework net internals', async () => {
const root = await createFixtureRoot(); const root = await createFixtureRoot();
await writeFixture(root, 'games/a/assets/game/bad.ts', "import '../../../framework/net/net-client.ts'\n"); await writeFixture(root, 'games/a/assets/game/bad.ts', "import '../../../framework/net/net-client.ts'\n");
@@ -122,14 +122,6 @@ test('Task 1 route validator is the shared authority for game-route admission',
for (const route of ['platform', 'agent', 'room']) { for (const route of ['platform', 'agent', 'room']) {
assert.throws(() => assertGameRoute(route), /reserved.*route/i); assert.throws(() => assertGameRoute(route), /reserved.*route/i);
} }
const validatorSource = readFileSync(new URL(
'../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts',
import.meta.url,
), 'utf8');
assert.match(validatorSource, /import \{ Route \} from '\.\.\/\.\.\/core\/constants\.ts';/);
assert.match(validatorSource, /new Set<string>\(Object\.values\(Route\)\)/);
assert.doesNotMatch(validatorSource, /new Set\(\[['"]platform['"]/);
}); });
test('execute exposes only the GameHost command union and delegates semantic commands', () => { test('execute exposes only the GameHost command union and delegates semantic commands', () => {
@@ -221,6 +221,7 @@ function findViolation(context) {
const filePath = displayPath(file); const filePath = displayPath(file);
const resolvedPath = resolved ? displayPath(resolved) : specifier; const resolvedPath = resolved ? displayPath(resolved) : specifier;
const isContract = CONTRACTS_PATH.test(filePath); const isContract = CONTRACTS_PATH.test(filePath);
const isSdk = SDK_ALLOWED.test(filePath);
const isGame = isInside(file, gamesDir); const isGame = isInside(file, gamesDir);
const isFramework = isInside(file, frameworkDir); const isFramework = isInside(file, frameworkDir);
@@ -232,6 +233,14 @@ function findViolation(context) {
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}`);
} }
if (isSdk
&& !isSdkMigrationBarrel(filePath, frameworkDir)
&& resolved
&& isInside(resolved, frameworkDir)
&& !SDK_ALLOWED.test(resolvedPath)) {
return violation(filePath, specifier, `sdk cannot import framework implementation ${resolvedPath}`);
}
if (isGame && resolved && isFrameworkReference(resolved, frameworkDir) && !SDK_ALLOWED.test(resolvedPath)) { if (isGame && resolved && isFrameworkReference(resolved, frameworkDir) && !SDK_ALLOWED.test(resolvedPath)) {
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}`);
} }
@@ -264,6 +273,10 @@ function isLegacyRuntimeImporter(filePath, frameworkDir) {
return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath); return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath);
} }
function isSdkMigrationBarrel(filePath, frameworkDir) {
return filePath === `${displayPath(frameworkDir)}/sdk/index.ts`;
}
/** /**
* ActiveGame is the retained compatibility owner of IGameModule until Task 11 * ActiveGame is the retained compatibility owner of IGameModule until Task 11
* removes both it and the migration-only declarations from sdk/index.ts. * removes both it and the migration-only declarations from sdk/index.ts.