lili 1981a6be01
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(storage): 闭合 Agent 到 Runtime 的 OSS 游戏链路
增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
2026-07-30 05:21:44 -07:00

197 lines
9.0 KiB
JavaScript
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.

#!/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();
}