feat(amodel-gen): 形状门泛化到全 void 方法返回值误用 + 防回归单测(M1 U1)
KTD2 实证现有形状门已接住 base2/base3 两类已知幻觉(check 跑两款各 FAIL 于唯一形状门 错误;翻车产物早于形状门落地 3.5h),据此把形状门①从硬编码 drawButton 泛化为 apiVoidMethodSet 全 void 方法的赋值/链式返回值消费检测,②补 define 数组批量变体。形态 收窄到赋值/链式——base1/4/5 合法 void 调用(独立语句 / if(guard)call() 防御写法)实证 零误杀;条件/参数形态会误杀防御写法,移交 WU-C 5.4。判定抽 _shapeGateErrors 便于单测。 验证:check.test.mjs 11/11(靶子 catch + 泛化4方法 + 链式 + 零误杀6类);check base1-5 行为不变(base1/4/5 PASS、base2/3 FAIL);真跑重新生成打地鼠过九门(E_live 进对局 distinctStates=52、H_progress score 0→44)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
08fab84b81
commit
6b334ff738
118
game-runtime/tools/amodel-gen/tests/check.test.mjs
Normal file
118
game-runtime/tools/amodel-gen/tests/check.test.mjs
Normal file
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* check.test.mjs — amodel-gen check() 形状静态门(_shapeGateErrors)防回归单测
|
||||
* owner:便宜档 M1 U1 | 跑法:node --test tools/amodel-gen/tests/check.test.mjs(cwd 任意)
|
||||
*
|
||||
* 【守的不变量】
|
||||
* ① 返 void 的插件 API 返回值被赋值/链式消费 → catch(base2 drawButton 实证 + 泛化到所有 void 方法)。
|
||||
* ② sceneFsm.define 单对象/数组批量误用 → catch(base3 2048 实证)。
|
||||
* 零误杀:base1/4/5 实证的合法写法(独立语句、if(guard)call()、try/catch、比较、注释/字符串里的 API 名、非插件键同名方法)一律不报。
|
||||
* _shapeGateErrors 真实读各插件 api.d.ts 抽 void 名集,故本测同时把关「api.d.ts 的 void 标注被正确解析」。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { _shapeGateErrors } from '../tools.mjs';
|
||||
|
||||
/** 是否报了形状门错误。 */
|
||||
const hasShape = (errs) => errs.some((e) => e.startsWith('形状门'));
|
||||
/** 包一层函数体(贴近真实 game-logic.js 写法)。 */
|
||||
const wrap = (body) => `export function createGame(){ function f(g){ ${body} } return { _forensicsView(){}, handleTap(){} }; }`;
|
||||
|
||||
// ───────── ① 返 void API 返回值被消费 → catch ─────────
|
||||
|
||||
test('① base2 幻觉:drawButton 返回值赋值 → catch + 指向 pointInRect', () => {
|
||||
const errs = _shapeGateErrors(wrap(`menuStartBtn = hudUi.drawButton(g, '开始游戏', bx, by, bw, bh);`), 'game-logic.js');
|
||||
assert.ok(hasShape(errs), '应 catch drawButton 赋值');
|
||||
assert.ok(errs.some((e) => e.includes('drawButton') && e.includes('pointInRect')), '应给出 pointInRect 改法');
|
||||
});
|
||||
|
||||
test('① 泛化:其它 void 方法(drawText/play/flashScreen/drawBar)被赋值 → catch', () => {
|
||||
const cases = [
|
||||
`x = hudUi.drawText(g, '得分', 0, 0, {});`,
|
||||
`const r = audioMusic.play('bgm');`,
|
||||
`let y = juice.flashScreen(0.3);`,
|
||||
`const b = hudUi.drawBar(g, rect, 0.5, {});`,
|
||||
];
|
||||
for (const c of cases) {
|
||||
const errs = _shapeGateErrors(wrap(c), 'game-logic.js');
|
||||
assert.ok(hasShape(errs), `应 catch 赋值:${c}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('① 链式:void 方法后链式取值 → catch', () => {
|
||||
const errs = _shapeGateErrors(wrap(`hudUi.drawButton(g, btn, {}).pressed;`), 'game-logic.js');
|
||||
assert.ok(hasShape(errs), '应 catch 链式消费');
|
||||
});
|
||||
|
||||
// ───────── ② sceneFsm.define 批量误用 → catch ─────────
|
||||
|
||||
test('② base3 幻觉:define 单对象批量 → catch', () => {
|
||||
const errs = _shapeGateErrors(wrap(`sceneFsm.define({ menu: { onEnter(){} }, play: {}, over: {} });`), 'game-logic.js');
|
||||
assert.ok(hasShape(errs), '应 catch define 对象批量');
|
||||
assert.ok(errs.some((e) => e.includes('define')), '错误应提及 define');
|
||||
});
|
||||
|
||||
test('② define 数组批量 → catch', () => {
|
||||
const errs = _shapeGateErrors(wrap(`sceneFsm.define([{ name: 'menu' }, { name: 'play' }]);`), 'game-logic.js');
|
||||
assert.ok(hasShape(errs), '应 catch define 数组批量');
|
||||
});
|
||||
|
||||
// ───────── 零误杀:合法写法一律不报 ─────────
|
||||
|
||||
test('零误杀:合法 void 独立语句调用', () => {
|
||||
const legit = [
|
||||
`hudUi.drawButton(g, btn, { label: '开始' });`,
|
||||
`hudUi.drawText(g, '得分', 16, 28, {});`,
|
||||
`hudUi.drawBar(g, rect, ratio, {});`,
|
||||
`sessionScore.reset();`,
|
||||
`audioMusic.playSfx('blip');`,
|
||||
`juice.flashScreen('#fff', 0.3);`,
|
||||
`sceneFsm.render(g);`,
|
||||
`sceneFsm.define('menu', { onEnter(){} });`,
|
||||
];
|
||||
for (const line of legit) {
|
||||
const errs = _shapeGateErrors(wrap(line), 'game-logic.js');
|
||||
assert.equal(hasShape(errs), false, `误杀:${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('零误杀:if(guard) 单行体 void 调用(base4/5 防御写法,前缀是 `)`)', () => {
|
||||
const legit = [
|
||||
`if (typeof juice.flashScreen === 'function') juice.flashScreen(FLASH_DUR, FLASH_PEAK);`,
|
||||
`if (audioMusic && audioMusic.play) audioMusic.play('idle');`,
|
||||
`if (audioMusic && audioMusic.stop) audioMusic.stop();`,
|
||||
];
|
||||
for (const line of legit) {
|
||||
const errs = _shapeGateErrors(wrap(line), 'game-logic.js');
|
||||
assert.equal(hasShape(errs), false, `误杀:${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('零误杀:try/catch 包裹的 void 调用', () => {
|
||||
const errs = _shapeGateErrors(wrap(`try { sessionScore.reset(); } catch (_) { /* 容错 */ }`), 'game-logic.js');
|
||||
assert.equal(hasShape(errs), false);
|
||||
});
|
||||
|
||||
test('零误杀:比较运算(==/===)不被误判为赋值', () => {
|
||||
const errs = _shapeGateErrors(wrap(`if (a === 1) hudUi.drawText(g, 'x', 0, 0, {});`), 'game-logic.js');
|
||||
assert.equal(hasShape(errs), false);
|
||||
});
|
||||
|
||||
test('零误杀:注释 / 字符串里的 API 名(经 stripCode)不触发', () => {
|
||||
const src = `
|
||||
// 反例:别写 menuBtn = hudUi.drawButton(g, btn)
|
||||
/* x = audioMusic.play('bgm') 也是错的 */
|
||||
export function createGame(){
|
||||
const tip = 'y = juice.flashScreen(0.3)';
|
||||
function f(g){ hudUi.drawText(g, '提示', 0, 0, {}); }
|
||||
return { _forensicsView(){}, handleTap(){} };
|
||||
}`;
|
||||
const errs = _shapeGateErrors(src, 'game-logic.js');
|
||||
assert.equal(hasShape(errs), false, '注释/字符串里的 API 名不应触发');
|
||||
});
|
||||
|
||||
test('零误杀:非插件键的同名 void 方法(自定义对象 reset)不误判', () => {
|
||||
// reset 是 session-score 的 void,但 myThing 不是插件键 → 不查(防止误伤自定义对象同名方法)
|
||||
const errs = _shapeGateErrors(wrap(`const r = myThing.reset();`), 'game-logic.js');
|
||||
assert.equal(hasShape(errs), false);
|
||||
});
|
||||
@ -164,6 +164,72 @@ function apiMethodSet(dir) {
|
||||
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*\([^\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;
|
||||
if (method === 'drawButton') {
|
||||
errs.push(`形状门 ${f}: drawButton(g,rect,opts) 返 void(只绘制、返 undefined)——别把它赋给变量再判命中(赋值后变量恒 undefined → 命中永远短路 → 卡菜单)。改:先定义按钮矩形 const btn={x,y,w,h};单独调 hudUi.drawButton(g,btn,{label}) 绘制;命中用 hudUi.pointInRect(x,y,btn) 或 collision.pointInRect(x,y,btn)`);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。
|
||||
* 1) node --check 各 src/*.js + entry.js(语法);
|
||||
@ -276,21 +342,12 @@ export function check(id) {
|
||||
}
|
||||
|
||||
// 5) 形状静态门(治"方法名对但参数形状/返回值幻觉、带病过 check"两类实测翻车):
|
||||
// ① drawButton 返 void——把它的返回值赋给变量再判命中 = 命中永远短路 → 卡菜单(base2 打地鼠实证)。
|
||||
// ② sceneFsm.define(name,handlers) 逐场景调——单对象批量 define({menu,play}) → 场景没真注册 → transition 被忽略 → 卡菜单(base3 2048 实证)。
|
||||
// 只查 agent 写的游戏文件;stripCode 已去注释/字符串避免误伤文档示例。正则刻意收窄(已对 base1/4/5 好游戏验不误伤)。
|
||||
// ① 返 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;
|
||||
const s = stripCode(readFileSync(abs, 'utf8'));
|
||||
// ① 赋值使用 drawButton 返回值(返 void):匹配「= … drawButton(」(含 const/let x = drawButton、x = hudUi.drawButton)。
|
||||
if (/=\s*[\w.]*\bdrawButton\s*\(/.test(s)) {
|
||||
errors.push(`形状门 ${f}: drawButton(g,rect,opts) 返 void(只绘制、返 undefined)——别把它赋给变量再判命中(赋值后变量恒 undefined → 命中永远短路 → 卡菜单)。改:先定义按钮矩形 const btn={x,y,w,h};单独调 hudUi.drawButton(g,btn,{label}) 绘制;命中用 hudUi.pointInRect(x,y,btn) 或 collision.pointInRect(x,y,btn)`);
|
||||
}
|
||||
// ② define 单对象批量(第一参应是场景名字符串):匹配「.define( {」(name 写成对象)。
|
||||
if (/\.define\s*\(\s*\{/.test(s)) {
|
||||
errors.push(`形状门 ${f}: sceneFsm.define(name, handlers) 第一参须是场景名【字符串】、逐场景各调一次——别 define({menu:{},play:{}}) 单对象批量(第一参变 [object Object]、场景全没注册 → transition 被忽略 → 卡菜单)。改:sceneFsm.define('menu',{...}); sceneFsm.define('play',{...}); sceneFsm.define('over',{...})`);
|
||||
}
|
||||
for (const e of _shapeGateErrors(readFileSync(abs, 'utf8'), f)) errors.push(e);
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user