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"