#!/usr/bin/env node /** * validate — S4 可判定核(非 LLM)· TypeScript 实现(方案 A·纯格式核) * * S4 可判定核的唯一实现。 * 靠 Node 原生类型擦除运行(Node ≥ 22.18 / 23.6 直接 `node validate.ts` 即可),无需编译、无依赖。 * * 设计同 .py:只查 存在 / 计数 / 二值 / 枚举 / 外键,不做任何语义判断。 * 「读懂意思才能判」的活儿全交给 validator 复核者。 * * 子命令: * cases 核用例结构(contract §2 七硬约束的「机械核」列 + `去向=fold:` 折叠指针真伪) * md 核交付件成品(文件名规范 / 禁字段 / 单一人工伴随件存在 / pre_test_case / 禁散文折叠字样) * verify 核 validator 报告的引文接地(req_span 场景存在 + quote 逐字命中 SPEC/test_case;非语义) * slice-spec 确定性切「截至当前批」的累计 SPEC 视图(前言 + 指定 --scenes 的完整场景块,逐字保序) * * 退出码: * 0 = pass(全过) * 1 = 规则失败(可重试,会吐 report) * 2 = 不可解析(文件读进来了但解不出该有的结构) * 3 = 配置(文件)缺失(要读的文件根本不存在) * * ╔══════════════════════════════════════════════════════════════════════════╗ * ║ 改 contract §3(格式 / 字段标记 / 四张枚举)必须【同步改本文件顶部】! ║ * ║ 下面的 MARK_* 常量 + 两张枚举集合,是 contract §3.3 字段清单的【代码镜像】。 ║ * ║ 本文件顶部的 MARK_* 与枚举须与 contract §3 逐字一致(格式的唯一代码副本)。 ║ * ╚══════════════════════════════════════════════════════════════════════════╝ */ import * as fs from "node:fs"; import * as path from "node:path"; // ── 真实字段标记 = contract.md §3.3 的【代码侧镜像】 ────────────────────────── // 用例头:`### Scenario N-M: <标题> [P0] [推导]? [SKIP: <原因>]?` const MARK_SCENARIO = /^###\s+Scenario\s+(\d+)-(\d+)\s*[::]\s*(.+?)\s*$/; const TAG_PRIORITY = /\[(P0|P1|P2)\]/; // 优先级标记 const TAG_DERIVED = "[推导]"; // 派生标记(整串字面) const TAG_SKIP = /\[SKIP\s*[::]\s*([^\]]+?)\s*\]/; // 取 [SKIP: 原因] 里的原因 const MARK_PRECOND = /^\s*-\s*前置条件\s*[::]\s*$/; // `- 前置条件:` 区段头 // `- 条件K: …(<标注>)` 分组1=K 分组2=正文(不含标注) 分组3=末尾全角括号里的标注 const MARK_COND = /^\s*-\s*条件(\d+)\s*[::]\s*(.*?)\s*(([^)]*))\s*$/; // 同上但【没有】末尾标注的条件行(用来识别「漏标注」) const MARK_COND_NOANNOT = /^\s*-\s*条件(\d+)\s*[::]\s*(.+?)\s*$/; const MARK_ACTION = /^\s*-\s*动作\s*[::]\s*(.*)$/; // `- 动作:…` const MARK_EXPECT = /^\s*-\s*预期结果\s*[::]\s*(.*)$/; // `- 预期结果:…` const MARK_TPHEAD = /^\s*-\s*测试点\s*[::]\s*$/; // `- 测试点:` 区段头 // `- TP-K(步骤N后)?: <具体二值>` 分组1=K 分组2=正文 const MARK_TP = /^\s*-\s*TP-(\d+)\s*(?:([^)]*))?\s*[::]\s*(.+?)\s*$/; const FILE_MAP = "## 编号映射表"; // 功能名称 | SPEC 编号 | REQ 编号 (有源 #1) const FILE_SRCMAP = "## 场景来源映射"; // 用例 | spec 场景 | delta(记账;delta 里的 `去向=fold:` 指针由 checkFoldPointers 决定性核验,其余仍不强核) // `去向=fold:N-M#TP-k` 折叠指针:折叠只准活在账本这一行,空折叠=指针指向不存在的用例/TP → cases FAIL。 // 用【精确前缀 `去向=fold:`】匹配,不用宽正则(防 delta 里恰好出现 "fold" 字样误触发)。 const FOLD_POINTER = /去向=fold:(\d+)-(\d+)#TP-(\d+)/g; // spec 里的场景块标题:`## 场景N`(N 可中文数字或阿拉伯) const SPEC_SCENE = /^##\s*场景\s*([一二三四五六七八九十百零0-9]+)\s*[::]?\s*(.*)$/; // 映射表里引用的 SPEC 编号,如 SPEC-01 / SPEC-1(finditer → 全局) const SPEC_ID_IN_TEXT = /SPEC-(\d+)/g; // 映射表里引用的「场景X」内联形式 const SCENE_REF_IN_TEXT = /场景\s*([一二三四五六七八九十百零0-9]+)/g; // ── 四张枚举(全中文;contract §3.3 末)──────────────────────────────────── // #7 前置条件标注枚举 const PRECOND_ANNOT = new Set([ "AutoTest 自动处理", "见前置用例", "特殊测试数据", "已下沉到测试步骤", ]); // #5 SKIP 原因枚举 const SKIP_REASONS = new Set([ "跨应用", "不可观测", "白盒", "故障注入", "上下文超窗", "输入缺失", ]); // #5 SKIP 用例的「应有」预期不许是这些空话 const SKIP_EMPTY_EXPECT = new Set([ "无", "略", "N.A.", "n.a.", "N/A", "n/a", "无。", "略。", ]); // md 子命令:test_case.md 里【禁止出现】的旧式/生造字段 const FORBIDDEN_FIELDS = [ "true_value", "observable", "来源:span", "触发词", "定位:", "- 定位", "trigger", "where-provenance", "scenarios.json", ]; // md 子命令:禁止的「散文折叠声称」字样闭集(精确动词形态,非裸「折叠」) // —— 折叠只准活在 `## 场景来源映射` 的 `去向=fold:N-M#TP-k` 账本行;写进散文=空白支票,机械掐。 // 「折叠面板/折叠列表」等正常 UI 语境不含「进/入/into」,不命中,不误伤。命中即 `prose_fold_claim` FAIL。 const PROSE_FOLD_CLAIM = ["折叠进", "折叠入", "folded into"]; // md 子命令:旧式不合规文件名(应统一为固定命名) const BAD_FILENAME = /^SPEC-.*(testcase|test_case|pre-testcase|manual-intervention)\.md$/i; // 源码文件名:行号(白盒引用)。\w 用 Unicode 等价 [\p{L}\p{N}_]。 const CODE_REF = /[\p{L}\p{N}_./\\-]+\.(?:ets|ts|tsx|js|jsx|kt|java|swift|cpp|cc|c|h|py)\s*[::]\s*\d/u; // 孤立的标签行(如 / ) const STRAY_TAG = /^\s*<\/?[a-zA-Z][\w:-]*>\s*$/; // ── 小工具(报告字节格式,确保产物稳定)────────── /** 格式 ['a', 'b', 'c'](单引号、`, ` 分隔)。本场景字符串内无引号/反斜杠。 */ function listRepr(arr: string[]): string { return "[" + arr.map((x) => "'" + x + "'").join(", ") + "]"; } /** 按码点升序排序(BMP 范围内等同 JS 默认 .sort())。 */ function sortedCopy(items: Iterable): string[] { return [...items].sort(); } /** win32 路径规范化:`/`→`\`、折叠重复分隔符、去尾分隔符。 */ function pathStr(p: string): string { if (process.platform === "win32") { let s = p.replace(/\//g, "\\").replace(/\\+/g, "\\"); if (s.length > 1) s = s.replace(/\\+$/, ""); return s; } let s = p.replace(/\/+/g, "/"); if (s.length > 1) s = s.replace(/\/+$/, ""); return s; } /** 按换行切分,行尾换行不产生末尾空串。 */ function splitlines(s: string): string[] { if (s === "") return []; const parts = s.split(/\r\n|\r|\n/); if (parts.length && parts[parts.length - 1] === "") parts.pop(); return parts; } // ── 报告数据结构 ──────────────────────────────────────────────────────────── interface FailedItem { rule: string; detail: string; scenario_id?: string; fix_hint?: string; } function failedItemToDict(it: FailedItem): Record { const d: Record = { rule: it.rule, detail: it.detail }; if (it.scenario_id) d.scenario_id = it.scenario_id; if (it.fix_hint) d.fix_hint = it.fix_hint; return d; } class Report { command: string; target: string; spec: string; checks_run = 0; checks_passed = 0; checks_failed = 0; failed_items: FailedItem[] = []; scenario_count = 0; constructor(command: string, target: string, spec = "") { this.command = command; this.target = target; this.spec = spec; } /** 开一项检查;返回当前已记的违规条数,供 endCheck 比对这项有没有新增违规。 */ startCheck(): number { this.checks_run += 1; return this.failed_items.length; } /** 收一项检查:跟开检查时的违规数比,没新增=过,有新增=挂。 */ endCheck(marker: number): void { if (this.failed_items.length === marker) this.checks_passed += 1; else this.checks_failed += 1; } fail(item: FailedItem): void { this.failed_items.push(item); } get passed(): boolean { return this.checks_failed === 0; } toDict(): Record { return { command: this.command, target: this.target, spec: this.spec, result: this.passed ? "pass" : "fail", checks_run: this.checks_run, checks_passed: this.checks_passed, checks_failed: this.checks_failed, scenario_count: this.scenario_count, failed_items: this.failed_items.map(failedItemToDict), }; } } // ── 解析出来的「一条用例」 ────────────────────────────────────────────────── interface Precond { k: number; text: string; annot: string; has_annot: boolean; } interface Scenario { n: number; m: number; title: string; priority: string; is_derived: boolean; is_skip: boolean; skip_reason: string; preconds: Precond[]; action: string; expect: string; tps: string[]; line_no: number; } function sid(c: Scenario): string { return `${c.n}-${c.m}`; } class ParseError extends Error {} // ── 中文数字 → number 小工具(场景号「场景六」「场景二十五」要能解析)────────── const CN_DIGIT: Record = { "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, }; function cnNumToInt(raw: string): number | null { const s = raw.trim(); if (!s) return null; if (/^[0-9]+$/.test(s)) return parseInt(s, 10); let total = 0; let pending = 0; let hasUnit = false; let hasAny = false; for (const ch of s) { if (ch === "零") { pending = 0; hasAny = true; } else if (ch in CN_DIGIT) { pending = CN_DIGIT[ch]; hasAny = true; } else if (ch === "十") { hasUnit = true; hasAny = true; total += (pending ? pending : 1) * 10; pending = 0; } else if (ch === "百") { hasUnit = true; hasAny = true; total += (pending ? pending : 1) * 100; pending = 0; } else { return null; // 出现不认识的字,放弃 } } total += pending; if (!hasAny) return null; if (total === 0 && !hasUnit) return null; return total; } // ── 解析 test_case.md → Scenario[] ────────────────────────────────────────── function stripHeaderTags( rest: string, ): { title: string; priority: string; is_derived: boolean; is_skip: boolean; skip_reason: string } { let priority = ""; const m = TAG_PRIORITY.exec(rest); if (m) priority = m[1]; const is_derived = rest.includes(TAG_DERIVED); let is_skip = false; let skip_reason = ""; const sm = TAG_SKIP.exec(rest); if (sm) { is_skip = true; skip_reason = sm[1].trim(); } // 纯标题 = 去掉所有 [..] 标记后剩下的 const title = rest.replace(/\[[^\]]*\]/g, "").trim(); return { title, priority, is_derived, is_skip, skip_reason }; } function parseScenarios(text: string): Scenario[] { const lines = splitlines(text); const scenarios: Scenario[] = []; let cur: Scenario | null = null; let section: "none" | "precond" | "action" | "expect" | "tp" = "none"; for (let idx0 = 0; idx0 < lines.length; idx0++) { const idx = idx0 + 1; const line = lines[idx0]; const head = MARK_SCENARIO.exec(line); if (head) { const n = parseInt(head[1], 10); const m = parseInt(head[2], 10); const t = stripHeaderTags(head[3]); cur = { n, m, title: t.title, priority: t.priority, is_derived: t.is_derived, is_skip: t.is_skip, skip_reason: t.skip_reason, preconds: [], action: "", expect: "", tps: [], line_no: idx, }; scenarios.push(cur); section = "none"; continue; } if (cur === null) continue; // 还没进入任何用例(文件头/映射表区域) // 碰到新的二级标题(## ...)说明用例区结束了 if (line.startsWith("## ")) { cur = null; section = "none"; continue; } if (MARK_PRECOND.test(line)) { section = "precond"; continue; } if (MARK_TPHEAD.test(line)) { section = "tp"; continue; } const am = MARK_ACTION.exec(line); if (am) { cur.action = am[1].trim(); section = "action"; continue; } const em = MARK_EXPECT.exec(line); if (em) { cur.expect = em[1].trim(); section = "expect"; continue; } // 区段内的明细行 if (section === "precond") { const cm = MARK_COND.exec(line); if (cm) { cur.preconds.push({ k: parseInt(cm[1], 10), text: cm[2].trim(), annot: cm[3].trim(), has_annot: true, }); continue; } const cm2 = MARK_COND_NOANNOT.exec(line); if (cm2) { cur.preconds.push({ k: parseInt(cm2[1], 10), text: cm2[2].trim(), annot: "", has_annot: false, }); continue; } } else if (section === "tp") { const tm = MARK_TP.exec(line); if (tm) { cur.tps.push(tm[2].trim()); continue; } } // 其它行(说明文字/空行)忽略 } return scenarios; } function parseNumberMap( text: string, ): { exists: boolean; specIds: Set; sceneIds: Set } { if (!text.includes(FILE_MAP)) return { exists: false, specIds: new Set(), sceneIds: new Set() }; const lines = splitlines(text); const specIds = new Set(); const sceneIds = new Set(); let inMap = false; for (const line of lines) { if (line.trim().startsWith(FILE_MAP)) { inMap = true; continue; } if (inMap) { if (line.startsWith("## ")) break; for (const sm of line.matchAll(SPEC_ID_IN_TEXT)) specIds.add(parseInt(sm[1], 10)); for (const sm of line.matchAll(SCENE_REF_IN_TEXT)) { const n = cnNumToInt(sm[1]); if (n !== null) sceneIds.add(n); } } } return { exists: true, specIds, sceneIds }; } // ── 解析 `## 场景来源映射` delta 里的 `去向=fold:N-M#TP-k` 折叠指针 ────────────── interface FoldPointer { n: number; m: number; k: number; raw: string; line_no: number; } function parseFoldPointers( text: string, ): { srcMapExists: boolean; pointers: FoldPointer[] } { // 表可能根本不存在(老 baseline 只在有推导时才出这张表)→ 不触发,向后兼容。 if (!text.includes(FILE_SRCMAP)) return { srcMapExists: false, pointers: [] }; const lines = splitlines(text); const pointers: FoldPointer[] = []; let inMap = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim().startsWith(FILE_SRCMAP)) { inMap = true; continue; } if (inMap) { if (line.startsWith("## ")) break; // 下一张二级表 → 本表结束 for (const m of line.matchAll(FOLD_POINTER)) { pointers.push({ n: parseInt(m[1], 10), m: parseInt(m[2], 10), k: parseInt(m[3], 10), raw: m[0], line_no: i + 1, }); } } } return { srcMapExists: true, pointers }; } function parseSpecScenes(text: string): Map { const scenes = new Map(); for (const line of splitlines(text)) { const m = SPEC_SCENE.exec(line); if (m) { const n = cnNumToInt(m[1]); if (n !== null) scenes.set(n, m[2].trim()); } } return scenes; } function parseSpecIds(text: string): Set { const ids = new Set(); for (const sm of text.matchAll(SPEC_ID_IN_TEXT)) ids.add(parseInt(sm[1], 10)); return ids; } // ── case_signature:逐字精确去重用的指纹 ───────────────────────────────────── function normalize(s: string): string { return s.replace(/ /g, " ").replace(/\s+/g, " ").trim(); } function caseSignature(c: Scenario): string { const preconds = c.preconds.map((p) => normalize(p.text)).join("|"); const action = normalize(c.action); const expect = normalize(c.expect); const tps = c.tps.map(normalize).sort().join("|"); return `PRE::${preconds}##ACT::${action}##EXP::${expect}##TP::${tps}`; } // ════════════════════════════════════════════════════════════════════════════ // cases 子命令:十项检查(末项 checkFoldPointers 只在出现 `去向=fold:` 指针时才可能 FAIL) // ════════════════════════════════════════════════════════════════════════════ function checkSceneMapping( cases: Scenario[], mapExists: boolean, mapSpecIds: Set, mapSceneIds: Set, specIds: Set, specSceneNums: Set, report: Report, ): void { const marker = report.startCheck(); if (!mapExists) { report.fail({ rule: "scene_mapping_table_missing", detail: `缺少 \`${FILE_MAP}\` 区块(有源 #1 要求每条用例都对得上 SPEC)`, fix_hint: `在文件顶部补 \`${FILE_MAP}\` 表:功能名称 | SPEC 编号 | REQ 编号`, }); report.endCheck(marker); return; } if (mapSpecIds.size === 0 && mapSceneIds.size === 0) { report.fail({ rule: "scene_mapping_table_empty", detail: `\`${FILE_MAP}\` 存在但没引用任何 SPEC 编号或场景号`, fix_hint: "表里每行须引用 spec 的外键:`场景X`(spec-generate 产物)或 `SPEC-NN`", }); } // 外键(旧式 SPEC-NN) if (specIds.size > 0) { for (const sidNum of [...mapSpecIds].sort((a, b) => a - b)) { if (!specIds.has(sidNum)) { report.fail({ rule: "scene_mapping_spec_not_found", detail: `映射表引用 SPEC-${String(sidNum).padStart(2, "0")},但 --spec 文件里查不到该编号`, fix_hint: "改对 SPEC 编号,或确认 spec 文件正确", }); } } } // 外键(场景X) if (specSceneNums.size > 0) { for (const n of [...mapSceneIds].sort((a, b) => a - b)) { if (!specSceneNums.has(n)) { report.fail({ rule: "scene_mapping_scene_not_found", detail: `映射表引用「场景${n}」,但 --spec 文件里没有该场景`, fix_hint: "改对场景号,或确认 spec 文件正确", }); } } } report.endCheck(marker); } function checkSceneTotality( cases: Scenario[], specScenes: Map, report: Report, ): void { const marker = report.startCheck(); const allScenes = new Set(specScenes.keys()); if (allScenes.size === 0) { report.fail({ rule: "spec_no_scenes", detail: "--spec 文件里没解析到任何 `## 场景N`(周延 #2 无从核起)", fix_hint: "确认 spec 文件含 `## 场景一` 这类场景块(中文数字或阿拉伯均可)", }); report.endCheck(marker); return; } const covered = new Set(cases.map((c) => c.n)); // SKIP 用例也算覆盖 const missing = [...allScenes].filter((n) => !covered.has(n)).sort((a, b) => a - b); for (const n of missing) { report.fail({ rule: "scene_not_covered", detail: `SPEC 场景 ${n}(「${specScenes.get(n)}」)没有任何用例或 SKIP 覆盖`, fix_hint: `为场景 ${n} 补一条 Scenario ${n}-x,或写一条带原因的 [SKIP]`, }); } report.endCheck(marker); } function checkActionPresent(cases: Scenario[], report: Report): void { const marker = report.startCheck(); for (const c of cases) { if (c.is_skip) continue; if (!c.action.trim()) { report.fail({ rule: "action_empty", scenario_id: sid(c), detail: `Scenario ${sid(c)} 的 \`- 动作\` 为空`, fix_hint: "补具体动作链:打开 X -> ... -> ...", }); } } report.endCheck(marker); } function checkTpPresent(cases: Scenario[], report: Report): void { const marker = report.startCheck(); for (const c of cases) { if (c.is_skip) continue; if (!c.expect.trim()) { report.fail({ rule: "expect_empty", scenario_id: sid(c), detail: `Scenario ${sid(c)} 的 \`- 预期结果\` 为空`, fix_hint: "补非空预期结果", }); } if (c.tps.length === 0 || c.tps.every((t) => !t.trim())) { report.fail({ rule: "tp_empty", scenario_id: sid(c), detail: `Scenario ${sid(c)} 没有任何非空 TP(\`- 测试点\` 下至少 1 条 TP-K)`, fix_hint: "补至少 1 条具体二值 TP", }); } } report.endCheck(marker); } function checkSkipWellformed(cases: Scenario[], report: Report): void { const marker = report.startCheck(); for (const c of cases) { if (!c.is_skip) continue; if (!c.skip_reason) { report.fail({ rule: "skip_reason_missing", scenario_id: sid(c), detail: `Scenario ${sid(c)} 标了 [SKIP] 但没写原因`, fix_hint: `写成 [SKIP: <原因>],原因取自 ${listRepr(sortedCopy(SKIP_REASONS))}`, }); } else if (!SKIP_REASONS.has(c.skip_reason)) { report.fail({ rule: "skip_reason_invalid", scenario_id: sid(c), detail: `Scenario ${sid(c)} 的 SKIP 原因「${c.skip_reason}」不在枚举内`, fix_hint: `原因须取自 ${listRepr(sortedCopy(SKIP_REASONS))}`, }); } const exp = c.expect.trim(); if (!exp) { report.fail({ rule: "skip_expect_empty", scenario_id: sid(c), detail: `Scenario ${sid(c)}(SKIP)的「应有」预期为空`, fix_hint: "SKIP 仍须写非空「应有」预期(声明应有结果 + 交还对象)", }); } else if (SKIP_EMPTY_EXPECT.has(exp)) { report.fail({ rule: "skip_expect_placeholder", scenario_id: sid(c), detail: `Scenario ${sid(c)}(SKIP)的预期是占位空话「${exp}」`, fix_hint: "写出真实「应有」预期,不许 无/略/N.A.", }); } } report.endCheck(marker); } function checkDedupExact( cases: Scenario[], report: Report, ): void { const marker = report.startCheck(); const runnable = cases.filter((c) => !c.is_skip); const seen = new Map(); const survivorsByScene = new Map(); for (const c of runnable) { const sig = caseSignature(c); if (seen.has(sig)) { const first = seen.get(sig)!; report.fail({ rule: "exact_duplicate", scenario_id: sid(c), detail: `Scenario ${sid(c)} 与 ${sid(first)} 逐字精确重复(前置条件 + 动作链 + 预期结果 + TP 集全相同)`, fix_hint: "派生重复可删并记账;若是场景唯一(base)用例撞车,标红保留进 review_notes", }); } else { seen.set(sig, c); survivorsByScene.set(c.n, (survivorsByScene.get(c.n) ?? 0) + 1); } } const scenesWithRunnable = new Set(runnable.map((c) => c.n)); for (const n of [...scenesWithRunnable].sort((a, b) => a - b)) { if ((survivorsByScene.get(n) ?? 0) < 1) { report.fail({ rule: "scene_empty_after_dedup", detail: `场景 ${n} 去重后没有任何用例剩下`, fix_hint: "保留该场景至少 1 条 base 用例", }); } } report.endCheck(marker); } function checkPrecondAnnotation(cases: Scenario[], report: Report): void { const marker = report.startCheck(); for (const c of cases) { for (const p of c.preconds) { if (!p.has_annot) { report.fail({ rule: "precond_annotation_missing", scenario_id: sid(c), detail: `Scenario ${sid(c)} 的 条件${p.k}「${p.text}」末尾没带(标注)`, fix_hint: `末尾补(标注),取自 ${listRepr(sortedCopy(PRECOND_ANNOT))}`, }); } else if (!PRECOND_ANNOT.has(p.annot)) { report.fail({ rule: "precond_annotation_invalid", scenario_id: sid(c), detail: `Scenario ${sid(c)} 的 条件${p.k} 标注「${p.annot}」不在枚举内`, fix_hint: `标注须取自 ${listRepr(sortedCopy(PRECOND_ANNOT))}`, }); } } } report.endCheck(marker); } function checkNoCodeRefs(cases: Scenario[], report: Report): void { const marker = report.startCheck(); for (const c of cases) { const fields: Array<[string, string]> = [["动作", c.action], ["预期结果", c.expect]]; c.tps.forEach((t, i) => fields.push([`TP-${i + 1}`, t])); for (const p of c.preconds) fields.push([`条件${p.k}`, p.text]); for (const [fname, val] of fields) { const m = CODE_REF.exec(val || ""); if (m) { report.fail({ rule: "code_ref_in_field", scenario_id: sid(c), detail: `Scenario ${sid(c)} 的「${fname}」含源码引用「${m[0]}」(白盒证据泄漏进黑盒用例)`, fix_hint: "删掉源码文件名/行号;现状与代码引用只进 review_notes 阻塞区,test_case.md 字段里一个字都不留", }); } } } report.endCheck(marker); } function checkNoStrayMarkup(tcText: string, report: Report): void { const marker = report.startCheck(); const lines = splitlines(tcText); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (STRAY_TAG.test(line)) { report.fail({ rule: "stray_markup", detail: `第 ${i + 1} 行是孤立标签「${line.trim()}」(疑似工具调用闭合标签漏进文件正文)`, fix_hint: "删掉该行;它不是任何合法用例字段", }); } } report.endCheck(marker); } // ── 折叠指针决定性核验(只核指针真伪,绝不做字段名语义匹配)──────────────────── // contract §3.1b「禁空白折叠」的程序侧那半:账本里写了 `去向=fold:N-M#TP-k`,就核 // Scenario N-M 真存在、且真有第 k 条 TP。空白折叠(指针指向不存在的用例/TP)→ FAIL, // 挡在 validator 语义门之前。**只在出现 `去向=fold:` 指针时触发**,老文件行为不变。 // 「TP-k 语义上够不够格覆盖被折叠字段」是不可判定的,归 validator,程序不碰。 function checkFoldPointers( cases: Scenario[], pointers: FoldPointer[], report: Report, ): void { const marker = report.startCheck(); const tpCount = new Map(); for (const c of cases) tpCount.set(sid(c), c.tps.length); for (const p of pointers) { const target = `${p.n}-${p.m}`; if (!tpCount.has(target)) { report.fail({ rule: "fold_target_scenario_missing", scenario_id: target, detail: `账本第 ${p.line_no} 行 \`${p.raw}\` 折叠指向 Scenario ${target},但该用例不存在(空白折叠:声称折叠却查无落点用例)`, fix_hint: "补出 Scenario N-M 并在其中落一条断言被折叠分支的 TP(可用合并 TP),或更新 fold 指针指向真实存在的用例;不得把分支折进散文而不落 TP", }); continue; } const cnt = tpCount.get(target)!; if (p.k > cnt) { report.fail({ rule: "fold_target_tp_missing", scenario_id: target, detail: `账本第 ${p.line_no} 行 \`${p.raw}\` 折叠指向 Scenario ${target} 的第 ${p.k} 条 TP,但该用例只有 ${cnt} 条 TP(空白折叠:指针指向不存在的 TP)`, fix_hint: "在 Scenario N-M 补出第 k 条 TP 断言被折叠分支的可观测结果(可用合并 TP),或更新 fold 指针指向现有 TP;两种修法择一", }); } } report.endCheck(marker); } interface CasesArgs { test_case: string; spec: string; ui: string; report: string; } function cmdCases(args: CasesArgs): number { const tcPath = args.test_case; const specPath = args.spec; // 配置缺失 → 3 if (!fs.existsSync(tcPath)) { process.stderr.write(`[validate] 找不到用例文件: ${pathStr(tcPath)}\n`); return 3; } if (!fs.existsSync(specPath)) { process.stderr.write(`[validate] 找不到 spec 文件: ${pathStr(specPath)}\n`); return 3; } if (args.ui && !fs.existsSync(args.ui)) { process.stderr.write( `[validate] 警告:--ui 指定的文件不存在,按「无对照表」处理: ${args.ui}\n`, ); } const tcText = fs.readFileSync(tcPath, "utf-8"); const specText = fs.readFileSync(specPath, "utf-8"); const report = new Report("cases", pathStr(tcPath), pathStr(specPath)); // 解析 → 不可解析则 2 try { const cases = parseScenarios(tcText); if (cases.length === 0) { throw new ParseError("文件里没解析到任何 `### Scenario N-M` 用例"); } const nm = parseNumberMap(tcText); const fold = parseFoldPointers(tcText); const specScenes = parseSpecScenes(specText); const specIds = parseSpecIds(specText); report.scenario_count = cases.length; // 十项检查 checkSceneMapping(cases, nm.exists, nm.specIds, nm.sceneIds, specIds, new Set(specScenes.keys()), report); checkSceneTotality(cases, specScenes, report); checkActionPresent(cases, report); checkTpPresent(cases, report); checkSkipWellformed(cases, report); checkDedupExact(cases, report); checkPrecondAnnotation(cases, report); checkNoCodeRefs(cases, report); checkNoStrayMarkup(tcText, report); checkFoldPointers(cases, fold.pointers, report); writeReport(args.report, report); return report.passed ? 0 : 1; } catch (e) { if (e instanceof ParseError) { process.stderr.write(`[validate] 不可解析: ${e.message}\n`); report.fail({ rule: "unparseable", detail: e.message }); writeReport(args.report, report); return 2; } throw e; } } // ════════════════════════════════════════════════════════════════════════════ // md 子命令:核交付件成品 // ════════════════════════════════════════════════════════════════════════════ // ── pre_test_case.md 段体格式 = contract §4 的【代码侧镜像】────────────────── const PRE_SEG_HEADER_LINE = /^##\s+段\s*(\d+)\s*[::]\s*(.+?)\s*$/; const PRE_SEG_OPEN = /^打开\s*\S/; const PRE_SEG_EXPECT = "期望结果:"; const PRE_SEG_FORBIDDEN_BULLET = /^\s*-\s*(用途|应有起始态|准备步骤|准备方式|达成态|适用)\s*[::]/; const PRE_SEG_NAME_FORBIDDEN = "前置"; const PRE_SEG_MAX_STEPS = 15; function checkMdFilename(outDir: string, report: Report): void { const marker = report.startCheck(); for (const name of fs.readdirSync(outDir)) { const full = path.join(outDir, name); let isFile = false; try { isFile = fs.statSync(full).isFile(); } catch { isFile = false; } if (isFile) { if (name.toLowerCase() === "manual-intervention.md") { report.fail({ rule: "manual_intervention_forbidden", detail: `目录含禁用的 ${name}(人工伴随件须合一为单一 review_notes.md,不得另起 manual-intervention.md)`, fix_hint: "把阻塞区+非阻塞区并进同一份 review_notes.md,删掉 manual-intervention.md", }); } else if (BAD_FILENAME.test(name)) { report.fail({ rule: "filename_noncompliant", detail: `目录含旧命名文件: ${name}`, fix_hint: "删旧命名,统一为 test_case.md / pre_test_case.md / review_notes.md", }); } } } report.endCheck(marker); } function checkMdForbiddenFields(mdText: string, report: Report): void { const marker = report.startCheck(); for (const fld of FORBIDDEN_FIELDS) { if (mdText.includes(fld)) { report.fail({ rule: "forbidden_field_present", detail: `test_case.md 含禁字段: '${fld}'`, fix_hint: `删除所有 '${fld}';只保留前置条件/动作/预期结果/测试点 这套真实字段`, }); } } report.endCheck(marker); } function checkMdCompanionExists(outDir: string, report: Report): void { const marker = report.startCheck(); const candidates = ["review_notes.md", "review-notes.md"]; const present = candidates.filter((c) => fs.existsSync(path.join(outDir, c))); if (present.length === 0) { report.fail({ rule: "companion_missing", detail: `缺单一人工伴随件(应有 ${listRepr(candidates)} 之一)`, fix_hint: "S6 须渲染【单一人工伴随件 review_notes.md】(阻塞区在上+非阻塞区在下,合一为一个文件)", }); } else if (present.length > 1) { // 【合一】硬约束:阻塞区 + 非阻塞区须渲染进同一份伴随件,不得拆成多文件 // (SKILL.md S6「渲染成一份」/「单一人工伴随件」)。同时存在多个 = 拆文件 = FAIL。 report.fail({ rule: "companion_not_merged", detail: `检出多个伴随件 ${listRepr(present)};阻塞与非阻塞须合一为【单一人工伴随件】,不得拆成多文件`, fix_hint: "把阻塞区(## 人工介入/特殊执行顺序 等)并进同一份 review_notes.md(阻塞区在上、非阻塞区在下),只保留一个伴随件文件", }); } report.endCheck(marker); } function checkPreTestcaseMd(outDir: string, report: Report): void { const marker = report.startCheck(); const pre = path.join(outDir, "pre_test_case.md"); const testcase = path.join(outDir, "test_case.md"); const testcaseText = fs.existsSync(testcase) ? fs.readFileSync(testcase, "utf-8") : ""; const needsPre = testcaseText.includes("(见前置用例)"); if (!fs.existsSync(pre)) { if (needsPre) { report.fail({ rule: "pre_testcase_required_missing", detail: "test_case.md 含「(见前置用例)」,但交付目录缺少 pre_test_case.md", fix_hint: "从所有「见前置用例」累积态前提反向组装 pre_test_case.md;只有无命中时才可省略", }); } report.endCheck(marker); return; } const text = fs.readFileSync(pre, "utf-8"); const lines = splitlines(text); // (d) 段体禁 bullet 字段:全文扫一遍 for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (PRE_SEG_FORBIDDEN_BULLET.test(line)) { report.fail({ rule: "pre_seg_forbidden_bullet", detail: `pre_test_case.md 第 ${i + 1} 行含禁用 bullet 字段「${line.trim()}」(段体只许『## 段N:』标题行 + 单行操作链)`, fix_hint: "删掉 - 用途/- 应有起始态/- 准备步骤 等字段;用途映射放 review_notes,准备步骤写成单行操作链", }); } } // (a)+(b)+(c) 逐段 const headers: Array<[number, RegExpExecArray]> = []; for (let i = 0; i < lines.length; i++) { const m = PRE_SEG_HEADER_LINE.exec(lines[i]); if (m) headers.push([i, m]); } if (headers.length === 0) { report.fail({ rule: "pre_testcase_no_segment", detail: "pre_test_case.md 存在但解析不到任何 `## 段N:` 段块", fix_hint: "按 contract §4 渲染 `## 段N: <段名>` + 单行操作链", }); report.endCheck(marker); return; } for (const [hi, hm] of headers) { const segNo = hm[1]; const segName = hm[2].trim(); // (c) 段名禁含「前置」 if (segName.includes(PRE_SEG_NAME_FORBIDDEN)) { report.fail({ rule: "pre_seg_name_forbidden", detail: `段${segNo} 段名「${segName}」含「前置」字样(会触碰 #7 前置条件字面禁用)`, fix_hint: "段名改成体现产出的说法,如『扫描 N 首测试歌曲』『创建基线歌单』", }); } // 取段标题行之后第一条非空、非新段标题的行作操作链 let chain = ""; let chainIdx = -1; for (let j = hi + 1; j < lines.length; j++) { const s = lines[j].trim(); if (!s) continue; if (PRE_SEG_HEADER_LINE.test(lines[j])) break; // 该段没有操作链行就到了下一段 chain = s; chainIdx = j + 1; break; } if (!chain) { report.fail({ rule: "pre_seg_bad_format", detail: `段${segNo}(「${segName}」)缺少操作链行`, fix_hint: "段标题行后须紧跟一行『打开 … -> … -> 期望结果:…』", }); continue; } // (b) 操作链:以「打开」起步 if (!PRE_SEG_OPEN.test(chain)) { report.fail({ rule: "pre_seg_bad_format", detail: `段${segNo} 操作链未以「打开」起步(第 ${chainIdx} 行)`, fix_hint: "每段是独立 AutoTest 任务,操作链须以『打开 {app}』起步", }); } // (b) 操作链:行内含全角「期望结果:」 if (!chain.includes(PRE_SEG_EXPECT)) { report.fail({ rule: "pre_seg_bad_format", detail: `段${segNo} 操作链缺行内「期望结果:」收尾(第 ${chainIdx} 行)`, fix_hint: "操作链须以『-> 期望结果:』收尾(全角冒号)", }); } // (b) 步骤数 ≤15(以 ` -> ` 切分,含打开与期望结果) const steps = chain.split(" -> ").filter((p) => p.trim()); if (steps.length > PRE_SEG_MAX_STEPS) { report.fail({ rule: "pre_seg_too_long", detail: `段${segNo} 操作链 ${steps.length} 步,超过上限 ${PRE_SEG_MAX_STEPS}(单段执行易超 CASE_TIMEOUT_MS=10min)`, fix_hint: `拆成多段,每段 ≤${PRE_SEG_MAX_STEPS} 步`, }); } } report.endCheck(marker); } // ── 散文折叠字样机械扫(hygiene lint,非主保障)────────────────────────────── // 主保障是 cases 的正向指针核验(空折叠 → fold 指针指向不存在的 TP → FAIL)。 // 这条只把「折叠写进散文却不进账本」这个最常见的假安慰形态掐掉:扫交付目录里每个 // `.md`,命中禁字样闭集(折叠进/折叠入/folded into,精确动词形态)即 FAIL。 // 改述式折叠(如「并入 1-1 的观测」)逃得过字面扫——那是信息论下限,本就不靠这条兜正确性。 function checkMdProseFold(outDir: string, report: Report): void { const marker = report.startCheck(); for (const name of fs.readdirSync(outDir)) { if (!name.toLowerCase().endsWith(".md")) continue; const full = path.join(outDir, name); let isFile = false; try { isFile = fs.statSync(full).isFile(); } catch { isFile = false; } if (!isFile) continue; const lines = splitlines(fs.readFileSync(full, "utf-8")); for (let i = 0; i < lines.length; i++) { const low = lines[i].toLowerCase(); for (const needle of PROSE_FOLD_CLAIM) { if (low.includes(needle.toLowerCase())) { report.fail({ rule: "prose_fold_claim", detail: `${name} 第 ${i + 1} 行含散文折叠声称字样「${needle}」(折叠只准写进 \`## 场景来源映射\` 的 \`去向=fold:N-M#TP-k\` 账本行,写进散文=空白支票)`, fix_hint: "删掉散文里的折叠声称;真要折叠就在账本写 `去向=fold:N-M#TP-k` 并在目标用例落一条断言该分支的 TP(可用合并 TP)", }); } } } } report.endCheck(marker); } interface MdArgs { output_dir: string; report: string; } function cmdMd(args: MdArgs): number { const outDir = args.output_dir; let isDir = false; try { isDir = fs.existsSync(outDir) && fs.statSync(outDir).isDirectory(); } catch { isDir = false; } if (!isDir) { process.stderr.write(`[validate] 找不到输出目录: ${pathStr(outDir)}\n`); return 3; } const tc = path.join(outDir, "test_case.md"); if (!fs.existsSync(tc)) { process.stderr.write(`[validate] 输出目录缺 test_case.md: ${pathStr(tc)}\n`); return 3; } const report = new Report("md", pathStr(outDir)); try { const mdText = fs.readFileSync(tc, "utf-8"); // test_case.md 至少要有一条用例,否则不可解析 const anyScenario = splitlines(mdText).some((l) => MARK_SCENARIO.test(l)); if (!anyScenario) { throw new ParseError("test_case.md 解析不到任何 `### Scenario N-M`"); } checkMdFilename(outDir, report); checkMdForbiddenFields(mdText, report); checkMdCompanionExists(outDir, report); checkPreTestcaseMd(outDir, report); checkMdProseFold(outDir, report); writeReport(args.report, report); return report.passed ? 0 : 1; } catch (e) { if (e instanceof ParseError) { process.stderr.write(`[validate] 不可解析: ${e.message}\n`); report.fail({ rule: "unparseable", detail: e.message }); writeReport(args.report, report); return 2; } throw e; } } // ════════════════════════════════════════════════════════════════════════════ // verify 子命令:核 validator 报告的「引文接地」(非语义,只查引文真伪) // —— req_span 指向的场景在 SPEC 里存在;quote 归一后逐字命中 SPEC 原文或 test_case 原文。 // 这是把「不可判定的语义裁决」压缩成的那个小引文决策的机械复核:不替 validator 判 // 对错,只保证它没有幻觉出源文里根本不存在的引文来 FAIL 无辜用例。② 引文可来自 SPEC // 场景原文(①③④)或 test_case 账本 delta(②),故两处任一命中即算接地。 // ════════════════════════════════════════════════════════════════════════════ interface VerifyArgs { report_in: string; spec: string; test_case: string; report: string; } function cmdVerify(args: VerifyArgs): number { const report = new Report("verify", pathStr(args.report_in), pathStr(args.spec)); let specText: string; try { specText = fs.readFileSync(args.spec, "utf-8"); } catch { process.stderr.write(`[validate] 找不到 --spec: ${pathStr(args.spec)}\n`); return 3; } let tcText = ""; try { tcText = args.test_case ? fs.readFileSync(args.test_case, "utf-8") : ""; } catch { tcText = ""; } let raw: string; try { raw = fs.readFileSync(args.report_in, "utf-8"); } catch { process.stderr.write(`[validate] 找不到 validator 报告: ${pathStr(args.report_in)}\n`); return 3; } let doc: { failed_items?: unknown }; try { doc = JSON.parse(raw); } catch (e) { process.stderr.write(`[validate] validator 报告不是合法 JSON: ${(e as Error).message}\n`); report.fail({ rule: "unparseable", detail: "validator 报告不是合法 JSON" }); writeReport(args.report, report); return 2; } const items = Array.isArray(doc?.failed_items) ? (doc.failed_items as Record[]) : []; const specScenes = parseSpecScenes(specText); const specIds = parseSpecIds(specText); const haySpec = normalize(specText); const hayTc = normalize(tcText); report.scenario_count = items.length; for (const it of items) { const sidStr = typeof it?.scenario === "string" ? it.scenario : "(无 scenario)"; const reqSpan = typeof it?.req_span === "string" ? it.req_span : ""; const quote = typeof it?.quote === "string" ? it.quote : ""; // ① req_span 场景存在性(能解析出场景号/SPEC 编号时才核;老式无编号则从宽跳过) const m1 = report.startCheck(); const sceneM = /场景\s*([一二三四五六七八九十百零0-9]+)/.exec(reqSpan); const specM = /SPEC-(\d+)/.exec(reqSpan); if (sceneM) { const n = cnNumToInt(sceneM[1]); if (n !== null && specScenes.size > 0 && !specScenes.has(n)) { report.fail({ rule: "verify_reqspan_scene_not_found", detail: `${sidStr}: req_span「${reqSpan}」引用场景${n},但 --spec 里没有该 \`## 场景${n}\``, scenario_id: sidStr, fix_hint: "改对场景号,或确认 validator 挂靠的场景真存在", }); } } else if (specM && specIds.size > 0 && !specIds.has(parseInt(specM[1], 10))) { report.fail({ rule: "verify_reqspan_scene_not_found", detail: `${sidStr}: req_span「${reqSpan}」引用 SPEC-${specM[1]},但 --spec 里没有该编号`, scenario_id: sidStr, fix_hint: "改对 SPEC 编号,或确认 validator 挂靠的场景真存在", }); } report.endCheck(m1); // ② quote 逐字接地(归一后须是 SPEC 原文或 test_case 原文的子串) const m2 = report.startCheck(); const nq = normalize(quote); if (nq === "") { report.fail({ rule: "verify_quote_empty", detail: `${sidStr}: failed_item 缺 quote 引文(FAIL 裁决必须带逐字引文)`, scenario_id: sidStr, fix_hint: "补 {req_span, quote};quote 逐字抄自 SPEC 场景原文或 test_case 账本", }); } else if (!haySpec.includes(nq) && !hayTc.includes(nq)) { report.fail({ rule: "verify_quote_not_grounded", detail: `${sidStr}: quote「${quote}」归一后既不在 SPEC 原文、也不在 test_case 原文里逐字出现(疑似幻觉引文)`, scenario_id: sidStr, fix_hint: "把 quote 改成逐字抄自 SPEC 场景原文(或 test_case 账本 delta)的句子,不得转述/杜撰", }); } report.endCheck(m2); } writeReport(args.report, report); return report.passed ? 0 : 1; } // ════════════════════════════════════════════════════════════════════════════ // slice-spec 子命令:确定性切「截至当前批」的累计 SPEC 视图 // —— SPEC 前言 + 指定 --scenes 场景号的完整 `## 场景N` 块,逐字、保序。取代 // orchestrator 手工拼接(手拼漏一场景/串一行即 S4 totality 误判)。 // ════════════════════════════════════════════════════════════════════════════ interface SliceArgs { spec: string; scenes: Set; out: string; } function sliceSpecScenes(text: string, want: Set): string { const lines = splitlines(text); const out: string[] = []; let seenScene = false; let keep = true; // 前言(首个场景头之前)恒保留 for (const line of lines) { const m = SPEC_SCENE.exec(line); if (m) { seenScene = true; const n = cnNumToInt(m[1]); keep = n !== null && want.has(n); } if (!seenScene || keep) out.push(line); } return out.join("\n") + "\n"; } function cmdSliceSpec(args: SliceArgs): number { let specText: string; try { specText = fs.readFileSync(args.spec, "utf-8"); } catch { process.stderr.write(`[validate] 找不到 --spec: ${pathStr(args.spec)}\n`); return 3; } const sliced = sliceSpecScenes(specText, args.scenes); fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true }); fs.writeFileSync(args.out, sliced, "utf-8"); // 请求的场景号在 SPEC 里没找到 → 警告并非 0 退出(帮 orchestrator 早发现批次/场景号配错) const have = parseSpecScenes(specText); const missing = [...args.scenes].filter((n) => !have.has(n)).sort((a, b) => a - b); if (missing.length > 0) { process.stderr.write(`[validate] slice-spec 警告:请求场景 ${missing.join(",")} 在 --spec 里不存在\n`); return 1; } return 0; } // ── 报告落盘 ──────────────────────────────────────────────────────────────── function writeReport(p: string, report: Report): void { fs.mkdirSync(path.dirname(path.resolve(p)), { recursive: true }); fs.writeFileSync(p, JSON.stringify(report.toDict(), null, 2), "utf-8"); } // ── 入口 ──────────────────────────────────────────────────────────────────── /** 极简参数解析:支持 `--key value` 与 `--key=value`;其余为位置参数。 */ function parseFlags( argv: string[], ): { positional: string[]; flags: Record } { const positional: string[] = []; const flags: Record = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const eq = a.indexOf("="); if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); } else { flags[a.slice(2)] = argv[i + 1] ?? ""; i++; } } else { positional.push(a); } } return { positional, flags }; } function usageError(msg: string): never { process.stderr.write(`validate: error: ${msg}\n`); process.stderr.write( "usage: validate (\n" + " cases --spec S --report R [--ui U]\n" + " | md --report R\n" + " | verify --spec S --report R [--test-case T]\n" + " | slice-spec --scenes 1,2,3 --out O\n" + ")\n", ); process.exit(2); } function main(): number { const argv = process.argv.slice(2); const cmd = argv[0]; if (cmd === "cases") { const { positional, flags } = parseFlags(argv.slice(1)); if (positional.length < 1) usageError("cases 缺少位置参数 test_case"); if (!flags.spec) usageError("cases 缺少必填参数 --spec"); if (!flags.report) usageError("cases 缺少必填参数 --report"); return cmdCases({ test_case: positional[0], spec: flags.spec, ui: flags.ui ?? "", report: flags.report, }); } else if (cmd === "md") { const { positional, flags } = parseFlags(argv.slice(1)); if (positional.length < 1) usageError("md 缺少位置参数 output_dir"); if (!flags.report) usageError("md 缺少必填参数 --report"); return cmdMd({ output_dir: positional[0], report: flags.report }); } else if (cmd === "verify") { const { positional, flags } = parseFlags(argv.slice(1)); if (positional.length < 1) usageError("verify 缺少位置参数 validator-report.json"); if (!flags.spec) usageError("verify 缺少必填参数 --spec"); if (!flags.report) usageError("verify 缺少必填参数 --report"); return cmdVerify({ report_in: positional[0], spec: flags.spec, test_case: flags["test-case"] ?? "", report: flags.report, }); } else if (cmd === "slice-spec") { const { positional, flags } = parseFlags(argv.slice(1)); if (positional.length < 1) usageError("slice-spec 缺少位置参数 spec.md"); if (!flags.scenes) usageError("slice-spec 缺少必填参数 --scenes"); if (!flags.out) usageError("slice-spec 缺少必填参数 --out"); const nums = new Set( flags.scenes.split(",").map((x) => parseInt(x.trim(), 10)).filter((x) => !Number.isNaN(x)), ); return cmdSliceSpec({ spec: positional[0], scenes: nums, out: flags.out }); } else { usageError("须指定子命令: cases | md | verify | slice-spec"); } } try { process.exit(main()); } catch (e) { const err = e as NodeJS.ErrnoException; if (err && err.code === "ENOENT") { process.exit(3); // 配置/输入文件缺失 → 全局致命 } if (e instanceof ParseError) { process.exit(2); // 不可解析 → SPEC 级致命 } process.stderr.write(`[validate] 内部错误: ${err?.message ?? String(e)}\n`); process.exit(2); }