Files
a-share-analysis/tools/boundaries/verify-project-boundaries.mjs
T

126 lines
4.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}