import { readFile } from "node:fs/promises"; import path from "node:path"; import { contentDigest, kingWenEntries, validateContentPackage, } from "./content-contract.mjs"; import { repositoryRoot } from "./repository-files.mjs"; const schemaPath = path.join(repositoryRoot, "content", "schema", "hexagram-content.schema.json"); const schema = JSON.parse(await readFile(schemaPath, "utf8")); if (schema.$schema !== "https://json-schema.org/draft/2020-12/schema") { throw new Error("Content JSON Schema must use draft 2020-12"); } const kotlinCatalogPath = path.join( repositoryRoot, "app", "src", "main", "java", "net", "opcapp", "flash", "domain", "casting", "HexagramCatalog.kt", ); const kotlinCatalog = await readFile(kotlinCatalogPath, "utf8"); const kotlinPairPattern = /TrigramPair\(Trigram\.(\w+), Trigram\.(\w+)\) to (\d+)/gu; const kotlinPairs = new Map( [...kotlinCatalog.matchAll(kotlinPairPattern)].map((match) => [ `${match[1]}/${match[2]}`, Number(match[3]), ]), ); const contractEntries = kingWenEntries(); if (kotlinPairs.size !== 64 || contractEntries.some((entry) => kotlinPairs.get(`${entry.upperTrigram}/${entry.lowerTrigram}`) !== entry.kingWenNumber)) { throw new Error("Kotlin King Wen catalog and content-contract lookup must match exactly"); } function validFixture() { return { schemaVersion: 1, contentVersion: "fixture-only-v1", specialUsageTexts: { qian: false, kun: false }, sources: [{ id: "fixture-source", title: "Automated test fixture", edition: "not publishable", license: "test data only", url: "https://example.invalid/fixture", }], hexagrams: contractEntries.map((entry) => ({ ...entry, name: `fixture-${entry.kingWenNumber}`, symbol: `fixture-symbol-${entry.kingWenNumber}`, judgmentOriginal: "fixture original text", judgmentPlain: "fixture plain text", lineTextsBottomUp: Array.from({ length: 6 }, (_, index) => `fixture original line ${index + 1}`), linePlainBottomUp: Array.from({ length: 6 }, (_, index) => `fixture plain line ${index + 1}`), specialUsageText: null, sourceRefs: ["fixture-source"], })), }; } function clone(value) { return structuredClone(value); } const fixture = validFixture(); const validErrors = validateContentPackage(fixture); if (validErrors.length > 0) { throw new Error(`Valid content fixture was rejected:\n${validErrors.join("\n")}`); } const digest = contentDigest(fixture); if (!/^[a-f0-9]{64}$/u.test(digest) || digest !== contentDigest(clone(fixture))) { throw new Error("Content digest must be a stable SHA-256 value"); } const negativeCases = [ ["unsupported schema", (value) => { value.schemaVersion = 2; }, "schemaVersion"], ["duplicate id", (value) => { value.hexagrams[1].kingWenNumber = 1; }, "unique"], ["wrong bottom-up pattern", (value) => { value.hexagrams[0].patternBottomUp[0] = "YIN"; }, "bottom-up"], ["five line texts", (value) => { value.hexagrams[0].lineTextsBottomUp.pop(); }, "exactly six"], ["unknown source", (value) => { value.hexagrams[0].sourceRefs = ["missing"]; }, "unknown source"], ["blank license", (value) => { value.sources[0].license = " "; }, "non-blank"], ["script content", (value) => { value.hexagrams[0].judgmentPlain = ""; }, "script-like"], ["special declaration mismatch", (value) => { value.specialUsageTexts.qian = true; }, "specialUsageTexts.qian"], ]; for (const [name, mutate, expected] of negativeCases) { const candidate = clone(fixture); mutate(candidate); const errors = validateContentPackage(candidate); if (!errors.some((error) => error.includes(expected))) { throw new Error(`${name} fixture did not fail with '${expected}':\n${errors.join("\n")}`); } } console.log( `Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`, ); const packagePath = path.join(repositoryRoot, "content", "packages", "hexagram-content.json"); const namesPath = path.join( repositoryRoot, "app", "src", "main", "java", "net", "opcapp", "flash", "core", "model", "HexagramNames.kt", ); const namesSource = await readFile(namesPath, "utf8"); const namesBlock = namesSource.match(/private val names = listOf\(([\s\S]*?)\)/u); if (!namesBlock) { throw new Error("HexagramNames.kt name table could not be parsed"); } const catalogNames = [...namesBlock[1].matchAll(/"([^"]+)"/gu)].map((match) => match[1]); if (catalogNames.length !== 64) { throw new Error("HexagramNames.kt must contain 64 names"); } const published = JSON.parse(await readFile(packagePath, "utf8")); const publishedErrors = validateContentPackage(published); if (publishedErrors.length > 0) { throw new Error(`Published content package failed validation:\n${publishedErrors.join("\n")}`); } published.hexagrams.forEach((hexagram) => { const expectedName = catalogNames[hexagram.kingWenNumber - 1]; const expectedSymbol = String.fromCodePoint(0x4DC0 + hexagram.kingWenNumber - 1); if (hexagram.name !== expectedName) { throw new Error(`Published package name for ${hexagram.kingWenNumber} must be ${expectedName}`); } if (hexagram.symbol !== expectedSymbol) { throw new Error(`Published package symbol for ${hexagram.kingWenNumber} must be ${expectedSymbol}`); } }); if (published.contentVersion !== "zh-Hans-2026.1") { throw new Error("Published contentVersion must be zh-Hans-2026.1"); } if (!published.specialUsageTexts.qian || !published.specialUsageTexts.kun) { throw new Error("Published package must include Qian 用九 and Kun 用六"); } console.log( `Published content package ${published.contentVersion} passed (digest ${contentDigest(published).slice(0, 12)}…).`, );