From b63fd1b8f71fe67e8a5b4d3f26527cb3d2dbfc6d Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 8 Jul 2026 02:19:56 -0700 Subject: [PATCH] =?UTF-8?q?fix(cheap-gen):=20=E6=AD=BB=E5=9C=88=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20fix=E2=91=A1-check=20=E7=BA=A2=E7=BA=BF=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E5=B8=A6=E7=A1=AE=E5=88=87=E8=A1=8C=E5=8F=B7=20+=20ed?= =?UTF-8?q?it=5Ffile=20=E8=A1=A5=E6=95=91=E6=8C=87=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:红线判定原只输出正则 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) --- .../amodel-gen/tests/check-banned.test.mjs | 104 ++++++++++++++++++ game-runtime/tools/amodel-gen/tools.mjs | 46 ++++++-- 2 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 game-runtime/tools/amodel-gen/tests/check-banned.test.mjs diff --git a/game-runtime/tools/amodel-gen/tests/check-banned.test.mjs b/game-runtime/tools/amodel-gen/tests/check-banned.test.mjs new file mode 100644 index 00000000..230c1ea7 --- /dev/null +++ b/game-runtime/tools/amodel-gen/tests/check-banned.test.mjs @@ -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)); +}); diff --git a/game-runtime/tools/amodel-gen/tools.mjs b/game-runtime/tools/amodel-gen/tools.mjs index 9a305d7c..c26f0544 100644 --- a/game-runtime/tools/amodel-gen/tools.mjs +++ b/game-runtime/tools/amodel-gen/tools.mjs @@ -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.<键>.() / ctx.() 必须在 api.d.ts 真存在。