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:
lili 2026-07-03 00:19:50 -07:00
parent db4aadd785
commit 8a566c9e0e
8 changed files with 424 additions and 2 deletions

View 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;
}

View 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/ 目录是否完整?`);
});

View File

@ -0,0 +1,5 @@
/** 断链形态①(非遗批产首败同型):具名 import 了对方没 export 的名字 → 模块级即炸。 */
import { spawnWave } from './lib.js';
export function f() {
return spawnWave();
}

View File

@ -0,0 +1,5 @@
/** 断链形态②:namespace 成员在对方 export 里不存在(esbuild 只给 warning、build 步退出码拦不住的正是它)。 */
import * as core from './lib.js';
export function f() {
return core.missingMember();
}

View File

@ -0,0 +1,5 @@
/** 断链形态③:import 的模块文件不存在(相对路径拼写错)。 */
import { real } from './lib-typo.js';
export function f() {
return real();
}

View File

@ -0,0 +1,4 @@
/** undef-scan 断链测试的被引模块:只 export real(具名),无 default。 */
export function real() {
return 1;
}

View File

@ -0,0 +1,5 @@
/** 合法链对照:import 对方真 export 的名字 → 断链扫描必须 0 报。 */
import { real } from './lib.js';
export function f() {
return real() + 1;
}

View File

@ -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 };
}