From 1482e0d773b7f26f07c190588eb72ab9ddbb420c Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Thu, 17 Sep 2026 09:03:38 +0530 Subject: [PATCH 1/2] feat(html): link canonical topology endpoints --- diffgraph/formatters/html.py | 48 ++++++++++++++++++++++++++++++------ tests/test_html_formatter.py | 14 +++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/diffgraph/formatters/html.py b/diffgraph/formatters/html.py index 966112b..54ab9f4 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,17 @@ 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) + 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 +125,46 @@ 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 _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..eaced24 100644 --- a/tests/test_html_formatter.py +++ b/tests/test_html_formatter.py @@ -90,6 +90,20 @@ 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_canonical_html_cli_is_atomic_honors_output_and_no_open(tmp_path, monkeypatch): root = changed_repo(tmp_path) destination = root / "report.html" From 205e690559fb56a84ec530a229deb5537e88dd88 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Thu, 17 Sep 2026 13:34:01 +0530 Subject: [PATCH 2/2] fix(html): reject duplicate object anchors --- diffgraph/formatters/html.py | 16 ++++++++++++++++ tests/test_html_formatter.py | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/diffgraph/formatters/html.py b/diffgraph/formatters/html.py index 54ab9f4..27229a6 100644 --- a/diffgraph/formatters/html.py +++ b/diffgraph/formatters/html.py @@ -58,6 +58,7 @@ def render(self) -> str: metadata = self.dg["metadata"] warnings = metadata.get("warnings", []) + self._validate_unique_object_ids(files, symbols) anchors = { item["id"]: self._object_anchor(item["id"]) for item in (*files, *symbols) @@ -131,6 +132,21 @@ def _object_anchor(item_id: str) -> str: 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] diff --git a/tests/test_html_formatter.py b/tests/test_html_formatter.py index eaced24..c8fe189 100644 --- a/tests/test_html_formatter.py +++ b/tests/test_html_formatter.py @@ -104,6 +104,16 @@ def test_html_formatter_links_relationship_endpoints_to_stable_object_anchors(): 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"