166 lines
7.7 KiB
JavaScript
166 lines
7.7 KiB
JavaScript
#!/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 { 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;
|
|
|
|
/** 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);缺 pytest 则整段 SKIP。 */
|
|
function runPythonStage(stage) {
|
|
if (!pytestAvailable()) {
|
|
return { ...stage, status: 'SKIP', pass: 0, fail: 0, skip: 0, note: '未装 pytest → 整段跳过(零新依赖口径);装后本段覆盖 tier2/cheap 的门' };
|
|
}
|
|
const files = stage.files.filter((f) => existsSync(resolve(REPO_ROOT, f)));
|
|
if (files.length === 0) return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: 'pytest 判例文件全缺失' };
|
|
const r = spawnSync('python3', ['-m', 'pytest', '-q', ...files], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
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;
|
|
} 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);
|
|
}
|
|
|
|
main();
|