#!/usr/bin/env node "use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); const path = require("node:path"); const zlib = require("node:zlib"); const { normalizeRelativePosixPath } = require("./wiki-file-discovery"); const DEFAULT_WARNING_DETAILS_REF = "wiki/graph-warnings.json"; const OFFLINE_WARNING_LIMIT_BYTES = 2 * 1024 * 1024; function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } function canonicalize(value) { if (Array.isArray(value)) return value.map(canonicalize); if (!value || typeof value !== "object") return value; const result = {}; for (const key of Object.keys(value).sort()) { if (value[key] !== undefined) result[key] = canonicalize(value[key]); } return result; } function canonicalBytes(value) { return Buffer.from(JSON.stringify(canonicalize(value)), "utf8"); } function serializeJsonForHtmlScript(value) { const json = JSON.stringify(canonicalize(value)); return Buffer.from(json.replace(/[<>&\u2028\u2029]/g, (character) => ( `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}` )), "utf8"); } function compareText(left, right) { return String(left).localeCompare(String(right), "en"); } function assertRelativeContentPath(value, fieldName) { if (typeof value !== "string" || !value || value.includes("\\")) { throw new Error(`${fieldName} must be a POSIX knowledge-base-relative path`); } let normalized; try { normalized = normalizeRelativePosixPath(value); } catch (_) { throw new Error(`${fieldName} must be a POSIX knowledge-base-relative path`); } if (normalized !== value || path.posix.isAbsolute(value)) { throw new Error(`${fieldName} must be a POSIX knowledge-base-relative path`); } return value; } function validateDetailsRef(detailsRef) { assertRelativeContentPath(detailsRef, "details_ref"); if (path.posix.basename(detailsRef) !== "graph-warnings.json") { throw new Error("details_ref must name graph-warnings.json"); } return detailsRef; } function normalizeOccurrence(occurrence) { if (!occurrence || typeof occurrence !== "object") { throw new Error("warning occurrence must be an object"); } assertRelativeContentPath(occurrence.source_path, "source_path"); return canonicalize(occurrence); } function normalizeCandidateSets(candidateSets) { if (!Array.isArray(candidateSets)) throw new Error("candidateSets must be an array"); const seen = new Set(); return candidateSets.map((candidateSet) => { if (!candidateSet || typeof candidateSet !== "object" || !candidateSet.candidate_set_id) { throw new Error("candidate_set_id is required"); } if (seen.has(candidateSet.candidate_set_id)) { throw new Error(`duplicate candidate_set_id: ${candidateSet.candidate_set_id}`); } seen.add(candidateSet.candidate_set_id); const candidates = Array.from(new Set((candidateSet.candidates || []).map((candidate) => ( assertRelativeContentPath(candidate, "candidate path") )))).sort(compareText); if (candidateSet.candidate_count !== candidates.length) { throw new Error(`candidate_count does not match candidates for ${candidateSet.candidate_set_id}`); } return canonicalize({ ...candidateSet, candidates }); }).sort((left, right) => compareText(left.candidate_set_id, right.candidate_set_id)); } function normalizeGroups(groups, candidateSetIds) { if (!Array.isArray(groups)) throw new Error("groups must be an array"); const seen = new Set(); const seenOccurrences = new Set(); return groups.map((group) => { if (!group || typeof group !== "object" || !group.warning_id) { throw new Error("warning_id is required"); } if (seen.has(group.warning_id)) throw new Error(`duplicate warning_id: ${group.warning_id}`); seen.add(group.warning_id); if (group.candidate_set_id && !candidateSetIds.has(group.candidate_set_id)) { throw new Error(`warning references missing candidate set: ${group.candidate_set_id}`); } if (!Number.isSafeInteger(group.occurrence_count) || group.occurrence_count < 0) { throw new Error(`invalid occurrence_count for ${group.warning_id}`); } const occurrences = (group.occurrences || []).map(normalizeOccurrence) .sort((left, right) => compareText(left.occurrence_id, right.occurrence_id)); for (const occurrence of occurrences) { if (seenOccurrences.has(occurrence.occurrence_id)) { throw new Error(`duplicate occurrence_id: ${occurrence.occurrence_id}`); } seenOccurrences.add(occurrence.occurrence_id); } if (group.occurrence_count !== occurrences.length) { throw new Error(`occurrence_count does not match occurrences for ${group.warning_id}`); } return canonicalize({ ...group, occurrences }); }).sort((left, right) => compareText(left.warning_id, right.warning_id)); } function sortGraphCollections(graphData) { const graph = structuredClone(graphData || {}); if (graph.meta && typeof graph.meta === "object") delete graph.meta.warning_summary; if (Array.isArray(graph.nodes)) graph.nodes.sort((left, right) => compareText(left.id, right.id)); if (Array.isArray(graph.edges)) { graph.edges.sort((left, right) => compareText( `${left.id || ""}\0${left.from || ""}\0${left.to || ""}`, `${right.id || ""}\0${right.from || ""}\0${right.to || ""}` )); } if (graph.learning && Array.isArray(graph.learning.communities)) { graph.learning.communities.sort((left, right) => compareText(left.id, right.id)); } return canonicalize(graph); } function graphBuildIdentityProjection(graphData) { const graph = sortGraphCollections(graphData); if (graph.meta && typeof graph.meta === "object") delete graph.meta.build_date; return canonicalize(graph); } function canonicalWarningDetailBytes(bundle) { return canonicalBytes({ version: bundle.version, build_id: bundle.build_id, candidate_sets: bundle.candidate_sets, groups: bundle.groups }); } function summarizeWarningGroups(groups) { const byCode = {}; let errorOccurrences = 0; let warningOccurrences = 0; for (const group of groups) { byCode[group.code] = (byCode[group.code] || 0) + group.occurrence_count; if (group.severity === "error") errorOccurrences += group.occurrence_count; else warningOccurrences += group.occurrence_count; } return canonicalize({ total_groups: groups.length, total_occurrences: errorOccurrences + warningOccurrences, error_occurrences: errorOccurrences, warning_occurrences: warningOccurrences, by_code: byCode }); } function summaryCountsMatch(summary, groups) { const actual = canonicalize({ total_groups: summary.total_groups, total_occurrences: summary.total_occurrences, error_occurrences: summary.error_occurrences, warning_occurrences: summary.warning_occurrences, by_code: summary.by_code }); return canonicalBytes(actual).equals(canonicalBytes(summarizeWarningGroups(groups))); } function assembleGraphArtifactPair({ graphData, groups, candidateSets, detailsRef = DEFAULT_WARNING_DETAILS_REF }) { const validatedDetailsRef = validateDetailsRef(detailsRef); const candidate_sets = normalizeCandidateSets(candidateSets); const normalizedGroups = normalizeGroups(groups, new Set(candidate_sets.map((item) => item.candidate_set_id))); const graphWithoutSummary = sortGraphCollections(graphData); const build_id = sha256(canonicalBytes({ graph_without_warning_summary: graphBuildIdentityProjection(graphWithoutSummary), warning_details: { candidate_sets, groups: normalizedGroups } })); const detailProjection = { version: 1, build_id, candidate_sets, groups: normalizedGroups }; const details_sha256 = sha256(canonicalBytes(detailProjection)); const counts = summarizeWarningGroups(normalizedGroups); const summary = canonicalize({ build_id, ...counts, details_ref: validatedDetailsRef, details_sha256 }); const normalizedGraph = canonicalize({ ...graphWithoutSummary, meta: { ...(graphWithoutSummary.meta || {}), warning_summary: summary } }); const warningBundle = canonicalize({ version: 1, build_id, summary, candidate_sets, groups: normalizedGroups }); return { graphData: normalizedGraph, warningBundle }; } function summariesMatch(left, right) { return Boolean(left && right && canonicalBytes(left).equals(canonicalBytes(right))); } function graphWithoutWarningSummary(graphData) { return sortGraphCollections(graphData); } function recalculateBuildId(graphData, warningBundle) { return sha256(canonicalBytes({ graph_without_warning_summary: graphBuildIdentityProjection(graphData), warning_details: { candidate_sets: warningBundle.candidate_sets, groups: warningBundle.groups } })); } function parseArtifactBytes(bytes) { return JSON.parse(Buffer.isBuffer(bytes) ? bytes.toString("utf8") : String(bytes)); } function validateArtifactObjects({ graphData, warningBundle, expectedDetailsRef }) { const summary = graphData && graphData.meta && graphData.meta.warning_summary; if (!summary || typeof summary !== "object") { return { status: "unavailable", reason: "invalid", summary: summary || null }; } try { validateDetailsRef(summary.details_ref); } catch (_) { return { status: "unavailable", reason: "invalid", summary }; } if (summary.details_ref !== expectedDetailsRef) { return { status: "unavailable", reason: "details_ref_mismatch", summary }; } if (!warningBundle || typeof warningBundle !== "object" || warningBundle.version !== 1) { return { status: "unavailable", reason: "invalid", summary }; } if (summary.build_id !== warningBundle.build_id || !summariesMatch(summary, warningBundle.summary)) { return { status: "unavailable", reason: "build_id_mismatch", summary }; } let normalizedSets; let normalizedGroups; try { normalizedSets = normalizeCandidateSets(warningBundle.candidate_sets); normalizedGroups = normalizeGroups( warningBundle.groups, new Set(normalizedSets.map((item) => item.candidate_set_id)) ); } catch (_) { return { status: "unavailable", reason: "invalid", summary }; } const canonicalBundle = canonicalize({ ...warningBundle, candidate_sets: normalizedSets, groups: normalizedGroups }); if (!summaryCountsMatch(summary, normalizedGroups)) { return { status: "unavailable", reason: "invalid", summary }; } const actualDetailsSha256 = sha256(canonicalWarningDetailBytes(canonicalBundle)); if (summary.details_sha256 !== actualDetailsSha256) { return { status: "unavailable", reason: "details_sha256_mismatch", summary }; } if (recalculateBuildId(graphData, canonicalBundle) !== summary.build_id) { return { status: "unavailable", reason: "build_id_mismatch", summary }; } return { status: "available", graphData, warningBundle: canonicalBundle }; } function isWithinRoot(rootPath, candidatePath) { return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}${path.sep}`); } async function validateArtifactDestinations({ kbRoot, graphPath, warningPath, detailsRef }) { const rootReal = await fsp.realpath(kbRoot); const graphAbsolute = path.resolve(graphPath); const warningAbsolute = path.resolve(warningPath); if (path.basename(graphAbsolute) !== "graph-data.json") { throw new Error("graph destination basename must be graph-data.json"); } if (path.basename(warningAbsolute) !== "graph-warnings.json") { throw new Error("warning destination basename must be graph-warnings.json"); } const graphParentReal = await fsp.realpath(path.dirname(graphAbsolute)); const warningParentReal = await fsp.realpath(path.dirname(warningAbsolute)); if (!isWithinRoot(rootReal, graphParentReal) || !isWithinRoot(rootReal, warningParentReal)) { throw new Error("artifact destination must remain inside the knowledge base"); } if (graphParentReal !== warningParentReal) { throw new Error("graph-data.json and graph-warnings.json must be sibling artifacts"); } const graphFinal = path.join(graphParentReal, "graph-data.json"); const warningFinal = path.join(warningParentReal, "graph-warnings.json"); for (const finalPath of [graphFinal, warningFinal]) { try { const stat = await fsp.lstat(finalPath); if (stat.isSymbolicLink() || !stat.isFile()) { throw new Error(`artifact destination is not a regular file: ${finalPath}`); } } catch (error) { if (error.code !== "ENOENT") throw error; } } const normalizedDetailsRef = validateDetailsRef(detailsRef); const detailsAbsolute = path.resolve(rootReal, ...normalizedDetailsRef.split("/")); const detailsParentReal = await fsp.realpath(path.dirname(detailsAbsolute)); const resolvedDetails = path.join(detailsParentReal, path.basename(detailsAbsolute)); if (!isWithinRoot(rootReal, detailsParentReal) || resolvedDetails !== warningFinal) { throw new Error("details_ref does not resolve to the final sibling graph-warnings.json"); } const expectedDetailsRef = path.relative(rootReal, warningFinal).split(path.sep).join("/"); if (normalizedDetailsRef !== expectedDetailsRef) { throw new Error("details_ref does not match the final warning destination"); } return { rootReal, graphFinal, warningFinal, outputParent: graphParentReal, expectedDetailsRef }; } async function writeSyncedFile(filePath, bytes) { const handle = await fsp.open(filePath, "wx", 0o600); try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } } async function fsyncDirectory(directoryPath) { let handle; try { handle = await fsp.open(directoryPath, fs.constants.O_RDONLY); await handle.sync(); } catch (error) { if (!["EINVAL", "ENOTSUP", "EBADF", "EPERM", "EISDIR"].includes(error.code)) throw error; } finally { if (handle) await handle.close(); } } function operationDirectoryName(name) { return /^[a-f0-9]{64}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name); } async function pruneOldOperationDirectories(buildRoot, currentDirectory, now) { const entries = await fsp.readdir(buildRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || !operationDirectoryName(entry.name)) continue; const candidate = path.join(buildRoot, entry.name); if (candidate === currentDirectory) continue; const stat = await fsp.stat(candidate); if (now - stat.mtimeMs > 24 * 60 * 60 * 1000) { await fsp.rm(candidate, { recursive: true, force: true }); } } } async function commitGraphArtifactPair({ kbRoot, graphPath, warningPath, pair, hooks = {} }) { if (!pair || !pair.graphData || !pair.warningBundle) throw new Error("artifact pair is required"); const summary = pair.graphData.meta && pair.graphData.meta.warning_summary; if (!summary) throw new Error("graph warning summary is required"); const destinations = await validateArtifactDestinations({ kbRoot, graphPath, warningPath, detailsRef: summary.details_ref }); const stat = hooks.stat || ((target) => fsp.stat(target)); const tempParent = path.join(destinations.rootReal, ".wiki-tmp"); await fsp.mkdir(tempParent, { recursive: true, mode: 0o700 }); const tempParentReal = await fsp.realpath(tempParent); if (!isWithinRoot(destinations.rootReal, tempParentReal)) { throw new Error("temporary graph build directory escapes knowledge base"); } const buildRoot = path.join(tempParentReal, "graph-build"); await fsp.mkdir(buildRoot, { recursive: true, mode: 0o700 }); const [tempDevice, graphDevice, warningDevice] = await Promise.all([ stat(tempParentReal), stat(path.dirname(destinations.graphFinal)), stat(path.dirname(destinations.warningFinal)) ]); if (tempDevice.dev !== graphDevice.dev || tempDevice.dev !== warningDevice.dev) { throw new Error("graph artifact destinations must use the same filesystem device as .wiki-tmp"); } const graphBytes = Buffer.from(`${JSON.stringify(pair.graphData, null, 2)}\n`, "utf8"); const warningBytes = Buffer.from(`${JSON.stringify(pair.warningBundle, null, 2)}\n`, "utf8"); let graphObject; let warningObject; try { graphObject = parseArtifactBytes(graphBytes); warningObject = parseArtifactBytes(warningBytes); } catch (error) { throw new Error(`invalid artifact pair JSON: ${error.message}`); } const preflight = validateArtifactObjects({ graphData: graphObject, warningBundle: warningObject, expectedDetailsRef: destinations.expectedDetailsRef }); if (preflight.status !== "available") { throw new Error(`invalid artifact pair: ${preflight.reason}`); } const operationDirectory = path.join( buildRoot, `${summary.build_id}-${crypto.randomUUID()}` ); await fsp.mkdir(operationDirectory, { recursive: false, mode: 0o700 }); const tempGraph = path.join(operationDirectory, "graph-data.json"); const tempWarning = path.join(operationDirectory, "graph-warnings.json"); await writeSyncedFile(tempGraph, graphBytes); await writeSyncedFile(tempWarning, warningBytes); const verifiedTemporary = validateArtifactObjects({ graphData: parseArtifactBytes(await fsp.readFile(tempGraph)), warningBundle: parseArtifactBytes(await fsp.readFile(tempWarning)), expectedDetailsRef: destinations.expectedDetailsRef }); if (verifiedTemporary.status !== "available") { throw new Error(`temporary artifact verification failed: ${verifiedTemporary.reason}`); } await fsp.rename(tempWarning, destinations.warningFinal); await fsyncDirectory(destinations.outputParent); if (hooks.afterWarningReplace) await hooks.afterWarningReplace(); await fsp.rename(tempGraph, destinations.graphFinal); await fsyncDirectory(destinations.outputParent); await fsp.rm(operationDirectory, { recursive: true, force: true }); await pruneOldOperationDirectories( buildRoot, operationDirectory, hooks.now ? hooks.now() : Date.now() ); } async function verifyGraphArtifactPair({ kbRoot, graphPath, warningPath }) { let graphData; try { graphData = parseArtifactBytes(await fsp.readFile(graphPath)); } catch (_) { return { status: "unavailable", reason: "invalid", summary: null }; } const summary = graphData && graphData.meta && graphData.meta.warning_summary; if (!summary || typeof summary !== "object") { return { status: "unavailable", reason: "invalid", summary: summary || null }; } let destinations; try { destinations = await validateArtifactDestinations({ kbRoot, graphPath, warningPath, detailsRef: summary.details_ref }); } catch (error) { return { status: "unavailable", reason: error.message.includes("details_ref") ? "details_ref_mismatch" : "invalid", summary }; } let warningBytes; try { warningBytes = await fsp.readFile(destinations.warningFinal); } catch (error) { return { status: "unavailable", reason: error.code === "ENOENT" ? "missing" : "invalid", summary }; } let warningBundle; try { warningBundle = parseArtifactBytes(warningBytes); } catch (_) { return { status: "unavailable", reason: "invalid", summary }; } return validateArtifactObjects({ graphData, warningBundle, expectedDetailsRef: destinations.expectedDetailsRef }); } function canonicalOfflineBundle(bundle) { const candidate_sets = (bundle.candidate_sets || []).map((candidateSet) => canonicalize({ ...candidateSet, candidates: (candidateSet.candidates || []).slice().sort(compareText) })).sort((left, right) => compareText(left.candidate_set_id, right.candidate_set_id)); const groups = (bundle.groups || []).map((group) => canonicalize({ ...group, occurrences: (group.occurrences || []).slice() .sort((left, right) => compareText(left.occurrence_id, right.occurrence_id)) })).sort((left, right) => compareText(left.warning_id, right.warning_id)); return canonicalize({ ...bundle, candidate_sets, groups }); } function offlinePayload(summary, bundle, truncated, omittedGroupCount, omittedCandidateSetCount) { return canonicalize({ summary, details_status: "available", details_unavailable_reason: null, warning_details_truncated: truncated, omitted_group_count: omittedGroupCount, omitted_candidate_set_count: omittedCandidateSetCount, bundle }); } function compressedPayloadBytes(payload) { const scriptBytes = serializeJsonForHtmlScript(payload); return { scriptBytes, compressedBytes: zlib.gzipSync(scriptBytes, { level: 9 }).length }; } function prepareOfflineWarningPayload({ summary, bundle, maxCompressedBytes = OFFLINE_WARNING_LIMIT_BYTES }) { if (!Number.isSafeInteger(maxCompressedBytes) || maxCompressedBytes <= 0) { throw new Error("maxCompressedBytes must be a positive integer"); } const completeBundle = canonicalOfflineBundle(bundle); let payload = offlinePayload(summary, completeBundle, false, 0, 0); let { scriptBytes, compressedBytes } = compressedPayloadBytes(payload); if (compressedBytes <= maxCompressedBytes) return { payload, scriptBytes, compressedBytes }; const compactBundle = canonicalOfflineBundle({ ...completeBundle, groups: completeBundle.groups.map((group) => ({ ...group, occurrences: group.occurrences.slice(0, 20) })), candidate_sets: completeBundle.candidate_sets.map((candidateSet) => ({ ...candidateSet, candidates: candidateSet.candidates.slice(0, 20) })) }); let omittedGroupCount = 0; let omittedCandidateSetCount = 0; const refresh = () => { payload = offlinePayload(summary, compactBundle, true, omittedGroupCount, omittedCandidateSetCount); ({ scriptBytes, compressedBytes } = compressedPayloadBytes(payload)); return compressedBytes <= maxCompressedBytes; }; if (refresh()) return { payload, scriptBytes, compressedBytes }; for (let index = compactBundle.groups.length - 1; index >= 0; index -= 1) { while (compactBundle.groups[index].occurrences.length > 0) { compactBundle.groups[index].occurrences.pop(); if (refresh()) return { payload, scriptBytes, compressedBytes }; } } for (let index = compactBundle.candidate_sets.length - 1; index >= 0; index -= 1) { while (compactBundle.candidate_sets[index].candidates.length > 0) { compactBundle.candidate_sets[index].candidates.pop(); if (refresh()) return { payload, scriptBytes, compressedBytes }; } } while (compactBundle.groups.length > 0) { compactBundle.groups.pop(); omittedGroupCount += 1; if (refresh()) return { payload, scriptBytes, compressedBytes }; } while (compactBundle.candidate_sets.length > 0) { compactBundle.candidate_sets.pop(); omittedCandidateSetCount += 1; if (refresh()) return { payload, scriptBytes, compressedBytes }; } if (!refresh()) { throw new Error("offline warning summary exceeds the compressed payload limit"); } return { payload, scriptBytes, compressedBytes }; } module.exports = { DEFAULT_WARNING_DETAILS_REF, OFFLINE_WARNING_LIMIT_BYTES, assembleGraphArtifactPair, canonicalWarningDetailBytes, commitGraphArtifactPair, prepareOfflineWarningPayload, serializeJsonForHtmlScript, verifyGraphArtifactPair };