import { readdirSync, readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; import { FRAMEWORK_INTERNAL, SDK_ALLOWED, scanImportBoundaries, } from '../../scripts/lib/import-boundaries.mjs'; const projectRoot = fileURLToPath(new URL('../../', import.meta.url)); const presentationRoot = join( projectRoot, 'YouleNexus', 'assets', 'framework', 'presentation', ); function forbiddenSpecifier(specifier) { return specifier === 'cc' || specifier.startsWith('cc/') || /(?:^|\/)(?:net|protocol)(?:\/|$)/.test(specifier) || /(?:platform-session|room-rpc-bus)(?:\.|$)/.test(specifier) || /(?:^|\/)platform\/session(?:\.|$)/.test(specifier); } function permitsTypeOnlyContract(specifier) { return /(?:^|\/)protocol\/contracts(?:\/|$)/.test(specifier); } function staticSpecifier(node) { return node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ? node.moduleSpecifier.text : null; } function importIsTypeOnly(node) { const clause = node.importClause; if (!clause) return false; if (clause.isTypeOnly) return true; return clause.name === undefined && clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings) && clause.namedBindings.elements.length > 0 && clause.namedBindings.elements.every(element => element.isTypeOnly); } function exportIsTypeOnly(node) { if (node.isTypeOnly) return true; return node.exportClause !== undefined && ts.isNamedExports(node.exportClause) && node.exportClause.elements.length > 0 && node.exportClause.elements.every(element => element.isTypeOnly); } function externalImportEqualsSpecifier(node) { if (!ts.isExternalModuleReference(node.moduleReference) || !node.moduleReference.expression || !ts.isStringLiteralLike(node.moduleReference.expression)) return null; return node.moduleReference.expression.text; } function literalCallSpecifier(node) { if (node.arguments.length !== 1 || !ts.isStringLiteralLike(node.arguments[0])) return null; return node.arguments[0].text; } function presentationViolations(source, file = 'presentation-inline.ts') { const sourceFile = ts.createSourceFile( file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); const violations = []; const inspect = (specifier, typeOnly, kind) => { if (!forbiddenSpecifier(specifier)) return; if (typeOnly && permitsTypeOnlyContract(specifier)) return; violations.push({ file, specifier, kind }); }; const visit = (node) => { if (ts.isImportDeclaration(node)) { const specifier = staticSpecifier(node); if (specifier !== null) inspect(specifier, importIsTypeOnly(node), 'import'); } else if (ts.isExportDeclaration(node)) { const specifier = staticSpecifier(node); if (specifier !== null) inspect(specifier, exportIsTypeOnly(node), 'export'); } else if (ts.isImportEqualsDeclaration(node)) { const specifier = externalImportEqualsSpecifier(node); if (specifier !== null) inspect(specifier, false, 'import-equals'); } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteralLike(node.argument.literal)) { inspect(node.argument.literal.text, true, 'import-type'); } else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) { const specifier = literalCallSpecifier(node); if (specifier !== null) { inspect(specifier, false, 'call'); } else { violations.push({ file, specifier: '', kind: 'call' }); } } ts.forEachChild(node, visit); }; ts.forEachChild(sourceFile, visit); return violations; } function typescriptFiles(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); return entry.isDirectory() ? typescriptFiles(path) : path.endsWith('.ts') ? [path] : []; }); } function sdkBarrelViolations(source, file = 'sdk-index-inline.ts') { const sourceFile = ts.createSourceFile( file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); const violations = sourceFile.statements.flatMap((statement) => { const allowed = ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause === undefined && staticSpecifier(statement) === './contracts/index.ts' && statement.attributes === undefined; return allowed ? [] : [{ file, kind: ts.SyntaxKind[statement.kind], source: statement.getText(sourceFile), }]; }); if (violations.length === 0 && sourceFile.statements.length !== 1) { violations.push({ file, kind: 'ContractsExportCardinality', source: String(sourceFile.statements.length), }); } return violations; } test('presentation boundary recognizes forbidden production imports', () => { assert.equal(forbiddenSpecifier('../net/wire-client.ts'), true); assert.equal(forbiddenSpecifier('../protocol/contracts/index.ts'), true); assert.equal(forbiddenSpecifier('../platform/runtime.ts'), false); }); test('presentation AST distinguishes pure type contracts from runtime dependencies', () => { const samples = [ ["import type { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0], ["import { type LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0], ["type Login = import('../protocol/contracts/index.ts').LoginAccountIdentity;", 0], ["export type { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0], ["export { type LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0], ["import { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 1], ["import { type LoginAccountIdentity, buildLoginRequest } from '../protocol/contracts/index.ts';", 1], ["export { type LoginAccountIdentity, buildLoginRequest } from '../protocol/contracts/index.ts';", 1], ["export * from '../protocol/contracts/index.ts';", 1], ["void import('../protocol/contracts/index.ts');", 1], ["void import(target);", 1], ["const contracts = require('../protocol/contracts/index.ts');", 1], ["const contracts = require(target);", 1], ["import protocol = require('../protocol/contracts/index.ts');", 1], ["import type { WireClient } from '../net/wire-client.ts';", 1], ["import type { Node } from 'cc';", 1], ["import { Node } from 'cc';", 1], ["import { PlatformSession } from '../platform/platform-session.ts';", 1], ["import type { PlatformSession } from '../platform/session.ts';", 1], ["const bus = require('../platform/room-rpc-bus.ts');", 1], ["import { parse } from '@framework/protocol';", 1], ["export * from '@framework/net';", 1], ["import type { ScenePort } from '../platform/scene-port.ts';", 0], ]; for (const [source, expected] of samples) { assert.equal(presentationViolations(source).length, expected, source); } }); test('every real presentation source satisfies the value-dependency boundary', () => { const violations = typescriptFiles(presentationRoot).flatMap(file => ( presentationViolations(readFileSync(file, 'utf8'), file) )); assert.deepEqual(violations, []); }); test('SDK public barrel exports contracts only', () => { const sdkIndex = join(projectRoot, 'YouleNexus', 'assets', 'framework', 'sdk', 'index.ts'); assert.deepEqual(sdkBarrelViolations(readFileSync(sdkIndex, 'utf8'), sdkIndex), []); }); test('SDK contracts-only gate rejects a local exported declaration', () => { const source = 'export const unexpectedSdkApi = 1;'; assert.equal(sdkBarrelViolations(source).length, 1); }); test('SDK contracts-only gate requires exactly one contracts re-export', () => { assert.ok(sdkBarrelViolations('').length > 0); assert.ok(sdkBarrelViolations([ "export * from './contracts/index.ts';", "export * from './contracts/index.ts';", ].join('\n')).length > 0); }); test('SDK contracts-only gate rejects alternate exports and import-type leaks', () => { const forbiddenSources = [ 'const privateHelper = 1;', 'export function unexpectedSdkApi() {}', 'export default class UnexpectedSdkApi {}', 'export interface UnexpectedSdkApi {}', 'export type UnexpectedSdkApi = string;', "export { GameEntry } from './contracts/index.ts';", "export type { GameEntry } from './contracts/index.ts';", "export * as contracts from './contracts/index.ts';", "export * from '../presentation/ui-contracts.ts';", "type Leaked = import('../presentation/ui-contracts.ts').PageModel; export type { Leaked };", "import type { PageModel } from '../presentation/ui-contracts.ts'; export type { PageModel };", "export * from './contracts/index.ts'; export const unexpectedSdkApi = 1;", ]; assert.deepEqual(sdkBarrelViolations("export * from './contracts/index.ts';"), []); for (const source of forbiddenSources) { assert.ok(sdkBarrelViolations(source).length > 0, source); } }); test('general architecture scanner keeps presentation internal to framework and out of SDK', () => { assert.equal(FRAMEWORK_INTERNAL.test('framework/presentation/platform-ui-controller.ts'), true); assert.equal(SDK_ALLOWED.test('framework/presentation/platform-ui-controller.ts'), false); const violations = scanImportBoundaries({ frameworkDir: join(projectRoot, 'YouleNexus', 'assets', 'framework'), gamesDir: join(projectRoot, 'games'), tsconfigPath: join(projectRoot, 'tsconfig.framework.json'), }); assert.deepEqual(violations, []); });