diff --git a/game-runtime/tools/amodel-gen/check-undef.mjs b/game-runtime/tools/amodel-gen/check-undef.mjs new file mode 100644 index 00000000..3f4cc67a --- /dev/null +++ b/game-runtime/tools/amodel-gen/check-undef.mjs @@ -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(`(? 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; +} diff --git a/game-runtime/tools/amodel-gen/tests/check-undef.test.mjs b/game-runtime/tools/amodel-gen/tests/check-undef.test.mjs new file mode 100644 index 00000000..8323dbc2 --- /dev/null +++ b/game-runtime/tools/amodel-gen/tests/check-undef.test.mjs @@ -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/ 目录是否完整?`); +}); diff --git a/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-named.js b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-named.js new file mode 100644 index 00000000..10270a31 --- /dev/null +++ b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-named.js @@ -0,0 +1,5 @@ +/** 断链形态①(非遗批产首败同型):具名 import 了对方没 export 的名字 → 模块级即炸。 */ +import { spawnWave } from './lib.js'; +export function f() { + return spawnWave(); +} diff --git a/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-ns.js b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-ns.js new file mode 100644 index 00000000..b9f3b5fe --- /dev/null +++ b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-ns.js @@ -0,0 +1,5 @@ +/** 断链形态②:namespace 成员在对方 export 里不存在(esbuild 只给 warning、build 步退出码拦不住的正是它)。 */ +import * as core from './lib.js'; +export function f() { + return core.missingMember(); +} diff --git a/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-resolve.js b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-resolve.js new file mode 100644 index 00000000..d8792b6d --- /dev/null +++ b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/broken-resolve.js @@ -0,0 +1,5 @@ +/** 断链形态③:import 的模块文件不存在(相对路径拼写错)。 */ +import { real } from './lib-typo.js'; +export function f() { + return real(); +} diff --git a/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/lib.js b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/lib.js new file mode 100644 index 00000000..a2316db6 --- /dev/null +++ b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/lib.js @@ -0,0 +1,4 @@ +/** undef-scan 断链测试的被引模块:只 export real(具名),无 default。 */ +export function real() { + return 1; +} diff --git a/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/ok-entry.js b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/ok-entry.js new file mode 100644 index 00000000..47a8738b --- /dev/null +++ b/game-runtime/tools/amodel-gen/tests/fixtures/undef-scan/ok-entry.js @@ -0,0 +1,5 @@ +/** 合法链对照:import 对方真 export 的名字 → 断链扫描必须 0 报。 */ +import { real } from './lib.js'; +export function f() { + return real() + 1; +} diff --git a/game-runtime/tools/amodel-gen/tools.mjs b/game-runtime/tools/amodel-gen/tools.mjs index 15448bf9..9a305d7c 100644 --- a/game-runtime/tools/amodel-gen/tools.mjs +++ b/game-runtime/tools/amodel-gen/tools.mjs @@ -7,7 +7,7 @@ * - read_file : repo 根只读(agent 自读 skill / api.d.ts / catch-fruit 范例);拒绝逃出 repo 根;超大截断。 * - write_file : 仅许写本 run 的 game 目录 games/amgen-/;拒绝越界。 * - 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 }; }