feat(sdk): define single-game public contracts
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanImportBoundaries } from './lib/import-boundaries.mjs';
|
||||
|
||||
const projectDir = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const violations = scanImportBoundaries({
|
||||
frameworkDir: resolve(projectDir, 'YouleNexus/assets/framework'),
|
||||
gamesDir: resolve(projectDir, 'games'),
|
||||
});
|
||||
|
||||
if (violations.length > 0) {
|
||||
for (const { file, specifier, message } of violations) {
|
||||
console.error(`[import-boundaries] ${file}: ${specifier} — ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('[import-boundaries] OK');
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
||||
|
||||
const SDK_ALLOWED = /framework[\\/]sdk(?:[\\/]|$)/;
|
||||
const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|domain|presentation|ui|core|compat)(?:[\\/]|$)/;
|
||||
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']);
|
||||
|
||||
/**
|
||||
* Recursively scans TypeScript imports for framework/game layering violations.
|
||||
* Paths in violations are normalized for useful, cross-platform diagnostics.
|
||||
*/
|
||||
export function scanImportBoundaries(options) {
|
||||
const frameworkDir = resolve(options.frameworkDir);
|
||||
const gamesDir = resolve(options.gamesDir);
|
||||
const violations = [];
|
||||
|
||||
for (const file of [...typescriptFiles(frameworkDir), ...typescriptFiles(gamesDir)]) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const imported of extractImports(source)) {
|
||||
const resolved = imported.specifier.startsWith('.')
|
||||
? resolve(dirname(file), imported.specifier)
|
||||
: null;
|
||||
const violation = findViolation({
|
||||
file,
|
||||
specifier: imported.specifier,
|
||||
bindings: imported.bindings,
|
||||
resolved,
|
||||
frameworkDir,
|
||||
gamesDir,
|
||||
});
|
||||
if (violation) violations.push(violation);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function* typescriptFiles(directory) {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(directory);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const file = resolve(directory, entry);
|
||||
if (statSync(file).isDirectory()) yield* typescriptFiles(file);
|
||||
else if (file.endsWith('.ts')) yield file;
|
||||
}
|
||||
}
|
||||
|
||||
function extractImports(source) {
|
||||
const imports = [];
|
||||
const staticImport = /\bimport\s+(?!\()(?:(?:type\s+)?([^;\n]+?)\s+from\s+)?(['"])([^'"\n]+)\2/g;
|
||||
for (const match of source.matchAll(staticImport)) {
|
||||
imports.push({ specifier: match[3], bindings: match[1] ?? '' });
|
||||
}
|
||||
const dynamicImport = /\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g;
|
||||
for (const match of source.matchAll(dynamicImport)) {
|
||||
imports.push({ specifier: match[2], bindings: '' });
|
||||
}
|
||||
return imports;
|
||||
}
|
||||
|
||||
function findViolation(context) {
|
||||
const { file, specifier, bindings, resolved, frameworkDir, gamesDir } = context;
|
||||
const filePath = displayPath(file);
|
||||
const resolvedPath = resolved ? displayPath(resolved) : specifier;
|
||||
const isContract = CONTRACTS_PATH.test(filePath);
|
||||
const isGame = isInside(file, gamesDir);
|
||||
const isFramework = isInside(file, frameworkDir);
|
||||
|
||||
if (isContract && specifier === 'cc') {
|
||||
return violation(filePath, specifier, `sdk/contracts cannot import cc (${filePath})`);
|
||||
}
|
||||
|
||||
if (isContract && resolved && dirname(resolved) !== dirname(file)) {
|
||||
return violation(filePath, specifier, `sdk/contracts may import only sibling contracts; sdk path resolved to ${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}`);
|
||||
}
|
||||
|
||||
if (isGame && resolved && SDK_ALLOWED.test(resolvedPath) && importsMigrationOnlyName(bindings)) {
|
||||
return violation(filePath, specifier, `game code cannot import ${migrationOnlyName(bindings)}; it is migration-only`);
|
||||
}
|
||||
|
||||
if (isFramework && resolved && isInside(resolved, gamesDir)) {
|
||||
return violation(filePath, specifier, `framework code cannot import games; framework import resolved to ${resolvedPath}`);
|
||||
}
|
||||
|
||||
if (resolved && LEGACY_RUNTIME.test(resolvedPath) && !isLegacyRuntimeImporter(filePath, frameworkDir)) {
|
||||
return violation(filePath, specifier, `new production code cannot import quarantined legacy runtime ${resolvedPath}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFrameworkReference(path, frameworkDir) {
|
||||
return isInside(path, frameworkDir)
|
||||
|| SDK_ALLOWED.test(path)
|
||||
|| FRAMEWORK_INTERNAL.test(path)
|
||||
|| /(?:^|[\\/])framework(?:[\\/]|$)/.test(path);
|
||||
}
|
||||
|
||||
function isLegacyRuntimeImporter(filePath, frameworkDir) {
|
||||
const normalizedFrameworkDir = displayPath(frameworkDir);
|
||||
return filePath === `${normalizedFrameworkDir}/sdk/index.ts` || LEGACY_RUNTIME.test(filePath);
|
||||
}
|
||||
|
||||
function importsMigrationOnlyName(bindings) {
|
||||
return [...MIGRATION_ONLY_NAMES].some((name) => new RegExp(`\\b${name}\\b`).test(bindings));
|
||||
}
|
||||
|
||||
function migrationOnlyName(bindings) {
|
||||
return [...MIGRATION_ONLY_NAMES].find((name) => new RegExp(`\\b${name}\\b`).test(bindings));
|
||||
}
|
||||
|
||||
function isInside(file, directory) {
|
||||
const pathFromDirectory = relative(directory, file);
|
||||
return pathFromDirectory !== '' && !pathFromDirectory.startsWith('..') && !isAbsolute(pathFromDirectory);
|
||||
}
|
||||
|
||||
function displayPath(file) {
|
||||
return file.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function violation(file, specifier, message) {
|
||||
return { file, specifier, message };
|
||||
}
|
||||
|
||||
export { FRAMEWORK_INTERNAL, LEGACY_RUNTIME, SDK_ALLOWED };
|
||||
Reference in New Issue
Block a user