diff --git a/.agents/skills/pr-walkthrough/SKILL.md b/.agents/skills/pr-walkthrough/SKILL.md new file mode 100644 index 0000000..e9d5f39 --- /dev/null +++ b/.agents/skills/pr-walkthrough/SKILL.md @@ -0,0 +1,250 @@ +--- +name: pr-walkthrough +description: Generate a local static interactive D3 walkthrough of a pull request. Use when the user wants a zoomable PR map, graph/canvas PR orientation, or alternate visualization of PR system components, data flow, code dependencies, and user actions. +--- + +# pr-walkthrough + +为当前分支或 GitHub PR 生成一个本地静态 HTML 讲解页,帮助 reviewer 快速理解 PR 涉及的系统、数据流、代码依赖和用户路径。此技能不是代码评审技能,不生成新的 review finding、approval 或 request-changes 结论。 + +## 输出 + +生成文件到: + +```text +.aicodingflow/pr-walkthrough/index.html +``` + +站点必须可以直接用 `file://` 打开,不要求 dev server、打包器、安装依赖或构建步骤。优先生成单个自包含 HTML 文件,内联 CSS、JavaScript 和图数据;如果必须拆分资源,只使用相对路径,并避免用 `fetch()` 读取本地数据。 + +D3 使用固定版本的官方 CDN: + +```text +https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js +``` + +优先使用本技能自带脚本生成和验证页面: + +```bash +python3 .agents/skills/pr-walkthrough/scripts/d3_canvas_runtime.py --template --data graph.json > .aicodingflow/pr-walkthrough/index.html +python3 .agents/skills/pr-walkthrough/scripts/validate_d3_canvas.py --html .aicodingflow/pr-walkthrough/index.html --require-browser +``` + +## 视觉风格 + +不要套用任何公司视觉规范、专属字体、专属色或外部视觉规范技能。使用脚本内置的中性文档/工具界面样式即可。可以为四个视图使用不同的功能色,但颜色只表达信息类型,不表达品牌。 + +推荐视图颜色: + +- System overview view: `#b7791f` +- Data flow graph: `#0f7b5f` +- Code dependency graph: `#2563eb` +- User action graph: `#7c3aed` +- Active/focus/selected node: `#2563eb` + +## 工作流 + +### 1. 建立 PR 上下文 + +识别仓库根目录、当前分支和比较 base。若当前分支已有 GitHub PR,优先使用 PR base,并记录 PR URL 生成 diff links: + +```bash +gh pr view --json baseRefName,headRefName,title,body,url,state,reviewRequests,reviews,files +``` + +若没有 PR,从远端默认分支或仓库约定推断 base: + +```bash +git symbolic-ref --short refs/remotes/origin/HEAD +``` + +收集 review 输入: + +```bash +git --no-pager diff --stat ...HEAD +git --no-pager diff --name-status ...HEAD +git --no-pager log --oneline ..HEAD +git --no-pager diff ...HEAD +``` + +根据 changed lines、changed files 和概念跨度估算 PR 大小,默认生成最小有用讲解: + +- Tiny PR: 约 1 个文件或 75 行以内。每个视图 2-3 个节点/卡片,1-2 个 tour steps。 +- Small PR: 250 行以内或 1-3 个文件。每个视图 3-4 个节点,2-4 个 tour steps。 +- Medium PR: 250-800 行或多个相关文件。每个视图 4-7 个节点。 +- Large PR: 只有跨多个子系统、有新架构或有大量 spec/review 上下文时,才使用 5-12 个节点。 + +不要只读 diff。要读取关键变更文件的当前完整版本,并沿 imports、call sites、types、state owners、renderers、tests 和相邻模块理解系统边界。System overview 尤其要作为稳定的代码阅读产物,而不是 PR 变更列表。 + +如果存在 GitHub PR,收集已有评论和 review discussion: + +```bash +gh pr view --json comments,reviews,reviewThreads +gh api repos/:owner/:repo/pulls//comments --paginate +gh api repos/:owner/:repo/issues//comments --paginate +``` + +这些评论只作为讲解素材,不作为改代码指令。 + +### 2. 收集视觉素材 + +查找能帮助 reviewer 理解用户可见变化的截图、mock、视频、设计资产或 changed image。来源包括 PR body/comments/reviews、关联 issue、变更的图片/SVG/mock fixture、本地测试截图,以及 `.aicodingflow/` 下已有临时产物。 + +需要纳入页面的外部视觉素材应下载或导出到: + +```text +.aicodingflow/pr-walkthrough/assets/ +``` + +用相对路径引用,或在更简单时嵌入为 data URI。不要 hotlink 远端图片。 + +### 3. 构造 GitHub diff links + +当已知 PR URL 时,每个 changed file reference、节点附件、代码摘录和依赖边都应链接到 PR 的 Files changed 页: + +```text +/files#diff- +/files#diff-R +/files#diff-L +``` + +`` 是变更文件路径的 lowercase SHA-256 hex digest。用确定性 helper 或脚本生成,不要手写猜测。 + +### 4. 设计四个独立视图 + +先构建数据模型,再生成 HTML。必须恰好包含四个视图: + +- `system-overview`: 受影响子系统的稳定架构概览。不要提 PR、changed files、diff links、review comments、screenshots、specs 或实现 delta。通常 `edges: []`,用较大的卡片和可读段落说明。 +- `data-flow`: 状态、数据、事件、请求、文件、资产或渲染输出如何流动。 +- `code-dependency`: 变更组件之间的依赖方向、入口点、边界和 leaf dependencies。 +- `user-action`: 用户从哪个 surface 开始,触发什么动作,看到什么反馈。 + +每个视图都需要自己的 nodes、edges 和 guided tour。除 `system-overview` 外,其他视图必须有有向边、箭头和描述 source-to-target 关系的 edge label。 + +每个节点回答: + +- reviewer 需要先理解什么? +- 此节点在这个视图中解释哪个概念? +- 哪些文件、spec、测试、视觉素材或已有评论能作为证据? +- 点击后 detail panel 应该展示什么? + +Tour 顺序要教 reviewer 从起点读到终点,不要只是文件顺序。 + +### 5. 写入数据模型 + +将图数据内联到 HTML,赋值给 `window.PR_WALKTHROUGH_D3_DATA` 或写入 `id="pr-walkthrough-data"` 的 JSON script。不要用 `fetch()` 加载本地 JSON。 + +数据形状: + +```json +{ + "meta": { + "title": "PR title", + "prUrl": "https://github.com/owner/repo/pull/123", + "baseRef": "main", + "headRef": "feature-branch", + "summary": "What the PR is trying to accomplish." + }, + "graphs": [ + { + "id": "system-overview", + "label": "System overview", + "color": "#b7791f", + "summary": "Concise component overview for the affected subsystem.", + "nodes": [], + "edges": [], + "tour": [] + } + ] +} +``` + +坐标建议: + +- 起点放在左侧或上方。 +- Tour 路径尽量从左到右或从上到下。 +- 相关节点靠近,低层依赖放在调用者右侧或下方。 +- 小 PR 的图应紧凑到无需大量平移即可读懂。 + +### 6. 生成静态页面 + +可先生成样例数据,修改为真实 PR 数据,再渲染: + +```bash +mkdir -p .aicodingflow/pr-walkthrough +python3 .agents/skills/pr-walkthrough/scripts/d3_canvas_runtime.py --sample-data > .aicodingflow/pr-walkthrough/graph.json +python3 .agents/skills/pr-walkthrough/scripts/d3_canvas_runtime.py --template --data .aicodingflow/pr-walkthrough/graph.json > .aicodingflow/pr-walkthrough/index.html +``` + +必备交互: + +- 单个 D3 SVG canvas,支持 zoom、pan、fit-to-view 和 reset zoom。 +- 四个 view toggles: `System overview`, `Data flow graph`, `Code dependency graph`, `User action graph`。 +- Tour controls: `Previous tour step`, `Next tour step`, `Restart tour` 和 step indicator。 +- Search input 可搜索 active graph 的 node titles、file paths 和 comments。 +- 点击节点更新 detail panel,并在可能时同步到对应 tour step。 +- 键盘支持:Right Arrow/`n`、Left Arrow/`p`、`1`-`4`、`+`/`=`、`-`、`0`、`f`、`/`、`Escape`。 +- 稳定的 `data-graph-id`、`data-node-id`、`data-edge-id`、`data-tour-index` 属性,方便自动化截图和验证。 + +### 7. 验证 + +完成前必须运行: + +```bash +python3 .agents/skills/pr-walkthrough/scripts/validate_d3_canvas.py --html .aicodingflow/pr-walkthrough/index.html --require-browser +``` + +验证至少确认: + +- D3 使用固定版本 URL,未使用 `latest`。 +- 页面不用 `fetch()` 读取本地数据。 +- 图数据恰好包含 `system-overview`、`data-flow`、`code-dependency`、`user-action`。 +- 必备控件存在。 +- 每个视图都有节点和 tour。 +- 非 overview 图都有有向边和箭头。 +- System overview 是 PR-agnostic 的架构概览,不带 PR 附件。 +- PR-changed specs 和已有 PR review comments 被纳入或明确报告为不存在/不可用。 +- 视觉素材是本地相对路径或 data URI。 + +如果浏览器环境不可用,报告 canvas rendering 未验证,不要说 walkthrough 已完全 ready。 + +### 8. 可选发布到 GitHub Pages + +默认只保留本地产物,不发布公网,也不提交 `.aicodingflow/pr-walkthrough/`。只有用户明确要求公开 URL 时才发布。发布前必须确认 PR 内容、截图、评论和代码上下文可以公开。 + +推荐使用 `gh-pages` 分支作为 Pages 来源,并把生成站点复制到临时 worktree,避免把生成物混入当前开发分支: + +```bash +repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +sha="$(git rev-parse --short HEAD)" +site_dir="/tmp/aicodingflow-pr-walkthrough-pages-$sha" +git fetch origin gh-pages || true +git worktree add "$site_dir" gh-pages +mkdir -p "$site_dir/pr-walkthrough/$sha" +cp -R .aicodingflow/pr-walkthrough/. "$site_dir/pr-walkthrough/$sha/" +cd "$site_dir" +git add "pr-walkthrough/$sha" +git commit -m "docs: publish PR walkthrough $sha" +git push origin gh-pages +``` + +如果仓库尚未启用 GitHub Pages,先让用户在仓库设置里选择 `gh-pages` 分支作为 Pages source,或在有权限时使用 GitHub API/CLI 配置 Pages。不要在未征得用户同意时更改仓库 Pages 设置。 + +发布后的 URL 通常为: + +```text +https://.github.io//pr-walkthrough// +``` + +## 最终回复 + +报告: + +- 生成的 walkthrough 路径。 +- `file://` URL。 +- 使用的 base branch、PR title 或 branch name。 +- 用于 diff links 的 GitHub PR URL。 +- 是否找到并纳入 PR review comments。 +- D3 canvas validation 是否通过。 +- 若发布,报告 GitHub Pages URL。 +- 重要 caveats、缺失 specs 或无法完成的验证。 diff --git a/.agents/skills/pr-walkthrough/scripts/d3_canvas_runtime.py b/.agents/skills/pr-walkthrough/scripts/d3_canvas_runtime.py new file mode 100644 index 0000000..827e978 --- /dev/null +++ b/.agents/skills/pr-walkthrough/scripts/d3_canvas_runtime.py @@ -0,0 +1,559 @@ +#!/usr/bin/env python3 +"""Reusable D3 multi-graph tour helpers for the pr-walkthrough skill.""" + +from __future__ import annotations + +import argparse +import html +import json +from pathlib import Path +from textwrap import dedent +from urllib.parse import urlsplit + +D3_VERSION = "7.9.0" +D3_CDN_URL = f"https://cdn.jsdelivr.net/npm/d3@{D3_VERSION}/dist/d3.min.js" +SAFE_LINK_SCHEMES = {"http", "https", "file"} + + +def safe_href(value: object) -> str: + raw = str(value or "").strip() + if not raw: + return "#" + parsed = urlsplit(raw) + if parsed.scheme and parsed.scheme.lower() not in SAFE_LINK_SCHEMES: + return "#" + if parsed.netloc and not parsed.scheme: + return "#" + return raw + + +def d3_canvas_css() -> str: + return dedent( + """ + :root { + --walkthrough-bg: #f6f7f9; + --walkthrough-panel: #ffffff; + --walkthrough-panel-2: #eef1f5; + --walkthrough-border: #d7dce2; + --walkthrough-text: #17202a; + --walkthrough-muted: #52606d; + --walkthrough-dim: #7b8794; + --walkthrough-accent: #2563eb; + --walkthrough-green: #0f7b5f; + --walkthrough-blue: #2563eb; + --walkthrough-purple: #7c3aed; + --walkthrough-yellow: #b7791f; + --walkthrough-font-sans: Inter, ui-sans-serif, system-ui, sans-serif; + --walkthrough-font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', ui-monospace, monospace; + } + * { box-sizing: border-box; } + body { margin: 0; min-height: 100vh; background: var(--walkthrough-bg); color: var(--walkthrough-text); font-family: var(--walkthrough-font-sans); } + a { color: var(--walkthrough-text); text-decoration-color: var(--walkthrough-accent); text-underline-offset: 3px; } + button, input { font: inherit; } + .d3-walkthrough-shell { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; } + .d3-walkthrough-header { display: grid; gap: 10px; padding: 28px 32px 20px; border-bottom: 1px solid var(--walkthrough-border); background: #ffffff; } + .d3-kicker { color: var(--walkthrough-accent); font-family: var(--walkthrough-font-mono); font-size: 12px; letter-spacing: 0; text-transform: uppercase; } + .d3-walkthrough-header h1 { margin: 0; max-width: 1080px; font-size: 42px; line-height: 1.05; letter-spacing: 0; } + .d3-meta-row { display: flex; flex-wrap: wrap; gap: 8px; color: var(--walkthrough-muted); font-family: var(--walkthrough-font-mono); font-size: 12px; } + .d3-summary { max-width: 920px; margin: 0; color: var(--walkthrough-muted); font-size: 17px; line-height: 1.45; } + .d3-canvas-layout { min-height: 0; display: grid; grid-template-columns: 310px minmax(560px, 1fr) 390px; gap: 0; } + .d3-control-panel, .d3-detail-panel { min-height: 0; overflow: auto; background: var(--walkthrough-panel); border-right: 1px solid var(--walkthrough-border); padding: 18px; } + .d3-detail-panel { border-right: 0; border-left: 1px solid var(--walkthrough-border); } + .d3-panel-title { margin: 0 0 12px; font-size: 12px; color: var(--walkthrough-muted); font-family: var(--walkthrough-font-mono); letter-spacing: 0; text-transform: uppercase; } + .d3-control-stack { display: grid; gap: 10px; margin-bottom: 18px; } + .d3-control-button, .d3-graph-toggle { border: 1px solid var(--walkthrough-border); background: var(--walkthrough-panel-2); color: var(--walkthrough-text); border-radius: 10px; padding: 10px 12px; cursor: pointer; text-align: left; } + .d3-control-button:hover, .d3-graph-toggle:hover, .d3-control-button:focus, .d3-graph-toggle:focus { border-color: var(--walkthrough-accent); outline: none; } + .d3-graph-toggle[aria-pressed="true"] { border-color: var(--graph-color, var(--walkthrough-accent)); box-shadow: inset 3px 0 0 var(--graph-color, var(--walkthrough-accent)); } + .d3-tour-card { border: 1px solid var(--walkthrough-border); background: #ffffff; border-radius: 8px; padding: 12px; margin-bottom: 14px; } + .d3-tour-step-label { color: var(--walkthrough-accent); font-family: var(--walkthrough-font-mono); font-size: 11px; letter-spacing: 0; text-transform: uppercase; } + .d3-tour-title { margin: 6px 0; font-size: 18px; line-height: 1.15; } + .d3-tour-body { color: var(--walkthrough-muted); line-height: 1.4; margin: 0; } + .d3-search { width: 100%; border: 1px solid var(--walkthrough-border); background: #ffffff; color: var(--walkthrough-text); border-radius: 8px; padding: 10px 12px; } + .d3-search:focus { border-color: var(--walkthrough-accent); outline: none; } + .d3-help { color: var(--walkthrough-dim); font-family: var(--walkthrough-font-mono); font-size: 11px; line-height: 1.5; } + .d3-canvas-stage { min-height: 0; position: relative; overflow: hidden; background: #f8fafc; } + #pr-walkthrough-canvas { width: 100%; height: 100%; min-height: 700px; display: block; } + .d3-canvas-error { position: absolute; inset: 18px; display: none; place-items: center; border: 1px solid var(--walkthrough-border); background: #fffffff2; color: var(--walkthrough-text); padding: 24px; z-index: 2; } + body.d3-canvas-error .d3-canvas-error { display: grid; } + .d3-graph-title { fill: #17202a; opacity: 0.42; font-family: var(--walkthrough-font-mono); font-size: 13px; letter-spacing: 0; text-transform: uppercase; } + .d3-edge path { fill: none; stroke: var(--edge-color, #868584); stroke-width: 2; stroke-opacity: 0.68; } + .d3-edge-arrow path { fill: var(--edge-color, #868584); } + .d3-edge text { fill: #3e4c59; font-family: var(--walkthrough-font-mono); font-size: 11px; paint-order: stroke; stroke: #f8fafc; stroke-width: 4px; stroke-linejoin: round; } + .d3-node { cursor: pointer; } + .d3-node rect { fill: #ffffff; stroke: var(--node-color, var(--walkthrough-accent)); stroke-width: 2; filter: drop-shadow(0 10px 24px #0000001f); } + .d3-node.is-selected rect { stroke: var(--walkthrough-accent); stroke-width: 4; } + .d3-node.is-tour-node rect { stroke: var(--walkthrough-accent); stroke-width: 4; filter: drop-shadow(0 0 16px #2563eb66); } + .d3-node.is-dimmed, .d3-edge.is-dimmed { opacity: 0.18; } + .d3-node-title { fill: #17202a; font-family: var(--walkthrough-font-sans); font-size: 15px; font-weight: 700; pointer-events: none; } + .d3-node-kind { fill: #52606d; font-family: var(--walkthrough-font-mono); font-size: 10px; letter-spacing: 0; text-transform: uppercase; pointer-events: none; } + .d3-node-summary { fill: #3e4c59; font-family: var(--walkthrough-font-sans); font-size: 12px; pointer-events: none; } + .d3-detail-title { margin: 0 0 6px; font-size: 24px; line-height: 1.1; } + .d3-detail-kind { color: var(--walkthrough-accent); font-family: var(--walkthrough-font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; } + .d3-detail-summary { color: var(--walkthrough-muted); line-height: 1.45; } + .d3-detail-section { margin-top: 18px; } + .d3-detail-section h3 { margin: 0 0 8px; color: var(--walkthrough-muted); font-family: var(--walkthrough-font-mono); font-size: 12px; letter-spacing: 0; text-transform: uppercase; } + .d3-detail-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } + .d3-detail-list li { border: 1px solid var(--walkthrough-border); background: #ffffff; border-radius: 8px; padding: 10px; color: var(--walkthrough-muted); line-height: 1.35; } + .d3-file-link { display: block; overflow-wrap: anywhere; color: var(--walkthrough-text); font-family: var(--walkthrough-font-mono); font-size: 12px; } + .d3-comment-author { display: block; color: var(--walkthrough-accent); font-family: var(--walkthrough-font-mono); font-size: 11px; margin-bottom: 4px; } + .d3-empty { color: var(--walkthrough-dim); } + @media (max-width: 1180px) { .d3-canvas-layout { grid-template-columns: 1fr; grid-template-rows: auto minmax(680px, 1fr) auto; } .d3-control-panel, .d3-detail-panel { border: 0; border-bottom: 1px solid var(--walkthrough-border); max-height: 360px; } } + """ + ).strip() + + +def d3_canvas_runtime_script() -> str: + return dedent( + f""" + + """ + ).strip() + + +def graph_controls_markup(data: dict) -> str: + buttons = "\n".join( + f'' + for graph in data.get("graphs", []) + ) + return dedent( + f""" + + """ + ).strip() + + +def html_template(data: dict) -> str: + meta = data.get("meta") or {} + title = str(meta.get("title") or "PR Walkthrough") + summary = str(meta.get("summary") or "Interactive PR walkthrough graphs.") + pr_url = str(meta.get("prUrl") or "") + base = str(meta.get("baseRef") or "") + head = str(meta.get("headRef") or "") + data_json = json.dumps(data, ensure_ascii=False).replace(" + + + + + {html.escape(title)} + + + +
+
+
AICodingFlow PR walkthrough
+

{html.escape(title)}

+
{html.escape(base)} ← {html.escape(head)}{f'Open PR' if pr_url else ''}
+

{html.escape(summary)}

+
+
+ {graph_controls_markup(data)} +
+ + +
+ +
+ + + {d3_canvas_runtime_script()} +
+ + + """ + ).strip() + + +def sample_data() -> dict: + return { + "meta": {"title": "Sample PR D3 walkthrough", "prUrl": "", "baseRef": "master", "headRef": "feature", "summary": "Replace this sample graph with PR-specific guided graph tours."}, + "graphs": [ + { + "id": "system-overview", "label": "System overview", "color": "#b7791f", "summary": "Major touched components.", + "nodes": [ + {"id": "surface", "title": "User-facing surface", "kind": "overview card", "x": -220, "y": -80, "width": 360, "height": 220, "summaryLines": 7, "summary": "Use a full paragraph here to define the surface, what code owns it, and why that concept matters for understanding the subsystem. Keep this scoped to stable architecture orientation.", "details": ["Explain the stable component."], "files": [], "comments": [], "links": []}, + {"id": "component", "title": "State or action owner", "kind": "overview card", "x": 220, "y": -80, "width": 360, "height": 220, "summaryLines": 7, "summary": "Use another full paragraph for the next essential concept. If a concept is not needed to understand the subsystem, leave it out of the system overview.", "details": ["Explain what this component owns."], "files": [], "comments": [], "links": []}, + ], + "edges": [], + "tour": [{"nodeId": "surface", "title": "Start with the surface", "body": "The system overview starts with the smallest useful orientation concept."}, {"nodeId": "component", "title": "Name the owner", "body": "Then identify the state or action owner for the subsystem."}], + }, + { + "id": "data-flow", "label": "Data flow graph", "color": "#0f7b5f", "summary": "How state moves.", + "nodes": [ + {"id": "intent", "title": "Intent", "kind": "input", "x": -260, "y": -80, "summary": "Spec intent enters the system.", "details": ["Start with the PR intent."], "files": [], "comments": [], "links": []}, + {"id": "state", "title": "State", "kind": "owner", "x": 80, "y": 20, "summary": "State owner carries the change.", "details": ["Explain the data owner."], "files": [], "comments": [], "links": []}, + ], + "edges": [{"source": "intent", "target": "state", "label": "flows into"}], + "tour": [{"nodeId": "intent", "title": "Start with intent", "body": "The data-flow graph begins with the product intent."}, {"nodeId": "state", "title": "Follow state", "body": "Then inspect where state is owned."}], + }, + { + "id": "code-dependency", "label": "Code dependency graph", "color": "#2563eb", "summary": "How code depends.", + "nodes": [ + {"id": "entry", "title": "Entry point", "kind": "entry", "x": -220, "y": -60, "summary": "Changed entry point.", "details": ["Start at the high-level code seam."], "files": [], "comments": [], "links": []}, + {"id": "leaf", "title": "Leaf dependency", "kind": "leaf", "x": 160, "y": 70, "summary": "Lower-level dependency.", "details": ["Inspect the dependency."], "files": [], "comments": [], "links": []}, + ], + "edges": [{"source": "entry", "target": "leaf", "label": "depends on"}], + "tour": [{"nodeId": "entry", "title": "Start at entry", "body": "Begin with the high-level code seam."}, {"nodeId": "leaf", "title": "Drill down", "body": "Then move to the leaf dependency."}], + }, + { + "id": "user-action", "label": "User action graph", "color": "#7c3aed", "summary": "How the user moves.", + "nodes": [ + {"id": "surface", "title": "Surface", "kind": "user", "x": -240, "y": -70, "summary": "Where the user starts.", "details": ["Explain the user-facing surface."], "files": [], "comments": [], "links": []}, + {"id": "feedback", "title": "Feedback", "kind": "result", "x": 160, "y": 70, "summary": "What the user sees.", "details": ["Explain the visible result."], "files": [], "comments": [], "links": []}, + ], + "edges": [{"source": "surface", "target": "feedback", "label": "user sees"}], + "tour": [{"nodeId": "surface", "title": "Start at surface", "body": "Begin where the user acts."}, {"nodeId": "feedback", "title": "End at feedback", "body": "End with what the user sees."}], + }, + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Emit reusable D3 PR walkthrough graph tour snippets.") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--css", action="store_true", help="Print pr-walkthrough D3 graph CSS.") + group.add_argument("--runtime", action="store_true", help="Print pinned-CDN D3 runtime and graph renderer.") + group.add_argument("--template", action="store_true", help="Print a complete HTML template from graph JSON.") + group.add_argument("--sample-data", action="store_true", help="Print sample graph JSON.") + parser.add_argument("--data", type=Path, help="Graph JSON file for --template. If omitted, sample data is used.") + args = parser.parse_args() + if args.css: + print(d3_canvas_css()) + elif args.runtime: + print(d3_canvas_runtime_script()) + elif args.sample_data: + print(json.dumps(sample_data(), indent=2)) + elif args.template: + data = json.loads(args.data.read_text()) if args.data else sample_data() + print(html_template(data)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/pr-walkthrough/scripts/validate_d3_canvas.py b/.agents/skills/pr-walkthrough/scripts/validate_d3_canvas.py new file mode 100644 index 0000000..c3371c1 --- /dev/null +++ b/.agents/skills/pr-walkthrough/scripts/validate_d3_canvas.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Validate a generated pr-walkthrough multi-graph tour HTML canvas.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +from html.parser import HTMLParser +from pathlib import Path + +REQUIRED_GRAPHS = {"system-overview", "data-flow", "code-dependency", "user-action"} +DIRECTED_EDGE_GRAPHS = {"data-flow", "code-dependency", "user-action"} +REQUIRED_CONTROLS = ( + "Fit to view", + "Reset zoom", + "System overview", + "Data flow graph", + "Code dependency graph", + "User action graph", + "Previous tour step", + "Next tour step", + "Restart tour", +) +OVERVIEW_FORBIDDEN_TEXT = re.compile( + r"\b(pr|pull request|diff|review|reviewer|comment|comments|spec|specs)\b|changed files|files changed", + re.I, +) + + +def iter_text_values(value: object) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + parts: list[str] = [] + for item in value: + parts.extend(iter_text_values(item)) + return parts + if isinstance(value, dict): + parts = [] + for item in value.values(): + parts.extend(iter_text_values(item)) + return parts + return [] + + +def validate_system_overview(graph: dict) -> list[str]: + errors: list[str] = [] + for key in ("files", "comments", "links"): + if graph.get(key): + errors.append(f"Graph system-overview must not include PR attachment field `{key}`") + for node in graph.get("nodes", []): + node_id = node.get("id") + for key in ("files", "comments", "links"): + if node.get(key): + errors.append(f"Graph system-overview node {node_id} must not include PR attachment field `{key}`") + searchable = " ".join(iter_text_values(node)) + if OVERVIEW_FORBIDDEN_TEXT.search(searchable): + errors.append(f"Graph system-overview node {node_id} contains PR-specific wording") + overview_text = " ".join( + str(graph.get(key, "")) + for key in ("label", "summary") + ) + overview_text += " " + " ".join(iter_text_values(graph.get("tour", []))) + if OVERVIEW_FORBIDDEN_TEXT.search(overview_text): + errors.append("Graph system-overview contains PR-specific wording") + return errors + + +class DataScriptExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.capture = False + self.parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "script" and dict(attrs).get("id") == "pr-walkthrough-data": + self.capture = True + self.parts = [] + + def handle_endtag(self, tag: str) -> None: + if tag == "script" and self.capture: + self.capture = False + + def handle_data(self, data: str) -> None: + if self.capture: + self.parts.append(data) + + +def extract_graph_data(html_text: str) -> dict: + parser = DataScriptExtractor() + parser.feed(html_text) + if parser.parts: + raw = "".join(parser.parts) + try: + return json.loads(raw) + except json.JSONDecodeError: + return json.loads(html.unescape(raw)) + match = re.search(r"window\.PR_WALKTHROUGH_D3_DATA\s*=\s*(\{.*?\});", html_text, re.S) + if not match: + raise ValueError("Missing inline D3 graph data") + return json.loads(match.group(1)) + + +def static_validate(html_text: str, data: dict) -> list[str]: + errors: list[str] = [] + lower = html_text.lower() + if "d3@7.9.0/dist/d3.min.js" not in html_text: + errors.append("HTML does not reference the pinned D3 7.9.0 CDN URL") + if "d3@latest" in lower or "/d3/latest" in lower: + errors.append("HTML uses an unpinned D3 `latest` runtime") + if "fetch(" in html_text: + errors.append("HTML uses fetch(); inline graph data is required for file:// usage") + if 'id="pr-walkthrough-canvas"' not in html_text: + errors.append("Missing #pr-walkthrough-canvas SVG") + if "marker-end" not in html_text or "d3-arrowhead" not in html_text: + errors.append("HTML does not include visible directed edge arrowhead rendering") + for label in REQUIRED_CONTROLS: + if label not in html_text: + errors.append(f"Missing required control label: {label}") + + graphs = data.get("graphs", []) + graph_ids = {graph.get("id") for graph in graphs} + missing = REQUIRED_GRAPHS - graph_ids + if missing: + errors.append(f"Missing required graphs: {', '.join(sorted(missing))}") + extra = graph_ids - REQUIRED_GRAPHS + if extra: + errors.append(f"Graph data includes unexpected graph ids: {', '.join(sorted(str(item) for item in extra))}") + if graph_ids != REQUIRED_GRAPHS: + errors.append("Graph data must include exactly system-overview, data-flow, code-dependency, and user-action") + + for graph in graphs: + graph_id = graph.get("id") + nodes = graph.get("nodes", []) + edges = graph.get("edges", []) + tour = graph.get("tour", []) + if not graph.get("label"): + errors.append(f"Graph {graph_id} missing label") + if not nodes: + errors.append(f"Graph {graph_id} has no nodes") + if graph_id in DIRECTED_EDGE_GRAPHS and not edges: + errors.append(f"Graph {graph_id} has no edges") + if not tour: + errors.append(f"Graph {graph_id} has no guided tour") + if graph_id == "system-overview": + errors.extend(validate_system_overview(graph)) + node_ids = {node.get("id") for node in nodes} + for node in nodes: + if not node.get("id"): + errors.append(f"Graph {graph_id} has node missing id") + if not node.get("title"): + errors.append(f"Graph {graph_id} node {node.get('id')} missing title") + if not node.get("summary") and not node.get("details"): + errors.append(f"Graph {graph_id} node {node.get('id')} missing explanatory text") + for edge in edges: + if not edge.get("label"): + errors.append(f"Graph {graph_id} edge {edge.get('id') or edge.get('source')} missing directional label") + if edge.get("source") not in node_ids: + errors.append(f"Graph {graph_id} edge references unknown source: {edge.get('source')}") + if edge.get("target") not in node_ids: + errors.append(f"Graph {graph_id} edge references unknown target: {edge.get('target')}") + for index, step in enumerate(tour): + if step.get("nodeId") not in node_ids: + errors.append(f"Graph {graph_id} tour step {index + 1} references unknown node: {step.get('nodeId')}") + if not step.get("title") or not step.get("body"): + errors.append(f"Graph {graph_id} tour step {index + 1} missing title/body") + return errors + + +def browser_validate(html_path: Path, timeout_ms: int) -> tuple[bool, str]: + try: + from playwright.sync_api import sync_playwright + except Exception as exc: + return False, f"Playwright is unavailable: {exc}" + + url = html_path.resolve().as_uri() + with sync_playwright() as playwright: + browser = None + launch_errors: list[str] = [] + for label, kwargs in (("bundled Chromium", {}), ("system Chrome", {"channel": "chrome"}), ("system Chromium", {"channel": "chromium"})): + try: + browser = playwright.chromium.launch(**kwargs) + break + except Exception as exc: + launch_errors.append(f"{label}: {exc}") + if browser is None: + return False, "Unable to launch a Playwright browser. " + " | ".join(launch_errors) + try: + page = browser.new_page(viewport={"width": 1440, "height": 960}) + page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms) + page.wait_for_function(""" + () => document.body.classList.contains('d3-canvas-ready') || + document.body.classList.contains('d3-canvas-error') + """, timeout=timeout_ms) + initial = page.evaluate(""" + () => ({ + ready: document.body.classList.contains('d3-canvas-ready'), + error: document.body.classList.contains('d3-canvas-error'), + nodes: document.querySelectorAll('.d3-node').length, + edges: document.querySelectorAll('.d3-edge').length, + arrows: document.querySelectorAll('.d3-edge path[marker-end]').length, + detailHasContent: Boolean(document.querySelector('#pr-walkthrough-details')?.textContent?.trim()), + tourText: document.querySelector('.d3-tour-step-label')?.textContent || '', + controls: Array.from(document.querySelectorAll('button')).map((button) => button.textContent.trim()), + }) + """) + graph_results = [] + for graph_id in ["system-overview", "data-flow", "code-dependency", "user-action"]: + page.click(f'[data-graph-id="{graph_id}"]') + page.wait_for_timeout(150) + before = page.text_content('.d3-tour-step-label') or '' + page.click('[data-d3-action="tour-next"]') + page.wait_for_timeout(120) + after = page.text_content('.d3-tour-step-label') or '' + graph_results.append(page.evaluate(""" + (args) => ({ + graphId: args.graphId, + before: args.before, + after: args.after, + nodes: document.querySelectorAll('.d3-node').length, + edges: document.querySelectorAll('.d3-edge').length, + arrows: document.querySelectorAll('.d3-edge path[marker-end]').length, + selected: document.querySelectorAll('.d3-node.is-tour-node').length, + pressed: document.querySelector(`[data-graph-id="${args.graphId}"]`)?.getAttribute('aria-pressed'), + }) + """, {"graphId": graph_id, "before": before, "after": after}) ) + except Exception as exc: + return False, f"browser validation failed while loading or inspecting the page: {exc}" + finally: + browser.close() + + if initial["error"] or not initial["ready"]: + return False, "D3 canvas reported an error state" + if initial["nodes"] == 0: + return False, "Initial graph did not render nodes" + if not initial["detailHasContent"]: + return False, "Detail panel did not render content" + missing_controls = [label for label in REQUIRED_CONTROLS if label not in initial["controls"]] + if missing_controls: + return False, f"Missing browser-visible controls: {', '.join(missing_controls)}" + for result in graph_results: + if result["nodes"] == 0: + return False, f"Graph {result['graphId']} did not render nodes" + if result["graphId"] in DIRECTED_EDGE_GRAPHS and result["edges"] == 0: + return False, f"Graph {result['graphId']} did not render directed edges" + if result["graphId"] in DIRECTED_EDGE_GRAPHS and result["arrows"] != result["edges"]: + return False, f"Graph {result['graphId']} did not render an arrowhead for every edge" + if result["selected"] == 0: + return False, f"Graph {result['graphId']} did not mark a tour node" + if result["pressed"] != "true": + return False, f"Graph {result['graphId']} toggle did not become active" + return True, "browser rendered all 4 graphs, directed arrows, and tour controls successfully" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate a pr-walkthrough generated HTML file.") + parser.add_argument("--html", required=True, type=Path, help="Path to .aicodingflow/pr-walkthrough/index.html") + parser.add_argument("--require-browser", action="store_true", help="Fail if browser validation cannot be performed.") + parser.add_argument("--timeout-ms", type=int, default=15000, help="Browser validation timeout.") + args = parser.parse_args() + + html_text = args.html.read_text() + data = extract_graph_data(html_text) + errors = static_validate(html_text, data) + if errors: + for error in errors: + print(f"FAIL - {error}") + return 1 + graph_count = len(data.get("graphs", [])) + node_count = sum(len(graph.get("nodes", [])) for graph in data.get("graphs", [])) + edge_count = sum(len(graph.get("edges", [])) for graph in data.get("graphs", [])) + print(f"Static validation passed: {graph_count} graph(s), {node_count} node(s), {edge_count} edge(s).") + ok, message = browser_validate(args.html, args.timeout_ms) + if ok: + print(f"PASS - {message}") + return 0 + prefix = "FAIL" if args.require_browser else "WARN" + print(f"{prefix} - {message}") + return 1 if args.require_browser else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/aicodingflow-tests/test_pr_walkthrough_scripts.py b/.github/aicodingflow-tests/test_pr_walkthrough_scripts.py new file mode 100644 index 0000000..f2ca359 --- /dev/null +++ b/.github/aicodingflow-tests/test_pr_walkthrough_scripts.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import unittest + +from script_imports import import_script + + +d3_runtime = import_script( + ".agents/skills/pr-walkthrough/scripts/d3_canvas_runtime.py", + "pr_walkthrough_d3_canvas_runtime", +) +validator = import_script( + ".agents/skills/pr-walkthrough/scripts/validate_d3_canvas.py", + "pr_walkthrough_validate_d3_canvas", +) + + +class PrWalkthroughScriptsTest(unittest.TestCase): + def test_safe_href_allows_only_explicit_safe_schemes_or_relative_paths(self) -> None: + self.assertEqual(d3_runtime.safe_href("https://example.test/path"), "https://example.test/path") + self.assertEqual(d3_runtime.safe_href("http://example.test/path"), "http://example.test/path") + self.assertEqual(d3_runtime.safe_href("file:///tmp/pr-walkthrough/index.html"), "file:///tmp/pr-walkthrough/index.html") + self.assertEqual(d3_runtime.safe_href("../relative/path.html"), "../relative/path.html") + self.assertEqual(d3_runtime.safe_href("#section"), "#section") + self.assertEqual(d3_runtime.safe_href("javascript:alert(1)"), "#") + self.assertEqual(d3_runtime.safe_href("java\nscript:alert(1)"), "#") + self.assertEqual(d3_runtime.safe_href("data:text/html,alert(1)"), "#") + self.assertEqual(d3_runtime.safe_href("//example.test/path"), "#") + + def test_runtime_sanitizes_graph_data_urls_before_rendering_hrefs(self) -> None: + runtime_script = d3_runtime.d3_canvas_runtime_script() + + self.assertIn("function safeHref(value)", runtime_script) + self.assertIn("escapeHtml(safeHref(file.url))", runtime_script) + self.assertIn("escapeHtml(safeHref(comment.url))", runtime_script) + self.assertIn("escapeHtml(safeHref(link.url))", runtime_script) + + def test_static_validate_rejects_pr_specific_system_overview_attachments(self) -> None: + data = d3_runtime.sample_data() + overview = next(graph for graph in data["graphs"] if graph["id"] == "system-overview") + overview["nodes"][0]["files"] = [{"path": "changed.py", "url": "https://example.test/files"}] + + errors = validator.static_validate(d3_runtime.html_template(data), data) + + self.assertIn("Graph system-overview node surface must not include PR attachment field `files`", errors) + + def test_static_validate_rejects_pr_specific_system_overview_text(self) -> None: + data = d3_runtime.sample_data() + overview = next(graph for graph in data["graphs"] if graph["id"] == "system-overview") + overview["nodes"][0]["summary"] = "This overview explains the PR diff for reviewers." + + errors = validator.static_validate(d3_runtime.html_template(data), data) + + self.assertIn("Graph system-overview node surface contains PR-specific wording", errors) + + def test_static_validate_accepts_sample_overview_without_pr_attachments(self) -> None: + data = d3_runtime.sample_data() + + errors = validator.static_validate(d3_runtime.html_template(data), data) + + self.assertEqual(errors, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitignore b/.gitignore index 4ad5343..f7a4696 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ __pycache__/ .coverage htmlcov/ +.aicodingflow/ + /.worktrees/