#!/usr/bin/env python3 """ dom-diff.py — Structural diff between two DOM JSON snapshots produced by the __CLONE_DUMP_COMPUTED__ walker. Used by the validate agent to surface concrete, actionable issues to the generate agent (font sizes, missing elements, wrong dimensions) instead of relying solely on PNG pixel diffs. Inputs: --captured , --rendered . Optionally --root-selector to scope the comparison to a specific subtree (typically a section's id or class), and --max-depth to cap recursion. Output (JSON to stdout): { matched: , missing_in_rendered: [{ path, tag, id, class, expected_h, expected_w }], extra_in_rendered: [{ path, tag, id, class }], style_mismatches: [{ path, property, expected, actual, severity }], size_mismatches: [{ path, expected, actual, delta_pct }], issues: [string, string, ...] // top human-readable issues } """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # Properties we treat as load-bearing for visual fidelity. Reported in this # order in the human-readable `issues` list. COMPARE_STYLE_PROPS = [ "fontSize", "fontFamily", "fontWeight", "lineHeight", "color", "backgroundColor", "backgroundImage", "padding", "margin", "borderRadius", "boxShadow", "display", "flexDirection", "justifyContent", "alignItems", "gridTemplateColumns", "gap", "textAlign", "letterSpacing", "textTransform", ] # How far apart two values can be before we flag them. NUMERIC_TOLERANCE_PX = 2 SIZE_TOLERANCE_PCT = 5.0 def find_subtree(node: dict, selector: str | None) -> dict | None: if not selector: return node target = selector.lstrip("#.") is_id = selector.startswith("#") is_class = selector.startswith(".") def walk(n: dict) -> dict | None: if not isinstance(n, dict): return None attrs = n.get("attrs") or {} if is_id and attrs.get("id") == target: return n if is_class: cls = attrs.get("class") or "" if target in cls.split(): return n if not is_id and not is_class: # Tag selector if n.get("tag") == target: return n for c in n.get("children") or []: r = walk(c) if r is not None: return r return None return walk(node) def parse_px(v: str | None) -> float | None: if not v: return None m = re.match(r"^(-?\d+(?:\.\d+)?)px$", str(v).strip()) if m: return float(m.group(1)) return None def normalize_style_value(v: str | None) -> str: if v is None: return "" s = str(v).strip() s = re.sub(r"\s+", " ", s) return s def style_close(prop: str, expected: str | None, actual: str | None) -> bool: if expected == actual: return True e_px = parse_px(expected) a_px = parse_px(actual) if e_px is not None and a_px is not None: return abs(e_px - a_px) <= NUMERIC_TOLERANCE_PX if normalize_style_value(expected) == normalize_style_value(actual): return True # backgroundImage: if both reference url(), only flag if the url paths differ if prop == "backgroundImage" and expected and actual: e_url = re.search(r"url\([^)]+\)", expected) a_url = re.search(r"url\([^)]+\)", actual) if e_url and a_url: return False # both have urls but different — flag return False return False def child_key(n: dict, idx: int) -> str: if not isinstance(n, dict): return f"text[{idx}]" tag = n.get("tag") or "?" attrs = n.get("attrs") or {} if attrs.get("id"): return f"{tag}#{attrs['id']}" return f"{tag}[{idx}]" def compare( a: dict | None, b: dict | None, path: str, issues: list[dict], counters: dict, max_depth: int, depth: int = 0, ) -> None: if depth > max_depth: return if a is None and b is None: return if a is None or not isinstance(a, dict): return # only flag when source had it if b is None or not isinstance(b, dict): # Element missing in rendered attrs = a.get("attrs") or {} bbox = a.get("bbox") or {} issues.append({ "kind": "missing_in_rendered", "path": path, "tag": a.get("tag"), "id": attrs.get("id"), "class": attrs.get("class"), "expected_w": round(bbox.get("width") or 0, 1), "expected_h": round(bbox.get("height") or 0, 1), }) counters["missing"] += 1 return # Tag mismatch if a.get("tag") != b.get("tag"): attrs_a = a.get("attrs") or {} attrs_b = b.get("attrs") or {} issues.append({ "kind": "tag_mismatch", "path": path, "expected_tag": a.get("tag"), "actual_tag": b.get("tag"), "expected_id": attrs_a.get("id"), "actual_id": attrs_b.get("id"), }) counters["tag_mismatch"] += 1 return counters["matched"] += 1 # Style comparison ca = (a.get("computed") or {}) cb = (b.get("computed") or {}) for prop in COMPARE_STYLE_PROPS: ev, av = ca.get(prop), cb.get(prop) if ev is None and av is None: continue # If property only exists on rendered, that's a divergence too — but # we only flag when source explicitly set it (prevents noise). if ev is None: continue if not style_close(prop, ev, av): issues.append({ "kind": "style_mismatch", "path": path, "property": prop, "expected": ev, "actual": av, }) counters["style"] += 1 # Size comparison ba = a.get("bbox") or {} bb = b.get("bbox") or {} ew, eh = ba.get("width") or 0, ba.get("height") or 0 aw, ah = bb.get("width") or 0, bb.get("height") or 0 if ew > 8 and eh > 8: wdelta = abs(ew - aw) / max(ew, 1) * 100 hdelta = abs(eh - ah) / max(eh, 1) * 100 if wdelta > SIZE_TOLERANCE_PCT or hdelta > SIZE_TOLERANCE_PCT: issues.append({ "kind": "size_mismatch", "path": path, "expected": [round(ew, 1), round(eh, 1)], "actual": [round(aw, 1), round(ah, 1)], "delta_pct": round(max(wdelta, hdelta), 1), }) counters["size"] += 1 # Children — match by (tag, id) tuple when ids exist, else positional ka = a.get("children") or [] kb = b.get("children") or [] # Filter to element children (skip pure text) ka_el = [(i, c) for i, c in enumerate(ka) if isinstance(c, dict) and "tag" in c] kb_el = [(i, c) for i, c in enumerate(kb) if isinstance(c, dict) and "tag" in c] # Build a lookup of rendered children by id (cheap) and by (tag, idx) fallback used_b: set[int] = set() b_by_id: dict[str, int] = {} for j, (_, ch) in enumerate(kb_el): cid = (ch.get("attrs") or {}).get("id") if cid: b_by_id[cid] = j for ai, (_, ch) in enumerate(ka_el): a_id = (ch.get("attrs") or {}).get("id") match_idx = None if a_id and a_id in b_by_id and b_by_id[a_id] not in used_b: match_idx = b_by_id[a_id] elif ai < len(kb_el) and ai not in used_b: cand = kb_el[ai][1] if cand.get("tag") == ch.get("tag"): match_idx = ai if match_idx is None: compare(ch, None, f"{path} > {child_key(ch, ai)}", issues, counters, max_depth, depth + 1) else: used_b.add(match_idx) compare(ch, kb_el[match_idx][1], f"{path} > {child_key(ch, ai)}", issues, counters, max_depth, depth + 1) # Extra children in rendered (no source counterpart) — only flag at top levels # to avoid noise from minor wrappers if depth < 3: for j, (_, ch) in enumerate(kb_el): if j in used_b: continue attrs = ch.get("attrs") or {} issues.append({ "kind": "extra_in_rendered", "path": f"{path} > {child_key(ch, j)}", "tag": ch.get("tag"), "id": attrs.get("id"), "class": attrs.get("class"), }) counters["extra"] += 1 def humanize(issues: list[dict], top_n: int = 12) -> list[str]: """Convert structured issues to human-readable strings, ranked by impact.""" # Rank: missing > tag_mismatch > size_mismatch > style_mismatch > extra weights = {"missing_in_rendered": 100, "tag_mismatch": 80, "size_mismatch": 50, "style_mismatch": 25, "extra_in_rendered": 10} style_weights = {"backgroundImage": 4, "fontSize": 3, "color": 2, "backgroundColor": 2, "fontFamily": 2} def score(i: dict) -> float: base = weights.get(i["kind"], 0) if i["kind"] == "size_mismatch": base += min(i.get("delta_pct", 0), 50) if i["kind"] == "style_mismatch": base += style_weights.get(i.get("property", ""), 1) return base issues_sorted = sorted(issues, key=score, reverse=True) out: list[str] = [] for i in issues_sorted[:top_n]: path = i.get("path", "?") if i["kind"] == "missing_in_rendered": out.append(f"{path}: missing in rendered (expected {i.get('tag')}{' #' + i['id'] if i.get('id') else ''}, ~{i.get('expected_w')}x{i.get('expected_h')})") elif i["kind"] == "tag_mismatch": out.append(f"{path}: expected <{i.get('expected_tag')}>, rendered <{i.get('actual_tag')}>") elif i["kind"] == "size_mismatch": ew, eh = i.get("expected", [0, 0]) aw, ah = i.get("actual", [0, 0]) out.append(f"{path}: size {aw}x{ah}, should be {ew}x{eh} (Δ{i.get('delta_pct')}%)") elif i["kind"] == "style_mismatch": out.append(f"{path}: {i.get('property')} is {i.get('actual')}, should be {i.get('expected')}") elif i["kind"] == "extra_in_rendered": out.append(f"{path}: extra element in rendered (<{i.get('tag')}>{' #' + i['id'] if i.get('id') else ''}) — not in source") return out def main(): parser = argparse.ArgumentParser() parser.add_argument("--captured", required=True) parser.add_argument("--rendered", required=True) parser.add_argument("--root-selector", default=None, help="e.g. #hero or .ecosystem to scope the diff to a section") parser.add_argument("--max-depth", type=int, default=8) parser.add_argument("--max-issues", type=int, default=200) args = parser.parse_args() captured = json.loads(Path(args.captured).read_text()) rendered = json.loads(Path(args.rendered).read_text()) a_root = find_subtree(captured, args.root_selector) b_root = find_subtree(rendered, args.root_selector) if a_root is None: print(json.dumps({"error": f"root selector not found in captured: {args.root_selector}"})) sys.exit(2) if b_root is None: print(json.dumps({"error": f"root selector not found in rendered: {args.root_selector}"})) sys.exit(3) issues: list[dict] = [] counters = {"matched": 0, "missing": 0, "extra": 0, "tag_mismatch": 0, "style": 0, "size": 0} compare(a_root, b_root, args.root_selector or a_root.get("tag", "body"), issues, counters, args.max_depth) issues = issues[: args.max_issues] humanized = humanize(issues, top_n=12) print(json.dumps({ "matched": counters["matched"], "counts": counters, "issues": humanized, "structured_issues": issues, })) if __name__ == "__main__": main()