626 lines
23 KiB
JavaScript
626 lines
23 KiB
JavaScript
#!/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
|
|
};
|