把项目从「手写 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 处引用自包含。
333 lines
16 KiB
TypeScript
333 lines
16 KiB
TypeScript
// 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("自包含性校验通过:每个分发目录都可以整体拷到别的机器上传。");
|
||
}
|