opus 执行位交付、主会话四步验收过(三套测试亲跑/红线 diff 亲读/冒烟截图亲眼): - 验收消费:check 新增源码级插件调用存在断言(_pluginCallPresenceErrors 词法层, 接替 F_wiring 真接线护栏);_forensicsView「必须实现」拦截摘除,降级为可选调试 接口(handleTap 输入契约与 ctx.log 日志纪律保留);gate_judge 放行措辞对齐四门 - play-spec/驱动器退役(逐件裁定 10 件):gen.mjs done 门自动产 play-spec 摘除、 cheap_gates/cheap_studio/cheap_modify/service_driver 调用摘除、_read_driver_type 改读 evidence/playtest/playtest.json;golden-spec 三工具(auto_vs_golden/bake_off/ compare_node)显式封存;ensure_play_spec 两实现保留一个版本窗口(只摘调用不删码) - prompt 全加载链清洗:cheap-system.md v1.6.3→1.7.0(删 _forensicsView 自动验收 契约红线与全部 targets/occupied 语义,五法句/品类指路同步),cheap_roles 内置 回退逐字节同步(test_roles 12/12 证 parity),modify 切片经运行时切块自动同步; 5 品类 skills+littlejs 手册+recipes×2+模板 8 件+README 6 件教学清洗,真设计 价值(单击 casual/可解性质/双层反馈/latch)保留 - 批账:hard_genre_batch/xtheme 行加 playtest 段,_row_total_cost 修 shadow 下 测试员成本漏算(v2 取 max 防双计) 验收(主会话亲验):pytest cheap-worker 512 绿 + tier2 141 绿 + node 49/49; 25 局基线新旧口径重放对照——真坏 5 局全拦(floor 拦 2/测试员拦 3)、无一例旧拦 新放,翻案 2 局均为破案坐实的仪器误杀;n=3 冒烟(mode=v2)全链绿:w2v2-sim harness verdict.pass=True 而测试员 reject(机械放行测试员逮住,盘上双证)、 w2v2-sim-fix 现象反馈两轮回喂→fix2 收敛(配方显示修成,局内截图亲眼); rg 零残留(模板残留均为「验收不读它」的降级调试接口合规形态);真跑 ¥8.4。 已知余项:repairFeedback 有产地、Service 续修环消费者未接(测试员 reject 不入 RepairMiddleware,续修判据仍四门)——是否接入随波3 校准数据一并裁;v1 配置级 回退窗口关闭为创始人知悉项,genconfig 默认 mode 仍 shadow、切 v2 归创始人。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
904 lines
53 KiB
JavaScript
904 lines
53 KiB
JavaScript
/**
|
||
* tools.mjs — agentic A-model 生成 harness 的工具层
|
||
* owner:amodel-gen spike | 消费方:gen.mjs(ReAct 循环) / play.mjs / CLI
|
||
*
|
||
* 【两类东西】
|
||
* A) 给 agent 的 5 工具(L1 沙箱边界):
|
||
* - read_file : repo 根只读(agent 自读 skill / api.d.ts / catch-fruit 范例);拒绝逃出 repo 根;超大截断。
|
||
* - write_file : 仅许写本 run 的 game 目录 games/amgen-<id>/;拒绝越界。
|
||
* - list_dir : repo 根内列目录。
|
||
* - check : 循环内快反馈 = node --check 各 src/*.js + 五法/导出名/红线静态 lint + 未定义标识符/import 断链扫描。
|
||
* - build : esbuild(scripts/build.mjs 锁参)→ bundle;循环内快反馈。
|
||
* 不给 agent 任意 shell。
|
||
* B) 确定性 scaffold(克隆 _template → 本 run 目录)。
|
||
*
|
||
* 【路径基准】本文件在 game-runtime/tools/amodel-gen/ → GAME_RUNTIME=../.. → REPO_ROOT=../../..
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
import {
|
||
readFileSync, writeFileSync, readdirSync, statSync,
|
||
mkdirSync, existsSync, rmSync, cpSync, renameSync,
|
||
} from 'node:fs';
|
||
import { resolve, dirname, relative, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { createRequire } from 'node:module';
|
||
import { scanUndefinedIdentifiers, scanImportChain } from './check-undef.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
// tools/amodel-gen → game-runtime → repo 根
|
||
export const GAME_RUNTIME = resolve(__dirname, '../..');
|
||
export const REPO_ROOT = resolve(__dirname, '../../..');
|
||
const GAMES_DIR = join(GAME_RUNTIME, 'games');
|
||
const TEMPLATE_DIR = join(GAMES_DIR, '_template');
|
||
const SHARED_DIR = join(GAMES_DIR, '_wg1-gen', '_shared'); // SAA 信封 plumbing 模板源
|
||
const WG1_DIR = join(GAMES_DIR, '_wg1-gen'); // SAA stage 目标根(play/emit 既有读盘契约)
|
||
|
||
const MAX_READ_BYTES = 200 * 1024; // 单文件读上限,防撑爆 agent context
|
||
|
||
/** 把 repo 相对路径解析为绝对路径,并校验不逃出 repo 根。 */
|
||
function resolveInRepo(p) {
|
||
const abs = resolve(REPO_ROOT, p);
|
||
if (abs !== REPO_ROOT && !abs.startsWith(REPO_ROOT + '/')) {
|
||
throw new Error(`路径越界(必须在 repo 根内):${p}`);
|
||
}
|
||
return abs;
|
||
}
|
||
|
||
/** 本 run 的 game 目录(= 写边界)。 */
|
||
export function gameDir(id) {
|
||
return join(GAMES_DIR, `amgen-${id}`);
|
||
}
|
||
|
||
/** 校验绝对路径落在本 run 的 game 目录内(写边界)。 */
|
||
function assertInGameDir(id, abs) {
|
||
const gd = gameDir(id);
|
||
if (abs !== gd && !abs.startsWith(gd + '/')) {
|
||
throw new Error(`write 越界:只许写 ${relative(REPO_ROOT, gd)}/ 内(你给的是 ${relative(REPO_ROOT, abs)})`);
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── 给 agent 的 5 工具 ─────────────────────────
|
||
|
||
/** read_file:repo 根只读。返回 {ok, content?, truncated?, error?}。 */
|
||
export function readFileTool(path) {
|
||
try {
|
||
const abs = resolveInRepo(path);
|
||
const st = statSync(abs);
|
||
if (st.isDirectory()) return { ok: false, error: `是目录,不是文件:${path}(请用 list_dir)` };
|
||
let content = readFileSync(abs, 'utf8');
|
||
let truncated = false;
|
||
if (Buffer.byteLength(content, 'utf8') > MAX_READ_BYTES) {
|
||
content = content.slice(0, MAX_READ_BYTES);
|
||
truncated = true; // 截断:超大文件只给前 200KB,提示 agent 它被截了
|
||
}
|
||
return { ok: true, content, truncated };
|
||
} catch (e) {
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
/** write_file:仅许写本 run game 目录。返回 {ok, bytes?, error?}。 */
|
||
export function writeFileTool(id, path, content) {
|
||
try {
|
||
const abs = resolveInRepo(path);
|
||
assertInGameDir(id, abs);
|
||
// L1 工程骨架固定 plumbing,agent 不许写——根除 M3 反复搞坏的 wiring 错类(viewport 漏键/registerOrder/插件装配/boot 接线)。
|
||
// agent 只写 L3:game-logic.js/core.js/render.js/balance.js/assets.js。
|
||
const base = abs.replace(/^.*[\\/]/, '');
|
||
const L1_FIXED = ['host-config.js', 'game.js', 'index.html', 'entry-bundle.js', 'entry.js', 'main.js'];
|
||
if (L1_FIXED.includes(base)) {
|
||
return { ok: false, error: `${base} 是 L1 固定 plumbing(boot 链/插件装配/viewport/工厂 wiring),不要写它。` +
|
||
'你只写 L3 游戏本体:src/{game-logic,core,render,balance,assets}.js。' +
|
||
'游戏逻辑写在 game-logic.js 的 createGame({plugins,bundle,viewport});插件经 plugins.<键> 直接用(已替你注入),无需装配。' };
|
||
}
|
||
mkdirSync(dirname(abs), { recursive: true });
|
||
writeFileSync(abs, content, 'utf8');
|
||
return { ok: true, bytes: Buffer.byteLength(content, 'utf8') };
|
||
} catch (e) {
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
/** list_dir:repo 根内列目录(目录名带尾 /)。返回 {ok, entries?, error?}。 */
|
||
export function listDirTool(path) {
|
||
try {
|
||
const abs = resolveInRepo(path);
|
||
const entries = readdirSync(abs, { withFileTypes: true })
|
||
.map((d) => (d.isDirectory() ? d.name + '/' : d.name))
|
||
.sort();
|
||
return { ok: true, entries };
|
||
} catch (e) {
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── scaffold / check / build ─────────────────────────
|
||
|
||
/** scaffold:克隆 _template → games/amgen-<id>/(去掉 dist;保留可玩骨架 + plumbing)。返回 game 目录。 */
|
||
export function scaffold(id) {
|
||
const gd = gameDir(id);
|
||
if (existsSync(gd)) rmSync(gd, { recursive: true, force: true });
|
||
cpSync(TEMPLATE_DIR, gd, { recursive: true });
|
||
rmSync(join(gd, 'dist'), { recursive: true, force: true }); // 旧产物(若有)清掉
|
||
rmSync(join(gd, 'test'), { recursive: true, force: true }); // 去 test/:spike 不让 agent 钻测试(gate=check+build+play),也去掉 README 的"跑测试"诱因
|
||
return gd;
|
||
}
|
||
|
||
// 游戏源红线:零裸时间/随机/DOM(时间随机经 boot.ctx,定时经 timer-scheduler)。只匹配"调用形态"。
|
||
const BANNED = [
|
||
/\bMath\.random\s*\(/, /\bDate\.now\s*\(/, /\bperformance\.now\s*\(/,
|
||
/\bsetTimeout\s*\(/, /\bsetInterval\s*\(/, /\brequestAnimationFrame\s*\(/,
|
||
/\bnew\s+AudioContext\b/, /\baddEventListener\s*\(/,
|
||
];
|
||
|
||
/** 去注释 + 字符串字面量,避免红线/结构正则误伤文档注释里的示例(如注释里写「禁用 Math.random()」)。
|
||
* 抹除时把内容换成【等量空格、保留换行】——结果串与原文行号一一对应(红线报错据此定位到行),
|
||
* 且长度不变、不误匹配(空格非 \w、非 `.`,既有的 matchAll/结构正则语义不变)。
|
||
*
|
||
* 【为什么必须单遍扫描,不能用多条独立正则】(2026-07-08 n=5 收敛环 trpg-cyber 坐实的 check 假阴性根治):
|
||
* 旧版 5 条独立 .replace 先剥行注释、再剥字符串——但注释与字符串会互相嵌套:字符串里的 `//`
|
||
* (赛博主题 '> JACK IN // FLOOR 01' 这类)会被行注释正则误当注释、吞到行尾(含闭合引号)→ 引号失衡
|
||
* → 后续字符串正则从游离引号起跨行匹配 → 级联误抹真代码(实测抹掉 169 行,连 bundle.tick/_forensicsView/
|
||
* handleTap 一起抹)→ check 误报红线方法"缺失"→ 模型明明写对了却被反复拒 → thrash 到步数顶熔断、烧钱失败。
|
||
* 单遍状态机逐字符扫,同一时刻只处于「码/行注释/块注释/单引/双引/模板」一种态,嵌套的 `//`、引号各归其态、
|
||
* 绝不越界——这是「注释与字符串互嵌」这类词法问题唯一正确的解法。逐字节保长、换行保留(行号对齐不变)。
|
||
*/
|
||
export function stripCode(s) {
|
||
const out = [];
|
||
const n = s.length;
|
||
let state = 'code'; // code | line | block | sq(单引) | dq(双引) | tpl(模板)
|
||
for (let i = 0; i < n; i++) {
|
||
const c = s[i];
|
||
const c2 = i + 1 < n ? s[i + 1] : '';
|
||
if (state === 'code') {
|
||
if (c === '/' && c2 === '/') { state = 'line'; out.push(' '); i += 1; continue; }
|
||
if (c === '/' && c2 === '*') { state = 'block'; out.push(' '); i += 1; continue; }
|
||
if (c === "'") { state = 'sq'; out.push(' '); continue; }
|
||
if (c === '"') { state = 'dq'; out.push(' '); continue; }
|
||
if (c === '`') { state = 'tpl'; out.push(' '); continue; }
|
||
out.push(c); continue; // 正常码逐字节保留(正则字面量按码处理,与旧版同,不新增误伤面)
|
||
}
|
||
if (state === 'line') { // 行注释:遇换行收尾
|
||
if (c === '\n') { state = 'code'; out.push('\n'); continue; }
|
||
out.push(' '); continue;
|
||
}
|
||
if (state === 'block') { // 块注释:遇 */ 收尾,换行保留
|
||
if (c === '*' && c2 === '/') { state = 'code'; out.push(' '); i += 1; continue; }
|
||
out.push(c === '\n' ? '\n' : ' '); continue;
|
||
}
|
||
// 字符串三态(sq/dq/tpl):转义跳一对、遇匹配闭合退回码、换行保留(模板可跨行;单双引跨行容错)
|
||
const quote = state === 'sq' ? "'" : state === 'dq' ? '"' : '`';
|
||
if (c === '\\') { out.push(' '); out.push(c2 === '\n' ? '\n' : (c2 ? ' ' : '')); i += 1; continue; }
|
||
if (c === quote) { state = 'code'; out.push(' '); continue; }
|
||
out.push(c === '\n' ? '\n' : ' '); continue;
|
||
}
|
||
return out.join('');
|
||
}
|
||
|
||
/** 插件键 → api.d.ts 目录(host-config 注入的 canonical 键名;特殊:juice/save/palettePost/physics 名≠dir)。 */
|
||
const PLUGIN_KEY_DIR = {
|
||
sceneFsm: 'scene-fsm', sessionScore: 'session-score', hudUi: 'hud-ui', timerScheduler: 'timer-scheduler',
|
||
save: 'save-progress', gamefeel: 'gamefeel', juice: 'particles-juice', palettePost: 'palette-post',
|
||
audioMusic: 'audio-music', collision: 'collision', physics: 'physics-lite',
|
||
};
|
||
/** PluginContext(boot.ctx)真方法集 + 运行时附加 log;time/random 是属性对象(ctx.time.nowMs()/ctx.random.next() 内层 time/random≠ctx,经"非键跳过"放行)。 */
|
||
// #1 阶段一B:time/random 既是对象(.nowMs()/.elapsedMs()、.next()/.range()/.reseed())又可直接调用(ctx.time()/ctx.random()),故两者都计入合法 ctx 调用名。
|
||
const CTX_METHODS = new Set(['getContext2d', 'onFrame', 'getInput', 'getAudioContext', 'getEngine', 'log', 'time', 'random']);
|
||
const _apiCache = new Map();
|
||
/** 读某插件 api.d.ts 抽其声明的方法名集(宽松:取所有「名(」形态,含泛型方法 名<T>(,over-permissive=少误报)。返回 Set|null。 */
|
||
function apiMethodSet(dir) {
|
||
if (_apiCache.has(dir)) return _apiCache.get(dir);
|
||
let set = null;
|
||
try {
|
||
const txt = readFileSync(join(GAME_RUNTIME, 'src', 'plugins', dir, 'api.d.ts'), 'utf8');
|
||
set = new Set();
|
||
// 名后可带可选 ? 与泛型形参 <…>(如 save.get<T = unknown>(…)):漏认泛型会把真实方法误判"不存在"、误杀带存档的游戏。
|
||
for (const m of txt.matchAll(/(?:^|\n)\s*(\w+)\??\s*(?:<[^>]*>)?\s*\(/g)) set.add(m[1]);
|
||
} catch { set = null; }
|
||
_apiCache.set(dir, set);
|
||
return set;
|
||
}
|
||
|
||
const _voidCache = new Map();
|
||
/**
|
||
* 读某插件 api.d.ts 抽「返 void 的方法名集」(逐行匹配 `名(...): void`;含 export declare function 与接口实例方法两形态)。
|
||
* 用途:形状门①据此判「把返 void 的绘制/音效/特效 API 的返回值赋值或链式消费」这类带病过 check 的幻觉(base2 实证 drawButton)。
|
||
* over-permissive:api.d.ts 未标返回类型的方法不入集 → 漏报优于误杀(对齐 apiMethodSet 的少误报取向)。返回 Set|null。
|
||
*/
|
||
function apiVoidMethodSet(dir) {
|
||
if (_voidCache.has(dir)) return _voidCache.get(dir);
|
||
let set = null;
|
||
try {
|
||
const txt = readFileSync(join(GAME_RUNTIME, 'src', 'plugins', dir, 'api.d.ts'), 'utf8');
|
||
set = new Set();
|
||
// 逐行锚定:行首(可带 export declare function / readonly 修饰)的「名(…): void」;参数里的 `=> void` 不以「): void」收尾、不误收。
|
||
for (const m of txt.matchAll(/^\s*(?:export\s+declare\s+function\s+|readonly\s+)?(\w+)\??\s*(?:<[^>]*>)?\s*\([^\n]*\)\s*:\s*void\b/gm)) set.add(m[1]);
|
||
} catch { set = null; }
|
||
_voidCache.set(dir, set);
|
||
return set;
|
||
}
|
||
|
||
/** 从 s 的 openParenIdx(指向某个 `(`)起配平括号,返回匹配 `)` 之后第一个非空白是否为 `.`(= 链式消费该调用的返回值)。s 须已 stripCode(字符串/注释已去,无游离括号)。 */
|
||
function isChainedAfter(s, openParenIdx) {
|
||
let depth = 0;
|
||
let i = openParenIdx;
|
||
for (; i < s.length; i++) {
|
||
const c = s[i];
|
||
if (c === '(') depth += 1;
|
||
else if (c === ')') { depth -= 1; if (depth === 0) { i += 1; break; } }
|
||
}
|
||
while (i < s.length && /\s/.test(s[i])) i += 1;
|
||
return s[i] === '.';
|
||
}
|
||
|
||
/**
|
||
* 形状静态门③②的判定(从 check 第5节抽出,便于单测):接原始源码与文件名,返回形状门 errors[]。
|
||
* ① 返 void 的插件 API 返回值被赋值/链式消费(void 名集按键从 api.d.ts 动态取;base2 drawButton 实证)。
|
||
* ② sceneFsm.define 单对象/数组批量误用(base3 2048 实证)。
|
||
* 形态刻意收窄到赋值/链式——base1/4/5 实证合法 void 调用全是独立语句 + if(guard)call(),零误杀;条件/参数形态移交 WU-C 5.4。
|
||
*/
|
||
export function _shapeGateErrors(rawSrc, f) {
|
||
const errs = [];
|
||
const s = stripCode(rawSrc);
|
||
// ① 返 void 插件 API 的返回值被赋值/链式消费:遍历 plugins.<键>.<方法>( 调用,<方法> ∈ 该键 void 名集时判返回值有没有被消费。
|
||
for (const m of s.matchAll(/\b(\w+)\.(\w+)\s*\(/g)) {
|
||
const key = m[1], method = m[2];
|
||
const dir = PLUGIN_KEY_DIR[key];
|
||
if (!dir) continue;
|
||
const vs = apiVoidMethodSet(dir);
|
||
if (!vs || !vs.has(method)) continue;
|
||
// 赋值消费:调用前最近非空白是单个 `=`(排除 == / != / <= / >= 比较);链式消费:调用闭括号后紧邻 `.`。
|
||
const before = s.slice(0, m.index).replace(/\s+$/, '');
|
||
const assigned = before.endsWith('=') && !/[=!<>]/.test(before.slice(-2, -1));
|
||
const chained = isChainedAfter(s, m.index + m[0].length - 1);
|
||
if (!assigned && !chained) continue;
|
||
// 泛化判据:凡 api.d.ts 标注返 void 的插件 API 被赋值/链式消费即拦(void 名集动态从 api.d.ts 取)。
|
||
// 阶段一B #5:drawButton 已改返命中矩形 {x,y,w,h}、从 api.d.ts void 集自然剔除,故此处不再有 drawButton 特例——
|
||
// 改签名治了根因(直觉写法 const btn=drawButton(...) 现在就对),把"返 void 误用"的拦截留给仍真返 void 的方法(drawText/drawPanel/drawBar/play/flashScreen 等)。
|
||
errs.push(`形状门 ${f}: ${key}.${method}() 返 void(只执行副作用、返 undefined)——别${assigned ? '把返回值赋给变量' : '在它后面链式取值'}(返回值恒 undefined → 后续判断/取值短路)。当独立语句调用即可:${key}.${method}(...);需要的状态走对应读取方法(见 src/plugins/${dir}/api.d.ts)`);
|
||
}
|
||
// ② define 单对象/数组批量(第一参应是场景名字符串):匹配「.define( {」或「.define( [」。
|
||
if (/\.define\s*\(\s*[{[]/.test(s)) {
|
||
errs.push(`形状门 ${f}: sceneFsm.define(name, handlers) 第一参须是场景名【字符串】、逐场景各调一次——别 define({menu:{},play:{}}) 单对象批量、也别 define([...]) 数组批量(第一参变 [object Object] / 数组、场景全没注册 → transition 被忽略 → 卡菜单)。改:sceneFsm.define('menu',{...}); sceneFsm.define('play',{...}); sceneFsm.define('over',{...})`);
|
||
}
|
||
return errs;
|
||
}
|
||
|
||
/**
|
||
* _pluginCallPresenceErrors —— 源码级「插件调用存在」断言(W-AXIS-V2 波2,接替 F_wiring 的真接线护栏)。
|
||
*
|
||
* F_wiring 的期望前缀集唯一来源是 play-spec;play-spec/驱动器随取证契约退役后,F 坍缩成「任意引擎调用即过」,
|
||
* 不再证真接线。「真接线」的机械半移交本断言(词法层、确定性,不判玩法):游戏源(game-logic/core/render 合并)
|
||
* 经 stripCode 去注释字符串后,必须至少出现一次对已注入插件键的方法调用 `<插件键>.<方法>(`(键 ∈ PLUGIN_KEY_DIR
|
||
* 的 11 键;解构形态 `sceneFsm.define(` 与属性形态 `plugins.sceneFsm.define(` 都命中)。零命中 = 模型抛开插件库
|
||
* 徒手裸写(通常伴随裸 canvas/裸事件,红线另拦)= 未接线,拦下。运行时半(音效/计分/表现真实发生)归测试员观察。
|
||
* 漏报优于误杀:同名局部变量的方法调用会被当成插件调用放行(存在断言方向上安全)。
|
||
*
|
||
* @param {Object<string,string>} sources 文件名→原始源码(存在的游戏源文件)
|
||
* @returns {string[]} errors(零命中时一条;命中则空)
|
||
*/
|
||
export function _pluginCallPresenceErrors(sources) {
|
||
const keys = Object.keys(PLUGIN_KEY_DIR);
|
||
for (const src of Object.values(sources || {})) {
|
||
const s = stripCode(String(src || ''));
|
||
for (const m of s.matchAll(/\b(\w+)\.(\w+)\s*\(/g)) {
|
||
if (keys.includes(m[1])) return []; // 任一文件出现任一插件键的方法调用即算接线
|
||
}
|
||
}
|
||
return [`插件接线断言: 游戏源(${Object.keys(sources || {}).join('/') || 'game-logic.js'})未发现任何插件调用——` +
|
||
`L1 已注入全 11 件能力插件(${keys.join('/')}),必须用它们承担场景机/计分/HUD/定时/音效等能力` +
|
||
`(如 const { sceneFsm, sessionScore } = plugins; sceneFsm.define('menu',{...})),别抛开插件库徒手裸写`];
|
||
}
|
||
|
||
// ───────────────────────── 词法真实红线判定(W-AXIS 波2)─────────────────────────
|
||
// 动机(诊断档 §2.1/§2.4):红线八条与 .boot.ctx/getInput 的命中判定原本是「stripCode 抹字符串/注释 → 对纯文本跑
|
||
// 正则」。stripCode 修好后(0be0f61c 单遍状态机)字符串内 // 已不 desync,但「对纯文本跑正则」仍是近似:token 间
|
||
// 夹空白/注释(Math./*c*/random())、模板插值内的裸调(`${Math.random()}`)靠正则都不牢。改为真词法扫描——
|
||
// 字符串/模板文本/注释一律不产 token(其内的 // 与 API 名天然不参与匹配),码区与插值 ${} 内的标识符/标点才产
|
||
// token 带行号,按 token 序列判命中。**纯 JS 零依赖**:check 每回合 shell-out 跑,现货只有 esbuild(无 JS-AST
|
||
// API)、acorn 未装,若引入解析器硬依赖则一旦缺包 check 崩=生成全线停摆;词法器抛异常时兜底退回 stripCode+正则
|
||
// (_bannedLineErrorsViaStrip),既保词法真实又绝不 fail-open 放行裸调。
|
||
|
||
/**
|
||
* lexTokens —— 单遍词法扫描,只产「码区」判定需要的 token:标识符(含关键字如 new)与标点 . ( ?.(各带 1-based 行号)。
|
||
* 字符串(''/"")、模板文本、行/块注释内不产 token;模板插值 ${…} 内部按【码】扫描(裸调藏进插值也照样产 token)。
|
||
* 行号在任何状态遇 \n 自增(命中定位到原文行);跨行容错对齐 stripCode(单双引串内换行退回外层)。
|
||
* @param {string} src 原始源码 @returns {{k:'id'|'p', v:string, line:number}[]}
|
||
*/
|
||
export function lexTokens(src) {
|
||
const toks = [];
|
||
const n = src.length;
|
||
let line = 1;
|
||
let state = 'code'; // code | line(行注释) | block(块注释) | sq(单引) | dq(双引) | tpl(模板)
|
||
const stack = []; // 进字符串/插值前保存的外层 state(支持模板插值嵌套)
|
||
let brace = 0; // 当前 code 区 { } 深度(仅用于判 ${…} 的闭合 })
|
||
const braceBase = []; // 每层插值起始 brace 基准(回到基准的 } = 该层插值结束)
|
||
for (let i = 0; i < n; i++) {
|
||
const c = src[i];
|
||
const c2 = i + 1 < n ? src[i + 1] : '';
|
||
if (state === 'code') {
|
||
if (c === '\n') { line++; continue; }
|
||
if (c === '/' && c2 === '/') { state = 'line'; i++; continue; }
|
||
if (c === '/' && c2 === '*') { state = 'block'; i++; continue; }
|
||
if (c === "'") { stack.push('code'); state = 'sq'; continue; }
|
||
if (c === '"') { stack.push('code'); state = 'dq'; continue; }
|
||
if (c === '`') { stack.push('code'); state = 'tpl'; continue; }
|
||
if (c === '{') { brace++; continue; }
|
||
if (c === '}') {
|
||
if (braceBase.length && brace === braceBase[braceBase.length - 1]) {
|
||
braceBase.pop(); state = stack.pop(); continue; // 回到插值起始层 → 弹回模板态
|
||
}
|
||
if (brace > 0) brace--;
|
||
continue;
|
||
}
|
||
if (/[A-Za-z_$]/.test(c)) { // 标识符(关键字如 new 也当 id,只看 value)
|
||
let j = i + 1;
|
||
while (j < n && /[\w$]/.test(src[j])) j++;
|
||
toks.push({ k: 'id', v: src.slice(i, j), line });
|
||
i = j - 1;
|
||
continue;
|
||
}
|
||
if (c === '?' && c2 === '.') { toks.push({ k: 'p', v: '?.', line }); i++; continue; }
|
||
if (c === '.') { toks.push({ k: 'p', v: '.', line }); continue; }
|
||
if (c === '(') { toks.push({ k: 'p', v: '(', line }); continue; }
|
||
continue; // 其余(运算符/数字/;[]等)与红线匹配无关,跳过
|
||
}
|
||
if (state === 'line') { if (c === '\n') { line++; state = 'code'; } continue; }
|
||
if (state === 'block') {
|
||
if (c === '\n') line++;
|
||
else if (c === '*' && c2 === '/') { state = 'code'; i++; }
|
||
continue;
|
||
}
|
||
if (state === 'sq' || state === 'dq') {
|
||
const q = state === 'sq' ? "'" : '"';
|
||
if (c === '\\') { if (c2 === '\n') line++; i++; continue; } // 转义跳一对(含续行)
|
||
if (c === '\n') { line++; state = stack.pop(); continue; } // 未闭合换行:退回外层(跨行容错)
|
||
if (c === q) { state = stack.pop(); }
|
||
continue;
|
||
}
|
||
// state === 'tpl'(模板字面量)
|
||
if (c === '\\') { if (c2 === '\n') line++; i++; continue; }
|
||
if (c === '\n') { line++; continue; }
|
||
if (c === '`') { state = stack.pop(); continue; }
|
||
if (c === '$' && c2 === '{') { braceBase.push(brace); stack.push('tpl'); state = 'code'; i++; continue; } // 进插值:按码扫描
|
||
}
|
||
return toks;
|
||
}
|
||
|
||
/** 点号调用形态 [id:obj][p:.][id:prop][p:(] 是否在 toks[i](=obj 处)成立。 */
|
||
function _dotCallAt(toks, i, prop) {
|
||
return toks[i + 1] && toks[i + 1].k === 'p' && toks[i + 1].v === '.'
|
||
&& toks[i + 2] && toks[i + 2].k === 'id' && toks[i + 2].v === prop
|
||
&& toks[i + 3] && toks[i + 3].k === 'p' && toks[i + 3].v === '(';
|
||
}
|
||
|
||
/**
|
||
* _bannedLineErrors —— 红线八条命中判定【词法真实版】。用 lexTokens 产的 token 序列判命中,不再对去注释纯文本跑正则:
|
||
* ① 字符串/模板文本/注释内的同名 API 绝不误命中(0be0f61c 案回归);② 函数内/模板插值内的真实裸调照样命中
|
||
* (词法真实,非纯文本近似);③ 报错带确切行号 + edit_file 补救指向。词法器/匹配异常 → 兜底退回
|
||
* _bannedLineErrorsViaStrip(绝不 fail-open)。BANNED[] 保留作命中规则的报错源文案(下标须与匹配 add 一致)。
|
||
* @param {string} rawSrc 原始源码 @param {string} f 文件名 @returns {string[]}
|
||
*/
|
||
export function _bannedLineErrors(rawSrc, f) {
|
||
try {
|
||
const toks = lexTokens(rawSrc);
|
||
const hits = new Map(); // BANNED 下标 → Set<line>
|
||
const add = (idx, line) => { let s = hits.get(idx); if (!s) { s = new Set(); hits.set(idx, s); } s.add(line); };
|
||
for (let i = 0; i < toks.length; i++) {
|
||
const t = toks[i];
|
||
if (t.k !== 'id') continue;
|
||
const nxt = toks[i + 1];
|
||
const call = !!(nxt && nxt.k === 'p' && nxt.v === '('); // 后缀 name( 形态
|
||
switch (t.v) {
|
||
case 'Math': if (_dotCallAt(toks, i, 'random')) add(0, t.line); break;
|
||
case 'Date': if (_dotCallAt(toks, i, 'now')) add(1, t.line); break;
|
||
case 'performance': if (_dotCallAt(toks, i, 'now')) add(2, t.line); break;
|
||
case 'setTimeout': if (call) add(3, t.line); break;
|
||
case 'setInterval': if (call) add(4, t.line); break;
|
||
case 'requestAnimationFrame': if (call) add(5, t.line); break;
|
||
case 'new': if (nxt && nxt.k === 'id' && nxt.v === 'AudioContext') add(6, t.line); break;
|
||
case 'addEventListener': if (call) add(7, t.line); break;
|
||
default: break;
|
||
}
|
||
}
|
||
const errs = [];
|
||
for (let idx = 0; idx < BANNED.length; idx++) {
|
||
const set = hits.get(idx);
|
||
if (!set || !set.size) continue;
|
||
const sorted = [...set].sort((a, b) => a - b).slice(0, 10);
|
||
errs.push(`红线 ${f} 第 ${sorted.join('/')} 行: 禁用 ${BANNED[idx].source}——时间/随机经 boot.ctx(ctx.time.nowMs()/ctx.random.next())、定时经 timer-scheduler、输入经实例方法 handleTap/handleKey 由 L1 派发;词法真实扫:字符串/注释内的同名不误报、函数内/模板插值内的裸调照样命中(别靠 typeof 守卫或封装绕)。用 edit_file 把该行改回 ctx 形态,别整文件重写。`);
|
||
}
|
||
return errs;
|
||
} catch (e) {
|
||
return _bannedLineErrorsViaStrip(rawSrc, f); // 词法器异常:退回旧实现(绝不漏报裸调)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* _bannedLineErrorsViaStrip —— 红线八条判定的【旧实现】:stripCode 抹注释/字符串 → 对纯文本跑 BANNED 正则,逐行定位行号。
|
||
* 现降为词法真实版 _bannedLineErrors 的【异常兜底】+【新旧 diff 对照基线】(换轴时并跑一批验差异)。逐字节保留旧行为、不删。
|
||
* @param {string} rawSrc 原始源码 @param {string} f 文件名 @returns {string[]} 红线 errors
|
||
*/
|
||
export function _bannedLineErrorsViaStrip(rawSrc, f) {
|
||
const errs = [];
|
||
const s = stripCode(rawSrc); // 已抹注释/字符串、保行号
|
||
const lines = s.split('\n');
|
||
for (const re of BANNED) {
|
||
const rx = new RegExp(re.source, re.flags.replace(/[gy]/g, '')); // 去 g/y 防 lastIndex 粘连
|
||
if (!rx.test(s)) continue; // 整串命中才报(保持原检测强度,不漏跨行写法)
|
||
const hit = [];
|
||
for (let i = 0; i < lines.length && hit.length < 10; i++) {
|
||
if (rx.test(lines[i])) hit.push(i + 1);
|
||
}
|
||
const where = hit.length ? `第 ${hit.join('/')} 行` : '(跨行命中,行号未定位)';
|
||
errs.push(`红线 ${f} ${where}: 禁用 ${re.source}——时间/随机经 boot.ctx(ctx.time.nowMs()/ctx.random.next())、定时经 timer-scheduler、输入经实例方法 handleTap/handleKey 由 L1 派发;函数内的回退裸调、任意嵌套层同样命中(正则按纯文本扫,别靠 typeof 守卫绕)。用 edit_file 把该行改回 ctx 形态,别整文件重写。`);
|
||
}
|
||
return errs;
|
||
}
|
||
|
||
/**
|
||
* _bootCtxLine —— `.boot.ctx` 双层 boot 误用的命中行(0=未命中);词法序列 [p:.][id:boot][p:.|?.][id:ctx]。
|
||
* 须有前导 `.`(即 X.boot.ctx),故正确形态 boot.ctx(boot 是入参、前面是 = 或 ( )不误命中。异常兜底退回正则。
|
||
*/
|
||
export function _bootCtxLine(rawSrc) {
|
||
try {
|
||
const toks = lexTokens(rawSrc);
|
||
for (let i = 0; i < toks.length; i++) {
|
||
if (toks[i].k === 'p' && toks[i].v === '.'
|
||
&& toks[i + 1] && toks[i + 1].k === 'id' && toks[i + 1].v === 'boot'
|
||
&& toks[i + 2] && toks[i + 2].k === 'p' && (toks[i + 2].v === '.' || toks[i + 2].v === '?.')
|
||
&& toks[i + 3] && toks[i + 3].k === 'id' && toks[i + 3].v === 'ctx') {
|
||
return toks[i + 1].line;
|
||
}
|
||
}
|
||
return 0;
|
||
} catch { return /\.boot\s*\??\.\s*ctx\b/.test(stripCode(rawSrc)) ? -1 : 0; } // 兜底:命中给 -1(有命中、行号未定位)
|
||
}
|
||
|
||
/** _getInputLine —— `getInput(` 命中行(0=未命中);词法序列 [id:getInput][p:(]。异常兜底退回正则。 */
|
||
export function _getInputLine(rawSrc) {
|
||
try {
|
||
const toks = lexTokens(rawSrc);
|
||
for (let i = 0; i < toks.length; i++) {
|
||
if (toks[i].k === 'id' && toks[i].v === 'getInput' && toks[i + 1] && toks[i + 1].k === 'p' && toks[i + 1].v === '(') {
|
||
return toks[i].line;
|
||
}
|
||
}
|
||
return 0;
|
||
} catch { return /\bgetInput\s*\(/.test(stripCode(rawSrc)) ? -1 : 0; }
|
||
}
|
||
|
||
/**
|
||
* check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。
|
||
* 1) node --check 各 src/*.js + entry.js(语法);
|
||
* 2) 结构契约:game.js 默认导出 + update 内调 bundle.tick;host-config.js 导出 buildTemplateHostConfig;
|
||
* 3) 红线:游戏源(game/core/render)零裸时间/随机/DOM;
|
||
* 4) API-存在静态门;5) 形状静态门;
|
||
* 6) 未定义标识符 + import 断链静态扫描(check-undef.mjs;node --check 只查语法不查引用,这层拦"运行时才炸")。
|
||
* 返回 {ok, errors[]}。
|
||
*/
|
||
export function check(id) {
|
||
const gd = gameDir(id);
|
||
const errors = [];
|
||
const srcDir = join(gd, 'src');
|
||
let srcFiles = [];
|
||
try {
|
||
srcFiles = readdirSync(srcDir).filter((f) => f.endsWith('.js'));
|
||
} catch {
|
||
return { ok: false, errors: ['src/ 目录不存在或不可读'] };
|
||
}
|
||
|
||
// 1) 语法
|
||
for (const rel of [...srcFiles.map((f) => join('src', f)), 'entry.js']) {
|
||
const abs = join(gd, rel);
|
||
if (!existsSync(abs)) continue;
|
||
const r = spawnSync('node', ['--check', abs], { encoding: 'utf8' });
|
||
if (r.status !== 0) {
|
||
errors.push(`语法错误 ${rel}: ${(r.stderr || '').trim().split('\n').slice(0, 3).join(' ')}`);
|
||
}
|
||
}
|
||
|
||
// 2) 结构契约(3 层架构:agent 写 game-logic.js;game.js/host-config 是 L1 固定 plumbing,writeFileTool 拒写)
|
||
const logicAbs = join(gd, 'src', 'game-logic.js');
|
||
if (existsSync(logicAbs)) {
|
||
const rawLogic = readFileSync(logicAbs, 'utf8');
|
||
const g = stripCode(rawLogic); // 结构存在门(createGame/bundle.tick/_forensicsView/handleTap)仍用去注释文本正则;红线命中判定走词法(下)
|
||
// 窄契约(L1 game.js wrapper 按此名 import;名写错→import 失败→boot 崩):必须命名导出 createGame。
|
||
if (!/export\s+function\s+createGame\b/.test(g)) {
|
||
errors.push('game-logic.js 必须 export function createGame({plugins,bundle,viewport})(L1 的 game.js wrapper 按此名 import,勿改名/勿默认导出)');
|
||
}
|
||
if (!/\bbundle\.tick\s*\(/.test(g)) {
|
||
errors.push('game-logic.js 的 update(dt) 内必须调 bundle.tick(dt)(否则插件 onFrame 不推进、timer 永不到期、一局不结束)');
|
||
}
|
||
// _forensicsView「必须实现」拦截已摘(W-AXIS-V2 波2):自动验收契约随 tap-targets 驱动器退役,验收改由
|
||
// 测试 agent 视觉引导真玩(看截图、点所见),不再读状态导出。_forensicsView 降级为可选调试接口——写了
|
||
// 不拦、不写也不拦;handleTap 输入契约与 ctx.log 日志纪律保留(服务真人与测试员,见下)。
|
||
// ctx 契约硬化(治 M3 实测错:把 boot.ctx 写成 boot.boot.ctx → 多套一层 → undefined → ctx=null → 不订阅输入 →
|
||
// 游戏点不动、卡菜单;且九门默认未驱动时 G_input skip → 漏判,带病过门)。host 传入的 boot 已是 {ctx,mainContext,canvas,seed,assets},
|
||
// 受控面就是 boot.ctx。命中判定走词法(W-AXIS 波2):序列 .boot.ctx(boot.boot.ctx / b.boot.ctx 皆中),正确的 boot.ctx 无前导「.boot」不误伤。
|
||
const _bootLn = _bootCtxLine(rawLogic);
|
||
if (_bootLn) {
|
||
errors.push(`init(boot) 受控面是 boot.ctx,不是 boot.boot.ctx${_bootLn > 0 ? `(第 ${_bootLn} 行)` : ''}——host 传入的 boot 已是 {ctx,...};多套一层 .boot → undefined → ctx=null → 无输入、游戏点不动`);
|
||
}
|
||
// 输入收归 L1(根治 B):L3 绝不自己订阅输入(ctx.getInput / pendingClicks 队列 / update 内挑时机消费点击 →
|
||
// M3 实测把点击消费放进 play 分支、在 menu early-return 之后 → 菜单点击永不消费 → 游戏启动不了)。
|
||
// 改为:只写实例方法 handleTap(x,y)[/handleKey(key)],L1 的 game.js wrapper 在 pointerdown/keydown 时直达调用。命中判定走词法(W-AXIS 波2)。
|
||
const _giLn = _getInputLine(rawLogic);
|
||
if (_giLn) {
|
||
errors.push(`game-logic.js${_giLn > 0 ? ` 第 ${_giLn} 行` : ''} 不要调 ctx.getInput()——输入订阅已收归 L1(game.js wrapper):只写实例方法 handleTap(x,y)(点击)/handleKey(key)(键盘),L1 在 pointerdown/keydown 时直达调用它(根治"点击在错 phase 被消费/永不消费→启动不了")`);
|
||
}
|
||
// 必须暴露至少一个输入方法(否则游戏收不到输入、点不动)。handleTap(点击主输入)或 handleKey(方向键/空格)。
|
||
if (!/\bhandleTap\b/.test(g) && !/\bhandleKey\b/.test(g)) {
|
||
errors.push('game-logic.js 必须实现至少一个输入方法:handleTap(x,y)(点击/经营点客/打地鼠等主输入)或 handleKey(key)(方向键/空格类)——L1 据此在 pointerdown/keydown 时派发;不实现则游戏收不到输入、点不动');
|
||
}
|
||
} else {
|
||
errors.push('缺 src/game-logic.js(你要写的游戏本体;game.js 是 L1 固定 wrapper,别写它)');
|
||
}
|
||
const hcAbs = join(gd, 'src', 'host-config.js');
|
||
if (existsSync(hcAbs)) {
|
||
const hc = stripCode(readFileSync(hcAbs, 'utf8'));
|
||
if (!/export\s+function\s+buildTemplateHostConfig\b/.test(hc)) {
|
||
errors.push('host-config.js 必须保留导出名 buildTemplateHostConfig(固定 plumbing main.js 据此 import,勿改名)');
|
||
}
|
||
} else {
|
||
errors.push('缺 src/host-config.js');
|
||
}
|
||
|
||
// 2.5) game-logic.js 必须真改写过(防 write 路径写错→没覆盖→拿 _template 原样蒙混过 check)
|
||
try {
|
||
const tmpl = readFileSync(join(TEMPLATE_DIR, 'src', 'game-logic.js'), 'utf8');
|
||
if (existsSync(logicAbs) && readFileSync(logicAbs, 'utf8') === tmpl) {
|
||
errors.push(`game-logic.js 与 _template 完全相同:还没真正改写游戏(确认 write 路径写进了 ${relative(REPO_ROOT, gd)}/src/game-logic.js)`);
|
||
}
|
||
} catch { /* 模板缺失则跳过 */ }
|
||
|
||
// 3) 红线(只查 agent 写的游戏文件;game.js/host-config 固定不查)。判定抽到 _bannedLineErrors(便于单测),报错带确切行号 + edit_file 补救指向。
|
||
for (const f of ['game-logic.js', 'core.js', 'render.js']) {
|
||
const abs = join(gd, 'src', f);
|
||
if (!existsSync(abs)) continue;
|
||
for (const e of _bannedLineErrors(readFileSync(abs, 'utf8'), f)) errors.push(e);
|
||
}
|
||
|
||
// 4) API-存在静态门(玩法无关,治"方法名幻觉" stopBgm/defineScenes/ctx.nowMs):游戏调的 plugins.<键>.<m>() / ctx.<m>() 必须在 api.d.ts 真存在。
|
||
// 只查已知插件键(host-config 注入的 11)+ ctx 直接方法;非键(state/Math/g/bundle/time/random 内层…)跳过 → over-permissive 少误报。
|
||
for (const f of ['game-logic.js', 'core.js', 'render.js']) {
|
||
const abs = join(gd, 'src', f);
|
||
if (!existsSync(abs)) continue;
|
||
const s = stripCode(readFileSync(abs, 'utf8'));
|
||
for (const m of s.matchAll(/\b(\w+)\.(\w+)\s*\(/g)) {
|
||
const key = m[1], method = m[2];
|
||
if (key === 'ctx') {
|
||
if (!CTX_METHODS.has(method)) {
|
||
errors.push(`API 静态门 ${f}: ctx.${method}() 不存在——boot.ctx 真方法 = ${[...CTX_METHODS].join('/')};时间 ctx.time()(或 .nowMs())、随机 ctx.random()(或 .next()/.range(a,b))——#1 后 time/random 既可直接调用又保留方法`);
|
||
}
|
||
} else if (PLUGIN_KEY_DIR[key]) {
|
||
const ms = apiMethodSet(PLUGIN_KEY_DIR[key]);
|
||
if (ms && ms.size && !ms.has(method)) {
|
||
errors.push(`API 静态门 ${f}: ${key}.${method}() 不存在——见 src/plugins/${PLUGIN_KEY_DIR[key]}/api.d.ts 的真方法名(M3 方法名幻觉)`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5) 形状静态门(治"方法名对但参数形状/返回值幻觉、带病过 check"两类实测翻车):
|
||
// ① 返 void 的插件 API 返回值被赋值/链式消费(base2 drawButton 实证);② sceneFsm.define 批量误用(base3 实证)。
|
||
// 判定抽到 _shapeGateErrors(便于单测);只查 agent 写的游戏文件。
|
||
for (const f of ['game-logic.js', 'core.js', 'render.js']) {
|
||
const abs = join(gd, 'src', f);
|
||
if (!existsSync(abs)) continue;
|
||
for (const e of _shapeGateErrors(readFileSync(abs, 'utf8'), f)) errors.push(e);
|
||
}
|
||
|
||
// 5.5) 插件接线存在断言(W-AXIS-V2 波2,接替 F_wiring 的真接线护栏;词法层确定性,判定见 _pluginCallPresenceErrors):
|
||
// 游戏源三文件合并后必须至少调一次已注入插件(零命中=抛开插件库徒手裸写=未接线)。
|
||
{
|
||
const srcs = {};
|
||
for (const f of ['game-logic.js', 'core.js', 'render.js']) {
|
||
const abs = join(gd, 'src', f);
|
||
if (existsSync(abs)) srcs[f] = readFileSync(abs, 'utf8');
|
||
}
|
||
for (const e of _pluginCallPresenceErrors(srcs)) errors.push(e);
|
||
}
|
||
|
||
// 6) 未定义标识符 + import 断链静态扫描(4 例实证同族:解谜 gen3 未定义 cy / gen6 未定义 stepUntilMs /
|
||
// 非遗首败 import 断链——语法过 check、运行时才炸,九门才拦得住,浪费整局;node --check 只查语法不查引用)。
|
||
// esbuild(树内现货)当作用域分析器,判定与漏报口径见 check-undef.mjs;esbuild 不可用时函数自身降级返回空。
|
||
// 生成侧五文件都查(balance/assets 同为 agent 手笔;存在才扫)。
|
||
for (const f of ['game-logic.js', 'core.js', 'render.js', 'balance.js', 'assets.js']) {
|
||
const abs = join(gd, 'src', f);
|
||
if (!existsSync(abs)) continue;
|
||
for (const e of scanUndefinedIdentifiers(readFileSync(abs, 'utf8'), f)) errors.push(e);
|
||
}
|
||
// import 断链:以 game-logic.js 为根静态解析整个 import 图(core/render/balance/assets 全经它可达;
|
||
// 根不可达的文件运行时也不加载,不属"运行时才炸"族)。game-logic.js 缺失时上面第 2 节已报,此处跳过。
|
||
if (existsSync(logicAbs)) {
|
||
for (const e of scanImportChain(logicAbs, gd)) errors.push(e);
|
||
}
|
||
|
||
return { ok: errors.length === 0, errors };
|
||
}
|
||
|
||
// ───────────────────────── tsc 类型门(W-AXIS 波3;advisory·独立通道·永不阻断)─────────────────────────
|
||
// 定位(诊断档 §四⑤ / plan 波3 ①):TapTap 类生成线都有「真编译/LSP 门」;本项目此前只有 node --check(语法)
|
||
// + esbuild 作用域分析(未定义标识符),缺一层「真类型检查」——ctx.time 用错形态、拼错本地属性名、把数字当函数
|
||
// 调、字符串做算术这类运行时才炸的错,语法门与作用域门都放行。tsc checkJs 对游戏源做真类型推断能把它们挡在
|
||
// CDP 真玩开销前(与 TS2551「拼错 push→建议 push」同族,带修复建议)。
|
||
//
|
||
// 硬约束(plan 波3 边界 + AGENTS.md §6.13):
|
||
// 1) 本波【只 advisory】——findings 绝不进 check 的 errors[]、绝不改 check 退出码/ok;转阻断是 n=5 校准后
|
||
// 的另一个决定(下面 _tscGateMode 留了 'block' 开关位,本波默认 'advisory',不激活阻断)。
|
||
// 2) tsc 不可用(未装 typescript / 解析失败 / 运行抛错)→ 输出一行降级说明即返回,check 主判定完全不受影响
|
||
// ——fail-open 只发生在本通道自身。故整函数不抛、任何异常都吞成 degraded。
|
||
// 3) 只查 agent 写的 L3 源(game-logic/core/render/balance/assets),不碰 L1 plumbing。
|
||
//
|
||
// 消噪校准(对 10 款金标语料——2 golden + 6 _template* + 2 _fewshot*——逐一跑到零误伤,W-AXIS 波3 实测):
|
||
// · lib 含 dom:游戏源 JSDoc 用 CanvasRenderingContext2D 等 DOM 类型,不含 dom 会误报 TS2304。
|
||
// · 8xxx(JSDoc 元数据码,如 @param 名不匹配 TS8024)整族丢弃:本门查逻辑/类型 bug,不查 JSDoc 卫生。
|
||
// · TS2307 指向平台类型契约(src/core/*.d.ts、src/plugins/*/api.d.ts)丢弃:真实产物在 games/amgen-<id>/src/
|
||
// 下该相对路径解析得到,只有金标 fixture 被移出 games/ 才深度错位——位置伪影、非代码错。
|
||
// · ctx.log / handleTap / handleKey / _forensicsView / _handleAction 这些【运行时附加、.d.ts 曾漏声明】的成员,
|
||
// 已在 src/core/{api,game-host}.d.ts 补声明(非在此消噪)——让类型面与运行时契约一致,真实的 handleTap 拼写
|
||
// 错等仍会被抓。ctx/plugins/bundle/engine 等运行时注入面无类型标注 → 天然 any → 不产生契约误报。
|
||
|
||
const _TSC_GATE_ENV = 'CHEAP_TSC_GATE'; // 门开关:'advisory'(默认,本波) | 'off'(关) | 'block'(留位,本波不激活)
|
||
const _TSC_L3_FILES = ['game-logic.js', 'core.js', 'render.js', 'balance.js', 'assets.js'];
|
||
|
||
/** 读门模式(env CHEAP_TSC_GATE;缺省 advisory)。block 是给 n=5 校准后转阻断留的开关位,本波【不激活】,遇到按 advisory 跑。 */
|
||
function _tscGateMode() {
|
||
const v = (process.env[_TSC_GATE_ENV] || '').trim().toLowerCase();
|
||
if (v === 'off') return 'off';
|
||
// 'block' 本波不激活:仍按 advisory 跑(留位不阻断);其余一律 advisory。
|
||
return 'advisory';
|
||
}
|
||
|
||
/** 惰性解析 typescript(装了才有;createRequire 从本文件 resolve → 命中 game-runtime/node_modules/typescript)。解析不到返回 null。 */
|
||
function _resolveTypescript() {
|
||
try {
|
||
const req = createRequire(import.meta.url);
|
||
return req('typescript');
|
||
} catch {
|
||
return null; // 未装 typescript:上层输出降级说明,主判定不受影响
|
||
}
|
||
}
|
||
|
||
/**
|
||
* tscAdvisory:对本 run 游戏 L3 源跑 tsc checkJs 真类型检查,产【advisory】反馈(永不阻断,永不抛)。
|
||
* 返回 {mode, available, findings:[{code,loc,msg}], note}——CLI/worker 只把它格式化进 check 输出的独立一节,
|
||
* 绝不折进 errors[]。tsc 不可用/任何异常 → available:false + note 降级说明。
|
||
*/
|
||
export function tscAdvisory(id) {
|
||
const mode = _tscGateMode();
|
||
if (mode === 'off') return { mode, available: false, findings: [], note: 'tsc 类型门已关(CHEAP_TSC_GATE=off)' };
|
||
try {
|
||
const ts = _resolveTypescript();
|
||
if (!ts) {
|
||
return { mode, available: false, findings: [],
|
||
note: 'tsc 不可用(未安装 typescript)——类型门 advisory 通道降级,check 主判定不受影响。装:cd game-runtime && npm i -D typescript' };
|
||
}
|
||
const srcDir = join(gameDir(id), 'src');
|
||
const files = _TSC_L3_FILES.map((f) => join(srcDir, f)).filter((p) => existsSync(p));
|
||
if (!files.length) return { mode, available: true, findings: [], note: '无 L3 源可查' };
|
||
const options = {
|
||
allowJs: true, checkJs: true, noEmit: true, skipLibCheck: true,
|
||
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'], target: ts.ScriptTarget.ES2020,
|
||
module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||
strict: false, noImplicitAny: false, noImplicitThis: false, types: [],
|
||
forceConsistentCasingInFileNames: true,
|
||
};
|
||
const program = ts.createProgram(files, options);
|
||
const fileSet = new Set(files.map((f) => resolve(f)));
|
||
const isNoise = (d) => {
|
||
if (d.code >= 8000 && d.code < 9000) return true; // JSDoc 元数据族
|
||
if (d.code === 2307 && d.file && d.start != null) { // 指平台类型契约的 module-not-found = fixture 位置伪影
|
||
const t = d.file.text.slice(d.start, d.start + 200);
|
||
if (/src\/(core|plugins)\/[^'"]*\.d\.ts/.test(t)) return true;
|
||
}
|
||
return false;
|
||
};
|
||
const findings = [];
|
||
for (const d of ts.getPreEmitDiagnostics(program)) {
|
||
if (!d.file || !fileSet.has(resolve(d.file.fileName)) || isNoise(d)) continue;
|
||
const msg = ts.flattenDiagnosticMessageText(d.messageText, ' ').replace(/\s+/g, ' ').trim();
|
||
let loc = d.file.fileName.split('/').slice(-1)[0];
|
||
if (d.start != null) {
|
||
const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
|
||
loc += `:${line + 1}:${character + 1}`;
|
||
}
|
||
findings.push({ code: d.code, loc, msg });
|
||
}
|
||
return { mode, available: true, findings, note: null };
|
||
} catch (e) {
|
||
// fail-open 只在本通道:任何异常吞成降级,绝不连累 check 主判定(诊断档铁律:硬依赖不得让 check 崩)。
|
||
return { mode, available: false, findings: [], note: `tsc advisory 通道异常(已降级,不影响主判定):${e && e.message ? e.message : e}` };
|
||
}
|
||
}
|
||
|
||
/** 把 tscAdvisory 结果格式化成 check 输出里的独立 advisory 节(供 CLI/worker 追加打印;标记「仅参考不阻断」)。 */
|
||
export function formatTscAdvisory(adv) {
|
||
if (!adv) return '';
|
||
if (!adv.available) return `[check:tsc-advisory] ${adv.note || '不可用'}(仅参考,不阻断)`;
|
||
if (!adv.findings.length) return '[check:tsc-advisory] 类型检查 0 发现(仅参考,不阻断)';
|
||
const lines = adv.findings.slice(0, 30).map((f) => ` · TS${f.code} ${f.loc} ${f.msg}`);
|
||
const more = adv.findings.length > 30 ? `\n …(另 ${adv.findings.length - 30} 条,略)` : '';
|
||
return `[check:tsc-advisory] 类型检查 ${adv.findings.length} 发现(仅参考·不阻断·非红线;确认是真问题再改):\n${lines.join('\n')}${more}`;
|
||
}
|
||
|
||
/** build:esbuild 锁参打包(cwd=game-runtime)。返回 {ok, error?}。 */
|
||
export function build(id) {
|
||
const entry = `games/amgen-${id}/entry.js`;
|
||
const out = `games/amgen-${id}/dist/template-bundle.js`;
|
||
const r = spawnSync(
|
||
'node',
|
||
['scripts/build.mjs', entry, out, '--global-name=TemplateGame'],
|
||
{ cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 },
|
||
);
|
||
if (r.status !== 0) {
|
||
const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n');
|
||
return { ok: false, error: err || `build 退出码 ${r.status}` };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
// ───────────────────────── SAA 模式(批 A:信封对齐 __GameBundle + stage 到 _wg1-gen)─────────────────────────
|
||
|
||
/** SAA stage 目标目录(_wg1-gen/<gameId>/;SAA play/emit 既有读盘契约,治 review B1)。 */
|
||
export function saaGameDir(gameId) {
|
||
return join(WG1_DIR, gameId);
|
||
}
|
||
|
||
/**
|
||
* scaffoldSaa:clone 模板 → amgen-<gameId>/,换上 A-model SAA 信封 plumbing(治 review B2)。
|
||
* - entry-bundle.js = entry-bundle.amodel.template.js(导出 bootGameHost,经 --global-name=__GameBundle 挂 window.__GameBundle)
|
||
* - index.html = _wg1-gen/_shared/index.template.html(调 __GameBundle.bootGameHost,置 __genBooted)
|
||
* - 去 spike 入口(entry.js/src/main.js;SAA 不用)+ dist/test
|
||
* agent 仍只写 src/{game,core,render,host-config,balance,assets}.js(与 spike 同;信封差异全在 plumbing)。
|
||
*
|
||
* @param {string} gameId 本 run 游戏 id。
|
||
* @param {string} [templateName='_template'] 起始模板目录名(扩模板:经营=_template-shop 等 per-genre 黄金骨架;
|
||
* 缺省 _template=通用点圆得分起点)。模板不存在则回落 _template(不静默失败,打 warn 可查)。
|
||
*/
|
||
export function scaffoldSaa(gameId, templateName = '_template') {
|
||
const gd = gameDir(gameId);
|
||
let tmplDir = join(GAMES_DIR, templateName || '_template');
|
||
if (!existsSync(tmplDir)) {
|
||
console.warn(`[scaffold-saa] 模板 ${templateName} 不存在,回落 _template`);
|
||
tmplDir = TEMPLATE_DIR;
|
||
}
|
||
if (existsSync(gd)) rmSync(gd, { recursive: true, force: true });
|
||
cpSync(tmplDir, gd, { recursive: true });
|
||
rmSync(join(gd, 'dist'), { recursive: true, force: true });
|
||
rmSync(join(gd, 'test'), { recursive: true, force: true });
|
||
// 换 SAA 信封 plumbing(覆盖 _template 的 spike 信封 entry.js/index.html)
|
||
cpSync(join(SHARED_DIR, 'entry-bundle.amodel.template.js'), join(gd, 'entry-bundle.js'));
|
||
cpSync(join(SHARED_DIR, 'index.template.html'), join(gd, 'index.html'));
|
||
// 去 spike 入口(SAA 用 entry-bundle.js;entry.js/main.js 不参与构建,删之免混淆)
|
||
rmSync(join(gd, 'entry.js'), { force: true });
|
||
rmSync(join(gd, 'src', 'main.js'), { force: true });
|
||
return gd;
|
||
}
|
||
|
||
/** buildSaa:esbuild 打 entry-bundle.js → bundle.iife.js(--global-name=__GameBundle)。 */
|
||
export function buildSaa(gameId) {
|
||
const entry = `games/amgen-${gameId}/entry-bundle.js`;
|
||
const out = `games/amgen-${gameId}/bundle.iife.js`;
|
||
const r = spawnSync('node', ['scripts/build.mjs', entry, out, '--global-name=__GameBundle'],
|
||
{ cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 });
|
||
if (r.status !== 0) {
|
||
const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n');
|
||
return { ok: false, error: err || `buildSaa 退出码 ${r.status}` };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
/** stage:原子(写 tmp→rename)把自包含 bundle + SAA index 拷到 _wg1-gen/<gameId>/(SAA play/emit 读这里;play-spec 由 SAA 产、本步不碰)。 */
|
||
export function stage(gameId) {
|
||
const gd = gameDir(gameId);
|
||
const dst = saaGameDir(gameId);
|
||
mkdirSync(dst, { recursive: true });
|
||
for (const f of ['bundle.iife.js', 'index.html']) {
|
||
const src = join(gd, f);
|
||
if (!existsSync(src)) return { ok: false, error: `stage 缺源 ${f}(先 buildSaa)` };
|
||
const tmp = join(dst, f + '.tmp');
|
||
cpSync(src, tmp);
|
||
renameSync(tmp, join(dst, f)); // 原子 rename,防 play 读半成品
|
||
}
|
||
// 资产目录(若有外采美术):整目录拷到 staged gameId 目录,使 play.cdp / studio 经相对 ./assets/ 可 fetch
|
||
// → boot-game-host.loadHostAssets 据 manifest.json 载图注入 boot.assets。无美术(_template 空 manifest)→ 拷个空表,无害。
|
||
const assetsSrc = join(gd, 'assets');
|
||
if (existsSync(assetsSrc)) {
|
||
const assetsDst = join(dst, 'assets');
|
||
rmSync(assetsDst, { recursive: true, force: true });
|
||
cpSync(assetsSrc, assetsDst, { recursive: true });
|
||
}
|
||
return { ok: true, dir: dst };
|
||
}
|
||
|
||
/**
|
||
* ensurePlaySpec —— 据游戏 _forensicsView().state() 形态自动产 _wg1-gen/<gameId>/play-spec.json(治"纯 harness 裸跑无 driver→driven=false→E/H 降 advisory→假绿")。
|
||
* 【退役·保留一个版本窗口(W-AXIS-V2 波2,2026-07-10)】gen.mjs done 门已摘除调用——便宜档验收权威 =
|
||
* 四门投影(A/B/C/D)∧ 测试 agent 真玩,九门以 undriven 硬集跑。实现保留仅供封存对照工具历史复跑;窗口期满一并清理。
|
||
* driver 推断(照 play.cdp.cjs 的 driver 家族):
|
||
* · state 有 targets 数组(点击类:打地鼠/经营点客/点离散目标)→ tap-targets + targetMode:'occupied'(只点 occupied===true 的目标,且 phase≠play 时自动 start 起局);
|
||
* · 否则(按键类,无 targets)→ key-cycle(循环按方向键)。
|
||
* gatespec:assertAfterPlay 断 score 上升(真玩应加分)+ expectLatch(限时类 latch 现有 advisory 兜底、不误杀)。
|
||
* **已存在 play-spec(如 SAA Java 路产的)则不覆盖**——只在缺失时补。
|
||
* @param {string} gameId stage 后的 gameId(写 _wg1-gen/<gameId>/play-spec.json)
|
||
* @param {object|null} state smokeBoot 抓到的 _forensicsView().state() 快照(null=读不到→保守按键类)
|
||
* @returns {{ok:boolean, wrote:boolean, reason?:string, driverType?:string, path?:string}}
|
||
*/
|
||
export function ensurePlaySpec(gameId, state) {
|
||
try {
|
||
const dst = saaGameDir(gameId);
|
||
const specPath = join(dst, 'play-spec.json');
|
||
if (existsSync(specPath)) {
|
||
return { ok: true, wrote: false, reason: '已存在 play-spec(SAA Java 路或上次产)→不覆盖', path: specPath };
|
||
}
|
||
// 形态推断:state.targets 是非空(或存在)数组 → 点击类;否则按键类。读不到 state 保守按键类(key-cycle 对绝大多数游戏无害)。
|
||
const hasTargets = !!(state && Array.isArray(state.targets));
|
||
let driver, exportState;
|
||
if (hasTargets) {
|
||
driver = { type: 'tap-targets', targetMode: 'occupied', targetsPath: 'targets', steps: 50, stepMs: 220 };
|
||
exportState = ['phase', 'score', 'targets'];
|
||
} else {
|
||
driver = { type: 'key-cycle', keys: ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'], steps: 60, stepMs: 180, downMs: 60 };
|
||
exportState = ['phase', 'score'];
|
||
}
|
||
const spec = {
|
||
exportState,
|
||
driver,
|
||
assertAfterPlay: [{ path: 'score', op: 'increased', why: '真玩应加分' }],
|
||
expectLatch: true, // latch 现有 advisory 兜底:限时/无快速失败态类玩不到终局但有真进展→降 advisory 不致命(play.cdp.cjs)
|
||
};
|
||
mkdirSync(dst, { recursive: true });
|
||
const tmp = specPath + '.tmp';
|
||
writeFileSync(tmp, JSON.stringify(spec, null, 2), 'utf8');
|
||
renameSync(tmp, specPath); // 原子写,防 play 读半成品
|
||
return { ok: true, wrote: true, driverType: driver.type, path: specPath };
|
||
} catch (e) {
|
||
return { ok: false, wrote: false, reason: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── CLI(独立验证用)─────────────────────────
|
||
// 用法:node tools.mjs scaffold <id> | check <id> | build <id>
|
||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
const [, , cmd, id, arg3] = process.argv;
|
||
if (!cmd || !id) {
|
||
console.error('用法:node tools.mjs <scaffold|check|build|scaffold-saa|build-saa|stage> <id> [templateName]');
|
||
process.exit(2);
|
||
}
|
||
if (cmd === 'scaffold') {
|
||
const gd = scaffold(id);
|
||
console.log('[scaffold] →', relative(REPO_ROOT, gd));
|
||
} else if (cmd === 'check') {
|
||
const r = check(id);
|
||
console.log('[check]', r.ok ? 'PASS' : 'FAIL');
|
||
r.errors.forEach((e) => console.log(' -', e));
|
||
// tsc 类型门:独立 advisory 节,只打印、绝不改退出码(本波不阻断;缺 tsc 自降级)。放在主判定之后,
|
||
// 即便它慢/降级也不动 r.ok 与 exit code——fail-open 只在本通道自身(plan 波3 边界)。
|
||
try { const line = formatTscAdvisory(tscAdvisory(id)); if (line) console.log(line); } catch { /* advisory 绝不连累主判定 */ }
|
||
process.exit(r.ok ? 0 : 1);
|
||
} else if (cmd === 'build') {
|
||
const r = build(id);
|
||
console.log('[build]', r.ok ? 'PASS' : 'FAIL');
|
||
if (!r.ok) { console.log(r.error); process.exit(1); }
|
||
} else if (cmd === 'scaffold-saa') {
|
||
const gd = scaffoldSaa(id, arg3);
|
||
console.log('[scaffold-saa] →', relative(REPO_ROOT, gd), `(SAA 信封 plumbing, 模板=${arg3 || '_template'})`);
|
||
} else if (cmd === 'build-saa') {
|
||
const r = buildSaa(id);
|
||
console.log('[build-saa]', r.ok ? 'PASS' : 'FAIL');
|
||
if (!r.ok) { console.log(r.error); process.exit(1); }
|
||
} else if (cmd === 'stage') {
|
||
const r = stage(id);
|
||
console.log('[stage]', r.ok ? 'PASS → ' + relative(REPO_ROOT, r.dir) : 'FAIL ' + r.error);
|
||
if (!r.ok) process.exit(1);
|
||
} else {
|
||
console.error('未知命令:', cmd);
|
||
process.exit(2);
|
||
}
|
||
}
|