From bc8d68e413eed2373529294f781b6337d1d1569e Mon Sep 17 00:00:00 2001 From: "Xingdi (Eric) Yuan" <4028684+xingdi-eric-yuan@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:40:01 -0400 Subject: [PATCH 1/2] fix(viewer): escape lineage metadata in HTML Escape short names, branch fallbacks, and test counts at rendering boundaries. Add parser and CLI regressions while preserving metadata and count display semantics. Fixes #37 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 + skills/shadow-frog-viewer/SKILL.md | 2 + skills/shadow-frog-viewer/dream-lineage.py | 14 +- .../shadow_frog_viewer/test_dream_lineage.py | 229 ++++++++++++++++++ 4 files changed, 246 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 875a339..907d757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ shadow knowledge bases for any codebase. repeated guidance and examples in the core, Dream, Init, Meditate, Update, and Viewer skills while preserving data formats, policy limits, and safety gates. +### Fixed +- **Lineage HTML escaping** — render experiment names (including branch fallbacks) + and manifest test counts as literal text in timeline and compact tree views, + without changing stored metadata or empty/zero count display behavior (#37). + --- ## 2026-09-22 diff --git a/skills/shadow-frog-viewer/SKILL.md b/skills/shadow-frog-viewer/SKILL.md index 3c0e68e..315fe53 100644 --- a/skills/shadow-frog-viewer/SKILL.md +++ b/skills/shadow-frog-viewer/SKILL.md @@ -83,6 +83,8 @@ python3 .github/skills/shadow-frog-viewer/dream-lineage.py --shadow-dir /path/to The HTML groups compounding chains and fresh experiments, includes a full lineage tree, and supports expanding each experiment's report. +Experiment names and test-count metadata render as literal text, not HTML; +escaping happens at rendering time without changing the stored metadata. ## Fallback: Shell One-Liners diff --git a/skills/shadow-frog-viewer/dream-lineage.py b/skills/shadow-frog-viewer/dream-lineage.py index 0139695..2d8f247 100644 --- a/skills/shadow-frog-viewer/dream-lineage.py +++ b/skills/shadow-frog-viewer/dream-lineage.py @@ -296,7 +296,7 @@ def flatten_chain(branch, meta, children, depth=0): def node_html(branch, meta, children, with_report=True): """Render a single node as a flat timeline row.""" info = meta.get(branch, {}) - short = info.get("short", branch) + short = htmlmod.escape(info.get("short", branch)) cat = info.get("cat", "unknown") color = CAT_COLORS.get(cat, "#607D8B") verdict = VERDICT_MAP.get(info.get("verdict", ""), "—") @@ -308,7 +308,10 @@ def node_html(branch, meta, children, with_report=True): sid = stable_id(branch) - test_badge = f'{tests} tests' if tests else "" + test_badge = ( + f'{htmlmod.escape(str(tests))} tests' + if tests else "" + ) disc_badge = f'{disc} disc' if disc else "" report_btn = "" @@ -337,7 +340,7 @@ def node_html(branch, meta, children, with_report=True): def compact_node(branch, meta, children, prefix="", is_last=True): """Render a single line in the compact tree view.""" info = meta.get(branch, {}) - short = info.get("short", branch) + short = htmlmod.escape(info.get("short", branch)) cat = info.get("cat", "unknown") color = CAT_COLORS.get(cat, "#607D8B") verdict = VERDICT_MAP.get(info.get("verdict", ""), "—") @@ -346,7 +349,10 @@ def compact_node(branch, meta, children, prefix="", is_last=True): report = info.get("full_report", "") connector = "└── " if is_last else "├── " - test_info = f' {tests}t' if tests else "" + test_info = ( + f' {htmlmod.escape(str(tests))}t' + if tests else "" + ) sid = stable_id(branch) report_btn = "" diff --git a/tests/skills/shadow_frog_viewer/test_dream_lineage.py b/tests/skills/shadow_frog_viewer/test_dream_lineage.py index 7d73f1e..d300310 100644 --- a/tests/skills/shadow_frog_viewer/test_dream_lineage.py +++ b/tests/skills/shadow_frog_viewer/test_dream_lineage.py @@ -4,6 +4,7 @@ import re import subprocess import sys +from copy import deepcopy from html.parser import HTMLParser from pathlib import Path @@ -396,6 +397,28 @@ def handle_endtag(self, tag): self.errors.append(f"close {tag} not in stack") +_HTML_TEXT = ( + ' & "café" 雪 🐸 &' +) + + +class _ContentParser(HTMLParser): + """Collect decoded text and actual elements/attributes without executing HTML.""" + + def __init__(self, html_text): + super().__init__(convert_charrefs=True) + self.elements = [] + self.text = [] + self.feed(html_text) + self.close() + + def handle_starttag(self, tag, attrs): + self.elements.append((tag, tuple(attrs))) + + def handle_data(self, data): + self.text.append(data) + + def _li_runs_inside_ul(html_text: str) -> bool: """Verify every
  • is contained within a