#!/usr/bin/env node /** * run.mjs —— 门金标判例库·全量回归统一入口(零新依赖:仅 Node 内置 node:test / node:child_process) * * 职责:把散在各判据近旁的验收门「误杀/漏放」判例聚合成一趟可复跑回归,逐 stage 输出 * 过/挂/跳,给出总计与退出码。**门判据任何改动都必须先过这趟。** * * 判例不在此处重抄——各 stage 指向判例真身(单一事实源): * · shape-gate → game-runtime/tools/amodel-gen/tests/check.test.mjs(判据 tools.mjs _shapeGateErrors) * · check-undef → game-runtime/tools/amodel-gen/tests/check-undef.test.mjs(判据 check-undef.mjs;缺 esbuild 时该门降级、单测整体 skip) * · latch-synonyms→ contracts/gate-fixtures/cases/latch-terminal-synonyms.test.mjs(判据 play.cdp.cjs isTerminal) * · python-gates → tier2/cheap-worker 的门 pytest(判据 gate_judge.py / cheap_gates.py;缺 pytest 时整段 skip) * * 设计:能跑的必须绿,跑不动的(缺 esbuild / 缺 pytest)诚实 SKIP 而非假绿、也不算失败—— * 这样在完整 CI 里四段全跑、在裸环境里 Node 核心段仍跑,均可挂载。 * 退出码:任一 stage 有真实失败(fail>0 或 driven 段异常)→ 1;否则(全过或含 skip)→ 0。 * * 跑法:node contracts/gate-fixtures/run.mjs */ import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { delimiter, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); // gate-fixtures → contracts → 仓根 const REPO_ROOT = resolve(__dirname, '../..'); const NODE = process.execPath; // 服务端 CI 要求四段全部真跑;本地裸环境未装可选依赖时仍保留诚实 SKIP。 const REQUIRE_ALL = process.env.GATE_FIXTURES_REQUIRE_ALL === '1'; /** pytest 是否可用(缺则 python-gates 整段 skip,保持零新依赖)。 */ function pytestAvailable() { const r = spawnSync('python3', ['-c', 'import pytest'], { stdio: 'ignore' }); return r.status === 0; } /** 从 node:test 的 TAP 输出解析计数(# pass N / # fail N / # skipped N)。 */ function parseNodeTap(out) { const grab = (k) => { const m = out.match(new RegExp(`^#\\s*${k}\\s+(\\d+)`, 'm')); return m ? Number(m[1]) : 0; }; return { tests: grab('tests'), pass: grab('pass'), fail: grab('fail'), skip: grab('skipped') }; } /** 从 pytest -q 尾行解析计数(如 "3 passed, 1 skipped in 0.1s")。 */ function parsePytest(out) { const grab = (k) => { const m = out.match(new RegExp(`(\\d+)\\s+${k}`)); return m ? Number(m[1]) : 0; }; const fail = grab('failed') + grab('error') + grab('errors'); return { tests: grab('passed') + grab('skipped') + fail, pass: grab('passed'), fail, skip: grab('skipped') }; } /** 跑一个 node:test 判例文件,返回结构化结果。 */ function runNodeStage(stage) { const abs = resolve(REPO_ROOT, stage.file); if (!existsSync(abs)) { return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: `判例文件缺失:${stage.file}` }; } const r = spawnSync(NODE, ['--test', '--test-reporter=tap', abs], { cwd: REPO_ROOT, encoding: 'utf8' }); const out = `${r.stdout || ''}${r.stderr || ''}`; const c = parseNodeTap(out); // node --test:退出码 0=无失败;非 0 且解析到 fail>0=真实失败;非 0 但无 fail 计数=进程异常(算失败)。 let status; if (r.status === 0) status = c.pass === 0 && c.skip > 0 ? 'SKIP' : 'PASS'; else status = c.fail > 0 ? 'FAIL' : 'ERROR'; const note = status === 'SKIP' ? `${c.skip} 条单测在本环境 skip(判据自带零依赖降级,如缺 esbuild;装齐依赖后转真跑)` : undefined; return { ...stage, status, ...c, note, raw: out }; } /** * 跑 python-gates(pytest);strict 模式下固定判例缺少任意一个都立即判红。 * 可注入根目录与 pytest 探针,供入口自身用真实临时文件验证缺失判例分支。 */ export function runPythonStage(stage, options = {}) { const repoRoot = options.repoRoot ?? REPO_ROOT; const requireAll = options.requireAll ?? REQUIRE_ALL; const hasPytest = options.hasPytest ?? pytestAvailable; if (!hasPytest()) { return { ...stage, status: 'SKIP', pass: 0, fail: 0, skip: 0, note: '未装 pytest → 整段跳过(零新依赖口径);装后本段覆盖 tier2/cheap 的门' }; } const missing = stage.files.filter((f) => !existsSync(resolve(repoRoot, f))); if (requireAll && missing.length > 0) { return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: `pytest 判例文件缺失:${missing.join(',')}`, }; } const files = stage.files.filter((f) => existsSync(resolve(repoRoot, f))); if (files.length === 0) return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: 'pytest 判例文件全缺失' }; // tier2 测试按服务部署布局导入顶层 worker 包;统一门必须复刻该模块根,不能依赖调用者碰巧设置 PYTHONPATH。 const pythonPath = [resolve(repoRoot, 'tier2/gen-worker'), process.env.PYTHONPATH] .filter(Boolean) .join(delimiter); const r = spawnSync('python3', ['-m', 'pytest', '-q', ...files], { cwd: repoRoot, encoding: 'utf8', env: { ...process.env, PYTHONPATH: pythonPath }, }); const out = `${r.stdout || ''}${r.stderr || ''}`; const c = parsePytest(out); let status; if (r.status === 0) status = c.pass === 0 && c.skip > 0 ? 'SKIP' : 'PASS'; else if (r.status === 5) status = 'SKIP'; // pytest 退出码 5 = 未收集到用例 else status = c.fail > 0 ? 'FAIL' : 'ERROR'; return { ...stage, status, ...c, raw: out }; } // ── stage 清单(判据→判例) ── const NODE_STAGES = [ { name: 'shape-gate', judge: 'game-runtime/tools/amodel-gen/tools.mjs (_shapeGateErrors 形状静态门)', file: 'game-runtime/tools/amodel-gen/tests/check.test.mjs', covers: 'base2 drawButton 返 void 被消费 / base3 sceneFsm.define 批量误用 / base1·4·5 合法写法零误杀', }, { name: 'check-undef', judge: 'game-runtime/tools/amodel-gen/check-undef.mjs (未定义标识符 + import 断链静态扫描)', file: 'game-runtime/tools/amodel-gen/tests/check-undef.test.mjs', covers: 'gen3 未定义 cy / gen6 未定义 stepUntilMs / 非遗 import 断链三形态 / 8 目录正样本零误报(缺 esbuild→SKIP)', }, { name: 'latch-synonyms', judge: 'game-runtime/games/_wg1-gen/_shared/play.cdp.cjs (H 门 latch isTerminal 终态判定)', file: 'contracts/gate-fixtures/cases/latch-terminal-synonyms.test.mjs', covers: 'base5 终态 over/win/lose 被误杀回归 + 非终态反向漂移守卫', }, ]; const PYTHON_STAGE = { name: 'python-gates', judge: 'tier2/gen-worker/worker/gate_judge.py + cheap-worker/cheap_gates.py', files: [ 'tier2/gen-worker/tests/test_gate_judge.py', 'tier2/gen-worker/tests/test_gate_judge_cheap.py', 'tier2/gen-worker/tests/test_run_gates_stale_verdict.py', 'cheap-worker/tests/test_cheap_gates.py', 'cheap-worker/tests/test_golden_specs.py', 'cheap-worker/tests/test_auto_vs_golden.py', ], covers: 'verdict→反馈映射 / stale verdict 拦截 / cheap 门 / W-GENRE 四品类金标 play-spec', }; const ICON = { PASS: '✔', FAIL: '✗', SKIP: '⊘', ERROR: '✗', MISSING: '✗' }; function main() { console.log('门金标判例库 · 全量回归'); console.log('='.repeat(72)); const results = []; for (const s of NODE_STAGES) results.push(runNodeStage(s)); results.push(runPythonStage(PYTHON_STAGE)); let totPass = 0, totFail = 0, totSkip = 0, hardFail = 0, skipStages = 0; for (const r of results) { const icon = ICON[r.status] || '?'; console.log(`\n[${r.status}] ${r.name} ${icon}`); console.log(` 判据:${r.judge}`); console.log(` 覆盖:${r.covers}`); if (r.status === 'SKIP' && (r.pass + r.fail === 0)) { console.log(` ${r.note || '整段跳过'}`); skipStages += 1; if (REQUIRE_ALL) hardFail += 1; } else if (r.status === 'MISSING' || r.status === 'ERROR') { console.log(` ${r.note || '判例执行异常'}`); hardFail += 1; } else { console.log(` ${r.pass} 过 / ${r.fail} 挂 / ${r.skip} 跳`); } totPass += r.pass; totFail += r.fail; totSkip += r.skip; if (r.status === 'FAIL') hardFail += 1; } console.log(`\n${'='.repeat(72)}`); const green = hardFail === 0 && totFail === 0; console.log(`总计:${totPass} 过 / ${totFail} 挂 / ${totSkip} 跳;${skipStages} 段整段跳过 → ${green ? '绿(全量通过)' : '红(有判据回归或必跑段跳过)'}`); if (!green) { console.log('失败明细见上;门判据改动前必须先让本回归全绿。'); for (const r of results) if (r.status === 'FAIL' || r.status === 'ERROR' || r.status === 'MISSING') { console.log(`\n----- ${r.name} 输出 -----\n${(r.raw || r.note || '').trim().slice(-1500)}`); } } process.exit(green ? 0 : 1); } // 被 node:test 导入时只暴露纯入口;直接执行仍保持原 CLI 行为与退出码。 if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main(); }