Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions diffgraph/formatters/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import html
import hashlib
import json
import os
import tempfile
Expand Down Expand Up @@ -57,9 +58,18 @@ def render(self) -> str:
metadata = self.dg["metadata"]
warnings = metadata.get("warnings", [])

file_items = self._object_items(files, "No files in the selected snapshot.")
symbol_items = self._object_items(symbols, "No symbols in the artifact.")
relationship_items = self._relationship_items(relationships)
self._validate_unique_object_ids(files, symbols)
anchors = {
item["id"]: self._object_anchor(item["id"])
for item in (*files, *symbols)
}
file_items = self._object_items(
files, "No files in the selected snapshot.", anchors
)
symbol_items = self._object_items(
symbols, "No symbols in the artifact.", anchors
)
relationship_items = self._relationship_items(relationships, anchors)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
warning_items = (
"".join(f"<li><pre>{_json(warning)}</pre></li>" for warning in warnings)
or "<li>None</li>"
Expand Down Expand Up @@ -116,23 +126,61 @@ def render(self) -> str:
"""

@staticmethod
def _object_items(items: list[dict], empty_message: str) -> str:
def _object_anchor(item_id: str) -> str:
"""Return a stable, safe fragment target for a canonical object ID."""
return "object-{}".format(
hashlib.sha256(item_id.encode("utf-8")).hexdigest()
)

@staticmethod
def _validate_unique_object_ids(
files: list[dict], symbols: list[dict]
) -> None:
"""Reject duplicate object IDs before rendering ambiguous anchor targets."""
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; "
f"duplicates: {', '.join(duplicate_ids)}"
)

@staticmethod
def _object_items(
items: list[dict], empty_message: str, anchors: dict[str, str]
) -> str:
if not items:
return f'<p class="panel">{html.escape(empty_message)}</p>'
return "".join(
f'<article><h3><code>{_text(item["id"])}</code></h3><pre>{_json(item)}</pre></article>'
f'<article id="{anchors[item["id"]]}"><h3><code>{_text(item["id"])}</code></h3>'
f'<pre>{_json(item)}</pre></article>'
for item in items
)

@staticmethod
def _relationship_items(relationships: list[dict]) -> str:
def _relationship_items(
relationships: list[dict], anchors: dict[str, str]
) -> str:
if not relationships:
return '<p class="panel">No relationships in the artifact.</p>'

def endpoint(item_id: str) -> str:
anchor = anchors.get(item_id)
text = _text(item_id)
if anchor is None:
return f"<code>{text}</code>"
return (
f'<a href="#{anchor}" aria-label="Jump to {text}">'
f"<code>{text}</code></a>"
)

return "".join(
"<article>"
f'<div class="edge"><code>{_text(item["source_id"])}</code>'
f'<div class="edge">{endpoint(item["source_id"])}'
f'<span class="kind">{_text(item["kind"])}</span>'
f'<code>{_text(item["target_id"])}</code></div>'
f'{endpoint(item["target_id"])}</div>'
f'<pre>{_json(item)}</pre>'
"</article>"
for item in relationships
Expand Down
24 changes: 24 additions & 0 deletions tests/test_html_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,30 @@ def test_html_formatter_sorts_topology_and_escapes_artifact_text():
assert embedded_artifact(report) == value


def test_html_formatter_links_relationship_endpoints_to_stable_object_anchors():
value = golden_artifact()
artifact = ValidatedArtifact.from_value(value)

report = HtmlFormatter(artifact).render()

relationship = value["relationships"][0]
for item_id in (relationship["source_id"], relationship["target_id"]):
anchor = HtmlFormatter._object_anchor(item_id)
assert f'<article id="{anchor}">' in report
assert f'href="#{anchor}"' in report
assert f'aria-label="Jump to {item_id}"' in report


def test_html_formatter_rejects_duplicate_object_ids_before_rendering_anchors():
value = golden_artifact()
duplicate_file = dict(value["files"][0])
value["files"].append(duplicate_file)
artifact = ValidatedArtifact.from_value(value)

with pytest.raises(ValueError, match="requires unique file and symbol IDs"):
HtmlFormatter(artifact).render()


def test_canonical_html_cli_is_atomic_honors_output_and_no_open(tmp_path, monkeypatch):
root = changed_repo(tmp_path)
destination = root / "report.html"
Expand Down
Loading