Files
Find-Word/scripts/check-build.mjs
T
2026-10-14 10:01:46 +00:00

172 lines
7.1 KiB
JavaScript
Raw 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.
import { readFileSync, readdirSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { ENVIRONMENTS, PATTERNS, WORDS } from "../src/content.ts";
// 빌드가 성공해도 public 파일이 빠지면 게임은 실행 중 실패한다. 따라서 배포
// 폴더 자체의 파일 해시를 원본 manifest와 비교하는 계약을 빌드 마지막에 둔다.
export function verifyBuild(directory) {
// 번들에 실제 포함되는 전이 의존성도 잠금 파일에서 찾는다. 프로젝트의
// 비공개 라이선스와 별개로 설치된 MIT 전문·버전을 사본 세 곳에서 보존한다.
try {
const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8");
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
for (const [path, pkg] of Object.entries(lock.packages)) {
if (!path || pkg.dev) continue;
const name = path.split("node_modules/").at(-1);
const license = readFileSync(`${path}/LICENSE`, "utf8").trim();
if (!notice.includes(`## ${name} ${pkg.version}\n\n${license}`))
throw new Error(`${name} ${pkg.version}의 전문이 없습니다.`);
}
for (const path of [
"public/THIRD_PARTY_NOTICES.md",
resolve(directory, "THIRD_PARTY_NOTICES.md"),
]) {
if (readFileSync(path, "utf8") !== notice)
throw new Error(`${path}가 원본과 다릅니다.`);
}
} catch (cause) {
throw new Error("런타임 의존성 notice 누락 또는 불일치", { cause });
}
const manifest = JSON.parse(
readFileSync("public/audio/manifest.json", "utf8"),
);
const builtManifest = JSON.parse(
readFileSync(resolve(directory, "audio/manifest.json"), "utf8"),
);
if (JSON.stringify(manifest) !== JSON.stringify(builtManifest))
throw new Error("빌드 음성 목록이 원본과 다릅니다.");
// manifest끼리 일치해도 함께 축약될 수 있으므로 앱 콘텐츠에서 독립 재구성한다.
const expectedVoice = [
"intro.wav",
...PATTERNS.flatMap((pattern) =>
WORDS.map((word) => `${pattern.id}-${word.id}.wav`),
),
].sort();
if (
expectedVoice.length !== 101 ||
manifest.files.length !== 101 ||
JSON.stringify(manifest.files.map((entry) => entry.file).sort()) !==
JSON.stringify(expectedVoice)
)
throw new Error("음성은 정확한 예상 목록 101개가 필요합니다.");
if (
JSON.stringify(readdirSync(resolve(directory, "audio")).sort()) !==
JSON.stringify([...expectedVoice, "manifest.json"].sort())
)
throw new Error("빌드 음성 파일 누락 또는 추가 파일");
for (const entry of manifest.files) {
const bytes = readFileSync(resolve(directory, "audio", entry.file));
if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256)
throw new Error(`빌드 자산 해시 불일치: ${entry.file}`);
}
// 현장음은 별도 라이선스가 있는 독립 자산이다. 누락뿐 아니라 큰 원본이
// 잘못 섞여 배포되는 경우도 디렉터리 파일 집합을 엄격히 비교해 차단한다.
try {
const source = readFileSync("public/ambience/manifest.json", "utf8");
if (
readFileSync(resolve(directory, "ambience/manifest.json"), "utf8") !==
source
)
throw new Error("manifest 불일치");
const ambience = JSON.parse(source);
const plan = JSON.parse(
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
);
if (
JSON.stringify(
ambience.files.map((entry) => entry.environmentId).sort(),
) !== JSON.stringify(ENVIRONMENTS.map((entry) => entry.id).sort())
)
throw new Error("현장음 예상 환경 목록 불일치");
if (
ambience.files.length !== 8 ||
new Set(ambience.files.map((e) => e.sha256)).size !== 8
)
throw new Error("고유 녹음 8개가 필요합니다.");
const expected = [
...ambience.files.map((e) => e.file),
"manifest.json",
"AMBIENCE_NOTICES.md",
].sort();
if (
JSON.stringify(readdirSync(resolve(directory, "ambience")).sort()) !==
JSON.stringify(expected)
)
throw new Error("누락 또는 원본 파일 혼입");
const notice = readFileSync("public/ambience/AMBIENCE_NOTICES.md", "utf8");
if (
readFileSync(
resolve(directory, "ambience/AMBIENCE_NOTICES.md"),
"utf8",
) !== notice
)
throw new Error("고지 불일치");
for (const entry of ambience.files) {
const bytes = readFileSync(resolve(directory, "ambience", entry.file));
if (
bytes.length !== entry.bytes ||
createHash("sha256").update(bytes).digest("hex") !== entry.sha256
)
throw new Error(`자산 해시 불일치: ${entry.file}`);
const approved = plan.assets.find(
(asset) => asset.environmentId === entry.environmentId,
);
if (
!approved ||
Object.entries(approved).some(
([key, value]) =>
JSON.stringify(entry[key]) !== JSON.stringify(value),
) ||
entry.file !== `${entry.environmentId}.wav` ||
entry.sourceSha256 !== approved.expectedSourceSha256 ||
entry.outputLicense !== approved.license ||
!Number.isFinite(approved.selectedSegmentStartSeconds) ||
approved.selectedSegmentStartSeconds < 0 ||
!Number.isFinite(approved.directMediaDurationSeconds) ||
approved.selectedSegmentStartSeconds + 12 >
approved.directMediaDurationSeconds ||
entry.conversion?.startSeconds !==
approved.selectedSegmentStartSeconds ||
entry.conversion?.durationSeconds !== 12 ||
entry.duration !== 12 ||
typeof entry.conversion?.operations !== "string" ||
!entry.conversion.operations.trim()
)
throw new Error(`승인 출처/변경 메타데이터 불일치: ${entry.file}`);
// 다른 항목에 같은 라이선스 URL이 있어도 이 항목의 누락을 가리지 못한다.
const sections = notice
.split("## ")
.filter((section) =>
section.startsWith(`${entry.displayName} — ${entry.environmentId}\n`),
);
const lines = sections[0]?.split("\n") ?? [];
if (
sections.length !== 1 ||
![
entry.attribution,
`Creator: ${entry.creator}`,
`Source: ${entry.pageUrl}`,
`Download: ${entry.directUrl}`,
`License: ${entry.license} — ${entry.licenseUrl}`,
`Output license: ${entry.outputLicense}`,
`Changes: ${entry.conversion.operations}; source seconds ${entry.conversion.startSeconds}–${entry.conversion.startSeconds + entry.conversion.durationSeconds}.`,
].every((line) => lines.includes(line))
)
throw new Error(`출처 고지 누락: ${entry.file}`);
}
} catch (cause) {
throw new Error("현장음 자산/manifest/notice 누락 또는 불일치", { cause });
}
return manifest.files.length;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
console.log(
`빌드 음성 자산 ${verifyBuild("dist")}개 + 현장음 8개 및 런타임/현장음 notice 확인`,
);
}