"""Deterministic, offline HTML adapter for canonical DiffGraph artifacts. The report is the interactive graph: a small inline SVG renderer (no runtime dependency) fills the window, a top bar summarizes the snapshot, and a details panel shows the selected node's evidence. The validated artifact is embedded as inert JSON for the script; it is not rendered as text (use ``--format json`` for the raw artifact). The renderer performs no repository reads, structural analysis, AI calls, or network access. """ from __future__ import annotations import base64 import hashlib import html import json import os import tempfile import textwrap from pathlib import Path from diffgraph.contract import ValidatedArtifact from diffgraph.formatters.common import warning_lines def _text(value: object) -> str: """Escape one artifact value for HTML text content.""" if value is None: return "null" if isinstance(value, bool): return "true" if value else "false" return html.escape(str(value), quote=True) def _embedded_json(value: object) -> str: """Embed compact JSON without allowing artifact strings to terminate the script tag.""" return ( json.dumps(value, sort_keys=True, separators=(",", ":")) .replace("<", "\\u003c") .replace(">", "\\u003e") .replace("&", "\\u0026") ) def _plural(count: int, noun: str) -> str: return f"{count} {noun}{'' if count == 1 else 's'}" def _short_ref(value: object) -> str: text = str(value or "") return text[:12] if len(text) in (40, 64) else text def _diff_label(diff_ref: dict) -> str: """Human label for the analyzed snapshot (e.g. ``main...feature``).""" kind = diff_ref.get("kind") if kind == "commit_range": base = diff_ref.get("requested_base_ref") or _short_ref(diff_ref.get("base_ref")) head = diff_ref.get("requested_head_ref") or _short_ref(diff_ref.get("head_ref")) separator = "..." if diff_ref.get("comparison_mode") == "three_dot" else ".." label = f"{base}{separator}{head}" elif kind == "working_tree": label = f"{diff_ref.get('requested_base_ref') or _short_ref(diff_ref.get('base_ref'))} → working tree" elif kind == "staged" and diff_ref.get("requested_base_ref"): label = f"{diff_ref['requested_base_ref']} → index (staged)" else: label = {"unstaged": "Unstaged changes", "staged": "Staged changes"}.get(kind, str(kind)) pathspecs = diff_ref.get("pathspecs") or [] if pathspecs: label += " — " + ", ".join(str(item) for item in pathspecs) return label # This code is static: artifact values remain inert JSON until it is parsed, and # all text is assigned through textContent (never innerHTML). Its CSP hash is # calculated at render time so the report can permit exactly this script. _INTERACTIVE_SCRIPT = textwrap.dedent( """\ (() => { 'use strict'; const NS = 'http://www.w3.org/2000/svg'; const source = document.getElementById('diffgraph-artifact'); const host = document.getElementById('diffgraph-graph'); const layout = document.getElementById('diffgraph-layout'); const panel = document.getElementById('diffgraph-details'); if (!source || !host || !panel || !layout) return; let artifact; try { artifact = JSON.parse(source.textContent); } catch (_) { return; } const files = [...(artifact.files || [])].sort((a, b) => a.id.localeCompare(b.id)); const symbols = [...(artifact.symbols || [])].sort((a, b) => a.id.localeCompare(b.id)); const relationships = [...(artifact.relationships || [])].sort((a, b) => a.id.localeCompare(b.id)); if (!files.length) return; const svg = document.createElementNS(NS, 'svg'); // The viewBox tracks the host's pixel size, so pan/zoom work in screen // pixels; the layout itself is LAYOUT_WIDTH units wide. svg.setAttribute('preserveAspectRatio', 'xMinYMin meet'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', 'Interactive DiffGraph visualization'); svg.setAttribute('tabindex', '0'); // No SVG : browsers show it as a native tooltip over every child, // hiding the graph's own tooltip. aria-label already names the graph. const viewport = document.createElementNS(NS, 'g'); svg.appendChild(viewport); host.appendChild(svg); const tooltip = document.createElement('div'); tooltip.className = 'graph-tooltip'; tooltip.hidden = true; host.appendChild(tooltip); const state = { scale: 1, x: 0, y: 0, dragging: false, collapsed: new Set(), expanded: new Set(), collapsedModules: new Set(), showUnchanged: false, selected: null, wide: false }; const DRAWN_KINDS = new Set(['calls', 'imports', 'inherits', 'implements']); const SYMBOL_RIGHT = 700; const FILE_RIGHT = 1128; const MODULE_RIGHT = 1152; const edgeIndex = new Map(); // Colors live in the stylesheet so both light and dark themes apply. const changeKinds = new Set(['added', 'deleted', 'modified', 'unchanged', 'untracked', 'renamed']); const changeClass = (kind) => `ck-${changeKinds.has(kind) ? kind : 'unchanged'}`; const svgEl = (name, attrs = {}) => { const node = document.createElementNS(NS, name); Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, String(value))); return node; }; const addText = (parent, value, x, y, cls) => { const node = svgEl('text', { x, y, class: cls || '' }); node.textContent = value == null ? '' : String(value); parent.appendChild(node); return node; }; const htmlEl = (tag, cls, text) => { const node = document.createElement(tag); if (cls) node.className = cls; if (text != null) node.textContent = String(text); return node; }; // Optional inferred summary: untrusted model text, only ever set via textContent. const summaryOf = (item) => { const summary = item && item.summary; if (!summary || typeof summary.text !== 'string' || !summary.text.trim()) return null; const inference = (summary.evidence || []).find((entry) => entry && entry.kind === 'llm_inference'); return { text: summary.text.trim().replace(/\\s+/g, ' '), model: inference && inference.model ? String(inference.model) : null }; }; const truncate = (value, max) => (value.length > max ? value.slice(0, max - 1) + '…' : value); // Keep the end of a long path (the file name matters most), cut at a '/' // where possible. Full paths stay in tooltips and the details panel. const truncatePath = (value, max) => { const text = String(value); if (text.length <= max) return text; const tail = text.slice(text.length - (max - 1)); const slash = tail.indexOf('/'); return '…' + (slash > 0 && slash < tail.length - 12 ? tail.slice(slash) : tail); }; const summaryLine = (summary, max) => truncate(`AI · ${summary.text}`, max); const fileIndex = new Map(files.map((file) => [file.id, file])); const symbolIndex = new Map(symbols.map((symbol) => [symbol.id, symbol])); const nameOf = (id) => { const file = fileIndex.get(id); if (file) return file.path || id; const symbol = symbolIndex.get(id); if (symbol) return symbol.qualified_name || symbol.name || id; if (String(id).startsWith('module::')) { const module = moduleList.find((entry) => entry.id === id); return `module ${module ? module.name : id}`; } return id; }; // Default view: changed symbols plus their containing declarations. // Call neighbours are not shown by default: one hub function can call, // or be called by, dozens of unchanged symbols. They are counted in the // per-file toggle so the change's dependency graph stays one click away. const changedSymbolIds = new Set(symbols.filter((symbol) => symbol.change_kind && symbol.change_kind !== 'unchanged').map((symbol) => symbol.id)); // Import statements are real changes but low-signal; they wait behind ▾. // Context files (outside the diff) exist only for their callers, so those show too. const contextFileIds = new Set(files.filter((file) => file.change_kind === 'unchanged').map((file) => file.id)); const relevantSymbolIds = new Set([...changedSymbolIds].filter((id) => symbolIndex.get(id).kind !== 'import')); symbols.forEach((symbol) => { if (contextFileIds.has(symbol.file_id)) relevantSymbolIds.add(symbol.id); }); [...relevantSymbolIds].forEach((id) => { let parent = symbolIndex.get(id).parent_id; while (parent && symbolIndex.has(parent) && !relevantSymbolIds.has(parent)) { relevantSymbolIds.add(parent); parent = symbolIndex.get(parent).parent_id; } }); const callNeighbourIds = new Set(); relationships.forEach((relationship) => { if (relationship.kind !== 'calls') return; if (changedSymbolIds.has(relationship.source_id)) callNeighbourIds.add(relationship.target_id); if (changedSymbolIds.has(relationship.target_id)) callNeighbourIds.add(relationship.source_id); }); // ---- Hover: a short label; full details live in the panel. ---- const showTooltip = (text, event) => { tooltip.textContent = text; tooltip.hidden = false; const rect = host.getBoundingClientRect(); tooltip.style.left = Math.max(8, event.clientX - rect.left + 12) + 'px'; tooltip.style.top = Math.max(8, event.clientY - rect.top + 12) + 'px'; }; const hideTooltip = () => { tooltip.hidden = true; }; const hoverText = (item) => { if (item.relationships) return edgeLabel(item); const summary = summaryOf(item); const head = `${item.path ? 'file' : item.kind || 'symbol'} · ${nameOf(item.id)} · ${item.change_kind || 'unknown'}`; return summary ? `${head}\\n${truncate(summary.text, 240)}` : head; }; const callLine = (relationship) => { const site = (relationship.evidence || []).find((entry) => entry && entry.kind === 'call_site' && entry.line_start); return site ? site.line_start : null; }; const edgeLabel = (edge) => { const rels = edge.relationships; const kinds = [...new Set(rels.map((r) => r.kind))].join('/'); const ends = rels.length === 1 ? `${nameOf(rels[0].source_id)} → ${nameOf(rels[0].target_id)}` : `${nameOf(edge.from.id)} → ${nameOf(edge.to.id)}`; const lines = [...new Set(rels.map(callLine).filter(Boolean))].sort((a, b) => a - b); return `${rels.length} ${kinds}: ${ends}${lines.length ? `\\nat line${lines.length > 1 ? 's' : ''} ${lines.slice(0, 8).join(', ')}${lines.length > 8 ? ', …' : ''}` : ''}`; }; // ---- Micro diff: exact before/after source embedded by the producer ---- const splitLines = (text) => { const lines = String(text || '').split('\\n'); if (lines.length && lines[lines.length - 1] === '') lines.pop(); return lines; }; const diffOps = (beforeSpan, afterSpan) => { const before = beforeSpan ? splitLines(beforeSpan.text) : []; const after = afterSpan ? splitLines(afterSpan.text) : []; const a0 = beforeSpan ? beforeSpan.line_start : 0; const b0 = afterSpan ? afterSpan.line_start : 0; const n = before.length, m = after.length; const ops = []; if (n * m > 4000000) { before.forEach((text, i) => ops.push({ op: '-', a: a0 + i, text })); after.forEach((text, j) => ops.push({ op: '+', b: b0 + j, text })); return ops; } const w = m + 1; const table = new Uint32Array((n + 1) * w); for (let i = n - 1; i >= 0; i -= 1) for (let j = m - 1; j >= 0; j -= 1) { table[i * w + j] = before[i] === after[j] ? table[(i + 1) * w + j + 1] + 1 : Math.max(table[(i + 1) * w + j], table[i * w + j + 1]); } let i = 0, j = 0; while (i < n && j < m) { if (before[i] === after[j]) { ops.push({ op: ' ', a: a0 + i, b: b0 + j, text: before[i] }); i += 1; j += 1; } else if (table[(i + 1) * w + j] >= table[i * w + j + 1]) { ops.push({ op: '-', a: a0 + i, text: before[i] }); i += 1; } else { ops.push({ op: '+', b: b0 + j, text: after[j] }); j += 1; } } for (; i < n; i += 1) ops.push({ op: '-', a: a0 + i, text: before[i] }); for (; j < m; j += 1) ops.push({ op: '+', b: b0 + j, text: after[j] }); return ops; }; const parseUnified = (text) => { const ops = []; let a = 0, b = 0; let inHunk = false; splitLines(text).forEach((line) => { // File headers precede the first hunk; inside a hunk, '--- x' is a // removed line that starts with '--' (e.g. a SQL comment). if (!inHunk && (line.startsWith('--- ') || line.startsWith('+++ '))) return; const hunk = /^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@/.exec(line); if (hunk) { inHunk = true; a = Number(hunk[1]); b = Number(hunk[2]); ops.push({ op: '@', text: line }); return; } const sign = line[0]; const body = line.slice(1); if (sign === '-') { ops.push({ op: '-', a, text: body }); a += 1; } else if (sign === '+') { ops.push({ op: '+', b, text: body }); b += 1; } else if (sign === ' ') { ops.push({ op: ' ', a, b, text: body }); a += 1; b += 1; } }); return ops; }; const foldContext = (ops, context = 3) => { const keep = ops.map(() => false); ops.forEach((entry, index) => { if (entry.op === ' ') return; for (let k = Math.max(0, index - context); k <= Math.min(ops.length - 1, index + context); k += 1) keep[k] = true; }); const rows = []; let run = []; const flush = () => { if (run.length) { rows.push(run.length > 1 ? { op: 'fold', ops: run } : run[0]); run = []; } }; ops.forEach((entry, index) => { if (keep[index] || entry.op !== ' ') { flush(); rows.push(entry); } else run.push(entry); }); flush(); return rows; }; const cell = (cls, text) => htmlEl('span', cls, text == null ? '' : text); const renderDiff = (ops, options = {}) => { const container = htmlEl('div', `diff ${state.wide ? 'diff-split' : 'diff-unified'}`); const paint = (rows) => { const out = []; const foldRow = (entry) => { const button = htmlEl('button', 'diff-fold', `⋯ ${entry.ops.length} unchanged lines`); button.type = 'button'; button.addEventListener('click', () => { const index = rows.indexOf(entry); rows.splice(index, 1, ...entry.ops); container.replaceChildren(...paint(rows)); }); return button; }; if (!state.wide) { rows.forEach((entry) => { if (entry.op === 'fold') { out.push(foldRow(entry)); return; } if (entry.op === '@') { out.push(htmlEl('div', 'diff-hunk', entry.text)); return; } const row = htmlEl('div', `diff-row op-${entry.op === '+' ? 'add' : entry.op === '-' ? 'del' : 'eq'}`); row.append(cell('ln', entry.a), cell('ln', entry.b), cell('sign', entry.op === ' ' ? '' : entry.op), cell('code', entry.text)); out.push(row); }); return out; } for (let index = 0; index < rows.length;) { const entry = rows[index]; if (entry.op === 'fold') { out.push(foldRow(entry)); index += 1; continue; } if (entry.op === '@') { out.push(htmlEl('div', 'diff-hunk', entry.text)); index += 1; continue; } if (entry.op === ' ') { const row = htmlEl('div', 'diff-row op-eq'); row.append(cell('ln', entry.a), cell('code', entry.text), cell('ln', entry.b), cell('code', entry.text)); out.push(row); index += 1; continue; } const dels = [], adds = []; while (index < rows.length && (rows[index].op === '-' || rows[index].op === '+')) { (rows[index].op === '-' ? dels : adds).push(rows[index]); index += 1; } for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) { const left = dels[k], right = adds[k]; const row = htmlEl('div', 'diff-row'); row.append( cell(`ln${left ? ' op-del' : ''}`, left && left.a), cell(`code${left ? ' op-del' : ' empty'}`, left && left.text), cell(`ln${right ? ' op-add' : ''}`, right && right.b), cell(`code${right ? ' op-add' : ' empty'}`, right && right.text), ); out.push(row); } } return out; }; const rows = options.fold ? foldContext(ops) : ops.slice(); const limit = options.limit || Infinity; if (rows.length > limit) { const visible = rows.slice(0, limit); container.replaceChildren(...paint(visible)); const more = htmlEl('button', 'show-all', `Show all ${rows.length} diff lines`); more.type = 'button'; more.addEventListener('click', () => { container.replaceChildren(...paint(rows)); }); const wrapper = htmlEl('div'); wrapper.append(container, more); return wrapper; } container.replaceChildren(...paint(rows)); return container; }; const diffSection = (heading, ops, options, note) => { if (!ops.length) return null; const wrapper = htmlEl('section', 'details-section'); const head = htmlEl('div', 'diff-head'); head.appendChild(htmlEl('h3', null, heading)); const wide = htmlEl('button', 'diff-wide', state.wide ? '⤡ Narrow' : '⤢ Side by side'); wide.type = 'button'; wide.addEventListener('click', () => { state.wide = !state.wide; layout.classList.toggle('details-wide', state.wide); renderDetails(); }); head.appendChild(wide); wrapper.appendChild(head); if (note) wrapper.appendChild(htmlEl('p', 'details-meta', note)); wrapper.appendChild(renderDiff(ops, options)); return wrapper; }; const symbolDiff = (symbol) => { const source = symbol.source; if (!source || (!source.before && !source.after)) return null; const truncated = (source.before && source.before.truncated) || (source.after && source.after.truncated); return diffSection('Change', diffOps(source.before, source.after), { fold: true }, truncated ? 'Declaration truncated at the size limit.' : null); }; const fileDiff = (file) => { if (!file.diff || !file.diff.unified) return null; return diffSection('Diff', parseUnified(file.diff.unified), { limit: 400 }, file.diff.truncated ? 'Diff truncated at the size limit.' : null); }; // ---- Details panel ---- const help = panel.querySelector('.details-help'); const body = panel.querySelector('.details-body'); const reveal = (id) => { const symbol = symbolIndex.get(id); const fileId = symbol ? symbol.file_id : id; state.collapsed.delete(fileId); state.collapsedModules.delete(moduleIdOf.get(fileId)); if (symbol && !relevantSymbolIds.has(id)) state.expanded.add(fileId); }; const linkTo = (id) => { if (!fileIndex.has(id) && !symbolIndex.has(id)) return htmlEl('code', null, id); const button = htmlEl('button', 'node-link', nameOf(id)); button.type = 'button'; button.title = nameOf(id); button.addEventListener('click', () => select(id)); return button; }; const section = (heading, ids) => { const unique = [...new Set(ids)]; if (!unique.length) return null; const wrapper = htmlEl('section', 'details-section'); wrapper.appendChild(htmlEl('h3', null, `${heading} (${unique.length})`)); const list = htmlEl('ul'); const fill = (ids) => list.replaceChildren(...ids.map((id) => { const item = htmlEl('li'); item.appendChild(linkTo(id)); return item; })); const limit = 12; fill(unique.slice(0, limit)); wrapper.appendChild(list); if (unique.length > limit) { const more = htmlEl('button', 'show-all', `Show all ${unique.length}`); more.type = 'button'; more.addEventListener('click', () => { fill(unique); more.remove(); }); wrapper.appendChild(more); } return wrapper; }; const evidenceSection = (evidence) => { const entries = (evidence || []).filter((entry) => entry && entry.kind !== 'structural_basis' && entry.kind !== 'llm_inference'); if (!entries.length) return null; const wrapper = htmlEl('section', 'details-section'); wrapper.appendChild(htmlEl('h3', null, 'Evidence')); entries.forEach((entry) => { const lines = entry.line_start ? `:${entry.line_start}${entry.line_end && entry.line_end !== entry.line_start ? `-${entry.line_end}` : ''}` : ''; wrapper.appendChild(htmlEl('p', 'evidence-kind', `${entry.kind}${entry.file ? ` · ${entry.file}${lines}` : ''}`)); if (entry.snippet) wrapper.appendChild(htmlEl('pre', 'evidence-snippet', entry.snippet)); }); return wrapper; }; const summaryBlock = (item) => { const summary = summaryOf(item); if (!summary) return null; const block = htmlEl('div', 'ai-summary'); block.appendChild(htmlEl('span', 'ai-badge', 'AI')); block.appendChild(htmlEl('span', 'ai-model', summary.model || 'summary')); block.appendChild(htmlEl('p', null, summary.text)); return block; }; const header = (kindLabel, name, changeKind) => { const wrapper = htmlEl('header', 'details-header'); wrapper.appendChild(htmlEl('p', 'details-kind', kindLabel)); wrapper.appendChild(htmlEl('h2', null, name)); if (changeKind) wrapper.appendChild(htmlEl('span', `badge ${changeClass(changeKind)}`, changeKind)); return wrapper; }; const incoming = (id, kind) => relationships.filter((r) => r.target_id === id && r.kind === kind).map((r) => r.source_id); const outgoing = (id, kind) => relationships.filter((r) => r.source_id === id && r.kind === kind).map((r) => r.target_id); const renderDetails = () => { const selected = state.selected; if (!selected) { help.hidden = false; body.hidden = true; body.replaceChildren(); return; } const parts = []; if (selected.type === 'edge') { const edge = selected.value; parts.push(header(`${edge.relationships.length} relationship${edge.relationships.length === 1 ? '' : 's'}`, `${nameOf(edge.from.id)} → ${nameOf(edge.to.id)}`, null)); edge.relationships.forEach((rel) => { const block = htmlEl('section', 'details-section'); const ends = htmlEl('p', 'details-edge'); ends.append(htmlEl('span', 'badge', rel.kind), document.createTextNode(' '), linkTo(rel.source_id), htmlEl('span', 'edge-arrow-text', ' → '), linkTo(rel.target_id)); block.appendChild(ends); const meta = [rel.label, rel.resolution_method && `resolution: ${rel.resolution_method}`, callLine(rel) && `line ${callLine(rel)}`].filter(Boolean); if (meta.length) block.appendChild(htmlEl('p', 'details-meta', meta.join(' · '))); (rel.evidence || []).filter((entry) => entry && entry.snippet).forEach((entry) => block.appendChild(htmlEl('pre', 'evidence-snippet', entry.snippet))); parts.push(block); }); } else if (fileIndex.has(selected.id)) { const file = fileIndex.get(selected.id); parts.push(header(contextFileIds.has(file.id) ? 'file outside the diff' : 'file', file.path || file.id, contextFileIds.has(file.id) ? null : file.change_kind)); if (contextFileIds.has(file.id)) parts.push(htmlEl('p', 'details-meta', 'Unchanged. Shown because it calls or imports changed code.')); const stats = []; if (file.old_path && file.old_path !== file.path) stats.push(`renamed from ${file.old_path}`); if (file.language) stats.push(file.language); if (file.lines_added != null || file.lines_removed != null) stats.push(`+${file.lines_added || 0} / -${file.lines_removed || 0}`); const fileSymbols = symbols.filter((symbol) => symbol.file_id === file.id); if (fileSymbols.length) stats.push(`${fileSymbols.filter((symbol) => changedSymbolIds.has(symbol.id)).length} of ${fileSymbols.length} symbols changed`); parts.push(htmlEl('p', 'details-meta', stats.join(' · '))); parts.push(summaryBlock(file)); parts.push(section('Changed symbols', fileSymbols.filter((symbol) => changedSymbolIds.has(symbol.id)).map((symbol) => symbol.id))); parts.push(section('Imported by', incoming(file.id, 'imports').concat(...fileSymbols.map((symbol) => incoming(symbol.id, 'imports'))).filter((id) => id !== file.id))); parts.push(section('Imports', outgoing(file.id, 'imports').filter((id) => fileIndex.has(id) || (symbolIndex.has(id) && symbolIndex.get(id).file_id !== file.id)))); parts.push(fileDiff(file)); } else { const symbol = symbolIndex.get(selected.id); parts.push(header(symbol.kind || 'symbol', symbol.qualified_name || symbol.name || symbol.id, symbol.change_kind)); const location = symbol.location ? `${symbol.location.file}:${symbol.location.line_start}-${symbol.location.line_end}` : nameOf(symbol.file_id); parts.push(htmlEl('p', 'details-meta', location)); parts.push(summaryBlock(symbol)); if (symbol.source) parts.push(symbolDiff(symbol)); else if (!changedSymbolIds.has(symbol.id)) parts.push(htmlEl('p', 'details-meta', 'Unchanged in this snapshot.')); if (symbol.parent_id) parts.push(section('Defined in', [symbol.parent_id])); parts.push(section('Inherits from', outgoing(symbol.id, 'inherits'))); parts.push(section('Subclassed by', incoming(symbol.id, 'inherits'))); parts.push(section('Calls', outgoing(symbol.id, 'calls'))); parts.push(section('Called by', incoming(symbol.id, 'calls'))); parts.push(section('Imported by', incoming(symbol.id, 'imports').filter((id) => id !== symbol.file_id))); parts.push(evidenceSection(symbol.evidence)); } body.replaceChildren(...parts.filter(Boolean)); help.hidden = true; body.hidden = false; layout.classList.remove('details-closed'); }; const select = (id) => { reveal(id); state.selected = { type: 'node', id }; renderDetails(); draw(); }; const selectEdge = (edge) => { state.selected = { type: 'edge', id: edge.key, value: edge }; renderDetails(); draw(); }; const clearSelection = () => { state.selected = null; renderDetails(); draw(); }; const closeButton = panel.querySelector('.details-close'); if (closeButton) closeButton.addEventListener('click', () => { layout.classList.add('details-closed'); clearSelection(); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && state.selected) clearSelection(); }); const highlight = (item, on) => { if (item.relationships) return; (edgeIndex.get(item.id) || new Set()).forEach((edge) => edge.classList.toggle('hover', on)); }; const interactive = (node, item, onSelect) => { node.addEventListener('pointerenter', (event) => { highlight(item, true); showTooltip(hoverText(item), event); }); node.addEventListener('pointermove', (event) => showTooltip(hoverText(item), event)); node.addEventListener('pointerleave', () => { highlight(item, false); hideTooltip(); }); node.addEventListener('click', (event) => { event.stopPropagation(); onSelect(); }); node.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(); } }); }; const isSelected = (id) => Boolean(state.selected && state.selected.id === id); // ---- Reading order: top to bottom, callers above the code they call ---- // Changed source comes first (the change's entry points at the very top), // then files outside the diff, then tests, which call everything. const fileOf = (id) => (symbolIndex.has(id) ? symbolIndex.get(id).file_id : id); const isTestPath = (path) => { const parts = String(path || '').split('/'); const base = parts[parts.length - 1]; return parts.slice(0, -1).some((part) => part === 'tests' || part === 'test') || base.startsWith('test_') || base.endsWith('_test.py'); }; const category = (file) => (isTestPath(file.path) ? 2 : file.change_kind === 'unchanged' ? 1 : 0); const dependencyEdges = relationships.filter((r) => DRAWN_KINDS.has(r.kind) && !(r.kind === 'imports' && fileOf(r.target_id) === r.source_id)); // Kahn's algorithm: a node is placed once everything that depends on it // (its callers) is placed. A cycle releases the node with the fewest // unplaced callers. Ties use ``compare``. const topoOrder = (ids, pairs, compare) => { const idSet = new Set(ids); const callers = new Map(ids.map((id) => [id, new Set()])); const callees = new Map(ids.map((id) => [id, new Set()])); pairs.forEach(([from, to]) => { if (from === to || !idSet.has(from) || !idSet.has(to)) return; callees.get(from).add(to); callers.get(to).add(from); }); const placed = new Set(); const order = []; const pending = (id) => [...callers.get(id)].filter((from) => !placed.has(from)).length; const tie = (a, b) => compare(a, b, callees); while (order.length < ids.length) { const open = ids.filter((id) => !placed.has(id)); const ready = open.filter((id) => pending(id) === 0); const next = (ready.length ? ready.sort(tie) : open.sort((a, b) => pending(a) - pending(b) || tie(a, b)))[0]; placed.add(next); order.push(next); } return order; }; const filePairs = dependencyEdges.map((r) => [fileOf(r.source_id), fileOf(r.target_id)]); const byOutdegreeThenPath = (a, b, callees) => callees.get(b).size - callees.get(a).size || nameOf(a).localeCompare(nameOf(b)); const fileOrder = [0, 1, 2].flatMap((group) => topoOrder(files.filter((file) => category(file) === group).map((file) => file.id), filePairs, byOutdegreeThenPath)); const fileRank = new Map(fileOrder.map((id, index) => [id, index])); const moduleNameOf = (file) => { const bits = String(file.path || file.id).split('/'); return bits.length > 1 ? bits.slice(0, -1).join('/') : '(root)'; }; const moduleList = []; const moduleIdOf = new Map(); // A module is a directory within one reading group (changed source, // outside the diff, tests), so grouping never pulls a later group above // an earlier one, and every module id is unique. const GROUP_SUFFIX = ['', ' · outside the diff', ' · tests']; fileOrder.forEach((id) => { const file = fileIndex.get(id); const directory = moduleNameOf(file); const group = category(file); let module = moduleList.find((entry) => entry.directory === directory && entry.group === group); if (!module) { const testsDirectory = directory.split('/').some((part) => part === 'tests' || part === 'test'); const suffix = group === 2 && testsDirectory ? '' : GROUP_SUFFIX[group]; module = { directory, group, name: `${directory}${suffix}`, id: `module::${group}::${directory}`, files: [] }; moduleList.push(module); } module.files.push(file); moduleIdOf.set(file.id, module.id); }); // Many modules start collapsed, except the first: there is always a place to start. if (moduleList.length >= 5) moduleList.slice(1).forEach((module) => state.collapsedModules.add(module.id)); const lineOf = (symbol) => (symbol.location && symbol.location.line_start) || ((symbol.evidence || [])[0] || {}).line_start || 0; const topOf = (id) => { let current = symbolIndex.get(id); while (current && current.parent_id && symbolIndex.has(current.parent_id)) current = symbolIndex.get(current.parent_id); return current ? current.id : id; }; const symbolRank = new Map(); files.forEach((file) => { const own = symbols.filter((symbol) => symbol.file_id === file.id); const tops = own.filter((symbol) => !symbol.parent_id || !symbolIndex.has(symbol.parent_id)).map((symbol) => symbol.id); const pairs = dependencyEdges.filter((r) => symbolIndex.has(r.source_id) && symbolIndex.has(r.target_id) && fileOf(r.source_id) === file.id && fileOf(r.target_id) === file.id).map((r) => [topOf(r.source_id), topOf(r.target_id)]); // Ties favour symbols that call more of the file (entry points), then source order. const byLine = (a, b, callees) => callees.get(b).size - callees.get(a).size || lineOf(symbolIndex.get(a)) - lineOf(symbolIndex.get(b)); let rank = 0; topoOrder(tops, pairs, byLine).forEach((topId) => { symbolRank.set(topId, rank++); own.filter((symbol) => symbol.id !== topId && topOf(symbol.id) === topId).sort((a, b) => lineOf(a) - lineOf(b)).forEach((symbol) => symbolRank.set(symbol.id, rank++)); }); }); const startFileId = fileOrder.length ? fileOrder[0] : null; // ---- Graph toolbar ---- const toolbar = htmlEl('div', 'graph-toolbar'); const tool = (label, onClick) => { const button = htmlEl('button', 'tool', label); button.type = 'button'; button.addEventListener('click', onClick); toolbar.appendChild(button); return button; }; const unchangedButton = tool('Show unchanged', () => { state.showUnchanged = !state.showUnchanged; unchangedButton.textContent = state.showUnchanged ? 'Hide unchanged' : 'Show unchanged'; unchangedButton.setAttribute('aria-pressed', String(state.showUnchanged)); draw(); }); unchangedButton.setAttribute('aria-pressed', 'false'); if (moduleList.length > 1) { tool('Expand modules', () => { state.collapsedModules.clear(); draw(); }); tool('Collapse modules', () => { moduleList.forEach((module) => state.collapsedModules.add(module.id)); draw(); }); } host.appendChild(toolbar); const setTransform = () => viewport.setAttribute('transform', `translate(${state.x} ${state.y}) scale(${state.scale})`); // ---- Viewport: fit width by default, fit everything on request, zoom at the cursor ---- const LAYOUT_WIDTH = 1400; const MIN_SCALE = 0.1; const MAX_SCALE = 3; // Fits may go below the wheel minimum (tall or very narrow graphs). const FIT_FLOOR = 0.005; const clampScale = (value) => Math.max(MIN_SCALE, Math.min(MAX_SCALE, value)); const fitScale = (value) => Math.max(FIT_FLOOR, Math.min(1.25, value)); const hostSize = () => ({ width: Math.max(1, host.clientWidth), height: Math.max(1, host.clientHeight) }); // The toolbar wraps on narrow panels; reserve its real height. const toolbarSpace = () => toolbar.offsetTop + toolbar.offsetHeight + 8; const syncViewBox = () => { const { width, height } = hostSize(); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); }; // Drawn bounds, so long paths or names wider than the nominal layout fit too. const drawnBox = () => { try { const box = viewport.getBBox(); if (box.width && box.height) return box; } catch (_) { /* not rendered yet */ } return { x: 0, y: 0, width: LAYOUT_WIDTH, height: 0 }; }; const fitWidth = () => { const { width } = hostSize(); const box = drawnBox(); const right = Math.max(LAYOUT_WIDTH, box.x + box.width); state.scale = fitScale((width - 16) / right); state.x = 8; state.y = toolbarSpace(); setTransform(); }; // The scale at which the whole drawn graph fits the panel. const wholeGraphScale = () => { const box = drawnBox(); if (!box.height) return MIN_SCALE; const { width, height } = hostSize(); return fitScale(Math.min((width - 24) / box.width, (height - toolbarSpace() - 12) / box.height)); }; // Zoom out until the whole graph is visible (or to the wheel minimum, // whichever is smaller), never further: the graph cannot vanish, and a // narrow width fit can still zoom out to show everything. // The floor also respects the current scale so a narrow fitWidth scale // below MIN_SCALE can still be zoomed out from. const zoomFloor = () => Math.min(state.scale, MIN_SCALE, wholeGraphScale()); const fitAll = () => { const box = drawnBox(); if (!box.height) { fitWidth(); return; } const { width } = hostSize(); const top = toolbarSpace(); state.scale = wholeGraphScale(); state.x = (width - box.width * state.scale) / 2 - box.x * state.scale; state.y = top - box.y * state.scale; state.userMoved = true; setTransform(); }; tool('Fit to view', fitAll); tool('Fit width', () => { state.userMoved = false; fitWidth(); }); const draw = () => { viewport.replaceChildren(); const positions = new Map(); let y = 8; moduleList.forEach((module) => { const moduleY = y; const group = module.files; const moduleGroup = svgEl('g', { class: 'module-container' }); viewport.appendChild(moduleGroup); const moduleCollapsed = state.collapsedModules.has(module.id); const moduleToggle = svgEl('g', { class: 'module-toggle', tabindex: '0', role: 'button', 'aria-expanded': String(!moduleCollapsed), 'aria-label': `${moduleCollapsed ? 'Expand' : 'Collapse'} module ${module.name}` }); moduleToggle.appendChild(svgEl('rect', { x: 28, y: y + 4, width: 1120, height: 26, rx: 6, class: 'toggle-hit' })); addText(moduleToggle, `${moduleCollapsed ? '▶' : '▼'} module: ${truncatePath(module.name, 120)}`, 34, y + 22, 'module-label'); if (moduleCollapsed) { const changedCount = symbols.filter((symbol) => moduleIdOf.get(symbol.file_id) === module.id && changedSymbolIds.has(symbol.id)).length; const outside = group.filter((file) => file.change_kind === 'unchanged').length; const summaryText = `${group.length} file${group.length === 1 ? '' : 's'} · ${changedCount} changed symbol${changedCount === 1 ? '' : 's'}${outside ? ` · ${outside} outside the diff` : ''}`; addText(moduleToggle, summaryText, 34, y + 44, 'change-label'); } const flipModule = (event) => { event.stopPropagation(); if (state.collapsedModules.has(module.id)) state.collapsedModules.delete(module.id); else state.collapsedModules.add(module.id); draw(); }; moduleToggle.addEventListener('click', flipModule); moduleToggle.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); flipModule(event); } }); moduleGroup.appendChild(moduleToggle); if (moduleCollapsed) { positions.set(module.id, { y: y + 22, visible: true }); group.forEach((file) => { positions.set(file.id, { visible: false }); }); y += 60; const outline = svgEl('rect', { x: 24, y: moduleY, width: 1128, height: 56, rx: 14, class: 'module-outline collapsed' }); moduleGroup.insertBefore(outline, moduleGroup.firstChild); y += 18; return; } y += 34; group.forEach((file) => { const fileSymbols = symbols.filter((symbol) => symbol.file_id === file.id); const collapsed = state.collapsed.has(file.id); const fileSummary = summaryOf(file); const headerHeight = fileSummary ? 66 : 44; const expanded = state.expanded.has(file.id); const hiddenSymbols = fileSymbols.filter((symbol) => !relevantSymbolIds.has(symbol.id)); const hiddenCount = hiddenSymbols.length; const relatedCount = hiddenSymbols.filter((symbol) => callNeighbourIds.has(symbol.id)).length; const showAll = expanded || state.showUnchanged; const shownSymbols = (showAll ? fileSymbols : fileSymbols.filter((symbol) => relevantSymbolIds.has(symbol.id))) .sort((a, b) => (symbolRank.get(a.id) || 0) - (symbolRank.get(b.id) || 0)); const symbolRows = shownSymbols.map((symbol) => ({ symbol, summary: summaryOf(symbol) })); const moreHeight = hiddenCount && !state.showUnchanged ? 34 : 0; const symbolsHeight = symbolRows.reduce((total, row) => total + (row.summary ? 54 : 38), 0) + moreHeight; const height = collapsed || !fileSymbols.length ? headerHeight + 12 : Math.max(headerHeight + 28, headerHeight + symbolsHeight); const groupNode = svgEl('g', { class: 'file-container', tabindex: '0', role: 'button', 'aria-label': `Details for ${file.path || file.id}` }); const context = contextFileIds.has(file.id); groupNode.appendChild(svgEl('rect', { x: 48, y, width: 1080, height, rx: 10, class: `file-box ${changeClass(file.change_kind)}${context ? ' context' : ''}${isSelected(file.id) ? ' selected' : ''}` })); addText(groupNode, truncatePath(file.path || file.id, file.id === startFileId ? 84 : 96), 88, y + 29, 'file-label'); if (file.id === startFileId) addText(groupNode, '◆ start here', 880, y + 29, 'start-label'); addText(groupNode, context ? 'outside diff' : file.change_kind || 'unknown', context ? 1000 : 1014, y + 29, 'change-label'); if (fileSummary) addText(groupNode, summaryLine(fileSummary, 150), 88, y + 52, 'summary-label'); interactive(groupNode, file, () => select(file.id)); viewport.appendChild(groupNode); if (fileSymbols.length) { const toggle = svgEl('g', { class: 'collapse-toggle', tabindex: '0', role: 'button', 'aria-expanded': String(!collapsed), 'aria-label': `${collapsed ? 'Expand' : 'Collapse'} ${file.path || file.id}` }); toggle.appendChild(svgEl('rect', { x: 56, y: y + 12, width: 24, height: 24, rx: 5, class: 'toggle-hit' })); addText(toggle, collapsed ? '▶' : '▼', 63, y + 29, 'toggle'); const flip = (event) => { event.stopPropagation(); if (state.collapsed.has(file.id)) state.collapsed.delete(file.id); else state.collapsed.add(file.id); draw(); }; toggle.addEventListener('click', flip); toggle.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); flip(event); } }); viewport.appendChild(toggle); } positions.set(file.id, { x: 92, y: y + 29, visible: true }); let sy = y + headerHeight + 4; if (!collapsed) symbolRows.forEach(({ symbol, summary }) => { const boxHeight = summary ? 44 : 28; const node = svgEl('g', { class: 'symbol-node', tabindex: '0', role: 'button', 'aria-label': `Details for ${symbol.kind || 'symbol'} ${symbol.qualified_name || symbol.name || symbol.id}` }); node.appendChild(svgEl('rect', { x: 80, y: sy, width: 620, height: boxHeight, rx: 7, class: `symbol-box ${changeClass(symbol.change_kind)}${isSelected(symbol.id) ? ' selected' : ''}` })); addText(node, truncate(`${symbol.kind || 'symbol'}: ${symbol.qualified_name || symbol.name || symbol.id}`, 78), 94, sy + 19, 'symbol-label'); if (summary) addText(node, summaryLine(summary, 84), 94, sy + 36, 'summary-label'); node.appendChild(svgEl('circle', { cx: 678, cy: sy + 14, r: 5, class: `change-dot ${changeClass(symbol.change_kind)}` })); interactive(node, symbol, () => select(symbol.id)); viewport.appendChild(node); positions.set(symbol.id, { x: 390, y: sy + 14, visible: true }); sy += boxHeight + 10; }); if (!collapsed && hiddenCount && !state.showUnchanged) { const more = svgEl('g', { class: 'more-toggle', tabindex: '0', role: 'button', 'aria-expanded': String(expanded) }); const related = relatedCount ? ` · ${relatedCount} call or called by changed code` : ''; const label = expanded ? `▴ Show fewer (hide ${hiddenCount})` : `▾ ${hiddenCount} more symbol${hiddenCount === 1 ? '' : 's'} (unchanged or imports)${related}`; more.setAttribute('aria-label', label); more.appendChild(svgEl('rect', { x: 80, y: sy, width: 620, height: 24, rx: 7, class: 'more-box' })); addText(more, label, 94, sy + 16, 'more-label'); const toggleMore = (event) => { event.stopPropagation(); if (state.expanded.has(file.id)) state.expanded.delete(file.id); else state.expanded.add(file.id); draw(); }; more.addEventListener('click', toggleMore); more.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleMore(event); } }); viewport.appendChild(more); } fileSymbols.forEach((symbol) => { if (collapsed || !positions.has(symbol.id)) positions.set(symbol.id, { visible: false }); }); y += height + 14; }); const moduleHeight = y - moduleY - 14; const outline = svgEl('rect', { x: 24, y: moduleY, width: 1128, height: moduleHeight, rx: 14, class: 'module-outline' }); moduleGroup.insertBefore(outline, moduleGroup.firstChild); y += 18; }); // Edges are drawn last (on top) as arcs in the empty space right of the // symbol rows (symbol ↔ symbol) or right of the file boxes (anything // touching a file header), so boxes never hide them. An endpoint hidden // by a collapsed file or the ▾ toggle is lifted to its file header, and // parallel edges between the same two anchors merge into one with a count. // Lift an endpoint to the nearest visible anchor: symbol, file, module. const anchorOf = (id) => { const pos = positions.get(id); if (pos && pos.visible) return { id, y: pos.y, x: symbolIndex.has(id) ? SYMBOL_RIGHT : FILE_RIGHT, symbol: symbolIndex.has(id) }; const fileId = fileOf(id); const filePos = fileId !== id ? positions.get(fileId) : null; if (filePos && filePos.visible) return { id: fileId, y: filePos.y, x: FILE_RIGHT, symbol: false }; const moduleId = moduleIdOf.get(fileId); const modulePos = moduleId && positions.get(moduleId); return modulePos && modulePos.visible ? { id: moduleId, y: modulePos.y, x: MODULE_RIGHT, symbol: false } : null; }; const merged = new Map(); relationships.forEach((relationship) => { if (!DRAWN_KINDS.has(relationship.kind)) return; // A file's edge to its own import statement describes the import, not a dependency. if (relationship.kind === 'imports' && fileOf(relationship.target_id) === relationship.source_id) return; const from = anchorOf(relationship.source_id); const to = anchorOf(relationship.target_id); if (!from || !to || from.id === to.id) return; const key = `${from.id} -> ${to.id}`; if (!merged.has(key)) merged.set(key, { key, from, to, relationships: [] }); merged.get(key).relationships.push(relationship); }); const touches = (edge, id) => [edge.from.id, edge.to.id].some((end) => end === id || fileOf(end) === id) || edge.relationships.some((r) => r.source_id === id || r.target_id === id); const edges = svgEl('g', { class: 'relationship-edges' }); edgeIndex.clear(); [...merged.values()].sort((a, b) => a.key.localeCompare(b.key)).forEach((edge) => { const inner = edge.from.symbol && edge.to.symbol; const x = Math.max(edge.from.x, edge.to.x); const dy = Math.abs(edge.to.y - edge.from.y); const bulge = inner ? Math.min(290, 28 + dy * 0.35) : Math.min(220, 28 + dy * 0.3); const d = `M ${edge.from.x} ${edge.from.y} C ${x + bulge} ${edge.from.y}, ${x + bulge} ${edge.to.y}, ${edge.to.x} ${edge.to.y}`; const kinds = new Set(edge.relationships.map((r) => r.kind)); const classes = ['edge', kinds.has('calls') ? 'kind-calls' : kinds.has('inherits') || kinds.has('implements') ? 'kind-inherits' : 'kind-imports']; if (isSelected(edge.key)) classes.push('selected'); else if (state.selected && state.selected.type === 'node') classes.push(touches(edge, state.selected.id) ? 'active' : 'dim'); const group = svgEl('g', { class: classes.join(' '), tabindex: '0', role: 'button', 'aria-label': edgeLabel(edge) }); group.appendChild(svgEl('path', { d, class: 'edge-hit' })); group.appendChild(svgEl('path', { d, class: 'edge-line', 'marker-end': 'url(#arrow)' })); if (edge.relationships.length > 1) addText(group, String(edge.relationships.length), x + bulge * 0.75 + 3, (edge.from.y + edge.to.y) / 2 + 4, 'edge-count'); interactive(group, edge, () => selectEdge(edge)); edges.appendChild(group); [edge.from.id, edge.to.id, fileOf(edge.from.id), fileOf(edge.to.id)].forEach((id) => { if (!edgeIndex.has(id)) edgeIndex.set(id, new Set()); edgeIndex.get(id).add(group); }); }); const defs = svgEl('defs'); const marker = svgEl('marker', { id: 'arrow', markerWidth: 8, markerHeight: 8, refX: 7, refY: 3, orient: 'auto' }); marker.appendChild(svgEl('path', { d: 'M0,0 L0,6 L8,3 z', class: 'edge-arrow' })); defs.appendChild(marker); edges.insertBefore(defs, edges.firstChild); viewport.appendChild(edges); // Expanding or collapsing changes the drawn bounds; keep the fit until // the reader zooms or pans. if (state.userMoved) setTransform(); else fitWidth(); }; svg.addEventListener('wheel', (event) => { event.preventDefault(); // Keep the layout point under the cursor fixed while zooming. const rect = svg.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; const factor = event.deltaY < 0 ? 1.12 : 0.89; const next = Math.max(zoomFloor(), Math.min(MAX_SCALE, state.scale * factor)); state.x = px - (px - state.x) * (next / state.scale); state.y = py - (py - state.y) * (next / state.scale); state.scale = next; state.userMoved = true; setTransform(); }, { passive: false }); svg.addEventListener('pointerdown', (event) => { if (event.target === svg) { state.dragging = true; state.startX = event.clientX - state.x; state.startY = event.clientY - state.y; svg.setPointerCapture(event.pointerId); } }); svg.addEventListener('pointermove', (event) => { if (state.dragging) { state.x = event.clientX - state.startX; state.y = event.clientY - state.startY; state.userMoved = true; setTransform(); } }); svg.addEventListener('pointerup', () => { state.dragging = false; }); syncViewBox(); fitWidth(); draw(); // Resizing (window, details panel, side-by-side diff) keeps the fit // until the reader zooms or pans themselves. if (typeof ResizeObserver === 'function') { new ResizeObserver(() => { syncViewBox(); if (!state.userMoved) fitWidth(); }).observe(host); } })(); """ ).strip() def _script_csp_hash(script: str) -> str: digest = hashlib.sha256(script.encode("utf-8")).digest() return base64.b64encode(digest).decode("ascii") class HtmlFormatter: """Render one already-validated DiffGraph artifact as self-contained HTML.""" def __init__(self, artifact: ValidatedArtifact): if not isinstance(artifact, ValidatedArtifact): raise TypeError("HtmlFormatter requires a ValidatedArtifact") self.artifact = artifact self.dg = artifact.value def render(self) -> str: """Return deterministic HTML: the graph plus a compact snapshot bar.""" files = self.dg["files"] symbols = self.dg["symbols"] metadata = self.dg["metadata"] self._validate_unique_object_ids(files, symbols) script_hash = _script_csp_hash(_INTERACTIVE_SCRIPT) topbar = self._topbar(files, symbols, metadata) overall = self._overall_summary() empty = ( '<p class="graph-empty">No changes in the selected snapshot.</p>' if not files else "" ) return f"""<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-{script_hash}'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"> <title>wild diff — {_text(_diff_label(self.dg["diff_ref"]))} {topbar} {overall}
{empty}
""" def _topbar(self, files: list[dict], symbols: list[dict], metadata: dict) -> str: changed_files = sum(1 for item in files if item.get("change_kind") != "unchanged") context_files = len(files) - changed_files context_note = ( f" · {_plural(context_files, 'caller file')} outside the diff" if context_files else "" ) counts = {"added": 0, "modified": 0, "deleted": 0} for symbol in symbols: if symbol.get("change_kind") in counts and symbol.get("kind") != "import": counts[symbol["change_kind"]] += 1 symbol_counts = " · ".join( f"{count} {kind}" for kind, count in counts.items() if count ) or "no symbol changes" if metadata.get("privacy_tier") == "local": privacy = 'Local only' else: calls = metadata.get("llm_calls") or 0 model = metadata.get("llm_model") label = f"AI: {model} · {_plural(calls, 'call')}" if model else str(metadata.get("privacy_tier")) providers = ", ".join(metadata.get("cloud_providers_used") or []) title = f"Code was sent to {providers}" if providers else "Data left this machine" privacy = f'{_text(label)}' raw_warnings = metadata.get("warnings", []) lines = warning_lines(raw_warnings) warnings = ( '
⚠ ' f"{_text(_plural(len(lines), 'warning'))}" '
" if lines else "" ) about = ( '
About' '
' f"
Snapshot
{_text(_diff_label(self.dg['diff_ref']))}
" f"
Generated
{_text(self.dg['generated_at'])}
" f"
wild
{_text(self.dg['wild_version'])}
" f"
Schema
{_text(self.dg['schema_version'])}
" "
Raw data
wild diff --format json
" "
" ) return ( '
' 'wild diff' f'{_text(_diff_label(self.dg["diff_ref"]))}' f"{_text(_plural(changed_files, 'file'))} · {_text(symbol_counts)}{context_note}" '' f"{privacy}{warnings}{about}" "
" ) def _overall_summary(self) -> str: summary = self.dg.get("summary") if not isinstance(summary, dict) or not str(summary.get("text") or "").strip(): return "" return ( '

AI' f"{_text(summary['text'])}

" ) @staticmethod def _validate_unique_object_ids(files: list[dict], symbols: list[dict]) -> None: """Reject duplicate object IDs; the graph keys positions and selection by ID.""" object_ids = [item["id"] for item in (*files, *symbols)] duplicate_ids = sorted(item_id for item_id in set(object_ids) if object_ids.count(item_id) > 1) if duplicate_ids: raise ValueError("DiffGraph HTML rendering requires unique file and symbol IDs; duplicates: " + ", ".join(duplicate_ids)) def write(self, destination: Path) -> Path: """Atomically write a complete report and return its absolute path.""" rendered = self.render() temporary_path = None try: with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=destination.parent, prefix=f".{destination.name}.", suffix=".tmp", delete=False) as temporary: temporary_path = Path(temporary.name) temporary.write(rendered) os.replace(temporary_path, destination) except BaseException: if temporary_path is not None: try: temporary_path.unlink() except FileNotFoundError: pass raise return destination.absolute()