diff --git a/diffgraph/formatters/html.py b/diffgraph/formatters/html.py
index 966112b..27229a6 100644
--- a/diffgraph/formatters/html.py
+++ b/diffgraph/formatters/html.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import html
+import hashlib
import json
import os
import tempfile
@@ -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)
warning_items = (
"".join(f"
{_json(warning)}" for warning in warnings)
or "None"
@@ -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'{html.escape(empty_message)}
'
return "".join(
- f'{_text(item["id"])}
{_json(item)}'
+ f'{_text(item["id"])}
'
+ f'{_json(item)}'
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 'No relationships in the artifact.
'
+
+ def endpoint(item_id: str) -> str:
+ anchor = anchors.get(item_id)
+ text = _text(item_id)
+ if anchor is None:
+ return f"{text}"
+ return (
+ f''
+ f"{text}"
+ )
+
return "".join(
""
- f'{_text(item["source_id"])}'
+ f'
{endpoint(item["source_id"])}'
f'{_text(item["kind"])}'
- f'{_text(item["target_id"])}
'
+ f'{endpoint(item["target_id"])}
'
f'{_json(item)}'
""
for item in relationships
diff --git a/tests/test_html_formatter.py b/tests/test_html_formatter.py
index d998f91..c8fe189 100644
--- a/tests/test_html_formatter.py
+++ b/tests/test_html_formatter.py
@@ -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'' 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"