claude-dev-env 8.95.0 → 8.96.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.
- package/.agents/skills/correction/playbooks/fix.md +2 -2
- package/.agents/skills/correction/scripts/test_correction_task_card.py +7 -0
- package/.agents/skills/pr-visual/README.md +2 -0
- package/.agents/skills/pr-visual/SKILL.md +5 -3
- package/.agents/skills/pr-visual/assets/viewer.html +40 -10
- package/.agents/skills/pr-visual/fixtures/tracker/head/issues/1.json +31 -0
- package/.agents/skills/pr-visual/fixtures/tracker/head/issues/1.md +5 -0
- package/.agents/skills/pr-visual/fixtures/tracker/head/issues/2.json +31 -0
- package/.agents/skills/pr-visual/fixtures/tracker/head/issues/2.md +1 -0
- package/.agents/skills/pr-visual/fixtures/tracker/head/issues/4.json +20 -0
- package/.agents/skills/pr-visual/fixtures/tracker/head/issues/4.md +11 -0
- package/.agents/skills/pr-visual/fixtures/tracker/head/pulls/3.json +10 -0
- package/.agents/skills/pr-visual/fixtures/tracker/head/tracker.json +86 -0
- package/.agents/skills/pr-visual/fixtures/tracker/snapshot.json +20 -0
- package/.agents/skills/pr-visual/fixtures/tracker.scene.json +90 -0
- package/.agents/skills/pr-visual/scripts/collect.py +118 -3
- package/.agents/skills/pr-visual/scripts/render.py +117 -2
- package/.agents/skills/pr-visual/scripts/test_collect.py +48 -0
- package/.agents/skills/pr-visual/scripts/test_render.py +84 -0
- package/.agents/skills/pr-visual/scripts/verify.py +38 -4
- package/.agents/skills/pr-visual/scripts/visual_config/config/constants.py +38 -1
- package/bin/install-pstack-plugin.mjs +26 -0
- package/bin/install-pstack-plugin.test.mjs +22 -0
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ Use this playbook when this session lands the fix for the correction itself. One
|
|
|
8
8
|
4. Search for an existing control: a rule, hook, lint, test, or skill that already names this mistake. A control that exists and still let the mistake through makes this a repeat.
|
|
9
9
|
5. Choose the layer with [`correction-lens.md`](../../../../docs/rule-guides/correction-lens.md). When `Repeat` is `yes`, or step 4 found a control, go one layer above the one that failed.
|
|
10
10
|
6. Land the control in the repository whose code, CI, or agents it guards, per the guide's "Where the control lands" section. When that repository is not in this session, add it. When it cannot be added, print the brief, name the missing repository, and stop.
|
|
11
|
-
7. Prove the control. Show it fail on the mistake from the brief's `Evidence` line, then pass with the change, per [`falsify-before-green.md`](../../../../rules/falsify-before-green.md) and [`proof-before-pull-request.md`](../../../../docs/rule-guides/proof-before-pull-request.md). Without that proof, open no pull request.
|
|
12
|
-
8. Open the pull request through `pr-lifecycle`. Put the brief, the layer, and both proof results in the body.
|
|
11
|
+
7. Prove the control. Show it fail on the mistake from the brief's `Evidence` line, then pass with the change, per [`falsify-before-green.md`](../../../../rules/falsify-before-green.md) and [`proof-before-pull-request.md`](../../../../docs/rule-guides/proof-before-pull-request.md). Without that proof, open no pull request. When the control is a hook, read the correction instructions of the repository from step 6 as step 3 does. When they name an incident eval, add the incident as a case to that eval in the same pull request. Run the eval's compare against the base branch and keep the line that shows the case fails on the base and holds on the branch.
|
|
12
|
+
8. Open the pull request through `pr-lifecycle`. Put the brief, the layer, and both proof results in the body. When step 7 added an incident case, also name its path and its compare line.
|
|
13
13
|
|
|
14
14
|
**Reply:** the layer chosen, why each higher layer cannot hold the lesson, and the pull request link.
|
|
@@ -140,3 +140,10 @@ def test_should_return_the_playbook_from_step_two() -> None:
|
|
|
140
140
|
handed_off_steps = correction_task_card.handed_off_playbook_steps(FIX_PLAYBOOK_TEXT)
|
|
141
141
|
assert handed_off_steps.startswith("2. ")
|
|
142
142
|
assert "pull request link" in handed_off_steps
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_should_hand_off_the_incident_case_step_with_the_proof() -> None:
|
|
146
|
+
handed_off_steps = correction_task_card.handed_off_playbook_steps(FIX_PLAYBOOK_TEXT)
|
|
147
|
+
proof_step = handed_off_steps.split("\n7. ", 1)[1].split("\n8. ", 1)[0]
|
|
148
|
+
assert "incident eval" in proof_step
|
|
149
|
+
assert "same pull request" in proof_step
|
|
@@ -10,6 +10,8 @@ python scripts/render.py SCENE.json --out RUN/visual.html --links-only
|
|
|
10
10
|
python scripts/verify.py RUN/visual.html --out RUN/proof
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
An issue URL captures a tracker epic, its sub-issues, and the pull requests their bodies link. Its scene renders in the same viewer, and each source opens the captured issue or pull request. The rendered scene draws every card in a state lane, grouped under its epic, and each card opens its own state diagram of what it fixes and how it is proven. `fixtures/tracker.scene.json` shows the shape.
|
|
14
|
+
|
|
13
15
|
Flow diagrams, decision trees, dependency graphs, comparisons, bar charts, line charts, and scatter plots share the same source details. Numerical chart coordinates resolve JSON pointers against captured source files. The renderer does not supply an independent semantic code review.
|
|
14
16
|
|
|
15
17
|
Try the synthetic fixture gallery from this directory:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pr-visual
|
|
3
|
-
description: Read a pull request's summary and code, trace the changed behavior, and return an interactive visual. Use for "visualize this PR", "draw this change", "PR visual", "chart this PR", "graph this change", or a PR URL with a request for a visual. Accept any accessible GitHub PR.
|
|
3
|
+
description: Read a pull request's summary and code, trace the changed behavior, and return an interactive visual. Also draws a tracker epic's status from its issues and linked pull requests. Use for "visualize this PR", "draw this change", "PR visual", "chart this PR", "graph this change", a status catch-up on an epic, or a PR or issue URL with a request for a visual. Accept any accessible GitHub PR or issue.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# PR visual
|
|
@@ -11,13 +11,15 @@ 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.
|
|
15
|
+
|
|
14
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.
|
|
15
17
|
|
|
16
18
|
Use the current agent for interpretation. The collector and renderer run locally. The shell command alone does not review code. Complete the whole workflow when this skill is invoked.
|
|
17
19
|
|
|
18
20
|
## Workflow
|
|
19
21
|
|
|
20
|
-
1. **Capture the PR.** Run `python <skill-root>/scripts/collect.py <PR-URL> --out <run-dir>/evidence`. It saves the PR description, open or closed state, base and head commits, changed paths, patches, and full base/head changed-file source. Use an authorized GitHub connector if gh is unavailable. Preserve the same snapshot fields. If capture fails or a patch is incomplete, fetch the missing source before interpretation. Do not invent a complete review from a partial diff.
|
|
22
|
+
1. **Capture the PR or issue.** Run `python <skill-root>/scripts/collect.py <PR-or-issue-URL> --out <run-dir>/evidence`. An issue capture writes `issues/<n>.md`, `issues/<n>.json`, `pulls/<n>.json`, and `tracker.json` under `head/`; cite their lines, and chart `tracker.json` counts by JSON pointer. It saves the PR description, open or closed state, base and head commits, changed paths, patches, and full base/head changed-file source. Use an authorized GitHub connector if gh is unavailable. Preserve the same snapshot fields. If capture fails or a patch is incomplete, fetch the missing source before interpretation. Do not invent a complete review from a partial diff.
|
|
21
23
|
2. **Trace the behavior.** Read the snapshot description and diff. Follow the code from its entry point to its output, including decisions, side effects, failure paths, and material callers. Fetch unchanged callers at the captured commit when needed. Read repository review instructions. Compare the summary with the code. Record material extra scope, contradictions, and limitations. Treat prose claims and past test reports as claims until supported by current source or observed execution.
|
|
22
24
|
3. **Choose the format.** Read [the visual contract](references/visual-contract.md). Pick the structure the change needs. Use a state loop for a sampler, a decision tree for an admission gate, split lanes for two job paths, or an angle loop for capture. Keep the top dashboard to compact PR selectors. Embed quantitative charts inside the relevant flow-step cards and their expansions. Keep the review, charts, and evidence together on one Page. Use a chart when captured numerical data answers the question: bars for categories, lines for ordered observations, scatter for relationships. Read [format selection](references/formats.md). Keep the cream background, teal line icons, green progress paths, orange conditional paths, and generous spacing. Give every node a source-backed mini-diagram in a docked detail panel, related-step navigation, and collapsed exact explanation and sources. Keep the main map usable while the panel is open. Do not copy the shipping instructions into unrelated content.
|
|
23
25
|
4. **Render the scene.** Write a scene JSON using [the scene contract](references/scene-contract.md). Attach immutable file-and-line evidence to every node. Render it with `python <skill-root>/scripts/render.py <scene.json> --out <run-dir>/visual.html`. For hosted display, add `--links-only` to omit full PR bodies and source excerpts while retaining the diagrams and immutable links. For a batch, pass multiple scene files to create a compact selector dashboard and flow reviews. Avoid a detached chart-demo gallery. Compare the generated diagram with the source once more. Fix misleading arrows, labels, implied guarantees, or omitted branches before continuing.
|
|
@@ -56,7 +58,7 @@ Return the displayed visual and a short link to the saved result. Mention a mate
|
|
|
56
58
|
| `scripts/visual_config/` | Shared constants |
|
|
57
59
|
| `assets/` | Standalone viewer and icon symbol definitions |
|
|
58
60
|
| `references/` | Scene, visual, and format contracts |
|
|
59
|
-
| `fixtures/` | Synthetic flow, bar, line, and
|
|
61
|
+
| `fixtures/` | Synthetic flow, bar, line, scatter, and tracker examples |
|
|
60
62
|
| `scripts/test_render.py` | Evidence and numerical validation cases |
|
|
61
63
|
| `scripts/test_collect.py` | Capture consistency and stale-head cases |
|
|
62
64
|
| `scripts/test_metrics.py` | Embedded-chart provenance and passive literal extraction |
|
|
@@ -36,6 +36,13 @@
|
|
|
36
36
|
[data-pr-visual] .detail-map .mini-label{font-size:12px;font-weight:700}[data-pr-visual] .detail-map .mini-edge{font-size:10px;fill:#64746e}
|
|
37
37
|
[data-pr-visual] .detail-jumps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-bottom:18px}[data-pr-visual] .detail-jumps button{display:flex;flex-direction:column;align-items:center;gap:7px;background:#f2f5ea;border:1px solid #879b8e;border-radius:6px;padding:9px 4px;color:#17353f;font-size:11px;line-height:1.3}[data-pr-visual] .detail-jumps button span{color:#17353f}[data-pr-visual] .detail-jumps svg{width:24px;height:24px}
|
|
38
38
|
[data-pr-visual] .exact-source{border-top:1px solid #d5dcd2;padding-top:14px;margin:18px 0}[data-pr-visual] .exact-source summary{font-size:13px;font-weight:700;color:#52695f;cursor:pointer}
|
|
39
|
+
[data-pr-visual] .band{fill:#fffef8;stroke:#d5dcd2;stroke-width:1.5}[data-pr-visual] .band.lime{fill:#f1f9db;stroke:#a4c357}[data-pr-visual] .band.orange{fill:#fcf1e4;stroke:#ca955e}[data-pr-visual] .band.green{fill:#eef5ea;stroke:#9db996}[data-pr-visual] .band.blue{fill:#e9f2f5;stroke:#8faeb9}
|
|
40
|
+
[data-pr-visual] [data-card]{cursor:pointer;outline:none}[data-pr-visual] .card-chip{fill:#fffef8;stroke:#aabdac;stroke-width:1.5}[data-pr-visual] [data-card]:hover .card-chip,[data-pr-visual] [data-card]:focus .card-chip{stroke:#217757;stroke-width:3}
|
|
41
|
+
[data-pr-visual] .drawer .card-state{font-size:16px;font-weight:700;color:#17353f;margin:0 0 14px}[data-pr-visual] .drawer .card-link{font-size:16px;font-weight:700;color:#17353f;margin:18px 0}
|
|
42
|
+
[data-pr-visual] .card-step{fill:#fffef8;stroke:#aabdac;stroke-width:1.5}[data-pr-visual] .card-step.orange{fill:#fcf1e4;stroke:#ca955e}[data-pr-visual] .card-step.lime{fill:#f1f9db;stroke:#a4c357}[data-pr-visual] .card-step.blue{fill:#e9f2f5;stroke:#8faeb9}[data-pr-visual] .card-step.green{fill:#eef5ea;stroke:#9db996}
|
|
43
|
+
[data-pr-visual] .card-flow .card-step-label{font-size:16px;font-weight:800;fill:#17353f}[data-pr-visual] .card-flow .card-step-text{font-size:16px;fill:#17353f}
|
|
44
|
+
[data-pr-visual] .board .card-text{font-size:16px;font-weight:700;fill:#17353f}[data-pr-visual] .board .card-build,[data-pr-visual] .board .lane-sub{font-size:16px;fill:#17353f}[data-pr-visual] .board .card-note{font-size:16px;font-weight:700;fill:#17353f}[data-pr-visual] .board .board-group{font-size:18px;font-weight:800;fill:#17353f;text-decoration:underline}
|
|
45
|
+
[data-pr-visual] .board .subtitle,[data-pr-visual] .board .micro,[data-pr-visual] .board .kicker,[data-pr-visual] .board .stamp,[data-pr-visual] .board .foot-label{font-size:16px;fill:#17353f}
|
|
39
46
|
[data-pr-visual] .sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
|
|
40
47
|
[data-pr-visual] button:focus-visible,[data-pr-visual] summary:focus-visible,[data-pr-visual] a:focus-visible{outline:3px solid #17353f;outline-offset:3px}
|
|
41
48
|
[data-pr-visual] .toolbar button:focus-visible{outline-color:#d8f687}
|
|
@@ -49,7 +56,7 @@
|
|
|
49
56
|
<script type="application/json" data-scenes>__SCENES__</script><script>
|
|
50
57
|
(()=>{
|
|
51
58
|
const root=document.currentScript.closest('[data-pr-visual]'),$=s=>root.querySelector(s),all=s=>[...root.querySelectorAll(s)],ns='http://www.w3.org/2000/svg';
|
|
52
|
-
const scenes=JSON.parse($('[data-scenes]').textContent),icons=`__ICONS__`,prefix='prv-'+Math.random().toString(36).slice(2)+'-';
|
|
59
|
+
const scenes=JSON.parse($('[data-scenes]').textContent),isIssue=s=>s.pr.kind==='issue',refName=s=>isIssue(s)?'Issue':'PR',icons=`__ICONS__`,prefix='prv-'+Math.random().toString(36).slice(2)+'-';
|
|
53
60
|
const canvas=$('.canvas'),tabs=$('.tabs'),drawer=$('.drawer'),workspace=$('.workspace'),content=$('.content');let active=0,zoom=1,lastFocus;
|
|
54
61
|
canvas.id=prefix+'diagram';drawer.id=prefix+'details';
|
|
55
62
|
function el(tag,attrs={},text){const n=document.createElementNS(ns,tag);Object.entries(attrs).forEach(([k,v])=>{if(k==='href'&&v.startsWith('#'))v='#'+prefix+v.slice(1);if(k==='marker-end')v=v.replace('#','#'+prefix);n.setAttribute(k,String(v))});if(text!==undefined)n.textContent=text;return n}
|
|
@@ -102,9 +109,32 @@ function showPanel(title,flow,paragraphs,sources=[],raw,related=[],trigger,chart
|
|
|
102
109
|
if(related.length){content.append(dom('h3','Related steps'));const jumps=dom('div',undefined,'detail-jumps');related.forEach(n=>{const b=dom('button');b.dataset.detailTarget=n.id;const icon=el('svg',{viewBox:'0 0 48 48','aria-hidden':true});icon.append(el('use',{href:'#'+n.icon,width:48,height:48,class:'icon'}));b.append(icon,dom('span',n.label.join(' ')));b.onclick=()=>showNode(n.id);jumps.append(b)});content.append(jumps)}
|
|
103
110
|
const exact=dom('details',undefined,'exact-source');exact.append(dom('summary','Explanation and sources'));paragraphs.forEach(v=>exact.append(dom('p',v)));
|
|
104
111
|
sources.forEach(s=>{const a=dom('a',`${s.path} \u00b7 lines ${s.line}\u2013${s.end||s.line}`);a.href=s.url;a.target='_blank';a.rel='noopener noreferrer';exact.append(a);if(s.excerpt)exact.append(dom('pre',s.excerpt))});
|
|
105
|
-
if(raw)exact.append(dom('h3'
|
|
112
|
+
if(raw)exact.append(dom('h3',`${refName(scenes[active])} description at capture`),dom('pre',raw));content.append(exact);content.scrollTop=0;drawer.hidden=false;workspace.classList.add('open');$('.close').focus({preventScroll:true})}
|
|
106
113
|
function showNode(id){const s=scenes[active],n=s.nodes.find(n=>n.id===id);resetSelection();const trigger=all('[data-node]').find(g=>g.dataset.node===id);trigger.setAttribute('aria-expanded','true');const neighbors=new Set(s.edges.filter(e=>e.from===id||e.to===id).flatMap(e=>[e.from,e.to]));neighbors.delete(id);showPanel(n.label.join(' '),n.drilldown,n.detail,n.sources,undefined,s.nodes.filter(n=>neighbors.has(n.id)),trigger,n.chart)}
|
|
107
114
|
$('.close').onclick=()=>close();root.addEventListener('keydown',e=>{if(e.key==='Escape'&&!drawer.hidden){e.preventDefault();close()}});
|
|
115
|
+
function drawNode(svg,n,subClass='micro',subGap=19){const g=el('g',{class:'node','data-node':n.id,tabindex:0,role:'button','aria-expanded':'false','aria-controls':drawer.id,'aria-label':n.label.join(' ')});g.append(el('title',{},'Open code evidence'));if(n.chart){stepCard(g,n)}else if(n.kind==='decision'){g.append(el('path',{d:`M${n.x} ${n.y-54} L${n.x+62} ${n.y} L${n.x} ${n.y+54} L${n.x-62} ${n.y} Z`,class:'diamond'}));lines(g,n.x,n.y-3,n.label,'node-label','middle',21)}else{g.append(el('circle',{cx:n.x,cy:n.y,r:n.r||43,class:`disc ${n.kind||''}`}));g.append(el('use',{href:`#${n.icon}`,x:n.x-22,y:n.y-22,width:44,height:44,class:'icon'}));lines(g,n.x,n.y+70,n.label,'node-label','middle')}if(n.sub&&!n.chart)lines(g,n.x,n.y+(n.label.length>1?121:99),n.sub,subClass,'middle',subGap);const show=()=>showNode(n.id);g.onclick=show;g.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();show()}};svg.append(g)}
|
|
116
|
+
const measure=document.createElement('canvas').getContext('2d');
|
|
117
|
+
function wrap(value,width,font){measure.font=font;const out=[];let line='';value.split(' ').forEach(w=>{const next=line?`${line} ${w}`:w;if(line&&measure.measureText(next).width>width){out.push(line);line=w}else line=next});if(line)out.push(line);return out}
|
|
118
|
+
function link(href){return el('a',{href,target:'_blank',rel:'noopener noreferrer'})}
|
|
119
|
+
function drawBoard(svg,s){
|
|
120
|
+
const rail=150,left=300,right=1404,gap=14,pad=14,step=22,columns=2,width=(right-left-gap*(columns-1))/columns;let y=184,previous;
|
|
121
|
+
s.board.lanes.forEach(lane=>{const n={...s.nodes.find(n=>n.id===lane.node),x:rail,y:y+72},band=el('rect',{x:24,y,width:1392,rx:18,class:`band ${n.kind||''}`});svg.append(band);
|
|
122
|
+
const nodeBottom=n.y+(n.sub?(n.label.length>1?121:99)+(n.sub.length-1)*21:70+(n.label.length-1)*23)+12;let cy=y+20;
|
|
123
|
+
lane.groups.forEach(g=>{const head=link(g.url);text(head,left,cy+20,`${g.title} \u00b7 ${g.cards.length}`,'board-group');svg.append(head);cy+=36;
|
|
124
|
+
for(let i=0;i<g.cards.length;i+=columns){const row=g.cards.slice(i,i+columns).map(card=>{const inline=(card.steps||[]).find(v=>v.inline);return {card,body:wrap(card.title,width-pad*2,'bold 16px Arial'),build:inline?wrap(`${inline.label}: ${inline.text}`,width-pad*2,'16px Arial'):[],note:card.note?wrap(card.note,width-pad*2,'bold 16px Arial'):[]}});const height=pad*2+Math.max(...row.map(r=>r.body.length+r.build.length+r.note.length))*step-4;
|
|
125
|
+
row.forEach((r,j)=>{const x=left+j*(width+gap),chip=el('g',{tabindex:0,role:'button','aria-label':`${r.card.title}. Open its details.`});chip.dataset.card=r.card.number||'cited';chip.append(el('title',{},'Open what this card builds'),el('rect',{x,y:cy,width,height,rx:8,class:'card-chip'}));let ty=cy+pad+14;lines(chip,x+pad,ty,r.body,'card-text','start',step);ty+=r.body.length*step;lines(chip,x+pad,ty,r.build,'card-build','start',step);ty+=r.build.length*step;lines(chip,x+pad,ty,r.note,'card-note','start',step);const show=()=>showCard(r.card,lane,chip);chip.onclick=show;chip.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();show()}};svg.append(chip)});cy+=height+10}cy+=10});
|
|
126
|
+
const bottom=Math.max(cy,nodeBottom)+12;band.setAttribute('height',bottom-y);
|
|
127
|
+
if(previous!==undefined)svg.append(el('path',{d:`M${rail} ${previous} L${rail} ${n.y-54}`,class:'wire','marker-end':'url(#arrow)'}));
|
|
128
|
+
drawNode(svg,n,'lane-sub',21);previous=nodeBottom;y=bottom+16});
|
|
129
|
+
return y}
|
|
130
|
+
function cardFlow(steps){const width=396,pad=14,step=22,flow=el('svg',{class:'card-flow',role:'img','aria-label':steps.map(v=>v.label).join(', then ')});let y=4;
|
|
131
|
+
steps.forEach((v,i)=>{const body=wrap(v.text,width-pad*2-4,'16px Arial'),height=pad*2+step+body.length*step;if(i)flow.append(el('path',{d:`M${width/2} ${y-30} L${width/2} ${y-4}`,class:'wire green','marker-end':'url(#arrowGreen)'}));
|
|
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
|
+
flow.setAttribute('viewBox',`0 0 ${width} ${y-30}`);return flow}
|
|
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?.length?card.steps:[{label:'Card',text:card.title}]));
|
|
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
|
+
content.scrollTop=0;drawer.hidden=false;workspace.classList.add('open');$('.close').focus({preventScroll:true})}
|
|
108
138
|
function chartNode(svg,n,x,y,bar){
|
|
109
139
|
const g=el('g',{class:'node','data-node':n.id,tabindex:0,role:'button','aria-expanded':'false','aria-controls':drawer.id,'aria-label':`${n.label.join(' ')}: ${n.values.y}`});
|
|
110
140
|
g.append(el('title',{},`${n.label.join(' ')}: ${n.values.y}. Open source evidence.`));
|
|
@@ -126,21 +156,21 @@ function drawChart(svg,s){
|
|
|
126
156
|
if(chart.kind==='line'){const sorted=[...points].sort((a,b)=>a.n.values.x-b.n.values.x);svg.append(el('path',{d:sorted.map((p,i)=>`${i?'L':'M'}${p.x} ${p.y}`).join(' '),class:'wire green'}))}
|
|
127
157
|
points.forEach(p=>{const width=Math.min(150,(right-left)/points.length*.6),bar=isBar?el('rect',{x:p.x-width/2,y:Math.min(p.y,y(0)),width,height:Math.max(2,Math.abs(y(0)-p.y)),rx:5,class:'disc green'}):undefined;chartNode(svg,p.n,p.x,p.y,bar)})
|
|
128
158
|
}
|
|
129
|
-
function draw(index){close(false);active=index;const s=scenes[index];[...tabs.children].forEach((b,i)=>{b.setAttribute('aria-selected',i===index);b.tabIndex=i===index?0:-1});canvas.replaceChildren();const svg=el('svg',{viewBox:'0 0 1440 1020',role:'img','aria-label':`${s.title}. ${s.subtitle}`});const desc=el('desc',{},s.alt);svg.append(desc);const defs=el('defs');defs.innerHTML=icons.replace(/id="([^"]+)"/g,(_,id)=>`id="${prefix}${id}"`);
|
|
130
|
-
text(svg,42,128,s.before,'micro');text(svg,42,155,s.after,'micro');text(svg,1398,128,`${s.pr.changed_files} CHANGED FILES`,'kicker','end');
|
|
131
|
-
if(s.chart){drawChart(svg,s)}else{
|
|
159
|
+
function draw(index){close(false);active=index;const s=scenes[index];[...tabs.children].forEach((b,i)=>{b.setAttribute('aria-selected',i===index);b.tabIndex=i===index?0:-1});canvas.replaceChildren();const svg=el('svg',{viewBox:'0 0 1440 1020',role:'img','aria-label':`${s.title}. ${s.subtitle}`});if(s.board)svg.classList.add('board');const desc=el('desc',{},s.alt);svg.append(desc);const defs=el('defs');defs.innerHTML=icons.replace(/id="([^"]+)"/g,(_,id)=>`id="${prefix}${id}"`);const backdrop=el('rect',{width:1440,height:1020,fill:'#f6f5ef'});svg.append(defs,backdrop);text(svg,40,54,s.title,'title');text(svg,42,86,s.subtitle,'subtitle');text(svg,1398,49,`${refName(s).toUpperCase()} #${s.pr.number} \u00b7 ${s.pr.merged?'MERGED':s.pr.state==='closed'?'CLOSED':s.pr.draft?'DRAFT':'OPEN'}`,'kicker','end');
|
|
160
|
+
text(svg,42,128,s.before,'micro');text(svg,42,155,s.after,'micro');text(svg,1398,128,`${s.pr.changed_files} ${isIssue(s)?'TRACKED ISSUES':'CHANGED FILES'}`,'kicker','end');
|
|
161
|
+
let footTop=835;if(s.chart){drawChart(svg,s)}else if(s.board){footTop=Math.max(835,drawBoard(svg,s)+10)}else{
|
|
132
162
|
(s.groups||[]).forEach(g=>{svg.append(el('rect',{x:g.x,y:g.y,width:g.w,height:g.h,rx:20,class:'group'}));text(svg,g.x+g.w/2,g.y+27,g.label,'panel-label','middle')});
|
|
133
163
|
const chartArrows=[];s.edges.forEach(e=>{const cls=e.kind||'',target=s.nodes.find(n=>n.id===e.to),path=el('path',{d:e.path,class:`wire ${cls}`});if(target.chart)chartArrows.push({path,target,cls});else path.setAttribute('marker-end',`url(#${prefix}${cls==='green'?'arrowGreen':cls==='orange'?'arrowOrange':'arrow'})`);svg.append(path);if(e.label)text(svg,e.x,e.y,e.label,`edge-label ${cls}`,'middle')});
|
|
134
|
-
s.nodes.forEach(n=>
|
|
164
|
+
s.nodes.forEach(n=>drawNode(svg,n));
|
|
135
165
|
chartArrows.forEach(({path,target,cls})=>cardArrow(svg,path,target,cls));
|
|
136
166
|
}
|
|
137
|
-
svg.append(el('line',{x1:40,y1:835,x2:1400,y2:835,stroke:'#d5dcd2','stroke-width':2}));s.notes.forEach((n,i)=>{const x=42+i*455;text(
|
|
138
|
-
const when=new Date(s.pr.observed_at).toLocaleString('en-US',{timeZone:'America/New_York',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});text(
|
|
167
|
+
const shift=footTop-835,foot=el('g',{transform:`translate(0 ${shift})`}),height=1020+shift;svg.setAttribute('viewBox',`0 0 1440 ${height}`);backdrop.setAttribute('height',height);svg.append(foot);foot.append(el('line',{x1:40,y1:835,x2:1400,y2:835,stroke:'#d5dcd2','stroke-width':2}));s.notes.forEach((n,i)=>{const x=42+i*455;text(foot,x,876,n.label,'foot-label');lines(foot,x,907,n.text,'foot-text','start',23)});
|
|
168
|
+
const when=new Date(s.pr.observed_at).toLocaleString('en-US',{timeZone:'America/New_York',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});text(foot,42,991,`${isIssue(s)?'Tracker read':'Code reviewed'} \u00b7 ${when} ET \u00b7 ${s.pr.head.slice(0,10)}`,'stamp');text(foot,1398,991,s.board?'Click a card to see what it builds.':'Click any symbol for code and source links.','micro','end');canvas.append(svg);const a=document.createElement('a');a.className='sr-only';a.href=s.pr.url;a.textContent=`Open ${refName(s)} ${s.pr.number}`;canvas.append(a);setZoom(1,true)}
|
|
139
169
|
|
|
140
|
-
scenes.forEach((s,i)=>{const b=dom('button');b.dataset.reviewCard=String(i);b.append(dom('span'
|
|
170
|
+
scenes.forEach((s,i)=>{const b=dom('button');b.dataset.reviewCard=String(i);b.append(dom('span',`${refName(s)} #${s.pr.number}`,'tab-number'),dom('span',s.tab,'tab-name'));b.id=prefix+`tab-${i}`;b.role='tab';b.setAttribute('aria-controls',canvas.id);b.onclick=()=>draw(i);b.onkeydown=e=>{if(e.key==='ArrowRight'||e.key==='ArrowLeft'){e.preventDefault();const next=(i+(e.key==='ArrowRight'?1:scenes.length-1))%scenes.length;draw(next);tabs.children[next].focus()}};tabs.append(b)});
|
|
141
171
|
function setZoom(value,fit=false){zoom=Math.max(.7,Math.min(2,value));canvas.style.width=fit?'':`${1440*zoom}px`;canvas.style.maxWidth=fit?'1540px':'none'}
|
|
142
172
|
$('[data-plus]').onclick=()=>setZoom(zoom+.2);$('[data-minus]').onclick=()=>setZoom(zoom-.2);$('[data-fit]').onclick=()=>setZoom(1,true);
|
|
143
|
-
$('[data-compare]').onclick=e=>showPanel('Summary vs code',{title:'Compare the claim with the implementation',steps:[{label:[
|
|
173
|
+
$('[data-compare]').onclick=e=>showPanel('Summary vs code',{title:'Compare the claim with the implementation',steps:[{label:[refName(scenes[active]),'description'],icon:'list'},{label:['Trace the','code'],icon:'code'},{label:['Compare','behavior'],icon:'eye'}]},scenes[active].comparison,[],scenes[active].summary,scenes[active].nodes,e.currentTarget);
|
|
144
174
|
$('[data-review]').onclick=e=>showPanel('Review notes',{title:'Scope and evidence',steps:[{label:['Captured','source'],icon:'ledger'},{label:['Reviewed','path'],icon:'branches'},{label:['Scope and','limitations'],icon:'shield'}]},scenes[active].review,[],undefined,scenes[active].nodes,e.currentTarget);
|
|
145
175
|
draw(0);
|
|
146
176
|
})();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"number": 1,
|
|
3
|
+
"title": "Synthetic tracker epic",
|
|
4
|
+
"state": "open",
|
|
5
|
+
"state_reason": null,
|
|
6
|
+
"html_url": "https://github.com/example/project/issues/1",
|
|
7
|
+
"created_at": "2026-10-18T12:00:00Z",
|
|
8
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
9
|
+
"closed_at": null,
|
|
10
|
+
"labels": [],
|
|
11
|
+
"linked_pulls": [
|
|
12
|
+
{
|
|
13
|
+
"number": 3,
|
|
14
|
+
"title": "Synthetic fix",
|
|
15
|
+
"state": "closed",
|
|
16
|
+
"draft": false,
|
|
17
|
+
"merged_at": "2026-10-19T12:00:00Z",
|
|
18
|
+
"closed_at": "2026-10-19T12:00:00Z",
|
|
19
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
20
|
+
"html_url": "https://github.com/example/project/pull/3"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"progress": "merged_pull",
|
|
24
|
+
"counts": {
|
|
25
|
+
"open": 2,
|
|
26
|
+
"closed": 0,
|
|
27
|
+
"merged_pull": 1,
|
|
28
|
+
"open_pull": 0,
|
|
29
|
+
"no_pull": 1
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"number": 2,
|
|
3
|
+
"title": "Synthetic task with a fix",
|
|
4
|
+
"state": "open",
|
|
5
|
+
"state_reason": null,
|
|
6
|
+
"html_url": "https://github.com/example/project/issues/2",
|
|
7
|
+
"created_at": "2026-10-18T12:00:00Z",
|
|
8
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
9
|
+
"closed_at": null,
|
|
10
|
+
"labels": [],
|
|
11
|
+
"linked_pulls": [
|
|
12
|
+
{
|
|
13
|
+
"number": 3,
|
|
14
|
+
"title": "Synthetic fix",
|
|
15
|
+
"state": "closed",
|
|
16
|
+
"draft": false,
|
|
17
|
+
"merged_at": "2026-10-19T12:00:00Z",
|
|
18
|
+
"closed_at": "2026-10-19T12:00:00Z",
|
|
19
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
20
|
+
"html_url": "https://github.com/example/project/pull/3"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"progress": "merged_pull",
|
|
24
|
+
"counts": {
|
|
25
|
+
"open": 0,
|
|
26
|
+
"closed": 0,
|
|
27
|
+
"merged_pull": 0,
|
|
28
|
+
"open_pull": 0,
|
|
29
|
+
"no_pull": 0
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Synthetic task with a linked fix: https://github.com/example/project/pull/3
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"number": 4,
|
|
3
|
+
"title": "Synthetic task without a fix",
|
|
4
|
+
"state": "open",
|
|
5
|
+
"state_reason": null,
|
|
6
|
+
"html_url": "https://github.com/example/project/issues/4",
|
|
7
|
+
"created_at": "2026-10-18T12:00:00Z",
|
|
8
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
9
|
+
"closed_at": null,
|
|
10
|
+
"labels": [],
|
|
11
|
+
"linked_pulls": [],
|
|
12
|
+
"progress": "no_pull",
|
|
13
|
+
"counts": {
|
|
14
|
+
"open": 0,
|
|
15
|
+
"closed": 0,
|
|
16
|
+
"merged_pull": 0,
|
|
17
|
+
"open_pull": 0,
|
|
18
|
+
"no_pull": 0
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"number": 3,
|
|
3
|
+
"title": "Synthetic fix",
|
|
4
|
+
"state": "closed",
|
|
5
|
+
"draft": false,
|
|
6
|
+
"merged_at": "2026-10-19T12:00:00Z",
|
|
7
|
+
"closed_at": "2026-10-19T12:00:00Z",
|
|
8
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
9
|
+
"html_url": "https://github.com/example/project/pull/3"
|
|
10
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"number": 1,
|
|
3
|
+
"title": "Synthetic tracker epic",
|
|
4
|
+
"state": "open",
|
|
5
|
+
"state_reason": null,
|
|
6
|
+
"html_url": "https://github.com/example/project/issues/1",
|
|
7
|
+
"created_at": "2026-10-18T12:00:00Z",
|
|
8
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
9
|
+
"closed_at": null,
|
|
10
|
+
"labels": [],
|
|
11
|
+
"linked_pulls": [
|
|
12
|
+
{
|
|
13
|
+
"number": 3,
|
|
14
|
+
"title": "Synthetic fix",
|
|
15
|
+
"state": "closed",
|
|
16
|
+
"draft": false,
|
|
17
|
+
"merged_at": "2026-10-19T12:00:00Z",
|
|
18
|
+
"closed_at": "2026-10-19T12:00:00Z",
|
|
19
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
20
|
+
"html_url": "https://github.com/example/project/pull/3"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"progress": "merged_pull",
|
|
24
|
+
"children": [
|
|
25
|
+
{
|
|
26
|
+
"number": 2,
|
|
27
|
+
"title": "Synthetic task with a fix",
|
|
28
|
+
"state": "open",
|
|
29
|
+
"state_reason": null,
|
|
30
|
+
"html_url": "https://github.com/example/project/issues/2",
|
|
31
|
+
"created_at": "2026-10-18T12:00:00Z",
|
|
32
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
33
|
+
"closed_at": null,
|
|
34
|
+
"labels": [],
|
|
35
|
+
"linked_pulls": [
|
|
36
|
+
{
|
|
37
|
+
"number": 3,
|
|
38
|
+
"title": "Synthetic fix",
|
|
39
|
+
"state": "closed",
|
|
40
|
+
"draft": false,
|
|
41
|
+
"merged_at": "2026-10-19T12:00:00Z",
|
|
42
|
+
"closed_at": "2026-10-19T12:00:00Z",
|
|
43
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
44
|
+
"html_url": "https://github.com/example/project/pull/3"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"progress": "merged_pull",
|
|
48
|
+
"children": [],
|
|
49
|
+
"counts": {
|
|
50
|
+
"open": 0,
|
|
51
|
+
"closed": 0,
|
|
52
|
+
"merged_pull": 0,
|
|
53
|
+
"open_pull": 0,
|
|
54
|
+
"no_pull": 0
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"number": 4,
|
|
59
|
+
"title": "Synthetic task without a fix",
|
|
60
|
+
"state": "open",
|
|
61
|
+
"state_reason": null,
|
|
62
|
+
"html_url": "https://github.com/example/project/issues/4",
|
|
63
|
+
"created_at": "2026-10-18T12:00:00Z",
|
|
64
|
+
"updated_at": "2026-10-19T12:00:00Z",
|
|
65
|
+
"closed_at": null,
|
|
66
|
+
"labels": [],
|
|
67
|
+
"linked_pulls": [],
|
|
68
|
+
"progress": "no_pull",
|
|
69
|
+
"children": [],
|
|
70
|
+
"counts": {
|
|
71
|
+
"open": 0,
|
|
72
|
+
"closed": 0,
|
|
73
|
+
"merged_pull": 0,
|
|
74
|
+
"open_pull": 0,
|
|
75
|
+
"no_pull": 0
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"counts": {
|
|
80
|
+
"open": 2,
|
|
81
|
+
"closed": 0,
|
|
82
|
+
"merged_pull": 1,
|
|
83
|
+
"open_pull": 0,
|
|
84
|
+
"no_pull": 1
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"kind": "issue",
|
|
4
|
+
"observed_at": "2026-10-19T12:00:00Z",
|
|
5
|
+
"repo": "example/project",
|
|
6
|
+
"number": 1,
|
|
7
|
+
"url": "https://github.com/example/project/issues/1",
|
|
8
|
+
"state": "open",
|
|
9
|
+
"head": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
|
10
|
+
"base": null,
|
|
11
|
+
"diff_base": null,
|
|
12
|
+
"metadata": {
|
|
13
|
+
"title": "Synthetic tracker epic",
|
|
14
|
+
"body": "Synthetic tracker for rendering verification only.",
|
|
15
|
+
"draft": false,
|
|
16
|
+
"merged": false,
|
|
17
|
+
"tracked_issues": 3
|
|
18
|
+
},
|
|
19
|
+
"files": []
|
|
20
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"snapshot": "tracker/snapshot.json",
|
|
3
|
+
"tab": "Tracker",
|
|
4
|
+
"title": "Follow a synthetic tracker epic",
|
|
5
|
+
"subtitle": "Synthetic fixture: an epic, two tasks, and one linked fix.",
|
|
6
|
+
"before": "BEFORE: Task progress lives in issue bodies.",
|
|
7
|
+
"after": "AFTER: Each task links to its captured issue and fix.",
|
|
8
|
+
"alt": "A synthetic epic has two tasks. One task links a merged fix; the other has none yet.",
|
|
9
|
+
"nodes": [
|
|
10
|
+
{
|
|
11
|
+
"id": "epic",
|
|
12
|
+
"x": 300,
|
|
13
|
+
"y": 430,
|
|
14
|
+
"label": ["Synthetic", "epic"],
|
|
15
|
+
"sub": ["Synthetic", "branches"],
|
|
16
|
+
"icon": "list",
|
|
17
|
+
"kind": "blue",
|
|
18
|
+
"detail": ["Synthetic tracker data, not a project status."],
|
|
19
|
+
"sources": [{"path": "issues/1.md", "line": 3}],
|
|
20
|
+
"drilldown": {
|
|
21
|
+
"title": "Read the epic",
|
|
22
|
+
"steps": [
|
|
23
|
+
{"label": ["Epic", "body"], "icon": "ledger"},
|
|
24
|
+
{"label": ["Two", "tasks"], "icon": "list"}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "fixed",
|
|
30
|
+
"x": 700,
|
|
31
|
+
"y": 330,
|
|
32
|
+
"label": ["Task with", "a fix"],
|
|
33
|
+
"sub": ["Merged fix,", "task open"],
|
|
34
|
+
"icon": "merge",
|
|
35
|
+
"kind": "green",
|
|
36
|
+
"detail": ["The task body links a pull request that merged."],
|
|
37
|
+
"sources": [{"path": "issues/2.json", "line": 1, "end": 4}, {"path": "pulls/3.json", "line": 1, "end": 6}],
|
|
38
|
+
"drilldown": {
|
|
39
|
+
"title": "Follow the linked fix",
|
|
40
|
+
"steps": [
|
|
41
|
+
{"label": ["Task links", "a fix"], "icon": "github"},
|
|
42
|
+
{"label": ["Fix", "merged"], "icon": "merge", "kind": "green"}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "left",
|
|
48
|
+
"x": 700,
|
|
49
|
+
"y": 560,
|
|
50
|
+
"label": ["Task left", "to build"],
|
|
51
|
+
"sub": ["No fix", "yet"],
|
|
52
|
+
"icon": "flag",
|
|
53
|
+
"kind": "lime",
|
|
54
|
+
"detail": ["The task body links no pull request yet."],
|
|
55
|
+
"sources": [{"path": "tracker.json", "line": 79, "end": 85}],
|
|
56
|
+
"drilldown": {
|
|
57
|
+
"title": "Count what is left",
|
|
58
|
+
"steps": [
|
|
59
|
+
{"label": ["No linked", "fix"], "icon": "pause"},
|
|
60
|
+
{"label": ["Counted as", "left"], "icon": "flag"}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"edges": [
|
|
66
|
+
{"from": "epic", "to": "fixed", "path": "M340 412 L660 345", "kind": "green"},
|
|
67
|
+
{"from": "epic", "to": "left", "path": "M340 448 L660 545", "kind": "orange"}
|
|
68
|
+
],
|
|
69
|
+
"notes": [
|
|
70
|
+
{"label": "SOURCE", "text": ["Captured issues", "Links to each record"]},
|
|
71
|
+
{"label": "INTERACTION", "text": ["Select a task", "Open its issue"]},
|
|
72
|
+
{"label": "SCOPE", "text": ["Synthetic data only", "No project claim"]}
|
|
73
|
+
],
|
|
74
|
+
"board": {
|
|
75
|
+
"strip": ["^Synthetic "],
|
|
76
|
+
"wording": {"synthetic": "sample"},
|
|
77
|
+
"steps": [
|
|
78
|
+
{"heading": "Today", "label": "Today", "kind": "orange"},
|
|
79
|
+
{"heading": "Fix", "label": "Build", "kind": "lime", "inline": true},
|
|
80
|
+
{"heading": "Proof", "label": "Proof", "kind": "green"}
|
|
81
|
+
],
|
|
82
|
+
"lanes": [
|
|
83
|
+
{"node": "epic", "groups": [{"title": "Follow-up branches", "cards": [{"title": "Task one follow-up", "source": {"path": "issues/1.md", "line": 5}}]}]},
|
|
84
|
+
{"node": "left", "progress": "no_pull"},
|
|
85
|
+
{"node": "fixed", "progress": "merged_pull"}
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
"comparison": ["The fixture epic body and its captured tasks agree on one fix and one open task."],
|
|
89
|
+
"review": ["This fixture checks tracker rendering, not a project status."]
|
|
90
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Capture PR
|
|
1
|
+
"""Capture PR source or tracker issue records as immutable evidence through gh."""
|
|
2
2
|
import argparse
|
|
3
3
|
import base64
|
|
4
4
|
import concurrent.futures
|
|
@@ -67,7 +67,10 @@ def _capture_file(item: dict, metadata: dict, diff_base: str, destination: Path)
|
|
|
67
67
|
|
|
68
68
|
def _save_snapshot(snapshot: dict, destination: Path) -> None:
|
|
69
69
|
(destination / c.SNAPSHOT_NAME).write_text(json.dumps(snapshot, indent=c.JSON_INDENT), encoding="utf-8")
|
|
70
|
-
|
|
70
|
+
if snapshot.get("kind") == c.ISSUE_KIND:
|
|
71
|
+
all_sections = []
|
|
72
|
+
else:
|
|
73
|
+
all_sections = [c.DIFF_SECTION.format(filename=each_file["filename"], patch=each_file.get("patch", "Patch unavailable. Compare saved base and head source.")) for each_file in snapshot["files"]]
|
|
71
74
|
(destination / c.DIFF_NAME).write_text(c.PARAGRAPH_BREAK.join(all_sections), encoding="utf-8")
|
|
72
75
|
print(json.dumps({"pr": snapshot["number"], "snapshot": str(destination / c.SNAPSHOT_NAME), "state": snapshot["state"], "files": len(snapshot["files"])}), flush=True)
|
|
73
76
|
|
|
@@ -102,10 +105,122 @@ def collect(url: str, root: Path) -> None:
|
|
|
102
105
|
_save_snapshot(snapshot, destination)
|
|
103
106
|
|
|
104
107
|
|
|
108
|
+
def _sub_issues(repo: str, number: int) -> list[dict]:
|
|
109
|
+
all_children = []
|
|
110
|
+
page = 1
|
|
111
|
+
while True:
|
|
112
|
+
batch = _api(c.SUB_ISSUES_ENDPOINT.format(repo=repo, number=number, size=c.PAGE_SIZE, page=page))
|
|
113
|
+
all_children.extend(batch)
|
|
114
|
+
if len(batch) < c.PAGE_SIZE:
|
|
115
|
+
break
|
|
116
|
+
page += 1
|
|
117
|
+
return all_children
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _write_capture(destination: Path, relative_path: str, raw_bytes: bytes) -> dict:
|
|
121
|
+
capture_path = destination / c.HEAD_SIDE / relative_path
|
|
122
|
+
capture_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
capture_path.write_bytes(raw_bytes)
|
|
124
|
+
return {"filename": relative_path, "captures": {c.HEAD_SIDE: {"path": f"{c.HEAD_SIDE}/{relative_path}", "sha256": hashlib.sha256(raw_bytes).hexdigest(), "bytes": len(raw_bytes)}}}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _json_bytes(record: dict) -> bytes:
|
|
128
|
+
return (json.dumps(record, indent=c.JSON_INDENT) + c.NEWLINE).encode()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pull_progress(record: dict) -> str:
|
|
132
|
+
all_pulls = record["linked_pulls"]
|
|
133
|
+
if any(each_pull["merged_at"] for each_pull in all_pulls):
|
|
134
|
+
return "merged_pull"
|
|
135
|
+
if any(each_pull["state"] == "open" for each_pull in all_pulls):
|
|
136
|
+
return "open_pull"
|
|
137
|
+
return "no_pull"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _child_counts(all_children: list[dict]) -> dict[str, int]:
|
|
141
|
+
all_progress = [each_child["progress"] for each_child in all_children]
|
|
142
|
+
return {
|
|
143
|
+
"open": sum(each_child["state"] == "open" for each_child in all_children),
|
|
144
|
+
"closed": sum(each_child["state"] == "closed" for each_child in all_children),
|
|
145
|
+
**{each_progress: all_progress.count(each_progress) for each_progress in c.ALL_PULL_PROGRESS},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _capture_issue_tree(repo: str, issue: dict, depth: int, all_pulls_by_number: dict[int, dict], destination: Path, all_files: list[dict]) -> dict:
|
|
150
|
+
record = {each_field: issue.get(each_field) for each_field in c.ALL_ISSUE_FIELDS}
|
|
151
|
+
record["labels"] = [each_label["name"] for each_label in issue.get("labels", [])]
|
|
152
|
+
body = issue.get("body") or ""
|
|
153
|
+
linked_numbers = sorted({int(each_number) for each_number in re.findall(c.LINKED_PULL_PATTERN.format(repo=re.escape(repo)), body)})
|
|
154
|
+
for each_number in linked_numbers:
|
|
155
|
+
if each_number not in all_pulls_by_number:
|
|
156
|
+
pull = _api(c.PR_ENDPOINT.format(repo=repo, number=each_number))
|
|
157
|
+
all_pulls_by_number[each_number] = {each_field: pull.get(each_field) for each_field in c.ALL_PULL_FIELDS}
|
|
158
|
+
record["linked_pulls"] = [all_pulls_by_number[each_number] for each_number in linked_numbers]
|
|
159
|
+
record["progress"] = _pull_progress(record)
|
|
160
|
+
children = _sub_issues(repo, issue["number"]) if depth < c.SUB_ISSUE_DEPTH else []
|
|
161
|
+
record["children"] = [_capture_issue_tree(repo, each_child, depth + 1, all_pulls_by_number, destination, all_files) for each_child in children]
|
|
162
|
+
record["counts"] = _child_counts(record["children"])
|
|
163
|
+
all_files.append(_write_capture(destination, c.ISSUE_BODY_PATH.format(number=issue["number"]), (body + c.NEWLINE).encode()))
|
|
164
|
+
issue_record = {each_field: each_entry for each_field, each_entry in record.items() if each_field != "children"}
|
|
165
|
+
all_files.append(_write_capture(destination, c.ISSUE_RECORD_PATH.format(number=issue["number"]), _json_bytes(issue_record)))
|
|
166
|
+
return record
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _capture_tracker(repo: str, issue: dict, destination: Path) -> tuple[bytes, list[dict]]:
|
|
170
|
+
all_files: list[dict] = []
|
|
171
|
+
all_pulls_by_number: dict[int, dict] = {}
|
|
172
|
+
tracker_bytes = _json_bytes(_capture_issue_tree(repo, issue, 0, all_pulls_by_number, destination, all_files))
|
|
173
|
+
for each_number, each_pull in all_pulls_by_number.items():
|
|
174
|
+
all_files.append(_write_capture(destination, c.PULL_RECORD_PATH.format(number=each_number), _json_bytes(each_pull)))
|
|
175
|
+
all_files.append(_write_capture(destination, c.TRACKER_PATH, tracker_bytes))
|
|
176
|
+
return tracker_bytes, all_files
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _tracker_snapshot(url: str, repo: str, refreshed: dict, observed_at: str, tracker_bytes: bytes, all_files: list[dict]) -> dict:
|
|
180
|
+
tracked_count = sum(1 for each_file in all_files if re.fullmatch(c.CAPTURED_ISSUE_RECORD_PATTERN, each_file["filename"]))
|
|
181
|
+
metadata = {"title": refreshed["title"], "body": refreshed.get("body") or "", "draft": False, "merged": False, "tracked_issues": tracked_count}
|
|
182
|
+
return {"schema_version": 1, "kind": c.ISSUE_KIND, "observed_at": observed_at, "repo": repo, "number": refreshed["number"], "url": url, "state": refreshed["state"], "head": hashlib.sha256(tracker_bytes).hexdigest(), "base": None, "diff_base": None, "metadata": metadata, "files": all_files}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def collect_issue(url: str, root: Path) -> None:
|
|
186
|
+
"""Save an issue, its sub-issues, and the pull requests their bodies link.
|
|
187
|
+
|
|
188
|
+
A tracker epic becomes the same kind of snapshot a pull request does::
|
|
189
|
+
|
|
190
|
+
issues/12.md the issue body, line by line
|
|
191
|
+
issues/12.json state, labels, linked pull requests, child counts
|
|
192
|
+
pulls/34.json each linked pull request's state
|
|
193
|
+
tracker.json the whole tree, for charts and citations
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
url: GitHub issue URL.
|
|
197
|
+
root: Directory receiving a folder for the captured issue.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
CaptureRunFatal: The issue changed during capture.
|
|
201
|
+
"""
|
|
202
|
+
matched_url = re.fullmatch(c.ISSUE_URL_PATTERN, url)
|
|
203
|
+
if not matched_url:
|
|
204
|
+
raise CaptureRunFatal("Use a GitHub issue URL, https://github.com/owner/repo/issues/number")
|
|
205
|
+
repo, number = matched_url.groups()
|
|
206
|
+
endpoint = c.ISSUE_ENDPOINT.format(repo=repo, number=number)
|
|
207
|
+
issue = _api(endpoint)
|
|
208
|
+
observed_at = datetime.datetime.now(datetime.UTC).isoformat()
|
|
209
|
+
destination = root / f"issue-{number}" / observed_at.replace(":", "-")
|
|
210
|
+
tracker_bytes, all_files = _capture_tracker(repo, issue, destination)
|
|
211
|
+
refreshed = _api(endpoint)
|
|
212
|
+
if refreshed["updated_at"] != issue["updated_at"]:
|
|
213
|
+
raise CaptureRunFatal("Issue changed during capture. Rerun collection.")
|
|
214
|
+
_save_snapshot(_tracker_snapshot(url, repo, refreshed, observed_at, tracker_bytes, all_files), destination)
|
|
215
|
+
|
|
216
|
+
|
|
105
217
|
if __name__ == "__main__":
|
|
106
218
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
107
219
|
parser.add_argument("urls", nargs="+")
|
|
108
220
|
parser.add_argument("--out", type=Path, default=Path(c.DEFAULT_EVIDENCE_DIRECTORY))
|
|
109
221
|
arguments = parser.parse_args()
|
|
110
222
|
for each_url in arguments.urls:
|
|
111
|
-
|
|
223
|
+
if re.fullmatch(c.ISSUE_URL_PATTERN, each_url):
|
|
224
|
+
collect_issue(each_url, arguments.out)
|
|
225
|
+
else:
|
|
226
|
+
collect(each_url, arguments.out)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Validate evidence-backed diagram scenes and render a portable HTML visual."""
|
|
2
2
|
import argparse
|
|
3
3
|
import copy
|
|
4
|
+
import itertools
|
|
4
5
|
import json
|
|
5
6
|
import re
|
|
6
7
|
from pathlib import Path
|
|
@@ -36,7 +37,7 @@ def _validate_graph(scene: dict) -> None:
|
|
|
36
37
|
if not re.fullmatch(c.SVG_PATH_PATTERN, each_edge["path"]):
|
|
37
38
|
raise RenderRunFatal("Invalid SVG path.")
|
|
38
39
|
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
|
+
if not scene.get("chart") and not scene.get("board") 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
41
|
raise RenderRunFatal("Node lies outside diagram bounds.")
|
|
41
42
|
if not each_node.get("sources"):
|
|
42
43
|
raise RenderRunFatal("Every node needs source evidence.")
|
|
@@ -56,10 +57,117 @@ def _source_evidence(source: dict, snapshot: dict, snapshot_path: Path) -> None:
|
|
|
56
57
|
if not 1 <= first <= last <= len(all_lines):
|
|
57
58
|
raise RenderRunFatal("Invalid source line range.")
|
|
58
59
|
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]))
|
|
60
|
+
if snapshot.get("kind") == c.ISSUE_KIND:
|
|
61
|
+
source["url"] = _captured_record_link(source["path"], snapshot)
|
|
62
|
+
return
|
|
59
63
|
revision = snapshot.get("diff_base", snapshot[c.BASE_SIDE]) if side == c.BASE_SIDE else snapshot[c.HEAD_SIDE]
|
|
60
64
|
source["url"] = c.SOURCE_LINK.format(repo=snapshot["repo"], revision=revision, path=quote(source["path"], safe="/"), first=first, last=last)
|
|
61
65
|
|
|
62
66
|
|
|
67
|
+
def _captured_record_link(relative_path: str, snapshot: dict) -> str:
|
|
68
|
+
if matched_issue := re.fullmatch(c.CAPTURED_ISSUE_PATTERN, relative_path):
|
|
69
|
+
return c.ISSUE_LINK.format(repo=snapshot["repo"], number=matched_issue.group(1))
|
|
70
|
+
if matched_pull := re.fullmatch(c.CAPTURED_PULL_PATTERN, relative_path):
|
|
71
|
+
return c.PULL_LINK.format(repo=snapshot["repo"], number=matched_pull.group(1))
|
|
72
|
+
if relative_path == c.TRACKER_PATH:
|
|
73
|
+
return snapshot["url"]
|
|
74
|
+
raise RenderRunFatal("Tracker evidence must cite a captured issue, pull request, or tracker file.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _plain_title(record: dict, settings: dict) -> str:
|
|
78
|
+
title = settings.get("titles", {}).get(str(record["number"]), record["title"])
|
|
79
|
+
for each_pattern in settings.get("strip", []):
|
|
80
|
+
title = re.sub(each_pattern, "", title, count=1)
|
|
81
|
+
return title
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _issue_steps(number: int, snapshot_path: Path, settings: dict) -> list[dict]:
|
|
85
|
+
all_lines = (snapshot_path.parent / c.HEAD_SIDE / c.ISSUE_BODY_PATH.format(number=number)).read_text(encoding="utf-8").splitlines()
|
|
86
|
+
all_steps = []
|
|
87
|
+
for each_step in settings.get("steps", []):
|
|
88
|
+
heading = c.SECTION_HEADING.format(heading=each_step["heading"])
|
|
89
|
+
if heading not in all_lines:
|
|
90
|
+
continue
|
|
91
|
+
all_paragraph_lines = list(itertools.takewhile(str.strip, all_lines[all_lines.index(heading) + 1:]))
|
|
92
|
+
text = c.SPACE_SEPARATOR.join(all_paragraph_lines)
|
|
93
|
+
for each_word, each_plain_word in settings.get("wording", {}).items():
|
|
94
|
+
text = re.sub(c.WHOLE_PHRASE_PATTERN.format(phrase=re.escape(each_word)), each_plain_word, text)
|
|
95
|
+
all_steps.append({**{each_field: each_setting for each_field, each_setting in each_step.items() if each_field != "heading"}, "text": text})
|
|
96
|
+
return all_steps
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _tracker_card(issue: dict, repo: str, settings: dict, snapshot_path: Path) -> dict:
|
|
100
|
+
progress = issue["progress"]
|
|
101
|
+
all_pulls = [each_pull for each_pull in issue["linked_pulls"] if (each_pull["merged_at"] if progress == "merged_pull" else each_pull["state"] == "open")]
|
|
102
|
+
pulls = c.PULL_REFERENCE_SEPARATOR.join(c.PULL_REFERENCE.format(number=each_pull["number"]) for each_pull in all_pulls)
|
|
103
|
+
return {
|
|
104
|
+
"number": issue["number"],
|
|
105
|
+
"title": _plain_title(issue, settings),
|
|
106
|
+
"url": c.ISSUE_LINK.format(repo=repo, number=issue["number"]),
|
|
107
|
+
"note": c.ALL_CARD_NOTES[progress].format(pulls=pulls),
|
|
108
|
+
"steps": _issue_steps(issue["number"], snapshot_path, settings),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _tracker_groups(record: dict, progress: str, repo: str, settings: dict, snapshot_path: Path) -> list[dict]:
|
|
113
|
+
all_tasks = [each_child for each_child in record["children"] if not each_child["children"] and each_child["state"] == "open" and each_child["progress"] == progress and each_child["number"] not in settings.get("exclude", [])]
|
|
114
|
+
all_groups = [{"title": _plain_title(record, settings), "url": c.ISSUE_LINK.format(repo=repo, number=record["number"]), "cards": [_tracker_card(each_task, repo, settings, snapshot_path) for each_task in all_tasks]}] if all_tasks else []
|
|
115
|
+
for each_child in record["children"]:
|
|
116
|
+
all_groups.extend(_tracker_groups(each_child, progress, repo, settings, snapshot_path))
|
|
117
|
+
return all_groups
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cited_group(group: dict, snapshot: dict, snapshot_path: Path) -> dict:
|
|
121
|
+
for each_card in group["cards"]:
|
|
122
|
+
_source_evidence(each_card["source"], snapshot, snapshot_path)
|
|
123
|
+
each_card.setdefault("url", each_card["source"]["url"])
|
|
124
|
+
each_card.setdefault("note", "")
|
|
125
|
+
if not each_card["url"].startswith(c.REPOSITORY_LINK.format(repo=snapshot["repo"])):
|
|
126
|
+
raise RenderRunFatal("A board card must link inside the tracked repository.")
|
|
127
|
+
group.setdefault("url", snapshot["url"])
|
|
128
|
+
return group
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _board_lane(lane: dict, tracker: dict, settings: dict, snapshot: dict, snapshot_path: Path) -> dict:
|
|
132
|
+
if "progress" in lane:
|
|
133
|
+
all_groups = _tracker_groups(tracker, lane["progress"], snapshot["repo"], settings, snapshot_path)
|
|
134
|
+
else:
|
|
135
|
+
all_groups = [_cited_group(each_group, snapshot, snapshot_path) for each_group in lane.get("groups", [])]
|
|
136
|
+
return {"node": lane["node"], "groups": all_groups, "count": sum(len(each_group["cards"]) for each_group in all_groups)}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _prepare_board(scene: dict, snapshot: dict, snapshot_path: Path) -> None:
|
|
140
|
+
"""Place every tracker card in the state lane its scene names.
|
|
141
|
+
|
|
142
|
+
The flow runs top to bottom. Each lane is one state node with its cards
|
|
143
|
+
beside it, grouped under their parent epic::
|
|
144
|
+
|
|
145
|
+
(Not started) Convert . 2 [task 12] [task 13]
|
|
146
|
+
|
|
|
147
|
+
(Fixed) Icons . 1 [task 14 Merged in pull request 20]
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
scene: Scene with ``board`` settings: ``lanes`` in flow order, each
|
|
151
|
+
naming a scene ``node`` and either a tracker ``progress``
|
|
152
|
+
(``no_pull``, ``open_pull``, ``merged_pull``) or cited ``groups``
|
|
153
|
+
of cards; ``strip`` title prefix patterns, ``titles`` keyed by
|
|
154
|
+
issue number, ``exclude`` issue numbers that are not work,
|
|
155
|
+
``steps`` naming the body sections each card diagram shows,
|
|
156
|
+
and ``wording`` swaps for that text.
|
|
157
|
+
snapshot: Captured tracker snapshot.
|
|
158
|
+
snapshot_path: Snapshot file the tracker tree sits beside.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
RenderRunFatal: A node lacks exactly one lane, or a card leaves the repository.
|
|
162
|
+
"""
|
|
163
|
+
settings = scene["board"]
|
|
164
|
+
tracker = json.loads((snapshot_path.parent / c.HEAD_SIDE / c.TRACKER_PATH).read_text(encoding="utf-8"))
|
|
165
|
+
if sorted(each_lane["node"] for each_lane in settings["lanes"]) != sorted(each_node["id"] for each_node in scene["nodes"]):
|
|
166
|
+
raise RenderRunFatal("Every board node needs exactly one lane.")
|
|
167
|
+
all_lanes = [_board_lane(each_lane, tracker, settings, snapshot, snapshot_path) for each_lane in settings["lanes"]]
|
|
168
|
+
scene["board"] = {"lanes": all_lanes, "count": sum(each_lane["count"] for each_lane in all_lanes)}
|
|
169
|
+
|
|
170
|
+
|
|
63
171
|
def _prepare_sources(scene: dict, snapshot: dict, snapshot_path: Path) -> None:
|
|
64
172
|
for each_node in scene["nodes"]:
|
|
65
173
|
for each_source in each_node["sources"]:
|
|
@@ -82,10 +190,13 @@ def prepare(path: Path) -> dict:
|
|
|
82
190
|
snapshot_path = (path.parent / scene["snapshot"]).resolve()
|
|
83
191
|
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
|
84
192
|
scene["pr"] = {each_key: snapshot[each_key] for each_key in ("number", "url", "repo", "state", "head", "base", "observed_at")}
|
|
85
|
-
|
|
193
|
+
is_issue = snapshot.get("kind") == c.ISSUE_KIND
|
|
194
|
+
scene["pr"].update(kind=snapshot.get("kind", "pull"), draft=snapshot["metadata"]["draft"], merged=snapshot["metadata"].get("merged", False), changed_files=snapshot["metadata"]["tracked_issues"] if is_issue else len(snapshot["files"]), title=snapshot["metadata"]["title"])
|
|
86
195
|
scene["summary"] = snapshot["metadata"]["body"] or "No PR description."
|
|
87
196
|
_validate_graph(scene)
|
|
88
197
|
_prepare_sources(scene, snapshot, snapshot_path)
|
|
198
|
+
if is_issue and scene.get("board"):
|
|
199
|
+
_prepare_board(scene, snapshot, snapshot_path)
|
|
89
200
|
prepare_chart(scene, snapshot_path)
|
|
90
201
|
prepare_embedded_charts(scene, snapshot_path)
|
|
91
202
|
return scene
|
|
@@ -96,6 +207,10 @@ def _omit_private_payload(scene: dict) -> None:
|
|
|
96
207
|
for each_node in scene["nodes"]:
|
|
97
208
|
for each_source in each_node["sources"]:
|
|
98
209
|
each_source.pop("excerpt", None)
|
|
210
|
+
for each_lane in scene.get("board", {}).get("lanes", []):
|
|
211
|
+
for each_group in each_lane["groups"]:
|
|
212
|
+
for each_card in each_group["cards"]:
|
|
213
|
+
each_card.get("source", {}).pop("excerpt", None)
|
|
99
214
|
|
|
100
215
|
|
|
101
216
|
def render(all_scenes: list[dict], artifact_path: Path, is_links_only: bool = False) -> None:
|
|
@@ -56,3 +56,51 @@ def test_incomplete_changed_file_list_fails(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
56
56
|
monkeypatch.setattr(collect, "_api", Mock(return_value=[]))
|
|
57
57
|
with pytest.raises(collect.CaptureRunFatal, match="incomplete"):
|
|
58
58
|
collect._changed_files(REPOSITORY, "1", 1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
ISSUE_URL = "https://github.com/example/project/issues/10"
|
|
62
|
+
EPIC = {"number": 10, "title": "Synthetic epic", "state": "open", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": "Tasks below."}
|
|
63
|
+
TASK_WITH_FIX = {"number": 11, "title": "Task with a fix", "state": "open", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": "Fix: https://github.com/example/project/pull/20"}
|
|
64
|
+
TASK_IN_FLIGHT = {"number": 12, "title": "Task in flight", "state": "open", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": "Fix: https://github.com/example/project/pull/21"}
|
|
65
|
+
TASK_LEFT = {"number": 13, "title": "Task left", "state": "closed", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": None}
|
|
66
|
+
FINISHED_PULL = {"number": 20, "title": "Finished fix", "state": "closed", "draft": False, "merged_at": "2026-10-19T11:00:00Z"}
|
|
67
|
+
OPEN_PULL = {"number": 21, "title": "Open fix", "state": "open", "draft": True, "merged_at": None}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tracker_github(refreshed_epic: dict) -> Mock:
|
|
71
|
+
responses = {
|
|
72
|
+
"repos/example/project/issues/10": [EPIC, refreshed_epic],
|
|
73
|
+
"repos/example/project/issues/10/sub_issues?per_page=100&page=1": [[TASK_WITH_FIX, TASK_IN_FLIGHT, TASK_LEFT]],
|
|
74
|
+
"repos/example/project/issues/11/sub_issues?per_page=100&page=1": [[]],
|
|
75
|
+
"repos/example/project/issues/12/sub_issues?per_page=100&page=1": [[]],
|
|
76
|
+
"repos/example/project/issues/13/sub_issues?per_page=100&page=1": [[]],
|
|
77
|
+
"repos/example/project/pulls/20": [FINISHED_PULL],
|
|
78
|
+
"repos/example/project/pulls/21": [OPEN_PULL],
|
|
79
|
+
}
|
|
80
|
+
return Mock(side_effect=lambda endpoint, *options: responses[endpoint].pop(0))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_issue_capture_counts_each_task_by_its_linked_pull_request(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
84
|
+
"""An epic snapshot records each task's fix state and hashes every captured record."""
|
|
85
|
+
monkeypatch.setattr(collect, "_api", _tracker_github(EPIC))
|
|
86
|
+
collect.collect_issue(ISSUE_URL, tmp_path)
|
|
87
|
+
destination = next((tmp_path / "issue-10").iterdir())
|
|
88
|
+
snapshot = json.loads((destination / "snapshot.json").read_text())
|
|
89
|
+
tracker = json.loads((destination / "head/tracker.json").read_text())
|
|
90
|
+
assert tracker["counts"] == {"open": 2, "closed": 1, "merged_pull": 1, "open_pull": 1, "no_pull": 1}
|
|
91
|
+
assert [each_child["progress"] for each_child in tracker["children"]] == ["merged_pull", "open_pull", "no_pull"]
|
|
92
|
+
assert snapshot["kind"] == "issue"
|
|
93
|
+
assert snapshot["metadata"]["tracked_issues"] == 4
|
|
94
|
+
assert (destination / "head/issues/11.md").read_text() == TASK_WITH_FIX["body"] + "\n"
|
|
95
|
+
assert json.loads((destination / "head/pulls/21.json").read_text())["draft"] is True
|
|
96
|
+
for each_file in snapshot["files"]:
|
|
97
|
+
assert hashlib.sha256((destination / each_file["captures"]["head"]["path"]).read_bytes()).hexdigest() == each_file["captures"]["head"]["sha256"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_edited_issue_never_saves_a_tracker_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
101
|
+
"""An epic edited during capture requires another capture before rendering."""
|
|
102
|
+
edited = {**EPIC, "updated_at": "2026-10-19T12:05:00Z"}
|
|
103
|
+
monkeypatch.setattr(collect, "_api", _tracker_github(edited))
|
|
104
|
+
with pytest.raises(collect.CaptureRunFatal, match="changed during capture"):
|
|
105
|
+
collect.collect_issue(ISSUE_URL, tmp_path)
|
|
106
|
+
assert not list(tmp_path.rglob("snapshot.json"))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Exercise numerical provenance, evidence boundaries, and portable rendering."""
|
|
2
2
|
import copy
|
|
3
3
|
import json
|
|
4
|
+
import shutil
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -75,3 +76,86 @@ def test_embedded_text_cannot_close_script(tmp_path: Path) -> None:
|
|
|
75
76
|
artifact_path = tmp_path / "visual.html"
|
|
76
77
|
render([scene], artifact_path)
|
|
77
78
|
assert scene["summary"] not in artifact_path.read_text()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_tracker_scene_links_each_record_to_its_issue_or_pull_request(tmp_path: Path) -> None:
|
|
82
|
+
"""Tracker evidence opens the issue or pull request a captured record came from."""
|
|
83
|
+
scene = prepare(FIXTURES / "tracker.scene.json")
|
|
84
|
+
render([scene], tmp_path / "visual.html")
|
|
85
|
+
all_links = [each_source["url"] for each_node in scene["nodes"] for each_source in each_node["sources"]]
|
|
86
|
+
assert all_links == ["https://github.com/example/project/issues/1", "https://github.com/example/project/issues/2", "https://github.com/example/project/pull/3", "https://github.com/example/project/issues/1"]
|
|
87
|
+
assert scene["pr"]["kind"] == "issue"
|
|
88
|
+
assert scene["pr"]["changed_files"] == 3
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_tracker_scene_rejects_evidence_outside_captured_records(tmp_path: Path) -> None:
|
|
92
|
+
"""A tracker source must name a captured issue, pull request, or the tracker tree."""
|
|
93
|
+
tracker_copy = tmp_path / "tracker"
|
|
94
|
+
shutil.copytree(FIXTURES / "tracker", tracker_copy)
|
|
95
|
+
(tracker_copy / "head/notes.txt").write_text("synthetic\n")
|
|
96
|
+
snapshot = json.loads((tracker_copy / "snapshot.json").read_text())
|
|
97
|
+
with pytest.raises(RenderRunFatal, match="Tracker evidence"):
|
|
98
|
+
_source_evidence({"path": "notes.txt", "line": 1}, snapshot, tracker_copy / "snapshot.json")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_tracker_scene_places_every_card_in_its_state_lane(tmp_path: Path) -> None:
|
|
102
|
+
"""Each card lands once in the lane for its state, under its epic, with its issue link."""
|
|
103
|
+
scene = prepare(FIXTURES / "tracker.scene.json")
|
|
104
|
+
render([scene], tmp_path / "visual.html", is_links_only=True)
|
|
105
|
+
branches, left, fixed = scene["board"]["lanes"]
|
|
106
|
+
assert [each_lane["node"] for each_lane in scene["board"]["lanes"]] == ["epic", "left", "fixed"]
|
|
107
|
+
assert [(each_card["title"], each_card["note"]) for each_card in left["groups"][0]["cards"]] == [("task without a fix", "")]
|
|
108
|
+
assert [(each_card["title"], each_card["note"]) for each_card in fixed["groups"][0]["cards"]] == [("task with a fix", "Merged in pull request 3")]
|
|
109
|
+
assert fixed["groups"][0]["title"] == "tracker epic"
|
|
110
|
+
assert fixed["groups"][0]["cards"][0]["url"] == "https://github.com/example/project/issues/2"
|
|
111
|
+
assert branches["groups"][0]["cards"][0]["url"] == "https://github.com/example/project/issues/1"
|
|
112
|
+
assert scene["board"]["count"] == 3
|
|
113
|
+
assert left["groups"][0]["cards"][0]["steps"] == [
|
|
114
|
+
{"label": "Today", "kind": "orange", "text": "The sample task has no fix yet."},
|
|
115
|
+
{"label": "Build", "kind": "lime", "inline": True, "text": "Build the sample fix and link it."},
|
|
116
|
+
{"label": "Proof", "kind": "green", "text": "A sample test shows the fix."},
|
|
117
|
+
]
|
|
118
|
+
assert fixed["groups"][0]["cards"][0]["steps"] == []
|
|
119
|
+
assert "Task one ships" not in (tmp_path / "visual.html").read_text()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _board_scene(tmp_path: Path) -> dict:
|
|
123
|
+
shutil.copytree(FIXTURES / "tracker", tmp_path / "tracker")
|
|
124
|
+
return json.loads((FIXTURES / "tracker.scene.json").read_text())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _prepare_copy(tmp_path: Path, scene: dict) -> dict:
|
|
128
|
+
(tmp_path / "tracker.scene.json").write_text(json.dumps(scene))
|
|
129
|
+
return prepare(tmp_path / "tracker.scene.json")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_tracker_cited_card_rejects_a_link_outside_the_repository(tmp_path: Path) -> None:
|
|
133
|
+
"""A cited board card cannot point away from the tracked repository."""
|
|
134
|
+
scene = _board_scene(tmp_path)
|
|
135
|
+
scene["board"]["lanes"][0]["groups"][0]["cards"][0]["url"] = "https://example.com/elsewhere"
|
|
136
|
+
with pytest.raises(RenderRunFatal, match="tracked repository"):
|
|
137
|
+
_prepare_copy(tmp_path, scene)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_tracker_board_rejects_a_node_with_no_lane(tmp_path: Path) -> None:
|
|
141
|
+
"""Every state node on a board sits in exactly one lane."""
|
|
142
|
+
scene = _board_scene(tmp_path)
|
|
143
|
+
scene["board"]["lanes"].pop()
|
|
144
|
+
with pytest.raises(RenderRunFatal, match="exactly one lane"):
|
|
145
|
+
_prepare_copy(tmp_path, scene)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_tracker_scene_leaves_out_excluded_issues(tmp_path: Path) -> None:
|
|
149
|
+
"""An issue the scene names as not a work item stays off the board."""
|
|
150
|
+
scene = _board_scene(tmp_path)
|
|
151
|
+
scene["board"]["exclude"] = [4]
|
|
152
|
+
left_lane = _prepare_copy(tmp_path, scene)["board"]["lanes"][1]
|
|
153
|
+
assert left_lane["groups"] == [] and left_lane["count"] == 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_tracker_scene_uses_the_plain_title_a_scene_gives(tmp_path: Path) -> None:
|
|
157
|
+
"""A card shows the plain title a scene gives for an issue number."""
|
|
158
|
+
scene = _board_scene(tmp_path)
|
|
159
|
+
scene["board"]["titles"] = {"4": "Build the missing fix"}
|
|
160
|
+
left_lane = _prepare_copy(tmp_path, scene)["board"]["lanes"][1]
|
|
161
|
+
assert [each_card["title"] for each_card in left_lane["groups"][0]["cards"]] == ["Build the missing fix"]
|
|
@@ -51,6 +51,35 @@ def _toolbar_details(root: Locator, node_count: int) -> None:
|
|
|
51
51
|
root.get_by_role("button", name=c.CLOSE_BUTTON_LABEL).click()
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _check_board(root: Locator, scene: dict) -> None:
|
|
55
|
+
all_cards = root.locator(c.BOARD_CARD_SELECTOR)
|
|
56
|
+
if not scene.get("board"):
|
|
57
|
+
assert all_cards.count() == 0
|
|
58
|
+
return
|
|
59
|
+
assert all_cards.count() == scene["board"]["count"]
|
|
60
|
+
assert not all_cards.evaluate_all(c.CARD_FIT_EXPRESSION)
|
|
61
|
+
_check_board_text(root, c.CANVAS_TEXT_SELECTOR)
|
|
62
|
+
first_card = 0
|
|
63
|
+
for each_lane in scene["board"]["lanes"]:
|
|
64
|
+
if not each_lane["count"]:
|
|
65
|
+
continue
|
|
66
|
+
card = each_lane["groups"][0]["cards"][0]
|
|
67
|
+
all_cards.nth(first_card).click()
|
|
68
|
+
assert root.locator(c.DETAIL_TITLE_SELECTOR).inner_text() == card["title"]
|
|
69
|
+
assert root.locator(c.CARD_STEP_SELECTOR).count() == max(1, len(card.get("steps", [])))
|
|
70
|
+
_check_board_text(root, c.CARD_FLOW_TEXT_SELECTOR)
|
|
71
|
+
root.get_by_role("button", name=c.CLOSE_BUTTON_LABEL).click()
|
|
72
|
+
assert root.locator(c.DRAWER_SELECTOR).is_hidden()
|
|
73
|
+
first_card += each_lane["count"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_board_text(root: Locator, selector: str) -> None:
|
|
77
|
+
background = _luminance(c.PAGE_BACKGROUND_COLOR)
|
|
78
|
+
for each_text in root.locator(selector).evaluate_all(c.BOARD_TEXT_EXPRESSION):
|
|
79
|
+
assert each_text["size"] >= c.MINIMUM_BOARD_FONT_SIZE, each_text
|
|
80
|
+
assert (background + c.CONTRAST_OFFSET) / (_luminance(each_text["color"]) + c.CONTRAST_OFFSET) >= c.MINIMUM_BOARD_CONTRAST, each_text
|
|
81
|
+
|
|
82
|
+
|
|
54
83
|
def _keyboard_and_zoom(page: Page, root: Locator) -> None:
|
|
55
84
|
first_node = root.locator(c.NODE_SELECTOR).first
|
|
56
85
|
first_node.focus()
|
|
@@ -66,11 +95,15 @@ def _keyboard_and_zoom(page: Page, root: Locator) -> None:
|
|
|
66
95
|
|
|
67
96
|
def _scene_screenshots(page: Page, root: Locator, scene: dict, proof_directory: Path, scene_index: int) -> None:
|
|
68
97
|
page.screenshot(path=str(proof_directory / c.PR_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)), full_page=True)
|
|
98
|
+
if scene.get("board"):
|
|
99
|
+
root.locator(c.WORKSPACE_SELECTOR).evaluate(c.UNCLIP_WORKSPACE_EXPRESSION)
|
|
100
|
+
root.locator(c.CANVAS_SELECTOR).screenshot(path=str(proof_directory / c.BOARD_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)))
|
|
101
|
+
root.locator(c.WORKSPACE_SELECTOR).evaluate(c.RECLIP_WORKSPACE_EXPRESSION)
|
|
69
102
|
all_nodes = root.locator(c.NODE_SELECTOR)
|
|
70
103
|
all_nodes.first.click()
|
|
71
104
|
page.screenshot(path=str(proof_directory / c.DETAIL_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)), full_page=True)
|
|
72
105
|
if all_nodes.count() > 1:
|
|
73
|
-
|
|
106
|
+
root.locator(c.NODE_TARGET_SELECTOR.format(target=scene["nodes"][1]["id"])).click()
|
|
74
107
|
assert root.locator(c.DETAIL_TITLE_SELECTOR).inner_text() == c.SPACE_SEPARATOR.join(scene["nodes"][1]["label"])
|
|
75
108
|
page.keyboard.press("Escape")
|
|
76
109
|
|
|
@@ -78,17 +111,18 @@ def _scene_screenshots(page: Page, root: Locator, scene: dict, proof_directory:
|
|
|
78
111
|
def _verify_scene(page: Page, root: Locator, scene: dict, scene_index: int, proof_directory: Path) -> dict:
|
|
79
112
|
root.get_by_role("tab").nth(scene_index).click()
|
|
80
113
|
all_nodes = root.locator(c.NODE_SELECTOR)
|
|
81
|
-
for
|
|
82
|
-
|
|
114
|
+
for each_node in scene["nodes"]:
|
|
115
|
+
root.locator(c.NODE_TARGET_SELECTOR.format(target=each_node["id"])).click()
|
|
83
116
|
_check_node(root, each_node)
|
|
84
117
|
_navigate_related(root)
|
|
85
118
|
page.keyboard.press("Escape")
|
|
86
119
|
assert root.locator(c.DRAWER_SELECTOR).is_hidden()
|
|
87
120
|
assert root.locator(c.SELECTED_NODE_SELECTOR).count() == 0
|
|
88
121
|
_toolbar_details(root, len(scene["nodes"]))
|
|
122
|
+
_check_board(root, scene)
|
|
89
123
|
_keyboard_and_zoom(page, root)
|
|
90
124
|
_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"}
|
|
125
|
+
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", "board_cards": scene["board"]["count"] if scene.get("board") else "none"}
|
|
92
126
|
|
|
93
127
|
|
|
94
128
|
def _dashboard(page: Page, root: Locator, all_scenes: list[dict], proof_directory: Path) -> None:
|
|
@@ -12,6 +12,30 @@ HEAD_SIDE = "head"
|
|
|
12
12
|
BASE_SIDE = "base"
|
|
13
13
|
PR_URL_PATTERN = r"https://github\.com/([\w.-]+/[\w.-]+)/pull/([1-9]\d*)/?"
|
|
14
14
|
PR_ENDPOINT = "repos/{repo}/pulls/{number}"
|
|
15
|
+
ISSUE_URL_PATTERN = r"https://github\.com/([\w.-]+/[\w.-]+)/issues/([1-9]\d*)/?"
|
|
16
|
+
ISSUE_ENDPOINT = "repos/{repo}/issues/{number}"
|
|
17
|
+
SUB_ISSUES_ENDPOINT = "repos/{repo}/issues/{number}/sub_issues?per_page={size}&page={page}"
|
|
18
|
+
LINKED_PULL_PATTERN = r"https://github\.com/{repo}/pull/([1-9]\d*)"
|
|
19
|
+
ISSUE_KIND = "issue"
|
|
20
|
+
SUB_ISSUE_DEPTH = 2
|
|
21
|
+
ISSUE_RECORD_PATH = "issues/{number}.json"
|
|
22
|
+
ISSUE_BODY_PATH = "issues/{number}.md"
|
|
23
|
+
SECTION_HEADING = "## {heading}"
|
|
24
|
+
WHOLE_PHRASE_PATTERN = r"(?<!\w){phrase}(?!\w)"
|
|
25
|
+
PULL_RECORD_PATH = "pulls/{number}.json"
|
|
26
|
+
TRACKER_PATH = "tracker.json"
|
|
27
|
+
ISSUE_LINK = "https://github.com/{repo}/issues/{number}"
|
|
28
|
+
PULL_LINK = "https://github.com/{repo}/pull/{number}"
|
|
29
|
+
REPOSITORY_LINK = "https://github.com/{repo}/"
|
|
30
|
+
CAPTURED_ISSUE_PATTERN = r"issues/([1-9]\d*)\.(?:json|md)"
|
|
31
|
+
CAPTURED_ISSUE_RECORD_PATTERN = r"issues/([1-9]\d*)\.json"
|
|
32
|
+
CAPTURED_PULL_PATTERN = r"pulls/([1-9]\d*)\.json"
|
|
33
|
+
ALL_ISSUE_FIELDS = ("number", "title", "state", "state_reason", "html_url", "created_at", "updated_at", "closed_at")
|
|
34
|
+
ALL_PULL_PROGRESS = ("merged_pull", "open_pull", "no_pull")
|
|
35
|
+
ALL_CARD_NOTES = {"no_pull": "", "open_pull": "Open in pull request {pulls}", "merged_pull": "Merged in pull request {pulls}"}
|
|
36
|
+
PULL_REFERENCE = "{number}"
|
|
37
|
+
PULL_REFERENCE_SEPARATOR = ", "
|
|
38
|
+
ALL_PULL_FIELDS =("number", "title", "state", "draft", "merged_at", "closed_at", "updated_at", "html_url")
|
|
15
39
|
COMPARE_ENDPOINT = "repos/{repo}/compare/{base}...{head}"
|
|
16
40
|
FILES_ENDPOINT = "repos/{repo}/pulls/{number}/files?per_page={size}&page={page}"
|
|
17
41
|
CONTENTS_ENDPOINT = "repos/{repo}/contents/{path}?ref={revision}"
|
|
@@ -37,11 +61,23 @@ ROOT_SELECTOR = "[data-pr-visual]"
|
|
|
37
61
|
DRAWER_SELECTOR = ".drawer"
|
|
38
62
|
STAGE_SELECTOR = ".stage"
|
|
39
63
|
CANVAS_SELECTOR = ".canvas"
|
|
64
|
+
WORKSPACE_SELECTOR = ".workspace"
|
|
65
|
+
UNCLIP_WORKSPACE_EXPRESSION = "(n)=>{n.style.height='auto';n.firstElementChild.style.overflow='visible'}"
|
|
66
|
+
RECLIP_WORKSPACE_EXPRESSION = "(n)=>{n.style.height='';n.firstElementChild.style.overflow=''}"
|
|
40
67
|
EXACT_SELECTOR = ".exact-source"
|
|
41
68
|
DISCLOSURE_SELECTOR = ".exact-source summary"
|
|
42
69
|
DETAIL_TITLE_SELECTOR = "[data-detail-title]"
|
|
43
70
|
NODE_SELECTOR = "[data-node]"
|
|
44
71
|
JUMP_SELECTOR = "[data-detail-target]"
|
|
72
|
+
BOARD_CARD_SELECTOR = "[data-card]"
|
|
73
|
+
CANVAS_TEXT_SELECTOR = ".canvas svg text"
|
|
74
|
+
CARD_FLOW_TEXT_SELECTOR = ".drawer .card-flow text"
|
|
75
|
+
CARD_STEP_SELECTOR = ".drawer .card-flow rect"
|
|
76
|
+
MINIMUM_BOARD_FONT_SIZE = 16
|
|
77
|
+
MINIMUM_BOARD_CONTRAST = 7
|
|
78
|
+
PAGE_BACKGROUND_COLOR = "rgb(246, 245, 239)"
|
|
79
|
+
BOARD_TEXT_EXPRESSION = "(nodes)=>nodes.map(n=>({size:parseFloat(getComputedStyle(n).fontSize),color:getComputedStyle(n).fill}))"
|
|
80
|
+
CARD_FIT_EXPRESSION = "(cards)=>cards.filter(a=>{const r=a.querySelector('rect').getBBox();return [...a.querySelectorAll('text')].some(t=>{const b=t.getBBox();return b.x<r.x||b.x+b.width>r.x+r.width||b.y<r.y||b.y+b.height>r.y+r.height})}).map(a=>a.textContent)"
|
|
45
81
|
DATA_SCENES_SELECTOR = "[data-scenes]"
|
|
46
82
|
CANVAS_WIDTH_EXPRESSION = "(n)=>n.style.width"
|
|
47
83
|
DISCLOSURE_OPEN_EXPRESSION = "(n)=>n.open"
|
|
@@ -56,6 +92,7 @@ NODE_TARGET_SELECTOR = '[data-node="{target}"]'
|
|
|
56
92
|
SELECTED_NODE_SELECTOR = '[data-node][aria-expanded=true]'
|
|
57
93
|
PR_SCREENSHOT_NAME = "scene-{index}-pr-{number}.png"
|
|
58
94
|
DETAIL_SCREENSHOT_NAME = "scene-{index}-pr-{number}-detail.png"
|
|
95
|
+
BOARD_SCREENSHOT_NAME = "scene-{index}-pr-{number}-board.png"
|
|
59
96
|
MOBILE_SCREENSHOT_NAME = "mobile-detail.png"
|
|
60
97
|
REPORT_NAME = "verification.json"
|
|
61
98
|
DEFAULT_PROOF_DIRECTORY = "proof"
|
|
@@ -63,7 +100,7 @@ SPACE_SEPARATOR = " "
|
|
|
63
100
|
ZOOM_BUTTON_LABEL = "Zoom in"
|
|
64
101
|
CLOSE_BUTTON_LABEL = "Close details"
|
|
65
102
|
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>
|
|
103
|
+
OVERFLOW_EXPRESSION = "(nodes)=>nodes.filter(n=>{const b=n.getBBox(),v=n.ownerSVGElement.viewBox.baseVal; return b.x<0||b.x+b.width>v.width||b.y<0||b.y+b.height>v.height}).map(n=>n.textContent)"
|
|
67
104
|
MAIN_FRAGMENT_PATTERN = r"<main data-pr-visual>.*?</main>"
|
|
68
105
|
STYLE_FRAGMENT_PATTERN = r"<style>.*?</style>"
|
|
69
106
|
HOST_FIXTURE_HTML = '<div class="toolbar" id="host-toolbar">Host toolbar</div>'
|
|
@@ -67,6 +67,9 @@ const HOST_DEFINITIONS = Object.freeze({
|
|
|
67
67
|
orphanedVersionsDirectory: (homeDirectory, spec) => join(
|
|
68
68
|
homeDirectory, 'plugins', 'cache', spec.marketplaceName, spec.name,
|
|
69
69
|
),
|
|
70
|
+
staleCloneDirectory: (homeDirectory, spec) => join(
|
|
71
|
+
homeDirectory, 'plugins', 'marketplaces', `${spec.marketplaceRepository.replaceAll('/', '-')}..clone`,
|
|
72
|
+
),
|
|
70
73
|
installVerb: 'install',
|
|
71
74
|
uninstallVerb: 'uninstall',
|
|
72
75
|
}),
|
|
@@ -215,6 +218,24 @@ function singleLineDetail(text) {
|
|
|
215
218
|
return (text ?? '').split(/\r?\n/).map(eachLine => eachLine.trim()).filter(Boolean).join(' ');
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Delete the clone folder an interrupted marketplace add left behind.
|
|
223
|
+
*
|
|
224
|
+
* Claude Code clones a marketplace into `<owner>-<repository>..clone` and
|
|
225
|
+
* renames it when the clone finishes. A clone that stops partway leaves that
|
|
226
|
+
* folder with its `.git/shallow.lock`, and `marketplace remove` does not
|
|
227
|
+
* delete it, so every later add fails on the lock. Removing the folder
|
|
228
|
+
* before the add lets the add start from an empty slot.
|
|
229
|
+
*
|
|
230
|
+
* @param {string|undefined} cloneDirectory The leftover clone folder.
|
|
231
|
+
* @returns {boolean} True when a folder was removed.
|
|
232
|
+
*/
|
|
233
|
+
export function removeStaleMarketplaceClone(cloneDirectory) {
|
|
234
|
+
if (!cloneDirectory || !existsSync(cloneDirectory)) return false;
|
|
235
|
+
rmSync(cloneDirectory, { recursive: true, force: true, maxRetries: 3 });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
218
239
|
const ORPHANED_VERSION_MARKER = '.orphaned_at';
|
|
219
240
|
|
|
220
241
|
/**
|
|
@@ -269,6 +290,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
269
290
|
});
|
|
270
291
|
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
|
|
271
292
|
}
|
|
293
|
+
if (HOST_DEFINITIONS[host].staleCloneDirectory && homeDirectory) {
|
|
294
|
+
removeStaleMarketplaceClone(HOST_DEFINITIONS[host].staleCloneDirectory(homeDirectory, allSpecs[0]));
|
|
295
|
+
}
|
|
272
296
|
for (const commandArguments of plan.commands) {
|
|
273
297
|
const outcome = runCommand(executable, [...commandArguments], {
|
|
274
298
|
environment: commandEnvironment,
|
|
@@ -300,6 +324,8 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
300
324
|
* Each host first removes every plugin in the set and then the marketplace,
|
|
301
325
|
* so an older version never survives beside the new one. A removal that finds
|
|
302
326
|
* nothing to remove exits non-zero on a fresh home, and the install goes on.
|
|
327
|
+
* Before the Claude marketplace add, the install deletes the half-made clone
|
|
328
|
+
* folder an interrupted earlier add left, so the add starts in an empty slot.
|
|
303
329
|
*
|
|
304
330
|
* Each host is one member of the batch. A host without its command-line tool
|
|
305
331
|
* is skipped and a host whose command fails is reported, so the rules, hooks,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
installMarketplacePlugins,
|
|
16
16
|
marketplacePluginsPlan,
|
|
17
17
|
removeOrphanedPluginVersions,
|
|
18
|
+
removeStaleMarketplaceClone,
|
|
18
19
|
removeRetiredMarketplacePlugins,
|
|
19
20
|
shouldInstallMarketplacePlugin,
|
|
20
21
|
} from './install-pstack-plugin.mjs';
|
|
@@ -478,3 +479,24 @@ test('a held folder that was not searched adds no claim about what holds it', ()
|
|
|
478
479
|
assert.match(claude.warning, /EPERM: operation not permitted$/);
|
|
479
480
|
assert.doesNotMatch(claude.warning, /likely holder|Close /);
|
|
480
481
|
});
|
|
482
|
+
|
|
483
|
+
test('should delete a half-made marketplace clone before the shared marketplace add', () => {
|
|
484
|
+
const claudeRoot = mkdtempSync(join(tmpdir(), 'cde-stale-clone-'));
|
|
485
|
+
const cloneDirectory = join(claudeRoot, 'plugins', 'marketplaces', 'jl-cmd-projects-claude-dev-env..clone');
|
|
486
|
+
mkdirSync(join(cloneDirectory, '.git'), { recursive: true });
|
|
487
|
+
writeFileSync(join(cloneDirectory, '.git', 'shallow.lock'), '');
|
|
488
|
+
const allCloneStatesAtAdd = [];
|
|
489
|
+
const outcome = installMarketplacePlugins([USAGE_WRAPUP_PLUGIN_SPEC], { claudeRoot, hosts: ['claude'] }, {
|
|
490
|
+
runCommand: (executable, commandArguments) => {
|
|
491
|
+
if (commandArguments[2] === 'add') allCloneStatesAtAdd.push(existsSync(cloneDirectory));
|
|
492
|
+
return { status: 0, stderr: '' };
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
assert.equal(outcome.status, 'installed');
|
|
496
|
+
assert.deepEqual(allCloneStatesAtAdd, [false]);
|
|
497
|
+
rmSync(claudeRoot, { recursive: true, force: true });
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('should report no removal when no stale marketplace clone exists', () => {
|
|
501
|
+
assert.equal(removeStaleMarketplaceClone(join(tmpdir(), 'cde-no-such-clone')), false);
|
|
502
|
+
});
|