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

223 lines
8.1 KiB
JavaScript
Raw Permalink 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';
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 ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require')) &&
node.arguments.length === 1 &&
ts.isStringLiteralLike(node.arguments[0])
) {
specifiers.push(node.arguments[0].text);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return specifiers;
}
function isTestSource(relativeSourcePath) {
return /(?:^|\/)__tests__(?:\/|$)|\.(?:test|spec)\.[^.]+$/.test(relativeSourcePath);
}
function collectHttpBoundaryViolations(sourceFile, relativeSourcePath, moduleSpecifiers) {
if (isTestSource(relativeSourcePath)) return [];
const normalizedPath = relativeSourcePath.split(path.sep).join('/');
const isRequestBoundary = normalizedPath === 'utils/request.ts';
const isGeneratedContract = normalizedPath.startsWith('api/generated/');
const isBusinessApi = normalizedPath.startsWith('api/') && !isGeneratedContract;
const importsAxios = moduleSpecifiers.includes('axios');
const importsSharedRequest = moduleSpecifiers.includes('@/utils/request');
const violations = [];
let declaresBusinessApiPath = false;
if (importsAxios && !isRequestBoundary) {
violations.push(`Web 运行时代码禁止直接使用 Axios:${normalizedPath}`);
}
function containsBusinessApiPath(node) {
if (ts.isStringLiteralLike(node) && /^\/api(?:\/|$)/.test(node.text)) return true;
let found = false;
ts.forEachChild(node, (child) => {
if (!found && containsBusinessApiPath(child)) found = true;
});
return found;
}
function visit(node) {
if (ts.isCallExpression(node)) {
const callsFetch =
(ts.isIdentifier(node.expression) && node.expression.text === 'fetch') ||
(ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'fetch');
if (callsFetch) {
violations.push(`Web 运行时代码禁止直接调用 fetch:${normalizedPath}`);
}
if (
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'axios'
) {
if (!isRequestBoundary) {
violations.push(`Web 运行时代码禁止直接使用 Axios:${normalizedPath}`);
}
}
const hasBusinessApiArgument = node.arguments.some(containsBusinessApiPath);
if (hasBusinessApiArgument) {
declaresBusinessApiPath = true;
if (!isBusinessApi) {
violations.push(`业务 API 路径只能声明在 web/src/api:${normalizedPath}`);
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (isBusinessApi && declaresBusinessApiPath && !importsSharedRequest) {
violations.push(`业务 API 文件必须使用 @/utils/request:${normalizedPath}`);
}
return [...new Set(violations)];
}
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,
);
const moduleSpecifiers = collectModuleSpecifiers(sourceFile);
for (const specifier of moduleSpecifiers) {
if (resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions)) {
violations.push(
`Web 禁止依赖 Server:${path.relative(repositoryRoot, sourcePath)} -> ${specifier}`,
);
}
}
violations.push(
...collectHttpBoundaryViolations(
sourceFile,
path.relative(webSourceRoot, sourcePath),
moduleSpecifiers,
),
);
}
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;
}
}