fix(sdk): enforce runtime dependency boundary
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user