diablo2-web/tests/arch/boundaries.test.ts

237 lines
9.6 KiB
TypeScript

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<string, ReadonlySet<string>> = {
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: play.html is the sole HTML entry point in vite.config.ts and all 5 legacy HTML files are deleted', () => {
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')
const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8')
expect(viteCfg).toContain('play.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([])
})
})