claude-dev-env 8.96.0 → 8.96.2
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.
- package/.agents/skills/pr-visual/SKILL.md +1 -1
- package/.agents/skills/pr-visual/assets/viewer.html +1 -1
- package/.agents/skills/pr-visual/fixtures/tracker/head/issues/2.md +9 -0
- package/.agents/skills/pr-visual/fixtures/tracker.scene.json +1 -1
- package/.agents/skills/pr-visual/scripts/render.py +33 -0
- package/.agents/skills/pr-visual/scripts/test_render.py +62 -2
- package/.agents/skills/pr-visual/scripts/verify.py +2 -1
- package/.agents/skills/pr-visual/scripts/visual_config/config/constants.py +3 -0
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ Turn a PR into a visual explanation of what its code changes. Return the display
|
|
|
11
11
|
|
|
12
12
|
Accept a PR URL. Resolve a bare PR number only when the repository is known.
|
|
13
13
|
|
|
14
|
-
Accept an issue URL for a status catch-up on an epic or tracker. The collector captures the issue, its sub-issues two levels deep, and the pull requests their bodies link. Draw that scene in this same viewer, batched with the scenes of the pull requests it names. Every pr-visual request renders through `assets/viewer.html`; a status report never moves to another page template. A tracker scene always draws every card inside its state chart. Set `board` in the scene: `lanes` run top to bottom in flow order, and each lane names one scene node. A lane with `progress` (`no_pull`, `open_pull`, `merged_pull`) takes every open tracker card in that state, grouped under its parent epic. A lane with `groups` takes cards that each cite a captured record and link inside the repository, such as done, skipped, or follow-up work listed in an epic body. Every node sits in exactly one lane. `strip` drops title prefixes, `titles` gives plain wording by issue number, and `exclude` leaves out issues that are not work. `steps` names the issue body sections each card shows as its own state diagram, such as `Today` and `Fix`, with a label and colour; the step marked `inline` also prints on the card. `wording` swaps words or whole sentences in that text for plain ones. A cited card carries its `steps` itself. Clicking a card opens its diagram in the side panel, with a link to the card on GitHub. `verify.py` checks the card count, that each title fits its card, that the first card in each lane opens a diagram with one box per step, and that board and diagram text is 16px or larger at 7:1 contrast.
|
|
14
|
+
Accept an issue URL for a status catch-up on an epic or tracker. The collector captures the issue, its sub-issues two levels deep, and the pull requests their bodies link. Draw that scene in this same viewer, batched with the scenes of the pull requests it names. Every pr-visual request renders through `assets/viewer.html`; a status report never moves to another page template. A tracker scene always draws every card inside its state chart. Set `board` in the scene: `lanes` run top to bottom in flow order, and each lane names one scene node. A lane with `progress` (`no_pull`, `open_pull`, `merged_pull`) takes every open tracker card in that state, grouped under its parent epic. A lane with `groups` takes cards that each cite a captured record and link inside the repository, such as done, skipped, or follow-up work listed in an epic body. Every node sits in exactly one lane. `strip` drops title prefixes, `titles` gives plain wording by issue number, and `exclude` leaves out issues that are not work. `steps` names the issue body sections each card shows as its own state diagram, such as `Today` and `Fix`, with a label and colour; the step marked `inline` also prints on the card. `wording` swaps words or whole sentences in that text for plain ones. A cited card carries its `steps` itself. `render.py` stops when a card has no step of its own: each card needs one step whose text is not its title or note and appears on no other card, so a board of title and link chips never renders. Clicking a card opens its diagram in the side panel, with a link to the card on GitHub. `verify.py` checks the card count, that each title fits its card, that the first card in each lane opens a diagram with one box per step that shows each step's text, and that board and diagram text is 16px or larger at 7:1 contrast.
|
|
15
15
|
|
|
16
16
|
Read the description, changed code, relevant callers, and tests. Build an interactive diagram with short labels, visual drilldowns, source details, and a summary-to-code comparison.
|
|
17
17
|
|
|
@@ -132,7 +132,7 @@ function cardFlow(steps){const width=396,pad=14,step=22,flow=el('svg',{class:'ca
|
|
|
132
132
|
flow.append(el('rect',{x:2,y,width:width-4,height,rx:12,class:`card-step ${v.kind||''}`}));text(flow,pad+2,y+pad+15,v.label,'card-step-label');lines(flow,pad+2,y+pad+15+step,body,'card-step-text','start',step);y+=height+34});
|
|
133
133
|
flow.setAttribute('viewBox',`0 0 ${width} ${y-30}`);return flow}
|
|
134
134
|
function showCard(card,lane,trigger){const s=scenes[active],state=s.nodes.find(n=>n.id===lane.node);lastFocus=trigger;resetSelection();content.replaceChildren();$('[data-detail-title]').textContent=card.title;
|
|
135
|
-
content.append(dom('p',[state.label.join(' '),card.note].filter(Boolean).join(' \u00b7 '),'card-state'),cardFlow(card.steps
|
|
135
|
+
content.append(dom('p',[state.label.join(' '),card.note].filter(Boolean).join(' \u00b7 '),'card-state'),cardFlow(card.steps));
|
|
136
136
|
const a=dom('a',card.number?`Open issue ${card.number} on GitHub`:'Open it on GitHub','card-link');a.href=card.url;a.target='_blank';a.rel='noopener noreferrer';content.append(a);
|
|
137
137
|
content.scrollTop=0;drawer.hidden=false;workspace.classList.add('open');$('.close').focus({preventScroll:true})}
|
|
138
138
|
function chartNode(svg,n,x,y,bar){
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
{"heading": "Proof", "label": "Proof", "kind": "green"}
|
|
81
81
|
],
|
|
82
82
|
"lanes": [
|
|
83
|
-
{"node": "epic", "groups": [{"title": "Follow-up branches", "cards": [{"title": "Task one follow-up", "source": {"path": "issues/1.md", "line": 5}}]}]},
|
|
83
|
+
{"node": "epic", "groups": [{"title": "Follow-up branches", "cards": [{"title": "Task one follow-up", "source": {"path": "issues/1.md", "line": 5}, "steps": [{"label": "Today", "kind": "orange", "text": "Task one waits for a follow-up branch."}]}]}]},
|
|
84
84
|
{"node": "left", "progress": "no_pull"},
|
|
85
85
|
{"node": "fixed", "progress": "merged_pull"}
|
|
86
86
|
]
|
|
@@ -4,6 +4,7 @@ import copy
|
|
|
4
4
|
import itertools
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
|
+
from collections import Counter
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from urllib.parse import quote
|
|
9
10
|
|
|
@@ -166,6 +167,38 @@ def _prepare_board(scene: dict, snapshot: dict, snapshot_path: Path) -> None:
|
|
|
166
167
|
raise RenderRunFatal("Every board node needs exactly one lane.")
|
|
167
168
|
all_lanes = [_board_lane(each_lane, tracker, settings, snapshot, snapshot_path) for each_lane in settings["lanes"]]
|
|
168
169
|
scene["board"] = {"lanes": all_lanes, "count": sum(each_lane["count"] for each_lane in all_lanes)}
|
|
170
|
+
require_own_card_content(scene["board"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def require_own_card_content(board: dict) -> None:
|
|
174
|
+
"""Refuse a board whose cards say nothing of their own.
|
|
175
|
+
|
|
176
|
+
A card passes when one of its steps carries text that is not its title
|
|
177
|
+
or note and that no other card on the board repeats, and every step
|
|
178
|
+
carries text. A card holding only a title, a link, text another card
|
|
179
|
+
repeats, or a step with no text fails.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
board: Prepared board with ``lanes`` of ``groups`` of ``cards``.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
RenderRunFatal: Names each card with no content of its own.
|
|
186
|
+
"""
|
|
187
|
+
all_cards = [each_card for each_lane in board["lanes"] for each_group in each_lane["groups"] for each_card in each_group["cards"]]
|
|
188
|
+
text_counts = Counter(each_text for each_card in all_cards for each_text in _step_texts(each_card))
|
|
189
|
+
all_empty_titles = [each_card["title"] for each_card in all_cards if not _has_own_step(each_card, text_counts)]
|
|
190
|
+
if all_empty_titles:
|
|
191
|
+
raise RenderRunFatal(c.EMPTY_CARD_MESSAGE.format(count=len(all_empty_titles), total=len(all_cards), titles=c.CARD_TITLE_SEPARATOR.join(all_empty_titles[:c.EMPTY_CARD_TITLE_LIMIT])))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _step_texts(card: dict) -> set[str]:
|
|
195
|
+
return {each_step.get("text", "").strip() for each_step in card.get("steps", [])}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _has_own_step(card: dict, text_counts: Counter) -> bool:
|
|
199
|
+
all_borrowed_text = {card["title"].strip(), card.get("note", "").strip(), ""}
|
|
200
|
+
is_every_step_written = all(each_step.get("text") for each_step in card.get("steps", []))
|
|
201
|
+
return is_every_step_written and any(text_counts[each_text] == 1 and each_text not in all_borrowed_text for each_text in _step_texts(card))
|
|
169
202
|
|
|
170
203
|
|
|
171
204
|
def _prepare_sources(scene: dict, snapshot: dict, snapshot_path: Path) -> None:
|
|
@@ -10,7 +10,7 @@ import pytest
|
|
|
10
10
|
SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
11
11
|
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
12
12
|
from metrics import MetricRunFatal, prepare_chart
|
|
13
|
-
from render import RenderRunFatal, _source_evidence, prepare, render
|
|
13
|
+
from render import RenderRunFatal, _source_evidence, prepare, render, require_own_card_content
|
|
14
14
|
|
|
15
15
|
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures"
|
|
16
16
|
FORMATS = ("flow", "bar", "line", "scatter")
|
|
@@ -115,7 +115,7 @@ def test_tracker_scene_places_every_card_in_its_state_lane(tmp_path: Path) -> No
|
|
|
115
115
|
{"label": "Build", "kind": "lime", "inline": True, "text": "Build the sample fix and link it."},
|
|
116
116
|
{"label": "Proof", "kind": "green", "text": "A sample test shows the fix."},
|
|
117
117
|
]
|
|
118
|
-
assert fixed["groups"][0]["cards"][0]["steps"] ==
|
|
118
|
+
assert fixed["groups"][0]["cards"][0]["steps"][0] == {"label": "Today", "kind": "orange", "text": "The sample fix merged, and its task stays open."}
|
|
119
119
|
assert "Task one ships" not in (tmp_path / "visual.html").read_text()
|
|
120
120
|
|
|
121
121
|
|
|
@@ -137,6 +137,66 @@ def test_tracker_cited_card_rejects_a_link_outside_the_repository(tmp_path: Path
|
|
|
137
137
|
_prepare_copy(tmp_path, scene)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def test_tracker_board_rejects_cards_holding_only_a_title_and_link(tmp_path: Path) -> None:
|
|
141
|
+
"""A board of title and link chips stops the render."""
|
|
142
|
+
scene = _board_scene(tmp_path)
|
|
143
|
+
scene["board"]["steps"] = []
|
|
144
|
+
scene["board"]["lanes"][0]["groups"][0]["cards"][0].pop("steps")
|
|
145
|
+
with pytest.raises(RenderRunFatal, match="3 of 3 board cards carry no content of their own"):
|
|
146
|
+
_prepare_copy(tmp_path, scene)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_tracker_board_rejects_a_card_whose_text_another_card_repeats(tmp_path: Path) -> None:
|
|
150
|
+
"""Text another card repeats is not a card's own content."""
|
|
151
|
+
scene = _board_scene(tmp_path)
|
|
152
|
+
scene["board"]["lanes"][0]["groups"][0]["cards"][0]["steps"] = [{"label": "Today", "text": "The sample task has no fix yet."}]
|
|
153
|
+
with pytest.raises(RenderRunFatal, match="1 of 3 board cards .*Task one follow-up"):
|
|
154
|
+
_prepare_copy(tmp_path, scene)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_tracker_board_rejects_a_step_that_repeats_the_title(tmp_path: Path) -> None:
|
|
158
|
+
"""A step that only restates the card title is not content."""
|
|
159
|
+
scene = _board_scene(tmp_path)
|
|
160
|
+
scene["board"]["lanes"][0]["groups"][0]["cards"][0]["steps"] = [{"label": "Card", "text": "Task one follow-up"}]
|
|
161
|
+
with pytest.raises(RenderRunFatal, match="Task one follow-up"):
|
|
162
|
+
_prepare_copy(tmp_path, scene)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_own_card_content_accepts_cards_that_share_only_a_templated_step() -> None:
|
|
166
|
+
"""A shared step is allowed when each card also carries a step of its own."""
|
|
167
|
+
shared_step = {"label": "Your pick", "text": "Skip. Nothing gets built."}
|
|
168
|
+
all_cards = [{"title": f"Card {each_index}", "note": "", "steps": [{"label": "Today", "text": f"Card {each_index} has its own gap."}, shared_step]} for each_index in range(2)]
|
|
169
|
+
assert require_own_card_content({"lanes": [{"groups": [{"cards": all_cards}]}]}) is None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_own_card_content_names_the_card_with_only_shared_steps() -> None:
|
|
173
|
+
"""The refusal names the card whose every step another card repeats."""
|
|
174
|
+
shared_step = {"label": "Your pick", "text": "Skip. Nothing gets built."}
|
|
175
|
+
all_cards = [{"title": "Own card", "steps": [{"label": "Today", "text": "Own gap."}, shared_step]}, {"title": "Templated card", "steps": [shared_step]}]
|
|
176
|
+
with pytest.raises(RenderRunFatal, match="1 of 2 board cards .*: Templated card$"):
|
|
177
|
+
require_own_card_content({"lanes": [{"groups": [{"cards": all_cards}]}]})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_own_card_content_accepts_text_a_card_repeats_only_within_itself() -> None:
|
|
181
|
+
"""Two steps of one card that share text still count as that card's own."""
|
|
182
|
+
all_cards = [{"title": "Repeating card", "steps": [{"label": "Today", "text": "See the gap."}, {"label": "Fix", "text": "See the gap."}]}]
|
|
183
|
+
assert require_own_card_content({"lanes": [{"groups": [{"cards": all_cards}]}]}) is None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_own_card_content_refuses_steps_without_text() -> None:
|
|
187
|
+
"""A step holding only a label is no content."""
|
|
188
|
+
all_cards = [{"title": "Label card", "steps": [{"label": "Today"}]}]
|
|
189
|
+
with pytest.raises(RenderRunFatal, match="Label card"):
|
|
190
|
+
require_own_card_content({"lanes": [{"groups": [{"cards": all_cards}]}]})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_own_card_content_refuses_a_card_with_one_textless_step() -> None:
|
|
194
|
+
"""A step the viewer would draw with no text stops the render, even beside an own step."""
|
|
195
|
+
all_cards = [{"title": "Half card", "steps": [{"label": "Today", "text": "Own gap."}, {"label": "Fix"}]}]
|
|
196
|
+
with pytest.raises(RenderRunFatal, match="Half card"):
|
|
197
|
+
require_own_card_content({"lanes": [{"groups": [{"cards": all_cards}]}]})
|
|
198
|
+
|
|
199
|
+
|
|
140
200
|
def test_tracker_board_rejects_a_node_with_no_lane(tmp_path: Path) -> None:
|
|
141
201
|
"""Every state node on a board sits in exactly one lane."""
|
|
142
202
|
scene = _board_scene(tmp_path)
|
|
@@ -66,7 +66,8 @@ def _check_board(root: Locator, scene: dict) -> None:
|
|
|
66
66
|
card = each_lane["groups"][0]["cards"][0]
|
|
67
67
|
all_cards.nth(first_card).click()
|
|
68
68
|
assert root.locator(c.DETAIL_TITLE_SELECTOR).inner_text() == card["title"]
|
|
69
|
-
assert root.locator(c.CARD_STEP_SELECTOR).count() ==
|
|
69
|
+
assert root.locator(c.CARD_STEP_SELECTOR).count() == len(card["steps"])
|
|
70
|
+
assert all(each_step["text"] in c.SPACE_SEPARATOR.join(root.locator(c.CARD_FLOW_TEXT_SELECTOR).all_text_contents()) for each_step in card["steps"])
|
|
70
71
|
_check_board_text(root, c.CARD_FLOW_TEXT_SELECTOR)
|
|
71
72
|
root.get_by_role("button", name=c.CLOSE_BUTTON_LABEL).click()
|
|
72
73
|
assert root.locator(c.DRAWER_SELECTOR).is_hidden()
|
|
@@ -35,6 +35,9 @@ ALL_PULL_PROGRESS = ("merged_pull", "open_pull", "no_pull")
|
|
|
35
35
|
ALL_CARD_NOTES = {"no_pull": "", "open_pull": "Open in pull request {pulls}", "merged_pull": "Merged in pull request {pulls}"}
|
|
36
36
|
PULL_REFERENCE = "{number}"
|
|
37
37
|
PULL_REFERENCE_SEPARATOR = ", "
|
|
38
|
+
EMPTY_CARD_MESSAGE = "{count} of {total} board cards carry no content of their own. Give each card a step whose text is not its title or note and no other card repeats: {titles}"
|
|
39
|
+
EMPTY_CARD_TITLE_LIMIT = 5
|
|
40
|
+
CARD_TITLE_SEPARATOR = ", "
|
|
38
41
|
ALL_PULL_FIELDS =("number", "title", "state", "draft", "merged_at", "closed_at", "updated_at", "html_url")
|
|
39
42
|
COMPARE_ENDPOINT = "repos/{repo}/compare/{base}...{head}"
|
|
40
43
|
FILES_ENDPOINT = "repos/{repo}/pulls/{number}/files?per_page={size}&page={page}"
|