import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import ts from 'typescript'; import { parse } from '@vue/compiler-sfc'; const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.vue']); const INTERNAL_MESSAGE_MARKERS = ['MessageEnvelope', 'message-contracts']; async function collectSourceFiles(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 collectSourceFiles(entryPath); return SOURCE_EXTENSIONS.has(path.extname(entry.name)) ? [entryPath] : []; }), ); return nestedFiles.flat(); } function extractTypeScriptSource(sourcePath, sourceText) { if (path.extname(sourcePath) !== '.vue') return sourceText; const { descriptor, errors } = parse(sourceText, { filename: sourcePath }); const externalScript = descriptor.script?.src ?? descriptor.scriptSetup?.src; if (externalScript) { throw new Error( `Web Vue 组件禁止使用外部 script src:${path.basename(sourcePath)} -> ${externalScript}`, ); } if (errors.length > 0) { throw new Error(`Vue 单文件组件解析失败:${path.basename(sourcePath)}`); } // 使用 Vue 官方解析器读取普通脚本和 setup 脚本,避免 HTML 属性写法绕过正则门禁。 return [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean).join('\n'); } 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}`); } /** * 扫描 Web TypeScript 与 Vue 脚本,阻断对 Server 源码和内部消息契约的反向依赖。 */ 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 collectSourceFiles(webSourceRoot); const violations = []; for (const sourcePath of files) { const sourceText = extractTypeScriptSource(sourcePath, 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; } }