feat(GOV-009): 建立前端 OpenAPI 客户端边界

This commit is contained in:
2026-08-13 17:36:55 +08:00
parent 420aa22342
commit 4665336a63
11 changed files with 691 additions and 27 deletions
@@ -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;
}
}
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const checkerPath = path.resolve('tools/boundaries/verify-project-boundaries.mjs');
async function createFixture() {
const repositoryRoot = await mkdtemp(path.join(tmpdir(), 'a-share-boundary-'));
await mkdir(path.join(repositoryRoot, 'web/src/api/generated'), { recursive: true });
await mkdir(path.join(repositoryRoot, 'server/libs'), { recursive: true });
await writeFile(
path.join(repositoryRoot, 'web/src/api/generated/schema.ts'),
'export interface paths {}\n',
);
return repositoryRoot;
}
async function runChecker(repositoryRoot) {
try {
await execFileAsync(process.execPath, [checkerPath, '--root', repositoryRoot]);
return { exitCode: 0, stderr: '' };
} catch (error) {
return {
exitCode: error.code,
stderr: String(error.stderr),
};
}
}
test('拒绝 Web 通过静态、动态或导出语句越界依赖 Server', async () => {
const repositoryRoot = await createFixture();
await writeFile(
path.join(repositoryRoot, 'web/src/illegal.ts'),
[
"import '../../server/libs/static';",
"export { value } from '../../server/libs/exported';",
"void import('../../server/libs/dynamic');",
].join('\n'),
);
const result = await runChecker(repositoryRoot);
assert.equal(result.exitCode, 1);
assert.match(result.stderr, /Web 禁止依赖 Server/);
assert.match(result.stderr, /illegal\.ts/);
});
test('拒绝生成契约泄漏 RocketMQ 内部消息结构', async () => {
const repositoryRoot = await createFixture();
await writeFile(
path.join(repositoryRoot, 'web/src/api/generated/schema.ts'),
'export interface MessageEnvelope { messageId: string }\n',
);
const result = await runChecker(repositoryRoot);
assert.equal(result.exitCode, 1);
assert.match(result.stderr, /生成契约包含 Server 内部消息/);
});
test('拒绝通过 tsconfig 路径别名依赖 Server', async () => {
const repositoryRoot = await createFixture();
await writeFile(
path.join(repositoryRoot, 'web/tsconfig.json'),
JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@server/*': ['../server/*'] },
},
}),
);
await writeFile(
path.join(repositoryRoot, 'web/src/aliased.ts'),
"import type { Internal } from '@server/libs/internal';\nexport type Leaked = Internal;\n",
);
await writeFile(
path.join(repositoryRoot, 'server/libs/internal.ts'),
'export interface Internal { secret: string }\n',
);
const result = await runChecker(repositoryRoot);
assert.equal(result.exitCode, 1);
assert.match(result.stderr, /Web 禁止依赖 Server/);
});
test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => {
const repositoryRoot = await createFixture();
await writeFile(
path.join(repositoryRoot, 'web/src/legal.ts'),
"import type { paths } from './api/generated/schema';\nexport type ApiPaths = paths;\n",
);
const result = await runChecker(repositoryRoot);
assert.equal(result.exitCode, 0);
assert.equal(result.stderr, '');
});