import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; export const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); export function stableJson(value) { const sort = (item) => { if (Array.isArray(item)) return item.map(sort); if (item && typeof item === "object") { return Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])])); } return item; }; return `${JSON.stringify(sort(value))}\n`; } function safeChild(root, relative) { const resolvedRoot = path.resolve(root); const resolved = path.resolve(root, relative); if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`Unsafe reference path: ${relative}`); return resolved; } function verifyFiles(directory, manifest) { if (manifest.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(manifest.snapshotId)) { throw new Error("Invalid CoA snapshot manifest"); } for (const name of ["reference.json", "audit.json", "audit.md"]) { const file = safeChild(directory, name); const expected = manifest.files[name]; if (!expected || !fs.existsSync(file)) throw new Error(`Missing reference file: ${name}`); const bytes = fs.readFileSync(file); if (bytes.length !== expected.bytes || digest(bytes) !== expected.sha256) { throw new Error(`Corrupt reference file: ${name}; regenerate from verified sources using a fresh --output-root`); } } const expectedId = digest(stableJson(manifest.files)); if (expectedId !== manifest.snapshotId) throw new Error("Snapshot manifest identity mismatch"); } export function readSnapshot(outputRoot) { const pointerPath = path.join(outputRoot, "current.json"); if (!fs.existsSync(pointerPath)) throw new Error("No CoA reference snapshot; run coa:reference:build first"); const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf8")); if (!/^[a-f0-9]{64}$/.test(pointer.snapshotId)) throw new Error("Invalid reference snapshot pointer"); const directory = safeChild(outputRoot, `snapshots/${pointer.snapshotId}`); const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); if (pointer.snapshotId !== manifest.snapshotId) throw new Error("Reference pointer and manifest disagree"); verifyFiles(directory, manifest); return { directory, manifest, reference: JSON.parse(fs.readFileSync(path.join(directory, "reference.json"), "utf8")), report: JSON.parse(fs.readFileSync(path.join(directory, "audit.json"), "utf8")), }; } /** Publish immutable files before changing the current pointer; failures keep the old snapshot usable. */ export function publishSnapshot({ outputRoot, reference, report, markdown }) { const files = { "reference.json": stableJson(reference), "audit.json": stableJson(report), "audit.md": `${markdown.trimEnd()}\n`, }; const fileManifest = Object.fromEntries(Object.entries(files).map(([name, bytes]) => [name, { sha256: digest(bytes), bytes: Buffer.byteLength(bytes), }])); const snapshotId = digest(stableJson(fileManifest)); const manifest = { schemaVersion: 1, snapshotId, files: fileManifest }; const snapshotsRoot = path.join(outputRoot, "snapshots"); fs.mkdirSync(snapshotsRoot, { recursive: true }); const directory = safeChild(snapshotsRoot, snapshotId); if (fs.existsSync(directory)) { const previous = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); verifyFiles(directory, previous); if (stableJson(previous) !== stableJson(manifest)) throw new Error("Existing snapshot identity collision"); } else { const staging = fs.mkdtempSync(path.join(snapshotsRoot, ".staging-")); try { for (const [name, bytes] of Object.entries(files)) fs.writeFileSync(path.join(staging, name), bytes); fs.writeFileSync(path.join(staging, "manifest.json"), stableJson(manifest)); verifyFiles(staging, manifest); fs.renameSync(staging, directory); } finally { if (fs.existsSync(staging)) { safeChild(snapshotsRoot, path.relative(snapshotsRoot, staging)); fs.rmSync(staging, { recursive: true }); } } } const pointer = path.join(outputRoot, "current.json"); const temporary = path.join(outputRoot, `.current-${crypto.randomUUID()}.tmp`); try { fs.writeFileSync(temporary, stableJson({ schemaVersion: 1, snapshotId })); fs.renameSync(temporary, pointer); } finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } return { snapshotId, directory, manifest }; } export function lookupReference(reference, options = {}) { const needle = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); const requestedClass = options.class; const classes = reference.classes.filter((item) => !requestedClass || [ item.classId, item.gameId, item.className, ...(item.aliases ?? []), ].some((value) => needle(value) === needle(requestedClass))); if (requestedClass && classes.length === 0) throw new Error(`Unknown CoA class: ${requestedClass}`); const classIds = new Set(classes.map((item) => item.classId)); let nodes = reference.nodes.filter((node) => classIds.has(node.classId)); if (options.tree) { const tabs = new Set(classes.flatMap((item) => item.tabs).filter((tab) => [tab.tabId, tab.tabName] .some((value) => needle(value) === needle(options.tree))).map((tab) => tab.tabId)); if (!tabs.size) throw new Error(`Unknown tree: ${options.tree}`); nodes = nodes.filter((node) => tabs.has(node.tabId)); } if (options.ability) nodes = nodes.filter((node) => needle(node.name).includes(needle(options.ability))); const limit = Number(options.limit ?? 20); if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Lookup limit must be 1..1000"); const rankIds = new Set(nodes.flatMap((node) => node.rankDescriptions.map((rank) => rank.spellId))); let abilities = (reference.abilities ?? []).filter((item) => classIds.has(item.classId) && (!options.ability || needle(item.name).includes(needle(options.ability))) && (!options.tree || item.nodeIds.some((id) => nodes.some((node) => node.id === id)))); for (const ability of abilities) { for (const id of [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])]) rankIds.add(id); } const bySpellId = new Map(reference.spells.map((spell) => [spell.id, spell])); const pending = [...rankIds]; for (let index = 0; index < pending.length; index += 1) { for (const effect of bySpellId.get(pending[index])?.dbc?.effects ?? []) { const id = effect.triggerSpellId; if (id > 0 && !rankIds.has(id)) { rankIds.add(id); pending.push(id); } } } let spells = reference.spells.filter((spell) => options.spell ? spell.id === Number(options.spell) : rankIds.has(spell.id) || (options.ability && needle(spell.name).includes(needle(options.ability)) && !requestedClass)); if (options.spell) { if (!spells.length) throw new Error(`Spell ${options.spell} is not in this reference snapshot`); const spellId = Number(options.spell); nodes = nodes.filter((node) => node.rankDescriptions.some((rank) => rank.spellId === spellId)); abilities = abilities.filter((ability) => [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])].includes(spellId) || ability.nodeIds.some((id) => nodes.some((node) => node.id === id))); if (requestedClass && !rankIds.has(spellId)) throw new Error(`Spell ${spellId} has no evidence for class ${requestedClass}`); } return { baseline: reference.baseline, classes: classes.map(({ classId, gameId, className, aliases, tabs }) => ({ classId, gameId, className, aliases, tabs })), totals: { nodes: nodes.length, abilities: abilities.length, spells: spells.length }, nodes: nodes.slice(0, limit), abilities: abilities.slice(0, limit), spells: spells.slice(0, limit), }; }