import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import ts from 'typescript'; const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); const INTERNAL_MESSAGE_MARKERS = ['MessageEnvelope', 'message-contracts']; async function collectTypeScriptFiles(directory) { const entries = await readdir(directory, { withFileTypes: true }); const nestedFiles = await Promise.all( entries.map(async (entry) => { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); return TYPESCRIPT_EXTENSIONS.has(path.extname(entry.name)) ? [entryPath] : []; }), ); return nestedFiles.flat(); } function collectModuleSpecifiers(sourceFile) { const specifiers = []; function visit(node) { if ( (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ) { specifiers.push(node.moduleSpecifier.text); } if ( ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0]) ) { specifiers.push(node.arguments[0].text); } ts.forEachChild(node, visit); } visit(sourceFile); return specifiers; } function resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions) { const serverRoot = path.join(repositoryRoot, 'server'); const resolved = ts.resolveModuleName(specifier, sourcePath, compilerOptions, ts.sys) .resolvedModule?.resolvedFileName; // 模块解析在 fixture 没有目标文件时可能失败,此时仍需按导入者目录规范化相对路径, // 避免通过尚未生成的 Server 文件绕过工程边界。 const candidate = resolved ?? (specifier.startsWith('.') ? path.resolve(path.dirname(sourcePath), specifier) : ''); return candidate === serverRoot || candidate.startsWith(`${serverRoot}${path.sep}`); } export async function verifyProjectBoundaries(repositoryRoot) { const webSourceRoot = path.join(repositoryRoot, 'web', 'src'); const configPath = path.join(repositoryRoot, 'web', 'tsconfig.json'); const configFile = ts.readConfigFile(configPath, ts.sys.readFile); const compilerOptions = configFile.error ? { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler } : ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath)).options; const files = await collectTypeScriptFiles(webSourceRoot); const violations = []; for (const sourcePath of files) { const sourceText = await readFile(sourcePath, 'utf8'); const sourceFile = ts.createSourceFile( sourcePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); for (const specifier of collectModuleSpecifiers(sourceFile)) { if (resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions)) { violations.push( `Web 禁止依赖 Server:${path.relative(repositoryRoot, sourcePath)} -> ${specifier}`, ); } } } const generatedSchemaPath = path.join(webSourceRoot, 'api', 'generated', 'schema.ts'); try { const generatedSchema = await readFile(generatedSchemaPath, 'utf8'); for (const marker of INTERNAL_MESSAGE_MARKERS) { if (generatedSchema.includes(marker)) { violations.push(`生成契约包含 Server 内部消息:${marker}`); } } } catch (error) { if (error.code !== 'ENOENT') throw error; } if (violations.length > 0) { throw new Error(violations.join('\n')); } } function parseRepositoryRoot(argv) { const rootIndex = argv.indexOf('--root'); if (rootIndex >= 0 && argv[rootIndex + 1]) return path.resolve(argv[rootIndex + 1]); return process.cwd(); } if ( process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname) ) { const repositoryRoot = parseRepositoryRoot(process.argv.slice(2)); try { const rootStats = await stat(repositoryRoot); if (!rootStats.isDirectory()) throw new Error('仓库根路径不是目录'); await verifyProjectBoundaries(repositoryRoot); process.stdout.write('项目边界验证通过\n'); } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; } }