feat(amodel-gen): check 门加模块内未定义标识符+import断链静态扫描(4例实证失败模式)
动机:便宜档 M3 生成的代码语法过 check、运行时才炸,九门才拦得住,浪费整局——
解谜 gen3(renderMenu 用未定义 cy → 卡菜单 E/G/H 连锁挂)/ gen6(stepUntilMs 未定义
uncaught)/ 非遗首败(import 断链模块级即炸);node --check 只查语法不查引用。
- 新增 check-undef.mjs:esbuild(树内现货 devDependency,build 步同款)当作用域分析器,
零新依赖、零手写 parser——
① 未定义标识符 = transformSync + define 探针(define 只替换模块内任何作用域都未声明的
自由变量;声明全形态 var/let/const/函数提升/类/参数/解构嵌套默认值/catch 参/import
绑定由 esbuild 生产级 parser 认定,实测 12 形态零误认);
② import 断链 = buildSync(bundle,write:false) 以 game-logic.js 为根整图静态解析
(named/default/re-export 缺 export 的 error、namespace 成员缺 export 的 warning
——后者正是既有 build 步按退出码拦不住的形态、模块文件不存在的 resolve error);
报错为生成 agent 可照修的中文(文件+行+标识符名+一句修法);esbuild 不可用(零依赖
环境)静默降级,check 其余门照跑。
- tools.mjs check() 挂第 6 节:生成侧五文件(game-logic/core/render/balance/assets)
逐模块扫 + 断链整图解析;输出协议(PASS/FAIL+明细)与退出码语义不变。
- 零误报口径:白名单取宽(ES 内建+浏览器宿主,"能不能用"归红线门、本门只管定义与否);
typeof 直用守卫名/双下划线名/上下文关键字名/中文标识符/动态下标列为已知漏报面,宁漏勿误。
- 单测 15 项全绿:三实证失败模式必红且文案含标识符名 + 隐式全局赋值必红 + 声明全形态/
typeof/globalThis/属性/对象键/label/字符串/注释零误报 + 8 现存正样本(6 模板+2 few-shot)
0 报固化为持续回归门 + 语法坏文件不崩不添噪。
- 验证:新测 15/15 + 既有 check.test 11/11 零回归 + 7 完整正样本 scaffold-saa+check 全
PASS(_template 仅既有 2.5 节设计内报错、新门零添报)+ 注入三类 bug 端到端 check CLI
必红 + pytest cheap-worker 回归门 6/6 + node --check 全过。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
db4aadd785
commit
8a566c9e0e
214
game-runtime/tools/amodel-gen/check-undef.mjs
Normal file
214
game-runtime/tools/amodel-gen/check-undef.mjs
Normal file
@ -0,0 +1,214 @@
|
||||
/**
|
||||
* check-undef.mjs — check 门第 6 节:模块内「未定义标识符」+「import 断链」静态扫描
|
||||
* owner:便宜档生成线 check 门 | 消费方:tools.mjs check()(第 6 节挂接)/ tests/check-undef.test.mjs
|
||||
*
|
||||
* 【动机(4 例实证,同一失败模式族:语法过 check、运行时才炸,九门才拦得住,浪费整局)】
|
||||
* - 解谜批产 gen3:renderMenu 用了未定义变量 cy → scene-fsm 隔离该回调 → startBtn 恒 null 卡菜单 → E/G/H 连锁挂;
|
||||
* - 解谜批产 gen6:stepUntilMs 未定义 uncaught;
|
||||
* - 非遗批产首败:import 断链(import 了对方没 export 的名字)→ 模块级即炸;
|
||||
* - 共性:模块内引用了任何作用域都没定义的标识符,node --check 只查语法不查引用,本扫描补这层。
|
||||
*
|
||||
* 【实现:esbuild(树内现货 devDependency,build 步同款)当作用域分析器——零新依赖、零手写 parser】
|
||||
* ① 未定义标识符 = transformSync + define 探针:esbuild 的 define 只替换「模块内任何作用域都未声明」的
|
||||
* 自由变量(生产级作用域分析:var/let/const/function 提升/class/参数/解构含默认值嵌套/catch 参/import 绑定
|
||||
* 全认作已声明;属性名/对象键/label/字符串/注释里的同名词不碰)。把候选词逐一 define 成独特探针名,
|
||||
* transform 输出里出现探针 = 该名以自由变量形态被真实引用 → 减全局白名单后即「未定义标识符」。
|
||||
* ② import 断链 = buildSync(bundle, write:false) 以 game-logic.js 为根静态解析整个 import 图,三形态全覆盖:
|
||||
* named/default/re-export 缺目标 export(error "No matching export")、namespace 成员缺 export
|
||||
* (warning id=import-is-undefined,esbuild 只给 warning、既有 build 步按退出码拦不住的正是它)、
|
||||
* import 目标模块文件不存在(error "Could not resolve",只认相对路径,包名解析失败属环境问题不报)。
|
||||
*
|
||||
* 【零误报红线】「是否自由变量」的判定完全交给 esbuild 生产级 parser;自制部分只有候选词提取
|
||||
* (over-approximate 无害:已声明词/属性名/关键字外的任何词 define 了也只在真自由引用处被替换)与
|
||||
* 全局白名单(取宽:ES 内建 + 浏览器宿主全集——bundle 跑在浏览器,"能不能用"归红线门管,本门只管"定义与否")。
|
||||
* 【已知漏报面(增量防线,宁缺勿滥)】typeof 直用守卫过的名字(typeof x!=='undefined' 是合法探测)、
|
||||
* 上下文关键字同形名(get/set/of/as/from/async/static/let…)、双下划线开头名(宿主注入约定位)、
|
||||
* 中文等非 ASCII 标识符、eval/动态下标(NS['x'])拼出的引用、esbuild 摇不到的死代码不影响判定(照报,引用仍是引用)。
|
||||
* 【降级】esbuild 不可用(lane 零依赖环境只跑 node --test、未装 node_modules)→ 两函数返回空数组静默跳过,
|
||||
* check 其余门照跑;生产生成路径 build 步硬依赖 esbuild,故生产上本扫描恒在。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
// esbuild 经 createRequire 同步加载(CJS 入口);失败(零依赖环境)→ 降级为不可用,不崩 tools.mjs 的 import 链。
|
||||
const _require = createRequire(import.meta.url);
|
||||
let _esbuild = null;
|
||||
try { _esbuild = _require('esbuild'); } catch { /* 零依赖环境:静默降级,undefScanAvailable=false */ }
|
||||
|
||||
/** 本扫描是否可用(esbuild 在树里);单测据此 skip,check() 挂接处无感(函数自身降级返回空)。 */
|
||||
export const undefScanAvailable = !!_esbuild;
|
||||
|
||||
/** 探针名:define 替换后回找。前后缀独特 + 尾部 `__` 收词边界(…probe_cy__ 不会误中 …probe_cy2__ 的子串)。 */
|
||||
const PROBE_PREFIX = '__amgen_undef_probe_';
|
||||
const probeOf = (name) => `${PROBE_PREFIX}${name}__`;
|
||||
|
||||
/**
|
||||
* 不做 define 探针的词:JS 关键字/保留字/字面量/特殊绑定(esbuild 对关键字 key 直接 throw,实测 'if' 即炸)
|
||||
* + 上下文关键字(let/static/get/set/of/as/from/async/await/yield 既是语法词又可作变量名——探它有炸 transform
|
||||
* 的风险,剔除 = 这些名字的未定义引用漏报,宁漏勿误)。
|
||||
*/
|
||||
const KEYWORDS = new Set([
|
||||
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import',
|
||||
'in', 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true',
|
||||
'try', 'typeof', 'var', 'void', 'while', 'with',
|
||||
// 上下文关键字/严格模式保留字/特殊绑定
|
||||
'let', 'static', 'yield', 'await', 'async', 'get', 'set', 'of', 'as', 'from', 'arguments',
|
||||
'implements', 'interface', 'package', 'private', 'protected', 'public',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 合法自由变量白名单(宽口径,宁漏勿误):游戏 bundle 跑在浏览器,凡「某个真实运行环境里有定义」的全局都放行
|
||||
* ——报它们"未定义"就是误报;至于 L3 该不该用(裸 setTimeout/DOM 等)是红线门(BANNED)的职责,本门只管定义与否。
|
||||
*/
|
||||
const GLOBAL_WHITELIST = new Set([
|
||||
// — ECMAScript 内建:构造器/命名空间 —
|
||||
'Object', 'Function', 'Boolean', 'Symbol', 'Number', 'BigInt', 'Math', 'Date', 'String', 'RegExp',
|
||||
'Array', 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array',
|
||||
'Uint32Array', 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array',
|
||||
'Map', 'Set', 'WeakMap', 'WeakSet', 'WeakRef', 'FinalizationRegistry',
|
||||
'ArrayBuffer', 'SharedArrayBuffer', 'DataView', 'Atomics', 'JSON', 'Promise', 'Reflect', 'Proxy', 'Intl',
|
||||
'Error', 'AggregateError', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError', 'URIError',
|
||||
// — ECMAScript 内建:全局函数/值 —
|
||||
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'decodeURI', 'decodeURIComponent', 'encodeURI',
|
||||
'encodeURIComponent', 'escape', 'unescape', 'eval', 'globalThis', 'undefined', 'NaN', 'Infinity',
|
||||
'structuredClone', 'queueMicrotask',
|
||||
// — 浏览器宿主(canvas 游戏面 + 常见全局;红线门管"能不能用",这里只保"有定义") —
|
||||
'console', 'window', 'self', 'document', 'navigator', 'performance', 'devicePixelRatio', 'screen',
|
||||
'location', 'history', 'localStorage', 'sessionStorage', 'indexedDB', 'crypto', 'btoa', 'atob',
|
||||
'innerWidth', 'innerHeight', 'requestAnimationFrame', 'cancelAnimationFrame',
|
||||
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'alert', 'confirm', 'prompt',
|
||||
'getComputedStyle', 'matchMedia', 'fetch', 'URL', 'URLSearchParams', 'AbortController', 'AbortSignal',
|
||||
'Headers', 'Request', 'Response', 'FormData', 'Blob', 'File', 'FileReader', 'TextEncoder', 'TextDecoder',
|
||||
'Event', 'EventTarget', 'CustomEvent', 'DOMParser', 'XMLHttpRequest', 'WebSocket', 'Worker',
|
||||
'ResizeObserver', 'IntersectionObserver', 'MutationObserver',
|
||||
'Image', 'ImageData', 'Path2D', 'OffscreenCanvas', 'HTMLCanvasElement', 'CanvasRenderingContext2D',
|
||||
'OffscreenCanvasRenderingContext2D', 'createImageBitmap', 'ImageBitmap',
|
||||
'Audio', 'AudioContext', 'webkitAudioContext', 'AudioBuffer', 'OscillatorNode', 'GainNode',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 保行号地抹掉注释与字符串字面量(与 tools.mjs 的 stripCode 不同:等长空白替换,行号不漂移)。
|
||||
* 只服务「报错行号定位」这一辅助信息;候选词提取与真判定都不依赖它(判定在 esbuild)。
|
||||
*/
|
||||
function stripKeepLines(s) {
|
||||
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
||||
return s
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank) // 块注释(等长空白,\n 保留)
|
||||
.replace(/\/\/[^\n]*/g, blank) // 行注释
|
||||
.replace(/'(?:[^'\\\n]|\\.)*'/g, blank) // 单引号串
|
||||
.replace(/"(?:[^"\\\n]|\\.)*"/g, blank) // 双引号串
|
||||
.replace(/`(?:[^`\\]|\\.)*`/g, blank); // 模板串(含跨行;插值一并抹掉,行号定位失之交臂可接受)
|
||||
}
|
||||
|
||||
/** 正则转义。 */
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** 在抹掉注释/字符串后的文本里找 name 首个"像引用"的行号(排除 .name 属性形态);找不到返回 0(文案不带行号)。 */
|
||||
function findFirstRefLine(strippedSrc, name) {
|
||||
const re = new RegExp(`(?<![.\\w$])${escapeRe(name)}\\b`);
|
||||
const lines = strippedSrc.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (re.test(lines[i])) return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描①:模块内未定义标识符。
|
||||
* @param {string} rawSrc 模块原始源码(候选词从原文提——注释/字符串里的词 define 了也不会被替换,over-approximate 无害;
|
||||
* 这样模板串插值 `${cy}` 里的真引用也能抓到)。
|
||||
* @param {string} fileName 报错文案用的文件名(如 'render.js')。
|
||||
* @returns {string[]} 中文错误清单(空 = 干净或本扫描不可用/该文件语法坏由第 1 节报)。
|
||||
*/
|
||||
export function scanUndefinedIdentifiers(rawSrc, fileName) {
|
||||
if (!_esbuild) return [];
|
||||
if (rawSrc.includes(PROBE_PREFIX)) return []; // 自指防御:源码撞探针前缀(概率≈0)则放弃本文件,防假命中
|
||||
// typeof 直用守卫的名字剔除(typeof x !== 'undefined' 是合法探测写法;从原文宽剔除,宁漏勿误)
|
||||
const typeofGuarded = new Set();
|
||||
for (const m of rawSrc.matchAll(/\btypeof\s+([A-Za-z_$][\w$]*)/g)) typeofGuarded.add(m[1]);
|
||||
// 候选词:原文全部 ASCII 标识符形态词,减关键字/白名单/typeof 守卫/双下划线开头(宿主注入约定位 + __proto__ 防污染)
|
||||
const candidates = new Set();
|
||||
for (const m of rawSrc.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
||||
const w = m[0];
|
||||
if (w.length > 80) continue; // 异常长词(压缩产物/base64 误入)不探
|
||||
if (KEYWORDS.has(w) || GLOBAL_WHITELIST.has(w) || typeofGuarded.has(w)) continue;
|
||||
if (w.startsWith('__')) continue;
|
||||
candidates.add(w);
|
||||
}
|
||||
if (!candidates.size) return [];
|
||||
const define = {};
|
||||
for (const w of candidates) define[w] = probeOf(w);
|
||||
let out;
|
||||
try {
|
||||
out = _esbuild.transformSync(rawSrc, { loader: 'js', format: 'esm', define, logLevel: 'silent' });
|
||||
} catch {
|
||||
return []; // 语法坏:第 1 节 node --check 已报,本节不重复不添噪
|
||||
}
|
||||
const stripped = stripKeepLines(rawSrc);
|
||||
const errs = [];
|
||||
for (const w of [...candidates].sort()) {
|
||||
if (!out.code.includes(probeOf(w))) continue; // 没被替换 = 模块内已声明(或只出现在属性/键/字符串位)
|
||||
const line = findFirstRefLine(stripped, w);
|
||||
errs.push(
|
||||
`未定义标识符 ${fileName}${line ? ` 第 ${line} 行` : ''}: 用了未定义的 ${w}——它不在本模块任何作用域` +
|
||||
`(也不是合法全局)。修法:用前先声明(const ${w} = …)或从函数参数取;` +
|
||||
`若它定义在别的模块,先 import { ${w} } from './那个模块.js';若是拼写错误,改成正确的名字`,
|
||||
);
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描②:import 断链(整图)。以 entryAbs(通常 src/game-logic.js)为根 bundle 到内存,静态解析产物目录内
|
||||
* 模块互引;不落盘、不影响真 build。根不可达的文件运行时也不会加载,不属"运行时才炸"族,不在本扫描面。
|
||||
* @param {string} entryAbs 入口模块绝对路径。
|
||||
* @param {string} gameDirAbs 游戏目录绝对路径(absWorkingDir:报错里的文件路径以它为基准,短且稳)。
|
||||
* @returns {string[]} 中文错误清单。
|
||||
*/
|
||||
export function scanImportChain(entryAbs, gameDirAbs) {
|
||||
if (!_esbuild) return [];
|
||||
const errs = [];
|
||||
/** 把 esbuild 的 error/warning 逐条翻译成生成 agent 能照着自修的中文;非断链类(语法等)静默——第 1 节已报。 */
|
||||
const collect = (items, isWarning) => {
|
||||
for (const it of items || []) {
|
||||
const text = it.text || '';
|
||||
const file = (it.location && it.location.file) || '';
|
||||
const line = it.location && it.location.line;
|
||||
const at = `${file}${line ? ` 第 ${line} 行` : ''}`;
|
||||
let m;
|
||||
if ((m = text.match(/^No matching export in "(.+?)" for import "(.+?)"$/))) {
|
||||
const [, target, name] = m;
|
||||
if (name === 'default') {
|
||||
errs.push(`import 断链 ${at}: 对 "${target}" 用了 default import(import X from …),但对方没有 export default——改成具名 import { 名字 } from '…'(名字必须是对方真 export 的),或给对方补 export default`);
|
||||
} else {
|
||||
errs.push(`import 断链 ${at}: import 的 "${name}" 在 "${target}" 的 export 里不存在——去 ${target} 确认真 export 的名字(或修拼写);只能 import 对方真正 export 的名字`);
|
||||
}
|
||||
} else if (!isWarning && (m = text.match(/^Could not resolve "(.+?)"$/))) {
|
||||
// 只认相对路径:包名/裸名解析失败属环境问题(如零依赖环境),报出来会误导 agent 改对的代码
|
||||
if (m[1].startsWith('.')) {
|
||||
errs.push(`import 断链 ${at}: import 的模块 "${m[1]}" 不存在(路径解析失败)——检查相对路径与文件名拼写;游戏源模块都在 src/ 同目录,应写成 ./core.js ./render.js ./assets.js 这样`);
|
||||
}
|
||||
} else if (isWarning && it.id === 'import-is-undefined'
|
||||
&& (m = text.match(/^Import "(.+?)" will always be undefined because there is no matching export in "(.+?)"$/))) {
|
||||
const [, name, target] = m;
|
||||
errs.push(`import 断链 ${at}: 访问的导入成员 "${name}" 在 "${target}" 的 export 里不存在(运行时恒 undefined,一调用就炸)——去 ${target} 确认真 export 的名字或补上该 export`);
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
const res = _esbuild.buildSync({
|
||||
entryPoints: [entryAbs], bundle: true, write: false, format: 'esm',
|
||||
absWorkingDir: gameDirAbs, logLevel: 'silent',
|
||||
});
|
||||
collect(res.warnings, true);
|
||||
} catch (e) {
|
||||
collect(e && e.errors, false);
|
||||
collect(e && e.warnings, true);
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
166
game-runtime/tools/amodel-gen/tests/check-undef.test.mjs
Normal file
166
game-runtime/tools/amodel-gen/tests/check-undef.test.mjs
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* check-undef.test.mjs — check 门「未定义标识符 + import 断链」静态扫描防回归单测
|
||||
* owner:便宜档生成线 check 门 | 跑法:node --test tools/amodel-gen/tests/check-undef.test.mjs(cwd 任意)
|
||||
*
|
||||
* 【守的不变量】
|
||||
* ① 三个实证失败模式必红,且文案含标识符名(生成 agent 能照着自修):
|
||||
* gen3 型(函数体内未定义变量 cy)/ gen6 型(顶层未定义 stepUntilMs)/ 非遗型(import 断链,三形态);
|
||||
* ② 零误报:声明全形态(var/let/const/函数提升/类/类表达式/参数/catch 参/解构含默认值嵌套/import 绑定)、
|
||||
* typeof 守卫、globalThis 显式访问、白名单全局、属性/对象键/label/字符串/注释里的同名词 → 一律不报;
|
||||
* ③ 全部现存正样本(8 目录:6 模板 + 2 few-shot)生成侧文件 0 报 —— 零误报红线的持续回归门;
|
||||
* ④ 语法坏文件本扫描返回空(不崩、不添噪;语法错归 check 第 1 节 node --check 报)。
|
||||
* esbuild 不可用(零依赖环境)→ 全部 skip(扫描函数自身同样降级为空,契约一致)。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanUndefinedIdentifiers, scanImportChain, undefScanAvailable } from '../check-undef.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = join(__dirname, 'fixtures', 'undef-scan');
|
||||
// tests → amodel-gen → tools → game-runtime
|
||||
const GAME_RUNTIME = resolve(__dirname, '../../..');
|
||||
const skip = !undefScanAvailable; // 零依赖环境(无 esbuild)整套 skip
|
||||
|
||||
/** 包一层贴近真实 game-logic.js 的骨架(export + 内部函数)。 */
|
||||
const wrap = (body) => `export function createGame({ plugins, bundle, viewport }){ ${body} return { _forensicsView(){}, handleTap(){} }; }`;
|
||||
|
||||
// ───────── ① 三个实证失败模式必红 ─────────
|
||||
|
||||
test('失败① gen3 型:函数体内用了未定义变量 cy → 必红,文案含 cy', { skip }, () => {
|
||||
const src = wrap(`function renderMenu(g){ g.arc(cx, cy, 24, 0, 6.28); }`);
|
||||
const errs = scanUndefinedIdentifiers(src, 'game-logic.js');
|
||||
assert.ok(errs.length >= 2, `应报 cx 与 cy 两个未定义,实际:${JSON.stringify(errs)}`);
|
||||
assert.ok(errs.some((e) => e.includes('未定义标识符') && e.includes(' cy——')), `文案应点名 cy:${JSON.stringify(errs)}`);
|
||||
assert.ok(errs.every((e) => e.includes('game-logic.js')), '文案应带文件名');
|
||||
});
|
||||
|
||||
test('失败② gen6 型:顶层调用未定义 stepUntilMs → 必红,文案含 stepUntilMs', { skip }, () => {
|
||||
const src = `export function runRound(){ stepUntilMs(1000); }`;
|
||||
const errs = scanUndefinedIdentifiers(src, 'core.js');
|
||||
assert.equal(errs.length, 1, `应恰报 1 条:${JSON.stringify(errs)}`);
|
||||
assert.ok(errs[0].includes('stepUntilMs'), '文案应点名 stepUntilMs');
|
||||
assert.ok(errs[0].includes('core.js'), '文案应带文件名');
|
||||
});
|
||||
|
||||
test('失败①扩:对未定义名赋值/自增(隐式全局)同样必红', { skip }, () => {
|
||||
for (const body of [`comboCnt = 0;`, `comboCnt++;`, `comboCnt += 1;`]) {
|
||||
const errs = scanUndefinedIdentifiers(wrap(body), 'game-logic.js');
|
||||
assert.ok(errs.some((e) => e.includes('comboCnt')), `应抓隐式全局:${body} → ${JSON.stringify(errs)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('失败③ 非遗型 a:具名 import 断链 → 必红,文案含名字与目标模块', { skip }, () => {
|
||||
const errs = scanImportChain(join(FIXTURES, 'broken-named.js'), FIXTURES);
|
||||
assert.equal(errs.length, 1, JSON.stringify(errs));
|
||||
assert.ok(errs[0].includes('import 断链') && errs[0].includes('spawnWave') && errs[0].includes('lib.js'), errs[0]);
|
||||
});
|
||||
|
||||
test('失败③ 非遗型 b:namespace 成员缺 export(esbuild 仅 warning、build 退出码拦不住)→ 必红', { skip }, () => {
|
||||
const errs = scanImportChain(join(FIXTURES, 'broken-ns.js'), FIXTURES);
|
||||
assert.equal(errs.length, 1, JSON.stringify(errs));
|
||||
assert.ok(errs[0].includes('import 断链') && errs[0].includes('missingMember'), errs[0]);
|
||||
});
|
||||
|
||||
test('失败③ 非遗型 c:import 的模块文件不存在 → 必红,文案含坏路径', { skip }, () => {
|
||||
const errs = scanImportChain(join(FIXTURES, 'broken-resolve.js'), FIXTURES);
|
||||
assert.equal(errs.length, 1, JSON.stringify(errs));
|
||||
assert.ok(errs[0].includes('import 断链') && errs[0].includes('./lib-typo.js'), errs[0]);
|
||||
});
|
||||
|
||||
test('合法链对照:import 对方真 export 的名字 → 0 报', { skip }, () => {
|
||||
assert.deepEqual(scanImportChain(join(FIXTURES, 'ok-entry.js'), FIXTURES), []);
|
||||
});
|
||||
|
||||
// ───────── ② 零误报:声明全形态与合法写法一律不报 ─────────
|
||||
|
||||
test('零误报:各种声明形态(var/let/const/函数提升/类/类表达式/参数/catch/解构默认值嵌套)', { skip }, () => {
|
||||
const cases = [
|
||||
`var a = 1; f(a); function f(x){ return x; }`,
|
||||
`let b = 1; const c = 2; console.log(b + c);`,
|
||||
`g(); function g(){ return 1; }`, // 函数提升(先用后声明)
|
||||
`class Foo {} const k = new Foo(); console.log(k);`,
|
||||
`const K = class Inner { m(){ return Inner; } }; new K().m();`, // 类表达式自名
|
||||
`const h = function self(){ return self; }; h();`, // 函数表达式自名
|
||||
`function p({ a = 1, b: { c = 2 } = {} } = {}, [d] = []){ return a + c + d; } p();`, // 解构含默认值嵌套
|
||||
`try { g2(); } catch (err) { console.log(err); } function g2(){}`, // catch 参
|
||||
`function q(){ if (1) { var hoisted = 1; } return hoisted; } q();`, // var 块内提升
|
||||
];
|
||||
for (const body of cases) {
|
||||
assert.deepEqual(scanUndefinedIdentifiers(wrap(body), 'game-logic.js'), [], `误报:${body}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('零误报:import 绑定(具名/别名/default/namespace)是已声明', { skip }, () => {
|
||||
const src = `import { spawnTarget, ROUND_MS as RM } from './core.js';\nimport loadAssets from './assets.js';\nimport * as R from './render.js';\nexport function createGame(){ spawnTarget(); loadAssets(); R.renderMenu(); return RM; }`;
|
||||
assert.deepEqual(scanUndefinedIdentifiers(src, 'game-logic.js'), []);
|
||||
});
|
||||
|
||||
test('零误报:typeof 守卫 / globalThis 显式访问 / 白名单全局', { skip }, () => {
|
||||
const cases = [
|
||||
`if (typeof maybeGlobal !== 'undefined') console.log(1);`, // typeof 直用是合法探测
|
||||
`const v = globalThis.someInjected; console.log(v);`, // 显式 globalThis 属性访问
|
||||
`const r = Math.max(1, JSON.stringify({}).length); console.log(r, Infinity, NaN, undefined);`,
|
||||
`const c2d = new OffscreenCanvas(1, 1); console.log(c2d, performance, devicePixelRatio);`,
|
||||
];
|
||||
for (const body of cases) {
|
||||
assert.deepEqual(scanUndefinedIdentifiers(wrap(body), 'game-logic.js'), [], `误报:${body}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('零误报:属性名/对象键/label/字符串/注释里的同名词不是引用', { skip }, () => {
|
||||
const src = `
|
||||
// 注释里的 cy 与 stepUntilMs(1) 不该触发
|
||||
/* x = cy 也不该 */
|
||||
export function createGame(){
|
||||
const o = { cy: 1 }; // 对象键
|
||||
const tip = 'cy = 2; stepUntilMs()'; // 字符串
|
||||
cy: for (let i = 0; i < 2; i++) { break cy; } // label
|
||||
function f(q){ return q.cy + o.cy; } // 属性访问
|
||||
return { _forensicsView(){}, handleTap(){ f(o); console.log(tip); } };
|
||||
}`;
|
||||
assert.deepEqual(scanUndefinedIdentifiers(src, 'game-logic.js'), []);
|
||||
});
|
||||
|
||||
test('零误报:模板串插值引用已声明变量;shorthand 引用已声明变量', { skip }, () => {
|
||||
const src = wrap(`const score = 3; const msg = \`得分 \${score}\`; const pack = { score }; console.log(msg, pack);`);
|
||||
assert.deepEqual(scanUndefinedIdentifiers(src, 'game-logic.js'), []);
|
||||
});
|
||||
|
||||
test('必红对照:模板串插值引用未定义变量 / shorthand 引用未定义变量(真引用不放过)', { skip }, () => {
|
||||
const e1 = scanUndefinedIdentifiers(wrap('const msg = `x=${ghostVar}`; console.log(msg);'), 'render.js');
|
||||
assert.ok(e1.some((e) => e.includes('ghostVar')), JSON.stringify(e1));
|
||||
const e2 = scanUndefinedIdentifiers(wrap(`const pack = { ghostShort }; console.log(pack);`), 'render.js');
|
||||
assert.ok(e2.some((e) => e.includes('ghostShort')), JSON.stringify(e2));
|
||||
});
|
||||
|
||||
test('语法坏文件:本扫描返回空(不崩;语法错归 check 第 1 节报)', { skip }, () => {
|
||||
assert.deepEqual(scanUndefinedIdentifiers(`function ( { 断掉`, 'game-logic.js'), []);
|
||||
});
|
||||
|
||||
// ───────── ③ 全部现存正样本 0 报(零误报红线持续回归) ─────────
|
||||
|
||||
test('正样本回归:8 目录(6 模板 + 2 few-shot)生成侧文件扫描 + import 图全 0 报', { skip }, () => {
|
||||
const dirs = ['_template', '_template-shop', '_template-story', '_template-trpg',
|
||||
'_template-feiyi', '_template-puzzle', '_fewshot-feiyi', '_fewshot-puzzle'];
|
||||
const GEN_FILES = ['game-logic.js', 'core.js', 'render.js', 'balance.js', 'assets.js'];
|
||||
let checked = 0;
|
||||
for (const d of dirs) {
|
||||
const dir = join(GAME_RUNTIME, 'games', d);
|
||||
if (!existsSync(dir)) continue; // 目录不在(裁剪环境)则跳过该目录,在的必须 0 报
|
||||
const errs = [];
|
||||
for (const f of GEN_FILES) {
|
||||
const abs = join(dir, 'src', f);
|
||||
if (!existsSync(abs)) continue;
|
||||
errs.push(...scanUndefinedIdentifiers(readFileSync(abs, 'utf8'), `${d}/src/${f}`));
|
||||
checked += 1;
|
||||
}
|
||||
const logic = join(dir, 'src', 'game-logic.js');
|
||||
if (existsSync(logic)) errs.push(...scanImportChain(logic, dir));
|
||||
assert.deepEqual(errs, [], `正样本 ${d} 出现误报(零误报红线):\n${errs.join('\n')}`);
|
||||
}
|
||||
assert.ok(checked >= 8, `正样本文件数异常(${checked}),games/ 目录是否完整?`);
|
||||
});
|
||||
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-named.js
vendored
Normal file
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-named.js
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/** 断链形态①(非遗批产首败同型):具名 import 了对方没 export 的名字 → 模块级即炸。 */
|
||||
import { spawnWave } from './lib.js';
|
||||
export function f() {
|
||||
return spawnWave();
|
||||
}
|
||||
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-ns.js
vendored
Normal file
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-ns.js
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/** 断链形态②:namespace 成员在对方 export 里不存在(esbuild 只给 warning、build 步退出码拦不住的正是它)。 */
|
||||
import * as core from './lib.js';
|
||||
export function f() {
|
||||
return core.missingMember();
|
||||
}
|
||||
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-resolve.js
vendored
Normal file
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-resolve.js
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/** 断链形态③:import 的模块文件不存在(相对路径拼写错)。 */
|
||||
import { real } from './lib-typo.js';
|
||||
export function f() {
|
||||
return real();
|
||||
}
|
||||
4
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/lib.js
vendored
Normal file
4
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/lib.js
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/** undef-scan 断链测试的被引模块:只 export real(具名),无 default。 */
|
||||
export function real() {
|
||||
return 1;
|
||||
}
|
||||
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/ok-entry.js
vendored
Normal file
5
game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/ok-entry.js
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/** 合法链对照:import 对方真 export 的名字 → 断链扫描必须 0 报。 */
|
||||
import { real } from './lib.js';
|
||||
export function f() {
|
||||
return real() + 1;
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
* - 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。
|
||||
* - check : 循环内快反馈 = node --check 各 src/*.js + 五法/导出名/红线静态 lint + 未定义标识符/import 断链扫描。
|
||||
* - build : esbuild(scripts/build.mjs 锁参)→ bundle;循环内快反馈。
|
||||
* 不给 agent 任意 shell。
|
||||
* B) 确定性 scaffold(克隆 _template → 本 run 目录)。
|
||||
@ -24,6 +24,7 @@ import {
|
||||
import { resolve, dirname, relative, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { scanUndefinedIdentifiers, scanImportChain } from './check-undef.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// tools/amodel-gen → game-runtime → repo 根
|
||||
@ -235,7 +236,9 @@ export function _shapeGateErrors(rawSrc, f) {
|
||||
* check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。
|
||||
* 1) node --check 各 src/*.js + entry.js(语法);
|
||||
* 2) 结构契约:game.js 默认导出 + update 内调 bundle.tick;host-config.js 导出 buildTemplateHostConfig;
|
||||
* 3) 红线:游戏源(game/core/render)零裸时间/随机/DOM。
|
||||
* 3) 红线:游戏源(game/core/render)零裸时间/随机/DOM;
|
||||
* 4) API-存在静态门;5) 形状静态门;
|
||||
* 6) 未定义标识符 + import 断链静态扫描(check-undef.mjs;node --check 只查语法不查引用,这层拦"运行时才炸")。
|
||||
* 返回 {ok, errors[]}。
|
||||
*/
|
||||
export function check(id) {
|
||||
@ -351,6 +354,21 @@ export function check(id) {
|
||||
for (const e of _shapeGateErrors(readFileSync(abs, 'utf8'), f)) 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 };
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user