Files
jev-match-game/scripts/verify.ts
T

478 lines
23 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.
/**
* 内容完整性与引擎流程验证(不走浏览器):
* 1. 章节 JSON 的选择点 ID、维度标签与 docs/story-framework.md 第 4 节一一对应;
* 2. 节点图完整(startNodeId 存在、所有 next 跳转有效、全部节点可达);
* 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分;
* 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本;
* 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete;
* SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。
* 6. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过;
* 7. 条件文本:flag 推导(附录 A)、【若】标记零泄漏、差分显隐、条件不满足节点跳过无死路。
* 运行:pnpm verify
*/
import { readFileSync } from 'node:fs';
import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine';
import { LocalStorageEventStore } from '../src/engine/eventStore';
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
import { choiceFlags, narrationFlags } from '../src/engine/flags';
import { MemoryProgressStore } from '../src/engine/progress';
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
import type { Chapter, ChoiceNode, IdentityProfile, NarrationNode } from '../src/engine/types';
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
import { MockJevClient } from '../src/services/jev';
import type { JevAnswer, JevQuestion } from '../src/services/jev';
// story-framework.md 第 4 节的权威选择点地图(ID → 期望维度标签)
const EXPECTED: Record<string, string[]> = {
'C1-01': [],
'C1-02': ['attachment', 'self_disclosure'],
'C1-03': ['values'],
'C1-04': ['values', 'conflict_style'],
'C1-05': ['culture'],
'C1-06': ['risk'],
'C2-01': ['values', 'conflict_style'],
'C2-02': ['values', 'emotional_stability'],
'C2-03': ['risk', 'conflict_style'],
'C2-04': ['culture'],
'C2-05': ['attachment', 'reciprocity'],
'C2-06': ['conflict_style', 'values'],
'C3-01': ['culture'],
'C3-02': ['emotional_stability', 'attachment'],
'C3-03': ['values', 'emotional_stability'],
'C3-04': ['conflict_style', 'values'],
'C3-05': ['mate_preference'],
'C3-06': ['attachment', 'mate_preference'],
};
class MemoryStore implements EventStore {
events: StoryEvent[] = [];
async append(e: StoryEvent) {
this.events.push(e);
}
async list(sessionId?: string) {
return sessionId ? this.events.filter((e) => e.sessionId === sessionId) : [...this.events];
}
}
const chapters = [1, 2, 3].map(
(i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')) as Chapter,
);
let failures = 0;
function check(ok: boolean, msg: string) {
if (!ok) {
failures++;
console.error(`✗ ${msg}`);
} else {
console.log(`✓ ${msg}`);
}
}
// --- 1. 结构校验 ---
const seenPoints = new Map<string, string[]>();
for (const ch of chapters) {
const ids = new Set(ch.nodes.map((n) => n.id));
check(ids.has(ch.startNodeId), `${ch.id}: startNodeId 存在`);
const reachable = new Set<string>([ch.startNodeId]);
for (let changed = true; changed; ) {
changed = false;
for (const n of ch.nodes) {
if (!reachable.has(n.id)) continue;
const nexts: string[] =
n.type === 'choice'
? n.options.map((o) => o.next)
: n.type === 'chapterEnd'
? []
: [n.next];
for (const nx of nexts) {
check(ids.has(nx), `${ch.id}/${n.id}: next "${nx}" 有效`);
if (!reachable.has(nx)) {
reachable.add(nx);
changed = true;
}
}
}
}
check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`);
for (const n of ch.nodes) {
if (n.type === 'choice' || n.type === 'narration' || n.type === 'identity') {
check(!seenPoints.has(n.pointId), `${n.pointId}: 选择点 ID 全局唯一`);
seenPoints.set(n.pointId, n.dimensions);
}
}
}
// --- 2. 与母本选择点地图对照 ---
const expectedIds = Object.keys(EXPECTED).sort();
const actualIds = [...seenPoints.keys()].sort();
check(JSON.stringify(actualIds) === JSON.stringify(expectedIds), `选择点 ID 集合与母本一致(${actualIds.length} 个)`);
for (const [id, dims] of Object.entries(EXPECTED)) {
check(
JSON.stringify(seenPoints.get(id) ?? null) === JSON.stringify(dims),
`${id}: 维度标签 ${JSON.stringify(seenPoints.get(id))} == 母本 ${JSON.stringify(dims)}`,
);
}
// --- 3. V 类讲述点变体结构 ---
const narrationNodes = chapters.flatMap((c) => c.nodes.filter((n) => n.type === 'narration')) as NarrationNode[];
check(narrationNodes.map((n) => n.pointId).sort().join(',') === 'C1-02,C2-02,C2-05,C3-02', 'V 类讲述点为 C1-02/C2-02/C2-05/C3-02');
for (const n of narrationNodes) {
check(n.variants.length >= 2, `${n.pointId}: 有 ${n.variants.length} 个预写场景变体`);
check(
n.variants.every((v) => v.signature && v.variantId && v.label),
`${n.pointId}: 情绪签名 → variantId 映射完整`,
);
}
check(narrationNodes.find((n) => n.pointId === 'C3-02')?.variantFrom === 'C1-02', 'C3-02 变体由 C1-02 驱动(variantFrom)');
for (const n of narrationNodes) {
check(
FOLLOW_UP_DIMENSIONS.every((d) => typeof n.followUps?.[d] === 'string' && n.followUps[d]!.length > 0),
`${n.pointId}: 六维 followUps 追问模板齐全`,
);
}
// --- 4. Mock 模式端到端跑通三章 ---
const store = new MemoryStore();
const jev = new MockJevClient();
const engine = new StoryEngine(chapters, store, jev);
let guard = 0;
while (!engine.state.finished && guard++ < 200) {
const node = engine.currentNode();
if (node.type === 'choice') {
await engine.choose((node as ChoiceNode).options[0].id);
} else if (node.type === 'identity') {
const profile = await engine.extractIdentity('我叫阿黎,29岁,在杭州做互联网产品经理,硕士学历');
await engine.confirmIdentity(profile);
} else if (node.type === 'narration') {
// C2-05 走"沉默"路径验证合法选项,其余讲述;Mock 检测到缺失维度则回答追问
if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration();
else {
await engine.submitNarration('(验证用占位讲述)');
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
}
} else {
engine.advance();
}
}
check(engine.state.finished, '三章流程跑通(Mock 模式)');
const eventPoints = store.events
.filter((e) => e.kind === 'choice' || e.kind === 'narration' || e.kind === 'profile')
.map((e) => e.pointId)
.sort();
check(
JSON.stringify(eventPoints) === JSON.stringify(expectedIds),
`事件轨迹覆盖全部 ${expectedIds.length} 个选择点`,
);
const c305 = store.events.find((e) => e.pointId === 'C3-02' && e.kind === 'narration');
const c102 = store.events.find((e) => e.pointId === 'C1-02' && e.kind === 'narration');
check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果');
// --- 5. 画像报告:7 维 score 问题 ---
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
const dims = ['attachment', 'conflict_style', 'values', 'emotional_stability', 'risk', 'culture', 'mate_preference'];
const answers = await jev.ask(
store.events,
Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, instructions: d, criteria }])),
);
check(
dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1),
'Jev 7 维 score 打分返回(含 0-1 置信度)',
);
// --- 6. 重玩语义:新轨迹不修改旧事件 ---
const before = store.events.length;
const oldSession = engine.state.sessionId;
engine.replay('chapter1');
check(engine.state.sessionId !== oldSession, '重玩产生新 sessionId');
while (engine.currentNode().type !== 'choice') {
const n = engine.currentNode();
if (n.type === 'identity') {
await engine.confirmIdentity(await engine.extractIdentity('占位自我介绍'));
} else if (n.type === 'narration') {
await engine.submitNarration('(验证用占位讲述)');
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
} else {
engine.advance();
}
}
const firstChoice = engine.currentNode() as ChoiceNode;
await engine.choose(firstChoice.options[0].id);
const added = store.events.slice(before);
check(
added.filter((e) => e.kind === 'profile').length === 1 &&
added.filter((e) => e.kind === 'narration').length === 1 &&
added.filter((e) => e.kind === 'choice').length === 1,
`重玩追加新事件(profile + narration + choice,含追问事件共 ${added.length} 条),旧事件不变`,
);
check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留');
// --- 7. 追问补全流程(可控 noul 概率的 stub 驱动三条路径) ---
/** noul 概率可控的 Jev stub:missing 里的维度返回 0.1(缺失),其余 0.9;非 noul 题走 Mock */
class FixedNoulJev extends MockJevClient {
constructor(private missing: string[]) {
super();
}
override async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const out = await super.ask(state, questions);
for (const [id, q] of Object.entries(questions)) {
if (q.type === 'noul') {
out[id] = { type: 'noul', probability: this.missing.includes(id) ? 0.1 : 0.9, confidence: 0.9 };
}
}
return out;
}
}
/** 推进到指定节点(途中选择点取第一项、身份点直接确认、讲述点沉默略过) */
async function walkTo(eng: StoryEngine, nodeId: string) {
let g = 0;
while (eng.currentNode().id !== nodeId && g++ < 60) {
const n = eng.currentNode();
if (n.type === 'choice') await eng.choose((n as ChoiceNode).options[0].id);
else if (n.type === 'identity') await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
else if (n.type === 'narration') await eng.skipNarration();
else eng.advance();
}
}
// 7a. 缺失检测 → 追问挂起 → 回答合并
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FixedNoulJev(['visual']));
await walkTo(eng, 'c1-02');
const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode;
const r = await eng.submitNarration('去年夏天我离开了家乡');
const pending = eng.state.pendingFollowUp;
check(r === null && pending?.dimension === 'visual', '缺失检测:视觉缺失(最高优先级)→ 追问挂起,不立即落 narration 事件');
check(pending?.prompt === c102.followUps.visual, '追问话术取自节点 JSON 的 followUps 模板');
check(eng.state.nodeId === 'c1-02', '追问挂起期间节点不推进');
let threw = false;
try {
await eng.submitNarration('重复提交');
} catch {
threw = true;
}
check(threw, '追问挂起期间重复提交被拒(预算纪律:一个讲述点一次追问)');
const res = await eng.answerFollowUp('站台是灰蓝色的');
const narrationEv = s.events.find((e) => e.kind === 'narration');
const followupEv = s.events.find((e) => e.kind === 'followup');
check(!!followupEv && followupEv.followUpDimension === 'visual', '追问回答落 followup 事件(含缺失维度)');
check(
!!narrationEv?.transcript?.includes('去年夏天我离开了家乡') && narrationEv.transcript.includes('站台是灰蓝色的'),
'追问回答合并进讲述文本(state 合并后进情绪签名选择)',
);
check(!!res.variant && eng.state.pendingFollowUp === null && eng.state.nodeId === 'c1-d4', '追问收尾:变体解析完成并推进,无第二次追问');
}
// 7b. "记不清了"跳过 → followup_skipped 事件
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FixedNoulJev(['person']));
await walkTo(eng, 'c2-02');
await eng.submitNarration('有件事我一直后悔');
check(eng.state.pendingFollowUp?.dimension === 'person', '缺失检测:人物缺失 → 追问挂起');
await eng.skipFollowUp();
const skipped = s.events.find((e) => e.kind === 'followup_skipped');
check(!!skipped && skipped.followUpDimension === 'person' && skipped.transcript === null, '跳过落 followup_skipped 事件(对什么沉默也是数据)');
const narrationEv = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C2-02');
check(narrationEv?.transcript === '有件事我一直后悔', '跳过后讲述原文不变,流程继续');
}
// 7c. 六维俱全 → 不追问,直接走完
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FixedNoulJev([]));
await walkTo(eng, 'c2-02');
const r = await eng.submitNarration('一段细节齐全的讲述');
check(r !== null && eng.state.pendingFollowUp === null, '无缺失维度时不触发追问');
check(!s.events.some((e) => e.kind === 'followup' || e.kind === 'followup_skipped'), '无追问时不产生追问事件');
}
// --- 8. append-only:EventStore 实现不存在 update/delete 路径 ---
// (SQLite 实连只能在 Tauri 运行时内进行,这里只做接口层面断言)
for (const cls of [LocalStorageEventStore, SqliteEventStore, MemoryStore]) {
const props = Object.getOwnPropertyNames(cls.prototype).filter((p) => p !== 'constructor');
check(
!props.some((p) => ['update', 'delete', 'remove', 'set'].includes(p)),
`${cls.name}: 只有 ${props.sort().join('/')}(append-only,无 update/delete)`,
);
}
// --- 9. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过 ---
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new MockJevClient());
check(!(await eng.hasConsent()), '同意门:无 consent 事件时需要展示同意门');
await eng.grantConsent();
const ev = s.events.find((e) => e.kind === 'consent');
check(
!!ev && ev.consentVersion === CONSENT_VERSION && ev.ts > 0,
`同意动作落 consent 事件(consentVersion=${ev?.consentVersion},含时间戳)`,
);
await eng.grantConsent();
check(s.events.filter((e) => e.kind === 'consent').length === 1, '重复同意幂等(只存证一次)');
const eng2 = new StoryEngine(chapters, s, new MockJevClient());
check(await eng2.hasConsent(), 'consent 事件已存在则同意门跳过(全剧只出现一次)');
}
// --- 10. 条件文本:flag 推导、标记不泄漏、差分显隐、空节点跳过 ---
// 10a. 变量映射表(附录 A)
check(choiceFlags('C2-03', 'c_scout').flag_fresh_collapse === 'true', 'flag 推导:C2-03 c_scout → flag_fresh_collapse=true');
check(choiceFlags('C2-01', 'b_exposure').wenjian_health === 'weak', 'flag 推导:C2-01 b_exposure → wenjian_health=weak');
check(choiceFlags('C2-01', 'a_weakest').wenjian_health === 'recovering', 'flag 推导:C2-01 a_weakest → wenjian_health=recovering');
check(narrationFlags('C3-02', undefined, null).c3_02_mode === 'exit_room', 'flag 推导:C3-02 沉默退出 → c3_02_mode=exit_room');
{
const f = choiceFlags('C3-06', 'c_confide_wenjian');
check(
f.c3_06_response === 'c_confide' && f.c3_06_confidant === 'wenjian' && f.flag_arch_doubt === 'true',
'flag 推导:C3-06 队友子分支 → c_confide / confidant=wenjian / flag_arch_doubt',
);
}
check(narrationFlags('C2-05', 'wenjian_close_early', '(讲述)').flag_wenjian_secret === 'true', 'flag 推导:C2-05 深暴露 → flag_wenjian_secret');
/** 走查三章并收集途经节点与渲染文本;picker 可指定选择点的选项 */
async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
const seen: string[] = [];
const rendered: string[] = [];
let guard = 0;
while (!eng.state.finished && guard++ < 250) {
const node = eng.currentNode();
seen.push(node.id);
if (node.type === 'dialogue') rendered.push(eng.renderText(node.text));
else if (node.type === 'cutscene') rendered.push(eng.renderText(node.caption));
else if (node.type === 'choice') rendered.push(eng.renderText(node.prompt), ...node.options.map((o) => eng.renderText(o.label)));
else if (node.type === 'narration' || node.type === 'identity') rendered.push(eng.renderText(node.prompt));
if (node.type === 'choice') {
await eng.choose(picker[node.pointId] ?? node.options[0].id);
} else if (node.type === 'identity') {
await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
} else if (node.type === 'narration') {
if (node.pointId === 'C3-02' && picker.C3_02_SILENT) await eng.skipNarration();
else {
await eng.submitNarration('(验证用占位讲述)');
if (eng.state.pendingFollowUp) await eng.answerFollowUp('(验证用追问回答)');
}
} else {
eng.advance();
}
}
return { seen, rendered, finished: eng.state.finished };
}
// 10b. 默认路径(全取首选项):标记零泄漏 + 条件节点按 flag 跳过
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new MockJevClient());
const { seen, rendered, finished } = await walkAll(eng);
check(finished, '条件系统下默认路径三章跑通');
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(默认路径)');
check(seen.includes('c2-d1') && !seen.includes('c2-d1v'), '落点 a_safe:变体 A 节点可见、变体 B 节点被跳过');
check(!seen.includes('c3-d1a'), '未选 c_scout:flag_fresh_collapse 差分节点被跳过(无死路,继续走到 c3-d1b)');
check(seen.includes('c3-d1b'), '跳过差分节点后落到主线下一节点');
check(!seen.includes('c3-d6c'), '动机非 d_trust:§3-7 追加句节点被跳过');
const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
check(
d5a?.type === 'dialogue' && !eng.renderText(d5a.text).includes('通讯频道'),
'wenjian_health=recovering:温简在场,「通讯频道」内联段不渲染',
);
}
// 10c. 差分路径:c_scout 塌梁 + b_exposure 温简虚弱 + C3-02 沉默退出
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new MockJevClient());
const { seen, rendered, finished } = await walkAll(eng, { 'C2-03': 'c_scout', 'C2-01': 'b_exposure', C3_02_SILENT: '1' });
check(finished, '条件系统下差分路径三章跑通');
check(seen.includes('c3-d1a'), '选了 c_scout:flag_fresh_collapse 差分节点可见');
check(eng.state.flags.flag_fresh_collapse === 'true' && eng.state.flags.wenjian_health === 'weak', '引擎 flags 随选择增量更新');
const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
check(
d5a?.type === 'dialogue' && eng.renderText(d5a.text).includes('通讯频道'),
'wenjian_health=weak:温简台词的「通讯频道」内联段渲染',
);
const cut = chapters[2].nodes.find((n) => n.id === 'c3-cutscene');
check(
cut?.type === 'cutscene' && eng.renderText(cut.caption).includes('不出来的人'),
'C3-02 沉默退出:cutscene 追加馆长退出台词(exit_room 内联段)',
);
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(差分路径)');
}
// --- 11. 进度持久化与恢复(真机刷新/热更新断点续玩) ---
{
const s = new MemoryStore();
const progress = new MemoryProgressStore();
const eng = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
await walkTo(eng, 'c1-d5'); // 途经 C1-01 身份点与 C1-03 选择点(产生 flag)
const snap = progress.load();
check(
snap?.chapterId === 'chapter1' && snap.nodeId === 'c1-d5',
`推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`,
);
const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
const restored = eng2.restore(progress.load());
check(
restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId,
'重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)',
);
check(eng2.state.flags.c1_03_motive === 'a_truth', '恢复后 flags 随快照还原(条件文本不断档)');
// 内容更新导致持久化节点消失 → 降级该章开头
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
check(
eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} }) &&
eng3.currentNode().id === chapters[0].startNodeId,
'持久化节点已不存在 → 降级到该章开头(不崩溃)',
);
check(!eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} }), '持久化章节不存在 → 不恢复');
// replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点)
eng2.replay('chapter2');
const after = progress.load();
check(
after?.chapterId === 'chapter2' &&
after.nodeId === eng2.state.nodeId &&
eng2.state.sessionId !== snap?.sessionId,
'replay 产生新 sessionId 并重置持久化位置到目标章(跳过条件隐藏节点后的首个可见节点)',
);
}
// --- 12. Jev 调用失败优雅降级(CORS / 网络故障不卡死) ---
class FailingJev extends MockJevClient {
override async ask(): Promise<Record<string, JevAnswer>> {
throw new Error('模拟 Jev 故障:CORS / network');
}
}
{
// 12a. 讲述流程:缺失检测失败 → 不追问;情绪签名失败 → 沉默/通用档变体;节点照常推进
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FailingJev());
await walkTo(eng, 'c1-02');
const r = await eng.submitNarration('去年夏天我离开了家乡,站台上都是雾');
check(r !== null && eng.state.pendingFollowUp === null, 'Jev 故障:缺失检测失败降级为不追问,流程继续');
check(eng.state.nodeId === 'c1-d4', 'Jev 故障:变体解析失败后节点照常推进');
const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode;
const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!;
const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02');
check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体');
// 12b. 身份抽取失败 → 全空字段(UI 直接进可手填确认页),不抛出
const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev());
await walkTo(eng2, 'c1-01');
let profile: IdentityProfile | null = null;
let threw = false;
try {
profile = await eng2.extractIdentity('我叫阿黎,29岁,在杭州');
} catch {
threw = true;
}
check(
!threw && profile !== null && Object.values(profile).every((v) => v === ''),
'Jev 故障:身份抽取降级为全空字段进手填确认页(不抛出)',
);
await eng2.confirmIdentity(profile!);
check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进');
}
if (failures > 0) {
console.error(`\n${failures} 项校验失败`);
process.exit(1);
}
console.log('\n全部校验通过');