feat(GOV-009): 建立前端 OpenAPI 客户端边界
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user