完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)

这个提交包含在:
2026-07-18 15:46:14 +08:00
父节点 58c8decef9
当前提交 5d0a5f9a38
共修改 134 个文件,包含 10790 行新增和 1413 行删除
可执行文件 -> 普通文件
+386 -251
查看文件
@@ -1,41 +1,49 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const rootDir = path.resolve(new URL("../..", import.meta.url).pathname);
const routerPath = path.join(rootDir, "client/src/router/index.tsx");
const navSchemaPath = path.join(rootDir, "client/src/features/navigation/schema.ts");
const flowMatrixPath = path.join(rootDir, "docs/trialrun/项目级流程覆盖矩阵.csv");
const serverRoutesDir = path.join(rootDir, "server/src/routes");
const e2eDir = path.join(rootDir, "client/tests/e2e");
const defaultRootDir = path.resolve(new URL("../..", import.meta.url).pathname);
const ROUTE_CLASSIFICATIONS = new Set(["core", "supported-noncore", "deferred", "frozen"]);
const ROLE_FIXTURES = new Set(["employee", "manager", "ceo", "finance", "system_admin", "admin"]);
function usage() {
console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [--json] [--markdown <path>] [--fail-on-gaps]
console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [options]
输出:
--json 输出 JSON 到 stdout
--markdown <path> 输出 Markdown 摘要到指定路径
--fail-on-gaps 存在 P0 missing_e2e 或 missing_visual 时以 1 退出
Options:
--json 输出 JSON 到 stdout
--markdown <path> 输出 Markdown 摘要
--fail-on-gaps 任一本地候选阻断缺口存在时以 1 退出
--local-candidate 等同 --fail-on-gaps,并要求核心流程束均有可信 witness
--root <path> 使用指定仓库/测试 fixture 根目录
--help 显示帮助
`);
}
function parseArgs() {
const args = process.argv.slice(2);
function parseArgs(argv = process.argv.slice(2)) {
const parsed = {
json: false,
markdownPath: "",
failOnGaps: false,
localCandidate: false,
rootDir: defaultRootDir,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--json") {
parsed.json = true;
} else if (arg === "--markdown") {
parsed.markdownPath = args[index + 1] ?? "";
parsed.markdownPath = argv[index + 1] ?? "";
index += 1;
} else if (arg === "--fail-on-gaps") {
parsed.failOnGaps = true;
} else if (arg === "--local-candidate") {
parsed.localCandidate = true;
parsed.failOnGaps = true;
} else if (arg === "--root") {
parsed.rootDir = path.resolve(argv[index + 1] ?? "");
index += 1;
} else if (arg === "--help" || arg === "-h") {
usage();
process.exit(0);
@@ -44,9 +52,12 @@ function parseArgs() {
}
}
if (args.includes("--markdown") && !parsed.markdownPath) {
if (argv.includes("--markdown") && !parsed.markdownPath) {
throw new Error("--markdown 需要指定输出路径");
}
if (argv.includes("--root") && !argv[argv.indexOf("--root") + 1]) {
throw new Error("--root 需要指定目录");
}
return parsed;
}
@@ -55,64 +66,18 @@ function readText(file) {
return readFileSync(file, "utf8");
}
function walkFiles(dir, predicate = () => true) {
const output = [];
if (!existsSync(dir)) return output;
for (const name of readdirSync(dir)) {
const file = path.join(dir, name);
const stat = statSync(file);
if (stat.isDirectory()) {
output.push(...walkFiles(file, predicate));
} else if (predicate(file)) {
output.push(file);
}
function readJson(file) {
try {
return JSON.parse(readText(file));
} catch (error) {
throw new Error(`${path.basename(file)} 不是有效 JSON:${error instanceof Error ? error.message : String(error)}`);
}
return output.sort();
}
function toPosix(file) {
return file.split(path.sep).join("/");
}
function parseCsvLine(line) {
const cells = [];
let current = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (quoted && char === "\"" && next === "\"") {
current += "\"";
index += 1;
continue;
}
if (char === "\"") {
quoted = !quoted;
continue;
}
if (!quoted && char === ",") {
cells.push(current);
current = "";
continue;
}
current += char;
}
cells.push(current);
return cells;
}
function parseCsv(source) {
const lines = source.split(/\r?\n/).filter((line) => line.trim());
const header = parseCsvLine(lines[0] ?? "");
return lines.slice(1).map((line) => {
const cells = parseCsvLine(line);
return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""]));
});
}
function normalizeRoute(route) {
if (!route) return "/";
if (route === "*") return "*";
@@ -120,24 +85,11 @@ function normalizeRoute(route) {
return prefixed.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
}
function routeToKeyword(route) {
return normalizeRoute(route)
.replace(/^\/+/, "")
.replace(/:[^/]+/g, "")
.split("/")
.filter(Boolean)
.join(" ");
}
function parseRouterRoutes(routerPath) {
if (!existsSync(routerPath)) {
throw new Error(`路由文件不存在:${routerPath}`);
}
function routeToRegex(route) {
const normalized = normalizeRoute(route);
const escaped = normalized
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\\:[^/]+/g, "[^/]+");
return new RegExp(`(^|["'\`\\s])${escaped}($|["'\`?#\\s])`);
}
function parseRouterRoutes() {
const source = readText(routerPath);
const routeRegex = /<Route\s+([^>]*?)>/gs;
const routes = [];
@@ -149,159 +101,352 @@ function parseRouterRoutes() {
if (pathMatch || indexRoute) {
const rawPath = pathMatch?.[1] ?? pathMatch?.[2] ?? "/";
const normalized = indexRoute && !pathMatch ? "/" : normalizeRoute(rawPath);
if (normalized !== "*" && normalized !== "/403") {
routes.push({ path: normalized });
}
routes.push(normalized);
}
match = routeRegex.exec(source);
}
return [...new Map(routes.map((route) => [route.path, route])).values()].sort((a, b) => a.path.localeCompare(b.path));
return [...new Set(routes)].sort((a, b) => a.localeCompare(b));
}
function parsePageTitles() {
const source = existsSync(navSchemaPath) ? readText(navSchemaPath) : "";
const titleRegex = /\{\s*path:\s*"([^"]+)",\s*title:\s*"([^"]+)",\s*group:\s*"([^"]+)"/g;
const titles = new Map();
let match = titleRegex.exec(source);
while (match) {
titles.set(normalizeRoute(match[1]), { title: match[2], group: match[3] });
match = titleRegex.exec(source);
}
return titles;
}
function parseSpecs() {
return walkFiles(e2eDir, (file) => file.endsWith(".spec.ts")).map((file) => {
const relPath = toPosix(path.relative(rootDir, file));
const source = readText(file);
const lower = `${relPath}\n${source}`.toLowerCase();
return {
path: relPath,
source,
lower,
isMobile: /mobile|\/mobile|移动/.test(lower),
isWecom: /wecom|wxwork|企业微信|weixinwork/.test(lower),
isVisual: /visual|screenshot|tohavescreenshot|视觉/.test(lower),
isFlow: /login\(|createauthenticatedapiclient|api(post|get|patch)data|workflow|approve|submit|上传|审批|报销|请假/.test(lower),
};
});
}
function parseServerRoutes() {
return walkFiles(serverRoutesDir, (file) => file.endsWith(".js")).map((file) => toPosix(path.relative(rootDir, file)));
}
function splitList(value) {
return value.split("|").map((item) => item.trim()).filter(Boolean);
}
function routeTokenMatches(pattern, route) {
const normalizedPattern = normalizeRoute(pattern);
const normalizedRoute = normalizeRoute(route);
if (normalizedPattern === normalizedRoute) return true;
if (normalizedPattern.includes(":")) {
return routeToRegex(normalizedPattern).test(normalizedRoute);
}
return normalizedRoute === normalizedPattern || normalizedRoute.startsWith(`${normalizedPattern}/`);
}
function domainForRoute(route, domains) {
return domains.find((domain) => {
const routeTokens = [
...splitList(domain.PC_Routes ?? ""),
...splitList(domain.Mobile_WeCom_Routes ?? ""),
];
return routeTokens.some((token) => routeTokenMatches(token, route));
}) ?? null;
}
function specMatchesRoute(spec, route, domain) {
function routeWitness(route) {
const normalized = normalizeRoute(route);
const keyword = routeToKeyword(normalized);
const routeRegex = routeToRegex(normalized);
if (routeRegex.test(spec.source)) return true;
if (keyword && keyword.split(" ").some((part) => part.length >= 5 && spec.lower.includes(part.toLowerCase()))) {
return true;
}
if (!domain) return false;
const domainKeywords = [
domain.Domain,
...splitList(domain.PC_Routes ?? ""),
...splitList(domain.Mobile_WeCom_Routes ?? ""),
...splitList(domain.Server_Routes ?? "").map((item) => path.basename(item, ".js")),
]
.join(" ")
.toLowerCase()
.split(/[\s/|:_-]+/)
.filter((item) => item.length >= 4);
return domainKeywords.some((part) => spec.lower.includes(part));
if (!normalized.includes(":")) return normalized;
return normalized.replace(/\/:[^/]+(?=\/|$)/g, "/").replace(/\/$/, "");
}
function classifyRoute(route, specs, domain, titleMeta) {
const matchedSpecs = specs.filter((spec) => specMatchesRoute(spec, route.path, domain));
const coverage = {
pc: matchedSpecs.some((spec) => !spec.isMobile && !route.path.startsWith("/mobile")),
mobile: matchedSpecs.some((spec) => spec.isMobile) || route.path.startsWith("/mobile"),
wecom: matchedSpecs.some((spec) => spec.isWecom),
visual: matchedSpecs.some((spec) => spec.isVisual),
flow: matchedSpecs.some((spec) => spec.isFlow),
};
function isManualOrLiveSpec(relativePath) {
const normalized = toPosix(relativePath).toLowerCase();
return normalized.includes("/manual/") || /(^|\/|[-_.])live([-_.]|\/|$)/.test(normalized);
}
function inspectSpec(rootDir, relativePath, route, kind, { requireRouteWitness = true } = {}) {
const normalizedRelativePath = toPosix(String(relativePath ?? "").trim());
if (!normalizedRelativePath) {
return { path: normalizedRelativePath, valid: false, reason: "empty_spec_path" };
}
if (isManualOrLiveSpec(normalizedRelativePath)) {
return { path: normalizedRelativePath, valid: false, reason: "manual_or_live" };
}
const absolutePath = path.resolve(rootDir, normalizedRelativePath);
const allowedRoot = `${path.resolve(rootDir)}${path.sep}`;
if (!absolutePath.startsWith(allowedRoot) || !existsSync(absolutePath) || !statSync(absolutePath).isFile()) {
return { path: normalizedRelativePath, valid: false, reason: "missing_spec_file" };
}
const source = readText(absolutePath);
if (!/\btest(?:\.describe)?\s*\(/.test(source)) {
return { path: normalizedRelativePath, valid: false, reason: "not_executable_spec" };
}
const witness = routeWitness(route);
if (requireRouteWitness && witness !== "/" && !source.includes(witness)) {
return { path: normalizedRelativePath, valid: false, reason: "missing_route_witness" };
}
if (kind === "visual" && !/(?:toHaveScreenshot|\.screenshot\s*\(|视觉)/.test(source)) {
return { path: normalizedRelativePath, valid: false, reason: "not_visual_spec" };
}
return { path: normalizedRelativePath, valid: true, reason: "" };
}
function inspectWitnessSpec(rootDir, flow) {
const base = inspectSpec(rootDir, flow?.witnessSpec, "/", "behavior", { requireRouteWitness: false });
if (!base.valid) return base;
const source = readText(path.resolve(rootDir, base.path));
const manifestDrivenContract = [
"LOCAL_CANDIDATE_MANIFEST_DRIVEN_WITNESSES",
"test(flow.witnessTag",
"loginToWitness(page, flow.witnessRoute)",
"expectUniqueH1(page, flow.expectedH1)",
"installSyntheticApis(page, flow.roleFixture)",
"expectPrimaryActionContract(page, flow)",
"installBrowserErrorMonitor(page)",
].every((token) => source.includes(token));
const literalContract = [
flow?.witnessTag,
flow?.witnessRoute,
flow?.expectedH1,
flow?.roleFixture,
flow?.primaryAction?.allowed,
flow?.primaryAction?.forbidden,
].every((token) => typeof token === "string" && token.length > 0 && source.includes(token));
if (!manifestDrivenContract && !literalContract) {
return { ...base, valid: false, reason: "wrong_witness_association" };
}
return base;
}
function validateManifestShape(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new Error("route-manifest 顶层必须是对象");
}
if (manifest.schemaVersion !== 1) {
throw new Error("route-manifest.schemaVersion 必须为 1");
}
if (!Array.isArray(manifest.coreBundles) || !manifest.coreBundles.length) {
throw new Error("route-manifest.coreBundles 必须是非空数组");
}
if (!manifest.coreBundles.every((bundle) => typeof bundle === "string" && bundle.trim())) {
throw new Error("route-manifest.coreBundles 只能包含非空字符串");
}
if (!Array.isArray(manifest.routes)) {
throw new Error("route-manifest.routes 必须是数组");
}
if (!Array.isArray(manifest.localCandidateFlows)) {
throw new Error("route-manifest.localCandidateFlows 必须是数组");
}
if (!Array.isArray(manifest.acceptanceSpecs) || !manifest.acceptanceSpecs.length) {
throw new Error("route-manifest.acceptanceSpecs 必须是非空数组");
}
}
function validateRoute(route, manifestEntry, rootDir, coreBundleSet) {
if (!manifestEntry) {
return {
path: route,
classification: "unclassified",
bundle: "",
visualRequired: false,
behaviorSpecs: [],
visualSpecs: [],
gaps: ["unclassified"],
};
}
const gaps = [];
const isP0 = domain?.Tier === "P0";
if (!matchedSpecs.length) gaps.push("missing_e2e");
if (route.path.startsWith("/mobile") && !coverage.wecom) gaps.push("missing_wecom_assertion");
if (isP0 && !coverage.visual) gaps.push("missing_visual");
if (isP0 && !coverage.flow) gaps.push("missing_flow");
const classification = manifestEntry.classification;
if (!ROUTE_CLASSIFICATIONS.has(classification)) gaps.push("invalid_classification");
const behaviorChecks = (manifestEntry.behaviorSpecs ?? []).map((spec) => inspectSpec(rootDir, spec, route, "behavior"));
const visualChecks = (manifestEntry.visualSpecs ?? []).map((spec) => inspectSpec(rootDir, spec, route, "visual"));
const validBehaviorSpecs = behaviorChecks.filter((item) => item.valid).map((item) => item.path);
const validVisualSpecs = visualChecks.filter((item) => item.valid).map((item) => item.path);
const invalidBehaviorReasons = [...new Set(behaviorChecks.filter((item) => !item.valid).map((item) => item.reason))];
const invalidVisualReasons = [...new Set(visualChecks.filter((item) => !item.valid).map((item) => item.reason))];
if (classification === "core") {
if (!manifestEntry.bundle || !coreBundleSet.has(manifestEntry.bundle)) gaps.push("missing_core_bundle");
if (!validBehaviorSpecs.length) {
gaps.push(invalidBehaviorReasons.includes("manual_or_live") ? "manual_only" : "missing_behavior");
}
}
if (manifestEntry.visualRequired && !validVisualSpecs.length) {
gaps.push("missing_visual");
}
return {
path: route.path,
title: titleMeta?.title ?? "",
group: titleMeta?.group ?? "",
domain: domain?.Domain ?? "未映射",
tier: domain?.Tier ?? "unmapped",
coverage,
specs: matchedSpecs.map((spec) => spec.path),
path: route,
classification,
bundle: manifestEntry.bundle ?? "",
visualRequired: Boolean(manifestEntry.visualRequired),
behaviorSpecs: validBehaviorSpecs,
visualSpecs: validVisualSpecs,
evidenceProblems: {
behavior: invalidBehaviorReasons,
visual: invalidVisualReasons,
},
gaps,
};
}
function summarize(routes, domains, serverRoutes) {
const gapCounts = new Map();
for (const route of routes) {
for (const gap of route.gaps) {
gapCounts.set(gap, (gapCounts.get(gap) ?? 0) + 1);
}
function validateLocalCandidateFlow(bundle, flow, context) {
const gaps = [];
if (!flow) {
return {
bundle,
witnessRoute: "",
witnessTag: `@local-candidate:${bundle}`,
expectedH1: "",
roleFixture: "",
primaryAction: null,
witnessSpec: "",
evidenceProblems: ["missing_flow"],
covered: false,
gaps: ["missing_local_candidate_witness"],
};
}
const domainRows = domains.map((domain) => {
const domainRoutes = routes.filter((route) => route.domain === domain.Domain);
const expectedTag = `@local-candidate:${bundle}`;
if (flow.witnessTag !== expectedTag) gaps.push("invalid_witness_tag");
if (typeof flow.expectedH1 !== "string" || !flow.expectedH1.trim()) gaps.push("missing_expected_h1");
if (!ROLE_FIXTURES.has(flow.roleFixture)) gaps.push("invalid_role_fixture");
const allowedAction = flow.primaryAction?.allowed;
const forbiddenAction = flow.primaryAction?.forbidden;
if (
typeof allowedAction !== "string" || !allowedAction.trim() ||
typeof forbiddenAction !== "string" || !forbiddenAction.trim() ||
allowedAction === forbiddenAction
) {
gaps.push("invalid_primary_action");
}
const witnessRoute = normalizeRoute(flow.witnessRoute);
const registered = context.registeredRouteSet.has(witnessRoute);
const witnessRouteEntry = context.manifestEntries.get(witnessRoute);
if (!registered || !witnessRouteEntry) {
gaps.push("orphan_witness_route");
} else if (witnessRouteEntry.classification !== "core") {
gaps.push("witness_route_not_core");
} else if (witnessRouteEntry.bundle !== bundle) {
gaps.push("wrong_witness_association");
}
const witnessCheck = inspectWitnessSpec(context.rootDir, flow);
if (!witnessCheck.valid) {
const reasonMap = {
manual_or_live: "manual_or_live_witness",
missing_spec_file: "missing_witness_spec",
empty_spec_path: "missing_witness_spec",
wrong_witness_association: "wrong_witness_association",
};
gaps.push(reasonMap[witnessCheck.reason] ?? "invalid_witness_spec");
}
return {
bundle,
witnessRoute,
witnessTag: flow.witnessTag ?? "",
expectedH1: flow.expectedH1 ?? "",
roleFixture: flow.roleFixture ?? "",
primaryAction: flow.primaryAction ?? null,
witnessSpec: witnessCheck.valid ? witnessCheck.path : "",
evidenceProblems: witnessCheck.valid ? [] : [witnessCheck.reason],
covered: gaps.length === 0,
gaps: [...new Set(gaps)],
};
}
function duplicates(values) {
const seen = new Set();
const duplicateValues = new Set();
for (const value of values) {
if (seen.has(value)) duplicateValues.add(value);
seen.add(value);
}
return [...duplicateValues].sort();
}
export function buildReport(rootDir) {
const routerPath = path.join(rootDir, "client/src/router/index.tsx");
const manifestPath = path.join(rootDir, "scripts/qa/route-manifest.json");
if (!existsSync(manifestPath)) {
throw new Error(`路由 manifest 不存在:${manifestPath}`);
}
const manifest = readJson(manifestPath);
validateManifestShape(manifest);
const registeredRoutes = parseRouterRoutes(routerPath);
const manifestEntries = new Map();
const duplicateManifestRoutes = [];
for (const entry of manifest.routes) {
const normalizedPath = normalizeRoute(entry?.path);
if (manifestEntries.has(normalizedPath)) duplicateManifestRoutes.push(normalizedPath);
manifestEntries.set(normalizedPath, { ...entry, path: normalizedPath });
}
const duplicateCoreBundles = duplicates(manifest.coreBundles);
const coreBundleSet = new Set(manifest.coreBundles);
const routes = registeredRoutes.map((route) => validateRoute(route, manifestEntries.get(route), rootDir, coreBundleSet));
const registeredRouteSet = new Set(registeredRoutes);
const orphanManifestRoutes = [...manifestEntries.keys()].filter((route) => !registeredRouteSet.has(route));
const coveredBundles = new Set(routes.filter((route) => route.classification === "core" && route.behaviorSpecs.length).map((route) => route.bundle));
const missingCoreBundles = manifest.coreBundles.filter((bundle) => !coveredBundles.has(bundle));
const duplicateLocalCandidateBundles = duplicates(manifest.localCandidateFlows.map((flow) => flow?.bundle ?? ""));
const duplicateWitnessTags = duplicates(manifest.localCandidateFlows.map((flow) => flow?.witnessTag ?? ""));
const localCandidateFlowMap = new Map(manifest.localCandidateFlows.map((flow) => [flow?.bundle, flow]));
const localCandidateFlows = manifest.coreBundles.map((bundle) =>
validateLocalCandidateFlow(bundle, localCandidateFlowMap.get(bundle), {
rootDir,
registeredRouteSet,
manifestEntries,
}),
);
const missingLocalCandidateBundles = localCandidateFlows.filter((flow) => !flow.covered).map((flow) => flow.bundle);
const unknownLocalCandidateBundles = [...localCandidateFlowMap.keys()].filter((bundle) => !coreBundleSet.has(bundle));
const duplicateAcceptanceSpecs = duplicates(manifest.acceptanceSpecs.map((spec) => toPosix(String(spec ?? ""))));
const candidateSpecPaths = [...new Set([
...manifest.localCandidateFlows.map((flow) => toPosix(String(flow?.witnessSpec ?? ""))),
...manifest.acceptanceSpecs.map((spec) => toPosix(String(spec ?? ""))),
].filter(Boolean))];
const duplicateCandidateSpecBasenames = duplicates(
candidateSpecPaths.map((spec) => path.posix.basename(spec)),
);
const acceptanceSpecs = manifest.acceptanceSpecs.map((spec) => {
const check = inspectSpec(rootDir, spec, "/", "behavior", { requireRouteWitness: false });
return {
domain: domain.Domain,
tier: domain.Tier,
routeCount: domainRoutes.length,
gapCount: domainRoutes.reduce((total, route) => total + route.gaps.length, 0),
routes: domainRoutes.map((route) => route.path),
knownGaps: domain.Known_Gaps,
nextAction: domain.Next_Action,
path: check.path,
valid: check.valid,
reason: check.reason,
gaps: check.valid ? [] : [check.reason === "manual_or_live" ? "manual_or_live_acceptance_spec" : "invalid_acceptance_spec"],
};
});
const globalGaps = [];
if (duplicateCoreBundles.length) globalGaps.push("duplicate_core_bundle");
if (duplicateManifestRoutes.length) globalGaps.push("duplicate_manifest_route");
if (orphanManifestRoutes.length) globalGaps.push("orphan_manifest_route");
if (missingCoreBundles.length) globalGaps.push("missing_core_bundle_behavior");
if (duplicateLocalCandidateBundles.length) globalGaps.push("duplicate_local_candidate_bundle");
if (duplicateWitnessTags.length) globalGaps.push("duplicate_witness_tag");
if (unknownLocalCandidateBundles.length) globalGaps.push("unknown_local_candidate_bundle");
if (duplicateAcceptanceSpecs.length) globalGaps.push("duplicate_acceptance_spec");
if (duplicateCandidateSpecBasenames.length) globalGaps.push("duplicate_candidate_spec_basename");
const allGapLists = [
...routes.map((route) => route.gaps),
...localCandidateFlows.map((flow) => flow.gaps),
...acceptanceSpecs.map((spec) => spec.gaps),
globalGaps,
];
const gapCounts = new Map();
for (const gaps of allGapLists) {
for (const gap of gaps) gapCounts.set(gap, (gapCounts.get(gap) ?? 0) + 1);
}
const classificationCounts = Object.fromEntries(
[...ROUTE_CLASSIFICATIONS].map((classification) => [classification, routes.filter((route) => route.classification === classification).length]),
);
const blockingGapCount = allGapLists.reduce((count, gaps) => count + gaps.length, 0);
return {
generatedAt: new Date().toISOString(),
manifest: toPosix(path.relative(rootDir, manifestPath)),
summary: {
routeCount: routes.length,
domainCount: domains.length,
serverRouteCount: serverRoutes.length,
classificationCounts,
coreBundleCount: manifest.coreBundles.length,
coveredCoreBundleCount: coveredBundles.size,
coveredWitnessCount: localCandidateFlows.filter((flow) => flow.covered).length,
acceptanceSpecCount: acceptanceSpecs.length,
validAcceptanceSpecCount: acceptanceSpecs.filter((spec) => spec.valid).length,
blockingGapCount,
gapCounts: Object.fromEntries([...gapCounts.entries()].sort()),
highRiskGapCount: routes.filter((route) => route.tier === "P0" && route.gaps.length).length,
},
domains: domainRows,
coreBundles: manifest.coreBundles.map((bundle) => ({ bundle, covered: coveredBundles.has(bundle) })),
localCandidateFlows,
acceptanceSpecs,
duplicateCoreBundles,
duplicateManifestRoutes,
duplicateLocalCandidateBundles,
duplicateWitnessTags,
duplicateAcceptanceSpecs,
duplicateCandidateSpecBasenames,
orphanManifestRoutes,
missingCoreBundles,
missingLocalCandidateBundles,
unknownLocalCandidateBundles,
globalGaps,
routes,
serverRoutes,
};
}
@@ -309,57 +454,50 @@ function renderMarkdown(report) {
const gapSummary = Object.entries(report.summary.gapCounts)
.map(([gap, count]) => `- ${gap}: ${count}`)
.join("\n") || "- none: 0";
const bundleRows = report.localCandidateFlows
.map((item) => `| ${item.bundle} | ${item.witnessTag} | ${item.witnessRoute} | ${item.roleFixture} | ${item.covered ? "Y" : "N"} |`)
.join("\n");
const routeRows = report.routes
.map((route) => `| ${route.path} | ${route.domain} | ${route.tier} | ${route.coverage.pc ? "Y" : "N"} | ${route.coverage.mobile ? "Y" : "N"} | ${route.coverage.wecom ? "Y" : "N"} | ${route.coverage.visual ? "Y" : "N"} | ${route.coverage.flow ? "Y" : "N"} | ${route.gaps.join("<br>") || "-"} |`)
.map((route) => `| ${route.path} | ${route.classification} | ${route.bundle || "-"} | ${route.behaviorSpecs.length} | ${route.visualSpecs.length} | ${route.gaps.join("<br>") || "-"} |`)
.join("\n");
const domainRows = report.domains
.map((domain) => `| ${domain.domain} | ${domain.tier} | ${domain.routeCount} | ${domain.gapCount} | ${domain.nextAction || "-"} |`)
.join("\n");
return `# 项目级流程覆盖扫描
return `# 本地候选路由与流程覆盖扫描
- 生成时间:${report.generatedAt}
- 前端路由数:${report.summary.routeCount}
- 业务域数:${report.summary.domainCount}
- 后端 route 文件数:${report.summary.serverRouteCount}
- P0 含缺口路由数:${report.summary.highRiskGapCount}
- 注册路由数:${report.summary.routeCount}
- 核心流程束:${report.summary.coveredCoreBundleCount}/${report.summary.coreBundleCount}
- 可信 witness:${report.summary.coveredWitnessCount}/${report.summary.coreBundleCount}
- 验收 specs:${report.summary.validAcceptanceSpecCount}/${report.summary.acceptanceSpecCount}
- 阻断缺口数:${report.summary.blockingGapCount}
## 缺口汇总
${gapSummary}
## 业务域
## 核心流程束 witness
| Domain | Tier | Routes | Gaps | Next Action |
| --- | --- | ---: | ---: | --- |
${domainRows}
| Bundle | Tag | Route | Role fixture | Covered |
| --- | --- | --- | --- | --- |
${bundleRows}
## 路由覆盖
| Route | Domain | Tier | PC | Mobile | WeCom | Visual | Flow | Gaps |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Route | Classification | Bundle | Behavior | Visual | Gaps |
| --- | --- | --- | ---: | ---: | --- |
${routeRows}
`;
}
export function hasBlockingGaps(report) {
return report.summary.blockingGapCount > 0;
}
function main() {
const args = parseArgs();
const domains = parseCsv(readText(flowMatrixPath));
const routes = parseRouterRoutes();
const titles = parsePageTitles();
const specs = parseSpecs();
const serverRoutes = parseServerRoutes();
const classifiedRoutes = routes.map((route) => {
const domain = domainForRoute(route.path, domains);
const titleMeta = titles.get(route.path) ?? titles.get(`${route.path}/`);
return classifyRoute(route, specs, domain, titleMeta);
});
const report = summarize(classifiedRoutes, domains, serverRoutes);
const report = buildReport(args.rootDir);
if (args.markdownPath) {
const outputPath = path.resolve(rootDir, args.markdownPath);
const outputPath = path.resolve(args.rootDir, args.markdownPath);
mkdirSync(path.dirname(outputPath), { recursive: true });
writeFileSync(outputPath, renderMarkdown(report), "utf8");
console.error(`[flow-coverage] markdown=${outputPath}`);
@@ -369,19 +507,16 @@ function main() {
console.log(JSON.stringify(report, null, 2));
}
if (args.failOnGaps) {
const hasBlockingGap = report.routes.some(
(route) => route.tier === "P0" && (route.gaps.includes("missing_e2e") || route.gaps.includes("missing_visual")),
);
if (hasBlockingGap) {
process.exit(1);
}
if (args.failOnGaps && hasBlockingGaps(report)) {
process.exitCode = 1;
}
}
try {
main();
} catch (error) {
console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
main();
} catch (error) {
console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}