← Files PDF ParserARCHIVED FILE

scripts/audit-deliverables.mjs

41.8 KB · Oct 3, 2026 · 06:36 UTC

↓ Download file

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {inflateSync} from 'node:zlib';

function usage(message) {
  if (message) console.error(`Error: ${message}`);
  console.error('Usage: audit-deliverables.mjs <deliverable-folder> --expected <positive-integer> [--json <report-path>] [--language <en|any>] [--strict-ui] [--allow-active-content]');
  process.exit(2);
}

const argv = process.argv.slice(2);
let rootArg = null;
let expected = null;
let outPath = null;
let language = 'en';
let strictUi = false;
let allowActiveContent = false;
for (let i = 0; i < argv.length; i += 1) {
  const arg = argv[i];
  if (arg === '--expected') {
    const value = argv[++i];
    if (!value || !/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 10_000) usage('--expected must be an integer from 1 to 10000');
    expected = Number(value);
  } else if (arg === '--json') {
    const value = argv[++i];
    if (!value) usage('--json requires a path');
    outPath = path.resolve(value);
  } else if (arg === '--language') {
    const value = argv[++i];
    if (!['en', 'any'].includes(value)) usage('--language must be en or any');
    language = value;
  } else if (arg === '--strict-ui') {
    strictUi = true;
  } else if (arg === '--allow-active-content') {
    allowActiveContent = true;
  } else if (arg === '--help') {
    usage();
  } else if (arg.startsWith('-')) {
    usage(`unknown option ${arg}`);
  } else if (rootArg === null) {
    rootArg = arg;
  } else {
    usage(`unexpected argument ${arg}`);
  }
}
if (expected === null) usage('--expected is required so an empty directory cannot pass');
const root = path.resolve(rootArg || '.');
function isWithin(base, target) { const relative = path.relative(base, target); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }
function outputInsideInput(base, target) {
  if (isWithin(base, target)) return true;
  try { return isWithin(fs.realpathSync(base), path.join(fs.realpathSync(path.dirname(target)), path.basename(target))); }
  catch { return false; }
}
if (outPath && outputInsideInput(root, outPath)) usage('--json must be outside the audited root');
if (outPath && fs.existsSync(outPath)) fs.unlinkSync(outPath);
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) usage('deliverable folder does not exist or is not a directory');

const MAX_TEXT_BYTES = 50 * 1024 * 1024;
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const MAX_WALK_ENTRIES = 5000;
const MAX_DELIVERABLE_FILES = 1000;
const MAX_TOTAL_QUESTION_SECTIONS = 20_000;
const MAX_TOKENS_PER_QUESTION = 20_000;
const MAX_TOTAL_TOKENS = 2_000_000;
const MAX_PNG_CHUNKS = 10_000;
const MAX_PNG_IDAT_CHUNKS = 4_096;
const MAX_RESPONSE_CANDIDATES = 64;
const MAX_ISSUES = 10_000;
const MAX_AGGREGATE_TEXT_BYTES = 256 * 1024 * 1024;
const MAX_AGGREGATE_ASSET_BYTES = 256 * 1024 * 1024;
const MAX_AGGREGATE_EMBEDDED_BYTES = 256 * 1024 * 1024;
const MAX_DECODED_IMAGE_BYTES = 128 * 1024 * 1024;
const MAX_AGGREGATE_DECODED_BYTES = 512 * 1024 * 1024;
const MAX_AGGREGATE_PIXELS = 200_000_000;
const MAX_IMAGE_PIXELS = 100_000_000;
const MAX_QUESTION_SECTIONS = Math.min(10_000, Math.max(1000, expected * 2));
const MAX_IMAGE_REFERENCES_PER_FORMAT = Math.max(1000, expected * 4);
const assetCache = new Map();
let aggregateTextBytes = 0;
let aggregateAssetBytes = 0;
let aggregateEmbeddedBytes = 0;
let aggregateDecodedBytes = 0;
let aggregatePixels = 0;
let resourceBudgetExhausted = false;
let totalMarkdownImageRefs = 0;
let totalHtmlImageRefs = 0;
let totalQuestionSections = 0;
let totalQuestionTokens = 0;
const sha256 = value => crypto.createHash('sha256').update(value).digest('hex');
const toPosix = value => value.split(path.sep).join('/');
const stripMdCode = text => text
  .replace(/^\s*```[^\n]*\n[\s\S]*?^\s*```\s*$/gm, '')
  .replace(/^\s*~~~[^\n]*\n[\s\S]*?^\s*~~~\s*$/gm, '')
  .replace(/<!--[\s\S]*?-->/g, '');
const stripHtmlInactive = text => text
  .replace(/<!--[\s\S]*?-->/g, '')
  .replace(/<(script|style|template|noscript|title)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
const uiRe = /student-produced response questions|Mark for Review|Question \d+ of \d+|Answer Preview|Hide Reference|^\s*Hi\s*$|^## Directions\s*$|<h2\b[^>]*>\s*Directions\s*<\/h2>|<p\b[^>]*>\s*Hi\s*<\/p>|<p\b[^>]*>\s*\d+:\s*<\/p>/gmi;
const cjkRe = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
const exhibitRe = /(?:graph|scatterplot|histogram|table|figure|chart|diagram|shaded region)\s+(?:shows|shown|indicates|displays|below|above|represents|illustrates)|(?:according to|based on|in|from)\s+the\s+(?:graph|table|chart|diagram|figure|scatterplot|histogram)(?:\s+(?:above|below))?/i;

const auditDiscoveryErrors = [];
function walk(directory, state = {entries: 0}) {
  const found = [];
  const handle = fs.opendirSync(directory);
  try {
    let entry;
    while ((entry = handle.readSync()) !== null) {
      state.entries += 1;
      if (state.entries > MAX_WALK_ENTRIES) throw new Error('walk entry limit exceeded');
      const full = path.join(directory, entry.name);
      const relative = toPosix(path.relative(root, full));
      if (entry.name.startsWith('.')) { auditDiscoveryErrors.push({severity: 'error', code: 'HIDDEN_ENTRY_PRESENT', file: relative}); continue; }
      if (entry.isSymbolicLink()) { auditDiscoveryErrors.push({severity: 'error', code: 'SYMLINK_ENTRY_PRESENT', file: relative}); continue; }
      if (entry.isDirectory()) found.push(...walk(full, state));
      else if (entry.isFile()) found.push(full);
    }
  } finally { handle.closeSync(); }
  return found;
}

function readStableFile(file, maxBytes) {
  const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
  const handle = fs.openSync(file, flags);
  try {
    const before = fs.fstatSync(handle);
    if (!before.isFile()) throw new Error('not_regular_file');
    if (before.size > maxBytes) throw new Error('file_too_large');
    const bytes = Buffer.allocUnsafe(before.size);
    let offset = 0;
    while (offset < before.size) {
      const count = fs.readSync(handle, bytes, offset, before.size - offset, offset);
      if (count < 1) throw new Error('unexpected_eof');
      offset += count;
    }
    const after = fs.fstatSync(handle);
    const current = fs.lstatSync(file);
    if (current.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || current.dev !== after.dev || current.ino !== after.ino || current.size !== after.size || current.mtimeMs !== after.mtimeMs) throw new Error('file_changed_during_read');
    return {bytes, stat: after};
  } finally { fs.closeSync(handle); }
}

function mdSections(text) {
  const clean = stripMdCode(text);
  const re = /^(?:###\s+(?:Question\s+)?(\d+)\.?|\*\*(\d+)\.\*\*|(\d+)\.)\s*$/gmi;
  const matches = [];
  let match;
  while ((match = re.exec(clean)) !== null) {
    if (matches.length >= MAX_QUESTION_SECTIONS) { const out = []; out.overflow = true; return out; }
    matches.push({index: match.index, number: Number(match[1] || match[2] || match[3])});
  }
  const out = matches.map((item, index) => ({ordinal: index + 1, number: item.number, text: clean.slice(item.index, index + 1 < matches.length ? matches[index + 1].index : clean.length)}));
  out.overflow = false;
  return out;
}

function htmlSections(text) {
  const clean = stripHtmlInactive(text);
  const re = /<h4\b(?=[^>]*\bclass\s*=\s*(["'])[^"']*\bqno\b[^"']*\1)[^>]*>([\s\S]*?)<\/h4>/gi;
  const matches = [];
  let match;
  while ((match = re.exec(clean)) !== null) {
    if (matches.length >= MAX_QUESTION_SECTIONS) { const out = []; out.overflow = true; return out; }
    matches.push({index: match.index, number: Number(match[2].replace(/<[^>]*>/g, '').match(/\d+/)?.[0] || 0)});
  }
  const out = matches.map((item, index) => ({ordinal: index + 1, number: item.number, text: clean.slice(item.index, index + 1 < matches.length ? matches[index + 1].index : clean.length)}));
  out.overflow = false;
  return out;
}

function mdImageRefs(text, limit = MAX_IMAGE_REFERENCES_PER_FORMAT) {
  const clean = stripMdCode(text);
  const refs = [];
  const inline = /!\[[^\]]*\]\((?:<([^>]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\)/g;
  let match;
  while ((match = inline.exec(clean)) !== null) {
    if (refs.length >= limit) return {refs: [], unsupported: 0, overflow: true};
    refs.push(match[1] || match[2]);
  }
  let unsupported = 0;
  const reference = /!\[[^\]]*\]\[[^\]]*\]/g;
  while (reference.exec(clean) !== null) {
    unsupported += 1;
    if (unsupported > limit) return {refs: [], unsupported, overflow: true};
  }
  return {refs, unsupported, overflow: false};
}

function htmlImageSources(text, limit = MAX_IMAGE_REFERENCES_PER_FORMAT) {
  const clean = stripHtmlInactive(text);
  const sources = [];
  const tagRe = /<img\b[^>]*>/gi;
  let match;
  while ((match = tagRe.exec(clean)) !== null) {
    if (sources.length >= limit) { sources.overflow = true; return sources; }
    const src = match[0].match(/(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
    sources.push(src ? (src[1] ?? src[2] ?? src[3]) : null);
  }
  sources.overflow = false;
  return sources;
}

function decodeHtmlEntities(text) {
  const named = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' '};
  return text
    .replace(/&#(?:x([0-9a-f]{1,6})|(\d{1,7}));?/gi, (match, hex, decimal) => {
      const value = hex ? Number.parseInt(hex, 16) : Number.parseInt(decimal, 10);
      return Number.isFinite(value) && value <= 0x10ffff ? String.fromCodePoint(value) : match;
    })
    .replace(/&(amp|lt|gt|quot|apos|nbsp|colon|sol|equals);?/gi, (match, entity) => ({...named, colon: ':', sol: '/', equals: '='})[entity.toLowerCase()] ?? match);
}

function visibleHtml(text) {
  return decodeHtmlEntities(stripHtmlInactive(text).replace(/<[^>]*>/g, ' '));
}

function answerValue(text, format) {
  let raw;
  if (format === 'html') {
    const answerElement = /<([a-z][a-z0-9]*)\b(?=[^>]*\bclass\s*=\s*(["'])[^"']*\banswer\b[^"']*\2)[^>]*>([\s\S]*?)<\/\1>/i.exec(text);
    if (!answerElement) return null;
    const match = /\bAnswer[ \t]*:[ \t]*([^<\n]+)/i.exec(answerElement[3]);
    if (!match) return null;
    raw = decodeHtmlEntities(match[1]);
  } else {
    const answerLine = /^\s*(?:>\s*)?\*\*Answer(?:\s*:|:\*\*)[^\n]*$/mi.exec(stripMdCode(text));
    if (!answerLine) return null;
    const match = /\bAnswer[ \t]*:[ \t]*(.*)$/i.exec(answerLine[0].replace(/[*_`]/g, ' '));
    if (!match) return null;
    raw = match[1];
  }
  const normalized = raw.normalize('NFKC').replace(/^[([{]+|[\])}.:]+$/g, '').trim().replace(/\s+/g, ' ').toUpperCase();
  if (!normalized) return null;
  const choice = /^([A-D])$/.exec(normalized);
  return choice ? choice[1] : normalized;
}

function countMatchesUpTo(text, re, limit) {
  re.lastIndex = 0;
  let count = 0;
  let match;
  while ((match = re.exec(text)) !== null) {
    count += 1;
    if (count > limit) return count;
    if (match[0].length === 0) re.lastIndex += 1;
  }
  return count;
}
function responseCandidateOverflow(text, format) {
  const re = format === 'html'
    ? /<(?:p|li)\b[^>]*>\s*(?:[-*+]\s*)?(?:\([A-D]\)|[A-D][.)]|[A-D])(?:\s|<)/gi
    : /^\s*(?:[-*+]\s*)?(?:\([A-D]\)|[A-D][.)]|[A-D])(?:\s|$)/gmi;
  return countMatchesUpTo(text, re, MAX_RESPONSE_CANDIDATES) > MAX_RESPONSE_CANDIDATES;
}

function responseChoices(text, format) {
  let lines;
  let structuredChoiceContainers = 0;
  const choices = [];
  if (format === 'html') {
    const beforeAnswer = text.split(/<[a-z][a-z0-9]*\b(?=[^>]*\bclass\s*=\s*(["'])[^"']*\banswer\b[^"']*\1)/i)[0];
    structuredChoiceContainers = (beforeAnswer.match(/<table\b/gi) || []).length;
    for (const row of beforeAnswer.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
      const cells = [...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]>/gi)].map(match => visibleHtml(match[1]));
      const label = /^\(?([A-D])\)?$/i.exec(cells[0] || '');
      if (label && cells[1]) choices.push({label: label[1].toUpperCase(), text: cells[1], explicit: true});
    }
    lines = [...beforeAnswer.matchAll(/<(?:p|li)\b[^>]*>([\s\S]*?)<\/(?:p|li)>/gi)].map(match => visibleHtml(match[1]));
  } else {
    const beforeAnswer = stripMdCode(text).split(/^\s*(?:>\s*)?\*\*Answer(?:\s*:|:\*\*)/mi)[0];
    structuredChoiceContainers = (beforeAnswer.match(/^\s*\|\s*:?-+/gm) || []).length + (beforeAnswer.match(/<table\b/gi) || []).length;
    lines = beforeAnswer.split(/\r?\n/).map(line => line.replace(/[*_`]/g, ' '));
    for (const row of beforeAnswer.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
      const cells = [...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]>/gi)].map(match => visibleHtml(match[1]));
      const label = /^\(?([A-D])\)?$/i.exec(cells[0] || '');
      if (label && cells[1]) choices.push({label: label[1].toUpperCase(), text: cells[1], explicit: true});
    }
    for (const line of lines) {
      const table = /^\s*\|\s*\(?([A-D])\)?\s*\|\s*(.*?)\s*\|\s*$/i.exec(line);
      if (table) choices.push({label: table[1].toUpperCase(), text: table[2], explicit: true});
    }
  }
  for (const line of lines) {
    const match = /^\s*(?:[-*+]\s*)?(?:\(([A-D])\)|([A-D])[.)]|([A-D]))(?:\s+(.*?))?\s*$/i.exec(line);
    if (!match) continue;
    choices.push({label: (match[1] || match[2] || match[3]).toUpperCase(), text: match[4] || '', explicit: Boolean(match[1] || match[2])});
  }
  for (let i = 0; i + 3 < choices.length; i += 1) {
    const run = choices.slice(i, i + 4);
    if (run.map(choice => choice.label).join('') === 'ABCD') {
      if (structuredChoiceContainers >= 4) for (const choice of run) if (!choice.text) choice.text = '[structured table choice]';
      return run;
    }
  }
  return choices.filter(choice => choice.explicit);
}

function contentTokens(text, format) {
  let value;
  if (format === 'html') {
    value = stripHtmlInactive(text)
      .replace(/<([a-z][a-z0-9]*)\b(?=[^>]*\bclass\s*=\s*(["'])[^"']*\banswer\b[^"']*\2)[^>]*>[\s\S]*?<\/\1>/gi, ' ')
      .replace(/<h4\b(?=[^>]*\bclass\s*=\s*(["'])[^"']*\bqno\b[^"']*\1)[^>]*>[\s\S]*?<\/h4>/gi, ' ');
    value = visibleHtml(value);
  } else {
    value = stripMdCode(text)
      .replace(/^\s*(?:>\s*)?\*\*Answer(?:\s*:|:\*\*)[\s\S]*$/gmi, ' ')
      .replace(/!\[[^\]]*\]\((?:<[^>]+>|[^)]+)\)/g, ' ')
      .replace(/^(?:###\s+(?:Question\s+)?\d+\.?|\*\*\d+\.\*\*|\d+\.)\s*$/gmi, ' ')
      .replace(/^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/gm, ' ')
      .replace(/[|#>*_`~]/g, ' ');
    value = decodeHtmlEntities(value.replace(/<[^>]*>/g, ' '));
  }
  const normalized = value.normalize('NFKC').toLowerCase();
  const tokens = [];
  const tokenRe = /[a-z0-9]+/g;
  let match;
  while ((match = tokenRe.exec(normalized)) !== null) {
    if (tokens.length >= MAX_TOKENS_PER_QUESTION) { tokens.overflow = true; return tokens; }
    tokens.push(match[0]);
  }
  tokens.overflow = false;
  return tokens;
}

function tokenDice(left, right) {
  if (left.length === 0 || right.length === 0) return left.length === right.length ? 1 : 0;
  const counts = new Map();
  for (const token of left) counts.set(token, (counts.get(token) || 0) + 1);
  let overlap = 0;
  for (const token of right) {
    const remaining = counts.get(token) || 0;
    if (remaining > 0) { overlap += 1; counts.set(token, remaining - 1); }
  }
  return (2 * overlap) / (left.length + right.length);
}

const crcTable = (() => {
  const table = new Uint32Array(256);
  for (let n = 0; n < 256; n += 1) {
    let value = n;
    for (let k = 0; k < 8; k += 1) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
    table[n] = value >>> 0;
  }
  return table;
})();
function crc32(bytes) {
  let crc = 0xffffffff;
  for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
  return (crc ^ 0xffffffff) >>> 0;
}
function imageMetadata(bytes) {
  if (resourceBudgetExhausted) return {error: 'aggregate_image_budget_exhausted'};
  if (!Buffer.isBuffer(bytes) || bytes.length === 0) return {error: 'empty_image'};
  if (bytes.length > MAX_IMAGE_BYTES) return {error: 'image_too_large'};
  if (bytes.length < 45 || !bytes.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return {error: 'unsupported_or_invalid_image'};
  let offset = 8;
  let width = 0;
  let height = 0;
  let bitDepth = 0;
  let colorType = -1;
  let sawIhdr = false;
  let chunkCount = 0;
  let idatCount = 0;
  let sawSrgb = false;
  let sawIdat = false;
  let idatEnded = false;
  let sawIend = false;
  const idat = [];
  while (offset + 12 <= bytes.length) {
    chunkCount += 1;
    if (chunkCount > MAX_PNG_CHUNKS) return {error: 'png_chunk_limit_exceeded'};
    const length = bytes.readUInt32BE(offset);
    const chunkEnd = offset + 12 + length;
    if (chunkEnd > bytes.length) return {error: 'truncated_png'};
    const typeBytes = bytes.subarray(offset + 4, offset + 8);
    const chunkType = typeBytes.toString('ascii');
    if (!/^[A-Za-z]{4}$/.test(chunkType) || !/[A-Z]/.test(chunkType[2])) return {error: 'invalid_png_chunk_type'};
    const data = bytes.subarray(offset + 8, offset + 8 + length);
    const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
    if (crc32(Buffer.concat([typeBytes, data])) !== expectedCrc) return {error: 'png_crc_mismatch'};
    if (!['IHDR', 'sRGB', 'IDAT', 'IEND'].includes(chunkType)) return {error: 'unsupported_png_chunk'};
    if (!sawIhdr) {
      if (chunkType !== 'IHDR' || length !== 13) return {error: 'invalid_png_ihdr'};
      width = data.readUInt32BE(0); height = data.readUInt32BE(4); bitDepth = data[8]; colorType = data[9];
      if (data[10] !== 0 || data[11] !== 0 || data[12] !== 0) return {error: 'unsupported_png_encoding'};
      sawIhdr = true;
    } else if (chunkType === 'sRGB') {
      if (sawSrgb || sawIdat || length !== 1 || data[0] > 3) return {error: 'invalid_png_srgb'};
      sawSrgb = true;
    } else if (chunkType === 'IDAT') {
      idatCount += 1;
      if (idatCount > MAX_PNG_IDAT_CHUNKS) return {error: 'png_idat_chunk_limit_exceeded'};
      if (idatEnded) return {error: 'nonconsecutive_png_idat'};
      sawIdat = true; idat.push(data);
    } else if (chunkType === 'IEND') {
      if (length !== 0 || offset + 12 !== bytes.length) return {error: 'invalid_png_iend'};
      sawIend = true; break;
    } else {
      if (sawIdat) idatEnded = true;
      if (/^[A-Z]/.test(chunkType)) return {error: 'unknown_png_critical_chunk'};
    }
    offset = chunkEnd;
  }
  if (!sawIhdr || !sawIdat || !sawIend) return {error: 'incomplete_png'};
  if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) return {error: 'invalid_image_dimensions'};
  if (width * height > MAX_IMAGE_PIXELS) return {error: 'image_dimensions_too_large'};
  const channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colorType];
  const validDepths = {0: [1,2,4,8,16], 2: [8,16], 3: [1,2,4,8], 4: [8,16], 6: [8,16]}[colorType];
  if (!channels || !validDepths?.includes(bitDepth)) return {error: 'unsupported_png_color_mode'};
  if (colorType === 3) return {error: 'palette_png_not_supported'};
  const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
  const expectedRawBytes = height * (1 + rowBytes);
  if (expectedRawBytes > MAX_DECODED_IMAGE_BYTES) { resourceBudgetExhausted = true; return {error: 'decoded_image_too_large'}; }
  aggregatePixels += width * height;
  aggregateDecodedBytes += expectedRawBytes;
  if (aggregatePixels > MAX_AGGREGATE_PIXELS || aggregateDecodedBytes > MAX_AGGREGATE_DECODED_BYTES) { resourceBudgetExhausted = true; return {error: 'aggregate_decoded_image_budget_exceeded'}; }
  const compressed = Buffer.concat(idat);
  let raw;
  try {
    const inflated = inflateSync(compressed, {maxOutputLength: expectedRawBytes, info: true});
    raw = inflated.buffer;
    if (inflated.engine?.bytesWritten !== compressed.length) return {error: 'trailing_png_compressed_data'};
  } catch { return {error: 'invalid_png_compressed_data'}; }
  if (raw.length !== expectedRawBytes) return {error: 'invalid_png_scanline_length'};
  for (let row = 0; row < height; row += 1) if (raw[row * (rowBytes + 1)] > 4) return {error: 'invalid_png_filter'};
  return {type: 'png', width, height, sha256: sha256(bytes)};
}

function resolveLocalAsset(reference, baseDirectory) {
  if (!reference || /^[a-z][a-z0-9+.-]*:/i.test(reference) || reference.startsWith('//')) {
    return {error: 'external_or_invalid_url'};
  }
  let decoded;
  try { decoded = decodeURIComponent(reference.split('#')[0].split('?')[0]); }
  catch { return {error: 'invalid_percent_encoding'}; }
  if (path.isAbsolute(decoded)) return {error: 'absolute_path'};
  const full = path.resolve(baseDirectory, decoded);
  const relative = path.relative(root, full);
  if (relative.startsWith('..') || path.isAbsolute(relative)) return {error: 'path_escape'};
  if (!fs.existsSync(full)) return {error: 'missing'};
  const stat = fs.lstatSync(full);
  if (stat.isSymbolicLink()) return {error: 'symlink_not_allowed'};
  if (!stat.isFile()) return {error: 'not_regular_file'};
  if (stat.size > MAX_IMAGE_BYTES) return {error: 'image_too_large'};
  const realRoot = fs.realpathSync(root);
  const realFull = fs.realpathSync(full);
  const realRelative = path.relative(realRoot, realFull);
  if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) return {error: 'realpath_escape'};
  if (assetCache.has(realFull)) return assetCache.get(realFull);
  let stable;
  try { stable = readStableFile(full, MAX_IMAGE_BYTES); }
  catch (error) { return {error: error.message}; }
  const bytes = stable.bytes;
  aggregateAssetBytes += bytes.length;
  if (aggregateAssetBytes > MAX_AGGREGATE_ASSET_BYTES) { resourceBudgetExhausted = true; return {error: 'aggregate_asset_bytes_exceeded'}; }
  const metadata = imageMetadata(bytes);
  if (metadata.error) return {error: metadata.error};
  const resolved = {full, relative: toPosix(relative), bytes, ...metadata};
  assetCache.set(realFull, resolved);
  return resolved;
}

function decodeDataImage(source, countAggregate = true) {
  const match = /^data:image\/(png);base64,([A-Za-z0-9+/]+={0,2})$/i.exec(source || '');
  if (!match) return {error: 'invalid_or_unsupported_data_uri'};
  let bytes;
  try { bytes = Buffer.from(match[2], 'base64'); }
  catch { return {error: 'invalid_base64'}; }
  const declaredType = match[1].toLowerCase();
  if (countAggregate) {
    aggregateEmbeddedBytes += bytes.length;
    if (aggregateEmbeddedBytes > MAX_AGGREGATE_EMBEDDED_BYTES) { resourceBudgetExhausted = true; return {error: 'aggregate_embedded_bytes_exceeded'}; }
  }
  const metadata = imageMetadata(bytes);
  if (metadata.error) return {error: metadata.error};
  if (metadata.type !== declaredType) return {error: 'mime_signature_mismatch'};
  return {bytes, ...metadata};
}

function addIssue(report, severity, code, file, detail = {}) {
  if (report.issues.length >= MAX_ISSUES) { report.issue_limit_exceeded = true; return; }
  report.issues.push({severity, code, file, ...detail});
}

let allFiles;
try { allFiles = walk(root); }
catch { usage(`deliverable tree exceeds ${MAX_WALK_ENTRIES} entries`); }
const deliverableCandidates = allFiles.filter(file => /_Complete\.(md|html)$/i.test(file));
const deliverables = deliverableCandidates.length > MAX_DELIVERABLE_FILES ? [] : deliverableCandidates;
const mdFiles = deliverables.filter(file => /\.md$/i.test(file)).sort();
const htmlFiles = deliverables.filter(file => /\.html$/i.test(file)).sort();
const report = {
  schema_version: 1,
  tool: {name: 'audit-deliverables', version: '1.0.0', node: process.version},
  generated_at: new Date().toISOString(),
  root: '.',
  contract: {expected, language, strict_ui: strictUi, allow_active_content: allowActiveContent},
  files: [],
  assets: [],
  issues: [],
  totals: {md_questions: 0, md_answers: 0, html_questions: 0, html_answers: 0, md_images: 0, html_images: 0},
};

report.issues.push(...auditDiscoveryErrors);
if (deliverableCandidates.length > MAX_DELIVERABLE_FILES) addIssue(report, 'error', 'DELIVERABLE_FILE_LIMIT_EXCEEDED', '.', {actual: deliverableCandidates.length, limit: MAX_DELIVERABLE_FILES});
if (mdFiles.length === 0) addIssue(report, 'error', 'NO_MARKDOWN_DELIVERABLES', '.');
if (htmlFiles.length === 0) addIssue(report, 'error', 'NO_HTML_DELIVERABLES', '.');

const mdByStem = new Map();
const htmlByStem = new Map();
for (const file of mdFiles) {
  const stem = toPosix(path.relative(root, file)).replace(/\.md$/i, '').toLowerCase();
  if (mdByStem.has(stem)) addIssue(report, 'error', 'DUPLICATE_MARKDOWN_STEM', stem);
  else mdByStem.set(stem, file);
}
for (const file of htmlFiles) {
  const stem = toPosix(path.relative(root, file)).replace(/\.html$/i, '').toLowerCase();
  if (htmlByStem.has(stem)) addIssue(report, 'error', 'DUPLICATE_HTML_STEM', stem);
  else htmlByStem.set(stem, file);
}
const stems = [...new Set([...mdByStem.keys(), ...htmlByStem.keys()])].sort();
const recordedAssets = new Set();
const seenPairHashes = new Set();
const seenQuestionFingerprints = new Map();

for (const stem of stems) {
  const mdPath = mdByStem.get(stem);
  const htmlPath = htmlByStem.get(stem);
  if (!mdPath) { addIssue(report, 'error', 'MISSING_MARKDOWN_PAIR', `${stem}.html`); continue; }
  if (!htmlPath) { addIssue(report, 'error', 'MISSING_HTML_PAIR', `${stem}.md`); continue; }
  const mdRel = toPosix(path.relative(root, mdPath));
  const htmlRel = toPosix(path.relative(root, htmlPath));
  let mdRead;
  let htmlRead;
  try { mdRead = readStableFile(mdPath, MAX_TEXT_BYTES); }
  catch (error) { addIssue(report, 'error', 'MARKDOWN_STABLE_READ_FAILED', mdRel, {reason: error.message}); continue; }
  try { htmlRead = readStableFile(htmlPath, MAX_TEXT_BYTES); }
  catch (error) { addIssue(report, 'error', 'HTML_STABLE_READ_FAILED', htmlRel, {reason: error.message}); continue; }
  const mdStat = mdRead.stat;
  const htmlStat = htmlRead.stat;
  aggregateTextBytes += mdStat.size + htmlStat.size;
  if (aggregateTextBytes > MAX_AGGREGATE_TEXT_BYTES) { addIssue(report, 'error', 'AGGREGATE_TEXT_BYTES_EXCEEDED', stem, {actual: aggregateTextBytes, limit: MAX_AGGREGATE_TEXT_BYTES}); continue; }
  const mdBuffer = mdRead.bytes;
  const htmlBuffer = htmlRead.bytes;
  const pairHash = sha256(Buffer.concat([mdBuffer, Buffer.from([0]), htmlBuffer]));
  if (seenPairHashes.has(pairHash)) addIssue(report, 'error', 'DUPLICATE_DELIVERABLE_CONTENT', stem, {pair_sha256: pairHash});
  else seenPairHashes.add(pairHash);
  let mdText;
  let htmlText;
  try { mdText = new TextDecoder('utf-8', {fatal: true}).decode(mdBuffer); }
  catch { addIssue(report, 'error', 'INVALID_MARKDOWN_UTF8', mdRel); continue; }
  try { htmlText = new TextDecoder('utf-8', {fatal: true}).decode(htmlBuffer); }
  catch { addIssue(report, 'error', 'INVALID_HTML_UTF8', htmlRel); continue; }
  if (/<!--|-->/.test(mdText) || /<!--|-->/.test(htmlText)) addIssue(report, 'error', 'HTML_COMMENT_NOT_ALLOWED', stem);
  const fenceCount = value => (value.match(/^\s*(?:```|~~~)/gm) || []).length;
  if (fenceCount(mdText) > 0) addIssue(report, 'error', 'FENCED_CODE_REQUIRES_MANUAL_REVIEW', mdRel);
  const hiddenContent = /(?:\s|<)(?:hidden|inert|popover)(?:\s|=|>|\/)|aria-hidden\s*=\s*["']?true|\bstyle\s*=|<(?:frame|frameset|noframes|noembed|pre|code|kbd|samp|textarea|template|noscript|xmp|plaintext|listing|dialog|details|select|option|datalist)\b/i;
  if (hiddenContent.test(mdText) || hiddenContent.test(htmlText)) addIssue(report, 'error', 'HIDDEN_OR_INERT_CONTENT', stem);
  if (/<img\b[^>]*\b(?:width|height)\s*=\s*(["']?)0(?:px)?\1(?:\s|>|\/)/i.test(htmlText)) addIssue(report, 'error', 'INVISIBLE_ZERO_DIMENSION_IMAGE', htmlRel);
  if (/(?:<|\s)[A-Za-z_:][-A-Za-z0-9_:.]*\s*=\s*(?:"[^"]*<\/?[a-z][^"]*"|'[^']*<\/?[a-z][^']*'|<\/?[a-z])/i.test(htmlText)) addIssue(report, 'error', 'HTML_TAG_INSIDE_ATTRIBUTE', htmlRel);
  if (/<img\b/i.test(mdText)) addIssue(report, 'error', 'RAW_HTML_IMAGE_IN_MARKDOWN', mdRel);
  if (/\b(?:data-src|srcset)\s*=/i.test(htmlText) || /<source\b/i.test(htmlText)) addIssue(report, 'error', 'ALTERNATE_IMAGE_SOURCE_NOT_ALLOWED', htmlRel);
  const canonicalActiveScan = decodeHtmlEntities(`${mdText}\n${htmlText}`).replace(/%([0-9a-f]{2})/gi, (match, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
  const compactActiveScan = canonicalActiveScan.replace(/[\u0000-\u0020]+/g, '');
  const activeContent = /<(?:script|style|link|iframe|frame|frameset|noframes|noembed|object|embed|applet|base|form|svg|math|input|video|audio|canvas|picture)\b|\bbackground\s*=|<meta\b[^>]*http-equiv\s*=\s*["']?refresh|<(?:img|source|link|image|use)\b[^>]*(?:src|href|xlink:href|srcset)\s*=\s*["']?https?:|\b(?:src|srcset|poster|action|xlink:href)\s*=\s*["']?(?:https?:|\/\/|data:(?!image\/png;base64,))|url\(\s*["']?(?:https?:|\/\/)|@import\s+(?:url\()?\s*["']?https?:|\bon\w+\s*=|(?:href|src)\s*=\s*["']?(?:javascript:|data:(?!image\/png;base64,))|<\s*(?:javascript:|data:)|\]\(\s*<?(?:javascript:|data:)|^\s*\[[^\]]+\]:\s*<?(?:javascript:|data:)/im;
  const encodedUrlAttribute = /(?:href|src|srcset|action|poster|xlink:href)\s*=\s*(?:["'][^"']*&(?:#|[a-z])|[^\s>]*&(?:#|[a-z]))/i;
  if (activeContent.test(canonicalActiveScan) || activeContent.test(compactActiveScan) || encodedUrlAttribute.test(htmlText)) addIssue(report, allowActiveContent ? 'warning' : 'error', 'ACTIVE_OR_EXTERNAL_CONTENT', stem);
  const sensitiveContent = /(?:X-Amz-(?:Signature|Credential|Security-Token)|X-Goog-(?:Signature|Credential)|[?#&](?:access_token|auth_token|api[_-]?key|signature|sig|token)=[^\s&#"']+|https?:\/\/[^\s\/@:]+:[^\s\/@]+@|Authorization\s*:\s*Bearer\s+\S+|\b(?:sk-|gh[pousr]_)[A-Za-z0-9_-]{20,})/i;
  if (sensitiveContent.test(canonicalActiveScan)) addIssue(report, 'error', 'SENSITIVE_URL_OR_SECRET_PATTERN', stem);
  const documentMdImages = mdImageRefs(mdText);
  const documentHtmlSources = htmlImageSources(htmlText);
  totalMarkdownImageRefs += documentMdImages.refs.length;
  totalHtmlImageRefs += documentHtmlSources.length;
  const imageReferenceLimitExceeded = documentMdImages.overflow || documentHtmlSources.overflow || totalMarkdownImageRefs > expected * 4 || totalHtmlImageRefs > expected * 4;
  if (imageReferenceLimitExceeded) addIssue(report, 'error', 'IMAGE_REFERENCE_LIMIT_EXCEEDED', stem, {markdown: totalMarkdownImageRefs, html: totalHtmlImageRefs, limit_per_format: expected * 4});
  const mSections = mdSections(mdText);
  const hSections = htmlSections(htmlText);
  if (mSections.overflow) addIssue(report, 'error', 'MARKDOWN_QUESTION_LIMIT_EXCEEDED', mdRel, {limit: MAX_QUESTION_SECTIONS});
  if (hSections.overflow) addIssue(report, 'error', 'HTML_QUESTION_LIMIT_EXCEEDED', htmlRel, {limit: MAX_QUESTION_SECTIONS});
  totalQuestionSections += mSections.length + hSections.length;
  if (totalQuestionSections > MAX_TOTAL_QUESTION_SECTIONS) { addIssue(report, 'error', 'TOTAL_QUESTION_SECTION_LIMIT_EXCEEDED', stem, {actual: totalQuestionSections, limit: MAX_TOTAL_QUESTION_SECTIONS}); continue; }
  const mdAnswers = mSections.reduce((sum, section) => {
    const count = countMatchesUpTo(section.text, /^\s*(?:>\s*)?\*\*Answer(?:\s*:|:\*\*)/gmi, 2);
    if (count !== 1) addIssue(report, 'error', 'MARKDOWN_ANSWER_ASSOCIATION', mdRel, {question_ordinal: section.ordinal, question_number: section.number, answer_count: count});
    return sum + count;
  }, 0);
  const htmlAnswers = hSections.reduce((sum, section) => {
    const count = countMatchesUpTo(section.text, /<[a-z][a-z0-9]*\b(?=[^>]*\bclass\s*=\s*(["'])[^"']*\banswer\b[^"']*\1)[^>]*>/gi, 2);
    if (count !== 1) addIssue(report, 'error', 'HTML_ANSWER_ASSOCIATION', htmlRel, {question_ordinal: section.ordinal, question_number: section.number, answer_count: count});
    return sum + count;
  }, 0);
  const mdNumbers = mSections.map(section => section.number);
  const htmlNumbers = hSections.map(section => section.number);
  if (JSON.stringify(mdNumbers) !== JSON.stringify(htmlNumbers)) addIssue(report, 'error', 'QUESTION_SEQUENCE_MISMATCH', stem, {markdown: mdNumbers, html: htmlNumbers});
  for (let i = 0; i < Math.min(mSections.length, hSections.length); i += 1) {
    const mdAnswer = answerValue(mSections[i].text, 'markdown');
    const htmlAnswer = answerValue(hSections[i].text, 'html');
    if (!mdAnswer) addIssue(report, 'error', 'EMPTY_OR_UNREADABLE_MARKDOWN_ANSWER', mdRel, {question_ordinal: i + 1, question_number: mSections[i].number});
    if (!htmlAnswer) addIssue(report, 'error', 'EMPTY_OR_UNREADABLE_HTML_ANSWER', htmlRel, {question_ordinal: i + 1, question_number: hSections[i].number});
    if (mdAnswer && htmlAnswer && mdAnswer !== htmlAnswer) addIssue(report, 'error', 'ANSWER_VALUE_MISMATCH', stem, {question_ordinal: i + 1, markdown_sha256: sha256(Buffer.from(mdAnswer)), html_sha256: sha256(Buffer.from(htmlAnswer))});
    if (responseCandidateOverflow(mSections[i].text, 'markdown') || responseCandidateOverflow(hSections[i].text, 'html')) { addIssue(report, 'error', 'RESPONSE_CANDIDATE_LIMIT_EXCEEDED', stem, {question_ordinal: i + 1, limit: MAX_RESPONSE_CANDIDATES}); continue; }
    const mdChoices = responseChoices(mSections[i].text, 'markdown');
    const htmlChoices = responseChoices(hSections[i].text, 'html');
    const mdChoiceLabels = mdChoices.map(choice => choice.label);
    const htmlChoiceLabels = htmlChoices.map(choice => choice.label);
    const mdVisualChoices = /!\[[^\]]*(?:answer[- ]?choice|four (?:choices|graphs|tables|figures))[^\]]*\]\([^)]+\)/i.test(mSections[i].text);
    const htmlVisualChoices = /<img\b[^>]*\balt\s*=\s*(["'])[^"']*(?:answer[- ]?choice|four (?:choices|graphs|tables|figures))[^"']*\1/i.test(hSections[i].text);
    const validResponse = (answer, choices, visualChoices) => {
      const labels = choices.map(choice => choice.label);
      if (!answer) return false;
      if (!/^[A-D]$/.test(answer)) return labels.length === 0;
      return (JSON.stringify(labels) === '["A","B","C","D"]' && choices.every(choice => choice.text.trim())) || (labels.length === 0 && visualChoices);
    };
    if (!validResponse(mdAnswer, mdChoices, mdVisualChoices)) addIssue(report, 'error', 'MARKDOWN_CHOICE_SET_INVALID', stem, {question_ordinal: i + 1, labels: mdChoiceLabels});
    if (!validResponse(htmlAnswer, htmlChoices, htmlVisualChoices)) addIssue(report, 'error', 'HTML_CHOICE_SET_INVALID', stem, {question_ordinal: i + 1, labels: htmlChoiceLabels});
    if (JSON.stringify(mdChoiceLabels) !== JSON.stringify(htmlChoiceLabels)) addIssue(report, 'error', 'RESPONSE_STRUCTURE_MISMATCH', stem, {question_ordinal: i + 1, markdown: mdChoiceLabels, html: htmlChoiceLabels});
    if (JSON.stringify(mdChoiceLabels) === JSON.stringify(htmlChoiceLabels)) {
      for (let choiceIndex = 0; choiceIndex < mdChoices.length; choiceIndex += 1) {
        const mdChoiceTokens = contentTokens(mdChoices[choiceIndex].text, 'markdown');
        const htmlChoiceTokens = contentTokens(htmlChoices[choiceIndex].text, 'html');
        if (mdChoiceTokens.overflow || htmlChoiceTokens.overflow || tokenDice(mdChoiceTokens, htmlChoiceTokens) < 0.9) addIssue(report, 'error', 'RESPONSE_CHOICE_CONTENT_MISMATCH', stem, {question_ordinal: i + 1, choice: mdChoices[choiceIndex].label});
      }
    }
    const mdTokens = contentTokens(mSections[i].text, 'markdown');
    const htmlTokens = contentTokens(hSections[i].text, 'html');
    if (mdTokens.overflow || htmlTokens.overflow) { addIssue(report, 'error', 'QUESTION_TOKEN_LIMIT_EXCEEDED', stem, {question_ordinal: i + 1, limit: MAX_TOKENS_PER_QUESTION}); continue; }
    totalQuestionTokens += mdTokens.length + htmlTokens.length;
    if (totalQuestionTokens > MAX_TOTAL_TOKENS) { addIssue(report, 'error', 'TOTAL_TOKEN_LIMIT_EXCEEDED', stem, {actual: totalQuestionTokens, limit: MAX_TOTAL_TOKENS}); continue; }
    const similarity = tokenDice(mdTokens, htmlTokens);
    if (similarity < 0.75) addIssue(report, 'error', 'QUESTION_CONTENT_PARITY_FAILED', stem, {question_ordinal: i + 1, score: Number(similarity.toFixed(4))});
    else if (similarity < 0.9) addIssue(report, 'warning', 'QUESTION_CONTENT_PARITY_LOW_CONFIDENCE', stem, {question_ordinal: i + 1, score: Number(similarity.toFixed(4))});
    const questionImageHashes = [];
    if (!imageReferenceLimitExceeded) {
      const questionMdRefs = mdImageRefs(mSections[i].text).refs;
      const questionHtmlSources = htmlImageSources(hSections[i].text);
      if (questionMdRefs.length !== questionHtmlSources.length) addIssue(report, 'error', 'QUESTION_IMAGE_COUNT_MISMATCH', stem, {question_ordinal: i + 1, markdown: questionMdRefs.length, html: questionHtmlSources.length});
      for (let imageIndex = 0; imageIndex < Math.min(questionMdRefs.length, questionHtmlSources.length); imageIndex += 1) {
        const mdImage = resolveLocalAsset(questionMdRefs[imageIndex], path.dirname(mdPath));
        const htmlImage = decodeDataImage(questionHtmlSources[imageIndex], false);
        if (!mdImage.error) questionImageHashes.push(mdImage.sha256);
        if (!mdImage.error && !htmlImage.error && mdImage.sha256 !== htmlImage.sha256) addIssue(report, 'error', 'QUESTION_IMAGE_BYTE_MISMATCH', stem, {question_ordinal: i + 1, image_ordinal: imageIndex + 1});
      }
    }
    if (!imageReferenceLimitExceeded) {
      const fingerprint = sha256(Buffer.from(`${mdTokens.join(' ')}\u0000${questionImageHashes.join(',')}`));
      if (seenQuestionFingerprints.has(fingerprint)) addIssue(report, 'error', 'DUPLICATE_QUESTION_FINGERPRINT', stem, {question_ordinal: i + 1, first: seenQuestionFingerprints.get(fingerprint)});
      else seenQuestionFingerprints.set(fingerprint, `${stem}#${i + 1}`);
    }
  }
  for (let i = 1; i < mdNumbers.length; i += 1) {
    if (mdNumbers[i] !== 1 && mdNumbers[i] !== mdNumbers[i - 1] + 1) addIssue(report, 'error', 'NONSEQUENTIAL_QUESTION_NUMBER', mdRel, {ordinal: i + 1, previous: mdNumbers[i - 1], current: mdNumbers[i]});
  }
  for (const section of mSections) {
    if (!exhibitRe.test(section.text)) continue;
    const exhibitImages = mdImageRefs(section.text, 8);
    const hasImage = !exhibitImages.overflow && exhibitImages.refs.length > 0;
    const hasTable = /<table\b[\s\S]*?<\/table>/i.test(section.text) || /^\s*\|.+\|\s*\r?\n\s*\|\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)+\|\s*\r?\n\s*\|.+\|\s*$/m.test(section.text);
    if (!hasImage && !hasTable) addIssue(report, 'error', 'MISSING_EXHIBIT', mdRel, {question_ordinal: section.ordinal, question_number: section.number});
  }
  const mdImages = documentMdImages;
  if (mdImages.unsupported) addIssue(report, 'error', 'UNSUPPORTED_REFERENCE_STYLE_IMAGE', mdRel, {count: mdImages.unsupported});
  const mdAssets = (imageReferenceLimitExceeded ? [] : mdImages.refs).map((reference, index) => {
    const resolved = resolveLocalAsset(reference, path.dirname(mdPath));
    if (resolved.error) addIssue(report, 'error', 'INVALID_MARKDOWN_IMAGE', mdRel, {image_ordinal: index + 1, reference_sha256: sha256(Buffer.from(reference)), reason: resolved.error});
    else if (!recordedAssets.has(resolved.relative)) {
      report.assets.push({file: resolved.relative, sha256: sha256(resolved.bytes), bytes: resolved.bytes.length});
      recordedAssets.add(resolved.relative);
    }
    return resolved;
  });
  const htmlSources = documentHtmlSources;
  const htmlAssets = (imageReferenceLimitExceeded ? [] : htmlSources).map((source, index) => {
    const decoded = decodeDataImage(source);
    if (decoded.error) addIssue(report, 'error', 'INVALID_HTML_IMAGE', htmlRel, {image_ordinal: index + 1, reason: decoded.error});
    return decoded;
  });
  if (mdAssets.length !== htmlAssets.length) addIssue(report, 'error', 'IMAGE_COUNT_MISMATCH', stem, {markdown: mdAssets.length, html: htmlAssets.length});
  for (let i = 0; i < Math.min(mdAssets.length, htmlAssets.length); i += 1) {
    if (!mdAssets[i].bytes || !htmlAssets[i].bytes) continue;
    const mdHash = sha256(mdAssets[i].bytes);
    if (mdHash !== htmlAssets[i].sha256) addIssue(report, 'error', 'EMBEDDED_IMAGE_BYTE_MISMATCH', stem, {image_ordinal: i + 1, markdown_sha256: mdHash, html_sha256: htmlAssets[i].sha256});
  }
  const mdUi = (stripMdCode(mdText).match(uiRe) || []).length;
  const htmlUi = (stripHtmlInactive(htmlText).match(uiRe) || []).length;
  if (mdUi + htmlUi > 0) addIssue(report, strictUi ? 'error' : 'warning', 'UI_RESIDUE_CANDIDATE', stem, {count: mdUi + htmlUi});
  if (language === 'en') {
    const cjkCount = (stripMdCode(mdText).match(cjkRe) || []).length + (stripHtmlInactive(htmlText).match(cjkRe) || []).length;
    if (cjkCount > 0) addIssue(report, 'error', 'NON_ENGLISH_CJK_RESIDUE', stem, {count: cjkCount});
  }
  const entry = {
    stem,
    markdown: {file: mdRel, sha256: sha256(mdBuffer), bytes: mdStat.size, mtime_ms: mdStat.mtimeMs, questions: mSections.length, answers: mdAnswers, images: mdAssets.length},
    html: {file: htmlRel, sha256: sha256(htmlBuffer), bytes: htmlStat.size, mtime_ms: htmlStat.mtimeMs, questions: hSections.length, answers: htmlAnswers, images: htmlAssets.length},
  };
  report.files.push(entry);
  report.totals.md_questions += mSections.length;
  report.totals.md_answers += mdAnswers;
  report.totals.html_questions += hSections.length;
  report.totals.html_answers += htmlAnswers;
  report.totals.md_images += mdAssets.length;
  report.totals.html_images += htmlAssets.length;
}

if (report.totals.md_questions !== expected) addIssue(report, 'error', 'EXPECTED_MARKDOWN_COUNT_MISMATCH', '.', {expected, actual: report.totals.md_questions});
if (report.totals.html_questions !== expected) addIssue(report, 'error', 'EXPECTED_HTML_COUNT_MISMATCH', '.', {expected, actual: report.totals.html_questions});
if (report.totals.md_answers !== expected) addIssue(report, 'error', 'EXPECTED_MARKDOWN_ANSWER_COUNT_MISMATCH', '.', {expected, actual: report.totals.md_answers});
if (report.totals.html_answers !== expected) addIssue(report, 'error', 'EXPECTED_HTML_ANSWER_COUNT_MISMATCH', '.', {expected, actual: report.totals.html_answers});
report.automated_structural_checks_pass = !report.issue_limit_exceeded && report.issues.every(issue => issue.severity !== 'error');
report.release_pass = false;
report.release_pass_reason = 'Manual source-pixel reconciliation, blind answer verification, rights review, and an independent current-byte review are outside this script.';

const output = `${JSON.stringify(report, null, 2)}\n`;
if (outPath) {
  fs.mkdirSync(path.dirname(outPath), {recursive: true});
  const temp = `${outPath}.tmp-${process.pid}`;
  fs.writeFileSync(temp, output, {flag: 'wx'});
  fs.renameSync(temp, outPath);
}
process.stdout.write(output);
process.exitCode = report.automated_structural_checks_pass ? 0 : 1;

SHA-256: d95e6d1f7668724c071d66d37c6612044afea7513ba85900a65b5360e1e85b7d