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 or .
@@ -535,6 +558,90 @@ def test_deep_chain_indents(self, dream_lineage):
assert out.count("ct-line") >= 3
+@pytest.mark.parametrize("renderer,suffix", [
+ ("node_html", " tests"),
+ ("compact_node", "t"),
+])
+class TestNodeMetadataEscaping:
+ """Both node renderers preserve metadata as literal, unmodified text."""
+
+ @pytest.mark.parametrize("field", ["short", "tests"])
+ @pytest.mark.parametrize("value", [
+ _HTML_TEXT,
+ ' café & "double" \'single\' 雪 🐸 ',
+ "<img onerror="void(0)"> &",
+ ], ids=["tag", "unicode-spaces", "literal-entities"])
+ def test_metadata_is_text_not_markup(
+ self, dream_lineage, renderer, suffix, field, value,
+ ):
+ render = getattr(dream_lineage, renderer)
+ branch = "dream/proj/experiment"
+ control = {branch: {
+ "short": "experiment", "tests": "7",
+ "title": 'Title & "雪" 🐸', "full_report": "Report body",
+ }}
+ meta = deepcopy(control)
+ meta[branch][field] = value
+ children = {branch: []}
+ before = deepcopy((meta, children))
+
+ parsed = _ContentParser(render(branch, meta, children))
+ baseline = _ContentParser(render(branch, control, children))
+
+ assert parsed.elements == baseline.elements
+ expected = value + suffix if field == "tests" else value
+ assert parsed.text.count(expected) == 1
+ assert 'Title & "雪" 🐸' in parsed.text
+ assert (meta, children) == before
+
+ @pytest.mark.parametrize("has_metadata", [False, True])
+ def test_missing_short_escapes_branch_fallback(
+ self, dream_lineage, renderer, suffix, has_metadata,
+ ):
+ render = getattr(dream_lineage, renderer)
+ branch = f"dream/proj/{_HTML_TEXT}"
+ meta = {branch: {"full_report": "Report body"}} if has_metadata else {}
+ children = {}
+ before = deepcopy((meta, children))
+ control = deepcopy(meta)
+ control.setdefault(branch, {})["short"] = "experiment"
+
+ parsed = _ContentParser(render(branch, meta, children))
+
+ assert parsed.elements == _ContentParser(render(branch, control, children)).elements
+ assert parsed.text.count(branch) == 1
+ assert (meta, children) == before
+
+ @pytest.mark.parametrize("info,short,tests", [
+ ({}, "branch", None),
+ ({"short": "", "tests": ""}, "", None),
+ ({"short": "0", "tests": "0"}, "0", "0"),
+ ({"tests": 0}, "branch", None),
+ ({"tests": None}, "branch", None),
+ ({"tests": 7}, "branch", "7"),
+ ({"tests": "007"}, "branch", "007"),
+ ({"tests": 2.5}, "branch", "2.5"),
+ ])
+ def test_empty_and_numeric_display_semantics(
+ self, dream_lineage, renderer, suffix, info, short, tests,
+ ):
+ meta = {"branch": info}
+ before = deepcopy(meta)
+ out = getattr(dream_lineage, renderer)("branch", meta, {})
+ parsed = _ContentParser(out)
+
+ name = re.search(r']*>(.*?)', out)
+ assert name and name.group(1) == short
+ badges = [
+ attrs for tag, attrs in parsed.elements
+ if dict(attrs).get("class") in {"badge test", "ct-test"}
+ ]
+ assert len(badges) == (0 if tests is None else 1)
+ if tests is not None:
+ assert tests + suffix in parsed.text
+ assert meta == before
+
+
# ---------------------------------------------------------------------------
# generate_html — happy path on coupon-demo
# ---------------------------------------------------------------------------
@@ -637,6 +744,128 @@ def test_fresh_count_equals_total(self, dream_lineage, coupon_demo, tmp_path, ca
assert "3 fresh" in msg
+class TestGenerateHtmlEscaping:
+ """Real index, manifest, and report files exercise every rendering path."""
+
+ @pytest.mark.parametrize("count_field", ["tests_passed", "test_count"])
+ @pytest.mark.parametrize("via_cli", [
+ False,
+ pytest.param(True, marks=[pytest.mark.slow, pytest.mark.integration]),
+ ], ids=["function", "cli"])
+ def test_untrusted_metadata_remains_literal(
+ self, dream_lineage, tmp_path, count_field, via_cli,
+ ):
+ shadow = tmp_path / ".shadow"
+ rows = [
+ ("20250101-120000Z-root & 'café 雪' 🐸", "investigation", "useful",
+ f"Root title {_HTML_TEXT}", "dream/proj/root", "main", "aaa"),
+ ("20250102-120000Z-fresh & 'café 雪' 🐸", "investigation", "useful",
+ f"Fresh title {_HTML_TEXT}", "dream/proj/fresh", "main", "bbb"),
+ ]
+ report = f"**Evidence**\n\n{_HTML_TEXT}\n\n```text\n{_HTML_TEXT}\n```"
+ reports = {row[0]: report for row in rows}
+ manifests = {row[0]: {count_field: _HTML_TEXT} for row in rows}
+ dreams = _build_dreams(shadow, rows, reports, manifests)
+ index_only = [
+ (f"20250103-120000Z-chain-name {_HTML_TEXT}", "investigation", "useful",
+ "Chain child", "dream/proj/child", "dream/proj/root", "ccc"),
+ (f"20250104-120000Z-fresh-name {_HTML_TEXT}", "investigation", "useful",
+ "Fresh leaf", "dream/proj/leaf", "main", "ddd"),
+ ]
+ # Index-only IDs can contain markup without creating invalid Windows filenames.
+ with (dreams / "_index.md").open("a", encoding="utf-8") as index:
+ for row in index_only:
+ index.write("| " + " | ".join(row) + " |\n")
+ inputs = {path: path.read_bytes() for path in dreams.rglob("*") if path.is_file()}
+
+ meta, children = dream_lineage.load_index(str(shadow))
+ dream_lineage.load_reports(str(shadow), meta)
+ for row in rows + index_only:
+ assert meta[row[4]]["short"] == row[0].split("Z-")[-1]
+ for row in rows:
+ assert meta[row[4]]["tests"] == _HTML_TEXT
+ assert meta[row[4]]["full_report"] == report
+ before = deepcopy((meta, children))
+ for branch in meta:
+ dream_lineage.node_html(branch, meta, children)
+ dream_lineage.compact_node(branch, meta, children)
+ assert (meta, children) == before
+
+ out = tmp_path / "lineage.html"
+ if via_cli:
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT), "--shadow-dir", str(shadow),
+ "-o", str(out)],
+ capture_output=True, text=True, encoding="utf-8",
+ cwd=tmp_path, timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ else:
+ dream_lineage.generate_html(str(shadow), str(out))
+ parsed = _ContentParser(out.read_text(encoding="utf-8"))
+
+ assert all(tag != "img" for tag, attrs in parsed.elements)
+ assert all(
+ name not in {"onerror", "data-lineage-test"}
+ for tag, attrs in parsed.elements for name, value in attrs
+ )
+ for row in rows + index_only:
+ short = row[0].split("Z-")[-1]
+ assert parsed.text.count(short) == (3 if row[0] in reports else 2)
+ for row in rows:
+ assert parsed.text.count(row[3]) == 2
+ assert f"✅ {row[3]}" in parsed.text
+ assert parsed.text.count(f"{_HTML_TEXT} tests") == 2
+ assert parsed.text.count(f"{_HTML_TEXT}t") == 2
+ assert parsed.text.count(_HTML_TEXT) == 2
+ assert parsed.text.count(_HTML_TEXT + "\n") == 2
+ assert {path: path.read_bytes() for path in inputs} == inputs
+
+ @pytest.mark.parametrize("manifest,expected", [
+ (None, ""),
+ ({}, ""),
+ ({"tests_passed": ""}, ""),
+ ({"test_count": ""}, ""),
+ ({"tests_passed": 0}, "0"),
+ ({"test_count": 0}, "0"),
+ ({"tests_passed": "0"}, "0"),
+ ({"test_count": "007"}, "007"),
+ ({"tests_passed": 7}, "7"),
+ ({"test_count": 2.5}, "2.5"),
+ ({"tests_passed": None}, "None"),
+ ({"tests_passed": "", "test_count": 99}, ""),
+ ({"tests_passed": 0, "test_count": 99}, "0"),
+ ({"tests_passed": 7, "test_count": _HTML_TEXT}, "7"),
+ ])
+ def test_manifest_count_display_semantics(
+ self, dream_lineage, tmp_path, manifest, expected,
+ ):
+ shadow = tmp_path / ".shadow"
+ did = "20250101-120000Z-counts"
+ branch = f"dream/proj/{did}"
+ manifests = {} if manifest is None else {did: manifest}
+ _build_dreams(
+ shadow,
+ [(did, "investigation", "useful", "Counts", branch, "main", "aaa")],
+ manifests=manifests,
+ )
+ meta, _ = dream_lineage.load_index(str(shadow))
+ dream_lineage.load_reports(str(shadow), meta)
+ assert meta[branch]["tests"] == expected
+
+ out = tmp_path / "lineage.html"
+ dream_lineage.generate_html(str(shadow), str(out))
+ parsed = _ContentParser(out.read_text(encoding="utf-8"))
+ badges = [
+ attrs for tag, attrs in parsed.elements
+ if dict(attrs).get("class") in {"badge test", "ct-test"}
+ ]
+ assert len(badges) == (2 if expected else 0)
+ if expected:
+ assert parsed.text.count(expected + " tests") == 1
+ assert parsed.text.count(expected + "t") == 1
+
+
# ---------------------------------------------------------------------------
# generate_html — synthetic edge cases
# ---------------------------------------------------------------------------
From 2bc056a98eea9b9b5df79732de4712cd6d49786e Mon Sep 17 00:00:00 2001
From: "Xingdi (Eric) Yuan" <4028684+xingdi-eric-yuan@users.noreply.github.com>
Date: Wed, 23 Sep 2026 10:10:29 -0400
Subject: [PATCH 2/2] Surface actionable lineage errors and warnings
Report required input/output failures with repair guidance, replace silent optional-data fallbacks with contextual warnings, and keep ordinary escaped metadata successful. Document stderr forwarding and agent-managed retries.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
CHANGELOG.md | 4 +
skills/shadow-frog-viewer/SKILL.md | 7 +
skills/shadow-frog-viewer/dream-lineage.py | 260 ++++++++++++------
.../shadow_frog_viewer/test_dream_lineage.py | 151 +++++++++-
4 files changed, 332 insertions(+), 90 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 907d757..9cee18e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,10 @@ shadow knowledge bases for any codebase.
and Viewer skills while preserving data formats, policy limits, and safety gates.
### Fixed
+- **Lineage error feedback** — required input/output failures now name the
+ affected path and repair action on stderr. Recoverable metadata omissions
+ and malformed rows produce visible warnings instead of silent fallbacks;
+ invalid explicit shadow paths no longer select a different shadow.
- **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).
diff --git a/skills/shadow-frog-viewer/SKILL.md b/skills/shadow-frog-viewer/SKILL.md
index 315fe53..ccee5a9 100644
--- a/skills/shadow-frog-viewer/SKILL.md
+++ b/skills/shadow-frog-viewer/SKILL.md
@@ -86,6 +86,13 @@ 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.
+Pass stderr back to the agent even when generation exits 0. Required directory,
+index, or output failures emit `ERROR` and exit 1; malformed optional metadata
+or omitted rows emit `WARNING` with the affected path/field and produce a
+partial view. Repair the indicated input or permissions and rerun the same
+command. Missing optional artifacts and safely escaped text are not errors.
+The helper does not invoke a model or retry automatically.
+
## Fallback: Shell One-Liners
If the Python script fails to execute (wrong Python version, missing
diff --git a/skills/shadow-frog-viewer/dream-lineage.py b/skills/shadow-frog-viewer/dream-lineage.py
index 2d8f247..a83f1e9 100644
--- a/skills/shadow-frog-viewer/dream-lineage.py
+++ b/skills/shadow-frog-viewer/dream-lineage.py
@@ -12,17 +12,59 @@
python3 dream-lineage.py # writes dream-lineage.html
python3 dream-lineage.py -o custom-name.html # custom output path
python3 dream-lineage.py --shadow-dir /path/to/.shadow
+
+Required input/output failures emit ERROR on stderr and exit 1. Recoverable
+omissions emit WARNING on stderr while producing a partial view. Correct the
+named input or output path and rerun; this helper does not invoke an LLM or retry.
"""
import argparse
import html as htmlmod
import json
+import logging
import os
import re
import sys
from collections import defaultdict
+logger = logging.getLogger(__name__)
+
+
+class LineageError(ValueError):
+ """A required input or output prevents generating the lineage report."""
+
+
+def _warn_input(path, problem):
+ logger.warning("Input %r: %s. Repair the indicated input and rerun dream-lineage.py.", path, problem)
+
+
+def _read_optional_text(path, limit=None):
+ if not os.path.lexists(path):
+ return None
+ try:
+ with open(path, encoding="utf-8") as stream:
+ return stream.read() if limit is None else stream.read(limit)
+ except (OSError, UnicodeError) as exc:
+ _warn_input(path, f"cannot read optional artifact; omitting its details ({exc})")
+ return None
+
+
+def _read_optional_manifest(path):
+ text = _read_optional_text(path)
+ if text is None:
+ return None
+ try:
+ manifest = json.loads(text)
+ except json.JSONDecodeError as exc:
+ _warn_input(path, f"invalid JSON; omitting manifest metadata ({exc})")
+ return None
+ if not isinstance(manifest, dict):
+ _warn_input(path, "manifest must be a JSON object; omitting its metadata")
+ return None
+ return manifest
+
+
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
@@ -52,60 +94,71 @@ def parse_args():
def find_shadow_dir(hint=None):
- if hint and os.path.isdir(hint):
+ if hint is not None:
+ if not os.path.isdir(hint):
+ raise LineageError(
+ f"Invalid --shadow-dir {hint!r}: expected an existing directory. "
+ "Correct the path and retry."
+ )
return hint
for candidate in [".shadow", os.path.join(os.getcwd(), ".shadow")]:
if os.path.isdir(candidate):
return candidate
- print("ERROR: .shadow/ directory not found. Use --shadow-dir.", file=sys.stderr)
- sys.exit(1)
+ raise LineageError(".shadow/ directory not found. Set --shadow-dir to the intended shadow and retry.")
def load_index(shadow_dir):
"""Parse _dreams/_index.md into structured data."""
index_path = os.path.join(shadow_dir, "_dreams", "_index.md")
- if not os.path.exists(index_path):
- print(f"ERROR: {index_path} not found.", file=sys.stderr)
- sys.exit(1)
+ try:
+ with open(index_path, encoding="utf-8") as stream:
+ index_lines = stream.readlines()
+ except (OSError, UnicodeError) as exc:
+ raise LineageError(
+ f"Cannot read lineage index {index_path!r}: {exc}. "
+ "Restore a readable UTF-8 index or correct --shadow-dir, then retry."
+ ) from exc
children = defaultdict(list)
meta = {}
branch_by_slug = {}
+ rows = []
# First pass: collect all branches and build slug index
- with open(index_path, encoding="utf-8") as f:
- for line in f:
- parts = [p.strip() for p in line.split("|")]
- if len(parts) < 8:
- continue
- did, cat, verdict, title, branch, parent, tip = parts[1:8]
- if not did or did.startswith("-") or did == "dream_id":
- continue
- short = did.split("Z-")[-1] if "Z-" in did else did
- meta[branch] = {
- "short": short, "cat": cat, "verdict": verdict,
- "title": title.strip(), "did": did, "tip": tip,
- }
- slug_match = re.search(r"t\d+-", branch)
- if slug_match:
- branch_by_slug[branch[slug_match.start():]] = branch
+ for line_number, line in enumerate(index_lines, 1):
+ parts = [p.strip() for p in line.split("|")]
+ if len(parts) < 8:
+ if line.lstrip().startswith("|"):
+ _warn_input(index_path, f"line {line_number}: malformed table row; expected seven columns, skipping row")
+ continue
+ did, cat, verdict, title, branch, parent, tip = parts[1:8]
+ if did.startswith("-") or did == "dream_id":
+ continue
+ if not did or not branch or not parent:
+ _warn_input(index_path, f"line {line_number}: missing dream_id, branch, or parent; skipping row")
+ continue
+ if branch in meta:
+ _warn_input(index_path, f"line {line_number}: duplicate branch {branch!r}; keeping its first row")
+ continue
+ rows.append((branch, parent))
+ short = did.split("Z-")[-1] if "Z-" in did else did
+ meta[branch] = {
+ "short": short, "cat": cat, "verdict": verdict,
+ "title": title.strip(), "did": did, "tip": tip,
+ }
+ slug_match = re.search(r"t\d+-", branch)
+ if slug_match:
+ branch_by_slug[branch[slug_match.start():]] = branch
# Second pass: resolve parent references (handle timestamp mismatches)
- with open(index_path, encoding="utf-8") as f:
- for line in f:
- parts = [p.strip() for p in line.split("|")]
- if len(parts) < 8:
- continue
- did, cat, verdict, title, branch, parent, tip = parts[1:8]
- if not did or did.startswith("-") or did == "dream_id":
- continue
- resolved = parent
- if parent != "main" and parent not in meta:
- m = re.search(r"t\d+-", parent)
- if m and parent[m.start():] in branch_by_slug:
- resolved = branch_by_slug[parent[m.start():]]
- if branch not in children[resolved]:
- children[resolved].append(branch)
+ for branch, parent in rows:
+ resolved = parent
+ if parent != "main" and parent not in meta:
+ m = re.search(r"t\d+-", parent)
+ if m and parent[m.start():] in branch_by_slug:
+ resolved = branch_by_slug[parent[m.start():]]
+ if branch not in children[resolved]:
+ children[resolved].append(branch)
# Third pass: check manifest.json and report body for better parent info
dreams_dir = os.path.join(shadow_dir, "_dreams")
@@ -117,26 +170,21 @@ def load_index(shadow_dir):
mp = ""
# Try manifest.json first
manifest_path = os.path.join(dreams_dir, did, "manifest.json")
- if os.path.exists(manifest_path):
- try:
- with open(manifest_path, encoding="utf-8") as f:
- mdata = json.load(f)
- mp = mdata.get("parent_branch", "")
- except Exception:
- pass
+ mdata = _read_optional_manifest(manifest_path)
+ if mdata is not None:
+ mp = mdata.get("parent_branch", "")
+ if mp is not None and not isinstance(mp, str):
+ _warn_input(manifest_path, "parent_branch must be text; ignoring the parent override")
+ mp = ""
# Try report body for parent references
if not mp or mp == "main":
report_path = os.path.join(dreams_dir, did, "report.md")
- if os.path.exists(report_path):
- try:
- with open(report_path, encoding="utf-8") as f:
- head = f.read(2000)
- # Check builds_on in frontmatter
- m = re.search(r"builds_on:\s*\[?\s*[\"']?([^\]\"'\n,]+)", head)
- if m:
- mp = m.group(1).strip().strip("\"'")
- except Exception:
- pass
+ head = _read_optional_text(report_path, limit=2000)
+ if head:
+ # Check builds_on in frontmatter
+ m = re.search(r"builds_on:\s*\[?\s*[\"']?([^\]\"'\n,]+)", head)
+ if m:
+ mp = m.group(1).strip().strip("\"'")
if not mp or mp == "main":
continue
# Resolve via slug matching
@@ -165,8 +213,8 @@ def load_index(shadow_dir):
children["main"].remove(branch)
if branch not in children[resolved]:
children[resolved].append(branch)
- except ValueError:
- pass
+ except ValueError as exc:
+ _warn_input(index_path, f"cannot re-parent {branch!r}: {exc}")
return meta, children
@@ -180,28 +228,24 @@ def load_reports(shadow_dir, meta):
info["tests"] = ""
info["discoveries_count"] = 0
- if os.path.exists(report_path):
- try:
- with open(report_path, encoding="utf-8") as f:
- content = f.read()
- body = content
- if content.startswith("---"):
- fm_end = content.find("---", 3)
- if fm_end > 0:
- body = content[fm_end + 3:].strip()
- info["full_report"] = body
- except Exception:
- pass
+ content = _read_optional_text(report_path)
+ if content is not None:
+ body = content
+ if content.startswith("---"):
+ fm_end = content.find("---", 3)
+ if fm_end > 0:
+ body = content[fm_end + 3:].strip()
+ info["full_report"] = body
manifest_path = os.path.join(shadow_dir, "_dreams", did, "manifest.json")
- if os.path.exists(manifest_path):
- try:
- with open(manifest_path, encoding="utf-8") as f:
- mdata = json.load(f)
- info["tests"] = str(mdata.get("tests_passed", mdata.get("test_count", "")))
- info["discoveries_count"] = len(mdata.get("discoveries", []))
- except Exception:
- pass
+ mdata = _read_optional_manifest(manifest_path)
+ if mdata is not None:
+ info["tests"] = str(mdata.get("tests_passed", mdata.get("test_count", "")))
+ discoveries = mdata.get("discoveries", [])
+ if isinstance(discoveries, list):
+ info["discoveries_count"] = len(discoveries)
+ else:
+ _warn_input(manifest_path, "discoveries must be a list; omitting its count")
# ---------------------------------------------------------------------------
@@ -405,6 +449,24 @@ def generate_html(shadow_dir, output_path):
meta, children = load_index(shadow_dir)
load_reports(shadow_dir, meta)
+ reachable = set()
+ pending = list(children.get("main", []))
+ while pending:
+ branch = pending.pop()
+ if branch in reachable:
+ continue
+ reachable.add(branch)
+ pending.extend(children.get(branch, []))
+ omitted = set(meta) - reachable
+ if omitted:
+ examples = ", ".join(repr(branch) for branch in sorted(omitted)[:5])
+ _warn_input(
+ os.path.join(shadow_dir, "_dreams", "_index.md"),
+ f"{len(omitted)} experiment(s), including {examples}, are unreachable "
+ "from the displayed main root; check parent references, root naming, "
+ "or cycles; those nodes are omitted from the views",
+ )
+
total = len(meta)
compound = sum(1 for p in children if p != "main" for _ in children[p])
@@ -551,9 +613,23 @@ def generate_html(shadow_dir, output_path):
templates=templates_html,
)
- with open(output_path, "w", encoding="utf-8") as f:
- f.write(page)
- print(f"Wrote {output_path} ({os.path.getsize(output_path):,} bytes)")
+ try:
+ page.encode("utf-8")
+ except UnicodeError as exc:
+ raise LineageError(
+ f"Cannot render UTF-8 HTML for {os.fspath(output_path)!r}: {exc}. "
+ "Repair invalid Unicode in the input metadata and retry."
+ ) from exc
+ try:
+ with open(output_path, "w", encoding="utf-8") as f:
+ f.write(page)
+ output_size = os.path.getsize(output_path)
+ except OSError as exc:
+ raise LineageError(
+ f"Cannot write lineage output {os.fspath(output_path)!r}: {exc}. "
+ "Create its parent directory or choose a writable --output path and retry."
+ ) from exc
+ print(f"Wrote {output_path} ({output_size:,} bytes)")
print(f" {total} experiments, {len(chain_roots)} chains (max depth {max_depth}), "
f"{compound} compounding, {total - compound} fresh")
@@ -737,7 +813,27 @@ def generate_html(shadow_dir, output_path):