523 行
19 KiB
JavaScript
523 行
19 KiB
JavaScript
#!/usr/bin/env node
|
||
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||
import path from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
|
||
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 [options]
|
||
|
||
Options:
|
||
--json 输出 JSON 到 stdout
|
||
--markdown <path> 输出 Markdown 摘要
|
||
--fail-on-gaps 任一本地候选阻断缺口存在时以 1 退出
|
||
--local-candidate 等同 --fail-on-gaps,并要求核心流程束均有可信 witness
|
||
--root <path> 使用指定仓库/测试 fixture 根目录
|
||
--help 显示帮助
|
||
`);
|
||
}
|
||
|
||
function parseArgs(argv = process.argv.slice(2)) {
|
||
const parsed = {
|
||
json: false,
|
||
markdownPath: "",
|
||
failOnGaps: false,
|
||
localCandidate: false,
|
||
rootDir: defaultRootDir,
|
||
};
|
||
|
||
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 = 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);
|
||
} else {
|
||
throw new Error(`未知参数:${arg}`);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function readText(file) {
|
||
return readFileSync(file, "utf8");
|
||
}
|
||
|
||
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)}`);
|
||
}
|
||
}
|
||
|
||
function toPosix(file) {
|
||
return file.split(path.sep).join("/");
|
||
}
|
||
|
||
function normalizeRoute(route) {
|
||
if (!route) return "/";
|
||
if (route === "*") return "*";
|
||
const prefixed = route.startsWith("/") ? route : `/${route}`;
|
||
return prefixed.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
||
}
|
||
|
||
function parseRouterRoutes(routerPath) {
|
||
if (!existsSync(routerPath)) {
|
||
throw new Error(`路由文件不存在:${routerPath}`);
|
||
}
|
||
|
||
const source = readText(routerPath);
|
||
const routeRegex = /<Route\s+([^>]*?)>/gs;
|
||
const routes = [];
|
||
let match = routeRegex.exec(source);
|
||
while (match) {
|
||
const attrs = match[1];
|
||
const pathMatch = attrs.match(/\bpath=(?:"([^"]+)"|\{`([^`]+)`\})/);
|
||
const indexRoute = /\bindex\b/.test(attrs);
|
||
if (pathMatch || indexRoute) {
|
||
const rawPath = pathMatch?.[1] ?? pathMatch?.[2] ?? "/";
|
||
const normalized = indexRoute && !pathMatch ? "/" : normalizeRoute(rawPath);
|
||
routes.push(normalized);
|
||
}
|
||
match = routeRegex.exec(source);
|
||
}
|
||
|
||
return [...new Set(routes)].sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
function routeWitness(route) {
|
||
const normalized = normalizeRoute(route);
|
||
if (!normalized.includes(":")) return normalized;
|
||
return normalized.replace(/\/:[^/]+(?=\/|$)/g, "/").replace(/\/$/, "");
|
||
}
|
||
|
||
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 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,
|
||
classification,
|
||
bundle: manifestEntry.bundle ?? "",
|
||
visualRequired: Boolean(manifestEntry.visualRequired),
|
||
behaviorSpecs: validBehaviorSpecs,
|
||
visualSpecs: validVisualSpecs,
|
||
evidenceProblems: {
|
||
behavior: invalidBehaviorReasons,
|
||
visual: invalidVisualReasons,
|
||
},
|
||
gaps,
|
||
};
|
||
}
|
||
|
||
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 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 {
|
||
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,
|
||
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()),
|
||
},
|
||
coreBundles: manifest.coreBundles.map((bundle) => ({ bundle, covered: coveredBundles.has(bundle) })),
|
||
localCandidateFlows,
|
||
acceptanceSpecs,
|
||
duplicateCoreBundles,
|
||
duplicateManifestRoutes,
|
||
duplicateLocalCandidateBundles,
|
||
duplicateWitnessTags,
|
||
duplicateAcceptanceSpecs,
|
||
duplicateCandidateSpecBasenames,
|
||
orphanManifestRoutes,
|
||
missingCoreBundles,
|
||
missingLocalCandidateBundles,
|
||
unknownLocalCandidateBundles,
|
||
globalGaps,
|
||
routes,
|
||
};
|
||
}
|
||
|
||
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.classification} | ${route.bundle || "-"} | ${route.behaviorSpecs.length} | ${route.visualSpecs.length} | ${route.gaps.join("<br>") || "-"} |`)
|
||
.join("\n");
|
||
|
||
return `# 本地候选路由与流程覆盖扫描
|
||
|
||
- 生成时间:${report.generatedAt}
|
||
- 注册路由数:${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
|
||
|
||
| Bundle | Tag | Route | Role fixture | Covered |
|
||
| --- | --- | --- | --- | --- |
|
||
${bundleRows}
|
||
|
||
## 路由覆盖
|
||
|
||
| Route | Classification | Bundle | Behavior | Visual | Gaps |
|
||
| --- | --- | --- | ---: | ---: | --- |
|
||
${routeRows}
|
||
`;
|
||
}
|
||
|
||
export function hasBlockingGaps(report) {
|
||
return report.summary.blockingGapCount > 0;
|
||
}
|
||
|
||
function main() {
|
||
const args = parseArgs();
|
||
const report = buildReport(args.rootDir);
|
||
|
||
if (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}`);
|
||
}
|
||
|
||
if (args.json || !args.markdownPath) {
|
||
console.log(JSON.stringify(report, null, 2));
|
||
}
|
||
|
||
if (args.failOnGaps && hasBlockingGaps(report)) {
|
||
process.exitCode = 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);
|
||
}
|
||
}
|