fix(skin): 修复最终评审发现的 6 处缺陷(junction 排除失效/meta 静默丢失等)

跨任务视角发现的问题:Critical1 materialize.mjs 的 junction 排除比较在
Windows 上因长路径前缀恒假、从未生效,且测试夹具的 junction 指向真源自身,
掩盖了此问题;Critical2 composeSkin 例外通道在 uuid 缺失/框架侧无 meta 时
静默失败,违反第二准则;Important3 可覆盖范围误把 theme/ 下的框架 TS 代码
也纳入;Important5 cli-entry 测试漏注册 check-skin/build-game;Important6
composeSkin 与 check-skin 各自拼路径,未共用 paths.mjs 权威推导;Important7
--platform 缺值时静默落到默认平台。逐条修复并补测试,Critical1 用诱饵框架
目录验证过能真正杀掉该 bug(变异推演见 fix report)。

spec §5 step5 的 dist/<name>/ 拷贝按要求标注为本期未实现,不实现。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:15:28 +08:00
co-authored by Claude Opus 5
parent 88deab3533
commit 5708926711
12 changed files with 209 additions and 24 deletions
@@ -49,7 +49,15 @@ function put(dir, rel, content) {
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
function readBinary(dir, rel) { return readFileSync(join(dir, ...rel.split('/'))); }
/** 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境 */
/**
* 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境。
*
* junction **故意不指向 frameworkSrc 本身**,而是指向一个内容相似、但多一个
* 标记文件的「诱饵」目录(frameworkDecoy)。这样"实体化后的 framework 到底是
* 从 junction 解引用来的,还是从真源 frameworkSrc 单独拷的"才是可判定的——
* 若两者是同一目录,字节完全相同,Critical 1(junction 排除分支在 Windows 上
* 从未生效)这个 bug 永远不会被测出来。
*/
function scaffold() {
const root = mkdtempSync(join(tmpdir(), 'youle-mat-'));
const frameworkSrc = join(root, 'nexus', 'assets', 'framework');
@@ -59,15 +67,24 @@ function scaffold() {
put(frameworkSrc, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkSrc, 'core/net.ts', 'export const x=1;');
// 诱饵:junction 实际指向这里。内容与 frameworkSrc 一致,但多一个标记文件。
const frameworkDecoy = join(root, 'decoy', 'assets', 'framework');
mkdirSync(frameworkDecoy, { recursive: true });
put(frameworkDecoy, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
put(frameworkDecoy, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
put(frameworkDecoy, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkDecoy, 'core/net.ts', 'export const x=1;');
put(frameworkDecoy, 'ui/atlas-hall/__decoy_marker__.png', DEFAULT_BG); // 只存在于诱饵里
const gameDir = join(root, 'games', 'demo');
mkdirSync(join(gameDir, 'assets'), { recursive: true });
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: '3.8.8' } }));
symlinkSync(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE);
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
put(gameDir, 'assets/game/theme.ts', 'export const theme={};');
mkdirSync(join(gameDir, 'library'), { recursive: true }); // 缓存目录,应被跳过
writeFileSync(join(gameDir, 'library', 'junk'), 'x');
return { root, frameworkSrc, gameDir, dest: join(root, 'workspace', 'demo') };
return { root, frameworkSrc, frameworkDecoy, gameDir, dest: join(root, 'workspace', 'demo') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
@@ -100,6 +117,17 @@ test('materialize 后写临时工程不影响框架真源', () => {
} finally { cleanup(s.root); }
});
test('materialize 的 framework 来自真源 frameworkSrc,而非 junction 解引用(诱饵标记文件必须不存在)', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.ok(
!existsSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
'framework 必须单独从 frameworkSrc 真源拷贝;诱饵(junction 目标)独有的标记文件不应出现在工作区里',
);
} finally { cleanup(s.root); }
});
test('materialize 拒绝覆盖已存在的目标目录', () => {
const s = scaffold();
try {
@@ -135,6 +163,47 @@ test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧 .meta 没有 uuid 时显式抛错(不得写出没有 uuid 的 .meta)', () => {
const s = scaffold();
try {
// 框架 bg.png 本身没有 .meta;给它补一份没有 uuid 字段的 .meta,
// 模拟 fwUuid === undefined 的场景(JSON.stringify 会静默丢掉 undefined 的键)
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"ver":"1.0.0"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
// 框架侧补一份没有 uuid 的 .meta(模拟框架 meta 本身缺 uuid 的坏数据)
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'bg.png.meta'), '{"ver":"0.9.0"}');
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-missing-uuid');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /uuid/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧没有 .meta 时显式抛错(不得静默丢弃 override 的 meta)', () => {
const s = scaffold();
try {
// bg.png 在框架侧没有 .meta,但 override 给它带了一份 —— 之前的实现会静默跳过整段合并
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"uuid":"game-own-uuid"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-orphan');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /\.meta/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => {
const s = scaffold();
try {