Files
Genarrative/server-node/scripts/generateBackendCapabilityArtifacts.ts
2026-04-20 23:07:43 +08:00

373 lines
12 KiB
TypeScript
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 { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BACKEND_CAPABILITY_MANIFEST,
type BackendCapabilityManifest,
type BackendDomainModule,
type BackendRouteCapability,
type BackendRouteSurface,
} from '../src/manifest/backendCapabilityManifest.js';
type GeneratedSummary = {
surfaceCount: number;
routeCount: number;
moduleCount: number;
publicRouteCount: number;
jwtRouteCount: number;
envSwitchRouteCount: number;
streamRouteCount: number;
};
type GeneratedArtifact = {
generatedAt: string;
manifestVersion: string;
generatedCommand: string;
outputTargets: BackendCapabilityManifest['outputTargets'];
summary: GeneratedSummary;
surfaces: Array<
BackendRouteSurface & {
routeCount: number;
routeIds: string[];
}
>;
modules: Array<
BackendDomainModule & {
routeCount: number;
routeIds: string[];
}
>;
routes: BackendRouteCapability[];
maintenanceRules: string[];
};
const currentFilePath = fileURLToPath(import.meta.url);
const scriptDirectory = path.dirname(currentFilePath);
const repoRoot = path.resolve(scriptDirectory, '..', '..');
/**
* 统一把 repo 相对路径转成绝对路径,避免不同工作目录下解析不一致。
*/
function resolveRepoPath(relativePath: string) {
return path.resolve(repoRoot, relativePath);
}
function sortById<T extends { id: string }>(items: T[]) {
return [...items].sort((left, right) => left.id.localeCompare(right.id, 'zh-Hans-CN'));
}
function sortRoutes(routes: BackendRouteCapability[]) {
return [...routes].sort((left, right) => {
if (left.path === right.path) {
return left.method.localeCompare(right.method, 'en-US');
}
return left.path.localeCompare(right.path, 'en-US');
});
}
/**
* 用最小约束校验 manifest 的唯一性与引用完整性,确保生成结果可维护。
*/
function assertUniqueIds(items: Array<{ id: string }>, label: string) {
const seen = new Set<string>();
const duplicates: string[] = [];
items.forEach((item) => {
if (seen.has(item.id)) {
duplicates.push(item.id);
return;
}
seen.add(item.id);
});
if (duplicates.length > 0) {
throw new Error(`${label} 存在重复 id${duplicates.join('、')}`);
}
}
async function assertSourceFileContains(params: {
sourceFile: string;
sourceHint: string;
routeId: string;
}) {
const absolutePath = resolveRepoPath(params.sourceFile);
const content = await readFile(absolutePath, 'utf8');
if (!content.includes(params.sourceHint)) {
throw new Error(
`路由 ${params.routeId} 的 sourceHint 未命中源码:${params.sourceFile} -> ${params.sourceHint}`,
);
}
}
async function validateModuleCoverage(modules: BackendDomainModule[]) {
const modulesRoot = resolveRepoPath('server-node/src/modules');
const directoryEntries = await readdir(modulesRoot, { withFileTypes: true });
const actualDirectories = directoryEntries
.filter((entry) => entry.isDirectory())
.map((entry) => `server-node/src/modules/${entry.name}`)
.sort((left, right) => left.localeCompare(right, 'en-US'));
const manifestDirectories = modules
.map((moduleItem) => moduleItem.directory)
.sort((left, right) => left.localeCompare(right, 'en-US'));
const missingInManifest = actualDirectories.filter(
(directory) => !manifestDirectories.includes(directory),
);
const staleInManifest = manifestDirectories.filter(
(directory) => !actualDirectories.includes(directory),
);
if (missingInManifest.length > 0) {
throw new Error(
`以下模块目录尚未进入能力 manifest${missingInManifest.join('、')}`,
);
}
if (staleInManifest.length > 0) {
throw new Error(
`manifest 中存在已失效的模块目录:${staleInManifest.join('、')}`,
);
}
}
async function validateManifest(manifest: BackendCapabilityManifest) {
assertUniqueIds(manifest.surfaces, '挂载面');
assertUniqueIds(manifest.modules, '内部模块');
assertUniqueIds(manifest.routes, '路由');
const surfaceIds = new Set(manifest.surfaces.map((surface) => surface.id));
const moduleIds = new Set(manifest.modules.map((moduleItem) => moduleItem.id));
for (const surface of manifest.surfaces) {
for (const relatedModuleId of surface.relatedModuleIds) {
if (!moduleIds.has(relatedModuleId)) {
throw new Error(
`挂载面 ${surface.id} 引用了未定义的模块:${relatedModuleId}`,
);
}
}
for (const mount of surface.mounts) {
const absoluteEntryPath = resolveRepoPath(mount.entryFile);
const content = await readFile(absoluteEntryPath, 'utf8');
if (!content.includes(mount.routeFactory)) {
throw new Error(
`挂载面 ${surface.id} 的入口文件缺少工厂引用:${mount.entryFile} -> ${mount.routeFactory}`,
);
}
}
}
for (const moduleItem of manifest.modules) {
for (const surfaceId of moduleItem.exposedBySurfaceIds) {
if (!surfaceIds.has(surfaceId)) {
throw new Error(
`模块 ${moduleItem.id} 引用了未定义的挂载面:${surfaceId}`,
);
}
}
const absoluteDirectory = resolveRepoPath(moduleItem.directory);
const statsEntries = await readdir(absoluteDirectory);
if (statsEntries.length === 0) {
throw new Error(`模块目录为空,无法作为能力边界:${moduleItem.directory}`);
}
}
for (const route of manifest.routes) {
if (!surfaceIds.has(route.surfaceId)) {
throw new Error(`路由 ${route.id} 引用了未定义的挂载面:${route.surfaceId}`);
}
for (const moduleId of route.domainModuleIds) {
if (!moduleIds.has(moduleId)) {
throw new Error(`路由 ${route.id} 引用了未定义的模块:${moduleId}`);
}
}
await assertSourceFileContains({
sourceFile: route.sourceFile,
sourceHint: route.sourceHint,
routeId: route.id,
});
}
await validateModuleCoverage(manifest.modules);
}
function buildSummary(routes: BackendRouteCapability[]): GeneratedSummary {
return {
surfaceCount: BACKEND_CAPABILITY_MANIFEST.surfaces.length,
routeCount: routes.length,
moduleCount: BACKEND_CAPABILITY_MANIFEST.modules.length,
publicRouteCount: routes.filter((route) => route.access === '公开').length,
jwtRouteCount: routes.filter((route) => route.access === 'JWT').length,
envSwitchRouteCount: routes.filter((route) => route.access.startsWith('开关:')).length,
streamRouteCount: routes.filter((route) => route.responseMode === 'stream').length,
};
}
function buildArtifact(manifest: BackendCapabilityManifest): GeneratedArtifact {
const routes = sortRoutes(manifest.routes);
const summary = buildSummary(routes);
const surfaces = sortById(manifest.surfaces).map((surface) => {
const surfaceRoutes = routes.filter((route) => route.surfaceId === surface.id);
return {
...surface,
routeCount: surfaceRoutes.length,
routeIds: surfaceRoutes.map((route) => route.id),
};
});
const modules = sortById(manifest.modules).map((moduleItem) => {
const moduleRoutes = routes.filter((route) =>
route.domainModuleIds.includes(moduleItem.id),
);
return {
...moduleItem,
routeCount: moduleRoutes.length,
routeIds: moduleRoutes.map((route) => route.id),
};
});
return {
generatedAt: new Date().toISOString(),
manifestVersion: manifest.version,
generatedCommand: manifest.generatedCommand,
outputTargets: manifest.outputTargets,
summary,
surfaces,
modules,
routes,
maintenanceRules: manifest.maintenanceRules,
};
}
function renderMarkdown(artifact: GeneratedArtifact) {
const lines: string[] = [];
lines.push('# Node 后端模块与接口索引');
lines.push('');
lines.push('> 该文档由 `server-node/src/manifest/backendCapabilityManifest.ts` 自动生成。');
lines.push(`> 生成命令:\`${artifact.generatedCommand}\``);
lines.push(`> 生成时间:\`${artifact.generatedAt}\``);
lines.push('');
lines.push('## 总览');
lines.push('');
lines.push(`- 对外挂载面:${artifact.summary.surfaceCount}`);
lines.push(`- 已登记路由:${artifact.summary.routeCount}`);
lines.push(`- 内部模块目录:${artifact.summary.moduleCount}`);
lines.push(`- 公开接口:${artifact.summary.publicRouteCount}`);
lines.push(`- JWT 接口:${artifact.summary.jwtRouteCount}`);
lines.push(`- 受环境开关控制的接口:${artifact.summary.envSwitchRouteCount}`);
lines.push(`- 流式接口:${artifact.summary.streamRouteCount}`);
lines.push('');
lines.push('## 产物');
lines.push('');
lines.push(`- JSON 清单:\`${artifact.outputTargets.json}\``);
lines.push(`- Markdown 索引:\`${artifact.outputTargets.markdown}\``);
lines.push(`- Manifest 源:\`server-node/src/manifest/backendCapabilityManifest.ts\``);
lines.push('');
lines.push('## 对外挂载面');
lines.push('');
artifact.surfaces.forEach((surface) => {
lines.push(`### ${surface.title}`);
lines.push('');
lines.push(`- 标识:\`${surface.id}\``);
lines.push(`- 路由数:${surface.routeCount}`);
lines.push(`- 入口:${surface.mounts.map((mount) => `\`${mount.entryFile} -> ${mount.mountPath} -> ${mount.routeFactory}\``).join('')}`);
lines.push(`- 关联模块:${surface.relatedModuleIds.length > 0 ? surface.relatedModuleIds.map((moduleId) => `\`${moduleId}\``).join('、') : '无'}`);
lines.push('- 责任:');
surface.responsibilities.forEach((item) => {
lines.push(` - ${item}`);
});
lines.push('- 主要服务边界:');
surface.primaryServiceBoundaries.forEach((item) => {
lines.push(` - ${item}`);
});
lines.push('');
});
lines.push('## 接口索引');
lines.push('');
lines.push('| 方法 | 路径 | 访问 | 响应 | 挂载面 | 内部模块 | 说明 |');
lines.push('| --- | --- | --- | --- | --- | --- | --- |');
artifact.routes.forEach((route) => {
const moduleLabel =
route.domainModuleIds.length > 0
? route.domainModuleIds.map((moduleId) => `\`${moduleId}\``).join('、')
: '无';
lines.push(
`| ${route.method} | \`${route.path}\` | ${route.access} | ${route.responseMode} | \`${route.surfaceId}\` | ${moduleLabel} | ${route.summary} |`,
);
});
lines.push('');
lines.push('## 内部模块边界');
lines.push('');
artifact.modules.forEach((moduleItem) => {
lines.push(`### ${moduleItem.title}`);
lines.push('');
lines.push(`- 标识:\`${moduleItem.id}\``);
lines.push(`- 目录:\`${moduleItem.directory}\``);
lines.push(`- 对外可见面:${moduleItem.exposedBySurfaceIds.map((surfaceId) => `\`${surfaceId}\``).join('、')}`);
lines.push(`- 关联路由数:${moduleItem.routeCount}`);
lines.push('- 职责:');
moduleItem.responsibilities.forEach((item) => {
lines.push(` - ${item}`);
});
lines.push('- 主要服务边界:');
moduleItem.primaryServiceBoundaries.forEach((item) => {
lines.push(` - ${item}`);
});
lines.push('- 关键文件:');
moduleItem.keyFiles.forEach((filePath) => {
lines.push(` - \`${filePath}\``);
});
lines.push('');
});
lines.push('## 维护规则');
lines.push('');
artifact.maintenanceRules.forEach((rule) => {
lines.push(`- ${rule}`);
});
lines.push('');
return `${lines.join('\n')}`;
}
async function writeArtifactFile(relativePath: string, content: string) {
const absolutePath = resolveRepoPath(relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content, 'utf8');
}
async function main() {
await validateManifest(BACKEND_CAPABILITY_MANIFEST);
const artifact = buildArtifact(BACKEND_CAPABILITY_MANIFEST);
const jsonContent = `${JSON.stringify(artifact, null, 2)}\n`;
const markdownContent = renderMarkdown(artifact);
await writeArtifactFile(artifact.outputTargets.json, jsonContent);
await writeArtifactFile(artifact.outputTargets.markdown, markdownContent);
console.log(
[
`backend capability artifacts generated`,
`json=${artifact.outputTargets.json}`,
`markdown=${artifact.outputTargets.markdown}`,
`routes=${artifact.summary.routeCount}`,
`modules=${artifact.summary.moduleCount}`,
].join(' | '),
);
}
void main().catch((error) => {
console.error(error);
process.exit(1);
});