feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源

把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
// export 改写的**单元测试**:直接调用 tools/lib/bundle.ts 里真正在用的那个函数。
//
// 六个坑,每个都对应一次真实事故,所以每个都有一条断言:
// ① 字符串替换里的 `$1exports.` 被解析成捕获组 "1e" → "exports." 整段消失
// ② 两条规则互相截胡(`export function` 先被吃成 `exports.function`,function 留在原地)
// ③ V8 把声明关键字吞掉 → `exports.foo(a) {`
// ④ 嵌套可选组让捕获位置漂移 → `exports.function resolveViewport(...)`
// ⑤ `export default {`(对象字面量)没有匹配到任何规则 → `exports.default {`
// ⑥ **改写毁掉本地绑定**:`export const X = 1` 直接变成 `exports.X = 1`,
// 于是同模块里别处引用 `X` 变成 ReferenceError。真事故:`frameForAspect` 的
// 默认参数 `referenceAspect = REFERENCE_ASPECT` 直到渲染第一帧才求值——
// 加载、尺寸、资源全部正常,只有画面是白的。所以现在一律保留声明原文,
// 导出推迟到模块体末尾。下面有专门一条断言盯着"声明必须还在"。
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { transformModuleForTest } from "../lib/bundle.ts";
function body(code: string) {
// 去掉包装体,只留模块体,方便断言
const m = /^__weModules\["__test__"\] = \(function \(\) \{\n const exports = \{\};\n([\s\S]*)\n return exports;\n\}\)\(\);\n$/.exec(code);
if (!m) throw new Error("包装体结构变了,测试需要同步更新");
return (m[1] ?? "")
.split("\n")
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
.join("\n");
}
function parses(code) {
const child = spawnSync(
process.execPath,
["--experimental-vm-modules", "--no-warnings", "-e", 'new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));'],
{ input: code, encoding: "utf8" },
);
return child.status === 0 ? null : (child.stderr || "").split("\n").find((l) => /SyntaxError|ReferenceError/.test(l)) ?? "解析失败";
}
const cases: [string, string][] = [
// [输入, 期望输出(精确,含空格)]
// 声明原文必须**逐字保留**,导出在末尾统一赋值——见文件头 ⑥。
["export default class C {}", "class C {}\nexports.default = C;"],
["export default Presets;", "exports.default = Presets;"],
["export default {", "exports.default = {"],
["export default function f(a) {}", "function f(a) {}\nexports.default = f;"],
["export default async function f(a) {}", "async function f(a) {}\nexports.default = f;"],
["export const x = 1;", "const x = 1;\nexports.x = x;"],
["export let y = 2;", "let y = 2;\nexports.y = y;"],
["export function resolveViewport(v) {}", "function resolveViewport(v) {}\nexports.resolveViewport = resolveViewport;"],
["export async function load(u) {}", "async function load(u) {}\nexports.load = load;"],
["export class PresetController {}", "class PresetController {}\nexports.PresetController = PresetController;"],
// 含 "exports" 的属性访问绝不能被误改
["class A {\n exports2 = 1;\n}", "class A {\n exports2 = 1;\n}"],
["obj.exports = 1;\nobj.exports.foo = 2;", "obj.exports = 1;\nobj.exports.foo = 2;"],
["export const a = 1;\nexport const b = 2;\n", "const a = 1;\nconst b = 2;\n\nexports.a = a;\nexports.b = b;"],
['export const defaultPresetId = "kv37";', 'const defaultPresetId = "kv37";\nexports.defaultPresetId = defaultPresetId;'],
];
let failed = 0;
const report = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failed++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? `\n ${detail}` : ""}`);
};
console.log("=== 单元断言(调真实实现) ===");
for (const [input, expected] of cases) {
const actual = body(transformModuleForTest(input));
report(
actual === expected,
JSON.stringify(input),
actual === expected ? undefined : `得到 ${JSON.stringify(actual)}\n 期望 ${JSON.stringify(expected)}`,
);
}
// ⑥ 的专用断言:声明必须原样活着。这条比"导出键对得上"更根本——
// 导出键对了、本地绑定没了,症状是运行到某一帧才炸,最难查。
console.log("\n=== 本地绑定必须活着(⑥) ===");
const bindingCases: [string, string][] = [
["export const X = 16 / 9;\nfunction f(a = X) { return a; }", "X"],
["export function f() {}\nfunction g() { return f(); }", "f"],
["export class C {}\nnew C();", "C"],
];
for (const [input, name] of bindingCases) {
const actual = body(transformModuleForTest(input));
// 声明本身要在(`const X =` / `function f(` / `class C `),不能只剩 exports.X = X
const declared = new RegExp(`(?:^|\\n)(?:const|let|var|async function|function|class) ${name}\\b`).test(actual);
report(declared, `${JSON.stringify(input)} 里 ${name} 的声明还在`, declared ? undefined : `实际 ${JSON.stringify(actual)}`);
}
// 声明与赋值都在的话,真跑一遍也不能抛 ReferenceError
console.log("\n=== 改写后可执行(不是只可解析) ===");
const runnable = [
"export const REFERENCE_ASPECT = 16 / 9;\nexport function frameForAspect(a = REFERENCE_ASPECT) { return a; }",
"export function helper() { return 42; }\nexport const value = helper();",
"export class Base {}\nexport const instance = new Base();",
];
for (const input of runnable) {
const code = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(input)}\n`;
const child = spawnSync(process.execPath, ["--input-type=module", "--no-warnings", "-e", code], { encoding: "utf8" });
const ok = child.status === 0;
report(ok, `${JSON.stringify(input.slice(0, 46))}… 执行不抛错`, ok ? undefined : (child.stderr || "").split("\n").slice(0, 3).join(" | "));
}
console.log("\n=== 真实产物:改写后必须可解析 ===");
const files = [ "build/scripts/index.js",
"build/scripts/viewport-fitter.js",
"build/scripts/preset-controller.js",
"build/scripts/wallpaper-engine.js",
"dist/releases/single-hsr-kv37/scripts/presets.js",
// 生成的 preset.js 里是 `export default {` 对象字面量——最容易漏的一类
"dist/releases/single-hsr-kv37/preset.js",
];
for (const file of files) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
report(false, `${file} 不存在`);
continue;
}
const wrapped = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(source)}\n`;
const problem = parses(wrapped);
report(problem === null, `${file} 改写后可解析`, problem ?? undefined);
}
// 关键:改写后 exports 的键必须与源码里的默认导出/具名导出对得上
console.log("\n=== 语义断言:导出的键不能丢 ===");
const semantic: [string, string[]][] = [
["export default { id: 1 };", ["default"]],
["export default Presets;", ["default"]],
["export default class C {}", ["default"]],
["export default function f() {}", ["default"]],
["export const a = 1;\nexport function b() {}", ["a", "b"]],
];
for (const [input, keys] of semantic) {
const actual = body(transformModuleForTest(input));
const missing = keys.filter((k) => !new RegExp(`^exports\\.${k}\\b`, "m").test(actual));
report(missing.length === 0, `${JSON.stringify(input)} 导出 ${JSON.stringify(keys)}`, missing.length ? `缺 ${JSON.stringify(missing)},实际 ${JSON.stringify(actual)}` : undefined);
}
console.log(failed === 0 ? "\nexport 改写:全部通过" : `\nexport 改写:${failed} 处失败`);
process.exit(failed === 0 ? 0 : 1);