import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import * as ts from 'typescript' import { describe, expect, it } from 'vitest' const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT_DIR = resolve(__dirname, '../..') const SRC_DIR = resolve(ROOT_DIR, 'src') const TOOLS_DIR = resolve(ROOT_DIR, 'tools') const CANONICAL_MODULES = ['baker', 'client', 'common', 'netproto', 'server'] as const const LEGACY_HTML_FILES = ['index.html', 'acts.html', 'acts-zh.html', 'acts-en.html', 'bnet.html'] as const const ALLOWED_MODULE_TARGETS: Record> = { common: new Set(['common']), netproto: new Set(['netproto']), server: new Set(['common', 'netproto', 'server']), client: new Set(['common', 'netproto', 'client']), baker: new Set(['common', 'baker']), } function collectTsFiles(dir: string): string[] { const results: string[] = [] let entries: string[] = [] try { entries = readdirSync(dir) } catch { return results } for (const name of entries) { const full = resolve(dir, name) const st = statSync(full) if (st.isDirectory()) { results.push(...collectTsFiles(full)) } else if (name.endsWith('.ts')) { results.push(full) } } return results } function stripComments(source: string): string { return source .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) .replace(/\/\/[^\n]*/g, '') } function extractImportSpecifiers(source: string): string[] { const stripped = stripComments(source) const specs: string[] = [] const fromRe = /(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g let m: RegExpExecArray | null while ((m = fromRe.exec(stripped)) !== null) { if (m[1]) specs.push(m[1]) } while ((m = dynRe.exec(stripped)) !== null) { if (m[1]) specs.push(m[1]) } return specs } describe('Architectural Boundaries & 5-Module Isolation (R3, F9 — Zero Whitelist Exceptions)', () => { it('Rule 1: src/ contains ONLY the 5 canonical module directories (common, netproto, server, client, baker)', () => { const entries = readdirSync(SRC_DIR).sort() expect(entries).toEqual([...CANONICAL_MODULES].sort()) for (const entry of entries) { expect(statSync(resolve(SRC_DIR, entry)).isDirectory()).toBe(true) } }) it('Rule 2: enforces strict acyclic module dependency DAG across common, netproto, server, client, and baker (0 exceptions)', () => { const violations: string[] = [] for (const modName of CANONICAL_MODULES) { const files = collectTsFiles(resolve(SRC_DIR, modName)) expect(files.length).toBeGreaterThan(0) for (const file of files) { const relFile = relative(ROOT_DIR, file) const specs = extractImportSpecifiers(readFileSync(file, 'utf8')) for (const spec of specs) { if (!spec.startsWith('.')) continue const targetAbs = resolve(dirname(file), spec) const targetRel = relative(ROOT_DIR, targetAbs).replace(/\\/g, '/') if (targetRel.startsWith('src/')) { const targetMod = targetRel.split('/')[1] ?? '' if (!ALLOWED_MODULE_TARGETS[modName]?.has(targetMod)) { violations.push(`${relFile} (${modName}) -> ${targetRel} (${targetMod})`) } } else { violations.push(`${relFile} (${modName}) imports outside src/: ${targetRel}`) } } } } expect(violations).toEqual([]) }) it('Rule 3: common, netproto, server, and client never import node:* builtins or Node globals (0 exceptions)', () => { const browserAndSimModules = ['common', 'netproto', 'server', 'client'] as const const violations: string[] = [] for (const modName of browserAndSimModules) { for (const file of collectTsFiles(resolve(SRC_DIR, modName))) { const relFile = relative(ROOT_DIR, file) const source = readFileSync(file, 'utf8') for (const spec of extractImportSpecifiers(source)) { if (spec.startsWith('node:') || spec === 'fs' || spec === 'path' || spec === 'child_process') { violations.push(`${relFile}: forbidden Node import '${spec}'`) } } const stripped = stripComments(source) if (/\bnode:(?:fs|path|url|crypto|zlib|child_process|net|os|http|https)\b/.test(stripped)) { violations.push(`${relFile}: contains node:* string literal`) } } } expect(violations).toEqual([]) }) it('Rule 4: common, netproto, and server are strictly deterministic (zero Math.random, Date.now, performance.now) and zero DOM globals (0 exceptions)', () => { const pureModules = ['common', 'netproto', 'server'] as const const violations: string[] = [] for (const modName of pureModules) { for (const file of collectTsFiles(resolve(SRC_DIR, modName))) { const relFile = relative(ROOT_DIR, file) const stripped = stripComments(readFileSync(file, 'utf8')) if (/\bMath\.random\s*\(/.test(stripped)) { violations.push(`${relFile}: forbidden Math.random()`) } if (modName !== 'netproto' && /\b(?:Date\.now|performance\.now)\s*\(/.test(stripped)) { violations.push(`${relFile}: forbidden wall-clock call Date.now()/performance.now()`) } if ( /\b(?:window|document|localStorage|HTMLCanvasElement|WebGL2RenderingContext|WebGLRenderingContext|AudioContext)\b/.test(stripped) ) { violations.push(`${relFile}: forbidden DOM global reference`) } } } expect(violations).toEqual([]) }) it('Rule 5: baker never references DOM globals and never imports client, server, or netproto (0 exceptions)', () => { const violations: string[] = [] for (const file of collectTsFiles(resolve(SRC_DIR, 'baker'))) { const relFile = relative(ROOT_DIR, file) const stripped = stripComments(readFileSync(file, 'utf8')) if (/\b(?:window|document|localStorage|HTMLCanvasElement|WebGL2RenderingContext|AudioContext)\b/.test(stripped)) { violations.push(`${relFile}: forbidden DOM global in baker`) } } expect(violations).toEqual([]) }) it('Rule 6: every import from src/{common,server,client,baker}/** and tools/** into src/netproto/ resolves specifically to src/netproto/index.ts', () => { const netprotoDir = resolve(SRC_DIR, 'netproto') const netprotoIndex = resolve(netprotoDir, 'index.ts') const violations: string[] = [] const filesToCheck = [ ...(['common', 'server', 'client', 'baker'] as const).flatMap((m) => collectTsFiles(resolve(SRC_DIR, m)), ), ...collectTsFiles(TOOLS_DIR), ] for (const file of filesToCheck) { const relFile = relative(ROOT_DIR, file) for (const spec of extractImportSpecifiers(readFileSync(file, 'utf8'))) { if (!spec.startsWith('.')) continue const target = resolve(dirname(file), spec) if (target === netprotoDir || target.startsWith(netprotoDir + '/')) { const resolvedTarget = target.endsWith('.ts') ? target : `${target}.ts` if (resolvedTarget !== netprotoIndex && target !== netprotoDir) { violations.push(`${relFile}: imports netproto internal '${spec}' (must import src/netproto/index.ts)`) } } } } expect(violations).toEqual([]) }) it('Rule 7: root HTML entry points are exactly {play.html, stats.html} in vite.config.ts and all 5 legacy HTML files are deleted', () => { const rootHtmlFiles = readdirSync(ROOT_DIR) .filter((f) => f.endsWith('.html')) .sort() expect(rootHtmlFiles).toEqual(['play.html', 'stats.html']) expect(existsSync(resolve(ROOT_DIR, 'play.html'))).toBe(true) const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8') expect(playHtml).toContain('/src/client/main.ts') expect(playHtml).toContain('D2Exocet') expect(playHtml).toContain('D2Formal436') expect(existsSync(resolve(ROOT_DIR, 'stats.html'))).toBe(true) const statsHtml = readFileSync(resolve(ROOT_DIR, 'stats.html'), 'utf8') expect(statsHtml).toContain('/src/client/stats/stats-main.ts') expect(statsHtml).toContain('D2Exocet') expect(statsHtml).toContain('D2Formal436') const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8') expect(viteCfg).toContain('play.html') expect(viteCfg).toContain('stats.html') for (const legacyHtml of LEGACY_HTML_FILES) { expect(existsSync(resolve(ROOT_DIR, legacyHtml))).toBe(false) expect(viteCfg).not.toContain(legacyHtml) } }) it('Rule 8: src/** contains zero .test.ts/.spec.ts files, zero non-literal dynamic import(...) calls, and zero new Function("...import(...") bypasses', () => { const allSrcFiles = collectTsFiles(SRC_DIR) const testFilesInSrc = allSrcFiles .map((f) => relative(ROOT_DIR, f)) .filter((f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts') || f.includes('__tests__')) expect(testFilesInSrc).toEqual([]) const dynamicViolations: string[] = [] for (const file of allSrcFiles) { const relFile = relative(ROOT_DIR, file) const text = readFileSync(file, 'utf8') const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) const visit = (node: ts.Node) => { if ( ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ) { const arg = node.arguments[0] if (!arg || !ts.isStringLiteral(arg)) { dynamicViolations.push(`${relFile}: non-literal dynamic import(${arg?.getText(sf) ?? ''})`) } } if ( ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'Function' ) { const callText = node.getText(sf) if (callText.includes('import(')) { dynamicViolations.push(`${relFile}: new Function dynamic import bypass (${callText})`) } } ts.forEachChild(node, visit) } visit(sf) } expect(dynamicViolations).toEqual([]) }) })