claude-dev-env 8.89.0 → 8.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.agents/skills/pr-visual/README.md +29 -0
  2. package/.agents/skills/pr-visual/SKILL.md +62 -0
  3. package/.agents/skills/pr-visual/assets/icons.symbols +27 -0
  4. package/.agents/skills/pr-visual/assets/viewer.html +147 -0
  5. package/.agents/skills/pr-visual/fixtures/bar.scene.json +193 -0
  6. package/.agents/skills/pr-visual/fixtures/flow.scene.json +201 -0
  7. package/.agents/skills/pr-visual/fixtures/head/metrics.json +16 -0
  8. package/.agents/skills/pr-visual/fixtures/integrated.scene.json +239 -0
  9. package/.agents/skills/pr-visual/fixtures/line.scene.json +193 -0
  10. package/.agents/skills/pr-visual/fixtures/scatter.scene.json +193 -0
  11. package/.agents/skills/pr-visual/fixtures/snapshot.json +21 -0
  12. package/.agents/skills/pr-visual/references/evidence.md +9 -0
  13. package/.agents/skills/pr-visual/references/formats.md +30 -0
  14. package/.agents/skills/pr-visual/references/scene-contract.md +48 -0
  15. package/.agents/skills/pr-visual/references/visual-contract.md +23 -0
  16. package/.agents/skills/pr-visual/scripts/collect.py +111 -0
  17. package/.agents/skills/pr-visual/scripts/metrics.py +99 -0
  18. package/.agents/skills/pr-visual/scripts/render.py +133 -0
  19. package/.agents/skills/pr-visual/scripts/test_collect.py +58 -0
  20. package/.agents/skills/pr-visual/scripts/test_metrics.py +47 -0
  21. package/.agents/skills/pr-visual/scripts/test_render.py +77 -0
  22. package/.agents/skills/pr-visual/scripts/verify.py +218 -0
  23. package/.agents/skills/pr-visual/scripts/visual_config/config/constants.py +104 -0
  24. package/bin/codex-skill-load-block.mjs +0 -25
  25. package/bin/codex-skill-load-block.test.mjs +5 -17
  26. package/bin/codex-trimmed-sol.test.mjs +0 -1
  27. package/bin/cursor-native-hooks.test.mjs +3 -3
  28. package/bin/ever-shipped-skills.mjs +1 -0
  29. package/bin/install-deepstack-plugin.mjs +42 -25
  30. package/bin/install-deepstack-plugin.test.mjs +37 -11
  31. package/bin/install-pstack-plugin.mjs +58 -11
  32. package/bin/install-pstack-plugin.test.mjs +54 -21
  33. package/bin/install.agents-home.test.mjs +0 -1
  34. package/bin/install.codex-rules.test.mjs +0 -2
  35. package/bin/install.cursor-rules.test.mjs +90 -26
  36. package/bin/install.mjs +30 -36
  37. package/bin/install.pr-visual.test.mjs +40 -0
  38. package/bin/install.prompt-context.test.mjs +3 -4
  39. package/bin/install.prune.test.mjs +0 -1
  40. package/bin/install.question-presentation.test.mjs +0 -4
  41. package/bin/install.settings-defaults.test.mjs +0 -1
  42. package/bin/install.shared-settings.test.mjs +0 -1
  43. package/bin/install.test.mjs +14 -44
  44. package/bin/install.transaction.test.mjs +0 -8
  45. package/bin/install.uninstall-transaction.test.mjs +0 -2
  46. package/bin/install.update-keeps-hooks.test.mjs +0 -1
  47. package/docs/BDD_SCENARIO_QUALITY.md +1 -1
  48. package/docs/BDD_TEST_LAYOUT.md +1 -1
  49. package/docs/CODE_RULES.md +1 -1
  50. package/docs/rule-guides/deepstack-wording.md +3 -5
  51. package/docs/rule-guides/session-title.md +5 -4
  52. package/hooks/blocking/session_title_turn_gate.py +287 -0
  53. package/hooks/blocking/test_session_title_format_gate.py +5 -0
  54. package/hooks/blocking/test_session_title_turn_gate.py +264 -0
  55. package/hooks/features/conduct-gates.md +3 -0
  56. package/hooks/features/session-context.md +0 -4
  57. package/hooks/hooks.json +5 -5
  58. package/hooks/hooks_constants/mod_handoff_constants.py +0 -2
  59. package/hooks/hooks_constants/session_title_constants.py +41 -3
  60. package/hooks/lifecycle/nested_project_hooks.py +2 -2
  61. package/hooks/lifecycle/test_nested_project_hooks.py +2 -2
  62. package/hooks/session/compaction_user_messages.py +2 -2
  63. package/package.json +2 -2
  64. package/rules/deepstack-wording.md +3 -3
  65. package/scripts/ci/install-playtest.mjs +58 -314
  66. package/scripts/ci/install-playtest.test.mjs +24 -6
  67. package/scripts/policy_lint/adapter_configuration.py +1 -0
  68. package/scripts/policy_lint/config/constants.py +1 -0
  69. package/scripts/test_deepstack_review_criterion.py +2 -3
  70. package/scripts/test_deepstack_wording_rule.py +6 -4
  71. package/scripts/tests/test_adapter_configuration.py +8 -0
  72. package/.agents/skills/deepen/SKILL.md +0 -26
  73. package/commands/deepen.md +0 -14
  74. package/docs/decisions/deepstack-gate.md +0 -32
  75. package/hooks/hooks_constants/deepen_first_prompt_constants.py +0 -187
  76. package/hooks/session/deepen_default_rewrite.py +0 -128
  77. package/hooks/session/deepen_first_prompt.py +0 -383
  78. package/hooks/session/deepen_role_gate.py +0 -171
  79. package/hooks/session/deepen_turn_marker.py +0 -75
  80. package/hooks/session/fixtures/deepstack_replay.json +0 -15
  81. package/hooks/session/grade_deepstack_replay.py +0 -78
  82. package/hooks/session/test_deepen_default_rewrite.py +0 -90
  83. package/hooks/session/test_deepen_first_prompt.py +0 -452
  84. package/hooks/session/test_deepen_role_gate.py +0 -83
  85. package/hooks/session/test_deepen_turn_marker.py +0 -59
  86. package/hooks/session/test_grade_deepstack_replay.py +0 -47
@@ -0,0 +1,133 @@
1
+ """Validate evidence-backed diagram scenes and render a portable HTML visual."""
2
+ import argparse
3
+ import copy
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from urllib.parse import quote
8
+
9
+ from metrics import prepare_chart, prepare_embedded_charts
10
+ from visual_config.config import constants as c
11
+
12
+
13
+ class RenderRunFatal(ValueError):
14
+ """Stop rendering when scene structure or evidence is invalid."""
15
+
16
+
17
+ def _validate_drilldown(node: dict) -> None:
18
+ flow = node.get("drilldown")
19
+ if not flow or not flow.get("title") or not c.MINIMUM_DRILLDOWN_STEPS <= len(flow.get("steps", [])) <= c.MAXIMUM_DRILLDOWN_STEPS:
20
+ raise RenderRunFatal("Every node needs a titled drilldown with two to six steps.")
21
+ for each_step in flow["steps"]:
22
+ if not each_step.get("label") or not each_step.get("icon"):
23
+ raise RenderRunFatal("Every drilldown step needs a label and icon.")
24
+ for each_edge in flow.get("edges", []):
25
+ if not 0 <= each_edge["from"] < len(flow["steps"]) or not 0 <= each_edge["to"] < len(flow["steps"]) or each_edge["from"] == each_edge["to"]:
26
+ raise RenderRunFatal("Invalid drilldown edge.")
27
+
28
+
29
+ def _validate_graph(scene: dict) -> None:
30
+ node_ids = [each_node["id"] for each_node in scene["nodes"]]
31
+ if len(set(node_ids)) != len(node_ids):
32
+ raise RenderRunFatal("Duplicate diagram node IDs.")
33
+ for each_edge in scene["edges"]:
34
+ if each_edge["from"] not in node_ids or each_edge["to"] not in node_ids:
35
+ raise RenderRunFatal("Edge refers to a missing node.")
36
+ if not re.fullmatch(c.SVG_PATH_PATTERN, each_edge["path"]):
37
+ raise RenderRunFatal("Invalid SVG path.")
38
+ for each_node in scene["nodes"]:
39
+ if not scene.get("chart") and (not c.MINIMUM_NODE_X <= each_node["x"] <= c.MAXIMUM_NODE_X or not c.MINIMUM_NODE_Y <= each_node["y"] <= c.MAXIMUM_NODE_Y):
40
+ raise RenderRunFatal("Node lies outside diagram bounds.")
41
+ if not each_node.get("sources"):
42
+ raise RenderRunFatal("Every node needs source evidence.")
43
+ _validate_drilldown(each_node)
44
+
45
+
46
+ def _source_evidence(source: dict, snapshot: dict, snapshot_path: Path) -> None:
47
+ side = source.get("side", c.HEAD_SIDE)
48
+ if side not in c.ALL_SOURCE_SIDES:
49
+ raise RenderRunFatal("Source side must be head or base.")
50
+ source_root = snapshot_path.parent / side
51
+ source_path = (source_root / source["path"]).resolve()
52
+ if not source_path.is_relative_to(source_root):
53
+ raise RenderRunFatal("Source path leaves snapshot.")
54
+ all_lines = source_path.read_text(encoding="utf-8-sig").splitlines()
55
+ first, last = source["line"], source.get("end", source["line"])
56
+ if not 1 <= first <= last <= len(all_lines):
57
+ raise RenderRunFatal("Invalid source line range.")
58
+ source["excerpt"] = c.NEWLINE.join(c.SOURCE_LINE.format(number=each_index + first, line=each_line) for each_index, each_line in enumerate(all_lines[first - 1:last]))
59
+ revision = snapshot.get("diff_base", snapshot[c.BASE_SIDE]) if side == c.BASE_SIDE else snapshot[c.HEAD_SIDE]
60
+ source["url"] = c.SOURCE_LINK.format(repo=snapshot["repo"], revision=revision, path=quote(source["path"], safe="/"), first=first, last=last)
61
+
62
+
63
+ def _prepare_sources(scene: dict, snapshot: dict, snapshot_path: Path) -> None:
64
+ for each_node in scene["nodes"]:
65
+ for each_source in each_node["sources"]:
66
+ _source_evidence(each_source, snapshot, snapshot_path)
67
+
68
+
69
+ def prepare(path: Path) -> dict:
70
+ """Bind a diagram to captured source and populate immutable evidence links.
71
+
72
+ Args:
73
+ path: Scene JSON path with a relative snapshot reference.
74
+
75
+ Returns:
76
+ The scene with PR identity, original summary, and source excerpts.
77
+
78
+ Raises:
79
+ RenderRunFatal: The scene or an evidence reference is invalid.
80
+ """
81
+ scene = json.loads(path.read_text(encoding="utf-8"))
82
+ snapshot_path = (path.parent / scene["snapshot"]).resolve()
83
+ snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
84
+ scene["pr"] = {each_key: snapshot[each_key] for each_key in ("number", "url", "repo", "state", "head", "base", "observed_at")}
85
+ scene["pr"].update(draft=snapshot["metadata"]["draft"], merged=snapshot["metadata"].get("merged", False), changed_files=len(snapshot["files"]), title=snapshot["metadata"]["title"])
86
+ scene["summary"] = snapshot["metadata"]["body"] or "No PR description."
87
+ _validate_graph(scene)
88
+ _prepare_sources(scene, snapshot, snapshot_path)
89
+ prepare_chart(scene, snapshot_path)
90
+ prepare_embedded_charts(scene, snapshot_path)
91
+ return scene
92
+
93
+
94
+ def _omit_private_payload(scene: dict) -> None:
95
+ scene.pop("summary", None)
96
+ for each_node in scene["nodes"]:
97
+ for each_source in each_node["sources"]:
98
+ each_source.pop("excerpt", None)
99
+
100
+
101
+ def render(all_scenes: list[dict], artifact_path: Path, is_links_only: bool = False) -> None:
102
+ """Write a standalone interactive diagram or tabbed gallery.
103
+
104
+ Args:
105
+ all_scenes: Prepared diagrams with captured source evidence.
106
+ artifact_path: Destination HTML file.
107
+ is_links_only: Omit original descriptions and code excerpts.
108
+
109
+ Raises:
110
+ RenderRunFatal: The HTML exceeds the host size limit.
111
+ """
112
+ all_scenes = copy.deepcopy(all_scenes)
113
+ if is_links_only:
114
+ for each_scene in all_scenes:
115
+ _omit_private_payload(each_scene)
116
+ payload = json.dumps(all_scenes, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
117
+ template = c.VIEWER_PATH.read_text(encoding="utf-8")
118
+ icons = c.ICONS_PATH.read_text(encoding="utf-8")
119
+ rendered_html = template.replace("__ICONS__", icons).replace("__SCENES__", payload)
120
+ if len(rendered_html.encode()) > c.MAXIMUM_HTML_BYTES:
121
+ raise RenderRunFatal("HTML exceeds the host visualization limit.")
122
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
123
+ artifact_path.write_text(rendered_html, encoding="utf-8")
124
+ print(f"{artifact_path}: {len(all_scenes)} diagrams, {len(rendered_html.encode())} bytes")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument("scenes", type=Path, nargs="+")
130
+ parser.add_argument("--out", type=Path, required=True)
131
+ parser.add_argument("--links-only", action="store_true", help="Omit original descriptions and code excerpts for hosted display.")
132
+ arguments = parser.parse_args()
133
+ render([prepare(each_path) for each_path in arguments.scenes], arguments.out, arguments.links_only)
@@ -0,0 +1,58 @@
1
+ """Check immutable capture and stale-head rejection with synthetic GitHub data."""
2
+ import base64
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from unittest.mock import Mock
9
+
10
+ import pytest
11
+
12
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
13
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
14
+
15
+ import collect
16
+
17
+ REPOSITORY = "example/project"
18
+ PR_URL = "https://github.com/example/project/pull/1"
19
+ HEAD_SHA = "b" * 40
20
+ BASE_SHA = "c" * 40
21
+ MERGE_BASE_SHA = "a" * 40
22
+ CONTENT = b"print('captured')\n"
23
+ METADATA = {"head": {"sha": HEAD_SHA, "repo": {"full_name": REPOSITORY}}, "base": {"sha": BASE_SHA, "repo": {"full_name": REPOSITORY}}, "changed_files": 1, "state": "open"}
24
+ CHANGED_FILES = [{"filename": "handler.py", "status": "modified", "patch": "fixture patch"}]
25
+ BLOB = {"encoding": "base64", "content": base64.b64encode(CONTENT).decode()}
26
+
27
+
28
+ def test_capture_uses_merge_base_and_hashes_saved_bytes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
29
+ """Both saved sides match the immutable revisions used by the diff."""
30
+ github = Mock(side_effect=[METADATA, {"merge_base_commit": {"sha": MERGE_BASE_SHA}}, CHANGED_FILES, BLOB, BLOB, METADATA])
31
+ monkeypatch.setattr(collect, "_api", github)
32
+ collect.collect(PR_URL, tmp_path)
33
+ destination = tmp_path / "pr-1" / HEAD_SHA
34
+ snapshot = json.loads((destination / "snapshot.json").read_text())
35
+ assert snapshot["diff_base"] == MERGE_BASE_SHA
36
+ for each_side in ("base", "head"):
37
+ assert (destination / each_side / "handler.py").read_bytes() == CONTENT
38
+ assert snapshot["files"][0]["captures"][each_side]["sha256"] == hashlib.sha256(CONTENT).hexdigest()
39
+ all_endpoints = [each_call.args[0] for each_call in github.call_args_list]
40
+ assert any(each_endpoint.endswith(f"?ref={MERGE_BASE_SHA}") for each_endpoint in all_endpoints)
41
+
42
+
43
+ def test_moved_head_never_saves_a_complete_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
44
+ """A changed PR head requires another capture before rendering."""
45
+ moved = copy.deepcopy(METADATA)
46
+ moved["head"]["sha"] = "d" * 40
47
+ github = Mock(side_effect=[METADATA, {"merge_base_commit": {"sha": MERGE_BASE_SHA}}, CHANGED_FILES, BLOB, BLOB, moved])
48
+ monkeypatch.setattr(collect, "_api", github)
49
+ with pytest.raises(collect.CaptureRunFatal, match="changed during capture"):
50
+ collect.collect(PR_URL, tmp_path)
51
+ assert not list(tmp_path.rglob("snapshot.json"))
52
+
53
+
54
+ def test_incomplete_changed_file_list_fails(monkeypatch: pytest.MonkeyPatch) -> None:
55
+ """A truncated file listing cannot imply complete coverage."""
56
+ monkeypatch.setattr(collect, "_api", Mock(return_value=[]))
57
+ with pytest.raises(collect.CaptureRunFatal, match="incomplete"):
58
+ collect._changed_files(REPOSITORY, "1", 1)
@@ -0,0 +1,47 @@
1
+ """Verify embedded chart provenance and passive Python literal extraction."""
2
+ import copy
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
9
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
10
+
11
+ from metrics import MetricRunFatal, _coordinate, prepare_embedded_charts
12
+ from render import prepare
13
+
14
+ FIXTURES = SCRIPTS_DIRECTORY.parents[0] / "fixtures"
15
+ ALL_LITERAL_CASES = (("assert metric == 2.0", {}, 2.0), ("Pair(90, 180)", {"argument": 1}, 180), ("peaks = (1, 2, 10)", {"element": 2}, 10))
16
+
17
+
18
+ @pytest.mark.parametrize("source_text,selector,expected", ALL_LITERAL_CASES)
19
+ def test_numeric_literals_are_read_without_executing_source(source_text: str, selector: dict, expected: float, tmp_path: Path) -> None:
20
+ """Executable statements in the source remain inert during chart preparation."""
21
+ source_root = tmp_path / "head"
22
+ source_root.mkdir()
23
+ (source_root / "example.py").write_text('raise RuntimeError("Source must remain inert")\n' + source_text + "\n")
24
+ node = {"sources": [{"path": "example.py", "line": 2}]}
25
+ assert _coordinate({"source": 0, "python_line": 2, **selector}, node, tmp_path / "snapshot.json") == expected
26
+
27
+
28
+ def test_step_chart_uses_reviewed_values() -> None:
29
+ """Step cards and expansions use captured fixture measurements."""
30
+ scene = prepare(FIXTURES / "integrated.scene.json")
31
+ assert [each_point["values"]["y"] for each_point in scene["nodes"][0]["chart"]["points"]] == [12, 20, 17]
32
+
33
+
34
+ def test_ungrounded_embedded_numbers_are_rejected() -> None:
35
+ """Author-entered chart coordinates cannot replace source references."""
36
+ scene = copy.deepcopy(prepare(FIXTURES / "integrated.scene.json"))
37
+ scene["nodes"][0]["chart"]["points"][0].pop("metrics")
38
+ with pytest.raises(MetricRunFatal):
39
+ prepare_embedded_charts(scene, FIXTURES / "snapshot.json")
40
+
41
+
42
+ def test_python_numbers_outside_cited_lines_are_rejected(tmp_path: Path) -> None:
43
+ """A measurement must be covered by its displayed evidence link."""
44
+ (tmp_path / "head").mkdir()
45
+ (tmp_path / "head/example.py").write_text("assert metric == 2\nassert metric == 10\n")
46
+ with pytest.raises(MetricRunFatal, match="outside"):
47
+ _coordinate({"source": 0, "python_line": 2}, {"sources": [{"path": "example.py", "line": 1}]}, tmp_path / "snapshot.json")
@@ -0,0 +1,77 @@
1
+ """Exercise numerical provenance, evidence boundaries, and portable rendering."""
2
+ import copy
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
10
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
11
+ from metrics import MetricRunFatal, prepare_chart
12
+ from render import RenderRunFatal, _source_evidence, prepare, render
13
+
14
+ FIXTURES = Path(__file__).resolve().parents[1] / "fixtures"
15
+ FORMATS = ("flow", "bar", "line", "scatter")
16
+ EXPECTED_Y = [12, 20, 17]
17
+ INVALID_VALUES = (True, None, "12", float("nan"), float("inf"))
18
+
19
+
20
+ @pytest.mark.parametrize("kind", FORMATS)
21
+ def test_render_source_backed_format(kind: str, tmp_path: Path) -> None:
22
+ """Every format retains immutable evidence and interactive detail data."""
23
+ scene = prepare(FIXTURES / f"{kind}.scene.json")
24
+ artifact_path = tmp_path / "visual.html"
25
+ render([scene], artifact_path)
26
+ assert "__SCENES__" not in artifact_path.read_text()
27
+ assert scene["nodes"][0]["sources"][0]["url"].endswith("/metrics.json#L4-L5")
28
+ if kind != "flow":
29
+ assert [each_node["values"]["y"] for each_node in scene["nodes"]] == EXPECTED_Y
30
+
31
+
32
+ @pytest.mark.parametrize("value", INVALID_VALUES)
33
+ def test_chart_rejects_nonnumeric_measurements(value: object, tmp_path: Path) -> None:
34
+ """Missing and nonnumeric observations cannot become plotted values."""
35
+ scene = json.loads((FIXTURES / "bar.scene.json").read_text())
36
+ (tmp_path / "head").mkdir()
37
+ (tmp_path / "head/metrics.json").write_text(json.dumps({"samples": [{"duration_ms": value}]}))
38
+ with pytest.raises(MetricRunFatal):
39
+ prepare_chart(scene, tmp_path / "snapshot.json")
40
+
41
+
42
+ def test_missing_pointer_fails_instead_of_zero() -> None:
43
+ """A missing observation cannot imply a zero measurement."""
44
+ scene = prepare(FIXTURES / "bar.scene.json")
45
+ scene["nodes"][0]["metrics"]["y"]["pointer"] = "/missing"
46
+ with pytest.raises(MetricRunFatal):
47
+ prepare_chart(scene, FIXTURES / "snapshot.json")
48
+
49
+
50
+ @pytest.mark.parametrize("source", [{"path": "../../outside", "line": 1}, {"path": "metrics.json", "line": 0}, {"path": "metrics.json", "line": 1, "side": "other"}])
51
+ def test_source_boundary_rejects_invalid_references(source: dict) -> None:
52
+ """Evidence must remain inside a captured side and a valid line range."""
53
+ snapshot = json.loads((FIXTURES / "snapshot.json").read_text())
54
+ with pytest.raises(RenderRunFatal):
55
+ _source_evidence(source, snapshot, FIXTURES / "snapshot.json")
56
+
57
+
58
+ def test_links_only_is_private_and_does_not_mutate_scene(tmp_path: Path) -> None:
59
+ """Hosted output drops payload excerpts without modifying the prepared scene."""
60
+ scene = prepare(FIXTURES / "flow.scene.json")
61
+ original = copy.deepcopy(scene)
62
+ artifact_path = tmp_path / "visual.html"
63
+ render([scene], artifact_path, is_links_only=True)
64
+ payload = artifact_path.read_text().split("data-scenes>", 1)[1].split("</script>", 1)[0]
65
+ public = json.loads(payload)[0]
66
+ assert "summary" not in public
67
+ assert all("excerpt" not in each_source for each_node in public["nodes"] for each_source in each_node["sources"])
68
+ assert scene == original
69
+
70
+
71
+ def test_embedded_text_cannot_close_script(tmp_path: Path) -> None:
72
+ """Captured prose remains data even when it contains HTML closing tags."""
73
+ scene = prepare(FIXTURES / "flow.scene.json")
74
+ scene["summary"] = "</script><script>window.untrusted=1</script>"
75
+ artifact_path = tmp_path / "visual.html"
76
+ render([scene], artifact_path)
77
+ assert scene["summary"] not in artifact_path.read_text()
@@ -0,0 +1,218 @@
1
+ """Exercise docked panels, mini-diagrams, source disclosures, and isolation."""
2
+ import argparse
3
+ import json
4
+ import re
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from playwright.sync_api import Locator, Page, sync_playwright
9
+ from visual_config.config import constants as c
10
+
11
+
12
+ def _panel_layout(root: Locator, is_mobile: bool = False) -> None:
13
+ stage_box = root.locator(c.STAGE_SELECTOR).bounding_box()
14
+ panel_box = root.locator(c.DRAWER_SELECTOR).bounding_box()
15
+ assert stage_box is not None and panel_box is not None
16
+ if is_mobile:
17
+ assert panel_box["y"] + panel_box["height"] <= stage_box["y"] + 1
18
+ else:
19
+ assert stage_box["x"] + stage_box["width"] <= panel_box["x"] + 1
20
+
21
+
22
+ def _check_node(root: Locator, node: dict) -> None:
23
+ assert root.locator(c.DRAWER_SELECTOR).is_visible()
24
+ assert root.locator(".drawer .detail-map").is_visible()
25
+ assert root.locator(".drawer .detail-map use").count() == len(node["drilldown"]["steps"])
26
+ assert not root.locator(c.EXACT_SELECTOR).evaluate(c.DISCLOSURE_OPEN_EXPRESSION)
27
+ _panel_layout(root)
28
+ assert root.locator(".drawer [data-embedded-chart]").count() == int(bool(node.get("chart")))
29
+ root.locator(c.DISCLOSURE_SELECTOR).click()
30
+ assert root.locator(".exact-source a").count() == len(node["sources"])
31
+ assert root.locator(".exact-source p").count() == len(node["detail"])
32
+ assert (root.locator(".exact-source pre").count() > 0) == bool(node["sources"][0].get("excerpt"))
33
+
34
+
35
+ def _navigate_related(root: Locator) -> None:
36
+ jumps = root.locator(c.JUMP_SELECTOR)
37
+ if jumps.count():
38
+ target = jumps.first.get_attribute("data-detail-target")
39
+ jumps.first.click()
40
+ assert root.locator(c.NODE_TARGET_SELECTOR.format(target=target)).get_attribute("aria-expanded") == "true"
41
+ assert not root.locator(c.EXACT_SELECTOR).evaluate(c.DISCLOSURE_OPEN_EXPRESSION)
42
+
43
+
44
+ def _toolbar_details(root: Locator, node_count: int) -> None:
45
+ for each_label in c.ALL_TOOLBAR_DETAIL_LABELS:
46
+ root.get_by_role("button", name=each_label, exact=True).click()
47
+ assert root.locator(".drawer .detail-map").is_visible()
48
+ assert root.locator(c.JUMP_SELECTOR).count() == node_count
49
+ root.locator(c.DISCLOSURE_SELECTOR).click()
50
+ assert root.locator(".exact-source p").count() > 0
51
+ root.get_by_role("button", name=c.CLOSE_BUTTON_LABEL).click()
52
+
53
+
54
+ def _keyboard_and_zoom(page: Page, root: Locator) -> None:
55
+ first_node = root.locator(c.NODE_SELECTOR).first
56
+ first_node.focus()
57
+ page.keyboard.press("Enter")
58
+ page.keyboard.press("Escape")
59
+ assert first_node.evaluate("(n)=>n===document.activeElement")
60
+ root.get_by_role("button", name=c.ZOOM_BUTTON_LABEL, exact=True).click()
61
+ assert root.locator(c.CANVAS_SELECTOR).evaluate(c.CANVAS_WIDTH_EXPRESSION)
62
+ root.get_by_role("button", name="Fit", exact=True).click()
63
+ assert root.locator(c.CANVAS_SELECTOR).evaluate(c.CANVAS_WIDTH_EXPRESSION) == ""
64
+ assert not root.locator(".canvas svg text").evaluate_all(c.OVERFLOW_EXPRESSION)
65
+
66
+
67
+ def _scene_screenshots(page: Page, root: Locator, scene: dict, proof_directory: Path, scene_index: int) -> None:
68
+ page.screenshot(path=str(proof_directory / c.PR_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)), full_page=True)
69
+ all_nodes = root.locator(c.NODE_SELECTOR)
70
+ all_nodes.first.click()
71
+ page.screenshot(path=str(proof_directory / c.DETAIL_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)), full_page=True)
72
+ if all_nodes.count() > 1:
73
+ all_nodes.nth(1).click()
74
+ assert root.locator(c.DETAIL_TITLE_SELECTOR).inner_text() == c.SPACE_SEPARATOR.join(scene["nodes"][1]["label"])
75
+ page.keyboard.press("Escape")
76
+
77
+
78
+ def _verify_scene(page: Page, root: Locator, scene: dict, scene_index: int, proof_directory: Path) -> dict:
79
+ root.get_by_role("tab").nth(scene_index).click()
80
+ all_nodes = root.locator(c.NODE_SELECTOR)
81
+ for each_index, each_node in enumerate(scene["nodes"]):
82
+ all_nodes.nth(each_index).click()
83
+ _check_node(root, each_node)
84
+ _navigate_related(root)
85
+ page.keyboard.press("Escape")
86
+ assert root.locator(c.DRAWER_SELECTOR).is_hidden()
87
+ assert root.locator(c.SELECTED_NODE_SELECTOR).count() == 0
88
+ _toolbar_details(root, len(scene["nodes"]))
89
+ _keyboard_and_zoom(page, root)
90
+ _scene_screenshots(page, root, scene, proof_directory, scene_index)
91
+ return {"pr": scene["pr"]["number"], "nodes_checked": all_nodes.count(), "docked_panel": "pass", "mini_diagrams": "pass", "related_navigation": "pass", "collapsed_sources": "pass", "keyboard_and_zoom": "pass"}
92
+
93
+
94
+ def _dashboard(page: Page, root: Locator, all_scenes: list[dict], proof_directory: Path) -> None:
95
+ tabs = root.get_by_role("tab")
96
+ assert tabs.count() == len(all_scenes)
97
+ assert root.locator(".tabs svg").count() == 0
98
+ assert root.locator(c.DASHBOARD_SELECTOR).bounding_box()["height"] <= c.MAXIMUM_DASHBOARD_HEIGHT
99
+ for each_index, each_scene in enumerate(all_scenes):
100
+ tabs.nth(each_index).click()
101
+ assert tabs.nth(each_index).get_attribute("aria-selected") == "true"
102
+ assert root.locator(c.DRAWER_SELECTOR).is_hidden()
103
+ assert root.locator("[data-step-chart]").count() == (0 if each_scene.get("chart") else sum(bool(each_node.get("chart")) for each_node in each_scene["nodes"]))
104
+ tabs.first.click()
105
+ root.screenshot(path=str(proof_directory / c.DASHBOARD_SCREENSHOT_NAME))
106
+
107
+
108
+ def _verify_mobile(page: Page, root: Locator, proof_directory: Path) -> None:
109
+ all_tabs = root.get_by_role("tab")
110
+ all_tabs.first.focus()
111
+ if all_tabs.count() > 1:
112
+ page.keyboard.press("ArrowRight")
113
+ assert all_tabs.nth(1).get_attribute("aria-selected") == "true"
114
+ page.set_viewport_size(c.ALL_MOBILE_VIEWPORT)
115
+ root.locator(c.NODE_SELECTOR).first.click()
116
+ _panel_layout(root, is_mobile=True)
117
+ assert root.locator(c.STAGE_SELECTOR).evaluate(c.SCROLLABLE_EXPRESSION)
118
+ root.locator(c.STAGE_SELECTOR).evaluate(c.SCROLL_MAP_EXPRESSION)
119
+ assert root.locator(c.STAGE_SELECTOR).evaluate(c.READ_SCROLL_EXPRESSION) > 0
120
+ page.screenshot(path=str(proof_directory / c.MOBILE_SCREENSHOT_NAME), full_page=True)
121
+ page.keyboard.press("Escape")
122
+
123
+
124
+ def _mount_two_widgets(page: Page, source: str) -> str:
125
+ page.set_viewport_size(c.ALL_DESKTOP_VIEWPORT)
126
+ page.set_content(c.HOST_FIXTURE_HTML)
127
+ background = page.locator(c.HOST_FIXTURE_SELECTOR).evaluate(c.BACKGROUND_EXPRESSION)
128
+ fragment = re.search(c.MAIN_FRAGMENT_PATTERN, source, re.DOTALL).group()
129
+ style = re.search(c.STYLE_FRAGMENT_PATTERN, source, re.DOTALL).group()
130
+ for each_instance in range(2):
131
+ page.evaluate(c.MOUNT_WIDGET_EXPRESSION, style + fragment)
132
+ return background
133
+
134
+
135
+ def _verify_isolation(page: Page, source: str) -> None:
136
+ background = _mount_two_widgets(page, source)
137
+ all_roots = page.locator(c.ROOT_SELECTOR)
138
+ all_roots.nth(0).locator(c.NODE_SELECTOR).first.click()
139
+ assert all_roots.nth(0).locator(c.DRAWER_SELECTOR).is_visible()
140
+ assert all_roots.nth(1).locator(c.DRAWER_SELECTOR).is_hidden()
141
+ all_roots.nth(1).get_by_role("button", name=c.ZOOM_BUTTON_LABEL, exact=True).click()
142
+ assert all_roots.nth(0).locator(c.CANVAS_SELECTOR).evaluate(c.CANVAS_WIDTH_EXPRESSION) == ""
143
+ assert all_roots.nth(0).get_by_role("tab").first.get_attribute("aria-selected") == "true"
144
+ assert page.locator(c.HOST_FIXTURE_SELECTOR).evaluate(c.BACKGROUND_EXPRESSION) == background
145
+ all_ids = page.locator("[id]").evaluate_all(c.READ_IDS_EXPRESSION)
146
+ assert len(all_ids) == len(set(all_ids)), "Widget instances share SVG or control IDs"
147
+ _verify_host_contrast(page, all_roots.nth(0))
148
+
149
+
150
+ def _luminance(color: str) -> float:
151
+ channels = [int(each_component) / c.RGB_CHANNEL_MAXIMUM for each_component in re.findall(c.RGB_COMPONENT_PATTERN, color)]
152
+ linear = [each_channel / c.SRGB_LINEAR_DIVISOR if each_channel <= c.SRGB_LINEAR_THRESHOLD else ((each_channel + c.SRGB_OFFSET) / c.SRGB_SCALE) ** c.SRGB_EXPONENT for each_channel in channels]
153
+ return sum(each_channel * each_weight for each_channel, each_weight in zip(linear, c.ALL_LUMINANCE_WEIGHTS, strict=True))
154
+
155
+
156
+ def _verify_host_contrast(page: Page, root: Locator) -> None:
157
+ page.add_style_tag(content=c.HOST_THEME_STYLES)
158
+ assert page.locator("body").evaluate(c.CONTROL_STYLES_EXPRESSION)["scheme"] == "dark"
159
+ assert page.locator(c.HOST_FIXTURE_SELECTOR).evaluate(c.CONTROL_STYLES_EXPRESSION)["color"] == c.WHITE_COLOR
160
+ assert root.evaluate(c.CONTROL_STYLES_EXPRESSION)["scheme"] == "light"
161
+ all_controls = root.locator("button")
162
+ for each_index in range(all_controls.count()):
163
+ control = all_controls.nth(each_index)
164
+ styles = control.evaluate(c.CONTROL_STYLES_EXPRESSION)
165
+ assert styles["opacity"] == "1" and styles["shadow"] == "none"
166
+ foreground, background = sorted((_luminance(styles["color"]), _luminance(styles["background"])))
167
+ assert (background + c.CONTRAST_OFFSET) / (foreground + c.CONTRAST_OFFSET) >= c.MINIMUM_TEXT_CONTRAST
168
+ children = control.locator("span")
169
+ for each_child in range(children.count()):
170
+ assert children.nth(each_child).evaluate(c.CONTROL_STYLES_EXPRESSION)["color"] == styles["color"]
171
+ close = root.get_by_role("button", name=c.CLOSE_BUTTON_LABEL)
172
+ assert close.evaluate(c.CONTROL_STYLES_EXPRESSION)["color"] == c.TEAL_COLOR
173
+ assert close.evaluate(c.CONTROL_STYLES_EXPRESSION)["border"] == c.CONTROL_BORDER_COLOR
174
+ page.keyboard.press("Tab")
175
+ close.focus()
176
+ assert close.evaluate(c.CONTROL_STYLES_EXPRESSION)["outline"] == c.TEAL_COLOR
177
+ root.get_by_role("button", name="Fit", exact=True).focus()
178
+ assert root.get_by_role("button", name="Fit", exact=True).evaluate(c.CONTROL_STYLES_EXPRESSION)["outline"] == c.LIME_COLOR
179
+
180
+
181
+ def verify(path: Path, proof_directory: Path, browser_path: str | None = None, should_disable_sandbox: bool = False) -> None:
182
+ """Exercise the rendered artifact in desktop, mobile, and shared-host layouts.
183
+
184
+ Args:
185
+ path: Rendered HTML artifact.
186
+ proof_directory: Directory receiving screenshots and the verification report.
187
+ browser_path: Optional Chromium executable; otherwise detect or use Playwright.
188
+ should_disable_sandbox: Disable Chromium sandbox only in an isolated container.
189
+ """
190
+ proof_directory.mkdir(parents=True, exist_ok=True)
191
+ with sync_playwright() as driver:
192
+ browser_path = browser_path or shutil.which("chromium") or shutil.which("google-chrome")
193
+ browser = driver.chromium.launch(executable_path=browser_path, headless=True, args=["--no-sandbox"] if should_disable_sandbox else [])
194
+ page = browser.new_page(viewport=c.ALL_DESKTOP_VIEWPORT, device_scale_factor=1)
195
+ all_errors = []
196
+ page.on("pageerror", lambda each_error: all_errors.append(str(each_error)))
197
+ page.set_content(path.read_text(encoding="utf-8"))
198
+ root = page.locator(c.ROOT_SELECTOR)
199
+ all_scenes = root.locator(c.DATA_SCENES_SELECTOR).evaluate(c.READ_SCENES_EXPRESSION)
200
+ _dashboard(page, root, all_scenes, proof_directory)
201
+ all_verdicts = [_verify_scene(page, root, each_scene, each_index, proof_directory) for each_index, each_scene in enumerate(all_scenes)]
202
+ _verify_mobile(page, root, proof_directory)
203
+ _verify_isolation(page, path.read_text(encoding="utf-8"))
204
+ assert not all_errors, all_errors
205
+ browser.close()
206
+ report = {"input": str(path), "diagrams": all_verdicts, "browser_errors": all_errors, "mobile_stacked_panel": "pass", "multiple_widget_isolation": "pass", "dark_host_contrast": "pass"}
207
+ (proof_directory / c.REPORT_NAME).write_text(json.dumps(report, indent=c.JSON_INDENT), encoding="utf-8")
208
+ print(json.dumps(report, indent=c.JSON_INDENT))
209
+
210
+
211
+ if __name__ == "__main__":
212
+ parser = argparse.ArgumentParser(description=__doc__)
213
+ parser.add_argument("html", type=Path)
214
+ parser.add_argument("--out", type=Path, default=Path(c.DEFAULT_PROOF_DIRECTORY))
215
+ parser.add_argument("--browser-path", help="Chromium executable; defaults to a detected browser or Playwright Chromium.")
216
+ parser.add_argument("--no-sandbox", action="store_true", help="Use only when the browser runs in an isolated container.")
217
+ arguments = parser.parse_args()
218
+ verify(arguments.html, arguments.out, arguments.browser_path, arguments.no_sandbox)
@@ -0,0 +1,104 @@
1
+ """Capture limits, artifact names, and diagram boundaries."""
2
+ from pathlib import Path
3
+
4
+ SKILL_ROOT = Path(__file__).resolve().parents[3]
5
+ PAGE_SIZE = 100
6
+ SOURCE_WORKERS = 4
7
+ JSON_INDENT = 2
8
+ NEWLINE = "\n"
9
+ PARAGRAPH_BREAK = "\n\n"
10
+ ALL_SOURCE_SIDES = ("base", "head")
11
+ HEAD_SIDE = "head"
12
+ BASE_SIDE = "base"
13
+ PR_URL_PATTERN = r"https://github\.com/([\w.-]+/[\w.-]+)/pull/([1-9]\d*)/?"
14
+ PR_ENDPOINT = "repos/{repo}/pulls/{number}"
15
+ COMPARE_ENDPOINT = "repos/{repo}/compare/{base}...{head}"
16
+ FILES_ENDPOINT = "repos/{repo}/pulls/{number}/files?per_page={size}&page={page}"
17
+ CONTENTS_ENDPOINT = "repos/{repo}/contents/{path}?ref={revision}"
18
+ SOURCE_LINK = "https://github.com/{repo}/blob/{revision}/{path}#L{first}-L{last}"
19
+ DIFF_SECTION = "--- {filename}\n{patch}"
20
+ SOURCE_LINE = "{number:4} {line}"
21
+ SNAPSHOT_NAME = "snapshot.json"
22
+ DIFF_NAME = "changes.diff"
23
+ DEFAULT_EVIDENCE_DIRECTORY = "evidence"
24
+ SVG_PATH_PATTERN = r"[MmLlHhVvQqCcSsTtAaZz0-9.,\s-]+"
25
+ MINIMUM_NODE_X = 60
26
+ MAXIMUM_NODE_X = 1380
27
+ MINIMUM_NODE_Y = 200
28
+ MAXIMUM_NODE_Y = 760
29
+ MINIMUM_DRILLDOWN_STEPS = 2
30
+ MAXIMUM_DRILLDOWN_STEPS = 6
31
+ MAXIMUM_HTML_BYTES = 256 * 1024
32
+ VIEWER_PATH = SKILL_ROOT / "assets" / "viewer.html"
33
+ ICONS_PATH = SKILL_ROOT / "assets" / "icons.symbols"
34
+ ALL_DESKTOP_VIEWPORT = {"width": 1440, "height": 1200}
35
+ ALL_MOBILE_VIEWPORT = {"width": 390, "height": 844}
36
+ ROOT_SELECTOR = "[data-pr-visual]"
37
+ DRAWER_SELECTOR = ".drawer"
38
+ STAGE_SELECTOR = ".stage"
39
+ CANVAS_SELECTOR = ".canvas"
40
+ EXACT_SELECTOR = ".exact-source"
41
+ DISCLOSURE_SELECTOR = ".exact-source summary"
42
+ DETAIL_TITLE_SELECTOR = "[data-detail-title]"
43
+ NODE_SELECTOR = "[data-node]"
44
+ JUMP_SELECTOR = "[data-detail-target]"
45
+ DATA_SCENES_SELECTOR = "[data-scenes]"
46
+ CANVAS_WIDTH_EXPRESSION = "(n)=>n.style.width"
47
+ DISCLOSURE_OPEN_EXPRESSION = "(n)=>n.open"
48
+ SOURCE_HAS_EXCERPTS_EXPRESSION = "s=>s.nodes.some(n=>n.sources.some(r=>r.excerpt))"
49
+ BACKGROUND_EXPRESSION = "(n)=>getComputedStyle(n).backgroundColor"
50
+ SCROLLABLE_EXPRESSION = "(n)=>n.scrollWidth>n.clientWidth"
51
+ SCROLL_MAP_EXPRESSION = "(n)=>n.scrollLeft=600"
52
+ READ_SCROLL_EXPRESSION = "(n)=>n.scrollLeft"
53
+ READ_SCENES_EXPRESSION = "(n)=>JSON.parse(n.textContent)"
54
+ READ_IDS_EXPRESSION = "(nodes)=>nodes.map(n=>n.id)"
55
+ NODE_TARGET_SELECTOR = '[data-node="{target}"]'
56
+ SELECTED_NODE_SELECTOR = '[data-node][aria-expanded=true]'
57
+ PR_SCREENSHOT_NAME = "scene-{index}-pr-{number}.png"
58
+ DETAIL_SCREENSHOT_NAME = "scene-{index}-pr-{number}-detail.png"
59
+ MOBILE_SCREENSHOT_NAME = "mobile-detail.png"
60
+ REPORT_NAME = "verification.json"
61
+ DEFAULT_PROOF_DIRECTORY = "proof"
62
+ SPACE_SEPARATOR = " "
63
+ ZOOM_BUTTON_LABEL = "Zoom in"
64
+ CLOSE_BUTTON_LABEL = "Close details"
65
+ ALL_TOOLBAR_DETAIL_LABELS = ("Summary vs code", "Review notes")
66
+ OVERFLOW_EXPRESSION = "(nodes)=>nodes.filter(n=>{const b=n.getBBox(); return b.x<0||b.x+b.width>1440||b.y<0||b.y+b.height>1020}).map(n=>n.textContent)"
67
+ MAIN_FRAGMENT_PATTERN = r"<main data-pr-visual>.*?</main>"
68
+ STYLE_FRAGMENT_PATTERN = r"<style>.*?</style>"
69
+ HOST_FIXTURE_HTML = '<div class="toolbar" id="host-toolbar">Host toolbar</div>'
70
+ HOST_FIXTURE_SELECTOR = "#host-toolbar"
71
+ HOST_THEME_STYLES = 'body{color-scheme:dark;color:white;background:#121212}button{color:white;opacity:.45;text-shadow:0 0 2px white}span{color:#cccccc}'
72
+ CONTROL_STYLES_EXPRESSION = '(n)=>{const s=getComputedStyle(n);return {color:s.color,background:s.backgroundColor,opacity:s.opacity,shadow:s.textShadow,scheme:s.colorScheme,border:s.borderTopColor,outline:s.outlineColor}}'
73
+ TEAL_COLOR = "rgb(23, 53, 63)"
74
+ WHITE_COLOR = "rgb(255, 255, 255)"
75
+ LIME_COLOR = "rgb(216, 246, 135)"
76
+ CONTROL_BORDER_COLOR = "rgb(135, 155, 142)"
77
+ MINIMUM_TEXT_CONTRAST = 4.5
78
+ RGB_COMPONENT_PATTERN = r"\d+"
79
+ RGB_CHANNEL_MAXIMUM = 255
80
+ SRGB_LINEAR_THRESHOLD = 0.04045
81
+ SRGB_LINEAR_DIVISOR = 12.92
82
+ SRGB_OFFSET = 0.055
83
+ SRGB_SCALE = 1.055
84
+ SRGB_EXPONENT = 2.4
85
+ ALL_LUMINANCE_WEIGHTS = (0.2126, 0.7152, 0.0722)
86
+ CONTRAST_OFFSET = 0.05
87
+ MOUNT_WIDGET_EXPRESSION = 'html=>{const t=document.createElement("template");t.innerHTML=html;const scripts=[...t.content.querySelectorAll("script:not([type])")];scripts.forEach(n=>n.remove());document.body.append(t.content);scripts.forEach(old=>{const script=document.createElement("script");script.textContent=old.textContent;document.querySelectorAll("[data-pr-visual]").item(document.querySelectorAll("[data-pr-visual]").length-1).append(script)})}'
88
+
89
+ ALL_CHART_KINDS = ("bar", "line", "scatter")
90
+ ALL_CHART_AXES = ("x", "y")
91
+ CHART_Y_AXIS = "y"
92
+ METRIC_SOURCE_INDEX = "source"
93
+
94
+ PYTHON_LINE_FIELD = "python_line"
95
+ PYTHON_ARGUMENT_FIELD = "argument"
96
+ PYTHON_ELEMENT_FIELD = "element"
97
+ CHART_X_AXIS = "x"
98
+ ALL_CHART_BASES = ("captured", "source-example")
99
+ ALL_EMBEDDED_CHART_FIELDS = ("title", "caption", "x_label", "y_label", "points")
100
+
101
+ DASHBOARD_SCREENSHOT_NAME = "dashboard.png"
102
+ MAXIMUM_DASHBOARD_HEIGHT = 120
103
+
104
+ DASHBOARD_SELECTOR = ".tabs"