Files
Shuery 3f11426964 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 处引用自包含。
2026-10-02 01:27:02 +08:00

333 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pnpm check:dist —— 分发目录的自我包含性门禁。
//
// A2 的要求是"分发目录应避免跨目录相对链接与绝对链接"(它能被整体拷到别的机器上传)。
// 这里把它变成硬门禁:扫描每个分发目录里的 HTML/CSS/JS,把每一处**静态可解析**的引用
// 解析出来,凡是落到分发目录之外的、或写成根绝对路径(/…)与 file:/// 的,一律 fail。
//
// 不做的事:不去猜 `asset()` 这类运行时推导(那是生成器的责任,生成器只吐相对路径)。
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { abs, exists, listDirs, log, readJson, walkFiles } from "./lib/fs.ts";
import { VaultError } from "./lib/vault.ts";
import type { DistMap } from "./lib/types.ts";
export interface CheckResult {
releases: number;
files: number;
references: number;
problems: string[];
warnings: string[];
}
/** 允许出现的非本地 URL 协议与内联数据。 */
const ALLOWED_PROTOCOL = /^(data:|blob:|mailto:|https?:\/\/|steam:|#)/i;
interface Reference {
/** 引用写下的原始文本。 */
raw: string;
/** 引用的文件(相对分发根)。 */
file: string;
/** 行号(1 起)。 */
line: number;
}
/** 从一份文本里抽出所有静态可解析的本地引用。 */
function extractReferences(file: string, text: string): Reference[] {
const out: Reference[] = [];
const push = (raw: string, line: number): void => {
if (!raw) return;
const normalized = raw.trim();
if (ALLOWED_PROTOCOL.test(normalized)) return;
out.push({ raw: normalized, file, line });
};
text.split(/\r?\n/).forEach((line, index) => {
const lineNo = index + 1;
const isCss = /\.css$/i.test(file);
// HTML/JS 里的字符串形态:src="/x"、href='y'、from "./z"、import("w")、fetch("v")
for (const match of line.matchAll(/(?:src|href|poster)\s*=\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
for (const match of line.matchAll(/(?:from|import|fetch)\s*\(?\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
// url(...) 只在 CSS 里是路径;JS 里 `url("${x}")` 是模板字符串,不是静态引用
if (isCss) {
for (const match of line.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) push(match[1] ?? "", lineNo);
for (const match of line.matchAll(/@import\s+["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
}
});
return out;
}
/** 解析一个引用:返回它相对分发根的路径,或一个错误原因。 */
function resolveReference(raw: string, file: string): { rel: string } | { error: string } {
if (raw.startsWith("file:///")) return { error: `绝对链接 file:///(分发目录必须可整体搬走)` };
if (raw.startsWith("//")) return { error: `协议相对链接 //(依赖宿主,跨机器行为不定)` };
if (raw.startsWith("/")) return { error: `根绝对路径 /(脱离分发根就没有意义)` };
const clean = raw.split(/[?#]/)[0] ?? "";
const parts = (file.includes("/") ? file.replace(/\/[^/]*$/, "/") : "") + clean;
const segments: string[] = [];
for (const segment of parts.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") {
if (segments.length === 0) return { error: `相对路径越出分发目录(${raw})` };
segments.pop();
continue;
}
segments.push(segment);
}
return { rel: segments.join("/") };
}
/** 从 preset.js 里抽出所有声明的音频落点(相对该 preset.js 所在目录)。 */
function extractAudioSources(text: string): { raw: string; line: number }[] {
const out: { raw: string; line: number }[] = [];
text.split(/\r?\n/).forEach((line, index) => {
for (const match of line.matchAll(/source:\s*asset\(\s*"([^"]*)"\s*\)/g)) {
out.push({ raw: match[1] ?? "", line: index + 1 });
}
});
return out;
}
/**
* 让 V8 真的解析一个产物模块,只求语法通过、不执行。
*
* 为什么非要有这一步:`presets.js` 与 `preset.js` 是**生成器拼出来的裸 JS 文本**,
* tsc 完全不看它们。生成器写出非法语法时,前面所有检查(引用扫描、路径存在性)都会通过,
* 直到浏览器里爆 SyntaxError 白屏——实测过一次真事故(数组字面量里写了计算属性名)。
*
* 用 vm.SourceTextModule 而不是 dynamic import:后者会**执行**模块,而壁纸脚本依赖 DOM。
* 用子进程是因为这个 API 需要 --experimental-vm-modules(V8 的解析器只暴露到这里)。
*/
async function checkModuleSyntax(files: { path: string; text: string }[]): Promise<string[]> {
const { spawnSync } = await import("node:child_process");
const script = `
const vm = require("node:vm");
const payload = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
const problems = [];
for (const item of payload) {
try {
new vm.SourceTextModule(item.text, { identifier: item.path });
} catch (error) {
problems.push(item.path + " " + String(error.message).split("\\n")[0]);
}
}
process.stdout.write(JSON.stringify(problems));
`;
const child = spawnSync(process.execPath, ["--experimental-vm-modules", "--no-warnings", "-e", script], {
input: JSON.stringify(files),
encoding: "utf8",
});
if (child.status !== 0) {
return [`无法运行语法解析子进程:${(child.stderr || "").trim().split("\n").slice(-3).join(" ")}`];
}
try {
return JSON.parse(child.stdout) as string[];
} catch {
return [`语法解析子进程输出无法解析:${child.stdout.slice(0, 200)}`];
}
}
export async function checkDist(options: { strict?: boolean; quiet?: boolean } = {}): Promise<CheckResult> {
const releasesDir = abs("dist/releases");
const mapPath = abs("dist/dist-map.json");
const result: CheckResult = { releases: 0, files: 0, references: 0, problems: [], warnings: [] };
if (!(await exists(mapPath))) {
result.problems.push("dist/dist-map.json 不存在(先跑 pnpm build)");
if (!options.quiet) report(result);
return result;
}
const map = await readJson<DistMap>(mapPath);
const modules: { path: string; text: string }[] = [];
for (const dir of await listDirs(releasesDir)) {
const root = join(releasesDir, dir);
if (!map.releases.some((r) => r.dir === dir)) {
result.problems.push(`dist/releases/${dir} 不在 dist-map.json 里(残留目录)`);
continue;
}
result.releases += 1;
const entry = map.releases.find((r) => r.dir === dir);
if (entry) {
// project.json 的静态字段必须指向目录内真实存在的文件
const project = await readJson<{ preview?: string; file?: string }>(join(root, "project.json"));
const fileField = project.file ?? "index.html";
if (!(await exists(join(root, fileField)))) result.problems.push(`${dir}/project.json 的 file 指向不存在的 ${fileField}`);
if (project.preview !== undefined && !(await exists(join(root, project.preview)))) {
result.problems.push(`${dir}/project.json 的 preview 指向不存在的 ${project.preview}`);
}
if (entry.defaultPresetId.length === 0) result.problems.push(`${dir}: dist-map 里没有 defaultPresetId`);
// 运行时脚本必须齐全(少了任何一个都会在 WE 里静默白屏)
for (const name of [
"scripts/index.js",
"scripts/presets.js",
"scripts/spine-player.js",
"scripts/preset-controller.js",
"scripts/background-controller.js",
"scripts/spine-controller.js",
"scripts/audio-controller.js",
"scripts/viewport-fitter.js",
"styles/index.css",
"styles/spine-player.css",
"index.html",
]) {
if (!(await exists(join(root, name)))) result.problems.push(`${dir}: 缺少 ${name}`);
}
// 调试服的热更新客户端**绝不能**进产物:它连的是 /__dev/events,那在真实 WE 里、
// 在任何静态托管上都不存在,只会白挨一次连接失败。这类"调试期专有代码混进发布产物"
// 靠人工抽查迟早会漏,所以钉在门禁上。
const indexText = await readFile(join(root, "index.html"), "utf8");
for (const marker of ["/__dev/events", "dev-reload-pill"]) {
if (indexText.includes(marker)) {
result.problems.push(`${dir}/index.html 里有调试服专属的 ${marker}`);
}
}
// 每档壁纸的 preset.js 必须与 dist-map 声明一致
for (const wallpaper of entry.wallpapers) {
const presetPath = wallpaper.dir ? `${wallpaper.dir}/preset.js` : "preset.js";
if (!(await exists(join(root, presetPath)))) result.problems.push(`${dir}: 缺少 ${presetPath}`);
}
const presetModules = (await walkFiles(root)).filter((f) => /(^|\/)preset\.js$/.test(f));
if (presetModules.length !== entry.wallpapers.length) {
result.problems.push(
`${dir}: preset.js 数量(${presetModules.length}) 与 dist-map 声明的壁纸数(${entry.wallpapers.length}) 不一致`,
);
}
// 语义校验:preset.js 里声明的每个音频落点都必须真实存在。
//
// 静态引用扫描(上面那圈)抓不到这类缺陷:音频走的是 `asset("../audios/x.flac")`,
// 参数是字面量,但 `asset` 的基准是 preset.js 自己的目录,只有按 preset.js 的位置解析才
// 知道对不对。合集分发把音频搬到分发根,前缀算错一层就会得到一个"语法正确、路径错误"的
// preset.js —— 它会一路通过编译、通过引用扫描,然后在 WE 里静音。
for (const presetRel of presetModules) {
const presetText = await readFile(join(root, presetRel), "utf8");
for (const audio of extractAudioSources(presetText)) {
if (audio.raw === "") continue; // 没有默认音源时是空串,跳过
const resolved = resolveReference(audio.raw, presetRel);
if ("error" in resolved) {
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频 ${resolved.error}`);
continue;
}
if (!(await exists(join(root, resolved.rel)))) {
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频引用不存在的 ${audio.raw}`);
}
}
}
// 单档分发不该出现共享音频目录(音频本来就只属于它一个)。
if (entry.type === "single" && (await exists(join(root, "audios", "_shared")))) {
result.problems.push(`${dir}: 单档分发里不该有 audios/_shared`);
}
// 合集类分发必须有合集根 audios/(bgm 要能选到任一壁纸的音源)——
// 但**整份分发一个音源都没有**时不该要求它:那只会逼出一个空的 audios/ 目录
// (比如纯场景壁纸、音源还没抓的页面)。判据是 preset.js 里有没有音频落点。
const hasAudio = (
await Promise.all(
presetModules.map(async (presetRel) => /[/\\]audios[/\\]/.test(await readFile(join(root, presetRel), "utf8"))),
)
).some(Boolean);
if (entry.type !== "single" && hasAudio && !(await exists(join(root, "audios")))) {
result.problems.push(`${dir}: 合集分发缺少合集根 audios/`);
}
// 自包含包(`pnpm build sim` 产出):它必须在 `file://` 下**双击就能开**,
// 所以这里查的是"页面上还有没有任何外部依赖"。
//
// 只在**真正的标签与赋值**上判定,不在整页文本上做正则——spine-player 是整份内联进来的,
// 它内部带着官网文档链接与一段编辑器示例模板(里面有 `<script src="https://…">` 的字符串)。
// 第一版对整页扫 `src=`/`href=`,于是把那些字符串当成真依赖,四个分发全报假阳性。
const simPage = join(root, "sim", "index.html");
if (await exists(simPage)) {
const simText = await readFile(simPage, "utf8");
const simProblems: string[] = [];
// 先剥掉内联脚本的**内容**,只在剩下的 HTML 骨架里找标签。
//
// 必须这么做:spine-player 是整份内联进来的,它内部带着一段编辑器示例模板,
// 里面有 `<script src="https://…">` 这样的字符串。对整页正则扫标签会把它们当成真依赖,
// 四个分发全报假阳性(实测过)。
//
// 关键细节:**只删标签之间的内容,保留开标签本身**。
// 第一版写成 `/<script[^>]*>[\s\S]*?<\/script>/` 整体替换,把开标签也一起删了,
// 于是所有 `<script src=…>` 都不再被检查——门禁看着在跑,实际只能查到 `<link>`。
// 是"注入一个外链 script 看它拦不拦得住"这个测试把这个漏洞暴露出来的。
const skeleton = simText.replace(
/<(script)\b([^>]*)>[\s\S]*?<\/script>/gi,
(_match, name: string, attrs: string) => `<${name}${attrs}></${name}>`,
);
const tags = skeleton.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
for (const tag of tags) {
if (/\btype\s*=\s*["']module["']/i.test(tag)) {
simProblems.push(`<script type="module">(file:// 下不会加载)`);
}
for (const match of tag.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
const raw = match[1] ?? "";
if (/^https?:\/\//i.test(raw)) simProblems.push(`标签引用了 http(s) 资源 ${raw}`);
else if (raw.startsWith("file:///")) simProblems.push(`标签引用了绝对路径 ${raw}`);
else if (raw.startsWith("/")) simProblems.push(`标签引用了根绝对路径 ${raw}`);
}
}
if (simProblems.length > 0) {
result.problems.push(`${dir}/sim/index.html: ${[...new Set(simProblems)].slice(0, 3).join(";")}`);
}
}
}
for (const rel of await walkFiles(root)) {
if (!/\.(html|css|js|mjs|json)$/i.test(rel)) continue;
result.files += 1;
const text = await readFile(join(root, rel), "utf8");
if (/\.(js|mjs)$/i.test(rel)) modules.push({ path: `${dir}/${rel}`, text });
for (const reference of extractReferences(rel, text)) {
result.references += 1;
const resolved = resolveReference(reference.raw, reference.file);
if ("error" in resolved) {
result.problems.push(`${dir}/${reference.file}:${reference.line} ${resolved.error}`);
continue;
}
if (!(await exists(join(root, resolved.rel)))) {
// 生成器只保证字面路径可解析;这里对"解析后不存在"的文件报错。
result.problems.push(`${dir}/${reference.file}:${reference.line} 引用不存在的 ${reference.raw}`);
}
}
}
}
// 语法门禁:所有产物 JS 必须能被 V8 解析成模块。
for (const problem of await checkModuleSyntax(modules)) result.problems.push(problem);
if (!options.quiet) report(result);
return result;
}
function report(result: CheckResult): void {
log(
`check:dist ${result.releases} 个分发 / ${result.files} 个文本文件 / ${result.references} 处引用 ` +
`${result.problems.length === 0 ? "✓ 自包含" : `✗ ${result.problems.length} 处问题`}`,
);
for (const problem of result.problems.slice(0, 30)) log(` ✗ ${problem}`);
if (result.problems.length > 30) log(` … 另有 ${result.problems.length - 30} 处`);
for (const warning of result.warnings) log(` ⚠ ${warning}`);
}
// 只在被当作入口直接运行时才自检——build.ts 会 import 它,那时不能执行这里的退出逻辑。
if (process.argv[1] !== undefined && resolve(process.argv[1]) === abs("tools/check-dist.ts")) {
const strict = process.argv.includes("--strict");
const result = await checkDist({ strict });
if (result.problems.length > 0) {
log("");
throw new VaultError(`自包含性校验未通过(${result.problems.length} 处)`);
}
log("");
log("自包含性校验通过:每个分发目录都可以整体拷到别的机器上传。");
}