fix(cheap-gen): 死圈修复 fix②-check 红线报错带确切行号 + edit_file 补救指向
问题:红线判定原只输出正则 source(如"禁用 \bMath\.random\("),agent 拿不到
行号、要靠整文件重写去猜改哪行;且旧 stripCode 把跨行块注释塌成单空格,若据
stripped 串算行号会漂。这正是死圈触发面之一——报错越模糊,越逼 agent 整文件重写。
改动:
· stripCode 改为"内容抹成等量空格、保留换行":结果串与原文行号一一对应,
长度不变且不误匹配(空格非 \w/`.`),既有 5 处调用点(形状门/API 门/结构门)语义不变。
· 抽出可测的 _bannedLineErrors(rawSrc, f):整串正则做权威判定(不漏 \s* 跨行写法),
逐行定位命中行号(1-based、最多列 10 处),报错带"红线 X 第 N 行 + 用 edit_file
改回 ctx 形态、别整文件重写";函数内回退/typeof 守卫后的裸调、任意嵌套照样命中。
· check 第3节红线块改调 _bannedLineErrors(同 _shapeGateErrors 抽法)。
测:新增 check-banned.test.mjs 7 测(行号正确/跨行块注释不漂/多处逐行/typeof 守卫
后仍命中/注释字符串零误杀/合规零报)+ 回归 check.test.mjs 8 形状测 + check-undef,
33 pass 0 fail 0 skip,exit=0(stripCode 改动未破坏既有门)。
待续(本计划剩项):fix③ brief 落 evidence、fix①-B 共享 tier2 熔断窄口径纠偏、n=5 收敛环。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
596ea13666
commit
b63fd1b8f7
104
game-runtime/tools/amodel-gen/tests/check-banned.test.mjs
Normal file
104
game-runtime/tools/amodel-gen/tests/check-banned.test.mjs
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* check-banned.test.mjs — amodel-gen 红线判定 _bannedLineErrors() 单测(死圈修复 2026-07-08 fix②-check)
|
||||
* owner:便宜档生成质量线 | 跑法:node --test tools/amodel-gen/tests/check-banned.test.mjs(cwd 任意)
|
||||
*
|
||||
* 【守的不变量】
|
||||
* ① 裸时间/随机/DOM(Math.random/Date.now/performance.now/setTimeout/setInterval/rAF/AudioContext/addEventListener)命中即报;
|
||||
* ② 报错带【确切行号】——跨行块注释在前也不漂(旧 stripCode 把块注释塌成单空格会致行号漂,本测正是回归它);
|
||||
* ③ 函数内的回退裸调、typeof 守卫后的裸调、任意嵌套层同样命中(正则按纯文本扫);
|
||||
* ④ 零误杀:注释 / 字符串里的 API 名(经 stripCode)不触发;合规 ctx 写法不报。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { _bannedLineErrors } from '../tools.mjs';
|
||||
|
||||
/** 是否报了某红线(按正则 source 关键片段匹配)。 */
|
||||
const hasBanned = (errs, frag) => errs.some((e) => e.includes(frag));
|
||||
|
||||
// ───────── ① 基本命中 + 行号 ─────────
|
||||
|
||||
test('① Math.random 命中并报出确切行号', () => {
|
||||
const src = `export function createGame(){\n function rng(){ return Math.random(); }\n return { _forensicsView(){}, handleTap(){} };\n}`;
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.equal(errs.length, 1, JSON.stringify(errs));
|
||||
assert.ok(errs[0].includes('第 2 行'), `应报第 2 行:${errs[0]}`);
|
||||
assert.ok(errs[0].includes('Math\\.random'), '应含被禁正则');
|
||||
assert.ok(errs[0].includes('edit_file'), '应给 edit_file 补救指向');
|
||||
});
|
||||
|
||||
test('① Date.now / setTimeout / addEventListener 各自命中', () => {
|
||||
const cases = [
|
||||
[`const t = Date.now();`, 'Date\\.now'],
|
||||
[`setTimeout(() => {}, 100);`, 'setTimeout'],
|
||||
[`window.addEventListener('click', h);`, 'addEventListener'],
|
||||
[`const c = new AudioContext();`, 'AudioContext'],
|
||||
];
|
||||
for (const [line, frag] of cases) {
|
||||
const errs = _bannedLineErrors(`export function createGame(){\n ${line}\n}`, 'render.js');
|
||||
assert.ok(hasBanned(errs, frag), `应命中 ${frag}:${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ───────── ② 行号不因跨行块注释而漂(回归旧 stripCode 塌行 bug) ─────────
|
||||
|
||||
test('② 跨行块注释在违规行之前 → 行号不漂', () => {
|
||||
// 块注释占第 1-3 行(旧实现塌成单空格会让后续行号整体上移);Math.random 在第 5 行。
|
||||
const src = [
|
||||
'/* 多行', // 1
|
||||
' 块', // 2
|
||||
' 注释 */', // 3
|
||||
'export function createGame(){', // 4
|
||||
' const r = Math.random();', // 5
|
||||
' return { _forensicsView(){}, handleTap(){} };', // 6
|
||||
'}', // 7
|
||||
].join('\n');
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.equal(errs.length, 1, JSON.stringify(errs));
|
||||
assert.ok(errs[0].includes('第 5 行'), `应报第 5 行(不漂):${errs[0]}`);
|
||||
});
|
||||
|
||||
test('② 多处命中 → 逐行列出', () => {
|
||||
const src = [
|
||||
'export function createGame(){', // 1
|
||||
' const a = Math.random();', // 2
|
||||
' const b = 1;', // 3
|
||||
' const c = Math.random();', // 4
|
||||
' return {};', // 5
|
||||
'}',
|
||||
].join('\n');
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.equal(errs.length, 1, '同一红线合并为一条');
|
||||
assert.ok(errs[0].includes('第 2/4 行'), `应列 2/4:${errs[0]}`);
|
||||
});
|
||||
|
||||
// ───────── ③ 函数内 / typeof 守卫后的回退裸调同样命中 ─────────
|
||||
|
||||
test('③ typeof 守卫后的回退裸调仍命中(别靠守卫绕)', () => {
|
||||
const src = `export function createGame(){\n function rng(){ return (typeof ctx !== 'undefined' && ctx.random) ? ctx.random.next() : Math.random(); }\n}`;
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.ok(hasBanned(errs, 'Math\\.random'), '守卫后的回退裸调应命中');
|
||||
assert.ok(errs[0].includes('第 2 行'), errs[0]);
|
||||
});
|
||||
|
||||
// ───────── ④ 零误杀 ─────────
|
||||
|
||||
test('④ 注释 / 字符串里的 API 名不触发', () => {
|
||||
const src = [
|
||||
'// 别写 Math.random(),要用 ctx.random.next()', // 行注释
|
||||
'/* 反例:const t = Date.now() 也禁 */', // 块注释
|
||||
'export function createGame(){',
|
||||
" const tip = '禁用 setTimeout(fn, 100)';", // 字符串
|
||||
' const r = ctx.random.next();', // 合规
|
||||
' return { _forensicsView(){}, handleTap(){} };',
|
||||
'}',
|
||||
].join('\n');
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.equal(errs.length, 0, `注释/字符串里的 API 名不应触发:${JSON.stringify(errs)}`);
|
||||
});
|
||||
|
||||
test('④ 合规 ctx 写法零报', () => {
|
||||
const src = `export function createGame(){\n const r = ctx.random.next();\n const t = ctx.time.nowMs();\n return { _forensicsView(){}, handleTap(){} };\n}`;
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.equal(errs.length, 0, JSON.stringify(errs));
|
||||
});
|
||||
@ -133,14 +133,17 @@ const BANNED = [
|
||||
/\bnew\s+AudioContext\b/, /\baddEventListener\s*\(/,
|
||||
];
|
||||
|
||||
/** 去注释 + 字符串字面量,避免红线/结构正则误伤文档注释里的示例(如注释里写「禁用 Math.random()」)。 */
|
||||
/** 去注释 + 字符串字面量,避免红线/结构正则误伤文档注释里的示例(如注释里写「禁用 Math.random()」)。
|
||||
* 抹除时把内容换成【等量空格、保留换行】——结果串与原文行号一一对应(红线报错据此定位到行),
|
||||
* 且长度不变、不误匹配(空格非 \w、非 `.`,既有的 matchAll/结构正则语义不变)。 */
|
||||
function stripCode(s) {
|
||||
const blank = (m) => m.replace(/[^\n]/g, ' '); // 非换行→空格、换行留
|
||||
return s
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释
|
||||
.replace(/\/\/[^\n]*/g, ' ') // 行注释
|
||||
.replace(/'(?:[^'\\]|\\.)*'/g, "''") // 单引号串
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""') // 双引号串
|
||||
.replace(/`(?:[^`\\]|\\.)*`/g, '``'); // 模板串
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank) // 块注释(可跨行)
|
||||
.replace(/\/\/[^\n]*/g, blank) // 行注释
|
||||
.replace(/'(?:[^'\\]|\\.)*'/g, blank) // 单引号串
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, blank) // 双引号串
|
||||
.replace(/`(?:[^`\\]|\\.)*`/g, blank); // 模板串(可跨行)
|
||||
}
|
||||
|
||||
/** 插件键 → api.d.ts 目录(host-config 注入的 canonical 键名;特殊:juice/save/palettePost/physics 名≠dir)。 */
|
||||
@ -232,6 +235,30 @@ export function _shapeGateErrors(rawSrc, f) {
|
||||
return errs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 红线判定(从 check 第3节抽出,便于单测):游戏源零裸时间/随机/DOM(时间随机经 boot.ctx、定时经 timer-scheduler)。
|
||||
* 判定用整串正则(不漏 `\s*` 跨行的极端写法),行号用逐行定位——保行号的 stripCode 使「命中行 = 原文行」。
|
||||
* 报错带【确切行号】+【edit_file 补救指向】:治死圈——agent 据此一行 edit 改回 ctx 形态,不必整文件重写(见 [[cheap-gen-deadloop-fix]] fix②)。
|
||||
* @param {string} rawSrc 原始源码 @param {string} f 文件名(报错用) @returns {string[]} 红线 errors
|
||||
*/
|
||||
export function _bannedLineErrors(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; // 权威判定:整串命中才报(保持原检测强度,不漏跨行写法)
|
||||
// 逐行定位命中行号(1-based,最多列 10 处);`\s*` 跨行的罕见写法逐行测不到 → 兜底不带行号,但绝不漏报。
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。
|
||||
* 1) node --check 各 src/*.js + entry.js(语法);
|
||||
@ -314,14 +341,11 @@ export function check(id) {
|
||||
}
|
||||
} catch { /* 模板缺失则跳过 */ }
|
||||
|
||||
// 3) 红线(只查 agent 写的游戏文件;game.js/host-config 固定不查)
|
||||
// 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;
|
||||
const s = stripCode(readFileSync(abs, 'utf8'));
|
||||
for (const re of BANNED) {
|
||||
if (re.test(s)) errors.push(`红线 ${f}: 禁用 ${re.source}(时间/随机经 boot.ctx,定时经 timer-scheduler,输入经实例方法 handleTap/handleKey 由 L1 派发)`);
|
||||
}
|
||||
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 真存在。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user