feature/review-html-tests-diagram — 9 commits ahead of merge base 9da40cf, reviewed with the edited pre-push-review skill and the new renderer package on this branch.
build_review_html.py is a 100-line entry point; the page logic lives in scripts/review_html/ with a golden fixture pinning the old output.blast_radius.py derives a one-hop import graph from two git trees using ecosystems.json; diagram.py projects and lays it out as inline SVG.Ready to push once the review fixes are committed
All 226 tests pass on the working tree. The review raised 16 findings and fixed 11 in place, including three correctness bugs (hunk line numbering, JSON-shaped secrets, quadratic coverage matching) and two defects in the new design that only showed up by running it on this branch: the skill's two-dot diff against a base the branch is behind, and a test-file fallback that flagged every spec document. The 5 skipped items are scaling and cosmetic. The fixes are uncommitted; commit them before pushing.
af91875 [doc]: changelog for phase 4 21c51f7 [feat]: Add ecosystem runners and wire test results and blast radius into the review skills fefed9f [doc]: changelog for phase 3 d46ef12 [feat]: Add JUnit, coverage, redaction, and the Tests section to the review renderer 380df63 [doc]: changelog for phase 2 6db2bed [feat]: Blast-radius diagram — projection, SVG layout, and blast_radius.py 4abdc2d [doc]: changelog for phase 1 aa085b0 [feat]: Split the review renderer into a package with a test harness a281da1 [doc]: Spec review-html-tests-diagram — test results and blast-radius diagram for HTML reviews working-tree Fixes applied in this review (uncommitted) The review pages that three skills produce (pr-review-html, pr-overview, pre-push-review) used to say what changed and what the reviewers thought of it, but nothing about whether the tests pass or how far the change reaches. This branch adds two things to those pages.
The first is a Tests card and section. Think of a school report card: the top shows a headline (pass rate, new tests, coverage of the new lines) and the section below shows the detail (which run produced the numbers, which jobs ran, which tests failed and why, which tests are new, and a per-file table of how many added lines the tests executed). Inside each file's diff, added lines the tests never ran get a red mark in the margin.
The second is a blast-radius diagram with three columns. The middle holds the changed files; the left holds files that import them (what could break); the right holds files they import (what they lean on). Boxes are grouped by package or directory, and each changed box links to its diff.
To make this work, the 900-line scripts/build_review_html.py became a thin front door over a scripts/review_html/ package, a new scripts/blast_radius.py reads git trees to find imports, scripts/ecosystems.json tells the script and the skills how each language works, and a make test harness of 226 tests checks it.
A reviewer no longer has to open CI in another tab to learn the suite is red, or guess whether a one-line change in a shared helper touches five files or fifty. The numbers come from the exact tree the diffs describe, so a coverage mark on line 42 refers to the line 42 the reader is looking at.
go test.Nine commits in four phases, plus uncommitted review fixes.
Phase 1 (aa085b0) splits the renderer into review_html/ (common.py, sections.py, css.py, template.py, render.py, inputs.py, warnings.py, diffs.py) and adds the harness. scripts/tests/fixtures/golden.html was generated by the renderer at 9da40cf and the golden test asserts byte equality after blanking the <style> block and the timestamp (Q13, Q31). inputs.read_guarded is the only file reader: 50 MB cap by stat, DOCTYPE rejection for XML, UTF-8 check, each refusal a named warning (Q20, Q65).
Phase 2 (6db2bed) adds diagram.py (project → layout → render_diagram), blast_radius.py, and the script-read half of ecosystems.json. Projection applies test exclusion, expansion collapse above 3, and the 15-node cap in that order (Q28); layout uses fixed 308 px columns with label budgets 37 and 30 characters at a 7.2 px advance (Q36, Q56), every <text> declaring textLength (Q25).
Phase 3 (d46ef12) adds junit.py, coverage.py, redact.py, and tests_section.py, and wires the Tests card, section, uncovered marks, and two summary stderr lines into render().
Phase 4 (21c51f7) adds the runner rows to ecosystems.json (gotestsum, pytest, vitest, jest, swift test, cargo nextest via llvm-cov) with a schema test, and rewrites the three SKILL.md files: pinned head SHA, CI artifact collection with an ordered CI-state rule list (Q45), the worktree fallback for same-repo PRs, recipe selection by reading Makefile text (Q60), pre-run copies for restore (Q38), and the severity floor applied by rendering twice (Q40).
Uncommitted fixes from this review: diffs._walk no longer treats an added line beginning ++ as a file header (it tracks whether it is inside a hunk); the credential pattern in redact.py accepts a closing quote before =/: so JSON-shaped secrets are caught; coverage.match indexes entries by path and by last segment, turning an O(files × entries) scan into a lookup (11 s → 0.1 s at 20k entries); build_review_html.py exits 2 with one error: line on malformed review JSON; inputs.read_json and inputs.xml_root replace four copies of the same guard; pre-push-review diffs against git merge-base; and blast_radius.is_test_file's fallback rule became whole-token (Q85) after the substring rule flagged specs/ files as tests.
The renderer parses formats and the skills map ecosystems (Decision 2). Every new input is a file referenced by name from the review JSON and resolved against --diff-dir; the skill never transcribes a graph or a test list through its context (Q57). blast_radius.py reads trees through git ls-tree plus one git cat-file --batch process, the working tree, or the GitHub trees and blobs API (--remote, capped at 500 blob calls, Q47). Edges carry method, granularity, and tree so the page can say "edges at package granularity" (Decision 4, Q63). Deleted files and old rename paths are scanned in the base tree with old_to_new mapping so their edges land on the new node.
Coverage matching is five global passes (exact, pools with residuals, shared removal once, residual check, merge), so the result is order-independent and a doubtful match is reported as ambiguous rather than guessed (Q19, Q58, Q73).
pr-overview now executes same-repo branch code in a throwaway worktree, disclosed in its skill text; fork PRs get the no-data card (Decision 3).render() runs load fragments → build_tests → build_diagram → render_files(files, fragments, uncovered), because the uncovered sets come out of the Tests build. diffs._walk is one generator shared by added_lines and render_diff, so the line numbers used for coverage lookup are the ones the marks land on; the ++ fix relies on diff --git resetting next_new to None, which keeps a fragment's +++ b/x line a header.
junit._parse_one keys cases by (classname or suite name, name) per source file and lets the last element win; a pass after an earlier failure or any rerun/flaky child sets flaky (Q41, Q59). source is the file name, which _jobs_table joins to artifacts[].junit and then to jobs[].name via artifacts[].job; attribution itself (Q46) is done by the skill.
coverage.match pass 2 only consults entries whose last path segment equals the changed file's, since a whole-segment suffix relation requires equal basenames; that is the speed-up, and it preserves the five-pass semantics. overall() merges by normalised primary path only, not aliases.
diagram.project decides column by edge direction (a node with edges both ways is a dependent, its dependency edge drawn in the left gutter per 5.2), folds all_package per path as vacuously true, and gives collapsed and +N more nodes ids that are digest of the newline-joined member list so hover rules and the member list agree. +N more is a 16th box counting files, not nodes (Q67, Q68).
blast_radius._build scans changed files with essential=True so the remote cap never refuses them (Q72); --tools edges replace scanned pairs through add_edge(..., replace=True). Resolver._roots retries with the last segment dropped so from a.b import c resolves whether c is a module or a symbol (Q71).
~/.claude/scripts is a symlink into the repo, so the entry point puts Path(__file__).resolve().parent on sys.path before importing the package (Q33). read_guarded is the single trust boundary for every external input, including diagram.json. The tests block, diagram_file, and change_classification are additive top-level keys; the golden test guarantees pages without them are unchanged. Rows added to ecosystems.json extend both the script and the agent without code changes; test_ecosystems.py validates both key sets.
blast_radius.diff_test_names still drops lines starting +++/--- (the rule just corrected in diffs._walk), so an added test whose line starts ++ is missed; multi-line test_decl patterns (Rust #[test]\nfn) only match when both lines are in the same added or removed set._availability counts JUnit files that yielded at least one case, so a valid but empty JUnit file reads as "0 of 1 files read"._jobs_table attributes by job name only; two runs with a job of the same name merge into one row.overall() can double count a file that appears under two different primary paths across inputs (Cobertura with and without a source root).--tools runs go list -json ./... with shell=True in the checkout, which can trigger module downloads; it is only passed against trees where the suite already runs.scripts/review_html/coverage.py
Why it matters. This decides which coverage entry feeds each changed file's diff marks; a wrong match paints red lines on the wrong file. The uncommitted rewrite changed the inner loops, so the reviewer should confirm the five-pass semantics survived the indexing.
What to look at. match, scripts/review_html/coverage.py:187-268; _residual and _segments above it
scripts/review_html/diffs.py
Why it matters. One generator feeds both added_lines (coverage lookup) and render_diff (the marks), and the uncommitted fix changes how +++ and --- lines are classified. Any drift here misplaces every uncovered mark.
What to look at. _walk, scripts/review_html/diffs.py:48-90
scripts/review_html/diagram.py
Why it matters. These are the rules the requirements assign to the renderer rather than the skill (4.6 to 4.8), and their order matters: collapsing before excluding test files would collapse mixed groups.
What to look at. project, scripts/review_html/diagram.py:160-296
scripts/blast_radius.py
Why it matters. This is the only code that reads repository content, in three modes (working tree, SHA via cat-file --batch, GitHub API). The base-tree pass, the remote cap, and the --tools replacement are where a wrong edge or a missing column would originate.
What to look at. _build, scripts/blast_radius.py:645-790; Resolver, 396-491
scripts/review_html/tests_section.py
Why it matters. build_tests is where every JSON field of the tests block is interpreted and where the uncommitted Q83 change removed the per-file table when no coverage parsed. The reviewer should check the no-data fallbacks and the counts that feed the severity floor.
What to look at. build_tests, scripts/review_html/tests_section.py:340-414; _coverage_parts 261-325; _no_data_card 156-170
claude/skills/pr-overview/SKILL.md
Why it matters. A skill described as read-only now runs install scripts and tests on the reviewer's machine under stated conditions. The trust conditions, their order, and the cleanup on timeout are prose the harness cannot check.
What to look at. Phase 1b, Worktree fallback subsection; the disclosure paragraph under the read-only statement
Decision 1. The page stays self-contained and works offline and in feed readers; the layout is arithmetic over fixed 308 px columns, so wide changes are capped and collapsed rather than laid out in full. Graphviz needs a binary on the build machine; Mermaid adds the page's first external script.
Decision 2 and Q32. review_html reads JUnit, lcov, Cobertura, and Go coverprofile and knows no runner. scripts/ecosystems.json is one machine-readable file read by both blast_radius.py (import rules) and the agent (runner recipes), so a new language is a row, not code.
Decision 3. pr-review-html and pre-push-review take results from the one verification run they already perform, against working-tree diffs. pr-overview pins the head SHA (Q23), uses CI artifacts first, and falls back to a detached worktree in the job directory only for same-repo PRs with a local clone and a permitting CI state (Q45); fork PRs, in-progress runs, and no clone are recorded as blocked states (Q26).
Decision 4 and Q63. Every edge records its method (import, expansion, tool:<name>) and granularity; a column with package-granular edges says so, groups of more than 3 expansion-only nodes collapse, and a column with no derivation method shows the reason instead of rendering empty.
Q15 and Q40. The renderer prints summary coverage: and summary tests: as its last stderr lines (head JUnit only, Q76); the skill greps them, raises verdict tone and publish severity, and renders again. Verdict and publish metadata stay pass-through fields.
Q20 and Q65. inputs.read_guarded is the only file reader in the package; it refuses by stat size, scans the first 64 KB for <!DOCTYPE, and warns on non-UTF-8 or unreadable files. defusedxml is not in the standard library.
Q19, Q58, Q73, Q44. Exact match, whole-segment suffix pools with residuals, shared-entry removal once without cascading, residual check, merge. A file with two suffix candidates of different depth is ambiguous rather than resolved to the longer one. Cobertura source roots become aliases on one entry (Q42) so they cannot trip the ambiguity rule.
Q18, Q41, Q59. Within one file, elements sharing (suite, name) collapse to the last element's outcome, flaky when an earlier attempt failed; Surefire's flaky and rerun elements and pytest's rerun are one marker. Identities are never collapsed across files, since one CI job each is allowed.
Q57. diagram_file and diff_tests_file name files that blast_radius.py wrote under --diff-dir; transcribing a 70-node graph through the agent's context is the reproducibility failure Q32 exists to prevent.
Q33 and Q34. build_review_html.py keeps the command line and inserts Path(__file__).resolve().parent on sys.path; ~/.claude/scripts is a symlink into the repo, so the package syncs unchanged. A root Makefile with make test is the single documented harness command.
Q13, Q31, Q43. The old renderer's output is compared after blanking the <style> element and the timestamp; new template placeholders are appended to existing lines so an empty substitution leaves no extra blank line.
Q14, Q25, Q36, Q51, Q56. Three fixed 308 px columns plus two 56 px gutters fit the 1036 px content width; the centre budget reserves 4 characters for the ⚑N badge and a 24 px lane for centre-to-centre edges. Fills use var(--x, #literal) so the diagram survives with the stylesheet removed (Q62); modified nodes use --accent-2 because the badge colour equals the link colour (Q35).
Q22, Q38, Q39, Q60. Makefile targets are inspected as literal recipe lines, never via make -n, which evaluates $(shell …). Dirty tracked files are copied before the run and copied back after; git stash is never used because it would carry away the fixes being verified. One Bash call at 600,000 ms is the whole budget.
Q83 and Q84, added during this review. A table of all "no coverage data" rows repeats the availability line, and the skills grep one stderr line for the floor, so per-file unmatched reasons live only in the section. This is a documented deviation from the letter of requirements 2.8 and 3.6.
Q85, uncommitted. Without an ecosystem row a file is a test only by name shape (test_x, x_test, x.test.ts, x.spec.js, XTests.swift, conftest.py) or a parent named test, tests, __tests__, or spec; the substring rule had flagged every file under specs/.
Uncommitted skill fix, rationale stated in the skill text: a two-dot diff straight against origin/<branch> shows reverse changes when the branch is behind it, so BASE=$(git merge-base origin/<branch> HEAD).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | review_html/diffs.py hunk walk | An added line whose content starts with `++` (e.g. `++i;`) was classified as a `+++` file header inside a hunk, so it did not advance the new-file counter and every later added line in the hunk was off by one; coverage marks landed on the wrong lines. | Headers are recognised only before the first `@@` of a file section; a `diff --git` line resets the counter. Regression tests added. |
| major | review_html/redact.py | The credential-assignment pattern required `=` or `:` directly after the keyword, so JSON-shaped secrets such as `"api_key": "sk-…"` passed through unredacted. | Pattern accepts an optional quote before the separator; test and design text updated. |
| major | review_html/coverage.py match() | Pass 2 re-split every entry path for every changed file: 20,000 Cobertura entries against 100 changed files took 11 s when CI paths differ from repo paths by a prefix, defeating the 5 s budget. | Segment tuples precomputed once, entries indexed by path and by basename. Same input now 0.1 s; equivalence checked against the old module over 3,000 random seeds. |
| major | pre-push-review SKILL.md Phase 1 | The skill diffed `origin/<branch>..HEAD`. This branch is one commit behind origin/main, so two unrelated skill files appeared as modified in the review diff. | BASE is now the merge base; the skill diffs `$BASE..HEAD`. |
| major | blast_radius.py test-file fallback | Files with no ecosystem row were flagged as tests by a `test|spec` substring on the file or parent-directory name, so every file under `specs/` and `docs/testing.md` counted as a test and the four spec documents were listed as unpatterned test files. | Whole-token rule: test-looking file names or a parent directory named test, tests, __tests__, or spec. Recorded as Q85. |
| minor | build_review_html.py | A malformed review.json produced a 12-line traceback with exit 1; the design says one line and exit 2. | ValueError and OSError are caught, one `error:` line is printed, exit status 2; subprocess test added. |
| minor | tests_section.py coverage counts | With no coverage file, matching still ran and stderr reported `unmatched=N` although the section omits the coverage report. | Early return before matching; counts are 0/0 with no coverage data. Recorded as Q83. |
| minor | duplication inside review_html | Read-then-parse JSON and XML-root parsing were written twice each; `overall()` re-implemented `_merge`; `diagram.project` rescanned all edges per node; outcome names were hand-written tuples in three modules. | `inputs.read_json` and `inputs.xml_root` helpers; `overall` uses `_merge`; one-pass edge dictionaries and `content_top` stored on `Layout`; `junit.OUTCOMES` constant. |
| minor | blast_radius.py duplication | The `--tools` loop re-implemented `add_edge`'s filters, first-group extraction appeared twice, and `git ls-files` ran three times in working-tree mode. | `add_edge(replace=True)`, a shared `_groups` helper, and the tracked set kept on `WorkingTree`. |
| minor | scripts/README.md and docstring | The README still claimed a highlight.js CDN load and neither it nor the entry-point docstring listed the `tests`, `diagram_file`, and `change_classification` keys. | Claim removed; keys documented in both places. |
| minor | spec bookkeeping | Requirement 3.6 wants a per-file coverage table even without data, and the stale changelog clause and requirement 1.10 wording no longer matched the implementation (Q46). | Q83 and Q84 record the readings; changelog clause dropped; 1.10 references Q46. |
| minor | blast_radius.py scaling | One `git diff` process per changed test file, sequential `gh api` blob reads in `--remote` mode, and the `go list` cross product built before filtering to changed files. | Left as is: each bites only on very large changes or remote scans near the 500-call cap; worth a follow-up if those cases appear. |
| minor | render.py section wiring | Section ids are listed three times (sections dict, TOC labels, template keyword arguments), and `_build` in blast_radius.py takes eight positional parameters. | Left as is: restructuring the template contract risks the golden fixture for no behavioural gain. |
| minor | tests | No test distinguishes the line-weighted diff-coverage aggregate from a per-file mean; `test_pattern_count_and_order` asserts the redaction pattern count. | Left as is: the aggregate arithmetic is covered indirectly; the count test is cheap to update when a pattern is added. |
| nit | inputs.py DOCTYPE scan | Only the first 64 KB is scanned for `<!DOCTYPE` although the whole buffer is in memory. | Left as is: the design documents the 64 KB window and a DOCTYPE must precede the root element. |
| nit | sections.py link and list helpers | `tests_section._link` and `_list` duplicate hand-built anchors and lists in `sections.py` and `diagram.py`. | Left as is: `sections.py` is golden-tested and the gain is cosmetic. |
Source: local run at 2026-09-05T01:28:56+10:00 · snapshot af91875efbc1eb9bd4afe9ffa14072a46f4b5b79 (dirty working tree)
Baseline: none
Execution: passed · JUnit: none · Coverage: none · Baseline: absent
Coverage scope: every test in the repository
The test runner could not be detected.
Derived by declaration name, from the diff (no baseline run).
Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 9da40cf.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9a344a1..cad99d4 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2026-09-04]++### Added+- `scripts/ecosystems.json` runner rows: gotestsum, pytest, vitest, jest, `swift test`, and `cargo llvm-cov nextest`, each with a detection rule, required binaries, coverage format, install command, the JUnit flags a Makefile target is inspected for, and per-row notes on known holes; a schema test keeps the script-read and runner keys honest (review-html-tests-diagram, phase 4)+- Tests section for review pages: `review_html/junit.py` parses JUnit XML (nested suites, Surefire and pytest rerun elements collapsing to one flaky case per source), `coverage.py` parses lcov, Cobertura, and Go coverprofile, maps paths, and matches entries to changed files in five global passes that report `no candidate` or `ambiguous` rather than guess, and `redact.py` replaces bearer tokens, cloud keys, credential assignments, URLs with userinfo, and PEM blocks with `[redacted]` before failure messages are truncated. `tests_section.py` renders the Tests card and section (provenance, availability, totals, per-job or per-artifact rows, failed tests, new and removed tests, per-file diff coverage, overall coverage, unmatched report) and feeds uncovered added lines back into the per-file diffs. `render()` prints `summary coverage:` and `summary tests:` to stderr last, after every warning, for the skills' severity floor (review-html-tests-diagram, phase 3)+- Timing tests: a generated 10 MB lcov file and a 5,000-case JUnit file must each parse in under 5 seconds; skipped on loaded hosts+- Blast-radius diagram: `review_html/diagram.py` projects a `diagram.json` description into three columns (dependents, changed, dependencies), excludes test files while counting them per changed node, collapses package-granular groups over 3 and caps side columns at 15 plus a `+N more` node, and lays the result out as inline SVG with fixed-width boxes, `textLength` labels, hover-dimmed edges, and CSS-variable colours with literal fallbacks. `render()` picks it up from the top-level `diagram_file` key unless `change_classification` is `docs-only` (review-html-tests-diagram, phase 2)+- `scripts/blast_radius.py`: derives the one-hop import graph around a change from two trees (a commit SHA, the working tree, or a remote repository via the GitHub API), writes `diagram.json` and `diff-tests.json`, and optionally replaces scanned edges with `go list` output. Language rules live in `scripts/ecosystems.json` (Go, Python, TypeScript, Swift, Rust)+- Test harness for the review renderer: `make test` runs `scripts/tests/` via unittest. A golden fixture (`fixtures/golden.json` and the page the renderer at `9da40cf` produced from it) pins the existing output; unit tests cover guarded reads, the warning collector, hunk arithmetic, binary detection, fragment loading, and uncovered-line marks (review-html-tests-diagram, phase 1)+- `review_html/inputs.py` and `warnings.py`: `read_guarded` refuses inputs over 50 MB, non-UTF-8 content, and XML carrying a DOCTYPE, and warns to stderr through a `Warnings` collector+- `review_html/diffs.py`: `load_fragments`, `added_lines`, `is_binary`, and `render_diff` with an optional set of uncovered new-file line numbers; a `.diff-uncovered` rule marks those lines with a red left border and gutter marker++### Changed+- `pr-review-html`, `pr-overview`, and `pre-push-review` skills: every input now lives in `$CLAUDE_JOB_DIR/review-inputs` (or a `mktemp` directory) and is passed with `--diff-dir`; `pr-review-html` and `pre-push-review` choose a test recipe by reading Makefile text, CLAUDE.md, or the ecosystem row, run it once with a restore procedure for files the run touches, and populate the `tests`, `diagram_file`, and `change_classification` blocks; `pr-overview` collects JUnit and coverage artifacts from GitHub Actions with ordered CI-state rules, falls back to a detached worktree run only for same-repo PRs with a local clone (stated in its read-only disclosure), and looks up a baseline run on the base branch; all three apply a severity floor from the renderer's `summary tests:` stderr line, drop the highlight.js claim, and name the `review_html` package when explaining where to edit the renderer+- `scripts/build_review_html.py` is now a thin entry point over the `scripts/review_html/` package (`common`, `sections`, `css`, `template`, `render`); the command line and output are unchanged, and the golden test proves it. A non-UTF-8 diff fragment now renders a placeholder instead of crashing the run+ ## [2026-07-27] ### Removed
diff --git a/Makefile b/Makefilenew file mode 100644index 0000000..2962629--- /dev/null+++ b/Makefile@@ -0,0 +1,4 @@+.PHONY: test++test:+ cd scripts && python3 -m unittest discover -s tests -t .
diff --git a/claude/skills/pr-overview/SKILL.md b/claude/skills/pr-overview/SKILL.mdindex 1b3b89f..5e7141a 100644--- a/claude/skills/pr-overview/SKILL.md+++ b/claude/skills/pr-overview/SKILL.md@@ -9,6 +9,8 @@ Fetch a GitHub PR, review it through multiple specialized agents (read-only), co This skill never modifies code, never commits, never pushes, and never resolves comment threads. It only produces an overview. For a workflow that also applies fixes, use `pr-review-html`; for one that addresses reviewer comments, use `pr-review-fixer`. +Two caveats to "never touches the repository": when CI has no usable test artifacts and the PR is a same-repo PR in a repository you have cloned, Phase 1b executes the branch's install scripts and tests on this machine, in a throwaway git worktree under the job directory, with your environment and credentials; and fetching the PR head writes a ref into the clone's `.git` (`FETCH_HEAD`), which touches no working-tree file and no branch.+ ## Phase 1: Fetch the PR Resolve which PR to look at, in this order:@@ -16,14 +18,77 @@ Resolve which PR to look at, in this order: - Otherwise the PR for the current branch via `gh pr view --json number` Pull what you need:-- `gh pr view <pr> --json number,title,author,baseRefName,headRefName,body,url,state,commits,files,createdAt`-- `gh pr diff <pr>` for the unified diff -Do **not** run `gh pr checkout`. This skill is read-only — the user may be on a different branch deliberately, and switching branches risks losing work. If the agents need code context beyond the diff, read files at the PR's `headRefName` via `gh api` rather than checking out.+```bash+gh pr view <pr> -R <owner>/<repo> --json number,title,author,baseRefName,headRefName,headRefOid,isCrossRepository,headRepository,body,url,state,commits,files,createdAt+```++**Pin the snapshot.** `SHA=<headRefOid>` from that JSON is the commit every later step describes — the diffs, the CI lookup, the fetch, the worktree, and the diagram all use it, so a branch that moves mid-review cannot give the page data from two trees. `isCrossRepository: true` means a fork PR. Every `gh api` and `gh run` call in this skill carries `-R <owner>/<repo>` so it works without a clone, and list endpoints use `--paginate`.++**Working directory.** Every generated input — diff fragments, downloaded artifacts, the diagram, the overview JSON itself — lives in `$INPUTS`, outside any working tree:++```bash+INPUTS="${CLAUDE_JOB_DIR:-$(mktemp -d)}/review-inputs"; mkdir -p "$INPUTS"+```++Both `$CLAUDE_JOB_DIR` and `mktemp -d` yield absolute paths; never use a relative one, as the Phase 1b subshell would resolve it into the worktree.++**Read the trees without checking out.** Do **not** run `gh pr checkout`. The user may be on a different branch deliberately, and switching branches risks losing work. Decide whether the current directory is a clone of the PR's repository (`git remote get-url origin` names `<owner>/<repo>`):++- *With a clone:* `git fetch origin refs/pull/<n>/head` and verify `test "$(git rev-parse FETCH_HEAD)" = "$SHA"` — the pull ref exists for every PR, forks included, and fetching executes nothing. Stop if the SHAs differ (the PR moved between `gh pr view` and the fetch; re-pin and fetch again). Then `git fetch origin <baseRefName>`, `MERGE_BASE=$(git merge-base origin/<baseRefName> "$SHA")`, and take the full diff for the agents from `git diff "$MERGE_BASE" "$SHA"`. Per-file fragments (Phase 6 step 1) come from `git diff "$MERGE_BASE" "$SHA" -- <path>`. Code context beyond the diff comes from `git show "$SHA":<path>`.+- *Without a clone:* `gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/compare/<baseRefName>...$SHA` gives `merge_base_commit.sha` as `MERGE_BASE` and one `patch` per entry in `files[]`. The API omits patches for binary files and lists at most 300 files; record any file without a patch as a missing fragment rather than fabricating one. Code context comes from `gh api -R <owner>/<repo> "repos/<owner>/<repo>/contents/<path>?ref=$SHA"`. Keep the PR `body`, `author`, `createdAt`, and `url` from the `gh pr view` JSON — these flow into Phase 6's `pr_description` section so the reader can see the author's framing verbatim. -Show the user which PR you're about to summarise (number, title, author, head → base, commit count) before doing the heavier work — a cheap sanity check that catches the wrong PR number early.+Show the user which PR you're about to summarise (number, title, author, head → base, commit count, pinned SHA, fork or not) before doing the heavier work — a cheap sanity check that catches the wrong PR number early.++## Phase 1b: Collect test results++Test data comes from GitHub Actions artifacts for the pinned SHA first, and from a local run in a throwaway worktree only when CI has nothing and the trust conditions below hold. Record what happened in the `tests` block (Phase 6): `provenance.ci_state` and `provenance.fallback_state` are separate fields, so "fork PR with expired artifacts" is two facts.++### CI artifacts++```bash+gh run list -R <owner>/<repo> --commit "$SHA" --json databaseId,status,conclusion,name,url+gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/actions/runs/<id>/artifacts # name, expired, size_in_bytes+gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/actions/runs/<id>/jobs # name, conclusion, html_url+gh run download <id> -R <owner>/<repo> -n <artifact> -D "$INPUTS/artifacts/<id>/<artifact>"+```++Record every job's name, outcome, and URL in `tests.jobs`. Download only artifacts with `expired: false` and `size_in_bytes` at most 100 MB; list larger ones by name and size in `tests.skipped_artifacts`. Identify files by content, never by name: JUnit is XML whose root element is `testsuites` or `testsuite`; Cobertura is XML rooted at `coverage`; lcov starts with `TN:` or `SF:`; coverprofile starts with `mode: `. Copy each recognised file into `$INPUTS` as `<run_id>-<artifact>--<basename>` so two artifacts or two runs cannot collide, and reference those names from `tests.junit`, `tests.coverage`, and `tests.artifacts[]`.++**CI state** is derived after sniffing, first rule that matches:++1. Any completed run yielded a JUnit file → `artifacts usable`; runs still `in_progress` or `queued` go into `tests.pending_runs` and the page notes them.+2. Any run `in_progress` or `queued` → `run in progress or queued`.+3. No runs → `no run`.+4. At least one artifact exists across completed runs and every one is expired → `artifacts expired`.+5. Any completed run with `conclusion: failure` and zero artifacts → `run failed before upload`.+6. Otherwise → `artifacts absent` (covers artifacts that contain no JUnit).++**Job attribution.** Artifacts belong to a run, not a job, so attribution is a convention: tokenise job and artifact names into lowercase alphanumeric runs; a file is attributed to the job whose token set is a subset of the artifact's token set, choosing the job with the most tokens; a tie leaves it attributed to the artifact. `test (ubuntu)` → `{test, ubuntu}` matches `test-results-ubuntu`. Put the winner in `tests.artifacts[].job`, or omit it.++### Worktree fallback++Permitted only when the CI state is `no run`, `run failed before upload`, `artifacts expired`, or `artifacts absent`. Otherwise `fallback_state` is `not needed` (artifacts usable) or `blocked by run in progress`. Then two trust conditions, checked in this order and recorded as the blocked state when they fail: the PR must be a same-repo PR (`isCrossRepository: false`; otherwise `blocked by fork PR` — fork code never runs here), and the current directory must be a clone of the PR's repository (otherwise `blocked by no local clone`).++Choose the command as `pr-review-html` Phase 5 does: read `~/.claude/scripts/ecosystems.json`, pick the language row covering the most changed files and the runner whose `detect` rule matches, then take the first tier that applies reading text only — a Makefile target whose literal recipe lines contain one of the runner's `junit_flags` (never `make -n`), a documented project test command with such a flag, or the runner's `recipe` with `{junit}`, `{coverage}`, `{inputs}` replaced by absolute paths under `$INPUTS`, its `env` exported, and its `config_files` written first. A missing `requires` binary records `no_data_reason: "required tool missing"`; no row or runner records `runner not detected`. `coverage_scope` is `repository` for the ecosystem recipe and `project-configured` otherwise.++Run everything in **one Bash call** with `timeout: 600000` (the tool's maximum, covering install and tests), with `WT="$CLAUDE_JOB_DIR/wt-<n>-<sha7>"` (or under the same `mktemp -d` parent as `$INPUTS` when the job directory is unset):++```bash+git worktree prune+git fetch origin refs/pull/<n>/head && test "$(git rev-parse FETCH_HEAD)" = "$SHA"+git worktree add --detach "$WT" "$SHA"+(cd "$WT" && <install> && <recipe with absolute $INPUTS paths>); status=$?+python3 ~/.claude/scripts/blast_radius.py --repo "$WT" --snapshot "$SHA" --base "$MERGE_BASE" --tools --out "$INPUTS"+git worktree remove --force "$WT"; git worktree prune+echo "recipe-status=$status"+```++`git worktree prune` at the start drops only entries whose directories are gone and skips locked ones; never remove a worktree that still exists on disk. The recipe's exit status becomes `run_outcome` (`passed` or `failed`) and `fallback_state` becomes `ran`. `blast_radius.py --tools` runs inside the same call because the worktree is the only checkout this skill has and it is gone afterwards; Phase 6 then skips its own `blast_radius.py` invocation. All outputs are under `$INPUTS`, so nothing is lost when the worktree goes.++If the call times out: run `git worktree remove --force "$WT"; git worktree prune` separately, keep whatever JUnit XML was written, set `run_outcome: "timed_out"`, `partial: true`, `fallback_state: "timed out"`, and `no_data_reason: "local run timed out"` when no JUnit was written. Nothing in the user's own checkout is touched, so there is nothing to restore. ## Phase 2: Fetch unresolved comments @@ -135,7 +200,28 @@ Render `{repo-root}/pr-overview.html` (overwrite if it exists) using the shared ### Step 1: Write per-file diff fragments -For each changed file in the PR, dump the unified diff to a separate `.txt` file. Use `gh pr diff <pr> -- <path>` or split the full PR diff. Put the fragments in a working directory next to where the JSON will live, e.g. `{repo-root}/.claude/pr-overview-diffs/` or `$CLAUDE_JOB_DIR`. Keep filenames simple (e.g. `diff-services-foo.txt`); the JSON references them by name.+For each changed file, write its diff from the pinned SHA to `$INPUTS/<name>.txt`: `git diff "$MERGE_BASE" "$SHA" -- <path>` with a clone, or the compare API's `files[].patch` without one (a file the API gave no patch for stays a missing fragment). Keep filenames simple (e.g. `diff-services-foo.txt`); the JSON references them by name.++### Step 1b: Baseline, blast radius, and classification++**Baseline** — test results for the merge base, used for new/removed tests and the overall coverage delta:++```bash+gh run list -R <owner>/<repo> --branch <baseRefName> --status success --limit 30 --json databaseId,headSha,url+```++The first candidate whose `headSha` equals `$MERGE_BASE` or is its ancestor wins. With a clone: `git merge-base --is-ancestor <headSha> "$MERGE_BASE"`, treating exit status 128 (commit not present locally) as "skip this candidate". Without a clone: `gh api -R <owner>/<repo> repos/<owner>/<repo>/compare/<headSha>...$MERGE_BASE` with `status` of `identical` or `ahead`. Never use a run later than the merge base — tests added on the base branch since would show as removed. Download and sniff the winner's artifacts exactly as in Phase 1b (same size cap, same content sniffing, same `<run_id>-<artifact>--<basename>` naming) and reference them as `baseline_junit` and `baseline_coverage`, with `baseline_provenance` naming the run. A winning run with no usable artifacts ends the search: `baseline_provenance: null`, and new/removed tests fall back to `diff-tests.json` below.++**Blast radius** — skip this when Phase 1b's worktree run already wrote `$INPUTS/diagram.json`. Otherwise run without `--tools` (there is no checkout to run a dependency tool in):++```bash+python3 ~/.claude/scripts/blast_radius.py --repo . --snapshot "$SHA" --base "$MERGE_BASE" --out "$INPUTS" # with a clone+python3 ~/.claude/scripts/blast_radius.py --remote <owner>/<repo> --snapshot "$SHA" --base "$MERGE_BASE" --out "$INPUTS" # without one+```++With a clone the script reads both trees from the fetched git objects, never from the working tree, so it works for fork PRs too. `--remote` reads them through the trees and blobs API, capped at 500 blob calls; past the cap the dependents column is marked partial. Both forms write `$INPUTS/diagram.json` and `$INPUTS/diff-tests.json`; reference them by file name and never transcribe them into the JSON.++**Classification** — the change is `docs-only` when every changed file is documentation (`.md`, `.rst`, `.adoc`, anything under a `docs/` directory), a `README`, `CHANGELOG`, `LICENSE`, `CONTRIBUTING`, or `CODEOWNERS` file with any extension, an image, a lockfile, or an editor/VCS dotfile such as `.gitignore`. Anything else — CI workflows, build configuration, dependency manifests, `.txt` files elsewhere — makes it `code`. ### Step 2: Assemble `overview.json` @@ -223,6 +309,41 @@ Schema (every top-level key is optional except `repo` and `files` — empty sect "diff_file": "diff-services-foo.txt"} ], + "change_classification": "code", // from Step 1b: code | docs-only+ "diagram_file": "diagram.json", // written by blast_radius.py, relative to $INPUTS++ "tests": { // from Phase 1b and Step 1b; present for every code change,+ "provenance": { // with no_data_reason set when nothing could be collected+ "source": "ci", // ci | local (local = the worktree fallback ran)+ "run_ids": [123], "run_urls": ["https://github.com/.../actions/runs/123"], // CI+ "timestamp": "2026-09-04T10:22:00+10:00", // local+ "snapshot": {"sha": "<SHA>", "dirty": false},+ "ci_state": "artifacts usable", // no run | run in progress or queued | run failed before upload |+ // artifacts expired | artifacts absent | artifacts usable+ "fallback_state": "not needed" // not needed | ran | blocked by fork PR | blocked by no local clone |+ }, // blocked by run in progress | timed out+ "baseline_provenance": {"source": "ci", "run_id": 120, // or null+ "run_url": "https://github.com/.../actions/runs/120", "sha": "<headSha>"},+ "coverage_scope": "project-configured", // project-configured for CI and tiers 1-2; repository for the ecosystem recipe+ "run_outcome": "passed", // passed | failed | timed_out | not_run (CI source: not_run)+ "partial": false, // true when a fallback timeout cut the run short+ "junit": ["123-test-results-ubuntu--junit.xml"], // file names under $INPUTS+ "coverage": ["123-test-results-ubuntu--coverage.out"],+ "baseline_junit": ["120-test-results-ubuntu--junit.xml"],+ "baseline_coverage": ["120-test-results-ubuntu--coverage.out"],+ "path_map": {"strip": null, "prepend": null}, // only when suffix matching cannot resolve coverage paths+ "jobs": [{"run_id": 123, "name": "test (ubuntu)", "outcome": "success", "url": "…"}],+ "artifacts": [{"name": "test-results-ubuntu", "run_id": 123,+ "junit": ["123-test-results-ubuntu--junit.xml"],+ "coverage": ["123-test-results-ubuntu--coverage.out"],+ "job": "test (ubuntu)"}], // omit job when attribution failed+ "pending_runs": [{"run_id": 124, "name": "integration", "status": "in_progress", "url": "…"}],+ "skipped_artifacts": [{"name": "build-output", "size_in_bytes": 412000000}],+ "run_touched_files": [], // always empty here: the fallback runs in a worktree+ "diff_tests_file": "diff-tests.json", // written by blast_radius.py; used when there is no baseline+ "no_data_reason": null // no tests found | runner not detected | required tool missing |+ }, // local run failed | local run timed out | ci+ "publish_metadata": { "title": "PR #123 — Add foo (overview)", "repoUrl": "https://github.com/owner/repo",@@ -233,15 +354,19 @@ Schema (every top-level key is optional except `repo` and `files` — empty sect } ``` +`no_data_reason: "ci"` tells the renderer to word the no-data card from `ci_state` and `fallback_state` (adding that the workflow must upload a JUnit XML artifact when the state is `no run`, `artifacts absent`, or `artifacts expired`). With `change_classification: "docs-only"` the Tests card, Tests section, and diagram are all omitted, whatever else is present.+ **Rendering contract** (implemented by the script — informational, you don't enforce it): - Pass-through HTML fields: `subtitle`, `at_a_glance` items, `verdict.detail`, every `explanation` panel, `decisions[].body`, `double_check[].body`. Write actual HTML. - All other fields are HTML-escaped automatically. Write plain text. - `pr_description.body` and `unresolved_comments[].body` are HTML-escaped and rendered in a `pre-wrap` monospace block — markdown markers (`##`, lists, fenced code) survive on screen as the author wrote them. **Do not** rewrite, trim, or summarise. Verbatim is the whole point. - Each unresolved comment renders as a warning-bordered card with a type pill (code/review/discussion), author, file:line (if code-level), date, and a "view on GitHub" link. Replies collapse into a `<details>` block.-- Diffs are escaped and dropped into `<pre><code class="language-diff">…</code></pre>` with highlight.js, then restyled to the Prism Dark green/red tokens.+- Diffs are escaped and coloured by the script's own stylesheet — no external assets. Added lines that have coverage data and zero hits carry an uncovered mark; added lines in files with no coverage data carry none. - The three-level explanation renders as CSS-only radio-button tabs in Beginner → Intermediate → Expert order. - Important-change cards show a magenta-bordered **Takeaway** callout and a cyan-bordered **Rationale** callout. - Findings counts derive from the `status` field; in this skill every finding is `"raised"`.+- `tests` renders a Tests card in the overview grid (pass rate, new tests, diff coverage) and a Tests section: provenance with CI links, CI state and fallback state, availability of run / JUnit / coverage / baseline as independent states, totals with the flaky count, pending runs, one row per job (or per artifact when unattributed), failed tests with messages redacted for secrets and truncated to 500 characters, new and removed tests (by identity with a baseline, by declaration name from `diff_tests_file` without one), a per-file diff-coverage table, the overall coverage delta when both sides have it, skipped artifacts, and any warnings. With no readable results it renders a no-data card from `no_data_reason`, `ci_state`, and `fallback_state`.+- `diagram_file` renders the Blast radius section as inline SVG before the per-file diffs: dependents, changed files, dependencies, grouped by package or directory, changed nodes linked to their diff. Test files leave the side columns, packages with more than 3 expansion-only files collapse, side columns cap at 15 nodes, and a column the script could not derive shows the reason instead. An absent or invalid file warns and omits the section. - TOC, overview cards, and section anchors are generated automatically. Empty sections vanish. - `publish_metadata` is emitted as a `<script type="application/json" id="review-meta">` block in `<head>`. @@ -249,16 +374,20 @@ Schema (every top-level key is optional except `repo` and `files` — empty sect ```bash python3 ~/.claude/scripts/build_review_html.py \- --data /path/to/overview.json \+ --data "$INPUTS/overview.json" \ --output {repo-root}/pr-overview.html \- --diff-dir /path/to/diff-fragments+ --diff-dir "$INPUTS" ``` -The script prints the output path on success. Surface that path to the user so they can open it in a browser.+Always pass `--diff-dir` explicitly; every file the JSON references (fragments, JUnit, coverage, baseline, `diagram.json`, `diff-tests.json`) is resolved against it. The script prints the output path on success. Surface that path to the user so they can open it in a browser.++**Error handling.** Missing diff fragments show as `(diff fragment 'name.txt' missing)` placeholders. A test, coverage, or diagram input that is missing, malformed, not UTF-8, over 50 MB, or XML with a `DOCTYPE` prints a `warning:` line naming the file, is listed in the Tests section, and the rest of the page still renders with exit status 0. Only an unreadable `overview.json` exits non-zero.++**Severity floor.** When a `tests` block is present and the change is not docs-only, the script's last two stderr lines are `summary coverage: matched=N unmatched=N` and `summary tests: passed=N failed=N errored=N skipped=N flaky=N` (head JUnit only, every job aggregated). Grep stderr for the `summary tests:` prefix. If `failed` or `errored` is non-zero: set `verdict.tone` to `warning` unless it is already `error`, prepend the failure count to `verdict.detail` (e.g. "3 failing tests — "), raise `publish_metadata.severity` to `needs-changes` unless it is already `blocking`, and run the script again to the same output path. When the line is absent there is no test data and no floor applies. Flaky tests and coverage values never change the verdict or severity. -### When to edit the script vs the SKILL.md+### When to edit the renderer vs the SKILL.md -- **Edit the script** (`~/.claude/scripts/build_review_html.py`) when you need a new card, callout colour, layout tweak, or theme adjustment. Changes there are shared with `pre-push-review` and `pr-review-html`.+- **Edit the renderer** (the `~/.claude/scripts/review_html/` package; `build_review_html.py` is only the command line) when you need a new card, callout colour, layout tweak, or theme adjustment. The Prism Dark palette lives in `review_html/css.py`; section markup in `sections.py`, the Tests section in `tests_section.py`, the diagram in `diagram.py`. Changes there are shared with `pre-push-review` and `pr-review-html`; `make test` in the agentic-coding repo covers them. - **Edit this SKILL.md** when you change the JSON contract, the output location, or phase semantics specific to PR overviews. ### Populating `publish_metadata`@@ -268,7 +397,7 @@ Always populate this field. Mapping rules: - `title`: human-readable, typically the PR title with `(overview)` suffix. - `repoUrl`: the PR's repo URL (from `gh pr view --json url` or `git remote get-url origin`). - `pr`: the PR number as an integer. **Do not** also set `branch`.-- `severity`: derive from the findings and the unresolved-comments count — no findings or unresolved comments → `lgtm`; nits or low-stakes unresolved threads only → `suggestions`; major findings or substantive unresolved threads → `needs-changes`; blocking/security/correctness issues → `blocking`.+- `severity`: derive from the findings and the unresolved-comments count — no findings or unresolved comments → `lgtm`; nits or low-stakes unresolved threads only → `suggestions`; major findings or substantive unresolved threads → `needs-changes`; blocking/security/correctness issues → `blocking`. Failing or errored tests floor it at `needs-changes` (Step 3). - `summary`: 1–3 sentences leading with the headline takeaway (e.g. "3 unresolved threads, all on error handling in foo.go"). ## Phase 7: Publish@@ -279,4 +408,4 @@ Check whether the `pulsar` binary is on PATH (`command -v pulsar`). If it is, in End with a short verdict for the user: **Looks good**, **Worth a closer look** (with the top 2–3 findings), or **Blocking concerns** (with the must-address list). Mention the unresolved-comment count and link to the HTML output (or the archived path returned by Phase 7 if publish ran). -This skill never pushes, commits, merges, or resolves threads — surface what's there and let the user decide what to do next.+This skill never pushes, commits, merges, or resolves threads — surface what's there and let the user decide what to do next. If Phase 1b ran the worktree fallback, say so, and if it was blocked, say why (fork PR, no local clone, run in progress).
diff --git a/claude/skills/pr-review-html/SKILL.md b/claude/skills/pr-review-html/SKILL.mdindex 2b99268..2276cdf 100644--- a/claude/skills/pr-review-html/SKILL.md+++ b/claude/skills/pr-review-html/SKILL.md@@ -14,14 +14,26 @@ Resolve which PR to review, in this order: - Otherwise the PR for the current branch via `gh pr view --json number` Pull what you need to review:-- `gh pr view <pr> --json number,title,author,baseRefName,headRefName,body,url,state,commits,files`-- `gh pr diff <pr>` for the unified diff+- `gh pr view <pr> --json number,title,author,baseRefName,headRefName,headRefOid,isCrossRepository,body,url,state,commits,files,createdAt`+- `gh pr diff <pr>` for the unified diff the review agents read (Phase 7 regenerates per-file diffs from the working tree after fixes) -If the user isn't on the PR branch, run `gh pr checkout <pr>` so any fixes land on the right local branch. If the working tree is dirty, stop and ask before checking out — silently switching branches risks losing work.+Record `headRefOid` (the head SHA), `isCrossRepository` (true for a fork PR), and `baseRefName`; Phases 5 and 7 use them. Note `<owner>/<repo>` from `url` — every `gh api` and `gh run` call in this skill carries `-R <owner>/<repo>`, and list endpoints use `--paginate`.++If the user isn't on the PR branch, run `gh pr checkout <pr>` so any fixes land on the right local branch. If the working tree is dirty, stop and ask before checking out — silently switching branches risks losing work. After checkout, `git fetch origin <baseRefName>` and record `MERGE_BASE=$(git merge-base origin/<baseRefName> HEAD)`; the diffs, the diagram, and the baseline lookup all compare against it.++Phases 4 and 5 execute the branch's install scripts and tests on this machine, with your environment and credentials. That applies to fork PRs too: this skill checks out and runs whatever PR it is asked to review. Keep the PR `body`, `author`, `createdAt`, and `url` from the `gh pr view` JSON — these flow into Phase 7's `pr_description` section so the reader can see the author's framing verbatim. -Show the user which PR you're about to review (number, title, author, head → base, commit count) before doing the heavier work — it's a cheap sanity check that catches a wrong PR number early.+Show the user which PR you're about to review (number, title, author, head → base, commit count, fork or not) before doing the heavier work — it's a cheap sanity check that catches a wrong PR number early.++**Working directory.** Every generated input — diff fragments, JUnit and coverage files, the diagram, the review JSON itself — lives in `$INPUTS`, outside the working tree so nothing lands in `git status`:++```bash+INPUTS="${CLAUDE_JOB_DIR:-$(mktemp -d)}/review-inputs"; mkdir -p "$INPUTS"+```++Both `$CLAUDE_JOB_DIR` and `mktemp -d` yield absolute paths; never use a relative one, as later steps run in subshells where it would resolve into the wrong directory. ## Phase 2: Locate the spec (if any) @@ -77,10 +89,26 @@ Aggregate the agent findings and fix them on the checked-out PR branch. The user ## Phase 5: Verify -After fixes:-- Run the project's test suite (use Makefile commands if present)-- Run linters and validators per project config-- If a test fails, treat it as a regression in the fix — investigate and revert/adjust rather than editing the test to make it pass+The verification run is also the source of the page's test results, so choose a command that emits JUnit XML before running anything, and run it once.++### Choose the command++Read `~/.claude/scripts/ecosystems.json`. The language row is the one whose `extensions` cover the most changed files; the runner within it is the first whose `detect` rule matches (`files` globs at the repo root, or top-level `package_json_keys` in `package.json`). Then, reading text only and executing nothing, take the first tier that applies:++1. **Makefile target.** A target whose literal recipe lines (read from the Makefile — never `make -n`, which still expands `$(shell …)` and runs `+` lines) contain one of the runner's `junit_flags`. Pass over a target whose output path is a `$(VAR)` reference you cannot read. Run the target and copy the outputs the recipe names into `$INPUTS`.+2. **Project instructions.** A command in CLAUDE.md or the README described as the test command, if it contains such a flag.+3. **Ecosystem recipe.** The runner's `recipe` with `{junit}`, `{coverage}`, and `{inputs}` replaced by absolute paths under `$INPUTS`, its `env` exported, and any `config_files` templates written under `$INPUTS` first. Every binary in `requires` must be on PATH; a missing one records `no_data_reason: "required tool missing"`. No row, or no runner whose `detect` rule matches, records `runner not detected`. Read the row's `notes` — they say where the coverage file lands, which extra JUnit files appear, and what the install line assumes.++A Makefile target or project command that emits nothing structured is passed over in favour of the next tier, not run as a second suite. `coverage_scope` is `repository` for tier 3 and `project-configured` for tiers 1 and 2; the page shows it.++### Run once++- **Before:** record `git status --porcelain -z` and copy every dirty tracked file to `$INPUTS/pre-run/<path>` — those are the Phase 4 fixes and must survive.+- **Run** the chosen command in one Bash call with `timeout: 600000` (the tool's maximum; it covers dependency installation and the tests) with every output under `$INPUTS`. The exit status becomes `run_outcome` (`passed` or `failed`). Run linters and validators per project config as well.+- **After:** for each tracked file whose content differs from before, restore a file that was clean pre-run with `git checkout -- <path>` and copy a file that was dirty pre-run back from `$INPUTS/pre-run/`. Untracked files that appeared outside `$INPUTS` are reported, never deleted. List touched and appeared paths in `tests.run_touched_files`. Never use `git stash` — it would carry away the fixes the run exists to verify.+- **On timeout:** keep whatever JUnit XML was written, set `run_outcome: "timed_out"` and `partial: true`, and add "fix verification incomplete: test run timed out" to `verdict.detail`.++If a test fails, treat it as a regression in the fix — investigate and revert/adjust rather than editing the test to make it pass. If you re-run after adjusting, repeat the before/after steps; the last run is the one the page reports. ## Phase 6: Implementation explanation and insight material @@ -105,7 +133,27 @@ Render `{repo-root}/pr-review.html` (overwrite if it exists) using the shared re ### Step 1: Write per-file diff fragments -For each changed file in the PR, dump the unified diff to a separate `.txt` file. Use `gh pr diff <pr> -- <path>` or split the full PR diff. Put the fragments in a working directory next to where the JSON will live, e.g. `{repo-root}/.claude/pr-review-diffs/` or `$CLAUDE_JOB_DIR`. Keep filenames simple (e.g. `diff-services-foo.txt`); the JSON references them by name.+Diffs come from the working tree after the fixes, not from `gh pr diff`, so the coverage marks land on the lines the reader sees. For each changed file (`git diff --name-status -M $MERGE_BASE`), write `git diff $MERGE_BASE -- <path>` to `$INPUTS/<name>.txt`. Files listed by `git ls-files --others --exclude-standard -z` are untracked additions: write `git diff --no-index /dev/null <path>` for them (exit status 1 is normal) and badge them `Added`. Keep filenames simple (e.g. `diff-services-foo.txt`); the JSON references them by name.++### Step 1b: Baseline, blast radius, and classification++**Baseline** — test results for the merge base, used for new/removed tests and the overall coverage delta:++```bash+gh run list -R <owner>/<repo> --branch <baseRefName> --status success --limit 30 --json databaseId,headSha,url+```++The first candidate whose `headSha` equals `$MERGE_BASE` or is its ancestor wins: `git merge-base --is-ancestor <headSha> $MERGE_BASE`, treating exit status 128 (commit not present locally) as "skip this candidate". Never use a run later than the merge base — tests added on the base branch since would show as removed. For the winner, list its artifacts with `gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/actions/runs/<id>/artifacts`, skip any with `expired: true` or `size_in_bytes` over 100 MB, download the rest with `gh run download <id> -R <owner>/<repo> -n <artifact> -D "$INPUTS/artifacts/<id>/<artifact>"`, and sniff the files by content: JUnit is XML whose root is `testsuites` or `testsuite`; Cobertura is XML rooted at `coverage`; lcov starts with `TN:` or `SF:`; coverprofile starts with `mode: `. Copy recognised files into `$INPUTS` as `<run_id>-<artifact>--<basename>` and reference them as `baseline_junit` and `baseline_coverage`, with `baseline_provenance` naming the run. A winning run with no usable artifacts ends the search: `baseline_provenance: null`, and new/removed tests fall back to `diff-tests.json` below.++**Blast radius** — run after Phase 5 so the working tree holds the fixes:++```bash+python3 ~/.claude/scripts/blast_radius.py --repo . --snapshot working-tree --base "$MERGE_BASE" --tools --out "$INPUTS"+```++This writes `$INPUTS/diagram.json` (the one-hop dependency graph) and `$INPUTS/diff-tests.json` (test declarations added and removed in changed test files). Reference both by file name; never transcribe them into the JSON.++**Classification** — the change is `docs-only` when every changed file is documentation (`.md`, `.rst`, `.adoc`, anything under a `docs/` directory), a `README`, `CHANGELOG`, `LICENSE`, `CONTRIBUTING`, or `CODEOWNERS` file with any extension, an image, a lockfile, or an editor/VCS dotfile such as `.gitignore`. Anything else — CI workflows, build configuration, dependency manifests, `.txt` files elsewhere — makes it `code`. ### Step 2: Assemble `review.json` @@ -176,6 +224,30 @@ Schema (every top-level key is optional except `repo` and `files` — empty sect "diff_file": "diff-services-foo.txt"} // OR "diff": "<inline diff text>" ], + "change_classification": "code", // from Step 1b: code | docs-only+ "diagram_file": "diagram.json", // written by blast_radius.py, relative to $INPUTS++ "tests": { // from Phase 5 and Step 1b; present for every code change,+ "provenance": { // with no_data_reason set when nothing could be collected+ "source": "local",+ "timestamp": "2026-09-04T10:22:00+10:00",+ "snapshot": {"sha": "<headRefOid>", "dirty": true} // dirty once fixes are applied+ },+ "baseline_provenance": {"source": "ci", "run_id": 120, // or null+ "run_url": "https://github.com/.../actions/runs/120", "sha": "<headSha>"},+ "coverage_scope": "repository", // repository (tier 3) | project-configured (tiers 1-2)+ "run_outcome": "passed", // passed | failed | timed_out | not_run+ "partial": false, // true when a timeout cut the run short+ "junit": ["junit.xml"], // file names under $INPUTS+ "coverage": ["coverage.out"],+ "baseline_junit": ["120-test-results--junit.xml"],+ "baseline_coverage": ["120-test-results--coverage.out"],+ "path_map": {"strip": null, "prepend": null}, // only when suffix matching cannot resolve coverage paths+ "run_touched_files": [], // from the Phase 5 restore step+ "diff_tests_file": "diff-tests.json", // written by blast_radius.py; used when there is no baseline+ "no_data_reason": null // no tests found | runner not detected | required tool missing |+ }, // local run failed | local run timed out+ "publish_metadata": { // always populate (see Phase 8). "title": "PR #123 — Add foo", "repoUrl": "https://github.com/owner/repo",@@ -186,14 +258,18 @@ Schema (every top-level key is optional except `repo` and `files` — empty sect } ``` +With `change_classification: "docs-only"` the Tests card, Tests section, and diagram are all omitted, whatever else is present.+ **Rendering contract** (implemented by the script — informational, you don't enforce it): - Pass-through HTML fields: `subtitle`, `at_a_glance` items, `verdict.detail`, every `explanation` panel, `decisions[].body`, `double_check[].body`. Write actual HTML. - All other fields are HTML-escaped automatically. Write plain text. - `pr_description.body` is HTML-escaped and rendered in a `pre-wrap` block with monospace styling — markdown markers (`##`, lists, fenced code) and any HTML comments survive on screen as the author wrote them. **Do not** rewrite, trim, or summarise the body; the whole point is verbatim authorial intent.-- Diffs are escaped and dropped into `<pre><code class="language-diff">…</code></pre>` with highlight.js, then restyled to the Prism Dark green/red tokens.+- Diffs are escaped and coloured by the script's own stylesheet — no external assets. Added lines that have coverage data and zero hits carry an uncovered mark; added lines in files with no coverage data carry none. - The three-level explanation renders as CSS-only radio-button tabs in Beginner → Intermediate → Expert order. - Important-change cards show a magenta-bordered **Takeaway** callout and a cyan-bordered **Rationale** callout. `rationale_unknown: true` swaps Rationale for a warning-bordered **Open question**. `rationale_inferred: true` appends `(inferred — not stated by the author)`. - Findings counts (raised / fixed / skipped) derive from the `status` field.+- `tests` renders a Tests card in the overview grid (pass rate, new tests, diff coverage) and a Tests section: provenance, availability of run / JUnit / coverage / baseline as independent states, totals with the flaky count, failed tests with messages redacted for secrets and truncated to 500 characters, new and removed tests (by identity with a baseline, by declaration name from `diff_tests_file` without one), a per-file diff-coverage table, the overall coverage delta when both sides have it, and any warnings. With no readable results it renders a no-data card from `no_data_reason`.+- `diagram_file` renders the Blast radius section as inline SVG before the per-file diffs: dependents, changed files, dependencies, grouped by package or directory, changed nodes linked to their diff. Test files leave the side columns, packages with more than 3 expansion-only files collapse, side columns cap at 15 nodes. An absent or invalid file warns and omits the section. - TOC, overview cards, and section anchors are generated automatically. Empty sections vanish. - `publish_metadata` is emitted as a `<script type="application/json" id="review-meta">` block in `<head>`, JSON-encoded with `</` escaped. Required by `pulsar publish` (see `docs/agent-contract.md` in the pulsar repo); harmless when present, ignored when absent. @@ -201,16 +277,20 @@ Schema (every top-level key is optional except `repo` and `files` — empty sect ```bash python3 ~/.claude/scripts/build_review_html.py \- --data /path/to/review.json \+ --data "$INPUTS/review.json" \ --output {repo-root}/pr-review.html \- --diff-dir /path/to/diff-fragments # defaults to the JSON file's directory+ --diff-dir "$INPUTS" ``` -The script prints the output path on success. Surface that path to the user so they can open it in a browser. Missing diff fragments degrade gracefully — they show as `(diff fragment 'name.txt' missing)` placeholders, so the rest of the review remains usable.+Always pass `--diff-dir` explicitly; every file the JSON references (fragments, JUnit, coverage, baseline, `diagram.json`, `diff-tests.json`) is resolved against it. The script prints the output path on success. Surface that path to the user so they can open it in a browser.++**Error handling.** Missing diff fragments show as `(diff fragment 'name.txt' missing)` placeholders. A test, coverage, or diagram input that is missing, malformed, not UTF-8, over 50 MB, or XML with a `DOCTYPE` prints a `warning:` line naming the file, is listed in the Tests section, and the rest of the page still renders with exit status 0. Only an unreadable `review.json` exits non-zero.++**Severity floor.** When a `tests` block is present and the change is not docs-only, the script's last two stderr lines are `summary coverage: matched=N unmatched=N` and `summary tests: passed=N failed=N errored=N skipped=N flaky=N` (head JUnit only). Grep stderr for the `summary tests:` prefix. If `failed` or `errored` is non-zero: set `verdict.tone` to `warning` unless it is already `error`, prepend the failure count to `verdict.detail` (e.g. "3 failing tests — "), raise `publish_metadata.severity` to `needs-changes` unless it is already `blocking`, and run the script again to the same output path. When the line is absent there is no test data and no floor applies. Flaky tests and coverage values never change the verdict or severity. -### When to edit the script vs the SKILL.md+### When to edit the renderer vs the SKILL.md -- **Edit the script** (`~/.claude/scripts/build_review_html.py`) when you need a new card, callout colour, layout tweak, or theme adjustment. The Prism Dark palette lives in the `CSS` constant inside the script. Changes there are shared with `pre-push-review`.+- **Edit the renderer** (the `~/.claude/scripts/review_html/` package; `build_review_html.py` is only the command line) when you need a new card, callout colour, layout tweak, or theme adjustment. The Prism Dark palette lives in `review_html/css.py`; section markup in `sections.py`, the Tests section in `tests_section.py`, the diagram in `diagram.py`. Changes there are shared with `pre-push-review` and `pr-overview`; `make test` in the agentic-coding repo covers them. - **Edit this SKILL.md** when you change the JSON contract, the output location, or the upstream phase semantics specific to PR reviews. ### Populating `publish_metadata`@@ -220,7 +300,7 @@ Always populate this field. Mapping rules: - `title`: human-readable, typically the PR title (e.g. `PR #123 — Add foo`). - `repoUrl`: the PR's repo URL (from `gh pr view --json url` or `git remote get-url origin`); the binary normalises SSH → HTTPS and strips `.git`. - `pr`: the PR number as an integer. **Do not** also set `branch` — exactly one is allowed.-- `severity`: derive from the verdict tone and findings — `success` and no major issues → `lgtm`; nits only → `suggestions`; major findings raised → `needs-changes`; blocking/security/correctness issues → `blocking`.+- `severity`: derive from the verdict tone and findings — `success` and no major issues → `lgtm`; nits only → `suggestions`; major findings raised → `needs-changes`; blocking/security/correctness issues → `blocking`. Failing or errored tests floor it at `needs-changes` (Step 3). - `summary`: 1–3 sentences leading with the headline finding (not "I reviewed PR X"). This is the feed-reader description. ## Phase 8: Publish
diff --git a/claude/skills/pre-push-review/SKILL.md b/claude/skills/pre-push-review/SKILL.mdindex 6ffc56a..2288ecf 100644--- a/claude/skills/pre-push-review/SKILL.md+++ b/claude/skills/pre-push-review/SKILL.md@@ -9,9 +9,17 @@ Review and fix unpushed commits before they reach the remote repository. ## Phase 1: Identify Changes -Determine which commits haven't been pushed to the remote repository. Use `git diff origin/<branch>..HEAD` to get the full diff of unpushed changes. Show the user which commits will be reviewed.+Determine which commits haven't been pushed to the remote repository. Record the base as the merge base, `BASE=$(git merge-base origin/<branch> HEAD)`, and use `git diff $BASE..HEAD` to get the full diff of unpushed changes; a two-dot diff straight against `origin/<branch>` shows reverse changes when the branch is behind it. Show the user which commits will be reviewed. -If there is no remote tracking branch yet, diff against `origin/main`.+If there is no remote tracking branch yet, use `origin/main` in place of `origin/<branch>`. Phase 7 compares the working tree against `BASE`.++**Working directory.** Every generated input — diff fragments, JUnit and coverage files, the diagram, the review JSON itself — lives in `$INPUTS`, outside the working tree so nothing lands in `git status`:++```bash+INPUTS="${CLAUDE_JOB_DIR:-$(mktemp -d)}/review-inputs"; mkdir -p "$INPUTS"+```++Both `$CLAUDE_JOB_DIR` and `mktemp -d` yield absolute paths; never use a relative one. ## Phase 2: Locate Relevant Specifications @@ -72,11 +80,25 @@ Wait for all agents to complete. Aggregate their findings and fix each issue dir ## Phase 5: Verify -After all fixes are applied:+The verification run is also the source of the page's test results, so choose a command that emits JUnit XML before running anything, and run it once.++### Choose the command -1. Run the project's test suite (use Makefile commands if available). All tests must pass.-2. Run linters and validators as specified in project configuration.-3. If any test fails, investigate whether the fix introduced a regression and revert or adjust the fix — do not modify the test to make it pass.+Read `~/.claude/scripts/ecosystems.json`. The language row is the one whose `extensions` cover the most changed files; the runner within it is the first whose `detect` rule matches (`files` globs at the repo root, or top-level `package_json_keys` in `package.json`). Then, reading text only and executing nothing, take the first tier that applies:++1. **Makefile target.** A target whose literal recipe lines (read from the Makefile — never `make -n`, which still expands `$(shell …)` and runs `+` lines) contain one of the runner's `junit_flags`. Pass over a target whose output path is a `$(VAR)` reference you cannot read. Run the target and copy the outputs the recipe names into `$INPUTS`.+2. **Project instructions.** A command in CLAUDE.md or the README described as the test command, if it contains such a flag.+3. **Ecosystem recipe.** The runner's `recipe` with `{junit}`, `{coverage}`, and `{inputs}` replaced by absolute paths under `$INPUTS`, its `env` exported, and any `config_files` templates written under `$INPUTS` first. Every binary in `requires` must be on PATH; a missing one records `no_data_reason: "required tool missing"`. No row, or no runner whose `detect` rule matches, records `runner not detected`. Read the row's `notes` — they say where the coverage file lands, which extra JUnit files appear, and what the install line assumes.++A Makefile target or project command that emits nothing structured is passed over in favour of the next tier, not run as a second suite. `coverage_scope` is `repository` for tier 3 and `project-configured` for tiers 1 and 2; the page shows it.++### Run once++1. **Before:** record `git status --porcelain -z` and copy every dirty tracked file to `$INPUTS/pre-run/<path>` — those are the Phase 4 fixes and must survive.+2. **Run** the chosen command in one Bash call with `timeout: 600000` (the tool's maximum; it covers dependency installation and the tests) with every output under `$INPUTS`. The exit status becomes `run_outcome` (`passed` or `failed`). All tests must pass. Run linters and validators as specified in project configuration as well.+3. **After:** for each tracked file whose content differs from before, restore a file that was clean pre-run with `git checkout -- <path>` and copy a file that was dirty pre-run back from `$INPUTS/pre-run/`. Untracked files that appeared outside `$INPUTS` are reported, never deleted. List touched and appeared paths in `tests.run_touched_files`. Never use `git stash` — it would carry away the fixes the run exists to verify.+4. **On timeout:** keep whatever JUnit XML was written, set `run_outcome: "timed_out"` and `partial: true`, and add "fix verification incomplete: test run timed out" to `verdict.detail`.+5. If any test fails, investigate whether the fix introduced a regression and revert or adjust the fix — do not modify the test to make it pass. If you re-run after adjusting, repeat the before/after steps; the last run is the one the page reports. ## Phase 6: Generate Implementation Explanation and Insight Material @@ -115,7 +137,19 @@ Overwrite any existing file. ### Step 1: Write per-file diff fragments -For each changed file, dump the unified diff (`git diff $BASE -- <path>` or equivalent) to a separate `.txt` file. Pick a working directory next to where the JSON will live, e.g. `{repo-root}/.claude/review-diffs/` or `$CLAUDE_JOB_DIR`. Keep the filenames simple (e.g. `diff-main.go.txt`); the JSON references them by name.+Diffs come from the working tree after the fixes, so the coverage marks land on the lines the reader sees. For each changed file (`git diff --name-status -M $BASE`), write `git diff $BASE -- <path>` to `$INPUTS/<name>.txt`. Files listed by `git ls-files --others --exclude-standard -z` are untracked additions: write `git diff --no-index /dev/null <path>` for them (exit status 1 is normal) and badge them `Added`. Keep the filenames simple (e.g. `diff-main.go.txt`); the JSON references them by name.++### Step 1b: Blast radius and classification++Run after Phase 5 so the working tree holds the fixes:++```bash+python3 ~/.claude/scripts/blast_radius.py --repo . --snapshot working-tree --base "$BASE" --tools --out "$INPUTS"+```++This writes `$INPUTS/diagram.json` (the one-hop dependency graph) and `$INPUTS/diff-tests.json` (test declarations added and removed in changed test files). Reference both by file name; never transcribe them into the JSON. There is no forge lookup in this skill, so there is no baseline: new and removed tests always come from `diff-tests.json`.++**Classification** — the change is `docs-only` when every changed file is documentation (`.md`, `.rst`, `.adoc`, anything under a `docs/` directory), a `README`, `CHANGELOG`, `LICENSE`, `CONTRIBUTING`, or `CODEOWNERS` file with any extension, an image, a lockfile, or an editor/VCS dotfile such as `.gitignore`. Anything else — CI workflows, build configuration, dependency manifests, `.txt` files elsewhere — makes it `code`. ### Step 2: Assemble `review.json` @@ -177,6 +211,27 @@ Write a JSON file with this shape. Every top-level key is optional except `repo` "diff_file": "diff-publish.go.txt"} // OR "diff": "<inline diff text>" ], + "change_classification": "code", // from Step 1b: code | docs-only+ "diagram_file": "diagram.json", // written by blast_radius.py, relative to $INPUTS++ "tests": { // from Phase 5 and Step 1b; present for every code change,+ "provenance": { // with no_data_reason set when nothing could be collected+ "source": "local",+ "timestamp": "2026-09-04T10:22:00+10:00",+ "snapshot": {"sha": "<HEAD sha>", "dirty": true} // dirty once fixes are applied+ },+ "baseline_provenance": null, // always null: no forge lookup pre-push+ "coverage_scope": "repository", // repository (tier 3) | project-configured (tiers 1-2)+ "run_outcome": "passed", // passed | failed | timed_out | not_run+ "partial": false, // true when a timeout cut the run short+ "junit": ["junit.xml"], // file names under $INPUTS+ "coverage": ["coverage.out"],+ "path_map": {"strip": null, "prepend": null}, // only when suffix matching cannot resolve coverage paths+ "run_touched_files": [], // from the Phase 5 restore step+ "diff_tests_file": "diff-tests.json", // written by blast_radius.py+ "no_data_reason": null // no tests found | runner not detected | required tool missing |+ }, // local run failed | local run timed out+ "publish_metadata": { // always populate (see Phase 8). "title": "Pre-push review: <branch>", "repoUrl": "https://github.com/owner/repo",@@ -187,13 +242,17 @@ Write a JSON file with this shape. Every top-level key is optional except `repo` } ``` +With `change_classification: "docs-only"` the Tests card, Tests section, and diagram are all omitted, whatever else is present.+ **Rendering contract** the script implements (you don't have to): - Pass-through HTML fields: `subtitle`, `at_a_glance` items, `verdict.detail`, every `explanation` panel, `decisions[].body`, `double_check[].body`. Write actual HTML here (e.g. `<p>`, `<ul>`, `<code>`). - Every other field is HTML-escaped automatically. Write plain text, no escaping.-- Diffs are escaped and dropped into `<pre><code class="language-diff">…</code></pre>`; highlight.js paints them and the stylesheet overrides additions/deletions to the Prism Dark green/red tokens.+- Diffs are escaped and coloured by the script's own stylesheet — no external assets. Added lines that have coverage data and zero hits carry an uncovered mark; added lines in files with no coverage data carry none. - The three-level explanation renders as CSS-only radio-button tabs in Beginner → Intermediate → Expert order. The first level present is checked by default. - Important-change cards render a magenta-bordered Takeaway callout and a cyan-bordered Rationale callout. `rationale_unknown: true` swaps the Rationale for a warning-bordered "Open question" callout. `rationale_inferred: true` appends a muted `(inferred — not stated by the author)`. - Findings counts (raised / fixed / skipped) are derived from the `status` field.+- `tests` renders a Tests card in the overview grid (pass rate, new tests, diff coverage) and a Tests section: provenance, availability of run / JUnit / coverage / baseline as independent states, totals with the flaky count, failed tests with messages redacted for secrets and truncated to 500 characters, new and removed tests by declaration name from `diff_tests_file` (labelled diff-derived), a per-file diff-coverage table, touched files, and any warnings. With no readable results it renders a no-data card from `no_data_reason`.+- `diagram_file` renders the Blast radius section as inline SVG before the per-file diffs: dependents, changed files, dependencies, grouped by package or directory, changed nodes linked to their diff. Test files leave the side columns, packages with more than 3 expansion-only files collapse, side columns cap at 15 nodes. An absent or invalid file warns and omits the section. - The TOC, overview cards, and per-section anchors are generated automatically; sections with no data are dropped. - `publish_metadata` is emitted as a `<script type="application/json" id="review-meta">` block in `<head>`, JSON-encoded with `</` escaped. Required by `pulsar publish` (see `docs/agent-contract.md` in the pulsar repo); harmless in browsers when present, ignored when absent. @@ -201,16 +260,20 @@ Write a JSON file with this shape. Every top-level key is optional except `repo` ```bash python3 ~/.claude/scripts/build_review_html.py \- --data /path/to/review.json \+ --data "$INPUTS/review.json" \ --output /path/to/pre-push-review.html \- --diff-dir /path/to/diff-fragments # defaults to the JSON file's directory+ --diff-dir "$INPUTS" ``` -The script prints the output path on success. Surface that path to the user so they can open it in a browser. If the JSON is malformed or a referenced diff fragment is missing, the script still renders the page — missing diffs become a `(diff fragment 'name.txt' missing)` placeholder so the rest of the review remains usable.+Always pass `--diff-dir` explicitly; every file the JSON references (fragments, JUnit, coverage, `diagram.json`, `diff-tests.json`) is resolved against it. The script prints the output path on success. Surface that path to the user so they can open it in a browser.++**Error handling.** Missing diff fragments show as `(diff fragment 'name.txt' missing)` placeholders. A test, coverage, or diagram input that is missing, malformed, not UTF-8, over 50 MB, or XML with a `DOCTYPE` prints a `warning:` line naming the file, is listed in the Tests section, and the rest of the page still renders with exit status 0. A malformed or unreadable `review.json` does not render: the script prints one line naming the problem and exits non-zero, so fix the JSON and run it again.++**Severity floor.** When a `tests` block is present and the change is not docs-only, the script's last two stderr lines are `summary coverage: matched=N unmatched=N` and `summary tests: passed=N failed=N errored=N skipped=N flaky=N`. Grep stderr for the `summary tests:` prefix. If `failed` or `errored` is non-zero: set `verdict.tone` to `warning` unless it is already `error`, prepend the failure count to `verdict.detail` (e.g. "3 failing tests — "), raise `publish_metadata.severity` to `needs-changes` unless it is already `blocking`, and run the script again to the same output path. When the line is absent there is no test data and no floor applies. Flaky tests and coverage values never change the verdict or severity. -### When to edit the script vs the SKILL.md+### When to edit the renderer vs the SKILL.md -- **Edit the script** when you need a new card, callout colour, layout tweak, or theme adjustment. The Prism Dark palette lives in the `CSS` constant inside the script.+- **Edit the renderer** (the `~/.claude/scripts/review_html/` package; `build_review_html.py` is only the command line) when you need a new card, callout colour, layout tweak, or theme adjustment. The Prism Dark palette lives in `review_html/css.py`; section markup in `sections.py`, the Tests section in `tests_section.py`, the diagram in `diagram.py`. Changes there are shared with `pr-review-html` and `pr-overview`; `make test` in the agentic-coding repo covers them. - **Edit the SKILL.md** when you change the JSON contract, the location-priority logic, or the upstream phase semantics. ### Populating `publish_metadata`@@ -220,7 +283,7 @@ Always populate this field. Mapping rules: - `title`: human-readable, mirror the H1 (e.g. `Pre-push review: <branch>`). - `repoUrl`: from `git remote get-url origin`; the binary normalises SSH → HTTPS and strips `.git`. - `branch`: the current branch name. **Do not** set `pr` for a pre-push review — no PR exists yet.-- `severity`: derive from the verdict tone and findings — `success`/no major findings → `lgtm`; `warning` with only nits → `suggestions`; major findings raised → `needs-changes`; blocking/security/correctness issues → `blocking`.+- `severity`: derive from the verdict tone and findings — `success`/no major findings → `lgtm`; `warning` with only nits → `suggestions`; major findings raised → `needs-changes`; blocking/security/correctness issues → `blocking`. Failing or errored tests floor it at `needs-changes` (Step 3). - `summary`: 1–3 sentences leading with the headline finding (not "I reviewed X"). This is what shows up in the user's feed reader. ## Phase 8: Publish
diff --git a/scripts/README.md b/scripts/README.mdindex 0f7c17a..1a5d9d7 100644--- a/scripts/README.md+++ b/scripts/README.md@@ -54,14 +54,64 @@ python3 ~/.claude/scripts/build_review_html.py \ **JSON schema**: see the docstring at the top of the script. Top-level keys are `repo`, `title`, `subtitle`, `metrics`, `verdict`, `at_a_glance`, `explanation` (beginner/intermediate/expert), `commits`, `important_changes`, `decisions`, `findings`, `double_check`, `files`. Empty sections are dropped from both the body and the table of contents. +Optional keys: `tests` holds the test-results block (JUnit and coverage file names, provenance, jobs, artifacts; see the spec's design document) rendered as the Tests card and section. `diagram_file` names a `diagram.json` written by `blast_radius.py`, read relative to `--diff-dir`, and rendered as the Blast radius section (inline SVG, before the per-file diffs). `change_classification: "docs-only"` suppresses both the Tests and Blast radius sections without a warning. An absent or invalid diagram file prints a warning to stderr, omits the section, and still exits 0. A review JSON that cannot be read or parsed prints one `error:` line and exits 2.+ **Behavior**:-- Renders the Prism Dark palette as inline CSS — fully self-contained except for the highlight.js CDN load for diff syntax colouring.+- Renders the Prism Dark palette as inline CSS — fully self-contained, with no external assets. - Important-change cards render Takeaway (magenta) and Rationale (cyan) callouts, with an Open Question (warning) variant when `rationale_unknown: true`. - The three-level explanation renders as CSS-only radio-button tabs (no JS required). - Per-file diffs are collapsed `<details>` blocks. Missing diff fragments degrade to a placeholder rather than failing the render. **Output**: Prints the absolute path of the written HTML on success. +**Layout**: `build_review_html.py` is a thin entry point; the renderer lives in the `review_html/` package next to it (`css.py` holds the stylesheet, `sections.py` the section renderers, `diagram.py` the blast-radius projection and SVG, `render.py` the orchestration). `sync-claude.sh` links the whole directory, so the package syncs with the script.++### blast_radius.py++**Purpose**: Derives the one-hop dependency graph around a change (the changed files, the files that import them, and the files they import) from git trees, and writes it as `diagram.json` for the renderer, plus `diff-tests.json` listing test declarations added and removed in changed test files. Used by the `pre-push-review`, `pr-review-html`, and `pr-overview` skills.++**Usage**:+```bash+python3 ~/.claude/scripts/blast_radius.py \+ --repo DIR \ # repository directory (default: .)+ --snapshot (SHA|working-tree) \ # the tree the page describes+ --base SHA \ # the tree it is compared against+ [--remote OWNER/REPO] \ # read both trees through the GitHub API (no clone needed)+ [--ecosystems FILE] \ # defaults to ecosystems.json next to the script+ [--tools] \ # run each ecosystem row's dependency tool in --repo+ --out DIR # writes DIR/diagram.json and DIR/diff-tests.json+```++**Prerequisites**: git, or the GitHub CLI (`gh`) authenticated for `--remote`. `--remote` needs a commit SHA snapshot and cannot be combined with `--tools`.++**Behavior**:+- Changed files come from `git diff --name-status -M -C` (copies count as added, type changes as modified) plus untracked files as added for a working-tree snapshot; with `--remote`, from the compare API.+- Trees come from `git ls-tree`, the working tree, or the trees API. Symlinks, submodules, and blobs over 1 MB are never scanned; skipped blobs are listed in `skipped`. Files whose extension has no row in `ecosystems.json` are not scanned.+- Imports are matched with each row's patterns and resolved to files by the row's resolver: `relative` (path relative to the importer, trying `extension_map`, `extensions`, then `index_files`), `roots` (segments joined under each `source_roots` entry, retrying once with the last segment dropped for symbol imports), or `unit` (a package, module, or target expanded to every file in it, recorded with `granularity: package`). Only edges touching a changed file are kept; each carries `method` (`import`, `expansion`, or `tool:<name>`), `granularity`, and `tree` (`snapshot` or `base`).+- Deleted files and the old paths of renamed files are scanned in the base tree, so their edges carry `tree: base`.+- `column_status` reports `complete`, `partial: remote scan cap reached` (the blob API is capped at 500 calls; dependencies of the changed files are always read), or `failed: <reason>` (`no import patterns for <extensions>`, `tree listing truncated`). A failed column is rendered as its reason, never as an empty column.+- With `--tools`, a row's `tool.deps` command runs in `--repo` and its edges replace the scanned edges for the same file pair; a failing tool leaves the scanned edges in place with a warning.+- `diff-tests.json` scans the diff of each changed test file with the row's `test_decl`; test files whose row has no pattern are listed in `unpatterned_files`.++**Output**: Prints the paths of the two files written. Exit status 1 on git or API errors, 2 on invalid arguments.++### ecosystems.json++One row per language, read by `blast_radius.py` and by the review skills. Keys the script reads:++| Key | Meaning |+|-----|---------|+| `extensions` | file extensions the row covers |+| `test_files` | regexes marking a path as a test file (files with no row fall back to a `test`/`spec` name or directory rule) |+| `test_decl` | regex whose first group (or whole match minus the leading keyword) is a test name; matched over the added and removed lines of a diff |+| `unit` | grouping rule: `{"kind": "directory"}`, `{"kind": "module_file", "module_file": "go.mod", "module_regex": ...}`, or `{"kind": "target_root", "target_root": "Sources"}` |+| `imports` | list of `{"regex", "resolve": "relative" \| "roots" \| "unit", "separator"}`; multiple groups are joined with the separator |+| `source_roots`, `index_files`, `extension_map` | inputs to the `roots` and `relative` resolvers |+| `tool` | `{"name", "deps", "format": "go-list-json" \| "pairs", "granularity"}`; `pairs` output is one `from<TAB>to` line per edge |+| `notes` | known holes in the row, shown to the agent |++Runner recipes (`runners[]`) that tell the skills how to emit JUnit XML and coverage live on the same rows and are read by the agent, not the script. Each runner has `name`, `detect` (`files` globs and/or `package_json_keys`), `recipe`, `requires` (binaries that must be on PATH), `coverage_format` (`lcov`, `cobertura`, or `coverprofile`), `install`, `junit_flags` (the flags a Makefile target must contain to count as emitting JUnit), and optionally `env` and `config_files` (templates written under the inputs directory). Recipes, env values, and templates use only the placeholders `{junit}`, `{coverage}`, and `{inputs}`. `scripts/tests/test_ecosystems.py` checks both key sets.+ ### copilot-pr-comments.sh **Purpose**: Fetches and displays GitHub Copilot's review comments and inline comments for the current branch's pull request.@@ -139,6 +189,16 @@ go run . <directory> # Convert all _test.go files in directory From: `tests := []struct { name string; ... }` with `for _, tt := range tests` To: `tests := map[string]struct { ... }` with `for name, tt := range tests` +## Tests++Run the renderer test suite from the repository root with:++```bash+make test+```++This runs `cd scripts && python3 -m unittest discover -s tests -t .`. Tests live in `scripts/tests/` with fixtures under `scripts/tests/fixtures/`. `fixtures/golden.html` is the page the renderer at commit `9da40cf` produced from `fixtures/golden.json`; `test_golden.py` renders the same JSON with the current entry point and compares the two after blanking the `<style>` contents and the `Generated …` footer line. Regenerate the golden page only when the rendering contract intentionally changes.+ ## Agent Usage Notes - Both scripts include error handling and provide informative output messages
diff --git a/scripts/blast_radius.py b/scripts/blast_radius.pynew file mode 100644index 0000000..5488b65--- /dev/null+++ b/scripts/blast_radius.py@@ -0,0 +1,827 @@+#!/usr/bin/env python3+"""Derive the one-hop dependency graph around a change from git trees.++Reads two trees (the snapshot and the base), scans imports per+``ecosystems.json``, and writes ``diagram.json`` (nodes, edges, column+status, skipped files) plus ``diff-tests.json`` (test declarations added and+removed in changed test files). The renderer applies the projection rules;+this script only reports what the trees say.++Usage:+ blast_radius.py --repo DIR --snapshot (SHA|working-tree) --base SHA+ [--remote OWNER/REPO] [--ecosystems FILE] [--tools] --out DIR++``--remote`` reads both trees through the GitHub trees and blobs API instead+of git, for use without a clone; it cannot be combined with a working-tree+snapshot or ``--tools``. ``--tools`` runs each ecosystem row's dependency tool+in ``--repo`` and lets its edges replace scanned edges for the same pair.+"""+from __future__ import annotations++import argparse+import base64+import json+import os+import posixpath+import re+import stat+import subprocess+import sys+from dataclasses import dataclass, field+from pathlib import Path++MAX_BLOB = 1024 * 1024 # blobs over this are recorded as skipped, never scanned+REMOTE_BLOB_CAP = 500 # blob API calls before dependents scanning stops+COMPARE_PAGE = 300 # compare API files per page+SKIP_MODES = ("120000", "160000") # symlinks and submodules+STATUS_CODES = {"A": "added", "M": "modified", "D": "deleted", "R": "renamed",+ "C": "added", "T": "modified"}+REMOTE_STATUSES = {"added": "added", "modified": "modified", "removed": "deleted",+ "renamed": "renamed", "copied": "added", "changed": "modified"}+# Without an ecosystem row: test-looking file names (test_x, x_test, x.test.ts,+# x.spec.js, XTests.swift, conftest.py) or a parent directory that is itself a+# test directory. Whole tokens only, so ``specs/`` and ``docs/testing.md`` are+# not tests.+_FALLBACK_TEST_NAME = re.compile(+ r"^(test[_-]|conftest\.py$)|[_-]tests?\.\w+$|\.(test|spec)\.\w+$|Tests?\.\w+$")+_FALLBACK_TEST_DIRS = frozenset({"test", "tests", "__tests__", "spec"})+++def warn(message: str) -> None:+ print(f"warning: {message}", file=sys.stderr)+++# --- ecosystems -------------------------------------------------------------++@dataclass+class ImportSpec:+ regex: re.Pattern+ resolve: str+ separator: str+++@dataclass+class Row:+ name: str+ extensions: list+ test_files: list+ test_decl: object # re.Pattern or None+ unit: dict+ imports: list+ source_roots: list+ index_files: list+ extension_map: dict+ tool: object # dict or None+++class Ecosystems:+ def __init__(self, data: dict) -> None:+ self.rows = {}+ self.by_ext = {}+ for name, raw in data.items():+ row = Row(+ name=name,+ extensions=[e.lower() for e in raw.get("extensions", [])],+ test_files=[re.compile(p) for p in raw.get("test_files", [])],+ test_decl=re.compile(raw["test_decl"], re.MULTILINE) if raw.get("test_decl") else None,+ unit=raw.get("unit") or {"kind": "directory"},+ imports=[ImportSpec(re.compile(i["regex"], re.MULTILINE), i.get("resolve", "relative"),+ i.get("separator", "."))+ for i in raw.get("imports", [])],+ source_roots=raw.get("source_roots") or ["."],+ index_files=raw.get("index_files") or [],+ extension_map=raw.get("extension_map") or {},+ tool=raw.get("tool"),+ )+ self.rows[name] = row+ for ext in row.extensions:+ self.by_ext.setdefault(ext, row)++ def row_for(self, path: str):+ return self.by_ext.get(posixpath.splitext(path)[1].lower())+++def load_ecosystems(path: Path) -> Ecosystems:+ return Ecosystems(json.loads(path.read_text(encoding="utf-8")))+++def is_test_file(path: str, row) -> bool:+ """The row's patterns, or the name/nearest-directory rule without a row."""+ if row is not None and row.test_files:+ return any(p.search(path) for p in row.test_files)+ name = posixpath.basename(path)+ parent = posixpath.basename(posixpath.dirname(path))+ return bool(_FALLBACK_TEST_NAME.search(name)) or parent in _FALLBACK_TEST_DIRS+++# --- changed files ----------------------------------------------------------++@dataclass+class Changed:+ path: str+ status: str+ old_path: object = None # str for renames+++def parse_name_status(raw: str) -> list:+ """Parse ``git diff --name-status -z`` output; C becomes added, T modified."""+ parts = raw.split("\0")+ out = []+ i = 0+ while i < len(parts) and parts[i]:+ code = parts[i][0]+ if code in "RC":+ old, new = parts[i + 1], parts[i + 2]+ i += 3+ else:+ old, new = None, parts[i + 1]+ i += 2+ if code in STATUS_CODES:+ out.append(Changed(new, STATUS_CODES[code], old if code == "R" else None))+ return out+++def git(repo: Path, *args: str, binary: bool = False):+ result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True)+ if result.returncode != 0:+ raise RuntimeError(f"git {' '.join(args[:2])} failed: {result.stderr.decode('utf-8', 'replace').strip()}")+ return result.stdout if binary else result.stdout.decode("utf-8", "replace")+++def changed_files_local(repo: Path, base: str, snapshot: str) -> list:+ args = ["diff", "--name-status", "-M", "-C", "-z", base]+ if snapshot != "working-tree":+ args.append(snapshot)+ changed = parse_name_status(git(repo, *args))+ if snapshot == "working-tree":+ seen = {c.path for c in changed}+ for path in git(repo, "ls-files", "--others", "--exclude-standard", "-z").split("\0"):+ if path and path not in seen:+ changed.append(Changed(path, "added", None))+ return changed+++def gh_api(path: str) -> object:+ """One GitHub API call through ``gh``; tests replace this function."""+ remote = "/".join(path.split("/")[1:3])+ result = subprocess.run(["gh", "api", "-R", remote, path], capture_output=True, text=True)+ if result.returncode != 0:+ raise RuntimeError(f"gh api {path}: {result.stderr.strip()}")+ return json.loads(result.stdout)+++def changed_files_remote(remote: str, base: str, snapshot: str) -> tuple:+ files = []+ page = 1+ while True:+ data = gh_api(f"repos/{remote}/compare/{base}...{snapshot}?per_page={COMPARE_PAGE}&page={page}")+ batch = data.get("files") or []+ files.extend(batch)+ if len(batch) < COMPARE_PAGE:+ break+ page += 1+ changed = []+ patches = {}+ for f in files:+ status = REMOTE_STATUSES.get(f.get("status"))+ if status is None:+ continue+ old = f.get("previous_filename") if f.get("status") == "renamed" else None+ changed.append(Changed(f["filename"], status, old))+ patches[f["filename"]] = f.get("patch") or ""+ return changed, patches+++# --- trees ------------------------------------------------------------------++@dataclass+class Entry:+ path: str+ sha: object # str, or None for working-tree files+ size: int+ mode: str+++def parse_ls_tree(raw: str) -> dict:+ """Parse ``git ls-tree -r -l -z``; symlinks and submodules are dropped."""+ entries = {}+ for record in raw.split("\0"):+ if not record:+ continue+ meta, _, path = record.partition("\t")+ fields = meta.split()+ if len(fields) != 4:+ continue+ mode, kind, sha, size = fields+ if mode in SKIP_MODES or kind != "blob":+ continue+ entries[path] = Entry(path, sha, int(size) if size.isdigit() else 0, mode)+ return entries+++class CatFile:+ """One ``git cat-file --batch`` process serving blob reads by SHA."""++ def __init__(self, repo: Path) -> None:+ self.proc = subprocess.Popen(["git", "-C", str(repo), "cat-file", "--batch"],+ stdin=subprocess.PIPE, stdout=subprocess.PIPE)++ def read(self, sha: str):+ self.proc.stdin.write((sha + "\n").encode("ascii"))+ self.proc.stdin.flush()+ header = self.proc.stdout.readline().split()+ if len(header) < 3:+ return None+ size = int(header[2])+ data = self.proc.stdout.read(size)+ self.proc.stdout.read(1)+ return data++ def close(self) -> None:+ try:+ self.proc.stdin.close()+ self.proc.wait(timeout=5)+ except Exception:+ self.proc.kill()+++class Tree:+ """A file listing plus a blob reader; ``label`` names it on edges."""++ label = ""+ truncated = False++ def __init__(self) -> None:+ self.entries = {}++ def read(self, path: str, essential: bool = False):+ raise NotImplementedError++ def close(self) -> None:+ pass+++class GitTree(Tree):+ def __init__(self, repo: Path, sha: str, label: str) -> None:+ super().__init__()+ self.label = label+ self.entries = parse_ls_tree(git(repo, "ls-tree", "-r", "-l", "-z", sha))+ self.cat = CatFile(repo)++ def read(self, path: str, essential: bool = False):+ entry = self.entries.get(path)+ if entry is None:+ return None+ data = self.cat.read(entry.sha)+ return None if data is None else data.decode("utf-8", "replace")++ def close(self) -> None:+ self.cat.close()+++class WorkingTree(Tree):+ label = "snapshot"++ def __init__(self, repo: Path) -> None:+ super().__init__()+ self.repo = repo+ tracked = git(repo, "ls-files", "-z")+ self.tracked = set(tracked.split("\0"))+ listing = tracked + git(repo, "ls-files", "--others", "--exclude-standard", "-z")+ for path in listing.split("\0"):+ if not path:+ continue+ try:+ st = os.lstat(repo / path)+ except OSError:+ continue+ if not stat.S_ISREG(st.st_mode):+ continue+ self.entries[path] = Entry(path, None, st.st_size, "100644")++ def read(self, path: str, essential: bool = False):+ try:+ return (self.repo / path).read_bytes().decode("utf-8", "replace")+ except OSError:+ return None+++class RemoteBudget:+ def __init__(self) -> None:+ self.calls = 0+ self.cap_hit = False+++class RemoteTree(Tree):+ def __init__(self, remote: str, sha: str, label: str, budget: RemoteBudget) -> None:+ super().__init__()+ self.remote = remote+ self.label = label+ self.budget = budget+ data = gh_api(f"repos/{remote}/git/trees/{sha}?recursive=1")+ self.truncated = bool(data.get("truncated"))+ for item in data.get("tree") or []:+ if item.get("type") != "blob" or item.get("mode") in SKIP_MODES:+ continue+ self.entries[item["path"]] = Entry(item["path"], item["sha"], int(item.get("size") or 0),+ item.get("mode", "100644"))++ def read(self, path: str, essential: bool = False):+ entry = self.entries.get(path)+ if entry is None:+ return None+ if not essential and self.budget.calls >= REMOTE_BLOB_CAP:+ self.budget.cap_hit = True+ return None+ self.budget.calls += 1+ data = gh_api(f"repos/{self.remote}/git/blobs/{entry.sha}")+ content = data.get("content") or ""+ if data.get("encoding") == "base64":+ raw = base64.b64decode(content)+ else:+ raw = content.encode("utf-8")+ return raw.decode("utf-8", "replace")+++# --- groups and resolution --------------------------------------------------++class Grouper:+ """Maps a file to its unit (package, target, or directory) within one tree."""++ def __init__(self, eco: Ecosystems, tree: Tree) -> None:+ self.eco = eco+ self.tree = tree+ self._modules = {} # module_file name -> {dir: module path}++ def _modules_for(self, row: Row) -> dict:+ name = row.unit.get("module_file")+ if name in self._modules:+ return self._modules[name]+ regex = re.compile(row.unit.get("module_regex", r"^module\s+(\S+)"), re.MULTILINE)+ modules = {}+ for path in sorted(self.tree.entries):+ if posixpath.basename(path) != name:+ continue+ text = self.tree.read(path, essential=True) or ""+ m = regex.search(text)+ if m:+ modules[posixpath.dirname(path)] = m.group(1)+ self._modules[name] = modules+ return modules++ def group(self, path: str, row) -> str:+ directory = posixpath.dirname(path) or "."+ if row is None:+ return directory+ kind = row.unit.get("kind", "directory")+ if kind == "module_file":+ modules = self._modules_for(row)+ d = posixpath.dirname(path)+ while True:+ if d in modules:+ rel = posixpath.relpath(posixpath.dirname(path) or ".", d or ".")+ return modules[d] if rel == "." else modules[d] + "/" + rel+ if not d:+ return directory+ d = posixpath.dirname(d)+ if kind == "target_root":+ root = row.unit.get("target_root", "Sources")+ if path.startswith(root + "/"):+ segments = path[len(root) + 1:].split("/")+ if len(segments) >= 2:+ return segments[0]+ return directory+++class Resolver:+ """Resolves import strings to files of one tree."""++ def __init__(self, eco: Ecosystems, tree: Tree, grouper: Grouper) -> None:+ self.eco = eco+ self.paths = set(tree.entries)+ self.grouper = grouper+ self._by_group = None++ def _unit_index(self) -> dict:+ if self._by_group is None:+ index = {}+ for path in sorted(self.paths):+ row = self.eco.row_for(path)+ if row is None:+ continue+ index.setdefault((row.name, self.grouper.group(path, row)), []).append(path)+ self._by_group = index+ return self._by_group++ def resolve(self, importer: str, name: str, spec: ImportSpec, row: Row) -> list:+ """Return ``[(target, granularity), ...]``; an unresolved import yields ``[]``."""+ if not name:+ return []+ if spec.resolve == "unit":+ targets = self._unit_index().get((row.name, name), [])+ return [(t, "package") for t in targets if t != importer]+ if spec.resolve == "roots":+ target = self._roots(importer, name, spec, row)+ else:+ target = self._relative(importer, name, row)+ if target is None or target == importer:+ return []+ return [(target, "file")]++ def _try(self, candidate: str, row: Row, with_extensions: bool = True):+ if with_extensions:+ if candidate in self.paths:+ return candidate+ ext = posixpath.splitext(candidate)[1]+ for alt in row.extension_map.get(ext, []):+ p = candidate[:-len(ext)] + alt+ if p in self.paths:+ return p+ for e in row.extensions:+ if candidate + e in self.paths:+ return candidate + e+ for index in row.index_files:+ p = posixpath.join(candidate, index) if candidate else index+ if p in self.paths:+ return p+ return None++ def _relative(self, importer: str, name: str, row: Row):+ base = posixpath.dirname(importer)+ bases = [base]+ if not name.startswith("."):+ # A bare name (Rust ``mod foo;``) may live under the importer's own+ # module directory; crate roots and index files use the sibling rule.+ stem = posixpath.splitext(posixpath.basename(importer))[0]+ if posixpath.basename(importer) not in row.index_files and stem not in ("lib", "main"):+ bases.insert(0, posixpath.join(base, stem) if base else stem)+ for b in bases:+ candidate = posixpath.normpath(posixpath.join(b, name) if b else name)+ if candidate.startswith("../") or candidate == "..":+ continue+ found = self._try(candidate, row)+ if found:+ return found+ return None++ def _roots(self, importer: str, name: str, spec: ImportSpec, row: Row):+ sep = spec.separator or "."+ if name.startswith(sep):+ level = 0+ while name.startswith(sep, level * len(sep)):+ level += 1+ rest = name[level * len(sep):]+ d = posixpath.dirname(importer)+ for _ in range(level - 1):+ d = posixpath.dirname(d)+ segments = [s for s in rest.split(sep) if s]+ bases = [d]+ else:+ segments = [s for s in name.split(sep) if s]+ bases = ["" if r == "." else r for r in row.source_roots]+ attempts = [segments]+ if segments:+ attempts.append(segments[:-1]) # a symbol import: drop the last segment once+ for attempt in attempts:+ for b in bases:+ candidate = posixpath.join(b, *attempt) if attempt else b+ found = self._try(candidate, row, with_extensions=bool(attempt))+ if found:+ return found+ return None+++def _groups(m: re.Match) -> list:+ """The match's participating capture groups, in order."""+ return [g for g in m.groups() if g is not None]+++def scan_imports(text: str, row: Row) -> list:+ """``[(import string, spec), ...]`` in source order."""+ out = []+ for spec in row.imports:+ for m in spec.regex.finditer(text):+ groups = _groups(m)+ if not groups:+ name = m.group(0)+ elif len(groups) == 1:+ name = groups[0]+ else:+ name = groups[0]+ for g in groups[1:]:+ name += g if name.endswith(spec.separator) else spec.separator + g+ out.append((name.strip(), spec))+ return out+++# --- tools ------------------------------------------------------------------++def parse_go_list(output: str, repo: Path) -> list:+ """File pairs from ``go list -json`` output (a stream of JSON objects)."""+ decoder = json.JSONDecoder()+ packages = []+ text = output.strip()+ index = 0+ while index < len(text):+ obj, end = decoder.raw_decode(text, index)+ packages.append(obj)+ index = end+ while index < len(text) and text[index].isspace():+ index += 1+ by_import = {p.get("ImportPath"): p for p in packages}+ real_repo = os.path.realpath(str(repo))++ def rel(pkg: dict, name: str) -> str:+ d = pkg.get("Dir") or ""+ if os.path.isabs(d):+ d = os.path.relpath(os.path.realpath(d), real_repo)+ return posixpath.normpath(posixpath.join(d.replace(os.sep, "/"), name))++ def files_of(pkg: dict) -> list:+ return [rel(pkg, f) for f in (pkg.get("GoFiles") or []) + (pkg.get("CgoFiles") or [])]++ pairs = []+ for pkg in packages:+ sets = (+ (files_of(pkg) + [rel(pkg, f) for f in pkg.get("TestGoFiles") or []],+ (pkg.get("Imports") or []) + (pkg.get("TestImports") or [])),+ ([rel(pkg, f) for f in pkg.get("XTestGoFiles") or []], pkg.get("XTestImports") or []),+ )+ for files, imports in sets:+ targets = [t for imp in imports if imp in by_import for t in files_of(by_import[imp])]+ for f in files:+ for t in targets:+ pairs.append((f, t))+ return pairs+++def parse_pairs(output: str) -> list:+ pairs = []+ for line in output.splitlines():+ a, _, b = line.partition("\t")+ if a and b:+ pairs.append((a.strip(), b.strip()))+ return pairs+++def tool_edges(row: Row, repo: Path) -> list:+ tool = row.tool+ result = subprocess.run(tool["deps"], shell=True, cwd=str(repo), capture_output=True, text=True)+ if result.returncode != 0:+ tail = result.stderr.strip().splitlines()[-1:] or [""]+ raise RuntimeError(f"exit {result.returncode}: {tail[0]}")+ if tool.get("format") == "go-list-json":+ return parse_go_list(result.stdout, repo)+ return parse_pairs(result.stdout)+++# --- diff-derived tests -----------------------------------------------------++def _decl_names(text: str, row: Row) -> set:+ names = set()+ for m in row.test_decl.finditer(text):+ groups = _groups(m)+ if groups:+ names.add(groups[0])+ else:+ names.add(re.sub(r"^\s*\w+\s+", "", m.group(0)).strip())+ return names+++def diff_test_names(diff: str, row: Row) -> tuple:+ """``(added, removed)`` declaration names from a unified diff."""+ added, removed = [], []+ for line in diff.split("\n"):+ if line.startswith("+") and not line.startswith("+++"):+ added.append(line[1:])+ elif line.startswith("-") and not line.startswith("---"):+ removed.append(line[1:])+ return _decl_names("\n".join(added), row), _decl_names("\n".join(removed), row)+++def local_diff(repo: Path, base: str, snapshot: str, changed: Changed, tracked: bool) -> str:+ if not tracked:+ result = subprocess.run(["git", "-C", str(repo), "diff", "--no-index", "--", "/dev/null", changed.path],+ capture_output=True)+ return result.stdout.decode("utf-8", "replace")+ args = ["diff", "-M", "-C", base]+ if snapshot != "working-tree":+ args.append(snapshot)+ args += ["--"] + [p for p in (changed.old_path, changed.path) if p]+ return git(repo, *args)+++# --- build ------------------------------------------------------------------++@dataclass+class Graph:+ nodes: dict = field(default_factory=dict)+ edges: dict = field(default_factory=dict)+ skipped: dict = field(default_factory=dict)+++def build(args: argparse.Namespace, eco: Ecosystems) -> tuple:+ repo = Path(args.repo).resolve()+ base, snapshot = args.base, args.snapshot+ working = snapshot == "working-tree"+ patches = {}+ if args.remote:+ changed, patches = changed_files_remote(args.remote, base, snapshot)+ budget = RemoteBudget()+ snap_tree = RemoteTree(args.remote, snapshot, "snapshot", budget)+ base_tree = RemoteTree(args.remote, base, "base", budget)+ else:+ changed = changed_files_local(repo, base, snapshot)+ budget = None+ snap_tree = WorkingTree(repo) if working else GitTree(repo, snapshot, "snapshot")+ base_tree = GitTree(repo, base, "base")+ try:+ return _build(args, eco, repo, changed, patches, snap_tree, base_tree, budget)+ finally:+ snap_tree.close()+ base_tree.close()+++def _build(args, eco, repo, changed, patches, snap_tree, base_tree, budget) -> tuple:+ changed_set = {c.path for c in changed}+ deleted = {c.path for c in changed if c.status == "deleted"}+ old_to_new = {c.old_path: c.path for c in changed if c.old_path}+ groupers = {snap_tree.label: Grouper(eco, snap_tree), base_tree.label: Grouper(eco, base_tree)}+ trees = {snap_tree.label: snap_tree, base_tree.label: base_tree}+ resolvers = {label: Resolver(eco, tree, groupers[label]) for label, tree in trees.items()}+ graph = Graph()++ def ensure_node(path: str, tree_label: str, changed_entry=None) -> None:+ if path in graph.nodes:+ return+ row = eco.row_for(path)+ status = changed_entry.status if changed_entry else "unchanged"+ graph.nodes[path] = {+ "path": path, "status": status,+ "group": groupers[tree_label].group(path, row),+ "is_test": is_test_file(path, row),+ "old_path": changed_entry.old_path if changed_entry else None,+ }++ for c in changed:+ ensure_node(c.path, "base" if c.status == "deleted" else "snapshot", c)++ column_status = {"dependents": "complete", "dependencies": "complete"}+ failed = None+ if snap_tree.truncated or base_tree.truncated:+ failed = "tree listing truncated"+ elif not any(eco.row_for(c.path) and eco.row_for(c.path).imports for c in changed):+ exts = sorted({posixpath.splitext(c.path)[1] or "(none)" for c in changed})+ failed = "no import patterns for " + ", ".join(exts)+ if failed:+ column_status = {"dependents": "failed: " + failed, "dependencies": "failed: " + failed}+ else:+ def add_edge(a: str, b: str, method: str, granularity: str, tree_label: str,+ replace: bool = False) -> None:+ if a == b or (a not in changed_set and b not in changed_set):+ return+ if (a, b) in graph.edges and not replace:+ return+ graph.edges[(a, b)] = {"from": a, "to": b, "method": method,+ "granularity": granularity, "tree": tree_label}+ ensure_node(a, tree_label)+ ensure_node(b, tree_label)++ import_cache = {}++ def scan(path: str, tree_label: str, essential: bool, only_targets=None) -> None:+ tree = trees[tree_label]+ row = eco.row_for(path)+ entry = tree.entries.get(path)+ if row is None or not row.imports or entry is None:+ return+ if entry.size > MAX_BLOB:+ graph.skipped.setdefault(path, "blob over 1 MB")+ return+ key = (entry.sha, row.name) if entry.sha else None+ imports = import_cache.get(key) if key else None+ if imports is None:+ text = tree.read(path, essential)+ if text is None:+ return+ imports = scan_imports(text, row)+ if key:+ import_cache[key] = imports+ source = old_to_new.get(path, path) if tree_label == "base" else path+ for name, spec in imports:+ for target, granularity in resolvers[tree_label].resolve(path, name, spec, row):+ if only_targets is not None and target not in only_targets:+ continue+ mapped = old_to_new.get(target, target) if tree_label == "base" else target+ add_edge(source, mapped, "expansion" if granularity == "package" else "import",+ granularity, tree_label)++ # Dependencies and centre edges: the changed files' own blobs.+ for c in changed:+ if c.status != "deleted":+ scan(c.path, "snapshot", essential=True)+ for path in sorted(deleted):+ scan(path, "base", essential=True)+ # Dependents: every other snapshot file.+ for path in sorted(snap_tree.entries):+ if path not in changed_set:+ scan(path, "snapshot", essential=False)+ # Base-tree importers of deleted files and old renamed paths.+ base_targets = deleted | set(old_to_new)+ if base_targets:+ for path in sorted(base_tree.entries):+ if path not in base_targets:+ scan(path, "base", essential=False, only_targets=base_targets)+ if budget is not None and budget.cap_hit:+ column_status["dependents"] = "partial: remote scan cap reached"++ if args.tools:+ changed_rows = {eco.row_for(c.path).name for c in changed if eco.row_for(c.path)}+ for name in sorted(changed_rows):+ row = eco.rows[name]+ if not row.tool:+ continue+ tool_name = row.tool.get("name") or row.tool["deps"].split()[0]+ try:+ pairs = tool_edges(row, repo)+ except (RuntimeError, OSError, ValueError) as exc:+ warn(f"tool {tool_name} failed, keeping scanned edges: {exc}")+ continue+ for a, b in pairs:+ add_edge(a, b, f"tool:{tool_name}", row.tool.get("granularity", "package"),+ "snapshot", replace=True)++ # Diff-derived tests.+ added, removed, unpatterned = set(), set(), []+ for c in changed:+ row = eco.row_for(c.path)+ if not is_test_file(c.path, row):+ continue+ if row is None or row.test_decl is None:+ unpatterned.append(c.path)+ continue+ if args.remote:+ diff = patches.get(c.path, "")+ else:+ # A working-tree snapshot is a WorkingTree, which lists the tracked files.+ is_tracked = (args.snapshot != "working-tree" or c.status == "deleted"+ or c.path in snap_tree.tracked)+ diff = local_diff(repo, args.base, args.snapshot, c, is_tracked)+ a, r = diff_test_names(diff, row)+ added |= a+ removed |= r++ diagram = {+ "snapshot_tree": args.snapshot,+ "base_tree": args.base,+ "nodes": [graph.nodes[p] for p in sorted(graph.nodes)],+ "edges": [graph.edges[k] for k in sorted(graph.edges)],+ "column_status": column_status,+ "skipped": [{"path": p, "reason": r} for p, r in sorted(graph.skipped.items())],+ }+ diff_tests = {+ "added": sorted(added - removed),+ "removed": sorted(removed - added),+ "unpatterned_files": sorted(unpatterned),+ }+ return diagram, diff_tests+++def main(argv=None) -> int:+ parser = argparse.ArgumentParser(description="Derive the one-hop dependency graph around a change.")+ parser.add_argument("--repo", default=".", help="Repository directory (default: current directory).")+ parser.add_argument("--snapshot", required=True, help="Commit SHA, or 'working-tree'.")+ parser.add_argument("--base", required=True, help="Base commit SHA.")+ parser.add_argument("--remote", default=None, metavar="OWNER/REPO",+ help="Read both trees through the GitHub API instead of git.")+ parser.add_argument("--ecosystems", type=Path,+ default=Path(__file__).resolve().parent / "ecosystems.json")+ parser.add_argument("--tools", action="store_true",+ help="Run each ecosystem row's dependency tool in --repo.")+ parser.add_argument("--out", required=True, type=Path, help="Directory for the two output files.")+ args = parser.parse_args(argv)++ if args.remote and args.snapshot == "working-tree":+ parser.error("--remote needs a commit SHA snapshot, not working-tree")+ if args.remote and args.tools:+ parser.error("--tools needs a local checkout and cannot be combined with --remote")+ try:+ eco = load_ecosystems(args.ecosystems)+ except (OSError, ValueError) as exc:+ print(f"error: cannot load ecosystems file {args.ecosystems}: {exc}", file=sys.stderr)+ return 1+ try:+ diagram, diff_tests = build(args, eco)+ except (RuntimeError, OSError, ValueError, KeyError) as exc:+ print(f"error: {exc}", file=sys.stderr)+ return 1+ args.out.mkdir(parents=True, exist_ok=True)+ for name, payload in (("diagram.json", diagram), ("diff-tests.json", diff_tests)):+ path = args.out / name+ path.write_text(json.dumps(payload, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")+ print(str(path))+ return 0+++if __name__ == "__main__":+ raise SystemExit(main())
diff --git a/scripts/build_review_html.py b/scripts/build_review_html.pyindex 89e0b6d..022e26b 100644--- a/scripts/build_review_html.py+++ b/scripts/build_review_html.py@@ -39,6 +39,11 @@ JSON schema (see SKILL.md "Phase 7" for the contract): "double_check": [{"title", "body": "<html>"}, ...], "files": [{"path", "badge", "stat", "diff"?: "<text>", "diff_file"?: "name.txt"}, ...],+ "tests": {...}, // optional; the Tests card and section, see+ // specs/review-html-tests-diagram/design.md+ "diagram_file": "diagram.json", // optional; written by blast_radius.py, read+ // relative to --diff-dir (Blast radius section)+ "change_classification": "docs-only", // optional; suppresses Tests and Blast radius "publish_metadata": { // optional; emits a <script id="review-meta"> "title": "...", // block in <head> consumable by `pulsar publish` "repoUrl": "https://...",@@ -54,902 +59,22 @@ Rendering contract: explanation panels, decisions[].body, double_check[].body. * Everything else is treated as plain text and HTML-escaped. * Empty sections are omitted from both the body and the table of contents.++The rendering itself lives in the ``review_html`` package next to this file;+this script only owns the command line. """ from __future__ import annotations import argparse-import hashlib-import html import json import sys-import textwrap-from datetime import datetime, timezone from pathlib import Path-from string import Template---# --- helpers -----------------------------------------------------------------def _escape(s: object) -> str:- return html.escape("" if s is None else str(s))---def _file_anchor(path: str) -> str:- return "file-" + hashlib.sha1(path.encode("utf-8")).hexdigest()[:10]---def _severity_pill(sev: str) -> str:- klass = {- "blocking": "error",- "major": "error",- "minor": "warning",- "nit": "tertiary",- "info": "tertiary",- }.get(sev.lower(), "tertiary")- return f'<span class="pill pill-{klass}">{_escape(sev)}</span>'---def _render_diff(diff: str) -> str:- """Render a unified diff as one <span class="diff-line"> per line.-- Each line is classified by its leading marker so consecutive additions or- deletions paint a continuous full-width background bar. Rendering this- ourselves (instead of letting highlight.js do it) avoids hljs's display:block- + trailing-newline double-spacing and the per-line "row pill" look.- """- if not diff:- return ""- lines = diff.split("\n")- if lines and lines[-1] == "":- lines.pop()- spans = []- for line in lines:- if line.startswith(("+++", "---")):- cls = "diff-file-header"- elif line.startswith("@@"):- cls = "diff-hunk"- elif line.startswith("+"):- cls = "diff-add"- elif line.startswith("-"):- cls = "diff-del"- elif line.startswith("\\"):- cls = "diff-meta"- else:- cls = "diff-context"- spans.append(f'<span class="diff-line {cls}">{_escape(line)}</span>')- return "".join(spans)---# --- section renderers -------------------------------------------------------def render_metrics(metrics: list[dict]) -> str:- return "\n".join(- f'<span class="chip">{_escape(m.get("label"))} '- f'<strong>{_escape(m.get("value"))}</strong></span>'- for m in metrics- )---def render_at_a_glance(items: list[str]) -> str:- if not items:- return ""- bullets = "\n".join(f"<li>{item}</li>" for item in items)- return f"""<div class="card">- <h3>At a glance</h3>- <ul>{bullets}</ul>- </div>"""---def render_important_links(changes: list[dict]) -> str:- if not changes:- return ""- bullets = "".join(- f'<li><span class="tag">key</span>'- f'<a href="#change-{i}">{_escape(c.get("title"))}</a></li>'- for i, c in enumerate(changes)- )- return f"""<div class="card">- <h3>Important changes</h3>- <ul>{bullets}</ul>- </div>"""---def render_verdict_card(verdict: dict) -> str:- if not verdict:- return ""- tone = verdict.get("tone", "success")- if tone not in ("success", "warning", "error"):- tone = "success"- return f"""<div class="card">- <h3>Verdict</h3>- <p><span class="verdict-pill verdict-{tone}">{_escape(verdict.get("label"))}</span></p>- <p class="muted">{verdict.get("detail", "")}</p>- </div>"""---def render_findings_summary_card(findings: list[dict]) -> str:- if not findings:- return ""- raised = len(findings)- fixed = sum(1 for f in findings if f.get("status", "fixed") == "fixed")- skipped = raised - fixed- return f"""<div class="card">- <h3>Review findings</h3>- <p>{raised} raised · {fixed} fixed · {skipped} skipped</p>- <p><a href="#findings">Jump to findings →</a></p>- </div>"""---def render_pr_description(pr: dict) -> str:- """Render the PR author's body verbatim.-- The body is HTML-escaped and dropped into a <pre> with pre-wrap so the- author's original text and line breaks survive unchanged. Markdown stays- visible as markdown — no parser is applied because the point is to show- motivation as the author wrote it, not the reviewer's interpretation.- """- if not pr or not pr.get("body"):- return ""- meta_parts = []- if pr.get("author"):- if pr.get("url"):- meta_parts.append(- f'<a href="{_escape(pr["url"])}">{_escape(pr["author"])}</a>'- )- else:- meta_parts.append(_escape(pr["author"]))- if pr.get("created_at"):- meta_parts.append(_escape(pr["created_at"]))- meta_line = " · ".join(meta_parts)- meta_html = (- f'<div class="meta">{meta_line}</div>' if meta_line else ""- )- return f"""<section id="pr-description">- <h2>Author's PR description</h2>- <p class="muted">Shown verbatim — the markdown the author wrote, unmodified.</p>- <div class="pr-description">- {meta_html}- <pre>{_escape(pr["body"])}</pre>- </div>- </section>"""---def render_commits(items: list[dict]) -> str:- if not items:- return ""- rows = []- for c in items:- sha = _escape(c.get("sha"))- subj = _escape(c.get("subject"))- meta = c.get("meta")- if meta is not None:- meta_html = _escape(meta)- else:- meta_html = f'{_escape(c.get("author"))} · {_escape(c.get("date"))}'- rows.append(- f'<li><code class="commit-sha">{sha}</code> '- f'<span class="commit-subj">{subj}</span> '- f'<span class="commit-meta">— {meta_html}</span></li>'- )- return f"""<section id="commits">- <h2>Commits</h2>- <ul class="commit-list">{"".join(rows)}</ul>- </section>"""---def render_explanation(panels: dict) -> str:- if not panels:- return ""- order = [("beginner", "Beginner"),- ("intermediate", "Intermediate"),- ("expert", "Expert")]- levels = [(key, label, panels[key]) for key, label in order if panels.get(key)]- if not levels:- return ""- radios = "\n".join(- f'<input type="radio" name="tabs" id="tab-{key}"'- f'{" checked" if i == 0 else ""}>'- for i, (key, _, _) in enumerate(levels)- )- labels = "\n".join(- f'<label for="tab-{key}">{_escape(label)}</label>'- for key, label, _ in levels- )- panels_html = "\n".join(- f'<div id="panel-{key}" class="tab-panel">{content}</div>'- for key, _, content in levels- )- return f"""<section id="explanation">- <h2>Three-level explanation</h2>- <div class="tabs">- {radios}- <div class="tab-labels">{labels}</div>- <div class="tab-panels">{panels_html}</div>- </div>- </section>"""---def render_important_changes(changes: list[dict]) -> str:- if not changes:- return ""- cards = []- for i, c in enumerate(changes):- file_path = c.get("file", "")- file_anchor = _file_anchor(file_path) if file_path else ""- what_text = _escape(c.get("what"))- what_block = (- f'<p><strong>What to look at.</strong> '- f'<a href="#{file_anchor}">{what_text}</a></p>'- if file_anchor and what_text- else (f'<p><strong>What to look at.</strong> {what_text}</p>' if what_text else "")- )- takeaway = c.get("takeaway")- takeaway_block = (- f'<div class="callout callout-takeaway">'- f'<strong>Takeaway.</strong> {_escape(takeaway)}</div>'- if takeaway else ""- )- if c.get("rationale_unknown"):- rationale_block = (- '<div class="callout callout-warning">'- '<strong>Open question.</strong> Rationale not stated by the author '- 'and not inferable from the diff.</div>'- )- elif c.get("rationale"):- rationale_html = _escape(c["rationale"])- if c.get("rationale_inferred"):- rationale_html += (- ' <span class="muted">(inferred — not stated by the author)</span>'- )- rationale_block = (- '<div class="callout callout-rationale">'- f'<strong>Rationale.</strong> {rationale_html}</div>'- )- else:- rationale_block = ""- cards.append(textwrap.dedent(f"""\- <div class="change-card" id="change-{i}">- <h3>{_escape(c.get("title"))}</h3>- <p class="muted">{_escape(file_path)}</p>- <p><strong>Why it matters.</strong> {_escape(c.get("why"))}</p>- {what_block}- {takeaway_block}- {rationale_block}- </div>"""))- return f"""<section id="important-changes">- <h2>Important changes — detailed</h2>- {"".join(cards)}- </section>"""---def render_decisions(items: list[dict]) -> str:- if not items:- return ""- callouts = []- for d in items:- body = d.get("body", "")- if d.get("inferred"):- body = body + ' <span class="muted">(inferred — not stated by the author.)</span>'- callouts.append(- '<div class="callout callout-rationale">'- f'<strong>{_escape(d.get("title"))}</strong> {body}</div>'- )- return f"""<section id="decisions">- <h2>Key decisions</h2>- {"".join(callouts)}- </section>"""---def render_findings_table(findings: list[dict]) -> str:- if not findings:- return ""- rows = []- for f in findings:- rows.append(- "<tr>"- f'<td>{_severity_pill(f.get("severity", "nit"))}</td>'- f'<td>{_escape(f.get("area"))}</td>'- f'<td>{_escape(f.get("finding"))}</td>'- f'<td>{_escape(f.get("resolution"))}</td>'- "</tr>"- )- return f"""<section id="findings">- <h2>Review findings</h2>- <table class="findings">- <thead><tr><th>Severity</th><th>Area</th><th>Finding</th><th>Resolution</th></tr></thead>- <tbody>{"".join(rows)}</tbody>- </table>- </section>"""---def render_unresolved_comments(items: list[dict]) -> str:- if not items:- return ""-- type_labels = {"code": "code", "review": "review", "discussion": "discussion"}-- cards = []- for c in items:- ctype = (c.get("type") or "discussion").lower()- label = type_labels.get(ctype, ctype)- author = c.get("author") or "(unknown)"- url = c.get("url")- created = c.get("created_at")- path = c.get("path")- line = c.get("line")-- location_bits = []- if path:- loc = _escape(path)- if line:- loc += f":{_escape(line)}"- location_bits.append(f'<code>{loc}</code>')- if created:- location_bits.append(f'<span class="muted">{_escape(created)}</span>')- if url:- location_bits.append(- f'<a href="{_escape(url)}" target="_blank" rel="noopener">view on GitHub</a>'- )- location_line = " · ".join(location_bits)-- body = _escape(c.get("body") or "").replace("\n", "<br>")-- replies_html = ""- replies = c.get("replies") or []- if replies:- reply_blocks = []- for r in replies:- r_author = _escape(r.get("author") or "(unknown)")- r_created = r.get("created_at")- header_bits = [f'<strong>{r_author}</strong>']- if r_created:- header_bits.append(f'<span class="muted">{_escape(r_created)}</span>')- r_body = _escape(r.get("body") or "").replace("\n", "<br>")- reply_blocks.append(- '<div class="reply">'- f'<div class="reply-header">{" · ".join(header_bits)}</div>'- f'<div class="reply-body">{r_body}</div>'- '</div>'- )- replies_html = (- '<details class="replies">'- f'<summary>{len(replies)} earlier repl'- f'{"y" if len(replies) == 1 else "ies"}</summary>'- f'{"".join(reply_blocks)}'- '</details>'- )-- cards.append(- '<div class="unresolved-comment">'- '<div class="unresolved-header">'- f'<span class="pill pill-warning">{_escape(label)}</span> '- f'<strong>{_escape(author)}</strong>'- f'{" · " + location_line if location_line else ""}'- '</div>'- f'<div class="unresolved-body">{body}</div>'- f'{replies_html}'- '</div>'- )-- return f"""<section id="unresolved-comments">- <h2>Unresolved comments</h2>- <p class="muted">Open review threads and PR-level comments still awaiting a response.</p>- {"".join(cards)}- </section>"""---def render_double_check(items: list[dict]) -> str:- if not items:- return ""- callouts = "".join(- '<div class="callout callout-warning">'- f'<strong>{_escape(d.get("title"))}</strong> {d.get("body", "")}</div>'- for d in items- )- return f"""<section id="double-check">- <h2>Things to double-check</h2>- {callouts}- </section>"""---def render_files(files: list[dict], diff_dir: Path | None) -> str:- if not files:- return ""- blocks = []- for f in files:- path = f.get("path", "")- badge = f.get("badge", "Modified")- stat = f.get("stat", "")- diff = f.get("diff")- if diff is None and f.get("diff_file") and diff_dir is not None:- fragment = diff_dir / f["diff_file"]- try:- diff = fragment.read_text(encoding="utf-8")- except FileNotFoundError:- diff = f"(diff fragment {f['diff_file']!r} missing)"- if diff is None:- diff = "(no diff provided)"- anchor = _file_anchor(path)- badge_class = "badge-" + "".join(ch for ch in badge.lower() if ch.isalnum())- blocks.append(textwrap.dedent(f"""\- <details id="{anchor}" class="file-diff">- <summary><span class="file-path">{_escape(path)}</span> <span class="badge {badge_class}">{_escape(badge)}</span> <span class="line-stat">{_escape(stat)}</span></summary>- <pre><code class="diff-block">{_render_diff(diff)}</code></pre>- </details>"""))- return f"""<section id="diffs">- <h2>Per-file diffs</h2>- <p class="muted">Click to expand.</p>- {"".join(blocks)}- </section>"""---def render_publish_metadata(meta: dict) -> str:- """Emit a <script id="review-meta"> block consumable by `pulsar publish`.-- See pulsar's docs/agent-contract.md. Fields are passed through verbatim,- minus a `</` escape inside the JSON to prevent premature </script> closure.- """- if not meta:- return ""- payload = json.dumps(meta, indent=2, ensure_ascii=False).replace("</", "<\\/")- return (- f'<script type="application/json" id="review-meta">\n{payload}\n</script>'- )---def build_toc(entries: list[tuple[str, str]]) -> str:- if not entries:- return ""- items = "\n".join(- f'<li><a href="#{sid}">{_escape(label)}</a></li>'- for sid, label in entries- )- return f'<nav class="toc"><ul>{items}</ul></nav>'---# --- CSS (Prism Dark palette) -----------------------------------------------CSS = """-:root {- --bg: #0B1020;- --bg-deep: #050C1B;- --surface-1: #101A33;- --surface-2: #142042;- --border: #26324F;- --border-subtle: #1F2A45;- --text-primary: #EAF1FF;- --text-secondary: #B7C3E3;- --text-tertiary: #7F8BB0;- --accent: #44C4DC;- --accent-2: #E474E4;- --accent-3: #4C6CBC;- --code-bg: #0A1226;- --code-border: #1F2A45;- --success: #22C55E;- --warning: #FBBF24;- --error: #EF476F;- --diff-add-bg: rgba(34, 197, 94, 0.12);- --diff-add-fg: #86EFAC;- --diff-del-bg: rgba(239, 71, 111, 0.12);- --diff-del-fg: #FCA5A5;-}--* { box-sizing: border-box; }-html { background: var(--bg); }-body {- background: var(--bg);- color: var(--text-primary);- font-family: -apple-system, "SF Pro Text", system-ui, sans-serif;- line-height: 1.6;- margin: 0;- padding: 0;-}--.page { max-width: 1100px; margin: 0 auto; padding: 32px; }--header.top-bar {- position: sticky; top: 0; z-index: 10;- background: var(--bg-deep);- border-bottom: 1px solid var(--border-subtle);-}-.top-stripe { height: 4px; background: linear-gradient(135deg, var(--accent-3), var(--accent-2)); }-.top-content {- display: flex; gap: 16px; flex-wrap: wrap; align-items: center;- padding: 14px 32px; max-width: 1100px; margin: 0 auto;-}-.top-content .repo-title { font-weight: 600; font-size: 15px; color: var(--text-primary); }--.chip {- display: inline-flex; align-items: center; gap: 6px;- background: var(--surface-1); border: 1px solid var(--border-subtle);- border-radius: 999px; padding: 4px 12px; font-size: 12px; color: var(--text-secondary);-}-.chip strong { color: var(--text-primary); }--h1, h2, h3 { color: var(--text-primary); line-height: 1.3; }-h1 { font-size: 30px; margin: 8px 0 4px; }-h2 { font-size: 22px; margin: 32px 0 12px; padding-top: 12px; border-top: 1px solid var(--border-subtle); }-h3 { font-size: 17px; margin: 16px 0 8px; }--p { color: var(--text-secondary); }-strong { color: var(--text-primary); }-a { color: var(--accent); text-decoration: none; }-a:hover { text-decoration: underline; }-.muted { color: var(--text-tertiary); }-code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }--.card-grid {- display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;- margin: 24px 0 32px;-}--.card {- background: var(--surface-1); border: 1px solid var(--border);- border-radius: 16px; padding: 20px;- transition: background-color 120ms ease;-}-.card:hover { background: var(--surface-2); }-.card h3 {- margin-top: 0; font-size: 14px;- text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary);-}-.card ul { margin: 8px 0 0; padding-left: 20px; }-.card li { margin: 4px 0; color: var(--text-secondary); }--.tag {- display: inline-block;- background: rgba(228, 116, 228, 0.15);- color: var(--accent-2);- border-radius: 4px;- padding: 0 6px;- font-size: 11px;- font-weight: 600;- text-transform: uppercase;- margin-right: 6px;-}--.verdict-pill {- display: inline-flex; align-items: center; gap: 8px;- padding: 10px 18px; border-radius: 999px;- font-weight: 600; font-size: 14px;- text-transform: uppercase; letter-spacing: 0.05em;-}-.verdict-success { background: rgba(34, 197, 94, 0.18); color: var(--success); border: 1px solid rgba(34,197,94,0.35); }-.verdict-warning { background: rgba(251, 191, 36, 0.15); color: var(--warning); border: 1px solid rgba(251,191,36,0.35); }-.verdict-error { background: rgba(239, 71, 111, 0.15); color: var(--error); border: 1px solid rgba(239,71,111,0.35); }--.toc {- background: var(--surface-1); border: 1px solid var(--border);- border-radius: 16px; padding: 16px 20px; margin: 0 0 24px;-}-.toc ul { list-style: none; padding-left: 0; margin: 0; column-count: 2; column-gap: 24px; }-.toc li { margin: 4px 0; }-.toc a { color: var(--text-secondary); }-.toc a:hover { color: var(--accent); }--.tabs {- margin: 16px 0; border: 1px solid var(--border);- border-radius: 16px; background: var(--surface-1);-}-.tabs input[type="radio"] { display: none; }-.tab-labels { display: flex; border-bottom: 1px solid var(--border-subtle); }-.tab-labels label {- padding: 12px 20px; cursor: pointer; color: var(--text-tertiary);- font-weight: 600; font-size: 13px;- text-transform: uppercase; letter-spacing: 0.06em;- border-bottom: 2px solid transparent; margin-bottom: -1px;-}-.tab-labels label:hover { color: var(--text-secondary); }--#tab-beginner:checked ~ .tab-labels label[for="tab-beginner"],-#tab-intermediate:checked ~ .tab-labels label[for="tab-intermediate"],-#tab-expert:checked ~ .tab-labels label[for="tab-expert"] {- color: var(--accent);- border-bottom-color: var(--accent);-}--.tab-panels { padding: 20px; }-.tab-panel { display: none; }-#tab-beginner:checked ~ .tab-panels #panel-beginner,-#tab-intermediate:checked ~ .tab-panels #panel-intermediate,-#tab-expert:checked ~ .tab-panels #panel-expert {- display: block;-}--.change-card {- background: var(--surface-1); border: 1px solid var(--border);- border-radius: 16px; padding: 20px; margin: 16px 0;- transition: background-color 120ms ease;-}-.change-card:hover { background: var(--surface-2); }--.callout {- margin: 12px 0; padding: 10px 14px;- background: var(--bg-deep);- border-radius: 6px; font-size: 14px; color: var(--text-secondary);-}-.callout-takeaway { border-left: 3px solid var(--accent-2); }-.callout-rationale { border-left: 3px solid var(--accent); }-.callout-warning { border-left: 3px solid var(--warning); }--table.findings {- width: 100%; border-collapse: collapse;- margin: 16px 0; background: var(--surface-1);- border: 1px solid var(--border); border-radius: 12px; overflow: hidden;-}-table.findings th, table.findings td {- padding: 10px 14px; text-align: left;- border-bottom: 1px solid var(--border-subtle);- font-size: 14px; vertical-align: top;-}-table.findings th {- background: var(--surface-2); color: var(--text-secondary);- text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em;-}-table.findings tr:last-child td { border-bottom: none; }--.pill {- display: inline-block; padding: 2px 10px; border-radius: 999px;- font-size: 11px; font-weight: 600;- text-transform: uppercase; letter-spacing: 0.06em;-}-.pill-error { background: rgba(239,71,111,0.18); color: var(--error); }-.pill-warning { background: rgba(251,191,36,0.18); color: var(--warning); }-.pill-success { background: rgba(34,197,94,0.18); color: var(--success); }-.pill-tertiary { background: rgba(127,139,176,0.18); color: var(--text-tertiary); }--.unresolved-comment {- background: var(--surface-1); border: 1px solid var(--border);- border-left: 3px solid var(--warning);- border-radius: 10px; padding: 14px 16px; margin: 12px 0;-}-.unresolved-header {- display: flex; flex-wrap: wrap; gap: 8px; align-items: center;- font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;-}-.unresolved-header code {- background: var(--code-bg); color: var(--accent);- padding: 1px 6px; border-radius: 4px; font-size: 12px;-}-.unresolved-body {- color: var(--text-primary); font-size: 14px; line-height: 1.55;- white-space: pre-wrap; word-break: break-word;- font-family: ui-monospace, "SF Mono", Menlo, monospace;-}-.replies {- margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-subtle);-}-.replies > summary {- cursor: pointer; color: var(--text-tertiary); font-size: 12px;-}-.reply { margin: 8px 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border-subtle); }-.reply-header { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }-.reply-body {- color: var(--text-primary); font-size: 13px;- white-space: pre-wrap; word-break: break-word;- font-family: ui-monospace, "SF Mono", Menlo, monospace;-}--.commit-list { list-style: none; padding-left: 0; }-.commit-list li { padding: 6px 0; border-bottom: 1px solid var(--border-subtle); }-.commit-list li:last-child { border-bottom: none; }-.commit-sha {- color: var(--accent); background: var(--code-bg);- padding: 2px 6px; border-radius: 4px;- font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px;-}-.commit-subj { color: var(--text-primary); }-.commit-meta { color: var(--text-tertiary); font-size: 13px; }--.file-diff {- background: var(--surface-1); border: 1px solid var(--border);- border-radius: 12px; margin: 8px 0; overflow: hidden;-}-.file-diff > summary {- cursor: pointer; padding: 12px 16px; font-weight: 500;- list-style: none; display: flex; gap: 12px; align-items: center;-}-.file-diff > summary::-webkit-details-marker { display: none; }-.file-diff > summary::before {- content: "▸"; color: var(--text-tertiary); font-size: 12px; margin-right: 4px;-}-.file-diff[open] > summary::before { content: "▾"; }-.file-path { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--text-primary); }-.line-stat {- color: var(--text-tertiary);- font-family: ui-monospace, "SF Mono", Menlo, monospace;- font-size: 12px; margin-left: auto;-}--.badge {- display: inline-block; font-size: 10px;- text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;- padding: 2px 8px; border-radius: 4px;-}-.badge-added { background: rgba(34,197,94,0.18); color: var(--success); }-.badge-modified { background: rgba(68,196,220,0.18); color: var(--accent); }-.badge-deleted { background: rgba(239,71,111,0.18); color: var(--error); }-.badge-renamed { background: rgba(76,108,188,0.18); color: var(--accent-3); }--.file-diff pre {- margin: 0; background: var(--code-bg);- border-top: 1px solid var(--code-border);- padding: 12px 0; overflow-x: auto; line-height: 1.45;-}-.file-diff code {- font-family: ui-monospace, "SF Mono", Menlo, monospace;- font-size: 13px; color: var(--text-secondary);- display: inline-block; min-width: 100%;-}-.diff-line {- display: block;- padding: 0 16px;- min-height: 1.45em;-}-.diff-add { background: var(--diff-add-bg); color: var(--diff-add-fg); }-.diff-del { background: var(--diff-del-bg); color: var(--diff-del-fg); }-.diff-hunk { color: var(--accent); }-.diff-file-header { color: var(--text-tertiary); }-.diff-meta { color: var(--text-tertiary); }-.diff-context { color: var(--text-secondary); }--.pr-description {- background: var(--surface-1);- border: 1px solid var(--border);- border-left: 3px solid var(--accent-2);- border-radius: 12px;- padding: 16px 20px;- margin: 8px 0 16px;-}-.pr-description .meta {- color: var(--text-tertiary);- font-size: 12px;- margin-bottom: 12px;-}-.pr-description pre {- margin: 0;- background: transparent;- font-family: ui-monospace, "SF Mono", Menlo, monospace;- font-size: 13px;- line-height: 1.55;- color: var(--text-secondary);- white-space: pre-wrap;- word-wrap: break-word;-}-.pr-description pre code,-.pr-description pre a { color: var(--text-primary); }--footer {- margin-top: 64px; padding-top: 24px;- border-top: 1px solid var(--border-subtle);- color: var(--text-tertiary); font-size: 13px;-}--@media (max-width: 720px) {- .card-grid { grid-template-columns: 1fr; }- .toc ul { column-count: 1; }- .page { padding: 16px; }-}-@media (prefers-reduced-motion: reduce) {- * { transition: none !important; }-}-"""---# --- top-level template ------------------------------------------------------PAGE_TEMPLATE = Template("""<!doctype html>-<html lang="en">-<head>-<meta charset="utf-8">-<title>$title_plain</title>-<meta name="viewport" content="width=device-width, initial-scale=1">-$publish_metadata-<style>$css</style>-</head>-<body>-<header class="top-bar">- <div class="top-stripe"></div>- <div class="top-content">- <span class="repo-title">$repo_name</span>- $metrics_chips- </div>-</header>--<div class="page">- <h1>$title_html</h1>- <p class="muted">$subtitle</p>-- <section class="card-grid">- $at_a_glance- $important_links- $verdict_card- $findings_summary- </section>-- $toc-- $pr_description_section- $commits_section- $explanation_section- $important_changes_section- $decisions_section- $findings_section- $unresolved_comments_section- $files_section- $double_check_section-- <footer>- Generated $timestamp · repo <code>$repo_path</code> · regenerate with <code>/pre-push-review</code>.- </footer>-</div>-</body>-</html>-""")---def render(data: dict, diff_dir: Path | None) -> str:- repo = data.get("repo", {})- repo_name = _escape(repo.get("name", "(repo)"))- repo_path = _escape(repo.get("path", ""))-- title = data.get("title") or f"Pre-push review: {repo.get('name', '')}"-- important_changes = data.get("important_changes", [])- findings = data.get("findings", [])-- sections = {- "pr-description": render_pr_description(data.get("pr_description", {})),- "commits": render_commits(data.get("commits", [])),- "explanation": render_explanation(data.get("explanation", {})),- "important-changes": render_important_changes(important_changes),- "decisions": render_decisions(data.get("decisions", [])),- "findings": render_findings_table(findings),- "unresolved-comments": render_unresolved_comments(data.get("unresolved_comments", [])),- "diffs": render_files(data.get("files", []), diff_dir),- "double-check": render_double_check(data.get("double_check", [])),- }-- toc_labels = {- "pr-description": "Author's description",- "commits": "Commits",- "explanation": "Three-level explanation",- "important-changes": "Important changes (detailed)",- "decisions": "Key decisions",- "findings": "Review findings",- "unresolved-comments": "Unresolved comments",- "diffs": "Per-file diffs",- "double-check": "Things to double-check",- }- toc_entries = [(sid, toc_labels[sid]) for sid in sections if sections[sid]] - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")+# ~/.claude/scripts is a symlink into the repository; resolve the real+# location so the package is found under -P or when invoked via the link.+sys.path.insert(0, str(Path(__file__).resolve().parent)) - return PAGE_TEMPLATE.substitute(- title_plain=_escape(title),- title_html=_escape(title),- css=CSS,- publish_metadata=render_publish_metadata(data.get("publish_metadata", {})),- repo_name=repo_name,- repo_path=repo_path,- subtitle=data.get("subtitle", ""),- metrics_chips=render_metrics(data.get("metrics", [])),- at_a_glance=render_at_a_glance(data.get("at_a_glance", [])),- important_links=render_important_links(important_changes),- verdict_card=render_verdict_card(data.get("verdict", {})),- findings_summary=render_findings_summary_card(findings),- toc=build_toc(toc_entries),- pr_description_section=sections["pr-description"],- commits_section=sections["commits"],- explanation_section=sections["explanation"],- important_changes_section=sections["important-changes"],- decisions_section=sections["decisions"],- findings_section=sections["findings"],- unresolved_comments_section=sections["unresolved-comments"],- files_section=sections["diffs"],- double_check_section=sections["double-check"],- timestamp=timestamp,- )+import review_html # noqa: E402 def main() -> int:@@ -969,10 +94,14 @@ def main() -> int: return 1 diff_dir = args.diff_dir if args.diff_dir is not None else args.data.parent- data = json.loads(args.data.read_text(encoding="utf-8"))+ try:+ data = json.loads(args.data.read_text(encoding="utf-8"))+ except (ValueError, OSError) as exc:+ print(f"error: {args.data}: {exc}", file=sys.stderr)+ return 2 args.output.parent.mkdir(parents=True, exist_ok=True)- args.output.write_text(render(data, diff_dir), encoding="utf-8")+ args.output.write_text(review_html.render(data, diff_dir), encoding="utf-8") print(str(args.output)) return 0
diff --git a/scripts/ecosystems.json b/scripts/ecosystems.jsonnew file mode 100644index 0000000..ee3fa78--- /dev/null+++ b/scripts/ecosystems.json@@ -0,0 +1,150 @@+{+ "go": {+ "extensions": [".go"],+ "test_files": ["_test\\.go$"],+ "test_decl": "^func ((?:Test|Fuzz|Benchmark)\\w+)\\s*\\(",+ "unit": {"kind": "module_file", "module_file": "go.mod", "module_regex": "^module\\s+(\\S+)"},+ "imports": [+ {"regex": "^\\s*(?:import\\s+)?(?:[\\w.]+\\s+)?\"([^\"]+)\"", "resolve": "unit"}+ ],+ "tool": {"name": "go list", "deps": "go list -json ./...", "format": "go-list-json", "granularity": "package"},+ "runners": [+ {+ "name": "gotestsum",+ "detect": {"files": ["go.mod"]},+ "recipe": "gotestsum --junitfile {junit} -- -coverprofile={coverage} -coverpkg=./... ./...",+ "requires": ["go", "gotestsum"],+ "coverage_format": "coverprofile",+ "install": "go mod download",+ "junit_flags": ["--junitfile"]+ }+ ],+ "notes": [+ "Imports name packages, so scanned edges expand to every file in the package.",+ "go list needs a resolvable module graph; when it fails the scanned edges stay in place with a warning.",+ "gotestsum writes the JUnit file and go test writes the coverprofile even when tests fail."+ ]+ },+ "python": {+ "extensions": [".py"],+ "test_files": ["(^|/)test_[^/]*\\.py$", "_test\\.py$", "(^|/)tests?/", "(^|/)conftest\\.py$"],+ "test_decl": "^\\s*(?:async\\s+)?def (test\\w*)\\s*\\(",+ "unit": {"kind": "directory"},+ "imports": [+ {"regex": "^\\s*from\\s+([\\w.]+)\\s+import\\s+\\(?\\s*(\\w+)", "resolve": "roots", "separator": "."},+ {"regex": "^\\s*import\\s+([\\w.]+)", "resolve": "roots", "separator": "."}+ ],+ "source_roots": [".", "src"],+ "index_files": ["__init__.py"],+ "runners": [+ {+ "name": "pytest",+ "detect": {"files": ["pytest.ini", "conftest.py", "pyproject.toml", "setup.cfg", "tox.ini"]},+ "recipe": "python3 -m pytest --junitxml={junit} --cov --cov-report=xml:{coverage}",+ "requires": ["python3", "pytest"],+ "coverage_format": "cobertura",+ "install": "python3 -m pip install -e . pytest pytest-cov",+ "junit_flags": ["--junitxml", "--junit-xml"]+ }+ ],+ "notes": [+ "Absolute imports resolve under the repository root and src/; namespace packages elsewhere yield no edge.",+ "The recipe needs pytest-cov; when it is not installed drop the two --cov flags, run without coverage, and leave the coverage list empty.",+ "The install line assumes a pyproject.toml or setup.py; a project that installs from requirements files needs its own install command."+ ]+ },+ "typescript": {+ "extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],+ "test_files": ["\\.(test|spec)\\.[tj]sx?$", "(^|/)__tests__/"],+ "test_decl": "^\\s*(?:it|test)\\(\\s*['\"`]([^'\"`]+)",+ "unit": {"kind": "directory"},+ "imports": [+ {"regex": "(?:from|import|require\\()\\s*['\"]([^'\"]+)['\"]", "resolve": "relative"}+ ],+ "index_files": ["index.ts", "index.tsx", "index.js"],+ "extension_map": {".js": [".ts", ".tsx", ".js"], ".jsx": [".tsx", ".jsx"]},+ "runners": [+ {+ "name": "vitest",+ "detect": {"files": ["vitest.config.*", "vitest.workspace.*"]},+ "recipe": "npx --no-install vitest run --reporter=junit --outputFile={junit} --coverage --coverage.reporter=lcov --coverage.reportsDirectory={inputs}/cov",+ "requires": ["npx"],+ "coverage_format": "lcov",+ "install": "npm ci",+ "junit_flags": ["--reporter=junit", "--reporter junit"]+ },+ {+ "name": "jest",+ "detect": {"files": ["jest.config.*"], "package_json_keys": ["jest"]},+ "recipe": "npx --no-install jest --ci --reporters=default --reporters=jest-junit --coverage --coverageReporters=lcov --coverageDirectory={inputs}/cov",+ "requires": ["npx"],+ "coverage_format": "lcov",+ "install": "npm ci",+ "junit_flags": ["--reporters=jest-junit", "--reporters jest-junit"],+ "env": {"JEST_JUNIT_OUTPUT_FILE": "{junit}"}+ }+ ],+ "notes": [+ "Only relative imports resolve; path aliases from tsconfig paths yield no edge.",+ "Both runners write lcov to {inputs}/cov/lcov.info; reference that file as the coverage input.",+ "npx --no-install fails rather than downloading a runner the project does not declare; vitest coverage needs @vitest/coverage-v8 or @vitest/coverage-istanbul, and jest needs jest-junit, both as project dependencies.",+ "A repository using pnpm or yarn needs the matching install command in place of npm ci."+ ]+ },+ "swift": {+ "extensions": [".swift"],+ "test_files": ["(^|/)Tests/", "Tests\\.swift$"],+ "test_decl": "(?:@Test\\b[^{]*?func\\s+|^\\s*func\\s+(?=test))(\\w+)",+ "unit": {"kind": "target_root", "target_root": "Sources"},+ "imports": [+ {"regex": "^\\s*(?:@testable\\s+)?import\\s+(\\w+)", "resolve": "unit"}+ ],+ "runners": [+ {+ "name": "swift test",+ "detect": {"files": ["Package.swift"]},+ "recipe": "swift test --enable-code-coverage --xunit-output {junit} && xcrun llvm-cov export -format=lcov -instr-profile .build/debug/codecov/default.profdata .build/debug/*.xctest/Contents/MacOS/*PackageTests > {coverage}",+ "requires": ["swift", "xcrun"],+ "coverage_format": "lcov",+ "install": "swift package resolve",+ "junit_flags": ["--xunit-output"]+ }+ ],+ "notes": [+ "Swift files inside one target never import each other, so only cross-target edges appear.",+ "Xcode projects without Sources/<Target> yield no unit and fall to directory grouping.",+ "The xctest bundle path in the recipe is the macOS layout; on Linux the test binary is .build/debug/*PackageTests.xctest itself. Coverage is exported only when the tests pass, the JUnit file is written either way.",+ "Swift 6 writes Swift Testing (@Test) results to a second file beside {junit} with a -swift-testing suffix; reference both files as JUnit inputs."+ ]+ },+ "rust": {+ "extensions": [".rs"],+ "test_files": ["(^|/)tests/", "_test\\.rs$"],+ "test_decl": "#\\[(?:\\w+::)?test\\]\\s*\\n\\s*(?:pub\\s+)?(?:async\\s+)?fn\\s+(\\w+)",+ "unit": {"kind": "directory"},+ "imports": [+ {"regex": "^\\s*(?:pub(?:\\([^)]*\\))?\\s+)?mod\\s+(\\w+)\\s*;", "resolve": "relative"},+ {"regex": "^\\s*(?:pub(?:\\([^)]*\\))?\\s+)?use\\s+crate::([\\w:]+)", "resolve": "roots", "separator": "::"}+ ],+ "source_roots": ["src"],+ "index_files": ["mod.rs"],+ "runners": [+ {+ "name": "cargo nextest",+ "detect": {"files": ["Cargo.toml"]},+ "recipe": "cargo llvm-cov nextest --workspace --lcov --output-path {coverage} --config-file {inputs}/nextest.toml",+ "requires": ["cargo", "cargo-nextest", "cargo-llvm-cov"],+ "coverage_format": "lcov",+ "install": "cargo fetch",+ "junit_flags": ["--config-file"],+ "config_files": {"nextest.toml": "[profile.default.junit]\npath = \"{junit}\"\n"}+ }+ ],+ "notes": [+ "use crate:: resolves under src/ at the repository root; workspace crates elsewhere yield no edge.",+ "#[cfg(test)] modules inside a source file are not flagged as test files.",+ "nextest emits JUnit only through its config file, so a Makefile target passing --config-file counts as a JUnit recipe only when the named file sets profile.<name>.junit.path.",+ "cargo llvm-cov needs the llvm-tools-preview rustup component; cargo-nextest and cargo-llvm-cov are separate installs."+ ]+ }+}
diff --git a/scripts/review_html/__init__.py b/scripts/review_html/__init__.pynew file mode 100644index 0000000..5814b33--- /dev/null+++ b/scripts/review_html/__init__.py@@ -0,0 +1,10 @@+"""Renderer for the review HTML page used by the review skills.++The package is imported by ``scripts/build_review_html.py``, which owns the+command line; ``render`` is the only public entry point.+"""+from __future__ import annotations++from .render import render++__all__ = ["render"]
diff --git a/scripts/review_html/common.py b/scripts/review_html/common.pynew file mode 100644index 0000000..38a902e--- /dev/null+++ b/scripts/review_html/common.py@@ -0,0 +1,34 @@+"""Helpers shared by every renderer module.++``sections.py`` and ``diagram.py`` both import from here; nothing here imports+from them.+"""+from __future__ import annotations++import hashlib+import html+++def escape(s: object) -> str:+ """HTML-escape a value for text or attribute context; ``None`` becomes ``""``."""+ return html.escape("" if s is None else str(s), quote=True)+++def digest(s: str) -> str:+ """First 10 hex characters of the SHA-1 of ``s``; shared by anchors and node ids."""+ return hashlib.sha1(s.encode("utf-8")).hexdigest()[:10]+++def file_anchor(path: str) -> str:+ return "file-" + digest(path)+++def severity_pill(sev: str) -> str:+ klass = {+ "blocking": "error",+ "major": "error",+ "minor": "warning",+ "nit": "tertiary",+ "info": "tertiary",+ }.get(sev.lower(), "tertiary")+ return f'<span class="pill pill-{klass}">{escape(sev)}</span>'
diff --git a/scripts/review_html/coverage.py b/scripts/review_html/coverage.pynew file mode 100644index 0000000..2537c8c--- /dev/null+++ b/scripts/review_html/coverage.py@@ -0,0 +1,291 @@+"""Coverage parsing, path mapping, matching against changed files, arithmetic.++The order is mapping → matching → merging: ``apply_path_map`` rewrites+paths, ``match`` decides which entries belong to which changed file in five+global passes, and only its last pass (and ``overall``) sums hits.+"""+from __future__ import annotations++import posixpath+from dataclasses import dataclass+from pathlib import Path++from .inputs import read_guarded, xml_root+from .warnings import Warnings+++@dataclass+class Entry:+ paths: list[str] # primary path first, then aliases+ hits: dict[int, int]+++Coverage = list[Entry]++NO_CANDIDATE = "no candidate"+AMBIGUOUS = "ambiguous"+++# --- parsing ---------------------------------------------------------------++def _sniff(path: Path) -> str | None:+ """``"xml"``, ``"lcov"``, ``"coverprofile"``, or ``None`` from the file head."""+ try:+ with path.open("rb") as fh:+ head = fh.read(4096)+ except OSError:+ return None+ text = head.decode("utf-8", errors="replace").lstrip("\N{ZERO WIDTH NO-BREAK SPACE} \t\r\n")+ if text.startswith("<"):+ return "xml"+ if text.startswith(("TN:", "SF:")):+ return "lcov"+ if text.startswith("mode: "):+ return "coverprofile"+ return None+++def parse_coverage(path: Path, warnings: Warnings) -> Coverage:+ kind = _sniff(path)+ text = read_guarded(path, warnings, xml=(kind == "xml"))+ if text is None:+ return []+ if kind == "lcov":+ return _parse_lcov(text)+ if kind == "coverprofile":+ return _parse_coverprofile(text, path.name, warnings)+ if kind == "xml":+ return _parse_cobertura(text, path.name, warnings)+ warnings.add(f"{path.name}: skipped, not lcov, Cobertura XML, or Go coverprofile")+ return []+++def _parse_lcov(text: str) -> Coverage:+ cov: Coverage = []+ current: Entry | None = None+ for line in text.splitlines():+ if line.startswith("SF:"):+ current = Entry([line[3:].strip()], {})+ cov.append(current)+ elif line.startswith("DA:") and current is not None:+ number, _, rest = line[3:].partition(",")+ count = rest.partition(",")[0]+ try:+ n = int(number)+ h = int(count)+ except ValueError:+ continue+ current.hits[n] = current.hits.get(n, 0) + h+ elif line.startswith("end_of_record"):+ current = None+ return [e for e in cov if e.paths[0]]+++def _parse_coverprofile(text: str, name: str, warnings: Warnings) -> Coverage:+ lines = text.splitlines()+ if not lines or not lines[0].startswith("mode: "):+ warnings.add(f"{name}: skipped, coverprofile has no mode line")+ return []+ entries: dict[str, Entry] = {}+ for line in lines[1:]:+ line = line.strip()+ if not line:+ continue+ file, sep, rest = line.rpartition(":")+ if not sep:+ continue+ parts = rest.split()+ if len(parts) != 3:+ continue+ try:+ start, end = parts[0].split(",")+ sl = int(start.split(".")[0])+ el = int(end.split(".")[0])+ count = int(parts[2])+ except ValueError:+ continue+ entry = entries.get(file)+ if entry is None:+ entry = entries[file] = Entry([file], {})+ for n in range(sl, el + 1):+ if count > entry.hits.get(n, -1):+ entry.hits[n] = count+ return list(entries.values())+++def _parse_cobertura(text: str, name: str, warnings: Warnings) -> Coverage:+ root = xml_root(text, name, warnings, ("coverage",), "coverage XML", "Cobertura <coverage>")+ if root is None:+ return []+ sources = [s.text.strip() for s in root.iter("source") if s.text and s.text.strip()]+ cov: Coverage = []+ for cls in root.iter("class"):+ filename = cls.get("filename")+ if not filename:+ continue+ hits: dict[int, int] = {}+ for line in cls.findall("lines/line"):+ try:+ n = int(line.get("number", ""))+ h = int(float(line.get("hits", "0")))+ except ValueError:+ continue+ if h > hits.get(n, -1):+ hits[n] = h+ paths = [filename] + [f"{s}/{filename}" for s in sources]+ cov.append(Entry(paths, hits))+ return cov+++# --- normalisation and mapping --------------------------------------------++def normalise(path: str) -> str:+ return posixpath.normpath(path.replace("\\", "/"))+++def apply_path_map(cov: Coverage, strip: str | None, prepend: str | None) -> Coverage:+ """Normalise every path, then strip and prepend whole leading segments."""+ strip = normalise(strip) if strip else None+ prepend = normalise(prepend) if prepend else None+ out: Coverage = []+ for e in cov:+ paths = []+ for p in e.paths:+ p = normalise(p)+ if strip:+ p = p.removeprefix(strip + "/")+ if prepend:+ p = normalise(f"{prepend}/{p}")+ paths.append(p)+ out.append(Entry(paths, e.hits))+ return out+++# --- matching ---------------------------------------------------------------++def _segments(path: str) -> tuple[str, ...]:+ return tuple(path.split("/"))+++def _residual(e: tuple[str, ...], c: tuple[str, ...]) -> tuple[str, tuple[str, ...]] | None:+ """Direction and uncovered segments when one segment tuple is a whole-segment suffix of the other."""+ if len(e) > len(c) and e[-len(c):] == c:+ return ("entry", e[:-len(c)])+ if len(c) > len(e) and c[-len(e):] == e:+ return ("file", c[:-len(e)])+ return None+++def _merge(entries: list[Entry]) -> dict[int, int]:+ merged: dict[int, int] = {}+ for e in entries:+ for n, h in e.hits.items():+ merged[n] = merged.get(n, 0) + h+ return merged+++def match(cov: Coverage, changed: list[str]) -> tuple[dict[str, dict[int, int]], dict[str, str]]:+ """Five global passes: exact, pools, shared removal, residuals, merge.++ Returns merged hits per matched changed file and the reason+ (``"no candidate"`` or ``"ambiguous"``) per unmatched file.+ """+ matched: dict[str, dict[int, int]] = {}+ unmatched: dict[str, str] = {}+ norm = {c: normalise(c) for c in changed}+ epaths = [[normalise(p) for p in e.paths] for e in cov]+ esegs = [[_segments(p) for p in paths] for paths in epaths]+ pool = set(range(len(cov)))+ # Entry indexes by path (pass 1) and by last segment (pass 2): a+ # whole-segment suffix relation needs equal last segments, so only+ # those entries can hold a residual for a changed file.+ by_path: dict[str, list[int]] = {}+ by_base: dict[str, list[int]] = {}+ for i, paths in enumerate(epaths):+ for p in set(paths):+ by_path.setdefault(p, []).append(i)+ for base in {segs[-1] for segs in esegs[i]}:+ by_base.setdefault(base, []).append(i)++ # 1. Exact.+ exact_files: dict[int, list[str]] = {}+ for c in changed:+ for i in by_path.get(norm[c], ()):+ exact_files.setdefault(i, []).append(c)+ for i, files in exact_files.items():+ if len(files) > 1:+ for c in files:+ unmatched[c] = AMBIGUOUS+ pool.discard(i)+ for c in changed:+ if c in unmatched:+ continue+ hits = [i for i in by_path.get(norm[c], ()) if i in pool]+ if hits:+ matched[c] = _merge([cov[i] for i in hits])+ pool.difference_update(hits)+ remaining = [c for c in changed if c not in matched and c not in unmatched]++ # 2. Pools with residuals.+ pools: dict[str, dict[int, tuple[str, tuple[str, ...]]]] = {}+ for c in remaining:+ pools[c] = {}+ csegs = _segments(norm[c])+ for i in by_base.get(csegs[-1], ()):+ if i not in pool:+ continue+ for segs in esegs[i]:+ residual = _residual(segs, csegs)+ if residual is not None:+ pools[c][i] = residual+ break++ # 3. Shared entries leave every pool, once, without cascading.+ seen_in: dict[int, int] = {}+ for c in remaining:+ for i in pools[c]:+ seen_in[i] = seen_in.get(i, 0) + 1+ shared = {i for i, n in seen_in.items() if n > 1}+ for c in remaining:+ had = bool(pools[c])+ pools[c] = {i: r for i, r in pools[c].items() if i not in shared}+ if had and not pools[c]:+ unmatched[c] = AMBIGUOUS++ # 4. Residuals.+ for c in remaining:+ if c in unmatched:+ continue+ if not pools[c]:+ unmatched[c] = NO_CANDIDATE+ elif len(set(pools[c].values())) > 1:+ unmatched[c] = AMBIGUOUS++ # 5. Merge.+ for c in remaining:+ if c not in unmatched:+ matched[c] = _merge([cov[i] for i in sorted(pools[c])])+ return matched, unmatched+++# --- arithmetic -------------------------------------------------------------++def diff_coverage(added: set[int], hits: dict[int, int]) -> tuple[int, int] | None:+ """``(covered, measurable)`` over added lines present in ``hits``, or ``None``."""+ measurable = [n for n in added if n in hits]+ if not measurable:+ return None+ covered = sum(1 for n in measurable if hits[n] > 0)+ return covered, len(measurable)+++def overall(cov: Coverage) -> tuple[int, int]:+ """``(covered, instrumented)`` after merging entries by normalised primary path."""+ by_path: dict[str, list[Entry]] = {}+ for e in cov:+ if e.paths:+ by_path.setdefault(normalise(e.paths[0]), []).append(e)+ merged = [_merge(entries) for entries in by_path.values()]+ covered = sum(1 for hits in merged for h in hits.values() if h > 0)+ instrumented = sum(len(hits) for hits in merged)+ return covered, instrumented
diff --git a/scripts/review_html/css.py b/scripts/review_html/css.pynew file mode 100644index 0000000..557b369--- /dev/null+++ b/scripts/review_html/css.py@@ -0,0 +1,373 @@+"""Stylesheet for the review page (Prism Dark palette).++``CSS`` is substituted into the page template as a value, never pasted+into the template literal, so ``$`` inside it needs no escaping."""+from __future__ import annotations++CSS = """+:root {+ --bg: #0B1020;+ --bg-deep: #050C1B;+ --surface-1: #101A33;+ --surface-2: #142042;+ --border: #26324F;+ --border-subtle: #1F2A45;+ --text-primary: #EAF1FF;+ --text-secondary: #B7C3E3;+ --text-tertiary: #7F8BB0;+ --accent: #44C4DC;+ --accent-2: #E474E4;+ --accent-3: #4C6CBC;+ --code-bg: #0A1226;+ --code-border: #1F2A45;+ --success: #22C55E;+ --warning: #FBBF24;+ --error: #EF476F;+ --diff-add-bg: rgba(34, 197, 94, 0.12);+ --diff-add-fg: #86EFAC;+ --diff-del-bg: rgba(239, 71, 111, 0.12);+ --diff-del-fg: #FCA5A5;+}++* { box-sizing: border-box; }+html { background: var(--bg); }+body {+ background: var(--bg);+ color: var(--text-primary);+ font-family: -apple-system, "SF Pro Text", system-ui, sans-serif;+ line-height: 1.6;+ margin: 0;+ padding: 0;+}++.page { max-width: 1100px; margin: 0 auto; padding: 32px; }++header.top-bar {+ position: sticky; top: 0; z-index: 10;+ background: var(--bg-deep);+ border-bottom: 1px solid var(--border-subtle);+}+.top-stripe { height: 4px; background: linear-gradient(135deg, var(--accent-3), var(--accent-2)); }+.top-content {+ display: flex; gap: 16px; flex-wrap: wrap; align-items: center;+ padding: 14px 32px; max-width: 1100px; margin: 0 auto;+}+.top-content .repo-title { font-weight: 600; font-size: 15px; color: var(--text-primary); }++.chip {+ display: inline-flex; align-items: center; gap: 6px;+ background: var(--surface-1); border: 1px solid var(--border-subtle);+ border-radius: 999px; padding: 4px 12px; font-size: 12px; color: var(--text-secondary);+}+.chip strong { color: var(--text-primary); }++h1, h2, h3 { color: var(--text-primary); line-height: 1.3; }+h1 { font-size: 30px; margin: 8px 0 4px; }+h2 { font-size: 22px; margin: 32px 0 12px; padding-top: 12px; border-top: 1px solid var(--border-subtle); }+h3 { font-size: 17px; margin: 16px 0 8px; }++p { color: var(--text-secondary); }+strong { color: var(--text-primary); }+a { color: var(--accent); text-decoration: none; }+a:hover { text-decoration: underline; }+.muted { color: var(--text-tertiary); }+code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }++.card-grid {+ display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;+ margin: 24px 0 32px;+}++.card {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 16px; padding: 20px;+ transition: background-color 120ms ease;+}+.card:hover { background: var(--surface-2); }+.card h3 {+ margin-top: 0; font-size: 14px;+ text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary);+}+.card ul { margin: 8px 0 0; padding-left: 20px; }+.card li { margin: 4px 0; color: var(--text-secondary); }++.tag {+ display: inline-block;+ background: rgba(228, 116, 228, 0.15);+ color: var(--accent-2);+ border-radius: 4px;+ padding: 0 6px;+ font-size: 11px;+ font-weight: 600;+ text-transform: uppercase;+ margin-right: 6px;+}++.verdict-pill {+ display: inline-flex; align-items: center; gap: 8px;+ padding: 10px 18px; border-radius: 999px;+ font-weight: 600; font-size: 14px;+ text-transform: uppercase; letter-spacing: 0.05em;+}+.verdict-success { background: rgba(34, 197, 94, 0.18); color: var(--success); border: 1px solid rgba(34,197,94,0.35); }+.verdict-warning { background: rgba(251, 191, 36, 0.15); color: var(--warning); border: 1px solid rgba(251,191,36,0.35); }+.verdict-error { background: rgba(239, 71, 111, 0.15); color: var(--error); border: 1px solid rgba(239,71,111,0.35); }++.toc {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 16px; padding: 16px 20px; margin: 0 0 24px;+}+.toc ul { list-style: none; padding-left: 0; margin: 0; column-count: 2; column-gap: 24px; }+.toc li { margin: 4px 0; }+.toc a { color: var(--text-secondary); }+.toc a:hover { color: var(--accent); }++.tabs {+ margin: 16px 0; border: 1px solid var(--border);+ border-radius: 16px; background: var(--surface-1);+}+.tabs input[type="radio"] { display: none; }+.tab-labels { display: flex; border-bottom: 1px solid var(--border-subtle); }+.tab-labels label {+ padding: 12px 20px; cursor: pointer; color: var(--text-tertiary);+ font-weight: 600; font-size: 13px;+ text-transform: uppercase; letter-spacing: 0.06em;+ border-bottom: 2px solid transparent; margin-bottom: -1px;+}+.tab-labels label:hover { color: var(--text-secondary); }++#tab-beginner:checked ~ .tab-labels label[for="tab-beginner"],+#tab-intermediate:checked ~ .tab-labels label[for="tab-intermediate"],+#tab-expert:checked ~ .tab-labels label[for="tab-expert"] {+ color: var(--accent);+ border-bottom-color: var(--accent);+}++.tab-panels { padding: 20px; }+.tab-panel { display: none; }+#tab-beginner:checked ~ .tab-panels #panel-beginner,+#tab-intermediate:checked ~ .tab-panels #panel-intermediate,+#tab-expert:checked ~ .tab-panels #panel-expert {+ display: block;+}++.change-card {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 16px; padding: 20px; margin: 16px 0;+ transition: background-color 120ms ease;+}+.change-card:hover { background: var(--surface-2); }++.callout {+ margin: 12px 0; padding: 10px 14px;+ background: var(--bg-deep);+ border-radius: 6px; font-size: 14px; color: var(--text-secondary);+}+.callout-takeaway { border-left: 3px solid var(--accent-2); }+.callout-rationale { border-left: 3px solid var(--accent); }+.callout-warning { border-left: 3px solid var(--warning); }++table.findings, table.tests {+ width: 100%; border-collapse: collapse;+ margin: 16px 0; background: var(--surface-1);+ border: 1px solid var(--border); border-radius: 12px; overflow: hidden;+}+table.findings th, table.findings td,+table.tests th, table.tests td {+ padding: 10px 14px; text-align: left;+ border-bottom: 1px solid var(--border-subtle);+ font-size: 14px; vertical-align: top;+}+table.findings th, table.tests th {+ background: var(--surface-2); color: var(--text-secondary);+ text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em;+}+table.findings tr:last-child td, table.tests tr:last-child td { border-bottom: none; }+table.tests td:last-child { white-space: pre-wrap; word-break: break-word; }+table.tests a { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; }++.tests-provenance, .tests-availability, .tests-totals, .tests-matching {+ font-size: 14px; color: var(--text-secondary);+}+.tests-nodata {+ border-left: 3px solid var(--warning);+ margin: 16px 0;+}+.tests-nodata:hover { background: var(--surface-1); }++.pill {+ display: inline-block; padding: 2px 10px; border-radius: 999px;+ font-size: 11px; font-weight: 600;+ text-transform: uppercase; letter-spacing: 0.06em;+}+.pill-error { background: rgba(239,71,111,0.18); color: var(--error); }+.pill-warning { background: rgba(251,191,36,0.18); color: var(--warning); }+.pill-success { background: rgba(34,197,94,0.18); color: var(--success); }+.pill-tertiary { background: rgba(127,139,176,0.18); color: var(--text-tertiary); }++.unresolved-comment {+ background: var(--surface-1); border: 1px solid var(--border);+ border-left: 3px solid var(--warning);+ border-radius: 10px; padding: 14px 16px; margin: 12px 0;+}+.unresolved-header {+ display: flex; flex-wrap: wrap; gap: 8px; align-items: center;+ font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;+}+.unresolved-header code {+ background: var(--code-bg); color: var(--accent);+ padding: 1px 6px; border-radius: 4px; font-size: 12px;+}+.unresolved-body {+ color: var(--text-primary); font-size: 14px; line-height: 1.55;+ white-space: pre-wrap; word-break: break-word;+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+}+.replies {+ margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-subtle);+}+.replies > summary {+ cursor: pointer; color: var(--text-tertiary); font-size: 12px;+}+.reply { margin: 8px 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border-subtle); }+.reply-header { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }+.reply-body {+ color: var(--text-primary); font-size: 13px;+ white-space: pre-wrap; word-break: break-word;+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+}++.commit-list { list-style: none; padding-left: 0; }+.commit-list li { padding: 6px 0; border-bottom: 1px solid var(--border-subtle); }+.commit-list li:last-child { border-bottom: none; }+.commit-sha {+ color: var(--accent); background: var(--code-bg);+ padding: 2px 6px; border-radius: 4px;+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px;+}+.commit-subj { color: var(--text-primary); }+.commit-meta { color: var(--text-tertiary); font-size: 13px; }++.file-diff {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 12px; margin: 8px 0; overflow: hidden;+}+.file-diff > summary {+ cursor: pointer; padding: 12px 16px; font-weight: 500;+ list-style: none; display: flex; gap: 12px; align-items: center;+}+.file-diff > summary::-webkit-details-marker { display: none; }+.file-diff > summary::before {+ content: "▸"; color: var(--text-tertiary); font-size: 12px; margin-right: 4px;+}+.file-diff[open] > summary::before { content: "▾"; }+.file-path { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--text-primary); }+.line-stat {+ color: var(--text-tertiary);+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+ font-size: 12px; margin-left: auto;+}++.badge {+ display: inline-block; font-size: 10px;+ text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;+ padding: 2px 8px; border-radius: 4px;+}+.badge-added { background: rgba(34,197,94,0.18); color: var(--success); }+.badge-modified { background: rgba(68,196,220,0.18); color: var(--accent); }+.badge-deleted { background: rgba(239,71,111,0.18); color: var(--error); }+.badge-renamed { background: rgba(76,108,188,0.18); color: var(--accent-3); }++.file-diff pre {+ margin: 0; background: var(--code-bg);+ border-top: 1px solid var(--code-border);+ padding: 12px 0; overflow-x: auto; line-height: 1.45;+}+.file-diff code {+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+ font-size: 13px; color: var(--text-secondary);+ display: inline-block; min-width: 100%;+}+.diff-line {+ display: block;+ padding: 0 16px;+ min-height: 1.45em;+}+.diff-add { background: var(--diff-add-bg); color: var(--diff-add-fg); }+.diff-del { background: var(--diff-del-bg); color: var(--diff-del-fg); }+.diff-hunk { color: var(--accent); }+.diff-file-header { color: var(--text-tertiary); }+.diff-meta { color: var(--text-tertiary); }+.diff-context { color: var(--text-secondary); }+.diff-uncovered {+ border-left: 3px solid var(--error);+ padding-left: 13px;+}+.diff-uncovered::before {+ content: "▌"; color: var(--error); margin-right: 6px;+}++.blast-scroll {+ overflow-x: auto;+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 12px; padding: 12px; margin: 12px 0;+}+.blast-scroll svg { display: block; }+.blast-legend {+ display: flex; flex-wrap: wrap; gap: 8px 18px;+ margin: 12px 0; font-size: 13px; color: var(--text-secondary);+}+.blast-key { display: inline-flex; align-items: center; gap: 6px; }+.blast-swatch {+ display: inline-block; width: 14px; height: 14px; border-radius: 3px;+ background: var(--surface-2); border: 1px solid var(--border);+}+.blast-swatch-added { background: rgba(34,197,94,0.18); border-color: var(--success); }+.blast-swatch-modified { background: rgba(228,116,228,0.18); border-color: var(--accent-2); }+.blast-swatch-deleted { background: rgba(239,71,111,0.18); border-color: var(--error); }+.blast-swatch-renamed { background: rgba(76,108,188,0.18); border-color: var(--accent-3); }+.blast-swatch-collapsed { border-style: dashed; }+.blast-members, .blast-skipped { font-size: 13px; color: var(--text-secondary); }+.blast-members code, .blast-skipped code { color: var(--text-primary); font-size: 12px; }++.pr-description {+ background: var(--surface-1);+ border: 1px solid var(--border);+ border-left: 3px solid var(--accent-2);+ border-radius: 12px;+ padding: 16px 20px;+ margin: 8px 0 16px;+}+.pr-description .meta {+ color: var(--text-tertiary);+ font-size: 12px;+ margin-bottom: 12px;+}+.pr-description pre {+ margin: 0;+ background: transparent;+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+ font-size: 13px;+ line-height: 1.55;+ color: var(--text-secondary);+ white-space: pre-wrap;+ word-wrap: break-word;+}+.pr-description pre code,+.pr-description pre a { color: var(--text-primary); }++footer {+ margin-top: 64px; padding-top: 24px;+ border-top: 1px solid var(--border-subtle);+ color: var(--text-tertiary); font-size: 13px;+}++@media (max-width: 720px) {+ .card-grid { grid-template-columns: 1fr; }+ .toc ul { column-count: 1; }+ .page { padding: 16px; }+}+@media (prefers-reduced-motion: reduce) {+ * { transition: none !important; }+}+"""
diff --git a/scripts/review_html/diagram.py b/scripts/review_html/diagram.pynew file mode 100644index 0000000..e04f11f--- /dev/null+++ b/scripts/review_html/diagram.py@@ -0,0 +1,565 @@+"""Blast-radius diagram: projection of the one-hop graph, layout, and SVG.++``project`` applies the rendering rules the skill never applies (test+exclusion, expansion collapse, the column cap) and knows nothing about SVG;+``layout`` turns a ``Projected`` into boxes, frames, and edge paths using+the fixed constants below; ``render_diagram`` emits the section HTML.+"""+from __future__ import annotations++import math+import textwrap+from dataclasses import dataclass, field++from .common import digest, escape, file_anchor+from .warnings import Warnings++CAP = 15 # side-column node cap (requirement 4.8)+COLLAPSE_ABOVE = 3 # expansion groups larger than this collapse (4.6)++COLUMNS = ("dependents", "changed", "dependencies")+SIDE_COLUMNS = ("dependents", "dependencies")+CHANGED_STATUSES = ("added", "modified", "deleted", "renamed")++# Layout constants (design, Q36 and Q56). Every text element declares a+# textLength of len(text) * ADV so the fit holds whatever font renders it.+ADV = 7.2+PAD = 10+BOX_H = 26+ROW_GAP = 8+GROUP_PAD = 8+GROUP_HEADER = 18+GROUP_GAP = 14+GUTTER = 56+LANE = 24+CONTENT_W = 1036+COL_W = (CONTENT_W - 2 * GUTTER) // 3 # 308+SIDE_BOX_W = COL_W - 2 * GROUP_PAD # 292+CENTRE_BOX_W = SIDE_BOX_W - LANE # 268+BADGE_RESERVE = 4 # characters kept for the ⚑N badge+TRACK_GAP = 6 # centre lane tracks, three of them+LINE_H = 16 # header note and reason lines+TITLE_Y = 12+NOTES_Y = 30+BOTTOM_PAD = 16++COL_X = {"dependents": 0, "changed": COL_W + GUTTER, "dependencies": 2 * (COL_W + GUTTER)}+COL_TITLE = {"dependents": "Dependents", "changed": "Changed", "dependencies": "Dependencies"}++FILL = {+ "added": "var(--success, #22C55E)",+ "modified": "var(--accent-2, #E474E4)",+ "deleted": "var(--error, #EF476F)",+ "renamed": "var(--accent-3, #4C6CBC)",+}+NEUTRAL_FILL = "var(--surface-2, #142042)"+BORDER = "var(--border, #26324F)"+TEXT = "var(--text-primary, #EAF1FF)"+MUTED = "var(--text-tertiary, #7F8BB0)"+EDGE = "var(--text-tertiary, #7F8BB0)"+FONT = 'ui-monospace, "SF Mono", Menlo, monospace'+++def budget(box_w: int, reserve: int = 0) -> int:+ """Largest label length L with (L + reserve) * ADV + 2 * PAD <= box_w."""+ return math.floor((box_w - 2 * PAD) / ADV) - reserve+++SIDE_BUDGET = budget(SIDE_BOX_W) # 37+CENTRE_BUDGET = budget(CENTRE_BOX_W, BADGE_RESERVE) # 30+BUDGET = {"dependents": SIDE_BUDGET, "changed": CENTRE_BUDGET, "dependencies": SIDE_BUDGET}+++def shorten(text: str, limit: int) -> str:+ if len(text) <= limit:+ return text+ return "…" + text[-(limit - 1):]+++def text_len(s: str) -> float:+ return len(s) * ADV+++# --- projection -------------------------------------------------------------++@dataclass+class PNode:+ id: str+ path: str # full path; first member path for collapsed nodes+ label: str # path, "<group> (N files)", or "+N more"+ group: object # str, or None for the overflow node+ status: str # added|modified|deleted|renamed|unchanged|collapsed|more+ members: list = field(default_factory=list) # sorted member paths, else []+ test_count: int = 0+ edges_to_changed: int = 0+++@dataclass+class PGroup:+ name: object # str, or None for the overflow group+ nodes: list+++@dataclass+class PEdge:+ src: str # node id+ dst: str # node id+ column: str # side column of the non-centre end; "changed" for centre-to-centre+ granularity: str+++@dataclass+class Projected:+ columns: dict # column -> list[PGroup]+ edges: list # list[PEdge]+ column_status: dict # dependents/dependencies -> status string+ package_granularity: dict # column -> bool+ skipped: list+ snapshot_tree: str+ base_tree: str++ def nodes(self, column: str) -> list:+ return [n for g in self.columns[column] for n in g.nodes]+++def node_id(path: str) -> str:+ return "n-" + digest(path)+++def _validate(desc: object) -> None:+ if not isinstance(desc, dict):+ raise ValueError("diagram description is not a JSON object")+ for key in ("nodes", "edges"):+ if not isinstance(desc.get(key), list):+ raise ValueError(f"diagram description has no {key!r} list")+ for n in desc["nodes"]:+ if not isinstance(n, dict) or not isinstance(n.get("path"), str):+ raise ValueError("diagram node without a path")+ for e in desc["edges"]:+ if not isinstance(e, dict) or not isinstance(e.get("from"), str) \+ or not isinstance(e.get("to"), str):+ raise ValueError("diagram edge without from and to")+ status = desc.get("column_status")+ if status is not None and not isinstance(status, dict):+ raise ValueError("column_status is not an object")+++def _group_nodes(nodes: list) -> list:+ """Order nodes into groups by name, nodes by path; ``None`` group last."""+ named: dict = {}+ for n in nodes:+ named.setdefault(n.group, []).append(n)+ groups = []+ for name in sorted(k for k in named if k is not None):+ groups.append(PGroup(name, sorted(named[name], key=lambda n: n.path)))+ if None in named:+ groups.append(PGroup(None, sorted(named[None], key=lambda n: n.path)))+ return groups+++def project(desc: dict) -> Projected:+ _validate(desc)+ raw_nodes = {}+ for n in desc["nodes"]:+ raw_nodes.setdefault(n["path"], n)+ status_of = {p: (n.get("status") or "unchanged") for p, n in raw_nodes.items()}+ changed = {p for p, s in status_of.items() if s in CHANGED_STATUSES}+ is_test = {p: bool(n.get("is_test")) for p, n in raw_nodes.items()}+ group_of = {p: str(n.get("group") or "") for p, n in raw_nodes.items()}++ # Edges between known nodes, deduplicated by (from, to), first wins.+ edges = {}+ for e in desc["edges"]:+ key = (e["from"], e["to"])+ if key in edges or key[0] not in raw_nodes or key[1] not in raw_nodes:+ continue+ if key[0] not in changed and key[1] not in changed:+ continue+ if key[0] == key[1]:+ continue+ edges[key] = str(e.get("granularity") or "file")++ # Column assignment.+ # A node with edges in both directions is a dependent.+ column_of = {p: "changed" for p in changed}+ for (a, b) in edges:+ if a not in changed:+ column_of[a] = "dependents"+ if b not in changed and column_of.get(b) != "dependents":+ column_of[b] = "dependencies"++ # 1. Test exclusion; counts include changed test files.+ test_count = {p: 0 for p in changed}+ for (a, b) in edges:+ if b in changed and is_test.get(a):+ test_count[b] += 1+ side_members = {+ col: [p for p, c in column_of.items() if c == col and not is_test[p]]+ for col in SIDE_COLUMNS+ }+ column_status = {}+ raw_status = desc.get("column_status") or {}+ for col in SIDE_COLUMNS:+ column_status[col] = str(raw_status.get(col) or "complete")+ if column_status[col].startswith("failed"):+ side_members[col] = []+ kept_paths = set(changed)+ for col in SIDE_COLUMNS:+ kept_paths.update(side_members[col])+ edges = {k: g for k, g in edges.items() if k[0] in kept_paths and k[1] in kept_paths}++ # Per path: the number of edges to a changed node, and whether every+ # such edge is package-granular (vacuously true with none).+ edge_count: dict = {}+ all_package: dict = {}+ for (a, b), g in edges.items():+ for p, other in ((a, b), (b, a)):+ if other in changed:+ edge_count[p] = edge_count.get(p, 0) + 1+ all_package[p] = all_package.get(p, True) and g == "package"++ # Node objects; ``owner`` maps a path to the node that represents it.+ owner = {}+ columns = {}+ for p in sorted(changed):+ node = PNode(node_id(p), p, p, group_of[p], status_of[p], [],+ test_count[p], edge_count.get(p, 0))+ owner[p] = node+ columns["changed"] = _group_nodes([owner[p] for p in changed])++ for col in SIDE_COLUMNS:+ nodes = []+ # 2. Expansion collapse per group.+ by_group: dict = {}+ for p in side_members[col]:+ by_group.setdefault(group_of[p], []).append(p)+ for group, members in by_group.items():+ collapsible = sorted(p for p in members if all_package.get(p, True))+ collapsed = set(collapsible)+ singles = [p for p in members if p not in collapsed]+ if len(collapsible) > COLLAPSE_ABOVE:+ node = PNode(node_id("\n".join(collapsible)), collapsible[0],+ f"{group} ({len(collapsible)} files)", group, "collapsed",+ collapsible, 0, sum(edge_count.get(p, 0) for p in collapsible))+ for p in collapsible:+ owner[p] = node+ nodes.append(node)+ else:+ singles += collapsible+ for p in singles:+ node = PNode(node_id(p), p, p, group, "unchanged", [], 0, edge_count.get(p, 0))+ owner[p] = node+ nodes.append(node)+ # 3. Cap.+ if len(nodes) > CAP:+ nodes.sort(key=lambda n: (-n.edges_to_changed, n.path))+ overflow = nodes[CAP:]+ nodes = nodes[:CAP]+ members = sorted(p for n in overflow for p in (n.members or [n.path]))+ more = PNode(node_id("\n".join(members)), members[0], f"+{len(members)} more",+ None, "more", members, 0, sum(n.edges_to_changed for n in overflow))+ for p in members:+ owner[p] = more+ nodes.append(more)+ columns[col] = _group_nodes(nodes)++ # Projected edges between owning nodes, deduplicated.+ p_edges = []+ seen = set()+ for (a, b), gran in sorted(edges.items()):+ src, dst = owner[a], owner[b]+ if a in changed and b in changed:+ col = "changed"+ elif a in changed:+ col = column_of[b]+ else:+ col = column_of[a]+ key = (src.id, dst.id)+ if key in seen:+ continue+ seen.add(key)+ p_edges.append(PEdge(src.id, dst.id, col, gran))++ package_granularity = {+ col: any(e.column == col and e.granularity == "package" for e in p_edges)+ for col in COLUMNS+ }+ skipped = desc.get("skipped") if isinstance(desc.get("skipped"), list) else []+ return Projected(+ columns=columns,+ edges=p_edges,+ column_status=column_status,+ package_granularity=package_granularity,+ skipped=skipped,+ snapshot_tree=str(desc.get("snapshot_tree") or ""),+ base_tree=str(desc.get("base_tree") or ""),+ )+++# --- layout -----------------------------------------------------------------++@dataclass+class Box:+ id: str+ column: str+ x: float+ y: float+ w: float+ h: float+ label: str+ text_len: float+ badge: object # "⚑N" or None+ badge_len: float+ node: PNode+++@dataclass+class Frame:+ column: str+ x: float+ y: float+ w: float+ h: float+ label: str+ text_len: float+++@dataclass+class EdgePath:+ src: str+ dst: str+ column: str+ d: str+++@dataclass+class Header:+ column: str+ title: str+ notes: list # lines under the title+ reason: list # lines drawn where nodes would be (failed or empty column)+++@dataclass+class Layout:+ boxes: dict # node id -> Box+ frames: list # list[Frame]+ edges: list # list[EdgePath]+ headers: list # list[Header]+ width: int+ height: int+ content_top: int # y where the column contents start, below the header notes+++def _wrap(text: str, limit: int) -> list:+ return textwrap.wrap(text, limit, break_long_words=True) or [""]+++def layout(p: Projected) -> Layout:+ headers = []+ for col in COLUMNS:+ notes = []+ status = p.column_status.get(col, "complete")+ if col != "changed" and status.startswith("partial"):+ notes += _wrap(status, SIDE_BUDGET)+ if p.package_granularity.get(col):+ notes.append("edges at package granularity")+ reason = []+ if col != "changed" and status.startswith("failed"):+ reason = _wrap(status, SIDE_BUDGET)+ elif not p.columns[col]:+ reason = ["none found"]+ headers.append(Header(col, COL_TITLE[col], notes, reason))+ content_top = NOTES_Y + LINE_H * max(len(h.notes) for h in headers)++ boxes = {}+ frames = []+ bottom = content_top+ for col in COLUMNS:+ x = COL_X[col]+ y = content_top+ box_w = CENTRE_BOX_W if col == "changed" else SIDE_BOX_W+ limit = BUDGET[col]+ header = next(h for h in headers if h.column == col)+ if header.reason:+ y += LINE_H * len(header.reason)+ for gi, group in enumerate(p.columns[col]):+ if gi:+ y += GROUP_GAP+ n = len(group.nodes)+ if group.name is not None:+ label = shorten(group.name, limit)+ h = GROUP_PAD + GROUP_HEADER + n * BOX_H + (n - 1) * ROW_GAP + GROUP_PAD+ frames.append(Frame(col, x, y, COL_W, h, label, text_len(label)))+ node_y = y + GROUP_PAD + GROUP_HEADER+ else:+ h = n * BOX_H + (n - 1) * ROW_GAP+ node_y = y+ for node in group.nodes:+ label = shorten(node.label, limit)+ badge = f"⚑{node.test_count}" if node.test_count > 0 else None+ boxes[node.id] = Box(node.id, col, x + GROUP_PAD, node_y, box_w, BOX_H, label,+ text_len(label), badge, text_len(badge) if badge else 0.0,+ node)+ node_y += BOX_H + ROW_GAP+ y += h+ bottom = max(bottom, y)+ height = int(bottom + BOTTOM_PAD)++ edges = []+ lane_x0 = COL_X["changed"] + GROUP_PAD + CENTRE_BOX_W+ centre_index = 0+ for e in p.edges:+ src, dst = boxes.get(e.src), boxes.get(e.dst)+ if src is None or dst is None:+ continue+ sy, dy = src.y + src.h / 2, dst.y + dst.h / 2+ if e.column == "changed":+ track = lane_x0 + TRACK_GAP * (centre_index % 3 + 1)+ centre_index += 1+ x = src.x + src.w+ d = f"M {_n(x)} {_n(sy)} L {_n(track)} {_n(sy)} L {_n(track)} {_n(dy)} L {_n(x)} {_n(dy)}"+ else:+ left = min(COL_X[src.column], COL_X[dst.column])+ mid = left + COL_W + GUTTER / 2+ if dst.x > src.x:+ x1, x2 = src.x + src.w, dst.x+ else:+ x1, x2 = src.x, dst.x + dst.w+ d = f"M {_n(x1)} {_n(sy)} C {_n(mid)} {_n(sy)} {_n(mid)} {_n(dy)} {_n(x2)} {_n(dy)}"+ edges.append(EdgePath(e.src, e.dst, e.column, d))+ return Layout(boxes, frames, edges, headers, CONTENT_W, height, content_top)+++def _n(v: float) -> str:+ s = f"{v:.1f}"+ return s[:-2] if s.endswith(".0") else s+++# --- SVG and section --------------------------------------------------------++def _text(x: float, y: float, s: str, fill: str, anchor: str = "start", cls: str = "") -> str:+ klass = f' class="{cls}"' if cls else ""+ return (f'<text{klass} x="{_n(x)}" y="{_n(y)}" textLength="{_n(text_len(s))}" '+ f'lengthAdjust="spacingAndGlyphs" dominant-baseline="middle" '+ f'text-anchor="{anchor}" fill="{fill}">{escape(s)}</text>')+++def render_svg(p: Projected, lay: Layout) -> str:+ out = [+ f'<svg class="blast" xmlns="http://www.w3.org/2000/svg" width="{lay.width}" '+ f'height="{lay.height}" viewBox="0 0 {lay.width} {lay.height}" '+ f"font-family='{FONT}' font-size=\"12\">",+ '<defs><marker id="blast-arrow" viewBox="0 0 10 10" refX="9" refY="5" '+ 'markerWidth="7" markerHeight="7" orient="auto">'+ f'<path d="M 0 0 L 10 5 L 0 10 z" fill="{EDGE}"/></marker></defs>',+ ]+ out.append('<g class="headers">')+ for h in lay.headers:+ x = COL_X[h.column] + PAD+ out.append(_text(x, TITLE_Y, h.title, TEXT, cls="col-title"))+ for i, note in enumerate(h.notes):+ out.append(_text(x, NOTES_Y + LINE_H * i, note, MUTED, cls="col-note"))+ for i, line in enumerate(h.reason):+ out.append(_text(x, lay.content_top + LINE_H * i + LINE_H / 2, line, MUTED, cls="col-reason"))+ out.append("</g>")++ out.append('<g class="frames">')+ for f in lay.frames:+ out.append(+ f'<g class="group"><rect x="{_n(f.x)}" y="{_n(f.y)}" width="{_n(f.w)}" '+ f'height="{_n(f.h)}" rx="8" fill="none" stroke="{BORDER}"/>'+ + _text(f.x + PAD, f.y + GROUP_PAD + GROUP_HEADER / 2, f.label, MUTED, cls="group-label")+ + "</g>"+ )+ out.append("</g>")++ out.append('<g class="edges">')+ for e in lay.edges:+ out.append(+ f'<path class="edge e-{e.src} e-{e.dst}" data-from="{e.src}" data-to="{e.dst}" '+ f'd="{e.d}" fill="none" stroke="{EDGE}" stroke-width="1.2" '+ 'marker-end="url(#blast-arrow)"/>'+ )+ out.append("</g>")++ out.append('<g class="nodes">')+ for box in lay.boxes.values():+ node = box.node+ changed = node.status in CHANGED_STATUSES+ if changed:+ colour = FILL[node.status]+ rect = (f'<rect x="{_n(box.x)}" y="{_n(box.y)}" width="{_n(box.w)}" height="{_n(box.h)}" '+ f'rx="6" fill="{colour}" fill-opacity="0.18" stroke="{colour}"/>')+ else:+ dashed = ' stroke-dasharray="5 3"' if node.status in ("collapsed", "more") else ""+ rect = (f'<rect x="{_n(box.x)}" y="{_n(box.y)}" width="{_n(box.w)}" height="{_n(box.h)}" '+ f'rx="6" fill="{NEUTRAL_FILL}" stroke="{BORDER}"{dashed}/>')+ title = node.path if not node.members else "\n".join(node.members)+ parts = [f'<g id="{box.id}"><title>{escape(title)}</title>', rect,+ _text(box.x + PAD, box.y + box.h / 2, box.label, TEXT, cls="label")]+ if box.badge:+ parts.append(_text(box.x + box.w - PAD, box.y + box.h / 2, box.badge, TEXT,+ anchor="end", cls="badge"))+ parts.append("</g>")+ g = "".join(parts)+ if changed:+ g = f'<a href="#{file_anchor(node.path)}">{g}</a>'+ out.append(g)+ out.append("</g>")+ out.append("</svg>")+ return "\n".join(out)+++def _hover_style(lay: Layout) -> str:+ rules = []+ for nid in lay.boxes:+ rules.append(f".blast:has(#{nid}:hover) .edge:not(.e-{nid}){{opacity:.15}}")+ rules.append(f".blast:has(#{nid}:hover) .edge.e-{nid}{{stroke-width:2}}")+ return "<style>\n" + "\n".join(rules) + "\n</style>"+++def _legend() -> str:+ keys = [("added", "added"), ("modified", "modified"), ("deleted", "deleted"),+ ("renamed", "renamed"), ("unchanged", "unchanged"),+ ("collapsed", "collapsed package group or +N more")]+ spans = "".join(+ f'<span class="blast-key"><span class="blast-swatch blast-swatch-{k}"></span>{label}</span>'+ for k, label in keys+ )+ spans += '<span class="blast-key">⚑N test files with an edge to the node</span>'+ return f'<div class="blast-legend">{spans}</div>'+++def render_diagram(desc: dict, warnings: Warnings) -> str:+ try:+ p = project(desc)+ except ValueError as exc:+ warnings.add(f"diagram description invalid: {exc}")+ return ""+ lay = layout(p)+ collapsed = [n for col in COLUMNS for n in p.nodes(col) if n.members]+ members_html = ""+ if collapsed:+ items = "".join(+ f"<li><code>{escape(n.label)}</code>: "+ + ", ".join(f"<code>{escape(m)}</code>" for m in n.members) + "</li>"+ for n in collapsed+ )+ members_html = f'<h3>Collapsed nodes</h3>\n<ul class="blast-members">{items}</ul>'+ skipped_html = ""+ if p.skipped:+ items = "".join(+ f'<li><code>{escape(s.get("path"))}</code> — {escape(s.get("reason"))}</li>'+ for s in p.skipped if isinstance(s, dict)+ )+ skipped_html = f'<h3>Skipped files</h3>\n<ul class="blast-skipped">{items}</ul>'+ return f"""<section id="diagram">+ <h2>Blast radius</h2>+ <p class="muted">Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot <code>{escape(p.snapshot_tree)}</code> against base <code>{escape(p.base_tree)}</code>.</p>+ {_hover_style(lay)}+ <div class="blast-scroll">{render_svg(p, lay)}</div>+ {_legend()}+ {members_html}+ {skipped_html}+ </section>"""
diff --git a/scripts/review_html/diffs.py b/scripts/review_html/diffs.pynew file mode 100644index 0000000..219ec18--- /dev/null+++ b/scripts/review_html/diffs.py@@ -0,0 +1,118 @@+"""Diff fragments: loading, hunk arithmetic, and rendering.++``load_fragments`` resolves every file's diff text once so the Tests section+and the per-file diff blocks read the same bytes. ``added_lines`` and+``render_diff`` share one walk over the hunks so the line numbers used for+coverage lookups are the ones the rendered marks land on.+"""+from __future__ import annotations++import re+from pathlib import Path+from typing import Iterator++from .common import escape+from .inputs import read_guarded+from .warnings import Warnings++_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")+++def load_fragments(files: list[dict], diff_dir: Path | None, warnings: Warnings) -> dict[str, str]:+ """Map each file's path to its diff text or a placeholder.++ An inline ``diff`` wins over ``diff_file``. A ``diff_file`` is read from+ ``diff_dir`` through ``read_guarded``; a missing file keeps the historic+ placeholder, an undecodable one gets its own, and both leave the rest of+ the page intact.+ """+ fragments: dict[str, str] = {}+ for f in files:+ path = f.get("path", "")+ diff = f.get("diff")+ name = f.get("diff_file")+ if diff is None and name and diff_dir is not None:+ fragment = diff_dir / name+ if not fragment.exists():+ diff = f"(diff fragment {name!r} missing)"+ else:+ diff = read_guarded(fragment, warnings)+ if diff is None:+ diff = f"(diff fragment {name!r} is not UTF-8)"+ if diff is None:+ diff = "(no diff provided)"+ fragments[path] = diff+ return fragments+++def _walk(diff: str) -> Iterator[tuple[str, str, int | None]]:+ """Yield ``(line, css_class, new_file_number)`` for each line of a diff.++ ``new_file_number`` is set only on ``+`` lines and is the line's number in+ the new file, tracked from the ``@@`` headers. Context and ``+`` lines+ advance the counter; ``-`` lines, headers, and ``\\`` markers do not.+ Line classes match the renderer's historic prefix rules, except that+ ``+++`` and ``---`` are file headers only before a file section's first+ ``@@``; inside a hunk they are added or removed lines whose content+ happens to start with ``++`` or ``--``. A ``diff --git`` line starts a+ new file section.+ """+ if not diff:+ return+ lines = diff.split("\n")+ if lines and lines[-1] == "":+ lines.pop()+ next_new: int | None = None+ for line in lines:+ number = None+ if line.startswith("diff --git "):+ cls = "diff-context"+ next_new = None+ elif next_new is None and line.startswith(("+++", "---")):+ cls = "diff-file-header"+ elif line.startswith("@@"):+ cls = "diff-hunk"+ m = _HUNK.match(line)+ next_new = int(m.group(1)) if m else None+ elif line.startswith("+"):+ cls = "diff-add"+ if next_new is not None:+ number = next_new+ next_new += 1+ elif line.startswith("-"):+ cls = "diff-del"+ elif line.startswith("\\"):+ cls = "diff-meta"+ else:+ cls = "diff-context"+ if next_new is not None:+ next_new += 1+ yield line, cls, number+++def added_lines(diff: str) -> set[int]:+ """New-file line numbers of every ``+`` line, from the ``@@`` headers."""+ return {number for _, _, number in _walk(diff) if number is not None}+++def is_binary(diff: str) -> bool:+ return any(+ line.startswith(("Binary files ", "GIT binary patch"))+ for line in diff.split("\n")+ )+++def render_diff(diff: str, uncovered: set[int] | None = None) -> str:+ """Render a unified diff as one <span class="diff-line"> per line.++ Each line is classified by its leading marker so consecutive additions or+ deletions paint a continuous full-width background bar. A ``+`` line whose+ new-file number is in ``uncovered`` also carries ``diff-uncovered``; with+ ``None`` the output is the historic rendering.+ """+ spans = []+ for line, cls, number in _walk(diff):+ if uncovered and number is not None and number in uncovered:+ cls += " diff-uncovered"+ spans.append(f'<span class="diff-line {cls}">{escape(line)}</span>')+ return "".join(spans)
diff --git a/scripts/review_html/inputs.py b/scripts/review_html/inputs.pynew file mode 100644index 0000000..02ee437--- /dev/null+++ b/scripts/review_html/inputs.py@@ -0,0 +1,68 @@+"""Guarded file reads.++``read_guarded`` is the only way the package reads an input file (diff+fragments, JUnit, coverage, diagram descriptions). It refuses inputs over+50 MB, inputs that are not UTF-8, and, for XML, inputs carrying a DOCTYPE+declaration; each refusal is recorded as a warning naming the file.+``read_json`` and ``xml_root`` build on it for the two structured formats.+"""+from __future__ import annotations++import json+import xml.etree.ElementTree as ET+from pathlib import Path++from .warnings import Warnings++MAX_INPUT_BYTES = 50 * 1024 * 1024+DOCTYPE_SCAN_BYTES = 64 * 1024+++def read_guarded(path: Path, warnings: Warnings, xml: bool = False) -> str | None:+ try:+ size = path.stat().st_size+ if size > MAX_INPUT_BYTES:+ warnings.add(f"{path.name}: skipped, larger than 50 MB ({size} bytes)")+ return None+ raw = path.read_bytes()+ except OSError as exc:+ warnings.add(f"{path.name}: cannot read ({exc.strerror or exc})")+ return None+ if xml and b"<!DOCTYPE" in raw[:DOCTYPE_SCAN_BYTES]:+ warnings.add(f"{path.name}: skipped, XML contains a DOCTYPE declaration")+ return None+ try:+ return raw.decode("utf-8")+ except UnicodeDecodeError:+ warnings.add(f"{path.name}: skipped, not valid UTF-8")+ return None+++def read_json(path: Path, warnings: Warnings, what: str) -> object:+ """Parsed JSON of a guarded read, or ``None`` with a warning naming ``what``."""+ text = read_guarded(path, warnings)+ if text is None:+ return None+ try:+ return json.loads(text)+ except ValueError as exc:+ warnings.add(f"{path.name}: {what} is not valid JSON ({exc})")+ return None+++def xml_root(text: str, name: str, warnings: Warnings, expected_tags: tuple,+ kind: str, expected: str) -> ET.Element | None:+ """Root element of ``text`` when it parses and its tag is expected, else ``None``.++ ``kind`` names the format in the malformed warning ("JUnit XML");+ ``expected`` describes the accepted root in the wrong-root warning.+ """+ try:+ root = ET.fromstring(text)+ except ET.ParseError as exc:+ warnings.add(f"{name}: skipped, {kind} is malformed ({exc})")+ return None+ if root.tag not in expected_tags:+ warnings.add(f"{name}: skipped, root element is <{root.tag}>, not {expected}")+ return None+ return root
diff --git a/scripts/review_html/junit.py b/scripts/review_html/junit.pynew file mode 100644index 0000000..f97c107--- /dev/null+++ b/scripts/review_html/junit.py@@ -0,0 +1,102 @@+"""JUnit XML parsing.++``parse_junit`` reads one or more JUnit files through ``read_guarded`` and+returns one ``Case`` per test identity per source file. Rerun and flaky+elements (Surefire and pytest-rerunfailures dialects) never make a case+that ultimately passed count as failed; they mark it flaky instead.+"""+from __future__ import annotations++import xml.etree.ElementTree as ET+from dataclasses import dataclass+from pathlib import Path++from .inputs import read_guarded, xml_root+from .warnings import Warnings++FLAKY_ELEMENTS = ("flakyFailure", "flakyError", "rerunFailure", "rerunError", "rerun")+SUITE_TAGS = ("testsuites", "testsuite")+OUTCOMES = ("passed", "failed", "errored", "skipped")+++@dataclass+class Case:+ suite: str+ name: str+ outcome: str # one of OUTCOMES+ flaky: bool+ message: str+ source: str # input file name, for job attribution+++def _message(el: ET.Element) -> str:+ """Message of the first failure or error child: its attribute, else its text."""+ for child in el:+ if child.tag in ("failure", "error"):+ attr = child.get("message")+ if attr is not None and attr != "":+ return attr+ return (child.text or "").strip()+ return ""+++def _classify(el: ET.Element) -> tuple[str, bool]:+ """Outcome and flaky flag of one ``testcase`` element, first rule wins."""+ tags = {child.tag for child in el}+ if "failure" in tags:+ return "failed", False+ if "error" in tags:+ return "errored", False+ if any(tag in tags for tag in FLAKY_ELEMENTS):+ return "passed", True+ if "skipped" in tags:+ return "skipped", False+ return "passed", False+++def _elements(root: ET.Element) -> list[tuple[str, ET.Element]]:+ """Every ``testcase`` with its enclosing suite name, in document order."""+ out: list[tuple[str, ET.Element]] = []+ for suite in root.iter():+ if suite.tag not in SUITE_TAGS:+ continue+ suite_name = suite.get("name") or ""+ for tc in suite:+ if tc.tag == "testcase":+ out.append((suite_name, tc))+ return out+++def _parse_one(path: Path, warnings: Warnings) -> list[Case]:+ text = read_guarded(path, warnings, xml=True)+ if text is None:+ return []+ root = xml_root(text, path.name, warnings, SUITE_TAGS, "JUnit XML", "a JUnit suite")+ if root is None:+ return []++ source = path.name+ cases: dict[tuple[str, str], Case] = {}+ for suite_name, tc in _elements(root):+ suite = tc.get("classname") or suite_name+ name = tc.get("name") or ""+ outcome, flaky = _classify(tc)+ message = _message(tc)+ key = (suite, name)+ earlier = cases.get(key)+ if earlier is None:+ cases[key] = Case(suite, name, outcome, flaky, message, source)+ continue+ # Same identity within one source: the last element's outcome wins,+ # and a pass after an earlier failure, error, or rerun is flaky.+ if outcome == "passed":+ flaky = flaky or earlier.flaky or earlier.outcome in ("failed", "errored")+ cases[key] = Case(suite, name, outcome, flaky, message, source)+ return list(cases.values())+++def parse_junit(paths: list[Path], warnings: Warnings) -> list[Case]:+ cases: list[Case] = []+ for path in paths:+ cases.extend(_parse_one(path, warnings))+ return cases
diff --git a/scripts/review_html/redact.py b/scripts/review_html/redact.pynew file mode 100644index 0000000..f76f2f4--- /dev/null+++ b/scripts/review_html/redact.py@@ -0,0 +1,37 @@+"""Secret redaction for failure messages.++The page is archived and served to feed readers, and test output routinely+carries tokens and connection strings. ``PATTERNS`` are applied in order and+every match becomes ``[redacted]``; ``clean_message`` redacts before it+truncates so a secret straddling the cut can never survive.+"""+from __future__ import annotations++import re++PATTERNS: list[re.Pattern] = [+ re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*"),+ re.compile(r"AKIA[0-9A-Z]{16}"),+ re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"),+ re.compile(r"xox[abprs]-[A-Za-z0-9-]+"),+ re.compile(r"""(?i)[A-Za-z0-9_]*(key|token|secret|password|passwd|pwd)["']?\s*[=:]\s*\S+"""),+ re.compile(r"[a-z][a-z0-9+.-]*://[^/\s:@]+:[^@\s]+@"),+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),+]++REDACTED = "[redacted]"+MESSAGE_LIMIT = 500+++def redact(text: str) -> str:+ for pattern in PATTERNS:+ text = pattern.sub(REDACTED, text)+ return text+++def clean_message(text: str, limit: int = MESSAGE_LIMIT) -> str:+ """Redact, then cap at ``limit`` characters with a trailing ellipsis."""+ text = redact(text)+ if len(text) > limit:+ return text[:limit - 1] + "…"+ return text
diff --git a/scripts/review_html/render.py b/scripts/review_html/render.pynew file mode 100644index 0000000..b7a2dd6--- /dev/null+++ b/scripts/review_html/render.py@@ -0,0 +1,145 @@+"""Page orchestration: turn a review JSON document into the final HTML."""+from __future__ import annotations++import sys+from datetime import datetime, timezone+from pathlib import Path++from .common import escape+from .css import CSS+from .diagram import render_diagram+from .diffs import load_fragments+from .inputs import read_json+from .junit import OUTCOMES+from .sections import (+ build_toc,+ render_at_a_glance,+ render_commits,+ render_decisions,+ render_double_check,+ render_explanation,+ render_files,+ render_findings_summary_card,+ render_findings_table,+ render_important_changes,+ render_important_links,+ render_metrics,+ render_pr_description,+ render_publish_metadata,+ render_unresolved_comments,+ render_verdict_card,+)+from .template import PAGE_TEMPLATE+from .tests_section import TestsResult, build_tests+from .warnings import Warnings+++def build_diagram(data: dict, diff_dir: Path | None, warnings: Warnings) -> str:+ """Section HTML for ``diagram_file``, or ``""``.++ A docs-only change suppresses the section silently; an absent, unreadable,+ or invalid description warns (naming the file) and omits it.+ """+ if data.get("change_classification") == "docs-only":+ return ""+ name = data.get("diagram_file")+ if not name:+ return ""+ if diff_dir is None:+ warnings.add(f"{name}: diagram_file given but no diff directory to read it from")+ return ""+ desc = read_json(diff_dir / name, warnings, "diagram description")+ if desc is None:+ return ""+ return render_diagram(desc, warnings)+++def render(data: dict, diff_dir: Path | None) -> str:+ warnings = Warnings()+ repo = data.get("repo", {})+ repo_name = escape(repo.get("name", "(repo)"))+ repo_path = escape(repo.get("path", ""))++ title = data.get("title") or f"Pre-push review: {repo.get('name', '')}"++ important_changes = data.get("important_changes", [])+ findings = data.get("findings", [])+ files = data.get("files", [])++ # Fragments are read once; the Tests section and the per-file diff blocks+ # both consume this dict. The Tests section supplies the uncovered line+ # marks the diff blocks draw, so it is built first.+ fragments = load_fragments(files, diff_dir, warnings)+ tests: TestsResult | None = None+ if data.get("tests") is not None and data.get("change_classification") != "docs-only":+ tests = build_tests(data["tests"], files, fragments, diff_dir, warnings)+ uncovered = tests.uncovered if tests else {}++ sections = {+ "pr-description": render_pr_description(data.get("pr_description", {})),+ "commits": render_commits(data.get("commits", [])),+ "explanation": render_explanation(data.get("explanation", {})),+ "important-changes": render_important_changes(important_changes),+ "decisions": render_decisions(data.get("decisions", [])),+ "findings": render_findings_table(findings),+ "tests": tests.section_html if tests else "",+ "unresolved-comments": render_unresolved_comments(data.get("unresolved_comments", [])),+ "diagram": build_diagram(data, diff_dir, warnings),+ "diffs": render_files(files, fragments, uncovered),+ "double-check": render_double_check(data.get("double_check", [])),+ }++ toc_labels = {+ "pr-description": "Author's description",+ "commits": "Commits",+ "explanation": "Three-level explanation",+ "important-changes": "Important changes (detailed)",+ "decisions": "Key decisions",+ "findings": "Review findings",+ "tests": "Tests",+ "unresolved-comments": "Unresolved comments",+ "diagram": "Blast radius",+ "diffs": "Per-file diffs",+ "double-check": "Things to double-check",+ }+ toc_entries = [(sid, toc_labels[sid]) for sid in sections if sections[sid]]++ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")++ page = PAGE_TEMPLATE.substitute(+ title_plain=escape(title),+ title_html=escape(title),+ css=CSS,+ publish_metadata=render_publish_metadata(data.get("publish_metadata", {})),+ repo_name=repo_name,+ repo_path=repo_path,+ subtitle=data.get("subtitle", ""),+ metrics_chips=render_metrics(data.get("metrics", [])),+ at_a_glance=render_at_a_glance(data.get("at_a_glance", [])),+ important_links=render_important_links(important_changes),+ verdict_card=render_verdict_card(data.get("verdict", {})),+ findings_summary=render_findings_summary_card(findings),+ tests_card=tests.card_html if tests else "",+ toc=build_toc(toc_entries),+ pr_description_section=sections["pr-description"],+ commits_section=sections["commits"],+ explanation_section=sections["explanation"],+ important_changes_section=sections["important-changes"],+ decisions_section=sections["decisions"],+ findings_section=sections["findings"],+ tests_section=sections["tests"],+ unresolved_comments_section=sections["unresolved-comments"],+ diagram_section=sections["diagram"],+ files_section=sections["diffs"],+ double_check_section=sections["double-check"],+ timestamp=timestamp,+ )++ # The skill greps these two lines to apply the severity floor; they must+ # be the last thing on stderr, after every warning.+ if tests:+ c = tests.counts+ print(f"summary coverage: matched={c['matched']} unmatched={c['unmatched']}", file=sys.stderr)+ tallies = " ".join(f"{o}={c[o]}" for o in OUTCOMES + ("flaky",))+ print(f"summary tests: {tallies}", file=sys.stderr)+ return page
diff --git a/scripts/review_html/sections.py b/scripts/review_html/sections.pynew file mode 100644index 0000000..6408013--- /dev/null+++ b/scripts/review_html/sections.py@@ -0,0 +1,396 @@+"""Section renderers for the review page.++Each function takes the relevant slice of the review JSON and returns+HTML, or an empty string when the section has nothing to show."""+from __future__ import annotations++import json+import textwrap++from .common import escape, file_anchor, severity_pill+from .diffs import render_diff+++# --- section renderers -----------------------------------------------------++def render_metrics(metrics: list[dict]) -> str:+ return "\n".join(+ f'<span class="chip">{escape(m.get("label"))} '+ f'<strong>{escape(m.get("value"))}</strong></span>'+ for m in metrics+ )+++def render_at_a_glance(items: list[str]) -> str:+ if not items:+ return ""+ bullets = "\n".join(f"<li>{item}</li>" for item in items)+ return f"""<div class="card">+ <h3>At a glance</h3>+ <ul>{bullets}</ul>+ </div>"""+++def render_important_links(changes: list[dict]) -> str:+ if not changes:+ return ""+ bullets = "".join(+ f'<li><span class="tag">key</span>'+ f'<a href="#change-{i}">{escape(c.get("title"))}</a></li>'+ for i, c in enumerate(changes)+ )+ return f"""<div class="card">+ <h3>Important changes</h3>+ <ul>{bullets}</ul>+ </div>"""+++def render_verdict_card(verdict: dict) -> str:+ if not verdict:+ return ""+ tone = verdict.get("tone", "success")+ if tone not in ("success", "warning", "error"):+ tone = "success"+ return f"""<div class="card">+ <h3>Verdict</h3>+ <p><span class="verdict-pill verdict-{tone}">{escape(verdict.get("label"))}</span></p>+ <p class="muted">{verdict.get("detail", "")}</p>+ </div>"""+++def render_findings_summary_card(findings: list[dict]) -> str:+ if not findings:+ return ""+ raised = len(findings)+ fixed = sum(1 for f in findings if f.get("status", "fixed") == "fixed")+ skipped = raised - fixed+ return f"""<div class="card">+ <h3>Review findings</h3>+ <p>{raised} raised · {fixed} fixed · {skipped} skipped</p>+ <p><a href="#findings">Jump to findings →</a></p>+ </div>"""+++def render_pr_description(pr: dict) -> str:+ """Render the PR author's body verbatim.++ The body is HTML-escaped and dropped into a <pre> with pre-wrap so the+ author's original text and line breaks survive unchanged. Markdown stays+ visible as markdown — no parser is applied because the point is to show+ motivation as the author wrote it, not the reviewer's interpretation.+ """+ if not pr or not pr.get("body"):+ return ""+ meta_parts = []+ if pr.get("author"):+ if pr.get("url"):+ meta_parts.append(+ f'<a href="{escape(pr["url"])}">{escape(pr["author"])}</a>'+ )+ else:+ meta_parts.append(escape(pr["author"]))+ if pr.get("created_at"):+ meta_parts.append(escape(pr["created_at"]))+ meta_line = " · ".join(meta_parts)+ meta_html = (+ f'<div class="meta">{meta_line}</div>' if meta_line else ""+ )+ return f"""<section id="pr-description">+ <h2>Author's PR description</h2>+ <p class="muted">Shown verbatim — the markdown the author wrote, unmodified.</p>+ <div class="pr-description">+ {meta_html}+ <pre>{escape(pr["body"])}</pre>+ </div>+ </section>"""+++def render_commits(items: list[dict]) -> str:+ if not items:+ return ""+ rows = []+ for c in items:+ sha = escape(c.get("sha"))+ subj = escape(c.get("subject"))+ meta = c.get("meta")+ if meta is not None:+ meta_html = escape(meta)+ else:+ meta_html = f'{escape(c.get("author"))} · {escape(c.get("date"))}'+ rows.append(+ f'<li><code class="commit-sha">{sha}</code> '+ f'<span class="commit-subj">{subj}</span> '+ f'<span class="commit-meta">— {meta_html}</span></li>'+ )+ return f"""<section id="commits">+ <h2>Commits</h2>+ <ul class="commit-list">{"".join(rows)}</ul>+ </section>"""+++def render_explanation(panels: dict) -> str:+ if not panels:+ return ""+ order = [("beginner", "Beginner"),+ ("intermediate", "Intermediate"),+ ("expert", "Expert")]+ levels = [(key, label, panels[key]) for key, label in order if panels.get(key)]+ if not levels:+ return ""+ radios = "\n".join(+ f'<input type="radio" name="tabs" id="tab-{key}"'+ f'{" checked" if i == 0 else ""}>'+ for i, (key, _, _) in enumerate(levels)+ )+ labels = "\n".join(+ f'<label for="tab-{key}">{escape(label)}</label>'+ for key, label, _ in levels+ )+ panels_html = "\n".join(+ f'<div id="panel-{key}" class="tab-panel">{content}</div>'+ for key, _, content in levels+ )+ return f"""<section id="explanation">+ <h2>Three-level explanation</h2>+ <div class="tabs">+ {radios}+ <div class="tab-labels">{labels}</div>+ <div class="tab-panels">{panels_html}</div>+ </div>+ </section>"""+++def render_important_changes(changes: list[dict]) -> str:+ if not changes:+ return ""+ cards = []+ for i, c in enumerate(changes):+ file_path = c.get("file", "")+ anchor = file_anchor(file_path) if file_path else ""+ what_text = escape(c.get("what"))+ what_block = (+ f'<p><strong>What to look at.</strong> '+ f'<a href="#{anchor}">{what_text}</a></p>'+ if anchor and what_text+ else (f'<p><strong>What to look at.</strong> {what_text}</p>' if what_text else "")+ )+ takeaway = c.get("takeaway")+ takeaway_block = (+ f'<div class="callout callout-takeaway">'+ f'<strong>Takeaway.</strong> {escape(takeaway)}</div>'+ if takeaway else ""+ )+ if c.get("rationale_unknown"):+ rationale_block = (+ '<div class="callout callout-warning">'+ '<strong>Open question.</strong> Rationale not stated by the author '+ 'and not inferable from the diff.</div>'+ )+ elif c.get("rationale"):+ rationale_html = escape(c["rationale"])+ if c.get("rationale_inferred"):+ rationale_html += (+ ' <span class="muted">(inferred — not stated by the author)</span>'+ )+ rationale_block = (+ '<div class="callout callout-rationale">'+ f'<strong>Rationale.</strong> {rationale_html}</div>'+ )+ else:+ rationale_block = ""+ cards.append(textwrap.dedent(f"""\+ <div class="change-card" id="change-{i}">+ <h3>{escape(c.get("title"))}</h3>+ <p class="muted">{escape(file_path)}</p>+ <p><strong>Why it matters.</strong> {escape(c.get("why"))}</p>+ {what_block}+ {takeaway_block}+ {rationale_block}+ </div>"""))+ return f"""<section id="important-changes">+ <h2>Important changes — detailed</h2>+ {"".join(cards)}+ </section>"""+++def render_decisions(items: list[dict]) -> str:+ if not items:+ return ""+ callouts = []+ for d in items:+ body = d.get("body", "")+ if d.get("inferred"):+ body = body + ' <span class="muted">(inferred — not stated by the author.)</span>'+ callouts.append(+ '<div class="callout callout-rationale">'+ f'<strong>{escape(d.get("title"))}</strong> {body}</div>'+ )+ return f"""<section id="decisions">+ <h2>Key decisions</h2>+ {"".join(callouts)}+ </section>"""+++def render_findings_table(findings: list[dict]) -> str:+ if not findings:+ return ""+ rows = []+ for f in findings:+ rows.append(+ "<tr>"+ f'<td>{severity_pill(f.get("severity", "nit"))}</td>'+ f'<td>{escape(f.get("area"))}</td>'+ f'<td>{escape(f.get("finding"))}</td>'+ f'<td>{escape(f.get("resolution"))}</td>'+ "</tr>"+ )+ return f"""<section id="findings">+ <h2>Review findings</h2>+ <table class="findings">+ <thead><tr><th>Severity</th><th>Area</th><th>Finding</th><th>Resolution</th></tr></thead>+ <tbody>{"".join(rows)}</tbody>+ </table>+ </section>"""+++def render_unresolved_comments(items: list[dict]) -> str:+ if not items:+ return ""++ type_labels = {"code": "code", "review": "review", "discussion": "discussion"}++ cards = []+ for c in items:+ ctype = (c.get("type") or "discussion").lower()+ label = type_labels.get(ctype, ctype)+ author = c.get("author") or "(unknown)"+ url = c.get("url")+ created = c.get("created_at")+ path = c.get("path")+ line = c.get("line")++ location_bits = []+ if path:+ loc = escape(path)+ if line:+ loc += f":{escape(line)}"+ location_bits.append(f'<code>{loc}</code>')+ if created:+ location_bits.append(f'<span class="muted">{escape(created)}</span>')+ if url:+ location_bits.append(+ f'<a href="{escape(url)}" target="_blank" rel="noopener">view on GitHub</a>'+ )+ location_line = " · ".join(location_bits)++ body = escape(c.get("body") or "").replace("\n", "<br>")++ replies_html = ""+ replies = c.get("replies") or []+ if replies:+ reply_blocks = []+ for r in replies:+ r_author = escape(r.get("author") or "(unknown)")+ r_created = r.get("created_at")+ header_bits = [f'<strong>{r_author}</strong>']+ if r_created:+ header_bits.append(f'<span class="muted">{escape(r_created)}</span>')+ r_body = escape(r.get("body") or "").replace("\n", "<br>")+ reply_blocks.append(+ '<div class="reply">'+ f'<div class="reply-header">{" · ".join(header_bits)}</div>'+ f'<div class="reply-body">{r_body}</div>'+ '</div>'+ )+ replies_html = (+ '<details class="replies">'+ f'<summary>{len(replies)} earlier repl'+ f'{"y" if len(replies) == 1 else "ies"}</summary>'+ f'{"".join(reply_blocks)}'+ '</details>'+ )++ cards.append(+ '<div class="unresolved-comment">'+ '<div class="unresolved-header">'+ f'<span class="pill pill-warning">{escape(label)}</span> '+ f'<strong>{escape(author)}</strong>'+ f'{" · " + location_line if location_line else ""}'+ '</div>'+ f'<div class="unresolved-body">{body}</div>'+ f'{replies_html}'+ '</div>'+ )++ return f"""<section id="unresolved-comments">+ <h2>Unresolved comments</h2>+ <p class="muted">Open review threads and PR-level comments still awaiting a response.</p>+ {"".join(cards)}+ </section>"""+++def render_double_check(items: list[dict]) -> str:+ if not items:+ return ""+ callouts = "".join(+ '<div class="callout callout-warning">'+ f'<strong>{escape(d.get("title"))}</strong> {d.get("body", "")}</div>'+ for d in items+ )+ return f"""<section id="double-check">+ <h2>Things to double-check</h2>+ {callouts}+ </section>"""+++def render_files(files: list[dict], fragments: dict[str, str],+ uncovered: dict[str, set[int]]) -> str:+ """Per-file diff blocks.++ ``fragments`` comes from ``diffs.load_fragments``; ``uncovered`` maps a+ path to the new-file line numbers that have coverage data and zero hits.+ """+ if not files:+ return ""+ blocks = []+ for f in files:+ path = f.get("path", "")+ badge = f.get("badge", "Modified")+ stat = f.get("stat", "")+ diff = fragments.get(path, "(no diff provided)")+ anchor = file_anchor(path)+ badge_class = "badge-" + "".join(ch for ch in badge.lower() if ch.isalnum())+ blocks.append(textwrap.dedent(f"""\+ <details id="{anchor}" class="file-diff">+ <summary><span class="file-path">{escape(path)}</span> <span class="badge {badge_class}">{escape(badge)}</span> <span class="line-stat">{escape(stat)}</span></summary>+ <pre><code class="diff-block">{render_diff(diff, uncovered.get(path))}</code></pre>+ </details>"""))+ return f"""<section id="diffs">+ <h2>Per-file diffs</h2>+ <p class="muted">Click to expand.</p>+ {"".join(blocks)}+ </section>"""+++def render_publish_metadata(meta: dict) -> str:+ """Emit a <script id="review-meta"> block consumable by `pulsar publish`.++ See pulsar's docs/agent-contract.md. Fields are passed through verbatim,+ minus a `</` escape inside the JSON to prevent premature </script> closure.+ """+ if not meta:+ return ""+ payload = json.dumps(meta, indent=2, ensure_ascii=False).replace("</", "<\\/")+ return (+ f'<script type="application/json" id="review-meta">\n{payload}\n</script>'+ )+++def build_toc(entries: list[tuple[str, str]]) -> str:+ if not entries:+ return ""+ items = "\n".join(+ f'<li><a href="#{sid}">{escape(label)}</a></li>'+ for sid, label in entries+ )+ return f'<nav class="toc"><ul>{items}</ul></nav>'
diff --git a/scripts/review_html/template.py b/scripts/review_html/template.pynew file mode 100644index 0000000..c27e551--- /dev/null+++ b/scripts/review_html/template.py@@ -0,0 +1,56 @@+"""Top-level page template.++Every placeholder is a substituted value; the stylesheet comes from+``css.CSS`` through the ``$css`` placeholder."""+from __future__ import annotations++from string import Template++PAGE_TEMPLATE = Template("""<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<title>$title_plain</title>+<meta name="viewport" content="width=device-width, initial-scale=1">+$publish_metadata+<style>$css</style>+</head>+<body>+<header class="top-bar">+ <div class="top-stripe"></div>+ <div class="top-content">+ <span class="repo-title">$repo_name</span>+ $metrics_chips+ </div>+</header>++<div class="page">+ <h1>$title_html</h1>+ <p class="muted">$subtitle</p>++ <section class="card-grid">+ $at_a_glance+ $important_links+ $verdict_card+ $findings_summary$tests_card+ </section>++ $toc++ $pr_description_section+ $commits_section+ $explanation_section+ $important_changes_section+ $decisions_section+ $findings_section$tests_section+ $unresolved_comments_section$diagram_section+ $files_section+ $double_check_section++ <footer>+ Generated $timestamp · repo <code>$repo_path</code> · regenerate with <code>/pre-push-review</code>.+ </footer>+</div>+</body>+</html>+""")
diff --git a/scripts/review_html/tests_section.py b/scripts/review_html/tests_section.pynew file mode 100644index 0000000..b61d787--- /dev/null+++ b/scripts/review_html/tests_section.py@@ -0,0 +1,414 @@+"""Tests card and Tests section built from the review JSON ``tests`` block.++``build_tests`` parses the JUnit and coverage inputs, matches coverage to+the changed files, and returns the card, the section, the uncovered line+sets for the per-file diffs, and the counts behind the ``summary`` lines.+"""+from __future__ import annotations++from dataclasses import dataclass, field+from pathlib import Path++from .common import escape, file_anchor+from .coverage import Coverage, apply_path_map, diff_coverage, match, overall, parse_coverage+from .diffs import added_lines, is_binary+from .inputs import read_json+from .junit import OUTCOMES, Case, parse_junit+from .redact import clean_message+from .warnings import Warnings++UPLOAD_STATES = ("no run", "artifacts absent", "artifacts expired")+UPLOAD_SENTENCE = (+ "To enable this section, the workflow must upload a JUnit XML file as an artifact, "+ "and a coverage file in a supported format (lcov, Cobertura XML, or Go coverprofile) "+ "to enable coverage."+)+REASON_TEXT = {+ "no tests found": "No tests were found.",+ "runner not detected": "The test runner could not be detected.",+ "required tool missing": "A required tool is missing.",+ "local run failed": "The local test run failed before writing results.",+ "local run timed out": "The local test run timed out before writing results.",+}+OUTCOME_TEXT = {"passed": "passed", "failed": "failed", "timed_out": "timed out", "not_run": "not run"}+SCOPE_TEXT = {"repository": "every test in the repository",+ "project-configured": "as the project configures it"}+DASH = "—"+++@dataclass+class TestsResult:+ card_html: str+ section_html: str+ uncovered: dict[str, set[int]] = field(default_factory=dict)+ counts: dict[str, int] = field(default_factory=dict)+++# --- small formatting helpers ---------------------------------------------++def _pct(num: int, den: int) -> str:+ return f"{round(100 * num / den)}%"+++def _pct1(num: int, den: int) -> str:+ return f"{100 * num / den:.1f}%"+++def _plural(n: int, word: str) -> str:+ return f"{n} {word}{'' if n == 1 else 's'}"+++def _source_label(source: str | None) -> str:+ return "CI" if source == "ci" else "a local run"+++def _link(url: object, text: str) -> str:+ return f'<a href="{escape(url)}">{text}</a>' if url else text+++def _tally(cases: list[Case]) -> dict[str, int]:+ counts = dict.fromkeys(OUTCOMES + ("flaky",), 0)+ for c in cases:+ counts[c.outcome] = counts.get(c.outcome, 0) + 1+ if c.flaky:+ counts["flaky"] += 1+ return counts+++def _list(items: list[str]) -> str:+ return "<ul>" + "".join(f"<li>{item}</li>" for item in items) + "</ul>"+++def _table(headers: list[str], rows: list[str]) -> str:+ head = "".join(f"<th>{h}</th>" for h in headers)+ return (f'<table class="tests"><thead><tr>{head}</tr></thead>'+ f'<tbody>{"".join(rows)}</tbody></table>')+++# --- section parts ------------------------------------------------------------++def _provenance(block: dict) -> str:+ prov = block.get("provenance") or {}+ source = prov.get("source")+ bits = []+ if source == "ci":+ bits.append("Source: <strong>CI</strong>")+ ids = prov.get("run_ids") or []+ urls = prov.get("run_urls") or []+ runs = [_link(urls[i] if i < len(urls) else None, f"run {escape(rid)}")+ for i, rid in enumerate(ids)]+ if runs:+ bits.append(", ".join(runs))+ else:+ text = "Source: <strong>local run</strong>"+ if prov.get("timestamp"):+ text += f" at {escape(prov['timestamp'])}"+ bits.append(text)+ snapshot = prov.get("snapshot") or {}+ if snapshot.get("sha"):+ text = f"snapshot <code>{escape(snapshot['sha'])}</code>"+ if snapshot.get("dirty"):+ text += " (dirty working tree)"+ bits.append(text)+ if prov.get("ci_state"):+ bits.append(f"CI state: <strong>{escape(prov['ci_state'])}</strong>")+ if prov.get("fallback_state"):+ bits.append(f"Fallback: <strong>{escape(prov['fallback_state'])}</strong>")+ lines = [f'<p class="tests-provenance">{" · ".join(bits)}</p>']++ base = block.get("baseline_provenance")+ if base:+ if base.get("source") == "ci":+ text = "Baseline: CI"+ if base.get("run_id") is not None:+ text += " " + _link(base.get("run_url"), f"run {escape(base['run_id'])}")+ else:+ text = "Baseline: local run"+ if base.get("timestamp"):+ text += f" at {escape(base['timestamp'])}"+ if base.get("sha"):+ text += f" at <code>{escape(base['sha'])}</code>"+ else:+ text = "Baseline: none"+ lines.append(f'<p class="tests-provenance">{text}</p>')+ return "\n".join(lines)+++def _files_read(label: str, read: int, listed: int) -> str:+ if not listed:+ return f"{label}: none"+ if read == listed:+ return f"{label}: {_plural(listed, 'file')}"+ return f"{label}: {read} of {listed} files read"+++def _availability(block: dict, junit_read: int, coverage_read: int, baseline: bool) -> str:+ outcome = OUTCOME_TEXT.get(block.get("run_outcome"), escape(block.get("run_outcome") or "unknown"))+ execution = f"Execution: <strong>{outcome}</strong>"+ if block.get("partial"):+ execution += " (partial results)"+ junit = _files_read("JUnit", junit_read, len(block.get("junit") or []))+ cov = _files_read("Coverage", coverage_read, len(block.get("coverage") or []))+ base = "Baseline: present" if baseline else "Baseline: absent"+ return f'<p class="tests-availability">{execution} · {junit} · {cov} · {base}</p>'+++def _no_data_card(block: dict) -> str:+ reason = block.get("no_data_reason")+ prov = block.get("provenance") or {}+ ci_state = prov.get("ci_state")+ if reason == "ci" or (reason is None and prov.get("source") == "ci" and ci_state):+ text = f"No results from CI. CI state: <strong>{escape(ci_state)}</strong>."+ if prov.get("fallback_state"):+ text += f" Local fallback: <strong>{escape(prov['fallback_state'])}</strong>."+ if ci_state in UPLOAD_STATES:+ text += " " + UPLOAD_SENTENCE+ elif reason:+ text = REASON_TEXT.get(reason, escape(reason))+ else:+ text = "No test results were read from the inputs."+ return f'<div class="card tests-nodata"><h3>No test results</h3><p>{text}</p></div>'+++def _jobs_table(block: dict, cases: list[Case]) -> str:+ jobs = block.get("jobs") or []+ artifacts = block.get("artifacts") or []+ if not jobs and not artifacts:+ return ""+ by_source: dict[str, list[Case]] = {}+ for c in cases:+ by_source.setdefault(c.source, []).append(c)++ def artifact_cases(a: dict) -> list[Case]:+ return [c for name in a.get("junit") or [] for c in by_source.get(name, [])]++ def cells(tally: dict[str, int] | None) -> str:+ if tally is None:+ return "".join(f"<td>{DASH}</td>" for _ in range(4))+ return "".join(f"<td>{tally[k]}</td>" for k in ("passed", "failed", "skipped", "errored"))++ rows = []+ for job in jobs:+ attributed = [a for a in artifacts if a.get("job") == job.get("name")]+ tally = _tally([c for a in attributed for c in artifact_cases(a)]) if attributed else None+ rows.append(f"<tr><td>{_link(job.get('url'), escape(job.get('name')))}</td>"+ f"<td>{escape(job.get('outcome') or DASH)}</td>{cells(tally)}</tr>")+ for a in artifacts:+ if a.get("job"):+ continue+ rows.append(f"<tr><td>artifact <code>{escape(a.get('name'))}</code></td><td>{DASH}</td>"+ f"{cells(_tally(artifact_cases(a)))}</tr>")+ return "<h3>Jobs</h3>\n" + _table(["Job", "Outcome", "Passed", "Failed", "Skipped", "Errored"], rows)+++def _failed_table(block: dict, cases: list[Case]) -> str:+ failed = [c for c in cases if c.outcome in ("failed", "errored")]+ if not failed:+ return ""+ where: dict[str, str] = {}+ for a in block.get("artifacts") or []:+ label = escape(a.get("job")) if a.get("job") else f"artifact {escape(a.get('name'))}"+ for name in a.get("junit") or []:+ where.setdefault(name, label)+ rows = [f"<tr><td>{escape(c.suite)}</td><td>{escape(c.name)}</td>"+ f"<td>{where.get(c.source, DASH)}</td><td>{escape(clean_message(c.message))}</td></tr>"+ for c in failed]+ return "<h3>Failed tests</h3>\n" + _table(["Suite", "Test", "Job or artifact", "Message"], rows)+++def _new_removed(block: dict, head: list[Case], base: list[Case],+ diff_dir: Path | None, warnings: Warnings) -> tuple[str, int | None]:+ """Section HTML and the new-test count for the card (``None`` when unknown)."""+ prov = block.get("provenance") or {}+ base_prov = block.get("baseline_provenance") or {}+ if base:+ head_ids = {(c.suite, c.name) for c in head}+ base_ids = {(c.suite, c.name) for c in base}+ new = sorted(head_ids - base_ids)+ removed = sorted(base_ids - head_ids)+ parts = ['<p class="muted">Derived by identity, from the baseline run.</p>']+ if prov.get("source") != base_prov.get("source"):+ parts.append('<p class="callout callout-warning">This comparison crosses sources: '+ f"head from {_source_label(prov.get('source'))}, baseline from "+ f"{_source_label(base_prov.get('source'))}. Tests that only run in one "+ "source appear as new or removed.</p>")+ items = [f"+ <code>{escape(s)}</code> {escape(n)}" for s, n in new]+ items += [f"− <code>{escape(s)}</code> {escape(n)}" for s, n in removed]+ parts.append(_list(items) if items else '<p class="muted">No new or removed tests.</p>')+ return "<h3>New and removed tests</h3>\n" + "\n".join(parts), len(new)++ name = block.get("diff_tests_file")+ if name and diff_dir is not None:+ data = read_json(diff_dir / name, warnings, "diff-derived test list")+ if isinstance(data, dict):+ added = [str(x) for x in data.get("added") or []]+ removed = [str(x) for x in data.get("removed") or []]+ unpatterned = [str(x) for x in data.get("unpatterned_files") or []]+ parts = ['<p class="muted">Derived by declaration name, from the diff (no baseline run).</p>']+ items = [f"+ {escape(n)}" for n in added]+ items += [f"− {escape(n)}" for n in removed]+ parts.append(_list(items) if items else '<p class="muted">No new or removed test declarations.</p>')+ if unpatterned:+ files = ", ".join(f"<code>{escape(f)}</code>" for f in unpatterned)+ parts.append(f'<p class="muted">No declaration pattern applies to {files}; '+ "those files yield nothing.</p>")+ return "<h3>New and removed tests</h3>\n" + "\n".join(parts), len(added)++ return ("<h3>New and removed tests</h3>\n"+ '<p class="muted">Not available: no baseline run and no diff-derived list.</p>'), None+++def _coverage_parts(block: dict, files: list[dict], fragments: dict[str, str],+ cov: Coverage, base_cov: Coverage) -> tuple[str, dict[str, set[int]], dict[str, int], str]:+ """Diff-coverage table, overall coverage, and unmatched report.++ Returns the HTML, the uncovered sets, the matched/unmatched counts, and+ the card's diff-coverage value. With no coverage entries there is+ nothing to match: every part is omitted and both counts are zero.+ """+ if not cov:+ return "", {}, {"matched": 0, "unmatched": 0}, "n/a"+ eligible = [f.get("path", "") for f in files+ if f.get("badge") != "Deleted" and not is_binary(fragments.get(f.get("path", ""), ""))]+ hits, unmatched = match(cov, eligible)+ counts = {"matched": len(hits), "unmatched": len(unmatched)}+ uncovered: dict[str, set[int]] = {}+ rows = []+ total_covered = total_measurable = 0+ for path in eligible:+ added = added_lines(fragments.get(path, ""))+ result = diff_coverage(added, hits[path]) if path in hits else None+ cell = f"<td>{DASH}</td><td>no coverage data</td>"+ if result is not None:+ covered, measurable = result+ total_covered += covered+ total_measurable += measurable+ cell = f"<td>{covered}</td><td>{_pct(covered, measurable)}</td>"+ zero = {n for n in added if hits[path].get(n) == 0}+ if zero:+ uncovered[path] = zero+ rows.append(f'<tr><td><a href="#{file_anchor(path)}">{escape(path)}</a></td>'+ f"<td>{len(added)}</td>{cell}</tr>")++ parts = ["<h3>Diff coverage</h3>"]+ if rows:+ parts.append(_table(["File", "Added lines", "Covered", "Diff coverage"], rows))+ if total_measurable:+ aggregate = _pct(total_covered, total_measurable)+ card_value = f"{aggregate} ({total_covered} of {total_measurable} added lines)"+ parts.append(f"<p>Aggregate diff coverage: <strong>{aggregate}</strong> "+ f"({total_covered} of {total_measurable} measurable added lines).</p>")+ else:+ card_value = "n/a"+ parts.append('<p class="muted">No changed file has measurable added lines.</p>')++ covered, instrumented = overall(cov)+ if instrumented:+ text = f"Head <strong>{_pct1(covered, instrumented)}</strong> ({covered} of {instrumented} lines)"+ base_covered, base_instrumented = overall(base_cov)+ if base_instrumented:+ delta = 100 * covered / instrumented - 100 * base_covered / base_instrumented+ text += (f" · baseline <strong>{_pct1(base_covered, base_instrumented)}</strong> "+ f"({base_covered} of {base_instrumented} lines) · delta <strong>{delta:+.1f} pp</strong>")+ parts.append(f"<h3>Overall coverage</h3>\n<p>{text}</p>")+ prov = block.get("provenance") or {}+ base_prov = block.get("baseline_provenance") or {}+ if base_instrumented and prov.get("source") != base_prov.get("source"):+ parts.append('<p class="callout callout-warning">The baseline coverage comes from a different '+ f"source: head from {_source_label(prov.get('source'))}, baseline from "+ f"{_source_label(base_prov.get('source'))}.</p>")++ parts.append(f'<p class="tests-matching">{len(hits)} of {len(eligible)} changed files matched coverage data.</p>')+ if unmatched:+ parts.append(_list([f"<code>{escape(p)}</code> — {escape(reason)}"+ for p, reason in unmatched.items()]))+ return "\n".join(parts), uncovered, counts, card_value+++def _card(pass_rate: str, new_tests: str, diff_cov: str) -> str:+ return f"""<div class="card">+ <h3>Tests</h3>+ <p>Pass rate: {pass_rate}</p>+ <p>New tests: {new_tests}</p>+ <p>Diff coverage: {diff_cov}</p>+ <p><a href="#tests">Jump to tests →</a></p>+ </div>"""+++# --- entry point --------------------------------------------------------------++def build_tests(block: dict, files: list[dict], fragments: dict[str, str],+ diff_dir: Path | None, warnings: Warnings) -> TestsResult:+ def inputs(key: str) -> list[Path]:+ names = block.get(key) or []+ return [diff_dir / n for n in names] if diff_dir is not None else []++ def coverage(key: str) -> tuple[Coverage, int]:+ """Mapped entries and the number of listed files that yielded any."""+ cov: Coverage = []+ read = 0+ for path in inputs(key):+ entries = parse_coverage(path, warnings)+ read += bool(entries)+ cov.extend(entries)+ path_map = block.get("path_map") or {}+ return apply_path_map(cov, path_map.get("strip"), path_map.get("prepend")), read++ head = parse_junit(inputs("junit"), warnings)+ base = parse_junit(inputs("baseline_junit"), warnings)+ cov, coverage_read = coverage("coverage")+ base_cov, _ = coverage("baseline_coverage")++ tally = _tally(head)+ denominator = tally["passed"] + tally["failed"] + tally["errored"]+ pass_rate = f"{_pct(tally['passed'], denominator)} ({tally['passed']} of {denominator})" if denominator else "n/a"++ parts = ['<section id="tests">', "<h2>Tests</h2>", _provenance(block),+ _availability(block, len({c.source for c in head}), coverage_read,+ bool(base) or bool(base_cov))]+ scope = block.get("coverage_scope")+ if scope:+ parts.append(f'<p class="muted">Coverage scope: {SCOPE_TEXT.get(scope, escape(scope))}</p>')+ if not head:+ parts.append(_no_data_card(block))+ else:+ parts.append(f'<p class="tests-totals">Totals: <strong>{tally["passed"]} passed</strong> · '+ f'{tally["failed"]} failed · {tally["skipped"]} skipped · '+ f'{tally["errored"]} errored · {tally["flaky"]} flaky</p>')+ pending = block.get("pending_runs") or []+ if pending:+ parts.append("<h3>Pending runs</h3>\n" + _list([+ f"{_link(r.get('url'), escape(r.get('name')))} (run {escape(r.get('run_id'))}, {escape(r.get('status'))})"+ for r in pending]))+ parts.append(_jobs_table(block, head))+ parts.append(_failed_table(block, head))++ new_removed_html, new_count = _new_removed(block, head, base, diff_dir, warnings)+ parts.append(new_removed_html)++ coverage_html, uncovered, match_counts, diff_cov_value = _coverage_parts(+ block, files, fragments, cov, base_cov)+ parts.append(coverage_html)++ touched = block.get("run_touched_files") or []+ if touched:+ parts.append("<h3>Files touched by the run</h3>\n"+ '<p class="callout callout-warning">The test run changed these tracked files; '+ "they were restored afterwards.</p>\n"+ + _list([f"<code>{escape(p)}</code>" for p in touched]))+ skipped = block.get("skipped_artifacts") or []+ if skipped:+ parts.append("<h3>Skipped artifacts</h3>\n" + _list([+ f"<code>{escape(a.get('name'))}</code> ({escape(a.get('size_in_bytes'))} bytes)" for a in skipped]))+ if warnings.items:+ parts.append("<h3>Warnings</h3>\n" + _list([escape(w) for w in warnings.items]))+ parts.append("</section>")++ counts = dict(tally)+ counts.update(match_counts)+ return TestsResult(+ card_html=_card(pass_rate, "n/a" if new_count is None else str(new_count), diff_cov_value),+ section_html="\n".join(p for p in parts if p),+ uncovered=uncovered,+ counts=counts,+ )
diff --git a/scripts/review_html/warnings.py b/scripts/review_html/warnings.pynew file mode 100644index 0000000..f74ac76--- /dev/null+++ b/scripts/review_html/warnings.py@@ -0,0 +1,18 @@+"""Warning collector threaded through parsing and rendering.++Each warning is printed to stderr the moment it is added, so a crash later+in the run still leaves the earlier warnings visible, and the full list is+available afterwards for the page.+"""+from __future__ import annotations++import sys+++class Warnings:+ def __init__(self) -> None:+ self.items: list[str] = []++ def add(self, message: str) -> None:+ self.items.append(message)+ print(f"warning: {message}", file=sys.stderr)
diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.pynew file mode 100644index 0000000..e69de29
diff --git a/scripts/tests/fixtures/golden-settings.diff b/scripts/tests/fixtures/golden-settings.diffnew file mode 100644index 0000000..7a9e2b3--- /dev/null+++ b/scripts/tests/fixtures/golden-settings.diff@@ -0,0 +1,8 @@+diff --git a/config/$settings.toml b/config/$settings.toml+--- a/config/$settings.toml++++ b/config/$settings.toml+@@ -1,3 +1,3 @@+ [widgets]+-widget_dir = "widgets"++dir = "widgets"+ context <tail> & more
diff --git a/scripts/tests/fixtures/golden.html b/scripts/tests/fixtures/golden.htmlnew file mode 100644index 0000000..508014c--- /dev/null+++ b/scripts/tests/fixtures/golden.html@@ -0,0 +1,503 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<title>PR review: acme/widgets #42 <golden></title>+<meta name="viewport" content="width=device-width, initial-scale=1">+<script type="application/json" id="review-meta">+{+ "title": "PR review: acme/widgets #42",+ "repoUrl": "https://github.com/acme/widgets",+ "pr": 42,+ "severity": "needs-changes",+ "summary": "Registry lands; one major test gap skipped. Contains <\/script> to exercise escaping."+}+</script>+<style>+:root {+ --bg: #0B1020;+ --bg-deep: #050C1B;+ --surface-1: #101A33;+ --surface-2: #142042;+ --border: #26324F;+ --border-subtle: #1F2A45;+ --text-primary: #EAF1FF;+ --text-secondary: #B7C3E3;+ --text-tertiary: #7F8BB0;+ --accent: #44C4DC;+ --accent-2: #E474E4;+ --accent-3: #4C6CBC;+ --code-bg: #0A1226;+ --code-border: #1F2A45;+ --success: #22C55E;+ --warning: #FBBF24;+ --error: #EF476F;+ --diff-add-bg: rgba(34, 197, 94, 0.12);+ --diff-add-fg: #86EFAC;+ --diff-del-bg: rgba(239, 71, 111, 0.12);+ --diff-del-fg: #FCA5A5;+}++* { box-sizing: border-box; }+html { background: var(--bg); }+body {+ background: var(--bg);+ color: var(--text-primary);+ font-family: -apple-system, "SF Pro Text", system-ui, sans-serif;+ line-height: 1.6;+ margin: 0;+ padding: 0;+}++.page { max-width: 1100px; margin: 0 auto; padding: 32px; }++header.top-bar {+ position: sticky; top: 0; z-index: 10;+ background: var(--bg-deep);+ border-bottom: 1px solid var(--border-subtle);+}+.top-stripe { height: 4px; background: linear-gradient(135deg, var(--accent-3), var(--accent-2)); }+.top-content {+ display: flex; gap: 16px; flex-wrap: wrap; align-items: center;+ padding: 14px 32px; max-width: 1100px; margin: 0 auto;+}+.top-content .repo-title { font-weight: 600; font-size: 15px; color: var(--text-primary); }++.chip {+ display: inline-flex; align-items: center; gap: 6px;+ background: var(--surface-1); border: 1px solid var(--border-subtle);+ border-radius: 999px; padding: 4px 12px; font-size: 12px; color: var(--text-secondary);+}+.chip strong { color: var(--text-primary); }++h1, h2, h3 { color: var(--text-primary); line-height: 1.3; }+h1 { font-size: 30px; margin: 8px 0 4px; }+h2 { font-size: 22px; margin: 32px 0 12px; padding-top: 12px; border-top: 1px solid var(--border-subtle); }+h3 { font-size: 17px; margin: 16px 0 8px; }++p { color: var(--text-secondary); }+strong { color: var(--text-primary); }+a { color: var(--accent); text-decoration: none; }+a:hover { text-decoration: underline; }+.muted { color: var(--text-tertiary); }+code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }++.card-grid {+ display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;+ margin: 24px 0 32px;+}++.card {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 16px; padding: 20px;+ transition: background-color 120ms ease;+}+.card:hover { background: var(--surface-2); }+.card h3 {+ margin-top: 0; font-size: 14px;+ text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary);+}+.card ul { margin: 8px 0 0; padding-left: 20px; }+.card li { margin: 4px 0; color: var(--text-secondary); }++.tag {+ display: inline-block;+ background: rgba(228, 116, 228, 0.15);+ color: var(--accent-2);+ border-radius: 4px;+ padding: 0 6px;+ font-size: 11px;+ font-weight: 600;+ text-transform: uppercase;+ margin-right: 6px;+}++.verdict-pill {+ display: inline-flex; align-items: center; gap: 8px;+ padding: 10px 18px; border-radius: 999px;+ font-weight: 600; font-size: 14px;+ text-transform: uppercase; letter-spacing: 0.05em;+}+.verdict-success { background: rgba(34, 197, 94, 0.18); color: var(--success); border: 1px solid rgba(34,197,94,0.35); }+.verdict-warning { background: rgba(251, 191, 36, 0.15); color: var(--warning); border: 1px solid rgba(251,191,36,0.35); }+.verdict-error { background: rgba(239, 71, 111, 0.15); color: var(--error); border: 1px solid rgba(239,71,111,0.35); }++.toc {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 16px; padding: 16px 20px; margin: 0 0 24px;+}+.toc ul { list-style: none; padding-left: 0; margin: 0; column-count: 2; column-gap: 24px; }+.toc li { margin: 4px 0; }+.toc a { color: var(--text-secondary); }+.toc a:hover { color: var(--accent); }++.tabs {+ margin: 16px 0; border: 1px solid var(--border);+ border-radius: 16px; background: var(--surface-1);+}+.tabs input[type="radio"] { display: none; }+.tab-labels { display: flex; border-bottom: 1px solid var(--border-subtle); }+.tab-labels label {+ padding: 12px 20px; cursor: pointer; color: var(--text-tertiary);+ font-weight: 600; font-size: 13px;+ text-transform: uppercase; letter-spacing: 0.06em;+ border-bottom: 2px solid transparent; margin-bottom: -1px;+}+.tab-labels label:hover { color: var(--text-secondary); }++#tab-beginner:checked ~ .tab-labels label[for="tab-beginner"],+#tab-intermediate:checked ~ .tab-labels label[for="tab-intermediate"],+#tab-expert:checked ~ .tab-labels label[for="tab-expert"] {+ color: var(--accent);+ border-bottom-color: var(--accent);+}++.tab-panels { padding: 20px; }+.tab-panel { display: none; }+#tab-beginner:checked ~ .tab-panels #panel-beginner,+#tab-intermediate:checked ~ .tab-panels #panel-intermediate,+#tab-expert:checked ~ .tab-panels #panel-expert {+ display: block;+}++.change-card {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 16px; padding: 20px; margin: 16px 0;+ transition: background-color 120ms ease;+}+.change-card:hover { background: var(--surface-2); }++.callout {+ margin: 12px 0; padding: 10px 14px;+ background: var(--bg-deep);+ border-radius: 6px; font-size: 14px; color: var(--text-secondary);+}+.callout-takeaway { border-left: 3px solid var(--accent-2); }+.callout-rationale { border-left: 3px solid var(--accent); }+.callout-warning { border-left: 3px solid var(--warning); }++table.findings {+ width: 100%; border-collapse: collapse;+ margin: 16px 0; background: var(--surface-1);+ border: 1px solid var(--border); border-radius: 12px; overflow: hidden;+}+table.findings th, table.findings td {+ padding: 10px 14px; text-align: left;+ border-bottom: 1px solid var(--border-subtle);+ font-size: 14px; vertical-align: top;+}+table.findings th {+ background: var(--surface-2); color: var(--text-secondary);+ text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em;+}+table.findings tr:last-child td { border-bottom: none; }++.pill {+ display: inline-block; padding: 2px 10px; border-radius: 999px;+ font-size: 11px; font-weight: 600;+ text-transform: uppercase; letter-spacing: 0.06em;+}+.pill-error { background: rgba(239,71,111,0.18); color: var(--error); }+.pill-warning { background: rgba(251,191,36,0.18); color: var(--warning); }+.pill-success { background: rgba(34,197,94,0.18); color: var(--success); }+.pill-tertiary { background: rgba(127,139,176,0.18); color: var(--text-tertiary); }++.unresolved-comment {+ background: var(--surface-1); border: 1px solid var(--border);+ border-left: 3px solid var(--warning);+ border-radius: 10px; padding: 14px 16px; margin: 12px 0;+}+.unresolved-header {+ display: flex; flex-wrap: wrap; gap: 8px; align-items: center;+ font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;+}+.unresolved-header code {+ background: var(--code-bg); color: var(--accent);+ padding: 1px 6px; border-radius: 4px; font-size: 12px;+}+.unresolved-body {+ color: var(--text-primary); font-size: 14px; line-height: 1.55;+ white-space: pre-wrap; word-break: break-word;+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+}+.replies {+ margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-subtle);+}+.replies > summary {+ cursor: pointer; color: var(--text-tertiary); font-size: 12px;+}+.reply { margin: 8px 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border-subtle); }+.reply-header { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }+.reply-body {+ color: var(--text-primary); font-size: 13px;+ white-space: pre-wrap; word-break: break-word;+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+}++.commit-list { list-style: none; padding-left: 0; }+.commit-list li { padding: 6px 0; border-bottom: 1px solid var(--border-subtle); }+.commit-list li:last-child { border-bottom: none; }+.commit-sha {+ color: var(--accent); background: var(--code-bg);+ padding: 2px 6px; border-radius: 4px;+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px;+}+.commit-subj { color: var(--text-primary); }+.commit-meta { color: var(--text-tertiary); font-size: 13px; }++.file-diff {+ background: var(--surface-1); border: 1px solid var(--border);+ border-radius: 12px; margin: 8px 0; overflow: hidden;+}+.file-diff > summary {+ cursor: pointer; padding: 12px 16px; font-weight: 500;+ list-style: none; display: flex; gap: 12px; align-items: center;+}+.file-diff > summary::-webkit-details-marker { display: none; }+.file-diff > summary::before {+ content: "▸"; color: var(--text-tertiary); font-size: 12px; margin-right: 4px;+}+.file-diff[open] > summary::before { content: "▾"; }+.file-path { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--text-primary); }+.line-stat {+ color: var(--text-tertiary);+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+ font-size: 12px; margin-left: auto;+}++.badge {+ display: inline-block; font-size: 10px;+ text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;+ padding: 2px 8px; border-radius: 4px;+}+.badge-added { background: rgba(34,197,94,0.18); color: var(--success); }+.badge-modified { background: rgba(68,196,220,0.18); color: var(--accent); }+.badge-deleted { background: rgba(239,71,111,0.18); color: var(--error); }+.badge-renamed { background: rgba(76,108,188,0.18); color: var(--accent-3); }++.file-diff pre {+ margin: 0; background: var(--code-bg);+ border-top: 1px solid var(--code-border);+ padding: 12px 0; overflow-x: auto; line-height: 1.45;+}+.file-diff code {+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+ font-size: 13px; color: var(--text-secondary);+ display: inline-block; min-width: 100%;+}+.diff-line {+ display: block;+ padding: 0 16px;+ min-height: 1.45em;+}+.diff-add { background: var(--diff-add-bg); color: var(--diff-add-fg); }+.diff-del { background: var(--diff-del-bg); color: var(--diff-del-fg); }+.diff-hunk { color: var(--accent); }+.diff-file-header { color: var(--text-tertiary); }+.diff-meta { color: var(--text-tertiary); }+.diff-context { color: var(--text-secondary); }++.pr-description {+ background: var(--surface-1);+ border: 1px solid var(--border);+ border-left: 3px solid var(--accent-2);+ border-radius: 12px;+ padding: 16px 20px;+ margin: 8px 0 16px;+}+.pr-description .meta {+ color: var(--text-tertiary);+ font-size: 12px;+ margin-bottom: 12px;+}+.pr-description pre {+ margin: 0;+ background: transparent;+ font-family: ui-monospace, "SF Mono", Menlo, monospace;+ font-size: 13px;+ line-height: 1.55;+ color: var(--text-secondary);+ white-space: pre-wrap;+ word-wrap: break-word;+}+.pr-description pre code,+.pr-description pre a { color: var(--text-primary); }++footer {+ margin-top: 64px; padding-top: 24px;+ border-top: 1px solid var(--border-subtle);+ color: var(--text-tertiary); font-size: 13px;+}++@media (max-width: 720px) {+ .card-grid { grid-template-columns: 1fr; }+ .toc ul { column-count: 1; }+ .page { padding: 16px; }+}+@media (prefers-reduced-motion: reduce) {+ * { transition: none !important; }+}+</style>+</head>+<body>+<header class="top-bar">+ <div class="top-stripe"></div>+ <div class="top-content">+ <span class="repo-title">acme/widgets</span>+ <span class="chip">Commits <strong>3</strong></span>+<span class="chip">Files <strong>6</strong></span>+<span class="chip">Lines <strong>+120 / -34</strong></span>+ </div>+</header>++<div class="page">+ <h1>PR review: acme/widgets #42 <golden></h1>+ <p class="muted">Review of <strong>feature/golden</strong> against <code>main</code> & friends</p>++ <section class="card-grid">+ <div class="card">+ <h3>At a glance</h3>+ <ul><li>Adds a <code>Widget</code> registry with lookup by name.</li>+<li>Removes the legacy <code>old_registry.py</code> module.</li></ul>+ </div>+ <div class="card">+ <h3>Important changes</h3>+ <ul><li><span class="tag">key</span><a href="#change-0">Registry class</a></li><li><span class="tag">key</span><a href="#change-1">Legacy module removed</a></li><li><span class="tag">key</span><a href="#change-2">Config key renamed</a></li><li><span class="tag">key</span><a href="#change-3">Docs touch-up</a></li></ul>+ </div>+ <div class="card">+ <h3>Verdict</h3>+ <p><span class="verdict-pill verdict-warning">Needs changes</span></p>+ <p class="muted">One <em>major</em> finding was skipped.</p>+ </div>+ <div class="card">+ <h3>Review findings</h3>+ <p>6 raised · 4 fixed · 2 skipped</p>+ <p><a href="#findings">Jump to findings →</a></p>+ </div>+ </section>++ <nav class="toc"><ul><li><a href="#pr-description">Author's description</a></li>+<li><a href="#commits">Commits</a></li>+<li><a href="#explanation">Three-level explanation</a></li>+<li><a href="#important-changes">Important changes (detailed)</a></li>+<li><a href="#decisions">Key decisions</a></li>+<li><a href="#findings">Review findings</a></li>+<li><a href="#unresolved-comments">Unresolved comments</a></li>+<li><a href="#diffs">Per-file diffs</a></li>+<li><a href="#double-check">Things to double-check</a></li></ul></nav>++ <section id="pr-description">+ <h2>Author's PR description</h2>+ <p class="muted">Shown verbatim — the markdown the author wrote, unmodified.</p>+ <div class="pr-description">+ <div class="meta"><a href="https://github.com/acme/widgets/pull/42">octocat</a> · 2026-09-01T10:00:00Z</div>+ <pre>## Why++The old registry was *slow* & untested.++- adds `Registry`+- removes `old_registry.py`++<script>alert(1)</script></pre>+ </div>+ </section>+ <section id="commits">+ <h2>Commits</h2>+ <ul class="commit-list"><li><code class="commit-sha">a1b2c3d</code> <span class="commit-subj">[feat]: Add Widget registry</span> <span class="commit-meta">— octocat · 2026-09-01</span></li><li><code class="commit-sha">e4f5a6b</code> <span class="commit-subj">[chore]: Remove old registry</span> <span class="commit-meta">— octocat · 2026-09-02 · 2 files</span></li><li><code class="commit-sha">c7d8e9f</code> <span class="commit-subj">[test]: Cover lookup & duplicate <names></span> <span class="commit-meta">— hubot · 2026-09-03</span></li></ul>+ </section>+ <section id="explanation">+ <h2>Three-level explanation</h2>+ <div class="tabs">+ <input type="radio" name="tabs" id="tab-beginner" checked>+<input type="radio" name="tabs" id="tab-intermediate">+<input type="radio" name="tabs" id="tab-expert">+ <div class="tab-labels"><label for="tab-beginner">Beginner</label>+<label for="tab-intermediate">Intermediate</label>+<label for="tab-expert">Expert</label></div>+ <div class="tab-panels"><div id="panel-beginner" class="tab-panel"><p>A registry is a lookup table. This change adds one for widgets.</p></div>+<div id="panel-intermediate" class="tab-panel"><p>The registry replaces module-level globals with an explicit <code>Registry</code> object.</p></div>+<div id="panel-expert" class="tab-panel"><p>Registration is idempotent; duplicate names raise <code>KeyError</code> at import time.</p></div></div>+ </div>+ </section>+ <section id="important-changes">+ <h2>Important changes — detailed</h2>+ <div class="change-card" id="change-0">+ <h3>Registry class</h3>+ <p class="muted">src/widgets/registry.py</p>+ <p><strong>Why it matters.</strong> Central lookup replaces scattered globals.</p>+ <p><strong>What to look at.</strong> <a href="#file-c08d64c2c7">The register() and lookup() methods</a></p>+ <div class="callout callout-takeaway"><strong>Takeaway.</strong> Registration order no longer matters.</div>+ <div class="callout callout-rationale"><strong>Rationale.</strong> The author says globals made testing hard.</div>+</div><div class="change-card" id="change-1">+ <h3>Legacy module removed</h3>+ <p class="muted">src/widgets/old_registry.py</p>+ <p><strong>Why it matters.</strong> Dead code after the registry landed.</p>+ <p><strong>What to look at.</strong> <a href="#file-9d982a5230">Confirm nothing else imports it</a></p>++ <div class="callout callout-rationale"><strong>Rationale.</strong> Likely to reduce maintenance surface. <span class="muted">(inferred — not stated by the author)</span></div>+</div><div class="change-card" id="change-2">+ <h3>Config key renamed</h3>+ <p class="muted">config/$settings.toml</p>+ <p><strong>Why it matters.</strong> The key name changed from widget_dir to widgets.dir.</p>+ <p><strong>What to look at.</strong> <a href="#file-d29b91093c">The [widgets] table</a></p>++ <div class="callout callout-warning"><strong>Open question.</strong> Rationale not stated by the author and not inferable from the diff.</div>+</div><div class="change-card" id="change-3">+ <h3>Docs touch-up</h3>+ <p class="muted"></p>+ <p><strong>Why it matters.</strong> README mentions the new registry.</p>++++</div>+ </section>+ <section id="decisions">+ <h2>Key decisions</h2>+ <div class="callout callout-rationale"><strong>Registry is a plain class, not a singleton.</strong> Instances can be created per test.</div><div class="callout callout-rationale"><strong>Duplicate names raise.</strong> Silently overwriting hid bugs before. <span class="muted">(inferred — not stated by the author.)</span></div>+ </section>+ <section id="findings">+ <h2>Review findings</h2>+ <table class="findings">+ <thead><tr><th>Severity</th><th>Area</th><th>Finding</th><th>Resolution</th></tr></thead>+ <tbody><tr><td><span class="pill pill-error">blocking</span></td><td>Correctness</td><td>lookup() returns None on miss but callers index the result</td><td>Raise KeyError instead</td></tr><tr><td><span class="pill pill-error">major</span></td><td>Tests</td><td>No test for duplicate registration</td><td>Left for the author</td></tr><tr><td><span class="pill pill-warning">minor</span></td><td>Style</td><td>Unused import <os></td><td>Removed</td></tr><tr><td><span class="pill pill-tertiary">nit</span></td><td>Naming</td><td>reg vs registry</td><td>Renamed to registry</td></tr><tr><td><span class="pill pill-tertiary">info</span></td><td>Docs</td><td>README example uses the old API</td><td>Updated</td></tr><tr><td><span class="pill pill-tertiary">unknown-level</span></td><td>Other</td><td>Severity outside the known set</td><td>Rendered as tertiary</td></tr></tbody>+ </table>+ </section>+ <section id="unresolved-comments">+ <h2>Unresolved comments</h2>+ <p class="muted">Open review threads and PR-level comments still awaiting a response.</p>+ <div class="unresolved-comment"><div class="unresolved-header"><span class="pill pill-warning">code</span> <strong>reviewer1</strong> · <code>src/widgets/registry.py:42</code> · <span class="muted">2026-09-02T09:00:00Z</span> · <a href="https://github.com/acme/widgets/pull/42#discussion_r1" target="_blank" rel="noopener">view on GitHub</a></div><div class="unresolved-body">Should this be a `dict` or an `OrderedDict`?<br>Order might matter for <listing>.</div><details class="replies"><summary>2 earlier replies</summary><div class="reply"><div class="reply-header"><strong>octocat</strong> · <span class="muted">2026-09-02T09:30:00Z</span></div><div class="reply-body">dict preserves insertion order since 3.7</div></div><div class="reply"><div class="reply-header"><strong>reviewer1</strong></div><div class="reply-body">Right, but document it?</div></div></details></div><div class="unresolved-comment"><div class="unresolved-header"><span class="pill pill-warning">review</span> <strong>reviewer2</strong> · <a href="https://github.com/acme/widgets/pull/42#pullrequestreview-1" target="_blank" rel="noopener">view on GitHub</a></div><div class="unresolved-body">Please add a changelog entry.</div><details class="replies"><summary>1 earlier reply</summary><div class="reply"><div class="reply-header"><strong>octocat</strong></div><div class="reply-body">Will do.</div></div></details></div><div class="unresolved-comment"><div class="unresolved-header"><span class="pill pill-warning">discussion</span> <strong>(unknown)</strong> · <code>README.md</code></div><div class="unresolved-body">Typo in the example.</div></div><div class="unresolved-comment"><div class="unresolved-header"><span class="pill pill-warning">discussion</span> <strong>bot</strong></div><div class="unresolved-body">Automated note without a known type.</div></div>+ </section>+ <section id="diffs">+ <h2>Per-file diffs</h2>+ <p class="muted">Click to expand.</p>+ <details id="file-c08d64c2c7" class="file-diff">+ <summary><span class="file-path">src/widgets/registry.py</span> <span class="badge badge-added">Added</span> <span class="line-stat">+48 / -0</span></summary>+ <pre><code class="diff-block"><span class="diff-line diff-context">diff --git a/src/widgets/registry.py b/src/widgets/registry.py</span><span class="diff-line diff-context">new file mode 100644</span><span class="diff-line diff-file-header">--- /dev/null</span><span class="diff-line diff-file-header">+++ b/src/widgets/registry.py</span><span class="diff-line diff-hunk">@@ -0,0 +1,6 @@</span><span class="diff-line diff-add">+class Registry:</span><span class="diff-line diff-add">+ def __init__(self) -> None:</span><span class="diff-line diff-add">+ self._items: dict[str, object] = {}</span><span class="diff-line diff-add">+</span><span class="diff-line diff-add">+ def register(self, name: str, item: object) -> None:</span><span class="diff-line diff-add">+ self._items[name] = item # <-- "quoted" & escaped</span></code></pre>+</details><details id="file-9d982a5230" class="file-diff">+ <summary><span class="file-path">src/widgets/old_registry.py</span> <span class="badge badge-deleted">Deleted</span> <span class="line-stat">+0 / -12</span></summary>+ <pre><code class="diff-block"><span class="diff-line diff-file-header">--- a/src/widgets/old_registry.py</span><span class="diff-line diff-file-header">+++ /dev/null</span><span class="diff-line diff-hunk">@@ -1,3 +0,0 @@</span><span class="diff-line diff-del">-WIDGETS = {}</span><span class="diff-line diff-del">-def register(name, item):</span><span class="diff-line diff-del">- WIDGETS[name] = item</span><span class="diff-line diff-meta">\ No newline at end of file</span></code></pre>+</details><details id="file-d29b91093c" class="file-diff">+ <summary><span class="file-path">config/$settings.toml</span> <span class="badge badge-modified">Modified</span> <span class="line-stat">+1 / -1</span></summary>+ <pre><code class="diff-block"><span class="diff-line diff-context">diff --git a/config/$settings.toml b/config/$settings.toml</span><span class="diff-line diff-file-header">--- a/config/$settings.toml</span><span class="diff-line diff-file-header">+++ b/config/$settings.toml</span><span class="diff-line diff-hunk">@@ -1,3 +1,3 @@</span><span class="diff-line diff-context"> [widgets]</span><span class="diff-line diff-del">-widget_dir = "widgets"</span><span class="diff-line diff-add">+dir = "widgets"</span><span class="diff-line diff-context"> context <tail> & more</span></code></pre>+</details><details id="file-f5affc48ee" class="file-diff">+ <summary><span class="file-path">src/widgets/lookup.py</span> <span class="badge badge-renamed">Renamed</span> <span class="line-stat">+2 / -2</span></summary>+ <pre><code class="diff-block"><span class="diff-line diff-context">(diff fragment 'golden-missing.diff' missing)</span></code></pre>+</details><details id="file-667fb7df63" class="file-diff">+ <summary><span class="file-path">docs/widgets.md</span> <span class="badge badge-modified">Modified</span> <span class="line-stat">+5 / -1</span></summary>+ <pre><code class="diff-block"><span class="diff-line diff-context">(no diff provided)</span></code></pre>+</details><details id="file-149272c650" class="file-diff">+ <summary><span class="file-path">assets/logo.png</span> <span class="badge badge-binaryfile">Binary file</span> <span class="line-stat"></span></summary>+ <pre><code class="diff-block"><span class="diff-line diff-context">Binary files a/assets/logo.png and b/assets/logo.png differ</span></code></pre>+</details>+ </section>+ <section id="double-check">+ <h2>Things to double-check</h2>+ <div class="callout callout-warning"><strong>Import cycle.</strong> <code>registry.py</code> imports <code>widget.py</code> which imports the registry.</div><div class="callout callout-warning"><strong>Config migration.</strong> Existing installs still carry <code>widget_dir</code>.</div>+ </section>++ <footer>+ Generated 2026-09-04 12:14:39 UTC · repo <code>/home/dev/widgets</code> · regenerate with <code>/pre-push-review</code>.+ </footer>+</div>+</body>+</html>
diff --git a/scripts/tests/fixtures/golden.json b/scripts/tests/fixtures/golden.jsonnew file mode 100644index 0000000..3dc02b2--- /dev/null+++ b/scripts/tests/fixtures/golden.json@@ -0,0 +1,156 @@+{+ "repo": {"name": "acme/widgets", "path": "/home/dev/widgets", "branch": "feature/golden", "remote": "origin"},+ "title": "PR review: acme/widgets #42 <golden>",+ "subtitle": "Review of <strong>feature/golden</strong> against <code>main</code> & friends",+ "metrics": [+ {"label": "Commits", "value": 3},+ {"label": "Files", "value": 6},+ {"label": "Lines", "value": "+120 / -34"}+ ],+ "verdict": {"label": "Needs changes", "tone": "warning", "detail": "One <em>major</em> finding was skipped."},+ "at_a_glance": [+ "Adds a <code>Widget</code> registry with lookup by name.",+ "Removes the legacy <code>old_registry.py</code> module."+ ],+ "pr_description": {+ "author": "octocat",+ "url": "https://github.com/acme/widgets/pull/42",+ "created_at": "2026-09-01T10:00:00Z",+ "body": "## Why\n\nThe old registry was *slow* & untested.\n\n- adds `Registry`\n- removes `old_registry.py`\n\n<script>alert(1)</script>"+ },+ "explanation": {+ "beginner": "<p>A registry is a lookup table. This change adds one for widgets.</p>",+ "intermediate": "<p>The registry replaces module-level globals with an explicit <code>Registry</code> object.</p>",+ "expert": "<p>Registration is idempotent; duplicate names raise <code>KeyError</code> at import time.</p>"+ },+ "commits": [+ {"sha": "a1b2c3d", "subject": "[feat]: Add Widget registry", "author": "octocat", "date": "2026-09-01"},+ {"sha": "e4f5a6b", "subject": "[chore]: Remove old registry", "author": "octocat", "date": "2026-09-02", "meta": "octocat · 2026-09-02 · 2 files"},+ {"sha": "c7d8e9f", "subject": "[test]: Cover lookup & duplicate <names>", "author": "hubot", "date": "2026-09-03"}+ ],+ "important_changes": [+ {+ "title": "Registry class",+ "file": "src/widgets/registry.py",+ "why": "Central lookup replaces scattered globals.",+ "what": "The register() and lookup() methods",+ "takeaway": "Registration order no longer matters.",+ "rationale": "The author says globals made testing hard."+ },+ {+ "title": "Legacy module removed",+ "file": "src/widgets/old_registry.py",+ "why": "Dead code after the registry landed.",+ "what": "Confirm nothing else imports it",+ "rationale": "Likely to reduce maintenance surface.",+ "rationale_inferred": true+ },+ {+ "title": "Config key renamed",+ "file": "config/$settings.toml",+ "why": "The key name changed from widget_dir to widgets.dir.",+ "what": "The [widgets] table",+ "rationale_unknown": true+ },+ {+ "title": "Docs touch-up",+ "why": "README mentions the new registry.",+ "what": ""+ }+ ],+ "decisions": [+ {"title": "Registry is a plain class, not a singleton.", "body": "Instances can be created per test."},+ {"title": "Duplicate names raise.", "body": "Silently overwriting hid bugs before.", "inferred": true}+ ],+ "findings": [+ {"severity": "blocking", "area": "Correctness", "finding": "lookup() returns None on miss but callers index the result", "resolution": "Raise KeyError instead", "status": "fixed"},+ {"severity": "major", "area": "Tests", "finding": "No test for duplicate registration", "resolution": "Left for the author", "status": "skipped"},+ {"severity": "minor", "area": "Style", "finding": "Unused import <os>", "resolution": "Removed", "status": "fixed"},+ {"severity": "nit", "area": "Naming", "finding": "reg vs registry", "resolution": "Renamed to registry"},+ {"severity": "info", "area": "Docs", "finding": "README example uses the old API", "resolution": "Updated", "status": "fixed"},+ {"severity": "unknown-level", "area": "Other", "finding": "Severity outside the known set", "resolution": "Rendered as tertiary", "status": "skipped"}+ ],+ "unresolved_comments": [+ {+ "author": "reviewer1",+ "type": "code",+ "path": "src/widgets/registry.py",+ "line": 42,+ "body": "Should this be a `dict` or an `OrderedDict`?\nOrder might matter for <listing>.",+ "url": "https://github.com/acme/widgets/pull/42#discussion_r1",+ "created_at": "2026-09-02T09:00:00Z",+ "replies": [+ {"author": "octocat", "body": "dict preserves insertion order since 3.7", "created_at": "2026-09-02T09:30:00Z"},+ {"author": "reviewer1", "body": "Right, but document it?"}+ ]+ },+ {+ "author": "reviewer2",+ "type": "review",+ "body": "Please add a changelog entry.",+ "url": "https://github.com/acme/widgets/pull/42#pullrequestreview-1",+ "replies": [+ {"author": "octocat", "body": "Will do."}+ ]+ },+ {+ "author": "",+ "type": "discussion",+ "path": "README.md",+ "body": "Typo in the example."+ },+ {+ "author": "bot",+ "type": "",+ "body": "Automated note without a known type."+ }+ ],+ "double_check": [+ {"title": "Import cycle.", "body": "<code>registry.py</code> imports <code>widget.py</code> which imports the registry."},+ {"title": "Config migration.", "body": "Existing installs still carry <code>widget_dir</code>."}+ ],+ "files": [+ {+ "path": "src/widgets/registry.py",+ "badge": "Added",+ "stat": "+48 / -0",+ "diff": "diff --git a/src/widgets/registry.py b/src/widgets/registry.py\nnew file mode 100644\n--- /dev/null\n+++ b/src/widgets/registry.py\n@@ -0,0 +1,6 @@\n+class Registry:\n+ def __init__(self) -> None:\n+ self._items: dict[str, object] = {}\n+\n+ def register(self, name: str, item: object) -> None:\n+ self._items[name] = item # <-- \"quoted\" & escaped\n"+ },+ {+ "path": "src/widgets/old_registry.py",+ "badge": "Deleted",+ "stat": "+0 / -12",+ "diff": "--- a/src/widgets/old_registry.py\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-WIDGETS = {}\n-def register(name, item):\n- WIDGETS[name] = item\n\\ No newline at end of file\n"+ },+ {+ "path": "config/$settings.toml",+ "badge": "Modified",+ "stat": "+1 / -1",+ "diff_file": "golden-settings.diff"+ },+ {+ "path": "src/widgets/lookup.py",+ "badge": "Renamed",+ "stat": "+2 / -2",+ "diff_file": "golden-missing.diff"+ },+ {+ "path": "docs/widgets.md",+ "badge": "Modified",+ "stat": "+5 / -1"+ },+ {+ "path": "assets/logo.png",+ "badge": "Binary file",+ "stat": "",+ "diff": "Binary files a/assets/logo.png and b/assets/logo.png differ\n"+ }+ ],+ "publish_metadata": {+ "title": "PR review: acme/widgets #42",+ "repoUrl": "https://github.com/acme/widgets",+ "pr": 42,+ "severity": "needs-changes",+ "summary": "Registry lands; one major test gap skipped. Contains </script> to exercise escaping."+ }+}
diff --git a/scripts/tests/test_blast_radius.py b/scripts/tests/test_blast_radius.pynew file mode 100644index 0000000..a380c33--- /dev/null+++ b/scripts/tests/test_blast_radius.py@@ -0,0 +1,599 @@+"""Tests for scripts/blast_radius.py against a generated git repository.++One repository is built per test class in a temp directory with Go, Python,+TypeScript, Rust, and Swift files; a base commit, a snapshot commit with every+change kind, a docs-only commit on a side branch, and an untracked file. The+script is driven in-process through ``main`` so ``--remote`` can be served by+a fake ``gh_api`` fed from the same repository.+"""+from __future__ import annotations++import base64+import contextlib+import io+import json+import os+import re+import subprocess+import sys+import tempfile+import unittest+from pathlib import Path++import blast_radius++REPO_ROOT = Path(__file__).resolve().parents[2]+SCRIPT = REPO_ROOT / "scripts" / "blast_radius.py"+ECOSYSTEMS = REPO_ROOT / "scripts" / "ecosystems.json"++BASE_FILES = {+ "go.mod": "module example.com/m\n\ngo 1.21\n",+ "main.go": (+ 'package main\n\nimport (\n\t"fmt"\n\n\t"example.com/m/pkg"\n\t"example.com/m/util"\n)\n\n'+ "func main() { fmt.Println(pkg.A(), util.U()) }\n"+ ),+ "pkg/a.go": 'package pkg\n\nimport "example.com/m/util"\n\nfunc A() string { return util.U() }\n',+ "pkg/b.go": 'package pkg\n\nfunc B() string { return "b" }\n',+ "pkg/gone.go": (+ 'package pkg\n\nimport "example.com/m/util"\n\n// Gone is the legacy entry point.\n'+ 'func Gone() string { return "gone:" + util.U() }\n\n'+ "// GoneTwice repeats Gone.\nfunc GoneTwice() string { return Gone() + Gone() }\n"+ ),+ "pkg/a_test.go": (+ 'package pkg_test\n\nimport (\n\t"testing"\n\n\t"example.com/m/pkg"\n)\n\n'+ "func TestA(t *testing.T) { _ = pkg.A() }\n\nfunc TestOld(t *testing.T) {}\n"+ ),+ "util/u.go": 'package util\n\nfunc U() string { return "u" }\n',+ "util/u_test.go": 'package util\n\nimport "testing"\n\nfunc TestU(t *testing.T) { _ = U() }\n',+ "src/app/__init__.py": "",+ "src/app/core.py": (+ "from .helpers import h\nfrom app.models import M\nfrom app.legacy import L\n\n\n"+ "def core():\n return h(), M, L\n"+ ),+ "src/app/helpers.py": "def h():\n return 1\n",+ "src/app/models.py": "M = 1\n",+ "src/app/legacy.py": "L = 2\n",+ "src/app/report.py": "from app.models import M\n\n\ndef report():\n return M\n",+ # Under src/ so the Swift Tests/ directory does not collide with it on+ # case-insensitive filesystems.+ "src/tests/test_core.py": "from app.core import core\n\n\ndef test_one():\n assert core()\n",+ "web/index.ts": 'export * from "./lib";\n',+ "web/lib/index.ts": 'import { x } from "./x.js";\nexport { x };\n',+ "web/lib/x.ts": "export const x = 1;\n",+ "web/app.ts": 'import { x } from "./lib";\nconsole.log(x);\n',+ "web/lib/x.test.ts": 'import { x } from "./x";\n\nit("does x", () => { expect(x).toBe(1); });\n',+ "src/lib.rs": "mod foo;\nmod bar;\n\nuse crate::bar::baz::Q;\n\npub fn lib() -> i32 { foo::f() + Q }\n",+ "src/foo.rs": "pub fn f() -> i32 { 1 }\n",+ "src/bar/mod.rs": "pub mod baz;\n",+ "src/bar/baz.rs": "pub const Q: i32 = 2;\n",+ "Sources/App/main.swift": "import Foundation\nimport Core\n\nprint(core())\n",+ "Sources/Core/core.swift": 'public func core() -> String { "core" }\n',+ "Tests/CoreTests/CoreTests.swift": (+ "import XCTest\n@testable import Core\n\nfinal class CoreTests: XCTestCase {\n"+ " func testCore() { XCTAssertEqual(core(), \"core\") }\n}\n"+ ),+ "notes.txt": "notes\n",+ "README.md": "# readme\n",+}++BIG_LINE = "// " + "x" * 97 + "\n" # 101 bytes+BIG_FILE = "package pkg\n" + BIG_LINE * 10500 # > 1 MiB+++def git(repo: Path, *args: str) -> str:+ result = subprocess.run(["git", "-C", str(repo), *args], check=True,+ capture_output=True, text=True)+ return result.stdout+++def write(repo: Path, rel: str, content: str) -> None:+ path = repo / rel+ path.parent.mkdir(parents=True, exist_ok=True)+ path.write_text(content, encoding="utf-8")+++def build_repo(repo: Path) -> dict:+ git(repo, "init", "-q", "-b", "main")+ git(repo, "config", "user.email", "t@example.com")+ git(repo, "config", "user.name", "T")+ git(repo, "config", "core.symlinks", "true")+ for rel, content in BASE_FILES.items():+ write(repo, rel, content)+ os.symlink("pkg/a.go", repo / "link_to_a.go")+ git(repo, "add", "-A")+ git(repo, "commit", "-q", "-m", "base")+ base = git(repo, "rev-parse", "HEAD").strip()++ write(repo, "pkg/a.go", BASE_FILES["pkg/a.go"] + "\n// changed\n")+ write(repo, "pkg/c.go", (+ 'package pkg\n\nimport (\n\t"strings"\n\n\t"example.com/m/util"\n)\n\n'+ "// C upper-cases the util value.\nfunc C() string { return strings.ToUpper(util.U()) }\n"+ "\nfunc c2() int { return 2 }\n"))+ (repo / "pkg/gone.go").unlink()+ write(repo, "util/v.go", BASE_FILES["util/u.go"]) # copy of u.go ...+ write(repo, "util/u.go", BASE_FILES["util/u.go"] + "// modified\n") # ... whose source changed+ git(repo, "mv", "src/app/models.py", "src/app/entities.py")+ write(repo, "src/app/core.py",+ "from .helpers import h\nfrom app.entities import M\n\n\ndef core():\n return h(), M\n")+ (repo / "src/app/legacy.py").unlink()+ (repo / "notes.txt").unlink()+ os.symlink("README.md", repo / "notes.txt") # type change+ write(repo, "pkg/big.go", BIG_FILE)+ write(repo, "web/lib/x.ts", "export const x = 2;\n")+ write(repo, "src/foo.rs", "pub fn f() -> i32 { 11 }\n")+ write(repo, "src/bar/baz.rs", "pub const Q: i32 = 22;\n")+ write(repo, "Sources/Core/core.swift", 'public func core() -> String { "core2" }\n')+ write(repo, "Tests/CoreTests/CoreTests.swift",+ BASE_FILES["Tests/CoreTests/CoreTests.swift"].replace(+ "}\n}\n", "}\n func testNew() {}\n}\n"))+ write(repo, "pkg/a_test.go", BASE_FILES["pkg/a_test.go"].replace("TestOld", "TestNew"))+ write(repo, "src/tests/test_core.py",+ BASE_FILES["src/tests/test_core.py"] + "\n\ndef test_two():\n assert True\n")+ write(repo, "spec/foo_spec.rb", 'describe "foo" do\n it "works" do\n end\nend\n')+ git(repo, "add", "-A")+ git(repo, "commit", "-q", "-m", "snapshot")+ snapshot = git(repo, "rev-parse", "HEAD").strip()++ git(repo, "checkout", "-q", "-b", "docs")+ write(repo, "README.md", "# readme\n\nmore\n")+ write(repo, "spec/foo_spec.rb", 'describe "foo" do\n it "works better" do\n end\nend\n')+ git(repo, "add", "-A")+ git(repo, "commit", "-q", "-m", "docs")+ docs = git(repo, "rev-parse", "HEAD").strip()+ git(repo, "checkout", "-q", "main")++ write(repo, "pkg/untracked.go", 'package pkg\n\nimport "example.com/m/util"\n\nfunc N() string { return util.U() }\n')+ return {"base": base, "snapshot": snapshot, "docs": docs}+++def run(args: list, stderr: io.StringIO = None) -> tuple:+ """Run main() in-process and return (exit code, diagram, diff_tests, stderr)."""+ buf = stderr if stderr is not None else io.StringIO()+ with tempfile.TemporaryDirectory() as out:+ with contextlib.redirect_stderr(buf), contextlib.redirect_stdout(io.StringIO()):+ try:+ code = blast_radius.main(args + ["--out", out])+ except SystemExit as exc: # argparse errors+ code = exc.code+ diagram = diff_tests = None+ if (Path(out) / "diagram.json").exists():+ diagram = json.loads((Path(out) / "diagram.json").read_text(encoding="utf-8"))+ if (Path(out) / "diff-tests.json").exists():+ diff_tests = json.loads((Path(out) / "diff-tests.json").read_text(encoding="utf-8"))+ return code, diagram, diff_tests, buf.getvalue()+++def node_map(diagram: dict) -> dict:+ return {n["path"]: n for n in diagram["nodes"]}+++def edge_map(diagram: dict) -> dict:+ return {(e["from"], e["to"]): e for e in diagram["edges"]}+++EXPECTED_EDGES = {+ # (from, to): (method, granularity, tree)+ ("main.go", "pkg/a.go"): ("expansion", "package", "snapshot"),+ ("main.go", "pkg/c.go"): ("expansion", "package", "snapshot"),+ ("main.go", "pkg/gone.go"): ("expansion", "package", "base"),+ ("main.go", "util/u.go"): ("expansion", "package", "snapshot"),+ ("main.go", "util/v.go"): ("expansion", "package", "snapshot"),+ ("pkg/a.go", "util/u.go"): ("expansion", "package", "snapshot"),+ ("pkg/c.go", "util/u.go"): ("expansion", "package", "snapshot"),+ ("pkg/c.go", "util/v.go"): ("expansion", "package", "snapshot"),+ ("pkg/gone.go", "util/u.go"): ("expansion", "package", "base"),+ ("pkg/a_test.go", "pkg/a.go"): ("expansion", "package", "snapshot"),+ ("pkg/a_test.go", "pkg/gone.go"): ("expansion", "package", "base"),+ ("src/app/core.py", "src/app/helpers.py"): ("import", "file", "snapshot"),+ ("src/app/core.py", "src/app/entities.py"): ("import", "file", "snapshot"),+ ("src/app/core.py", "src/app/legacy.py"): ("import", "file", "base"),+ ("src/app/report.py", "src/app/entities.py"): ("import", "file", "base"),+ ("src/tests/test_core.py", "src/app/core.py"): ("import", "file", "snapshot"),+ # Expansion reaches every file in the imported package, test files included;+ # the renderer, not the script, drops test files from the side columns.+ ("pkg/a_test.go", "pkg/b.go"): ("expansion", "package", "snapshot"),+ ("pkg/a.go", "util/u_test.go"): ("expansion", "package", "snapshot"),+ ("web/lib/index.ts", "web/lib/x.ts"): ("import", "file", "snapshot"),+ ("web/lib/x.test.ts", "web/lib/x.ts"): ("import", "file", "snapshot"),+ ("src/lib.rs", "src/foo.rs"): ("import", "file", "snapshot"),+ ("src/lib.rs", "src/bar/baz.rs"): ("import", "file", "snapshot"),+ ("src/bar/mod.rs", "src/bar/baz.rs"): ("import", "file", "snapshot"),+ ("Sources/App/main.swift", "Sources/Core/core.swift"): ("expansion", "package", "snapshot"),+ ("Tests/CoreTests/CoreTests.swift", "Sources/Core/core.swift"): ("expansion", "package", "snapshot"),+}++EXPECTED_NODES = {+ # path: (status, group, is_test, old_path)+ "pkg/a.go": ("modified", "example.com/m/pkg", False, None),+ "pkg/c.go": ("added", "example.com/m/pkg", False, None),+ "pkg/gone.go": ("deleted", "example.com/m/pkg", False, None),+ "pkg/big.go": ("added", "example.com/m/pkg", False, None),+ "pkg/a_test.go": ("modified", "example.com/m/pkg", True, None),+ "util/u.go": ("modified", "example.com/m/util", False, None),+ "util/v.go": ("added", "example.com/m/util", False, None),+ "main.go": ("unchanged", "example.com/m", False, None),+ "src/app/core.py": ("modified", "src/app", False, None),+ "src/app/entities.py": ("renamed", "src/app", False, "src/app/models.py"),+ "src/app/legacy.py": ("deleted", "src/app", False, None),+ "src/app/helpers.py": ("unchanged", "src/app", False, None),+ "src/app/report.py": ("unchanged", "src/app", False, None),+ "src/tests/test_core.py": ("modified", "src/tests", True, None),+ "pkg/b.go": ("unchanged", "example.com/m/pkg", False, None),+ "util/u_test.go": ("unchanged", "example.com/m/util", True, None),+ "web/lib/x.ts": ("modified", "web/lib", False, None),+ "web/lib/index.ts": ("unchanged", "web/lib", False, None),+ "web/lib/x.test.ts": ("unchanged", "web/lib", True, None),+ "src/foo.rs": ("modified", "src", False, None),+ "src/bar/baz.rs": ("modified", "src/bar", False, None),+ "src/lib.rs": ("unchanged", "src", False, None),+ "src/bar/mod.rs": ("unchanged", "src/bar", False, None),+ "Sources/Core/core.swift": ("modified", "Core", False, None),+ "Sources/App/main.swift": ("unchanged", "App", False, None),+ "Tests/CoreTests/CoreTests.swift": ("modified", "Tests/CoreTests", True, None),+ "notes.txt": ("modified", ".", False, None),+ "spec/foo_spec.rb": ("added", "spec", True, None),+}+++class RepoTestCase(unittest.TestCase):+ @classmethod+ def setUpClass(cls) -> None:+ cls._tmp = tempfile.TemporaryDirectory()+ cls.repo = Path(cls._tmp.name) / "repo"+ cls.repo.mkdir()+ cls.shas = build_repo(cls.repo)++ @classmethod+ def tearDownClass(cls) -> None:+ cls._tmp.cleanup()++ def assert_graph(self, diagram: dict, snapshot_tree: str) -> None:+ self.assertEqual(diagram["snapshot_tree"], snapshot_tree)+ self.assertEqual(diagram["base_tree"], self.shas["base"])+ nodes = node_map(diagram)+ for path, (status, group, is_test, old_path) in EXPECTED_NODES.items():+ with self.subTest(node=path):+ self.assertIn(path, nodes)+ n = nodes[path]+ self.assertEqual(n["status"], status)+ self.assertEqual(n["group"], group)+ self.assertEqual(n["is_test"], is_test)+ self.assertEqual(n["old_path"], old_path)+ for absent in ("link_to_a.go", "web/index.ts", "web/app.ts", "go.mod", "README.md",+ "fmt", "testing", "strings", "src/app/models.py", "src/app/__init__.py"):+ self.assertNotIn(absent, nodes, absent)+ edges = edge_map(diagram)+ for key, (method, granularity, tree) in EXPECTED_EDGES.items():+ with self.subTest(edge=key):+ self.assertIn(key, edges)+ e = edges[key]+ self.assertEqual(e["method"], method)+ self.assertEqual(e["granularity"], granularity)+ self.assertEqual(e["tree"], tree)+ for (a, b) in edges:+ self.assertIn(a, nodes, a)+ self.assertIn(b, nodes, b)+ self.assertTrue(nodes[a]["status"] != "unchanged" or nodes[b]["status"] != "unchanged")+ self.assertFalse(any(a == "pkg/big.go" for (a, _) in edges))+ self.assertEqual(diagram["column_status"], {"dependents": "complete", "dependencies": "complete"})+ self.assertIn({"path": "pkg/big.go", "reason": "blob over 1 MB"}, diagram["skipped"])++ def assert_diff_tests(self, diff_tests: dict) -> None:+ self.assertEqual(diff_tests["added"], ["TestNew", "testNew", "test_two"])+ self.assertEqual(diff_tests["removed"], ["TestOld"])+ self.assertEqual(diff_tests["unpatterned_files"], ["spec/foo_spec.rb"])+++class ShaSnapshotTest(RepoTestCase):+ def test_graph_and_diff_tests(self) -> None:+ code, diagram, diff_tests, err = run(+ ["--repo", str(self.repo), "--snapshot", self.shas["snapshot"], "--base", self.shas["base"]])+ self.assertEqual(code, 0, err)+ self.assert_graph(diagram, self.shas["snapshot"])+ self.assertNotIn("pkg/untracked.go", node_map(diagram))+ self.assert_diff_tests(diff_tests)++ def test_output_is_deterministic(self) -> None:+ args = ["--repo", str(self.repo), "--snapshot", self.shas["snapshot"], "--base", self.shas["base"]]+ _, a, ta, _ = run(args)+ _, b, tb, _ = run(args)+ self.assertEqual(a, b)+ self.assertEqual(ta, tb)++ def test_failed_columns_without_import_patterns(self) -> None:+ code, diagram, diff_tests, err = run(+ ["--repo", str(self.repo), "--snapshot", self.shas["docs"], "--base", self.shas["snapshot"]])+ self.assertEqual(code, 0, err)+ self.assertEqual(diagram["column_status"], {+ "dependents": "failed: no import patterns for .md, .rb",+ "dependencies": "failed: no import patterns for .md, .rb",+ })+ self.assertEqual(diagram["edges"], [])+ self.assertEqual(sorted(node_map(diagram)), ["README.md", "spec/foo_spec.rb"])+ self.assertEqual(diff_tests, {"added": [], "removed": [], "unpatterned_files": ["spec/foo_spec.rb"]})++ def test_script_runs_from_the_command_line(self) -> None:+ with tempfile.TemporaryDirectory() as out:+ result = subprocess.run(+ [sys.executable, str(SCRIPT), "--repo", str(self.repo),+ "--snapshot", self.shas["snapshot"], "--base", self.shas["base"], "--out", out],+ capture_output=True, text=True,+ )+ self.assertEqual(result.returncode, 0, result.stderr)+ self.assertTrue((Path(out) / "diagram.json").exists())+ self.assertTrue((Path(out) / "diff-tests.json").exists())+ self.assertIn("diagram.json", result.stdout)++ def test_bad_base_exits_non_zero(self) -> None:+ code, diagram, _, err = run(+ ["--repo", str(self.repo), "--snapshot", self.shas["snapshot"], "--base", "0" * 40])+ self.assertNotEqual(code, 0)+ self.assertIsNone(diagram)+ self.assertIn("error", err)+++class WorkingTreeSnapshotTest(RepoTestCase):+ def test_reads_disk_and_counts_untracked_as_added(self) -> None:+ code, diagram, diff_tests, err = run(+ ["--repo", str(self.repo), "--snapshot", "working-tree", "--base", self.shas["base"]])+ self.assertEqual(code, 0, err)+ self.assert_graph(diagram, "working-tree")+ nodes = node_map(diagram)+ self.assertEqual(nodes["pkg/untracked.go"],+ {"path": "pkg/untracked.go", "status": "added", "group": "example.com/m/pkg",+ "is_test": False, "old_path": None})+ edges = edge_map(diagram)+ self.assertEqual(edges[("pkg/untracked.go", "util/u.go")]["tree"], "snapshot")+ self.assertEqual(edges[("main.go", "pkg/untracked.go")]["method"], "expansion")+ self.assert_diff_tests(diff_tests)++ def test_untracked_test_file_contributes_diff_tests(self) -> None:+ write(self.repo, "pkg/extra_test.go",+ 'package pkg\n\nimport "testing"\n\nfunc TestExtra(t *testing.T) {}\n')+ try:+ code, diagram, diff_tests, err = run(+ ["--repo", str(self.repo), "--snapshot", "working-tree", "--base", self.shas["base"]])+ self.assertEqual(code, 0, err)+ self.assertIn("TestExtra", diff_tests["added"])+ self.assertTrue(node_map(diagram)["pkg/extra_test.go"]["is_test"])+ finally:+ (self.repo / "pkg/extra_test.go").unlink()+++GO_LIST_STUB = '''\+import json, sys+repo = sys.argv[1]+pkgs = [+ {"ImportPath": "example.com/m", "Dir": repo, "GoFiles": ["main.go"],+ "Imports": ["fmt", "example.com/m/pkg", "example.com/m/util"]},+ {"ImportPath": "example.com/m/pkg", "Dir": repo + "/pkg", "GoFiles": ["a.go", "b.go", "c.go"],+ "TestGoFiles": [], "XTestGoFiles": ["a_test.go"], "XTestImports": ["testing", "example.com/m/pkg"],+ "Imports": ["example.com/m/util"]},+ {"ImportPath": "example.com/m/util", "Dir": repo + "/util", "GoFiles": ["u.go", "v.go"],+ "Imports": []},+]+for p in pkgs:+ print(json.dumps(p, indent=1))+'''+++class ToolsTest(RepoTestCase):+ def ecosystems_with_tool(self, deps: str) -> Path:+ data = json.loads(ECOSYSTEMS.read_text(encoding="utf-8"))+ data["go"]["tool"] = {"name": "go list", "deps": deps, "format": "go-list-json",+ "granularity": "package"}+ path = Path(self._tmp.name) / "eco.json"+ path.write_text(json.dumps(data), encoding="utf-8")+ return path++ def test_tool_edges_replace_scanned_pairs(self) -> None:+ stub = Path(self._tmp.name) / "golist.py"+ stub.write_text(GO_LIST_STUB, encoding="utf-8")+ eco = self.ecosystems_with_tool(f'"{sys.executable}" "{stub}" "{self.repo}"')+ code, diagram, _, err = run(+ ["--repo", str(self.repo), "--snapshot", self.shas["snapshot"], "--base", self.shas["base"],+ "--tools", "--ecosystems", str(eco)])+ self.assertEqual(code, 0, err)+ edges = edge_map(diagram)+ for key in (("main.go", "pkg/a.go"), ("main.go", "pkg/c.go"), ("pkg/a.go", "util/u.go"),+ ("pkg/a_test.go", "pkg/a.go")):+ self.assertEqual(edges[key]["method"], "tool:go list", key)+ self.assertEqual(edges[key]["granularity"], "package")+ self.assertEqual(edges[key]["tree"], "snapshot")+ # The tool knows nothing about the base tree or other languages.+ self.assertEqual(edges[("main.go", "pkg/gone.go")]["method"], "expansion")+ self.assertEqual(edges[("main.go", "pkg/gone.go")]["tree"], "base")+ self.assertEqual(edges[("src/app/core.py", "src/app/helpers.py")]["method"], "import")+ self.assertEqual(edges[("pkg/a_test.go", "pkg/b.go")]["method"], "tool:go list")++ def test_failing_tool_keeps_scanned_edges_with_a_warning(self) -> None:+ eco = self.ecosystems_with_tool(f'"{sys.executable}" -c "import sys; sys.exit(3)"')+ code, diagram, _, err = run(+ ["--repo", str(self.repo), "--snapshot", self.shas["snapshot"], "--base", self.shas["base"],+ "--tools", "--ecosystems", str(eco)])+ self.assertEqual(code, 0, err)+ self.assertEqual(edge_map(diagram)[("main.go", "pkg/a.go")]["method"], "expansion")+ self.assertIn("warning", err)+ self.assertIn("go list", err)+++class FakeGitHub:+ """Serves compare, trees, and blobs from the local repository."""++ def __init__(self, repo: Path, truncated: bool = False) -> None:+ self.repo = repo+ self.truncated = truncated+ self.blob_calls = 0+ self.tree_calls = 0+ self.compare_calls = 0++ def __call__(self, path: str) -> object:+ m = re.match(r"repos/([^/]+/[^/]+)/(compare|git/trees|git/blobs)/([^?]+)(?:\?(.*))?$", path)+ assert m, path+ kind, rest = m.group(2), m.group(3)+ if kind == "compare":+ self.compare_calls += 1+ base, head = rest.split("...")+ files = []+ raw = git(self.repo, "diff", "--name-status", "-M", "-C", "-z", base, head)+ parts = raw.split("\0")+ i = 0+ names = {"A": "added", "M": "modified", "D": "removed", "R": "renamed",+ "C": "copied", "T": "changed"}+ while i < len(parts) and parts[i]:+ status = parts[i][0]+ if status in "RC":+ old, new = parts[i + 1], parts[i + 2]+ i += 3+ else:+ old, new = None, parts[i + 1]+ i += 2+ entry = {"status": names[status], "filename": new}+ if old:+ entry["previous_filename"] = old+ patch = git(self.repo, "diff", base, head, "-M", "-C", "--", *(p for p in (old, new) if p))+ body = patch.split("\n@@", 1)+ if len(body) == 2:+ entry["patch"] = "@@" + body[1]+ files.append(entry)+ page = int(re.search(r"(?:^|&)page=(\d+)", m.group(4) or "page=1").group(1))+ return {"files": files if page == 1 else [], "merge_base_commit": {"sha": base}}+ if kind == "git/trees":+ self.tree_calls += 1+ entries = []+ for line in git(self.repo, "ls-tree", "-r", "-l", rest).splitlines():+ meta, _, p = line.partition("\t")+ mode, typ, sha, size = meta.split()+ entries.append({"path": p, "mode": mode, "type": typ, "sha": sha,+ "size": int(size) if size != "-" else 0})+ return {"sha": rest, "tree": entries, "truncated": self.truncated}+ self.blob_calls += 1+ raw = subprocess.run(["git", "-C", str(self.repo), "cat-file", "blob", rest],+ check=True, capture_output=True).stdout+ return {"sha": rest, "encoding": "base64", "content": base64.b64encode(raw).decode()}+++class RemoteTest(RepoTestCase):+ def setUp(self) -> None:+ self._gh = blast_radius.gh_api+ self._cap = blast_radius.REMOTE_BLOB_CAP++ def tearDown(self) -> None:+ blast_radius.gh_api = self._gh+ blast_radius.REMOTE_BLOB_CAP = self._cap++ def test_remote_matches_local(self) -> None:+ fake = FakeGitHub(self.repo)+ blast_radius.gh_api = fake+ code, diagram, diff_tests, err = run(+ ["--remote", "acme/widgets", "--snapshot", self.shas["snapshot"], "--base", self.shas["base"]])+ self.assertEqual(code, 0, err)+ self.assert_graph(diagram, self.shas["snapshot"])+ self.assert_diff_tests(diff_tests)+ self.assertEqual(fake.tree_calls, 2)+ self.assertGreater(fake.blob_calls, 0)+ self.assertLessEqual(fake.blob_calls, blast_radius.REMOTE_BLOB_CAP)+ _, local, _, _ = run(+ ["--repo", str(self.repo), "--snapshot", self.shas["snapshot"], "--base", self.shas["base"]])+ self.assertEqual(diagram, local)++ def test_blob_cap_marks_dependents_partial(self) -> None:+ fake = FakeGitHub(self.repo)+ blast_radius.gh_api = fake+ blast_radius.REMOTE_BLOB_CAP = 1+ code, diagram, _, err = run(+ ["--remote", "acme/widgets", "--snapshot", self.shas["snapshot"], "--base", self.shas["base"]])+ self.assertEqual(code, 0, err)+ self.assertEqual(diagram["column_status"]["dependents"], "partial: remote scan cap reached")+ self.assertEqual(diagram["column_status"]["dependencies"], "complete")+ edges = edge_map(diagram)+ # Dependencies come from the changed files' own blobs and are unaffected.+ self.assertIn(("src/app/core.py", "src/app/helpers.py"), edges)+ self.assertIn(("pkg/a.go", "util/u.go"), edges)+ self.assertNotIn(("web/lib/index.ts", "web/lib/x.ts"), edges)++ def test_truncated_tree_fails_both_columns(self) -> None:+ blast_radius.gh_api = FakeGitHub(self.repo, truncated=True)+ code, diagram, _, err = run(+ ["--remote", "acme/widgets", "--snapshot", self.shas["snapshot"], "--base", self.shas["base"]])+ self.assertEqual(code, 0, err)+ self.assertEqual(diagram["column_status"], {+ "dependents": "failed: tree listing truncated",+ "dependencies": "failed: tree listing truncated",+ })+ self.assertEqual(diagram["edges"], [])+ self.assertIn("pkg/a.go", node_map(diagram))++ def test_remote_rejects_working_tree_and_tools(self) -> None:+ blast_radius.gh_api = FakeGitHub(self.repo)+ code, diagram, _, err = run(+ ["--remote", "acme/widgets", "--snapshot", "working-tree", "--base", self.shas["base"]])+ self.assertEqual(code, 2)+ self.assertIsNone(diagram)+ self.assertIn("working-tree", err)+ code, diagram, _, err = run(+ ["--remote", "acme/widgets", "--snapshot", self.shas["snapshot"], "--base", self.shas["base"],+ "--tools"])+ self.assertEqual(code, 2)+ self.assertIsNone(diagram)+ self.assertIn("--tools", err)+++class ParserTest(unittest.TestCase):+ def test_name_status_mapping(self) -> None:+ raw = ("A\0a.go\0M\0b.go\0D\0c.go\0R100\0old.py\0new.py\0C075\0src.go\0copy.go\0"+ "T\0notes.txt\0")+ changed = blast_radius.parse_name_status(raw)+ self.assertEqual(+ [(c.path, c.status, c.old_path) for c in changed],+ [("a.go", "added", None), ("b.go", "modified", None), ("c.go", "deleted", None),+ ("new.py", "renamed", "old.py"), ("copy.go", "added", None),+ ("notes.txt", "modified", None)],+ )++ def test_ls_tree_parsing_skips_symlinks_and_submodules(self) -> None:+ raw = ("100644 blob aaaa 12\tpkg/a.go\0"+ "120000 blob bbbb 8\tlink.go\0"+ "160000 commit cccc -\tvendor/sub\0"+ "100755 blob dddd 2000000\tpkg/big.go\0"+ "100644 blob eeee 3\tdir with\ttab/x.py\0")+ entries = blast_radius.parse_ls_tree(raw)+ self.assertEqual(sorted(entries), ["dir with\ttab/x.py", "pkg/a.go", "pkg/big.go"])+ self.assertEqual(entries["pkg/a.go"].sha, "aaaa")+ self.assertEqual(entries["pkg/big.go"].size, 2000000)++ def test_test_decl_names(self) -> None:+ eco = blast_radius.load_ecosystems(ECOSYSTEMS)+ diff = ("@@ -1,3 +1,4 @@\n+func TestNew(t *testing.T) {}\n-func TestOld(t *testing.T) {}\n"+ " func helper() {}\n+++ b/x_test.go\n")+ added, removed = blast_radius.diff_test_names(diff, eco.row_for("x_test.go"))+ self.assertEqual((added, removed), ({"TestNew"}, {"TestOld"}))+ rust = ("+#[test]\n+fn added_case() {}\n-#[tokio::test]\n-async fn removed_case() {}\n"+ "+fn not_a_test() {}\n")+ added, removed = blast_radius.diff_test_names(rust, eco.row_for("src/lib.rs"))+ self.assertEqual((added, removed), ({"added_case"}, {"removed_case"}))+ ts = "+it('adds numbers', () => {});\n+test(\"names things\", () => {});\n"+ added, _ = blast_radius.diff_test_names(ts, eco.row_for("a.test.ts"))+ self.assertEqual(added, {"adds numbers", "names things"})++ def test_test_file_detection(self) -> None:+ eco = blast_radius.load_ecosystems(ECOSYSTEMS)+ cases = {+ "pkg/a_test.go": True, "pkg/a.go": False,+ "tests/test_core.py": True, "src/app/core.py": False, "conftest.py": True,+ "web/lib/x.test.ts": True, "web/__tests__/y.ts": True, "web/lib/x.ts": False,+ "Tests/CoreTests/CoreTests.swift": True, "Sources/Core/core.swift": False,+ "tests/integration.rs": True, "src/lib.rs": False,+ "spec/foo_spec.rb": True, "lib/foo.rb": False, "docs/testing.md": False,+ # No row: whole-token names and directories only.+ "specs/review-html-tests-diagram/design.md": False, "specs/overview.md": False,+ "contest/latest.md": False, "lib/foo-test.sh": True, "lib/foo.spec.rb": True,+ "app/test/Helpers.kt": True, "Sources/App/AppTests.kt": True,+ }+ for path, expected in cases.items():+ with self.subTest(path=path):+ self.assertEqual(blast_radius.is_test_file(path, eco.row_for(path)), expected)+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_coverage.py b/scripts/tests/test_coverage.pynew file mode 100644index 0000000..96d45c5--- /dev/null+++ b/scripts/tests/test_coverage.py@@ -0,0 +1,330 @@+"""Tests for review_html.coverage: parsers, path mapping, matching, arithmetic."""+from __future__ import annotations++import contextlib+import io+import random+import tempfile+import unittest+from pathlib import Path++from review_html.coverage import (+ Entry,+ apply_path_map,+ diff_coverage,+ match,+ normalise,+ overall,+ parse_coverage,+)+from review_html.warnings import Warnings++LCOV = """\+TN:+SF:src/a.py+DA:1,1+DA:2,0+DA:3,4,checksum+end_of_record+SF:src/b.py+DA:10,0+end_of_record+SF:src/a.py+DA:2,2+DA:4,0+end_of_record+"""++COBERTURA = """\+<?xml version="1.0" ?>+<coverage line-rate="0.5" version="6.0">+ <sources>+ <source>/home/ci/repo</source>+ <source>/home/ci/repo/src</source>+ </sources>+ <packages>+ <package name="pkg">+ <classes>+ <class name="a.py" filename="pkg/a.py" line-rate="0.5">+ <methods>+ <method name="f"><lines><line number="1" hits="1"/></lines></method>+ </methods>+ <lines>+ <line number="1" hits="1"/>+ <line number="2" hits="0" branch="true" condition-coverage="50% (1/2)"/>+ </lines>+ </class>+ </classes>+ </package>+ </packages>+</coverage>+"""++COBERTURA_NO_SOURCES = """\+<coverage>+ <packages><package name="p"><classes>+ <class filename="x.py"><lines><line number="7" hits="3"/></lines></class>+ </classes></package></packages>+</coverage>+"""++COVERPROFILE_SET = """\+mode: set+github.com/org/repo/pkg/a.go:3.10,5.2 2 1+github.com/org/repo/pkg/a.go:5.2,7.1 1 0+github.com/org/repo/pkg/b.go:1.1,1.20 1 0+"""++COVERPROFILE_COUNT = """\+mode: count+github.com/org/repo/pkg/a.go:3.10,5.2 2 4+github.com/org/repo/pkg/a.go:4.1,4.30 1 0+github.com/org/repo/pkg/a.go:5.2,6.1 1 9+"""+++def entry(path: str, *aliases: str, hits: dict | None = None) -> Entry:+ return Entry([path, *aliases], dict(hits or {1: 1}))+++class ParserTest(unittest.TestCase):+ def setUp(self) -> None:+ self._tmp = tempfile.TemporaryDirectory()+ self.dir = Path(self._tmp.name)+ self.warnings = Warnings()+ self._stderr = contextlib.redirect_stderr(io.StringIO())+ self._stderr.__enter__()++ def tearDown(self) -> None:+ self._stderr.__exit__(None, None, None)+ self._tmp.cleanup()++ def parse(self, name: str, text: str) -> list[Entry]:+ path = self.dir / name+ path.write_text(text, encoding="utf-8")+ return parse_coverage(path, self.warnings)++ def test_lcov_keeps_repeated_sf_as_separate_entries(self) -> None:+ cov = self.parse("lcov.info", LCOV)+ self.assertEqual([e.paths for e in cov], [["src/a.py"], ["src/b.py"], ["src/a.py"]])+ self.assertEqual(cov[0].hits, {1: 1, 2: 0, 3: 4})+ self.assertEqual(cov[1].hits, {10: 0})+ self.assertEqual(cov[2].hits, {2: 2, 4: 0})+ self.assertEqual(self.warnings.items, [])++ def test_lcov_without_trailing_end_of_record_keeps_last_entry(self) -> None:+ cov = self.parse("lcov.info", "SF:a.py\nDA:1,1\n")+ self.assertEqual(cov, [Entry(["a.py"], {1: 1})])++ def test_cobertura_two_source_roots_become_aliases(self) -> None:+ cov = self.parse("coverage.xml", COBERTURA)+ self.assertEqual(len(cov), 1)+ self.assertEqual(cov[0].paths,+ ["pkg/a.py", "/home/ci/repo/pkg/a.py", "/home/ci/repo/src/pkg/a.py"])+ self.assertEqual(cov[0].hits, {1: 1, 2: 0})++ def test_cobertura_without_sources_has_primary_path_only(self) -> None:+ cov = self.parse("coverage.xml", COBERTURA_NO_SOURCES)+ self.assertEqual(cov, [Entry(["x.py"], {7: 3})])++ def test_coverprofile_set_mode_expands_blocks_to_lines(self) -> None:+ cov = self.parse("coverage.out", COVERPROFILE_SET)+ self.assertEqual([e.paths for e in cov],+ [["github.com/org/repo/pkg/a.go"], ["github.com/org/repo/pkg/b.go"]])+ # block 3..5 count 1, block 5..7 count 0: line 5 keeps the maximum+ self.assertEqual(cov[0].hits, {3: 1, 4: 1, 5: 1, 6: 0, 7: 0})+ self.assertEqual(cov[1].hits, {1: 0})++ def test_coverprofile_count_mode_takes_maximum_on_overlap(self) -> None:+ cov = self.parse("coverage.out", COVERPROFILE_COUNT)+ self.assertEqual(len(cov), 1)+ self.assertEqual(cov[0].hits, {3: 4, 4: 4, 5: 9, 6: 9})++ def test_coverprofile_without_mode_line_is_rejected(self) -> None:+ cov = self.parse("coverage.out", "a.go:1.1,2.1 1 1\n")+ self.assertEqual(cov, [])+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("coverage.out", self.warnings.items[0])++ def test_cobertura_doctype_is_rejected(self) -> None:+ cov = self.parse("coverage.xml", "<!DOCTYPE coverage><coverage/>")+ self.assertEqual(cov, [])+ self.assertIn("DOCTYPE", self.warnings.items[0])++ def test_malformed_xml_warns(self) -> None:+ cov = self.parse("coverage.xml", "<coverage><packages></coverage>")+ self.assertEqual(cov, [])+ self.assertIn("coverage.xml", self.warnings.items[0])++ def test_unknown_xml_root_warns(self) -> None:+ cov = self.parse("junit.xml", "<testsuite/>")+ self.assertEqual(cov, [])+ self.assertIn("junit.xml", self.warnings.items[0])++ def test_unrecognised_format_warns(self) -> None:+ cov = self.parse("notes.txt", "hello\n")+ self.assertEqual(cov, [])+ self.assertIn("notes.txt", self.warnings.items[0])++ def test_missing_file_warns(self) -> None:+ cov = parse_coverage(self.dir / "absent.info", self.warnings)+ self.assertEqual(cov, [])+ self.assertIn("absent.info", self.warnings.items[0])+++class PathMapTest(unittest.TestCase):+ def test_normalise(self) -> None:+ self.assertEqual(normalise("./src\\a.py"), "src/a.py")+ self.assertEqual(normalise("src//pkg/../a.py"), "src/a.py")+ self.assertEqual(normalise("/abs/./x.go"), "/abs/x.go")++ def test_strip_and_prepend_on_primary_and_aliases(self) -> None:+ cov = [entry("./src\\pkg/a.py", "/ci/repo/src/pkg/a.py", "srcx/pkg/a.py")]+ mapped = apply_path_map(cov, "src", "lib")+ self.assertEqual(mapped[0].paths,+ ["lib/pkg/a.py", "lib/ci/repo/src/pkg/a.py", "lib/srcx/pkg/a.py"])+ self.assertIs(mapped[0].hits, cov[0].hits)++ def test_strip_only_whole_segments(self) -> None:+ cov = [entry("srcx/a.py"), entry("src/a.py"), entry("src")]+ self.assertEqual([e.paths for e in apply_path_map(cov, "src/", None)],+ [["srcx/a.py"], ["a.py"], ["src"]])++ def test_no_map_only_normalises(self) -> None:+ cov = [entry("./a\\b.py")]+ self.assertEqual(apply_path_map(cov, None, None)[0].paths, ["a/b.py"])+++class MatchTest(unittest.TestCase):+ def test_exact_match_leaves_the_pool(self) -> None:+ cov = [entry("src/a.py", hits={1: 1})]+ hits, unmatched = match(cov, ["src/a.py", "pkg/src/a.py"])+ self.assertEqual(hits, {"src/a.py": {1: 1}})+ self.assertEqual(unmatched, {"pkg/src/a.py": "no candidate"})++ def test_exact_match_beats_suffix_candidates(self) -> None:+ cov = [entry("a/util.py", hits={1: 1}), entry("x/a/util.py", hits={2: 1})]+ hits, unmatched = match(cov, ["a/util.py"])+ self.assertEqual(hits, {"a/util.py": {1: 1}})+ self.assertEqual(unmatched, {})++ def test_exact_alias_equal_to_two_files_is_ambiguous_for_both(self) -> None:+ cov = [entry("a.py", "src/a.py", hits={1: 1})]+ hits, unmatched = match(cov, ["a.py", "src/a.py"])+ self.assertEqual(hits, {})+ self.assertEqual(unmatched, {"a.py": "ambiguous", "src/a.py": "ambiguous"})++ def test_shared_entry_removed_once_and_both_files_ambiguous(self) -> None:+ cov = [entry("util.py", hits={1: 1})]+ hits, unmatched = match(cov, ["a/util.py", "b/util.py"])+ self.assertEqual(hits, {})+ self.assertEqual(unmatched, {"a/util.py": "ambiguous", "b/util.py": "ambiguous"})++ def test_shared_removal_does_not_cascade(self) -> None:+ # util.py sits in both pools and is removed once; a/util.py stays+ # unique to src/a/util.py, which must match it.+ cov = [entry("util.py", hits={1: 1}), entry("a/util.py", hits={2: 1})]+ hits, unmatched = match(cov, ["src/a/util.py", "src/util.py"])+ self.assertEqual(hits, {"src/a/util.py": {2: 1}})+ self.assertEqual(unmatched, {"src/util.py": "ambiguous"})++ def test_distinct_residuals_are_ambiguous(self) -> None:+ cov = [entry("v1/a.py", hits={1: 1}), entry("v2/a.py", hits={1: 1})]+ hits, unmatched = match(cov, ["a.py"])+ self.assertEqual(hits, {})+ self.assertEqual(unmatched, {"a.py": "ambiguous"})++ def test_equal_residuals_merge_by_summing_hits(self) -> None:+ cov = [entry("/ci/repo/src/a.py", hits={1: 1, 2: 0}),+ entry("/ci/repo/src/a.py", hits={2: 3, 4: 0})]+ hits, unmatched = match(cov, ["src/a.py"])+ self.assertEqual(hits, {"src/a.py": {1: 1, 2: 3, 4: 0}})+ self.assertEqual(unmatched, {})++ def test_changed_path_longer_than_entry_matches(self) -> None:+ cov = [entry("pkg/a.go", hits={5: 2})]+ hits, unmatched = match(cov, ["cmd/pkg/a.go"])+ self.assertEqual(hits, {"cmd/pkg/a.go": {5: 2}})++ def test_partial_segment_is_not_a_suffix(self) -> None:+ cov = [entry("xa.py", hits={1: 1})]+ hits, unmatched = match(cov, ["a.py"])+ self.assertEqual(hits, {})+ self.assertEqual(unmatched, {"a.py": "no candidate"})++ def test_alias_can_carry_the_suffix_match(self) -> None:+ cov = [entry("a.py", "/ci/repo/src/a.py", hits={1: 1})]+ hits, unmatched = match(cov, ["src/a.py"])+ self.assertEqual(hits, {"src/a.py": {1: 1}})++ def test_paths_are_normalised_before_matching(self) -> None:+ cov = [entry(".\\src\\a.py", hits={1: 1})]+ hits, unmatched = match(cov, ["./src/a.py"])+ self.assertEqual(hits, {"./src/a.py": {1: 1}})++ def test_empty_coverage_reports_no_candidate(self) -> None:+ hits, unmatched = match([], ["a.py"])+ self.assertEqual(hits, {})+ self.assertEqual(unmatched, {"a.py": "no candidate"})+++class MatchPropertyTest(unittest.TestCase):+ SEGMENTS = ["src", "pkg", "a", "b", "lib", "x"]+ NAMES = ["util.py", "main.py", "a.py"]++ def random_path(self, rng: random.Random) -> str:+ depth = rng.randint(0, 3)+ segs = [rng.choice(self.SEGMENTS) for _ in range(depth)]+ return "/".join(segs + [rng.choice(self.NAMES)])++ def test_each_file_and_entry_used_at_most_once_and_order_independent(self) -> None:+ for seed in range(200):+ rng = random.Random(seed)+ changed = list({self.random_path(rng) for _ in range(rng.randint(1, 5))})+ # sentinel line numbers identify which entries contributed+ cov = [Entry([self.random_path(rng)] + [self.random_path(rng) for _ in range(rng.randint(0, 1))],+ {1000 + i: 1})+ for i in range(rng.randint(0, 6))]+ hits, unmatched = match(cov, changed)+ self.assertEqual(set(hits) | set(unmatched), set(changed), seed)+ self.assertEqual(set(hits) & set(unmatched), set(), seed)+ seen: dict[int, str] = {}+ for path, merged in hits.items():+ self.assertTrue(merged, seed)+ for line in merged:+ self.assertNotIn(line, seen, f"seed {seed}: entry used by {seen.get(line)} and {path}")+ seen[line] = path+ for reason in unmatched.values():+ self.assertIn(reason, ("no candidate", "ambiguous"))++ shuffled_cov = list(cov)+ rng.shuffle(shuffled_cov)+ shuffled_changed = list(changed)+ rng.shuffle(shuffled_changed)+ hits2, unmatched2 = match(shuffled_cov, shuffled_changed)+ self.assertEqual(hits2, hits, seed)+ self.assertEqual(unmatched2, unmatched, seed)+++class ArithmeticTest(unittest.TestCase):+ def test_diff_coverage_counts_only_added_lines_with_data(self) -> None:+ self.assertEqual(diff_coverage({1, 2, 3, 4}, {1: 1, 2: 0, 3: 5, 9: 1}), (2, 3))++ def test_diff_coverage_zero_denominator_is_none(self) -> None:+ self.assertIsNone(diff_coverage({1, 2}, {3: 1}))+ self.assertIsNone(diff_coverage(set(), {1: 1}))+ self.assertIsNone(diff_coverage({1}, {}))++ def test_overall_merges_repeated_entries_by_normalised_primary_path(self) -> None:+ cov = [Entry(["src/a.py"], {1: 1, 2: 0}),+ Entry(["./src\\a.py"], {2: 2, 3: 0}),+ Entry(["b.py", "src/a.py"], {1: 0})]+ # a.py: lines 1,2,3 with 2 covered; b.py: line 1 uncovered+ self.assertEqual(overall(cov), (2, 4))++ def test_overall_empty(self) -> None:+ self.assertEqual(overall([]), (0, 0))+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_diagram.py b/scripts/tests/test_diagram.pynew file mode 100644index 0000000..c4c04e7--- /dev/null+++ b/scripts/tests/test_diagram.py@@ -0,0 +1,946 @@+"""Tests for review_html.diagram: projection, layout, and section rendering."""+from __future__ import annotations++import contextlib+import io+import json+import random+import re+import subprocess+import sys+import tempfile+import unittest+import xml.etree.ElementTree as ET+from pathlib import Path++from review_html import render+from review_html.common import digest, file_anchor+from review_html.diagram import (+ ADV,+ BOX_H,+ CAP,+ CENTRE_BOX_W,+ CENTRE_BUDGET,+ COL_W,+ CONTENT_W,+ GROUP_GAP,+ GROUP_HEADER,+ GROUP_PAD,+ GUTTER,+ LANE,+ PAD,+ ROW_GAP,+ SIDE_BOX_W,+ SIDE_BUDGET,+ Projected,+ budget,+ layout,+ project,+ render_diagram,+ shorten,+)++REPO_ROOT = Path(__file__).resolve().parents[2]+++def desc(nodes, edges, column_status=None, **extra):+ """Build a diagram description from compact tuples.++ ``nodes`` are ``(path, status, group, is_test)``; ``edges`` are+ ``(from, to, granularity)`` with method ``import`` for file edges and+ ``expansion`` for package edges.+ """+ d = {+ "snapshot_tree": "abc1234",+ "base_tree": "def5678",+ "nodes": [+ {"path": p, "status": s, "group": g, "is_test": t, "old_path": None}+ for p, s, g, t in nodes+ ],+ "edges": [+ {"from": a, "to": b, "granularity": gran,+ "method": "expansion" if gran == "package" else "import",+ "tree": "snapshot"}+ for a, b, gran in edges+ ],+ "column_status": column_status or {"dependents": "complete", "dependencies": "complete"},+ "skipped": [],+ }+ d.update(extra)+ return d+++def paths(p: Projected, column: str) -> list:+ return [n.path for n in p.nodes(column)]+++def labels(p: Projected, column: str) -> list:+ return [n.label for n in p.nodes(column)]+++class ColumnAssignmentTest(unittest.TestCase):+ def test_changed_nodes_go_to_centre(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("b.py", "added", "pkg", False),+ ("c.py", "deleted", "pkg", False), ("d.py", "renamed", "pkg", False)],+ [],+ ))+ self.assertEqual(paths(p, "changed"), ["a.py", "b.py", "c.py", "d.py"])+ self.assertEqual(paths(p, "dependents"), [])+ self.assertEqual(paths(p, "dependencies"), [])++ def test_unchanged_with_edge_into_changed_is_dependent(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False)],+ [("u.py", "a.py", "file")],+ ))+ self.assertEqual(paths(p, "dependents"), ["u.py"])+ self.assertEqual(paths(p, "dependencies"), [])+ self.assertEqual(len(p.edges), 1)+ self.assertEqual((p.edges[0].src, p.edges[0].dst, p.edges[0].column),+ ("n-" + digest("u.py"), "n-" + digest("a.py"), "dependents"))++ def test_unchanged_with_edge_from_changed_is_dependency(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False)],+ [("a.py", "u.py", "file")],+ ))+ self.assertEqual(paths(p, "dependencies"), ["u.py"])+ self.assertEqual(paths(p, "dependents"), [])+ self.assertEqual(p.edges[0].column, "dependencies")++ def test_both_goes_to_dependents_with_both_edges(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False)],+ [("u.py", "a.py", "file"), ("a.py", "u.py", "file")],+ ))+ self.assertEqual(paths(p, "dependents"), ["u.py"])+ self.assertEqual(paths(p, "dependencies"), [])+ self.assertEqual(sorted((e.src, e.dst, e.column) for e in p.edges), sorted([+ ("n-" + digest("u.py"), "n-" + digest("a.py"), "dependents"),+ ("n-" + digest("a.py"), "n-" + digest("u.py"), "dependents"),+ ]))++ def test_centre_to_centre_edges_are_kept(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("b.py", "added", "pkg", False)],+ [("a.py", "b.py", "file")],+ ))+ self.assertEqual(len(p.edges), 1)+ self.assertEqual(p.edges[0].column, "changed")++ def test_unchanged_only_edges_and_orphans_are_dropped(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False),+ ("v.py", "unchanged", "pkg", False), ("w.py", "unchanged", "pkg", False)],+ [("u.py", "v.py", "file"), ("v.py", "a.py", "file")],+ ))+ self.assertEqual(paths(p, "dependents"), ["v.py"])+ self.assertEqual(len(p.edges), 1)++ def test_node_ids_share_the_anchor_digest(self) -> None:+ p = project(desc([("src/a.py", "modified", "src", False)], []))+ self.assertEqual(p.nodes("changed")[0].id, "n-" + digest("src/a.py"))++ def test_trees_and_skipped_pass_through(self) -> None:+ p = project(desc([("a.py", "modified", "pkg", False)], [],+ skipped=[{"path": "big.go", "reason": "blob over 1 MB"}]))+ self.assertEqual(p.snapshot_tree, "abc1234")+ self.assertEqual(p.base_tree, "def5678")+ self.assertEqual(p.skipped, [{"path": "big.go", "reason": "blob over 1 MB"}])+++class TestExclusionTest(unittest.TestCase):+ def test_test_files_leave_side_columns(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False),+ ("test_a.py", "unchanged", "tests", True),+ ("u.py", "unchanged", "pkg", False)],+ [("test_a.py", "a.py", "file"), ("u.py", "a.py", "file"), ("a.py", "test_a.py", "file")],+ ))+ self.assertEqual(paths(p, "dependents"), ["u.py"])+ self.assertEqual(paths(p, "dependencies"), [])+ self.assertEqual(len(p.edges), 1)++ def test_counts_include_changed_test_files(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False),+ ("test_a.py", "unchanged", "tests", True),+ ("test_b.py", "added", "tests", True),+ ("u.py", "unchanged", "pkg", False)],+ [("test_a.py", "a.py", "file"), ("test_b.py", "a.py", "file"), ("u.py", "a.py", "file"),+ ("a.py", "test_b.py", "file")],+ ))+ by_path = {n.path: n for n in p.nodes("changed")}+ self.assertEqual(by_path["a.py"].test_count, 2)+ self.assertEqual(by_path["test_b.py"].test_count, 0)+ self.assertEqual(paths(p, "changed"), ["a.py", "test_b.py"])++ def test_changed_test_file_stays_a_changed_node(self) -> None:+ p = project(desc([("test_a.py", "modified", "tests", True)], []))+ self.assertEqual(paths(p, "changed"), ["test_a.py"])+++def package_group(n: int, prefix: str = "pkg", target: str = "a.go"):+ nodes = [(f"{prefix}/f{i}.go", "unchanged", prefix, False) for i in range(n)]+ edges = [(f"{prefix}/f{i}.go", target, "package") for i in range(n)]+ return nodes, edges+++class ExpansionCollapseTest(unittest.TestCase):+ def test_more_than_three_package_nodes_collapse(self) -> None:+ nodes, edges = package_group(4)+ p = project(desc([("a.go", "modified", "root", False)] + nodes, edges))+ col = p.nodes("dependents")+ self.assertEqual(len(col), 1)+ node = col[0]+ self.assertEqual(node.label, "pkg (4 files)")+ self.assertEqual(node.status, "collapsed")+ members = sorted(f"pkg/f{i}.go" for i in range(4))+ self.assertEqual(node.members, members)+ self.assertEqual(node.id, "n-" + digest("\n".join(members)))+ self.assertEqual(node.path, members[0])+ self.assertEqual(node.edges_to_changed, 4)+ self.assertEqual(len(p.edges), 1)+ self.assertEqual(p.edges[0].src, node.id)+ self.assertEqual(p.edges[0].granularity, "package")++ def test_three_package_nodes_do_not_collapse(self) -> None:+ nodes, edges = package_group(3)+ p = project(desc([("a.go", "modified", "root", False)] + nodes, edges))+ self.assertEqual(len(p.nodes("dependents")), 3)++ def test_nodes_with_a_file_granular_edge_stay(self) -> None:+ nodes, edges = package_group(5)+ edges.append(("pkg/f0.go", "b.go", "file"))+ p = project(desc(+ [("a.go", "modified", "root", False), ("b.go", "added", "root", False)] + nodes, edges))+ col = p.nodes("dependents")+ # Nodes order by path; the collapsed node sorts by its first member.+ self.assertEqual([n.label for n in col], ["pkg/f0.go", "pkg (4 files)"])+ self.assertEqual(col[1].members, [f"pkg/f{i}.go" for i in range(1, 5)])++ def test_collapse_is_per_group_and_per_column(self) -> None:+ n1, e1 = package_group(4, "one")+ n2, e2 = package_group(2, "two")+ n3, e3 = package_group(4, "three")+ e3 = [(b, a, g) for a, b, g in e3] # dependencies+ p = project(desc([("a.go", "modified", "root", False)] + n1 + n2 + n3, e1 + e2 + e3))+ self.assertEqual(labels(p, "dependents"), ["one (4 files)", "two/f0.go", "two/f1.go"])+ self.assertEqual(labels(p, "dependencies"), ["three (4 files)"])++ def test_collapsed_rank_key_sums_member_edges(self) -> None:+ nodes, edges = package_group(4)+ edges += [(f"pkg/f{i}.go", "b.go", "package") for i in range(2)]+ p = project(desc(+ [("a.go", "modified", "root", False), ("b.go", "added", "root", False)] + nodes, edges))+ node = p.nodes("dependents")[0]+ self.assertEqual(node.edges_to_changed, 6)+ self.assertEqual(len(p.edges), 2)++ def test_test_exclusion_precedes_collapse(self) -> None:+ nodes, edges = package_group(4)+ nodes[0] = ("pkg/f0.go", "unchanged", "pkg", True)+ p = project(desc([("a.go", "modified", "root", False)] + nodes, edges))+ self.assertEqual(len(p.nodes("dependents")), 3)+ self.assertEqual(p.nodes("changed")[0].test_count, 1)+++class CapTest(unittest.TestCase):+ def test_side_column_over_cap_keeps_ranked_nodes(self) -> None:+ nodes = [("a.go", "modified", "root", False), ("b.go", "added", "root", False)]+ edges = []+ for i in range(20):+ path = f"lib/d{i:02d}.go"+ nodes.append((path, "unchanged", "lib", False))+ edges.append((path, "a.go", "file"))+ if i % 4 == 0:+ edges.append((path, "b.go", "file"))+ p = project(desc(nodes, edges))+ col = p.nodes("dependents")+ self.assertEqual(len(col), CAP + 1)+ kept = [n.path for n in col if n.status != "more"]+ self.assertEqual(len(kept), CAP)+ two_edge = [f"lib/d{i:02d}.go" for i in range(20) if i % 4 == 0]+ for path in two_edge:+ self.assertIn(path, kept)+ one_edge = [f"lib/d{i:02d}.go" for i in range(20) if i % 4 != 0]+ self.assertEqual(sorted(set(kept) - set(two_edge)), one_edge[:CAP - len(two_edge)])+ more = col[-1]+ self.assertEqual(more.status, "more")+ self.assertEqual(more.label, "+5 more")+ self.assertEqual(more.members, one_edge[CAP - len(two_edge):])+ self.assertEqual(more.id, "n-" + digest("\n".join(more.members)))+ self.assertIsNone(more.group)+ self.assertEqual(p.columns["dependents"][-1].name, None)+ self.assertEqual(p.columns["dependents"][-1].nodes, [more])+ self.assertTrue(any(e.src == more.id for e in p.edges))++ def test_exactly_cap_nodes_are_not_capped(self) -> None:+ nodes = [("a.go", "modified", "root", False)]+ edges = []+ for i in range(CAP):+ nodes.append((f"lib/d{i:02d}.go", "unchanged", "lib", False))+ edges.append((f"lib/d{i:02d}.go", "a.go", "file"))+ p = project(desc(nodes, edges))+ self.assertEqual(len(p.nodes("dependents")), CAP)+ self.assertFalse(any(n.status == "more" for n in p.nodes("dependents")))++ def test_collapsed_node_ranks_by_first_member_and_summed_edges(self) -> None:+ nodes = [("a.go", "modified", "root", False)]+ edges = []+ # 16 file-granular dependents with one edge each ...+ for i in range(16):+ nodes.append((f"zzz/d{i:02d}.go", "unchanged", "zzz", False))+ edges.append((f"zzz/d{i:02d}.go", "a.go", "file"))+ # ... plus a package group of 4 collapsing to one node with 4 edges.+ gn, ge = package_group(4, "aaa")+ p = project(desc(nodes + gn, edges + ge))+ col = p.nodes("dependents")+ self.assertEqual(len(col), CAP + 1)+ self.assertEqual(col[0].label, "aaa (4 files)")+ more = col[-1]+ self.assertEqual(more.label, "+2 more")+ self.assertEqual(more.members, ["zzz/d14.go", "zzz/d15.go"])++ def test_more_node_flattens_collapsed_members(self) -> None:+ changed = ["a.go", "b.go", "c.go", "d.go"]+ nodes = [(c, "modified", "root", False) for c in changed]+ edges = []+ for i in range(CAP):+ nodes.append((f"aaa/d{i:02d}.go", "unchanged", "aaa", False))+ for c in changed:+ edges.append((f"aaa/d{i:02d}.go", c, "file"))+ edges.append((f"aaa/d{i:02d}.go", "a.go", "file")) # duplicate, deduped+ gn, ge = package_group(4, "zzz")+ p = project(desc(nodes + gn, edges + ge))+ more = p.nodes("dependents")[-1]+ self.assertEqual(more.status, "more")+ self.assertEqual(more.label, "+4 more")+ self.assertEqual(more.members, [f"zzz/f{i}.go" for i in range(4)])++ def test_centre_is_never_capped(self) -> None:+ nodes = [(f"src/c{i:02d}.py", "modified", "src", False) for i in range(40)]+ p = project(desc(nodes, []))+ self.assertEqual(len(p.nodes("changed")), 40)+++class OrderingTest(unittest.TestCase):+ def test_groups_by_name_and_nodes_by_path(self) -> None:+ p = project(desc(+ [("a.py", "modified", "root", False),+ ("zeta/b.py", "unchanged", "zeta", False), ("zeta/a.py", "unchanged", "zeta", False),+ ("alpha/z.py", "unchanged", "alpha", False), ("alpha/m.py", "unchanged", "alpha", False)],+ [("zeta/b.py", "a.py", "file"), ("zeta/a.py", "a.py", "file"),+ ("alpha/z.py", "a.py", "file"), ("alpha/m.py", "a.py", "file")],+ ))+ groups = p.columns["dependents"]+ self.assertEqual([g.name for g in groups], ["alpha", "zeta"])+ self.assertEqual([n.path for n in groups[0].nodes], ["alpha/m.py", "alpha/z.py"])+ self.assertEqual([n.path for n in groups[1].nodes], ["zeta/a.py", "zeta/b.py"])++ def test_centre_grouped_too(self) -> None:+ p = project(desc(+ [("b/x.py", "modified", "b", False), ("a/y.py", "added", "a", False),+ ("a/x.py", "deleted", "a", False)], []))+ self.assertEqual([g.name for g in p.columns["changed"]], ["a", "b"])+ self.assertEqual(paths(p, "changed"), ["a/x.py", "a/y.py", "b/x.py"])+++class ColumnStatusTest(unittest.TestCase):+ def test_partial_keeps_nodes(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False)],+ [("u.py", "a.py", "file")],+ {"dependents": "partial: remote scan cap reached", "dependencies": "complete"},+ ))+ self.assertEqual(paths(p, "dependents"), ["u.py"])+ self.assertEqual(p.column_status["dependents"], "partial: remote scan cap reached")++ def test_failed_keeps_no_nodes_or_edges(self) -> None:+ p = project(desc(+ [("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False),+ ("v.py", "unchanged", "pkg", False)],+ [("u.py", "a.py", "file"), ("a.py", "v.py", "file")],+ {"dependents": "failed: no import patterns for .py", "dependencies": "complete"},+ ))+ self.assertEqual(paths(p, "dependents"), [])+ self.assertEqual(paths(p, "dependencies"), ["v.py"])+ self.assertEqual(len(p.edges), 1)+ self.assertEqual(p.column_status["dependents"], "failed: no import patterns for .py")++ def test_missing_status_defaults_to_complete(self) -> None:+ d = desc([("a.py", "modified", "pkg", False)], [])+ del d["column_status"]+ p = project(d)+ self.assertEqual(p.column_status, {"dependents": "complete", "dependencies": "complete"})+++class GranularityNoteTest(unittest.TestCase):+ def test_package_edge_flags_its_column_only(self) -> None:+ p = project(desc(+ [("a.go", "modified", "root", False), ("u.go", "unchanged", "u", False),+ ("v.go", "unchanged", "v", False)],+ [("u.go", "a.go", "package"), ("a.go", "v.go", "file")],+ ))+ self.assertEqual(p.package_granularity,+ {"dependents": True, "changed": False, "dependencies": False})++ def test_tool_edges_count_by_granularity(self) -> None:+ d = desc([("a.go", "modified", "root", False), ("b.go", "added", "root", False)],+ [("a.go", "b.go", "package")])+ d["edges"][0]["method"] = "tool:go list"+ p = project(d)+ self.assertTrue(p.package_granularity["changed"])+++class InvalidDescriptionTest(unittest.TestCase):+ def test_rejects_non_dict_and_missing_lists(self) -> None:+ for bad in ([], "x", {}, {"nodes": []}, {"edges": []}, {"nodes": "x", "edges": []},+ {"nodes": [{"status": "added"}], "edges": []},+ {"nodes": [{"path": "a"}], "edges": [{"from": "a"}]}):+ with self.subTest(bad=bad):+ with self.assertRaises(ValueError):+ project(bad)+++def random_description(rng: random.Random) -> dict:+ """A random one-hop graph whose non-failed side columns are never empty."""+ groups = [f"g{i}" for i in range(rng.randint(1, 5))]+ changed = [f"c{i}.go" for i in range(rng.randint(1, 8))]+ nodes = [(c, rng.choice(["added", "modified", "deleted", "renamed"]),+ rng.choice(groups), rng.random() < 0.2) for c in changed]+ edges = []+ status = {}+ for column, prefix in (("dependents", "in"), ("dependencies", "out")):+ status[column] = rng.choice(["complete", "complete", "partial: cap", "failed: no patterns"])+ count = rng.randint(0, 40)+ column_nodes = []+ for i in range(count):+ path = f"{prefix}/{rng.choice(groups)}/f{i:02d}.go"+ is_test = rng.random() < 0.25+ column_nodes.append((path, "unchanged", rng.choice(groups), is_test))+ if not status[column].startswith("failed"):+ column_nodes.append((f"{prefix}/anchor.go", "unchanged", groups[0], False))+ for path, _, _, _ in column_nodes:+ gran = rng.choice(["file", "package", "package"])+ targets = rng.sample(changed, rng.randint(1, len(changed)))+ for t in targets:+ if column == "dependents":+ edges.append((path, t, gran))+ else:+ edges.append((t, path, gran))+ nodes += column_nodes+ # A few centre-to-centre edges and some noise edges among unchanged nodes.+ for _ in range(rng.randint(0, 4)):+ a, b = rng.choice(changed), rng.choice(changed)+ if a != b:+ edges.append((a, b, rng.choice(["file", "package"])))+ rng.shuffle(nodes)+ rng.shuffle(edges)+ return desc(nodes, edges, status)+++class ProjectPropertyTest(unittest.TestCase):+ def test_properties_over_random_descriptions(self) -> None:+ rng = random.Random(20260904)+ for case in range(200):+ d = random_description(rng)+ with self.subTest(case=case):+ first = project(d)+ second = project(d)+ self.assertEqual(first, second)+ for column in ("dependents", "dependencies"):+ nodes = first.nodes(column)+ self.assertLessEqual(len(nodes), CAP + 1)+ self.assertLessEqual(len([n for n in nodes if n.status != "more"]), CAP)+ if not first.column_status[column].startswith("failed"):+ self.assertTrue(nodes, column)+ else:+ self.assertEqual(nodes, [])+ for n in nodes:+ self.assertEqual(n.test_count, 0)+ self.assertEqual(len(first.nodes("changed")),+ sum(1 for n in d["nodes"] if n["status"] != "unchanged"))+ ids = {n.id for c in first.columns for n in first.nodes(c)}+ for e in first.edges:+ self.assertIn(e.src, ids)+ self.assertIn(e.dst, ids)+++# --- layout ---------------------------------------------------------------++def numbers(d: str) -> list:+ return [float(x) for x in re.findall(r"-?\d+(?:\.\d+)?", d)]+++def points(d: str) -> list:+ nums = numbers(d)+ return [(nums[i], nums[i + 1]) for i in range(0, len(nums), 2)]+++class LayoutConstantsTest(unittest.TestCase):+ def test_constants_match_the_design(self) -> None:+ self.assertEqual(ADV, 7.2)+ self.assertEqual(PAD, 10)+ self.assertEqual(BOX_H, 26)+ self.assertEqual(ROW_GAP, 8)+ self.assertEqual(GROUP_PAD, 8)+ self.assertEqual(GROUP_HEADER, 18)+ self.assertEqual(GROUP_GAP, 14)+ self.assertEqual(GUTTER, 56)+ self.assertEqual(LANE, 24)+ self.assertEqual(CONTENT_W, 1036)+ self.assertEqual(COL_W, 308)+ self.assertEqual(COL_W, (CONTENT_W - 2 * GUTTER) // 3)+ self.assertEqual(SIDE_BOX_W, 292)+ self.assertEqual(SIDE_BOX_W, COL_W - 2 * GROUP_PAD)+ self.assertEqual(CENTRE_BOX_W, 268)+ self.assertEqual(CENTRE_BOX_W, SIDE_BOX_W - LANE)++ def test_budgets_compute_to_37_and_30(self) -> None:+ self.assertEqual(budget(SIDE_BOX_W), 37)+ self.assertEqual(budget(CENTRE_BOX_W, reserve=4), 30)+ self.assertEqual(SIDE_BUDGET, 37)+ self.assertEqual(CENTRE_BUDGET, 30)+ self.assertLessEqual(37 * ADV + 2 * PAD, SIDE_BOX_W)+ self.assertGreater(38 * ADV + 2 * PAD, SIDE_BOX_W)+ self.assertLessEqual((30 + 4) * ADV + 2 * PAD, CENTRE_BOX_W)+ self.assertGreater((31 + 4) * ADV + 2 * PAD, CENTRE_BOX_W)++ def test_shorten_keeps_trailing_characters_with_leading_ellipsis(self) -> None:+ self.assertEqual(shorten("short.py", 37), "short.py")+ long = "a/" * 30 + "file.py"+ out = shorten(long, 37)+ self.assertEqual(len(out), 37)+ self.assertTrue(out.startswith("…"))+ self.assertTrue(long.endswith(out[1:]))+++class LayoutPropertyTest(unittest.TestCase):+ def test_properties_over_random_projections(self) -> None:+ rng = random.Random(4711)+ col_x = {"dependents": 0, "changed": COL_W + GUTTER, "dependencies": 2 * (COL_W + GUTTER)}+ lane_x0 = col_x["changed"] + GROUP_PAD + CENTRE_BOX_W+ lane_x1 = lane_x0 + LANE+ for case in range(200):+ p = project(random_description(rng))+ with self.subTest(case=case):+ lay = layout(p)+ self.assertEqual(lay, layout(p))+ self.assertEqual(lay.width, CONTENT_W)+ self.assertGreater(lay.height, 0)+ for box in lay.boxes.values():+ self.assertLessEqual(box.text_len + 2 * PAD, box.w + 1e-6)+ if box.badge:+ self.assertLessEqual(box.badge_len + 2 * PAD, box.w + 1e-6)+ self.assertLessEqual(box.text_len + box.badge_len + 2 * PAD, box.w + 1e-6)+ expected_w = CENTRE_BOX_W if box.column == "changed" else SIDE_BOX_W+ self.assertEqual(box.w, expected_w)+ self.assertEqual(box.h, BOX_H)+ self.assertEqual(box.x, col_x[box.column] + GROUP_PAD)+ self.assertLessEqual(box.y + box.h, lay.height)+ for frame in lay.frames:+ self.assertLessEqual(frame.text_len + 2 * PAD, frame.w + 1e-6)+ self.assertEqual(frame.w, COL_W)+ self.assertEqual(frame.x, col_x[frame.column])+ # Boxes in one column never overlap vertically.+ for column in ("dependents", "changed", "dependencies"):+ ys = sorted((b.y, b.y + b.h) for b in lay.boxes.values() if b.column == column)+ for (a0, a1), (b0, b1) in zip(ys, ys[1:]):+ self.assertLessEqual(a1, b0)+ by_id = lay.boxes+ for edge in lay.edges:+ src, dst = by_id[edge.src], by_id[edge.dst]+ pts = points(edge.d)+ start, end = pts[0], pts[-1]+ if edge.column == "changed":+ for x, _ in pts:+ self.assertGreaterEqual(x, lane_x0)+ self.assertLessEqual(x, lane_x1)+ self.assertEqual(start, (src.x + src.w, src.y + src.h / 2))+ self.assertEqual(end, (dst.x + dst.w, dst.y + dst.h / 2))+ elif dst.x > src.x:+ self.assertEqual(start, (src.x + src.w, src.y + src.h / 2))+ self.assertEqual(end, (dst.x, dst.y + dst.h / 2))+ else:+ self.assertEqual(start, (src.x, src.y + src.h / 2))+ self.assertEqual(end, (dst.x + dst.w, dst.y + dst.h / 2))+++class LayoutFromProjectedTest(unittest.TestCase):+ """Layout tests that build a Projected directly, without project()."""++ def test_reverse_edge_attaches_on_the_correct_sides(self) -> None:+ from review_html.diagram import PEdge, PGroup, PNode+ dep = PNode("n-dep", "u.py", "u.py", "pkg", "unchanged", [], 0, 2)+ chg = PNode("n-chg", "a.py", "a.py", "pkg", "modified", [], 1, 2)+ p = Projected(+ columns={"dependents": [PGroup("pkg", [dep])], "changed": [PGroup("pkg", [chg])],+ "dependencies": []},+ edges=[PEdge("n-dep", "n-chg", "dependents", "file"),+ PEdge("n-chg", "n-dep", "dependents", "file")],+ column_status={"dependents": "complete", "dependencies": "complete"},+ package_granularity={"dependents": False, "changed": False, "dependencies": False},+ skipped=[], snapshot_tree="s", base_tree="b",+ )+ lay = layout(p)+ forward = next(e for e in lay.edges if e.src == "n-dep")+ reverse = next(e for e in lay.edges if e.src == "n-chg")+ d, c = lay.boxes["n-dep"], lay.boxes["n-chg"]+ self.assertEqual(points(forward.d)[0], (d.x + d.w, d.y + d.h / 2))+ self.assertEqual(points(forward.d)[-1], (c.x, c.y + c.h / 2))+ self.assertEqual(points(reverse.d)[0], (c.x, c.y + c.h / 2))+ self.assertEqual(points(reverse.d)[-1], (d.x + d.w, d.y + d.h / 2))+ mid = (COL_W + (COL_W + GUTTER)) / 2+ self.assertEqual(points(forward.d)[1][0], mid)+ self.assertEqual(points(reverse.d)[1][0], mid)+ self.assertEqual(c.badge, "⚑1")++ def test_group_geometry(self) -> None:+ from review_html.diagram import PGroup, PNode+ nodes = [PNode(f"n-{i}", f"p{i}", f"p{i}", "g", "unchanged", [], 0, 1) for i in range(3)]+ p = Projected(+ columns={"dependents": [PGroup("g", nodes)], "changed": [], "dependencies": []},+ edges=[], column_status={"dependents": "complete", "dependencies": "complete"},+ package_granularity={"dependents": False, "changed": False, "dependencies": False},+ skipped=[], snapshot_tree="s", base_tree="b",+ )+ lay = layout(p)+ frame = lay.frames[0]+ boxes = [lay.boxes[f"n-{i}"] for i in range(3)]+ self.assertEqual(boxes[0].y, frame.y + GROUP_PAD + GROUP_HEADER)+ self.assertEqual(boxes[1].y - boxes[0].y, BOX_H + ROW_GAP)+ self.assertEqual(frame.h, GROUP_PAD + GROUP_HEADER + 3 * BOX_H + 2 * ROW_GAP + GROUP_PAD)+++# --- rendering ------------------------------------------------------------++def svg_of(html: str) -> ET.Element:+ start = html.index("<svg")+ end = html.index("</svg>") + len("</svg>")+ root = ET.fromstring(html[start:end])+ for el in root.iter():+ if "}" in el.tag:+ el.tag = el.tag.split("}", 1)[1]+ return root+++def render_section(d: dict) -> tuple:+ from review_html.warnings import Warnings+ w = Warnings()+ with contextlib.redirect_stderr(io.StringIO()):+ html = render_diagram(d, w)+ return html, w.items+++def sample() -> dict:+ nodes = [+ ("pkg/a.go", "modified", "pkg", False), ("pkg/b.go", "added", "pkg", False),+ ("pkg/gone.go", "deleted", "pkg", False), ("pkg/new.go", "renamed", "pkg", False),+ ("pkg/a_test.go", "unchanged", "pkg", True), ("pkg/b_test.go", "added", "pkg", True),+ ("cmd/main.go", "unchanged", "cmd", False), ("util/u.go", "unchanged", "util", False),+ ]+ edges = [+ ("pkg/a_test.go", "pkg/a.go", "package"), ("pkg/b_test.go", "pkg/a.go", "package"),+ ("cmd/main.go", "pkg/a.go", "package"), ("pkg/a.go", "util/u.go", "file"),+ ("pkg/a.go", "pkg/b.go", "file"), ("pkg/b.go", "pkg/new.go", "file"),+ ]+ gn, ge = package_group(4, "big", "pkg/b.go")+ return desc(nodes + gn, edges + ge,+ skipped=[{"path": "vendor/huge.go", "reason": "blob over 1 MB"}])+++class RenderDiagramMarkupTest(unittest.TestCase):+ def setUp(self) -> None:+ self.html, self.warnings = render_section(sample())+ self.svg = svg_of(self.html)++ def test_no_warnings_and_section_shell(self) -> None:+ self.assertEqual(self.warnings, [])+ self.assertIn('<section id="diagram">', self.html)+ self.assertIn("<h2>Blast radius</h2>", self.html)+ self.assertIn('<div class="blast-scroll">', self.html)+ self.assertIn('class="blast-legend"', self.html)+ self.assertIn("abc1234", self.html)+ self.assertIn("def5678", self.html)++ def test_declared_size_and_no_scripts(self) -> None:+ self.assertEqual(self.svg.get("width"), str(CONTENT_W))+ self.assertTrue(int(self.svg.get("height")) > 0)+ self.assertNotIn("<script", self.html)+ self.assertNotIn("http://", self.html.replace("http://www.w3.org/2000/svg", ""))++ def test_node_markup(self) -> None:+ nid = "n-" + digest("pkg/a.go")+ g = self.svg.find(f".//g[@id='{nid}']")+ self.assertIsNotNone(g)+ self.assertEqual(g.find("title").text, "pkg/a.go")+ self.assertIsNotNone(g.find("rect"))+ texts = g.findall("text")+ self.assertEqual(texts[0].text, "pkg/a.go")+ self.assertEqual(texts[1].text, "⚑2")+ for t in texts:+ self.assertIsNotNone(t.get("textLength"))+ self.assertEqual(t.get("lengthAdjust"), "spacingAndGlyphs")+ # Changed nodes are wrapped in a link to the per-file diff anchor.+ a = self.svg.find(f".//a[@href='#{file_anchor('pkg/a.go')}']")+ self.assertIsNotNone(a)+ self.assertIsNotNone(a.find(f"g[@id='{nid}']"))++ def test_badge_only_on_changed_nodes_with_tests(self) -> None:+ for path in ("pkg/b.go", "cmd/main.go", "util/u.go", "pkg/b_test.go"):+ g = self.svg.find(f".//g[@id='n-{digest(path)}']")+ self.assertIsNotNone(g, path)+ self.assertEqual(len(g.findall("text")), 1, path)+ for path in ("cmd/main.go", "util/u.go"):+ self.assertIsNone(self.svg.find(f".//a[@href='#{file_anchor(path)}']"))++ def test_edge_markup(self) -> None:+ src, dst = "n-" + digest("cmd/main.go"), "n-" + digest("pkg/a.go")+ edges = [e for e in self.svg.iter("path") if e.get("class", "").startswith("edge ")]+ self.assertTrue(edges)+ for e in edges:+ self.assertEqual(e.get("class"), f"edge e-{e.get('data-from')} e-{e.get('data-to')}")+ self.assertEqual(e.get("marker-end"), "url(#blast-arrow)")+ self.assertTrue(any(e.get("data-from") == src and e.get("data-to") == dst for e in edges))+ self.assertIsNotNone(self.svg.find(".//marker[@id='blast-arrow']"))++ def test_fills_and_strokes_carry_literal_fallbacks(self) -> None:+ pattern = re.compile(r"^var\(--[a-z0-9-]+, #[0-9A-Fa-f]{6}\)$")+ seen = 0+ for el in self.svg.iter():+ for attr in ("fill", "stroke"):+ value = el.get(attr)+ if value is None or value == "none":+ continue+ seen += 1+ self.assertRegex(value, pattern)+ self.assertGreater(seen, 10)+ for path, var in (("pkg/b.go", "--success,"), ("pkg/a.go", "--accent-2,"),+ ("pkg/gone.go", "--error,"), ("pkg/new.go", "--accent-3,"),+ ("cmd/main.go", "--surface-2,")):+ rect = self.svg.find(f".//g[@id='n-{digest(path)}']/rect")+ self.assertIn(var, rect.get("fill"), path)++ def test_collapsed_node_is_dashed_and_members_listed(self) -> None:+ members = [f"big/f{i}.go" for i in range(4)]+ nid = "n-" + digest("\n".join(members))+ g = self.svg.find(f".//g[@id='{nid}']")+ self.assertIsNotNone(g)+ self.assertIsNotNone(g.find("rect").get("stroke-dasharray"))+ self.assertEqual(g.find("text").text, "big (4 files)")+ self.assertIsNone(self.svg.find(f".//g[@id='n-{digest('pkg/a.go')}']/rect").get("stroke-dasharray"))+ ul = re.search(r'<ul class="blast-members">(.*?)</ul>', self.html, re.DOTALL)+ self.assertIsNotNone(ul)+ positions = [ul.group(1).index(m) for m in members]+ self.assertEqual(positions, sorted(positions))++ def test_skipped_list(self) -> None:+ self.assertIn('<ul class="blast-skipped">', self.html)+ self.assertIn("vendor/huge.go", self.html)+ self.assertIn("blob over 1 MB", self.html)++ def test_hover_rules_per_node_in_style_element(self) -> None:+ style = re.search(r"<style>(.*?)</style>", self.html, re.DOTALL).group(1)+ for g in self.svg.iter("g"):+ nid = g.get("id")+ if nid and nid.startswith("n-"):+ self.assertIn(f".blast:has(#{nid}:hover) .edge:not(.e-{nid}){{opacity:.15}}", style)+ self.assertIn(f".blast:has(#{nid}:hover) .edge.e-{nid}{{stroke-width:2}}", style)++ def test_package_granularity_note_under_header(self) -> None:+ # Dependents (cmd/main.go) and the centre (pkg/b_test.go → pkg/a.go)+ # carry package edges; dependencies has only a file edge.+ texts = [t.text for t in self.svg.iter("text")]+ self.assertEqual(texts.count("edges at package granularity"), 2)++ def test_every_text_declares_textlength_within_its_box(self) -> None:+ for g in self.svg.iter("g"):+ rect = g.find("rect")+ if rect is None:+ continue+ w = float(rect.get("width"))+ for t in g.findall("text"):+ self.assertLessEqual(float(t.get("textLength")) + 2 * PAD, w + 1e-6, t.text)+ for t in self.svg.iter("text"):+ self.assertIsNotNone(t.get("textLength"), t.text)+ self.assertLessEqual(float(t.get("textLength")) + 2 * PAD, COL_W + 1e-6, t.text)++ def test_legend_swatches(self) -> None:+ legend = re.search(r'<div class="blast-legend">(.*?)</div>', self.html, re.DOTALL).group(1)+ for name in ("added", "modified", "deleted", "renamed", "unchanged", "collapsed"):+ self.assertIn(f"blast-swatch-{name}", legend)+++class RenderDiagramColumnStatusTest(unittest.TestCase):+ def test_failed_column_shows_reason_in_place(self) -> None:+ d = desc([("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False)],+ [("u.py", "a.py", "file")],+ {"dependents": "failed: no import patterns for .py", "dependencies": "complete"})+ html, _ = render_section(d)+ svg = svg_of(html)+ self.assertIsNone(svg.find(f".//g[@id='n-{digest('u.py')}']"))+ texts = " ".join(t.text or "" for t in svg.iter("text"))+ self.assertIn("no import patterns for .py", texts)++ def test_partial_column_shows_nodes_and_reason(self) -> None:+ d = desc([("a.py", "modified", "pkg", False), ("u.py", "unchanged", "pkg", False)],+ [("u.py", "a.py", "file")],+ {"dependents": "partial: remote scan cap reached", "dependencies": "complete"})+ html, _ = render_section(d)+ svg = svg_of(html)+ self.assertIsNotNone(svg.find(f".//g[@id='n-{digest('u.py')}']"))+ texts = " ".join(t.text or "" for t in svg.iter("text"))+ self.assertIn("remote scan cap reached", texts)++ def test_empty_complete_column_says_so(self) -> None:+ html, _ = render_section(desc([("a.py", "modified", "pkg", False)], []))+ texts = [t.text for t in svg_of(html).iter("text")]+ self.assertEqual(texts.count("none found"), 2)+++class RenderDiagramEscapingTest(unittest.TestCase):+ def test_special_characters_render_literally(self) -> None:+ nasty = 'src/a<b>&"$x.py'+ d = desc([(nasty, "modified", 'g<&"$', False), ("u<&.py", "unchanged", 'g<&"$', False)],+ [("u<&.py", nasty, "file")],+ skipped=[{"path": "s<&\"$.py", "reason": "r<&"}])+ html, warnings = render_section(d)+ self.assertEqual(warnings, [])+ svg = svg_of(html)+ g = svg.find(f".//g[@id='n-{digest(nasty)}']")+ self.assertEqual(g.find("title").text, nasty)+ self.assertEqual(g.find("text").text, nasty)+ self.assertIn("<b>&"$x.py", html)+ self.assertIn('g<&"$', html)+ self.assertIn("s<&"$.py", html)+ self.assertNotIn("<b>", html)++ def test_long_path_is_shortened_with_full_title(self) -> None:+ long = "very/" * 12 + "deep/file.py"+ html, _ = render_section(desc([(long, "added", "very", False)], []))+ g = svg_of(html).find(f".//g[@id='n-{digest(long)}']")+ self.assertEqual(g.find("title").text, long)+ label = g.find("text").text+ self.assertEqual(len(label), CENTRE_BUDGET)+ self.assertTrue(label.startswith("…"))+ self.assertTrue(long.endswith(label[1:]))+++class RenderDiagramInvalidTest(unittest.TestCase):+ def test_invalid_description_warns_and_returns_empty(self) -> None:+ for bad in ([], {"nodes": "x"}, {"nodes": [], "edges": [{"from": 1}]}):+ with self.subTest(bad=bad):+ html, warnings = render_section(bad)+ self.assertEqual(html, "")+ self.assertEqual(len(warnings), 1)+ self.assertIn("diagram", warnings[0])+++class DiagramDeterminismTest(unittest.TestCase):+ def test_same_description_renders_identical_bytes(self) -> None:+ rng = random.Random(99)+ for _ in range(20):+ d = random_description(rng)+ a, _ = render_section(d)+ b, _ = render_section(json.loads(json.dumps(d)))+ self.assertEqual(a, b)+++class RenderWiringTest(unittest.TestCase):+ def setUp(self) -> None:+ self._tmp = tempfile.TemporaryDirectory()+ self.dir = Path(self._tmp.name)+ self.stderr = io.StringIO()+ self._redirect = contextlib.redirect_stderr(self.stderr)+ self._redirect.__enter__()++ def tearDown(self) -> None:+ self._redirect.__exit__(None, None, None)+ self._tmp.cleanup()++ def base_data(self) -> dict:+ return {+ "repo": {"name": "x/y", "path": "/tmp/y"},+ "title": "t",+ "unresolved_comments": [{"author": "a", "type": "review", "body": "hi"}],+ "files": [{"path": "pkg/a.go", "badge": "Modified", "stat": "+1 / -1", "diff": "+x\n"}],+ }++ def test_diagram_file_renders_section_and_toc_before_diffs(self) -> None:+ (self.dir / "diagram.json").write_text(json.dumps(sample()), encoding="utf-8")+ data = self.base_data()+ data["diagram_file"] = "diagram.json"+ html = render(data, self.dir)+ self.assertIn('<section id="diagram">', html)+ self.assertIn('<li><a href="#diagram">Blast radius</a></li>', html)+ self.assertLess(html.index('<section id="unresolved-comments">'), html.index('<section id="diagram">'))+ self.assertLess(html.index('<section id="diagram">'), html.index('<section id="diffs">'))+ self.assertEqual(self.stderr.getvalue(), "")+ self.assertIn(".blast-scroll", html)++ def test_missing_diagram_file_warns_and_omits(self) -> None:+ data = self.base_data()+ data["diagram_file"] = "absent.json"+ html = render(data, self.dir)+ self.assertNotIn('id="diagram"', html)+ self.assertNotIn("Blast radius", html)+ self.assertIn("warning:", self.stderr.getvalue())+ self.assertIn("absent.json", self.stderr.getvalue())++ def test_invalid_json_warns_and_omits(self) -> None:+ (self.dir / "diagram.json").write_text("{not json", encoding="utf-8")+ data = self.base_data()+ data["diagram_file"] = "diagram.json"+ html = render(data, self.dir)+ self.assertNotIn('id="diagram"', html)+ self.assertIn("diagram.json", self.stderr.getvalue())++ def test_docs_only_suppresses_without_warning(self) -> None:+ (self.dir / "diagram.json").write_text(json.dumps(sample()), encoding="utf-8")+ data = self.base_data()+ data["diagram_file"] = "diagram.json"+ data["change_classification"] = "docs-only"+ html = render(data, self.dir)+ self.assertNotIn('id="diagram"', html)+ self.assertEqual(self.stderr.getvalue(), "")++ def test_no_diagram_file_is_silent(self) -> None:+ html = render(self.base_data(), self.dir)+ self.assertNotIn('id="diagram"', html)+ self.assertEqual(self.stderr.getvalue(), "")++ def test_entry_point_exits_zero_on_missing_description(self) -> None:+ data = self.base_data()+ data["diagram_file"] = "absent.json"+ (self.dir / "review.json").write_text(json.dumps(data), encoding="utf-8")+ out = self.dir / "review.html"+ result = subprocess.run(+ [sys.executable, "scripts/build_review_html.py",+ "--data", str(self.dir / "review.json"), "--output", str(out)],+ cwd=REPO_ROOT, capture_output=True, text=True,+ )+ self.assertEqual(result.returncode, 0, result.stderr)+ self.assertIn("warning:", result.stderr)+ self.assertIn("absent.json", result.stderr)+ self.assertNotIn('id="diagram"', out.read_text(encoding="utf-8"))++ def test_entry_point_exits_two_on_malformed_review_json(self) -> None:+ (self.dir / "review.json").write_text('{"repo": ', encoding="utf-8")+ out = self.dir / "review.html"+ result = subprocess.run(+ [sys.executable, "scripts/build_review_html.py",+ "--data", str(self.dir / "review.json"), "--output", str(out)],+ cwd=REPO_ROOT, capture_output=True, text=True,+ )+ self.assertEqual(result.returncode, 2, result.stderr)+ lines = result.stderr.splitlines()+ self.assertEqual(len(lines), 1, result.stderr)+ self.assertTrue(lines[0].startswith("error: "), lines[0])+ self.assertIn("review.json", lines[0])+ self.assertFalse(out.exists())+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_diffs.py b/scripts/tests/test_diffs.pynew file mode 100644index 0000000..94046cc--- /dev/null+++ b/scripts/tests/test_diffs.py@@ -0,0 +1,239 @@+"""Tests for review_html.diffs: fragment loading, hunk parsing, rendering."""+from __future__ import annotations++import contextlib+import io+import tempfile+import unittest+from pathlib import Path++from review_html.common import escape+from review_html.diffs import added_lines, is_binary, load_fragments, render_diff+from review_html.warnings import Warnings++TWO_HUNKS = """\+diff --git a/pkg/a.go b/pkg/a.go+index 1111111..2222222 100644+--- a/pkg/a.go++++ b/pkg/a.go+@@ -1,4 +1,5 @@+ package pkg++++import "fmt"+ +-func A() {}++func A() { fmt.Println("a") }+@@ -20,3 +21,4 @@ func Z() {+ x := 1++ y := 2+ _ = x++ _ = y+"""++RENAME = """\+diff --git a/old/name.py b/new/name.py+similarity index 88%+rename from old/name.py+rename to new/name.py+--- a/old/name.py++++ b/new/name.py+@@ -10,4 +10,5 @@ def f():+ a = 1+- b = 2++ b = 3++ c = 4+ return a+"""++NO_NEWLINE = """\+--- a/x.txt++++ b/x.txt+@@ -1,2 +1,2 @@+ keep+-old+\\ No newline at end of file++new+\\ No newline at end of file+"""++DEV_NULL = """\+diff --git a/dev/null b/notes.md+new file mode 100644+index 0000000..3333333+--- /dev/null++++ b/notes.md+@@ -0,0 +1,3 @@++# Notes++++- first+"""++DELETED = """\+--- a/gone.py++++ /dev/null+@@ -1,2 +0,0 @@+-a = 1+-b = 2+"""+++def legacy_render_diff(diff: str) -> str:+ """The renderer's _render_diff as it stood before diffs.py existed."""+ if not diff:+ return ""+ lines = diff.split("\n")+ if lines and lines[-1] == "":+ lines.pop()+ spans = []+ for line in lines:+ if line.startswith(("+++", "---")):+ cls = "diff-file-header"+ elif line.startswith("@@"):+ cls = "diff-hunk"+ elif line.startswith("+"):+ cls = "diff-add"+ elif line.startswith("-"):+ cls = "diff-del"+ elif line.startswith("\\"):+ cls = "diff-meta"+ else:+ cls = "diff-context"+ spans.append(f'<span class="diff-line {cls}">{escape(line)}</span>')+ return "".join(spans)+++class AddedLinesTest(unittest.TestCase):+ def test_multiple_hunks(self) -> None:+ self.assertEqual(added_lines(TWO_HUNKS), {2, 3, 5, 22, 24})++ def test_hunk_header_without_counts(self) -> None:+ diff = "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n"+ self.assertEqual(added_lines(diff), {1})++ def test_rename(self) -> None:+ self.assertEqual(added_lines(RENAME), {11, 12})++ def test_no_newline_marker_does_not_advance(self) -> None:+ self.assertEqual(added_lines(NO_NEWLINE), {2})++ def test_dev_null_fragment_from_no_index(self) -> None:+ self.assertEqual(added_lines(DEV_NULL), {1, 2, 3})++ def test_deleted_file_has_no_added_lines(self) -> None:+ self.assertEqual(added_lines(DELETED), set())++ def test_empty_and_placeholder(self) -> None:+ self.assertEqual(added_lines(""), set())+ self.assertEqual(added_lines("(diff fragment 'x' missing)"), set())++ def test_added_line_starting_with_plus_plus_is_not_a_header(self) -> None:+ self.assertEqual(added_lines("@@ -1,2 +1,4 @@\n a\n+++i;\n+b\n c\n"), {2, 3})++ def test_second_file_section_resets_the_counter(self) -> None:+ diff = ("diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1,2 @@\n a\n+b\n"+ "diff --git a/y b/y\n--- a/y\n+++ b/y\n@@ -5 +5,2 @@\n c\n+d\n")+ self.assertEqual(added_lines(diff), {2, 6})+++class IsBinaryTest(unittest.TestCase):+ def test_binary_files_differ(self) -> None:+ self.assertTrue(is_binary(+ "diff --git a/logo.png b/logo.png\n"+ "Binary files a/logo.png and b/logo.png differ\n"))++ def test_git_binary_patch(self) -> None:+ self.assertTrue(is_binary(+ "diff --git a/logo.png b/logo.png\nindex 111..222\nGIT binary patch\nliteral 10\n"))++ def test_text_diff_is_not_binary(self) -> None:+ self.assertFalse(is_binary(TWO_HUNKS))+ self.assertFalse(is_binary("+Binary files are mentioned in this added line\n"))+++class LoadFragmentsTest(unittest.TestCase):+ def setUp(self) -> None:+ self._tmp = tempfile.TemporaryDirectory()+ self.dir = Path(self._tmp.name)+ self.warnings = Warnings()+ self._stderr = contextlib.redirect_stderr(io.StringIO())+ self._stderr.__enter__()++ def tearDown(self) -> None:+ self._stderr.__exit__(None, None, None)+ self._tmp.cleanup()++ def test_inline_diff_wins_over_diff_file(self) -> None:+ (self.dir / "a.diff").write_text("+from file\n", encoding="utf-8")+ files = [{"path": "a.py", "diff": "+inline\n", "diff_file": "a.diff"}]+ self.assertEqual(load_fragments(files, self.dir, self.warnings), {"a.py": "+inline\n"})++ def test_reads_diff_file_from_diff_dir(self) -> None:+ (self.dir / "b.diff").write_text(TWO_HUNKS, encoding="utf-8")+ files = [{"path": "pkg/a.go", "diff_file": "b.diff"}]+ self.assertEqual(load_fragments(files, self.dir, self.warnings), {"pkg/a.go": TWO_HUNKS})+ self.assertEqual(self.warnings.items, [])++ def test_missing_file_yields_placeholder(self) -> None:+ files = [{"path": "c.py", "diff_file": "nope.diff"}]+ self.assertEqual(+ load_fragments(files, self.dir, self.warnings),+ {"c.py": "(diff fragment 'nope.diff' missing)"},+ )++ def test_non_utf8_yields_placeholder_and_warning(self) -> None:+ (self.dir / "bad.diff").write_bytes(b"+caf\xe9\n")+ files = [{"path": "d.py", "diff_file": "bad.diff"}]+ self.assertEqual(+ load_fragments(files, self.dir, self.warnings),+ {"d.py": "(diff fragment 'bad.diff' is not UTF-8)"},+ )+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("bad.diff", self.warnings.items[0])++ def test_no_source_yields_no_diff_provided(self) -> None:+ files = [{"path": "e.py"}, {"path": "f.py", "diff_file": "f.diff"}]+ self.assertEqual(+ load_fragments(files, None, self.warnings),+ {"e.py": "(no diff provided)", "f.py": "(no diff provided)"},+ )+++class RenderDiffTest(unittest.TestCase):+ def test_none_matches_legacy_output(self) -> None:+ for diff in (TWO_HUNKS, RENAME, NO_NEWLINE, DEV_NULL, DELETED, "", "+x\n", "+x"):+ with self.subTest(diff=diff[:20]):+ self.assertEqual(render_diff(diff, None), legacy_render_diff(diff))++ def test_uncovered_marks_matching_added_lines_only(self) -> None:+ html = render_diff(TWO_HUNKS, {2, 5, 22, 99})+ spans = html.split("</span>")[:-1]+ classes = [s.split('class="')[1].split('"')[0] for s in spans]+ self.assertEqual(classes.count("diff-line diff-add diff-uncovered"), 3)+ self.assertEqual(classes.count("diff-line diff-add"), 2)+ # The marked lines are exactly the requested new-file numbers.+ marked = [s for s in spans if "diff-uncovered" in s]+ self.assertTrue(marked[0].endswith(">+"))+ self.assertIn("fmt.Println", marked[1])+ self.assertIn("y := 2", marked[2])+ for cls in classes:+ if "diff-uncovered" in cls:+ self.assertIn("diff-add", cls)+ self.assertNotIn("diff-del diff-uncovered", html)+ self.assertNotIn("diff-context diff-uncovered", html)++ def test_empty_uncovered_set_matches_none(self) -> None:+ self.assertEqual(render_diff(TWO_HUNKS, set()), render_diff(TWO_HUNKS, None))++ def test_plus_plus_inside_hunk_renders_as_added_line(self) -> None:+ # The legacy renderer classed this line as a file header; the new+ # walk classes it by its position, which is the correct rendering.+ diff = "@@ -1,2 +1,4 @@\n a\n+++i;\n+b\n c\n"+ html = render_diff(diff, None)+ self.assertIn('<span class="diff-line diff-add">+++i;</span>', html)+ self.assertNotIn("diff-file-header", html)+ self.assertIn('<span class="diff-line diff-file-header">+++ b/f</span>',+ render_diff("--- a/f\n+++ b/f\n@@ -1 +1 @@\n+x\n", None))+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_ecosystems.py b/scripts/tests/test_ecosystems.pynew file mode 100644index 0000000..72e2fd4--- /dev/null+++ b/scripts/tests/test_ecosystems.py@@ -0,0 +1,200 @@+"""Schema checks for scripts/ecosystems.json.++The file is read by ``blast_radius.py`` (extensions, test files, imports,+units, tools) and by the review skills (runners, notes). Both sets of keys+are validated here so a row that renders diagrams but breaks recipe+selection, or the reverse, fails ``make test``.+"""+from __future__ import annotations++import json+import re+import unittest+from pathlib import Path++ECOSYSTEMS = Path(__file__).resolve().parents[1] / "ecosystems.json"++UNIT_KINDS = {"directory", "target_root", "module_file"}+RESOLVERS = {"relative", "roots", "unit"}+TOOL_FORMATS = {"go-list-json", "pairs"}+COVERAGE_FORMATS = {"lcov", "cobertura", "coverprofile"}+PLACEHOLDERS = {"junit", "coverage", "inputs"}+DETECT_KEYS = {"files", "package_json_keys"}+ROWS_WITH_RUNNERS = {"go", "python", "typescript", "swift", "rust"}++_PLACEHOLDER = re.compile(r"\{(\w+)\}")+++def _is_str_list(value: object) -> bool:+ return isinstance(value, list) and all(isinstance(v, str) and v for v in value)+++class EcosystemsSchemaTest(unittest.TestCase):+ @classmethod+ def setUpClass(cls) -> None:+ cls.data = json.loads(ECOSYSTEMS.read_text(encoding="utf-8"))++ def rows(self):+ for name, row in self.data.items():+ with self.subTest(row=name):+ yield name, row++ # --- keys the script reads ---------------------------------------------++ def test_file_is_an_object_of_rows(self) -> None:+ self.assertIsInstance(self.data, dict)+ self.assertTrue(self.data)+ for name, row in self.rows():+ self.assertIsInstance(row, dict, name)++ def test_required_script_keys(self) -> None:+ for name, row in self.rows():+ self.assertTrue(_is_str_list(row.get("extensions")) and row["extensions"], name)+ for ext in row["extensions"]:+ self.assertTrue(ext.startswith("."), f"{name}: extension {ext!r} lacks a dot")+ self.assertTrue(_is_str_list(row.get("test_files")) and row["test_files"], name)+ self.assertIn("unit", row, name)+ self.assertTrue("imports" in row or "notes" in row,+ f"{name}: needs imports or notes explaining their absence")++ def test_extensions_are_unique_across_rows(self) -> None:+ seen = {}+ for name, row in self.data.items():+ for ext in row["extensions"]:+ self.assertNotIn(ext, seen, f"{ext} claimed by both {seen.get(ext)} and {name}")+ seen[ext] = name++ def test_unit_rules(self) -> None:+ for name, row in self.rows():+ unit = row["unit"]+ self.assertIsInstance(unit, dict, name)+ self.assertIn(unit.get("kind"), UNIT_KINDS, name)+ if unit["kind"] == "module_file":+ self.assertIsInstance(unit.get("module_file"), str, name)+ re.compile(unit["module_regex"])+ if unit["kind"] == "target_root":+ self.assertIsInstance(unit.get("target_root"), str, name)++ def test_every_regex_compiles(self) -> None:+ for name, row in self.rows():+ for pattern in row["test_files"]:+ re.compile(pattern)+ if row.get("test_decl"):+ re.compile(row["test_decl"], re.MULTILINE)+ for spec in row.get("imports", []):+ re.compile(spec["regex"], re.MULTILINE)++ def test_test_decl_has_at_most_one_group(self) -> None:+ for name, row in self.rows():+ if row.get("test_decl"):+ self.assertLessEqual(re.compile(row["test_decl"]).groups, 1, name)++ def test_import_specs(self) -> None:+ for name, row in self.rows():+ imports = row.get("imports", [])+ self.assertIsInstance(imports, list, name)+ for spec in imports:+ self.assertIsInstance(spec, dict, name)+ self.assertIsInstance(spec.get("regex"), str, name)+ self.assertIn(spec.get("resolve", "relative"), RESOLVERS, name)+ if "separator" in spec:+ self.assertIsInstance(spec["separator"], str, name)+ if spec.get("resolve") == "roots":+ self.assertTrue(_is_str_list(row.get("source_roots")), f"{name}: roots needs source_roots")++ def test_optional_resolver_inputs(self) -> None:+ for name, row in self.rows():+ for key in ("source_roots", "index_files"):+ if key in row:+ self.assertTrue(_is_str_list(row[key]), f"{name}.{key}")+ if "extension_map" in row:+ self.assertIsInstance(row["extension_map"], dict, name)+ for ext, alternatives in row["extension_map"].items():+ self.assertTrue(ext.startswith("."), f"{name}: {ext}")+ self.assertTrue(_is_str_list(alternatives), f"{name}: {ext}")++ def test_tool_rows(self) -> None:+ for name, row in self.rows():+ tool = row.get("tool")+ if tool is None:+ continue+ self.assertIsInstance(tool, dict, name)+ self.assertIsInstance(tool.get("name"), str, name)+ self.assertIsInstance(tool.get("deps"), str, name)+ self.assertIn(tool.get("format"), TOOL_FORMATS, name)+ self.assertIn(tool.get("granularity"), {"file", "package"}, name)++ def test_notes_are_strings(self) -> None:+ for name, row in self.rows():+ if "notes" in row:+ self.assertTrue(_is_str_list(row["notes"]), name)++ # --- keys the agent reads ----------------------------------------------++ def test_known_rows_declare_runners(self) -> None:+ for name in sorted(ROWS_WITH_RUNNERS):+ with self.subTest(row=name):+ self.assertIn(name, self.data)+ self.assertTrue(self.data[name].get("runners"), f"{name}: no runners")++ def test_runner_shape(self) -> None:+ for name, row in self.rows():+ runners = row.get("runners", [])+ self.assertIsInstance(runners, list, name)+ names = [r.get("name") for r in runners]+ self.assertEqual(len(names), len(set(names)), f"{name}: duplicate runner names")+ for runner in runners:+ label = f"{name}/{runner.get('name')}"+ self.assertIsInstance(runner, dict, label)+ self.assertTrue(isinstance(runner.get("name"), str) and runner["name"], label)+ self.assertIsInstance(runner.get("recipe"), str, label)+ self.assertTrue(runner["recipe"].strip(), label)+ self.assertTrue(_is_str_list(runner.get("requires")) and runner["requires"], label)+ self.assertIn(runner.get("coverage_format"), COVERAGE_FORMATS, label)+ self.assertIsInstance(runner.get("install"), str, label)+ self.assertTrue(_is_str_list(runner.get("junit_flags")) and runner["junit_flags"], label)+ # Alternate spellings are allowed, but the recipe must use one of them.+ self.assertTrue(any(flag in runner["recipe"] for flag in runner["junit_flags"]),+ f"{label}: none of junit_flags appear in its own recipe")++ def test_runner_detection_rules(self) -> None:+ for name, row in self.rows():+ for runner in row.get("runners", []):+ label = f"{name}/{runner.get('name')}"+ detect = runner.get("detect")+ self.assertIsInstance(detect, dict, label)+ self.assertTrue(set(detect) & DETECT_KEYS, f"{label}: detect needs files or package_json_keys")+ self.assertFalse(set(detect) - DETECT_KEYS, f"{label}: unknown detect keys")+ for key, value in detect.items():+ self.assertTrue(_is_str_list(value) and value, f"{label}: detect.{key}")++ def test_recipes_use_only_known_placeholders(self) -> None:+ for name, row in self.rows():+ for runner in row.get("runners", []):+ label = f"{name}/{runner.get('name')}"+ texts = [runner["recipe"], runner.get("install", "")]+ texts += list((runner.get("env") or {}).values())+ texts += list((runner.get("config_files") or {}).values())+ for text in texts:+ used = set(_PLACEHOLDER.findall(text))+ self.assertFalse(used - PLACEHOLDERS, f"{label}: unknown placeholders {used - PLACEHOLDERS}")+ # {junit} must land somewhere: the recipe, an env value, or a config template.+ self.assertIn("{junit}", " ".join(texts), f"{label}: recipe never names {{junit}}")++ def test_env_and_config_files_are_objects_of_strings(self) -> None:+ for name, row in self.rows():+ for runner in row.get("runners", []):+ label = f"{name}/{runner.get('name')}"+ for key in ("env", "config_files"):+ if key in runner:+ self.assertIsInstance(runner[key], dict, f"{label}.{key}")+ for k, v in runner[key].items():+ self.assertIsInstance(k, str, f"{label}.{key}")+ self.assertIsInstance(v, str, f"{label}.{key}.{k}")+ for template in (runner.get("config_files") or {}):+ self.assertIn(template, runner["recipe"],+ f"{label}: config file {template!r} is written but never passed")+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_golden.py b/scripts/tests/test_golden.pynew file mode 100644index 0000000..b81ae87--- /dev/null+++ b/scripts/tests/test_golden.py@@ -0,0 +1,55 @@+"""Golden-fixture regression test for the review renderer.++``fixtures/golden.html`` was produced once by the renderer as it existed at+commit ``9da40cf`` (``git show 9da40cf:scripts/build_review_html.py``) from+``fixtures/golden.json``. The current renderer, invoked as a script by its+repo-relative path, must produce the same document apart from the contents+of the ``<style>`` element and the generation timestamp in the footer.+"""+from __future__ import annotations++import re+import subprocess+import sys+import tempfile+import unittest+from pathlib import Path++REPO_ROOT = Path(__file__).resolve().parents[2]+FIXTURES = Path(__file__).resolve().parent / "fixtures"+ENTRY_POINT = "scripts/build_review_html.py"++_STYLE = re.compile(r"<style>.*?</style>", re.DOTALL)+_GENERATED = re.compile(r"^\s*Generated .*$", re.MULTILINE)+++def normalise(document: str) -> str:+ document = _STYLE.sub("<style></style>", document)+ return _GENERATED.sub("Generated <normalised>", document)+++class GoldenFixtureTest(unittest.TestCase):+ def test_current_renderer_matches_golden(self) -> None:+ expected = (FIXTURES / "golden.html").read_text(encoding="utf-8")+ with tempfile.TemporaryDirectory() as tmp:+ output = Path(tmp) / "golden.html"+ result = subprocess.run(+ [sys.executable, ENTRY_POINT,+ "--data", str(FIXTURES / "golden.json"),+ "--output", str(output)],+ cwd=REPO_ROOT, capture_output=True, text=True,+ )+ self.assertEqual(result.returncode, 0, result.stderr)+ actual = output.read_text(encoding="utf-8")+ self.assertEqual(normalise(actual), normalise(expected))++ def test_normalise_drops_only_style_and_timestamp(self) -> None:+ page = "<style>a{}</style>\n<p>x</p>\n Generated 2026-01-01 · repo\n"+ self.assertEqual(+ normalise(page),+ "<style></style>\n<p>x</p>\nGenerated <normalised>\n",+ )+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_inputs.py b/scripts/tests/test_inputs.pynew file mode 100644index 0000000..dc4bcaf--- /dev/null+++ b/scripts/tests/test_inputs.py@@ -0,0 +1,97 @@+"""Tests for review_html.inputs.read_guarded and review_html.warnings.Warnings."""+from __future__ import annotations++import contextlib+import io+import tempfile+import unittest+from pathlib import Path++from review_html.inputs import MAX_INPUT_BYTES, read_guarded+from review_html.warnings import Warnings+++class WarningsTest(unittest.TestCase):+ def test_add_appends_and_prints_to_stderr_immediately(self) -> None:+ warnings = Warnings()+ stderr = io.StringIO()+ with contextlib.redirect_stderr(stderr):+ warnings.add("first thing")+ self.assertEqual(stderr.getvalue(), "warning: first thing\n")+ warnings.add("second thing")+ self.assertEqual(stderr.getvalue(), "warning: first thing\nwarning: second thing\n")+ self.assertEqual(warnings.items, ["first thing", "second thing"])++ def test_instances_do_not_share_items(self) -> None:+ with contextlib.redirect_stderr(io.StringIO()):+ a = Warnings()+ a.add("x")+ b = Warnings()+ self.assertEqual(b.items, [])+++class ReadGuardedTest(unittest.TestCase):+ def setUp(self) -> None:+ self._tmp = tempfile.TemporaryDirectory()+ self.dir = Path(self._tmp.name)+ self.warnings = Warnings()+ self._stderr = contextlib.redirect_stderr(io.StringIO())+ self._stderr.__enter__()++ def tearDown(self) -> None:+ self._stderr.__exit__(None, None, None)+ self._tmp.cleanup()++ def test_returns_text_for_utf8_file(self) -> None:+ path = self.dir / "ok.txt"+ path.write_text("héllo\n", encoding="utf-8")+ self.assertEqual(read_guarded(path, self.warnings), "héllo\n")+ self.assertEqual(self.warnings.items, [])++ def test_rejects_file_over_50_mb_by_stat(self) -> None:+ path = self.dir / "huge.xml"+ with path.open("wb") as fh:+ fh.seek(MAX_INPUT_BYTES) # sparse: one byte past the limit+ fh.write(b"\0")+ self.assertEqual(path.stat().st_size, MAX_INPUT_BYTES + 1)+ self.assertIsNone(read_guarded(path, self.warnings, xml=True))+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("huge.xml", self.warnings.items[0])+ self.assertIn("50 MB", self.warnings.items[0])++ def test_rejects_doctype_in_first_64_kb_when_xml(self) -> None:+ path = self.dir / "evil.xml"+ padding = "<!-- " + "x" * 60_000 + " -->\n"+ path.write_text(+ '<?xml version="1.0"?>\n' + padding ++ '<!DOCTYPE lolz [<!ENTITY lol "lol">]>\n<testsuites/>\n',+ encoding="utf-8",+ )+ self.assertIsNone(read_guarded(path, self.warnings, xml=True))+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("evil.xml", self.warnings.items[0])+ self.assertIn("DOCTYPE", self.warnings.items[0])++ def test_doctype_is_not_scanned_when_not_xml(self) -> None:+ path = self.dir / "page.html"+ path.write_text("<!DOCTYPE html>\n<p>hi</p>\n", encoding="utf-8")+ self.assertEqual(read_guarded(path, self.warnings), "<!DOCTYPE html>\n<p>hi</p>\n")+ self.assertEqual(self.warnings.items, [])++ def test_returns_none_with_warning_for_non_utf8(self) -> None:+ path = self.dir / "latin1.diff"+ path.write_bytes(b"+caf\xe9\n")+ self.assertIsNone(read_guarded(path, self.warnings))+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("latin1.diff", self.warnings.items[0])+ self.assertIn("UTF-8", self.warnings.items[0])++ def test_returns_none_with_warning_for_missing_file(self) -> None:+ path = self.dir / "absent.txt"+ self.assertIsNone(read_guarded(path, self.warnings))+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("absent.txt", self.warnings.items[0])+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_junit.py b/scripts/tests/test_junit.pynew file mode 100644index 0000000..1102e43--- /dev/null+++ b/scripts/tests/test_junit.py@@ -0,0 +1,221 @@+"""Tests for review_html.junit.parse_junit."""+from __future__ import annotations++import contextlib+import io+import tempfile+import unittest+from pathlib import Path++from review_html.junit import Case, parse_junit+from review_html.warnings import Warnings++NESTED = """\+<?xml version="1.0" encoding="UTF-8"?>+<testsuites name="all">+ <testsuite name="outer" tests="1">+ <testcase classname="pkg.Outer" name="test_outer" time="0.1"/>+ <testsuite name="inner" tests="3">+ <testcase classname="pkg.Inner" name="test_pass"/>+ <testcase classname="" name="test_no_classname"/>+ <testcase name="test_missing_classname"/>+ </testsuite>+ </testsuite>+</testsuites>+"""++OUTCOMES = """\+<testsuite name="suite" tests="6">+ <testcase classname="c" name="failed_msg">+ <failure message="assert 1 == 2" type="AssertionError">traceback text</failure>+ </testcase>+ <testcase classname="c" name="failed_text">+ <failure type="AssertionError">+ only the body carries the reason+ </failure>+ </testcase>+ <testcase classname="c" name="errored">+ <error message="boom"/>+ </testcase>+ <testcase classname="c" name="skipped">+ <skipped message="not on this platform"/>+ </testcase>+ <testcase classname="c" name="passed"/>+ <testcase classname="c" name="failed_and_error">+ <failure message="first"/>+ <error message="second"/>+ </testcase>+</testsuite>+"""++SUREFIRE = """\+<testsuite name="surefire" tests="3">+ <testcase classname="a.B" name="flaky_failure">+ <flakyFailure message="first attempt failed" type="AssertionError">trace</flakyFailure>+ </testcase>+ <testcase classname="a.B" name="rerun_failure">+ <rerunFailure message="attempt 1"/>+ <rerunFailure message="attempt 2"/>+ </testcase>+ <testcase classname="a.B" name="flaky_error">+ <flakyError message="transient"/>+ </testcase>+ <testcase classname="a.B" name="rerun_error_then_failed">+ <rerunError message="attempt 1"/>+ <failure message="final failure"/>+ </testcase>+</testsuite>+"""++PYTEST_RERUN = """\+<testsuites>+ <testsuite name="pytest" tests="3">+ <testcase classname="tests.test_x" name="test_retry">+ <rerun message="attempt 1 failed">trace</rerun>+ </testcase>+ <testcase classname="tests.test_x" name="test_retry">+ <rerun message="attempt 2 failed">trace</rerun>+ </testcase>+ <testcase classname="tests.test_x" name="test_retry"/>+ <testcase classname="tests.test_x" name="test_stable"/>+ </testsuite>+</testsuites>+"""++DUPLICATE_FAIL_THEN_PASS = """\+<testsuite name="s">+ <testcase classname="c" name="t"><failure message="first"/></testcase>+ <testcase classname="c" name="t"/>+</testsuite>+"""++DUPLICATE_PASS_THEN_FAIL = """\+<testsuite name="s">+ <testcase classname="c" name="t"/>+ <testcase classname="c" name="t"><failure message="last"/></testcase>+</testsuite>+"""++SINGLE = """\+<testsuite name="s">+ <testcase classname="c" name="t"/>+</testsuite>+"""+++class ParseJunitTest(unittest.TestCase):+ def setUp(self) -> None:+ self._tmp = tempfile.TemporaryDirectory()+ self.dir = Path(self._tmp.name)+ self.warnings = Warnings()+ self._stderr = contextlib.redirect_stderr(io.StringIO())+ self._stderr.__enter__()++ def tearDown(self) -> None:+ self._stderr.__exit__(None, None, None)+ self._tmp.cleanup()++ def write(self, name: str, text: str) -> Path:+ path = self.dir / name+ path.write_text(text, encoding="utf-8")+ return path++ def parse(self, *names_and_texts: tuple[str, str]) -> list[Case]:+ paths = [self.write(name, text) for name, text in names_and_texts]+ return parse_junit(paths, self.warnings)++ def by_name(self, cases: list[Case]) -> dict[str, Case]:+ return {c.name: c for c in cases}++ def test_nested_testsuites_and_classname_fallback(self) -> None:+ cases = self.parse(("nested.xml", NESTED))+ self.assertEqual(+ [(c.suite, c.name) for c in cases],+ [("pkg.Outer", "test_outer"),+ ("pkg.Inner", "test_pass"),+ ("inner", "test_no_classname"),+ ("inner", "test_missing_classname")],+ )+ self.assertTrue(all(c.outcome == "passed" and not c.flaky for c in cases))+ self.assertEqual(self.warnings.items, [])++ def test_outcomes_and_messages(self) -> None:+ cases = self.by_name(self.parse(("outcomes.xml", OUTCOMES)))+ self.assertEqual(cases["failed_msg"].outcome, "failed")+ self.assertEqual(cases["failed_msg"].message, "assert 1 == 2")+ self.assertEqual(cases["failed_text"].outcome, "failed")+ self.assertEqual(cases["failed_text"].message, "only the body carries the reason")+ self.assertEqual(cases["errored"].outcome, "errored")+ self.assertEqual(cases["errored"].message, "boom")+ self.assertEqual(cases["skipped"].outcome, "skipped")+ self.assertEqual(cases["passed"].outcome, "passed")+ self.assertEqual(cases["passed"].message, "")+ # failure precedes error; the message is the first failure or error element's+ self.assertEqual(cases["failed_and_error"].outcome, "failed")+ self.assertEqual(cases["failed_and_error"].message, "first")+ self.assertFalse(any(c.flaky for c in cases.values()))++ def test_surefire_flaky_and_rerun_elements(self) -> None:+ cases = self.by_name(self.parse(("surefire.xml", SUREFIRE)))+ for name in ("flaky_failure", "rerun_failure", "flaky_error"):+ self.assertEqual(cases[name].outcome, "passed", name)+ self.assertTrue(cases[name].flaky, name)+ self.assertEqual(cases["rerun_error_then_failed"].outcome, "failed")+ self.assertFalse(cases["rerun_error_then_failed"].flaky)+ self.assertEqual(cases["rerun_error_then_failed"].message, "final failure")++ def test_pytest_rerun_attempts_collapse_to_one_flaky_case(self) -> None:+ cases = self.parse(("pytest.xml", PYTEST_RERUN))+ self.assertEqual([c.name for c in cases], ["test_retry", "test_stable"])+ retry = cases[0]+ self.assertEqual(retry.outcome, "passed")+ self.assertTrue(retry.flaky)+ self.assertFalse(cases[1].flaky)++ def test_duplicate_fail_then_pass_is_one_flaky_passed_case(self) -> None:+ cases = self.parse(("dup.xml", DUPLICATE_FAIL_THEN_PASS))+ self.assertEqual(len(cases), 1)+ self.assertEqual(cases[0].outcome, "passed")+ self.assertTrue(cases[0].flaky)++ def test_duplicate_pass_then_fail_is_failed_not_flaky(self) -> None:+ cases = self.parse(("dup.xml", DUPLICATE_PASS_THEN_FAIL))+ self.assertEqual(len(cases), 1)+ self.assertEqual(cases[0].outcome, "failed")+ self.assertFalse(cases[0].flaky)+ self.assertEqual(cases[0].message, "last")++ def test_identities_across_sources_stay_separate(self) -> None:+ cases = self.parse(("job-a.xml", SINGLE), ("job-b.xml", SINGLE))+ self.assertEqual(len(cases), 2)+ self.assertEqual([c.source for c in cases], ["job-a.xml", "job-b.xml"])+ self.assertEqual({(c.suite, c.name) for c in cases}, {("c", "t")})++ def test_source_is_the_input_file_name(self) -> None:+ cases = self.parse(("123-test-results-ubuntu--junit.xml", OUTCOMES))+ self.assertTrue(all(c.source == "123-test-results-ubuntu--junit.xml" for c in cases))++ def test_malformed_xml_warns_and_skips_that_file(self) -> None:+ cases = self.parse(("bad.xml", "<testsuite><testcase name='x'></testsuite>"),+ ("good.xml", SINGLE))+ self.assertEqual([c.source for c in cases], ["good.xml"])+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("bad.xml", self.warnings.items[0])++ def test_doctype_and_missing_file_are_rejected_with_warnings(self) -> None:+ self.write("evil.xml", "<!DOCTYPE x [<!ENTITY e 'e'>]><testsuite/>")+ cases = parse_junit([self.dir / "evil.xml", self.dir / "absent.xml"], self.warnings)+ self.assertEqual(cases, [])+ self.assertEqual(len(self.warnings.items), 2)+ self.assertIn("DOCTYPE", self.warnings.items[0])+ self.assertIn("absent.xml", self.warnings.items[1])++ def test_unexpected_root_warns(self) -> None:+ cases = self.parse(("cov.xml", "<coverage/>"))+ self.assertEqual(cases, [])+ self.assertEqual(len(self.warnings.items), 1)+ self.assertIn("cov.xml", self.warnings.items[0])+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_redact.py b/scripts/tests/test_redact.pynew file mode 100644index 0000000..6524ede--- /dev/null+++ b/scripts/tests/test_redact.py@@ -0,0 +1,84 @@+"""Tests for review_html.redact: secret patterns and message truncation."""+from __future__ import annotations++import unittest++from review_html.redact import MESSAGE_LIMIT, PATTERNS, clean_message, redact++GH_TOKEN = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" # 36 characters after the prefix+++class RedactTest(unittest.TestCase):+ def test_pattern_count_and_order(self) -> None:+ self.assertEqual(len(PATTERNS), 7)+ self.assertTrue(PATTERNS[0].pattern.startswith("Bearer"))+ self.assertTrue(PATTERNS[-1].pattern.startswith("-----BEGIN"))++ def test_bearer_token(self) -> None:+ self.assertEqual(redact("Authorization: Bearer abc.DEF-123_x~+/== rest"),+ "Authorization: [redacted] rest")++ def test_aws_access_key(self) -> None:+ self.assertEqual(redact("key AKIAIOSFODNN7EXAMPLE used"), "key [redacted] used")++ def test_github_token(self) -> None:+ self.assertEqual(redact(f"token {GH_TOKEN} ok"), "token [redacted] ok")+ self.assertEqual(redact("ghp_short"), "ghp_short")++ def test_slack_token(self) -> None:+ self.assertEqual(redact("xoxb-123-456-abcDEF end"), "[redacted] end")++ def test_aws_secret_access_key_assignment(self) -> None:+ self.assertEqual(redact("AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCY x"),+ "[redacted] x")++ def test_bare_key_assignment(self) -> None:+ self.assertEqual(redact("KEY=abc123 done"), "[redacted] done")++ def test_password_colon(self) -> None:+ self.assertEqual(redact("password: hunter2\nnext"), "[redacted]\nnext")++ def test_quoted_json_key(self) -> None:+ cleaned = redact('{"api_key": "sk-abc123"}')+ self.assertIn("[redacted]", cleaned)+ self.assertNotIn("sk-abc123", cleaned)++ def test_url_with_userinfo(self) -> None:+ self.assertEqual(redact("dial postgres://user:s3cret@db.internal:5432/app failed"),+ "dial [redacted]db.internal:5432/app failed")++ def test_pem_private_key_block(self) -> None:+ text = ("before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEow\nAAA\n"+ "-----END RSA PRIVATE KEY-----\nafter")+ self.assertEqual(redact(text), "before\n[redacted]\nafter")++ def test_plain_text_is_unchanged(self) -> None:+ self.assertEqual(redact("assert 1 == 2 in test_add"), "assert 1 == 2 in test_add")++ def test_patterns_apply_sequentially(self) -> None:+ self.assertEqual(redact("Bearer abc and AKIAIOSFODNN7EXAMPLE"),+ "[redacted] and [redacted]")+++class CleanMessageTest(unittest.TestCase):+ def test_redaction_precedes_truncation(self) -> None:+ text = "x" * 480 + GH_TOKEN + " trailing text " + "y" * 100+ cleaned = clean_message(text)+ self.assertNotIn("ghp_", cleaned)+ self.assertIn("[redacted]", cleaned)+ self.assertLessEqual(len(cleaned), MESSAGE_LIMIT)+ # truncated first, the token would be cut to 16 characters and survive+ self.assertIn("ghp_", text[:MESSAGE_LIMIT])+ self.assertEqual(redact(text[:MESSAGE_LIMIT]), text[:MESSAGE_LIMIT])++ def test_short_message_is_not_truncated(self) -> None:+ self.assertEqual(clean_message("short"), "short")++ def test_long_message_is_capped(self) -> None:+ cleaned = clean_message("z" * 1000)+ self.assertEqual(len(cleaned), MESSAGE_LIMIT)+ self.assertTrue(cleaned.endswith("…"))+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_tests_section.py b/scripts/tests/test_tests_section.pynew file mode 100644index 0000000..5d52311--- /dev/null+++ b/scripts/tests/test_tests_section.py@@ -0,0 +1,486 @@+"""Tests for review_html.tests_section.build_tests and the render() wiring."""+from __future__ import annotations++import contextlib+import copy+import io+import json+import tempfile+import unittest+from pathlib import Path++from review_html import render+from review_html.common import file_anchor+from review_html.diffs import load_fragments+from review_html.tests_section import TestsResult, build_tests+from review_html.warnings import Warnings++HEAD_JUNIT = """\+<testsuite name="s">+ <testcase classname="pkg.A" name="test_ok"/>+ <testcase classname="pkg.A" name="test_new"/>+ <testcase classname="pkg.A" name="test_fail"><failure message="token=s3cr3tvalue boom"/></testcase>+ <testcase classname="pkg.A" name="test_skip"><skipped/></testcase>+ <testcase classname="pkg.A" name="test_flaky"><flakyFailure message="x"/></testcase>+ <testcase classname="pkg.A" name="test_err"><error message="err <here>"/></testcase>+</testsuite>+"""++OTHER_JUNIT = """\+<testsuite name="other">+ <testcase classname="pkg.B" name="test_b"/>+</testsuite>+"""++BASE_JUNIT = """\+<testsuite name="s">+ <testcase classname="pkg.A" name="test_ok"/>+ <testcase classname="pkg.A" name="test_fail"/>+ <testcase classname="pkg.A" name="test_skip"/>+ <testcase classname="pkg.A" name="test_flaky"/>+ <testcase classname="pkg.A" name="test_err"/>+ <testcase classname="pkg.B" name="test_b"/>+ <testcase classname="pkg.A" name="test_removed"/>+</testsuite>+"""++HEAD_LCOV = """\+SF:src/a.py+DA:1,1+DA:2,1+DA:3,0+end_of_record+SF:v1/util.py+DA:1,1+end_of_record+SF:v2/util.py+DA:1,0+end_of_record+SF:src/zero.py+DA:10,1+end_of_record+"""++BASE_LCOV = """\+SF:src/a.py+DA:1,1+DA:2,0+end_of_record+"""+++def diff(path: str, start: int, count: int) -> str:+ return (f"--- a/{path}\n+++ b/{path}\n@@ -1,0 +{start},{count} @@\n"+ + "".join(f"+line {start + i}\n" for i in range(count)))+++FILES = [+ {"path": "src/a.py", "badge": "Modified", "stat": "+3 / -0", "diff": diff("src/a.py", 2, 3)},+ {"path": "src/gone.py", "badge": "Deleted", "stat": "+0 / -3", "diff": "--- a/src/gone.py\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-x\n-y\n-z\n"},+ {"path": "assets/x.png", "badge": "Binary file", "stat": "", "diff": "Binary files a/assets/x.png and b/assets/x.png differ\n"},+ {"path": "src/nocov.py", "badge": "Modified", "stat": "+1 / -0", "diff": diff("src/nocov.py", 1, 1)},+ {"path": "util.py", "badge": "Modified", "stat": "+1 / -0", "diff": diff("util.py", 1, 1)},+ {"path": "src/zero.py", "badge": "Added", "stat": "+1 / -0", "diff": diff("src/zero.py", 1, 1)},+]+++def block() -> dict:+ return {+ "provenance": {"source": "ci", "run_ids": [123], "run_urls": ["https://ci/run/123"],+ "snapshot": {"sha": "abc1234def", "dirty": False},+ "ci_state": "artifacts usable", "fallback_state": "not needed"},+ "baseline_provenance": {"source": "local", "run_id": None, "run_url": None,+ "sha": "base9999", "timestamp": "2026-09-04T10:00:00Z"},+ "coverage_scope": "repository",+ "run_outcome": "failed",+ "partial": True,+ "junit": ["123-test-results-ubuntu--junit.xml", "123-other--junit.xml", "missing.xml"],+ "coverage": ["123-test-results-ubuntu--lcov.info"],+ "baseline_junit": ["base--junit.xml"],+ "baseline_coverage": ["base--lcov.info"],+ "path_map": {"strip": None, "prepend": None},+ "jobs": [{"run_id": 123, "name": "test (ubuntu)", "outcome": "success", "url": "https://ci/job/1"},+ {"run_id": 123, "name": "lint", "outcome": "failure", "url": "https://ci/job/2"}],+ "artifacts": [{"name": "test-results-ubuntu", "run_id": 123,+ "junit": ["123-test-results-ubuntu--junit.xml"],+ "coverage": ["123-test-results-ubuntu--lcov.info"], "job": "test (ubuntu)"},+ {"name": "other", "run_id": 123, "junit": ["123-other--junit.xml"],+ "coverage": []}],+ "pending_runs": [{"run_id": 124, "name": "integration", "status": "in_progress",+ "url": "https://ci/run/124"}],+ "skipped_artifacts": [{"name": "build-output", "size_in_bytes": 412000000}],+ "run_touched_files": ["go.sum"],+ "diff_tests_file": None,+ "no_data_reason": None,+ }+++class SectionCase(unittest.TestCase):+ def setUp(self) -> None:+ self._tmp = tempfile.TemporaryDirectory()+ self.dir = Path(self._tmp.name)+ self._stderr = io.StringIO()+ self._redirect = contextlib.redirect_stderr(self._stderr)+ self._redirect.__enter__()+ (self.dir / "123-test-results-ubuntu--junit.xml").write_text(HEAD_JUNIT, encoding="utf-8")+ (self.dir / "123-other--junit.xml").write_text(OTHER_JUNIT, encoding="utf-8")+ (self.dir / "base--junit.xml").write_text(BASE_JUNIT, encoding="utf-8")+ (self.dir / "123-test-results-ubuntu--lcov.info").write_text(HEAD_LCOV, encoding="utf-8")+ (self.dir / "base--lcov.info").write_text(BASE_LCOV, encoding="utf-8")++ def tearDown(self) -> None:+ self._redirect.__exit__(None, None, None)+ self._tmp.cleanup()++ def build(self, b: dict, files: list[dict] | None = None) -> TestsResult:+ files = copy.deepcopy(FILES if files is None else files)+ warnings = Warnings()+ fragments = load_fragments(files, self.dir, warnings)+ return build_tests(b, files, fragments, self.dir, warnings)++ def assertOrdered(self, html: str, *markers: str) -> None:+ positions = []+ for m in markers:+ self.assertIn(m, html)+ positions.append(html.index(m))+ self.assertEqual(positions, sorted(positions), markers)+++class CardTest(SectionCase):+ def test_no_data_card_shows_na_values_and_section_link(self) -> None:+ b = block()+ b.update(junit=[], coverage=[], baseline_junit=[], baseline_coverage=[],+ no_data_reason="runner not detected", jobs=[], artifacts=[])+ card = self.build(b).card_html+ self.assertIn('<div class="card">', card)+ self.assertIn("<h3>Tests</h3>", card)+ self.assertIn("<p>Pass rate: n/a</p>", card)+ self.assertIn("<p>New tests: n/a</p>", card)+ self.assertIn("<p>Diff coverage: n/a</p>", card)+ self.assertIn('<a href="#tests">Jump to tests →</a>', card)++ def test_card_values_with_data(self) -> None:+ card = self.build(block()).card_html+ self.assertIn("<p>Pass rate: 67% (4 of 6)</p>", card)+ self.assertIn("<p>New tests: 1</p>", card)+ self.assertIn("<p>Diff coverage: 50% (1 of 2 added lines)</p>", card)+++class SectionTest(SectionCase):+ def test_section_order(self) -> None:+ html = self.build(block()).section_html+ self.assertTrue(html.startswith('<section id="tests">'))+ self.assertOrdered(+ html,+ "<h2>Tests</h2>",+ "Source:",+ "Baseline:",+ "Execution:",+ "Coverage scope:",+ "Totals:",+ "<h3>Pending runs</h3>",+ "<h3>Jobs</h3>",+ "<h3>Failed tests</h3>",+ "<h3>New and removed tests</h3>",+ "<h3>Diff coverage</h3>",+ "<h3>Overall coverage</h3>",+ "changed files matched coverage data",+ "<h3>Files touched by the run</h3>",+ "<h3>Skipped artifacts</h3>",+ "<h3>Warnings</h3>",+ )++ def test_provenance_with_ci_link_and_both_states(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("Source: <strong>CI</strong>", html)+ self.assertIn('<a href="https://ci/run/123">run 123</a>', html)+ self.assertIn("<code>abc1234def</code>", html)+ self.assertIn("CI state: <strong>artifacts usable</strong>", html)+ self.assertIn("Fallback: <strong>not needed</strong>", html)+ self.assertIn("Baseline: local run", html)+ self.assertIn("<code>base9999</code>", html)++ def test_local_provenance_shows_timestamp_and_dirty_flag(self) -> None:+ b = block()+ b["provenance"] = {"source": "local", "timestamp": "2026-09-04T09:00:00Z",+ "snapshot": {"sha": "abc", "dirty": True}}+ b["baseline_provenance"] = None+ html = self.build(b).section_html+ self.assertIn("Source: <strong>local run</strong> at 2026-09-04T09:00:00Z", html)+ self.assertIn("<code>abc</code> (dirty working tree)", html)+ self.assertIn("Baseline: none", html)+ self.assertNotIn("CI state:", html)++ def test_availability_line_states_are_independent(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("Execution: <strong>failed</strong> (partial results)", html)+ self.assertIn("JUnit: 2 of 3 files read", html)+ self.assertIn("Coverage: 1 file", html)+ self.assertIn("Baseline: present", html)++ def test_coverage_scope(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("Coverage scope: every test in the repository", html)+ b = block()+ b["coverage_scope"] = "project-configured"+ self.assertIn("Coverage scope: as the project configures it", self.build(b).section_html)++ def test_totals_with_flaky_alongside(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("Totals: <strong>4 passed</strong> · 1 failed · 1 skipped · 1 errored · 1 flaky", html)++ def test_pending_runs(self) -> None:+ html = self.build(block()).section_html+ self.assertIn('<a href="https://ci/run/124">integration</a> (run 124, in_progress)', html)++ def test_job_rows_and_artifact_rows(self) -> None:+ html = self.build(block()).section_html+ self.assertIn('<tr><td><a href="https://ci/job/1">test (ubuntu)</a></td><td>success</td>'+ "<td>3</td><td>1</td><td>1</td><td>1</td></tr>", html)+ self.assertIn('<tr><td><a href="https://ci/job/2">lint</a></td><td>failure</td>'+ "<td>—</td><td>—</td><td>—</td><td>—</td></tr>", html)+ self.assertIn("<tr><td>artifact <code>other</code></td><td>—</td>"+ "<td>1</td><td>0</td><td>0</td><td>0</td></tr>", html)++ def test_no_jobs_or_artifacts_omits_table(self) -> None:+ b = block()+ b["jobs"] = []+ b["artifacts"] = []+ self.assertNotIn("<h3>Jobs</h3>", self.build(b).section_html)++ def test_failed_tests_with_job_or_artifact_and_redacted_message(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("<td>pkg.A</td><td>test_fail</td><td>test (ubuntu)</td><td>[redacted] boom</td>", html)+ self.assertIn("<td>pkg.A</td><td>test_err</td><td>test (ubuntu)</td><td>err <here></td>", html)+ self.assertNotIn("s3cr3tvalue", html)++ def test_failed_test_from_unattributed_artifact_names_the_artifact(self) -> None:+ (self.dir / "123-other--junit.xml").write_text(+ '<testsuite name="o"><testcase classname="pkg.B" name="test_b"><failure message="m"/></testcase></testsuite>',+ encoding="utf-8")+ html = self.build(block()).section_html+ self.assertIn("<td>pkg.B</td><td>test_b</td><td>artifact other</td><td>m</td>", html)++ def test_new_and_removed_by_identity_from_baseline_with_cross_source_note(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("by identity, from the baseline run", html)+ self.assertIn("<ul><li>+ <code>pkg.A</code> test_new</li><li>− <code>pkg.A</code> test_removed</li></ul>", html)+ self.assertIn("crosses sources", html)+ self.assertIn("head from CI, baseline from a local run", html)++ def test_same_source_baseline_has_no_cross_source_note(self) -> None:+ b = block()+ b["baseline_provenance"] = {"source": "ci", "run_id": 120, "run_url": "https://ci/run/120", "sha": "base9999"}+ html = self.build(b).section_html+ self.assertNotIn("crosses sources", html)+ self.assertIn('Baseline: CI <a href="https://ci/run/120">run 120</a>', html)++ def test_new_and_removed_by_name_from_diff_tests_file(self) -> None:+ (self.dir / "diff-tests.json").write_text(+ json.dumps({"added": ["TestFoo", "TestBar"], "removed": ["TestOld"],+ "unpatterned_files": ["spec/foo_spec.rb"]}), encoding="utf-8")+ b = block()+ b["baseline_junit"] = []+ b["diff_tests_file"] = "diff-tests.json"+ result = self.build(b)+ html = result.section_html+ self.assertIn("by declaration name, from the diff", html)+ self.assertIn("<ul><li>+ TestFoo</li><li>+ TestBar</li><li>− TestOld</li></ul>", html)+ self.assertIn("No declaration pattern applies to <code>spec/foo_spec.rb</code>", html)+ self.assertNotIn("crosses sources", html)+ self.assertIn("<p>New tests: 2</p>", result.card_html)++ def test_no_baseline_and_no_diff_tests_says_so(self) -> None:+ b = block()+ b["baseline_junit"] = []+ result = self.build(b)+ self.assertIn("no baseline run and no diff-derived list", result.section_html)+ self.assertIn("<p>New tests: n/a</p>", result.card_html)++ def test_per_file_table(self) -> None:+ html = self.build(block()).section_html+ a = file_anchor("src/a.py")+ self.assertIn(f'<tr><td><a href="#{a}">src/a.py</a></td><td>3</td><td>1</td><td>50%</td></tr>', html)+ for path in ("src/nocov.py", "util.py", "src/zero.py"):+ anchor = file_anchor(path)+ self.assertIn(f'<td><a href="#{anchor}">{path}</a></td><td>1</td><td>—</td><td>no coverage data</td>', html)+ self.assertNotIn("src/gone.py", html.split("<h3>Diff coverage</h3>")[1].split("<h3>Overall coverage</h3>")[0])+ self.assertNotIn("assets/x.png", html)+ self.assertIn("Aggregate diff coverage: <strong>50%</strong> (1 of 2 measurable added lines)", html)++ def test_overall_coverage_with_delta_and_cross_source_note(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("Head <strong>66.7%</strong> (4 of 6 lines) · baseline <strong>50.0%</strong> (1 of 2 lines) · delta <strong>+16.7 pp</strong>", html)+ self.assertIn("baseline coverage comes from a different source", html)++ def test_overall_coverage_head_only(self) -> None:+ b = block()+ b["baseline_coverage"] = []+ html = self.build(b).section_html+ self.assertIn("Head <strong>66.7%</strong> (4 of 6 lines)", html)+ self.assertNotIn("baseline <strong>", html)+ self.assertNotIn("delta", html)++ def test_no_coverage_omits_coverage_subsections(self) -> None:+ b = block()+ b["coverage"] = []+ b["baseline_coverage"] = []+ result = self.build(b)+ html = result.section_html+ self.assertNotIn("<h3>Diff coverage</h3>", html)+ self.assertNotIn("<h3>Overall coverage</h3>", html)+ self.assertNotIn("changed files matched coverage data", html)+ self.assertIn("Coverage: none", html)+ self.assertEqual(result.uncovered, {})+ self.assertEqual(result.counts["matched"], 0)+ self.assertEqual(result.counts["unmatched"], 0)++ def test_unmatched_report(self) -> None:+ result = self.build(block())+ html = result.section_html+ self.assertIn("2 of 4 changed files matched coverage data", html)+ self.assertIn("<li><code>src/nocov.py</code> — no candidate</li>", html)+ self.assertIn("<li><code>util.py</code> — ambiguous</li>", html)+ self.assertEqual(result.counts["matched"], 2)+ self.assertEqual(result.counts["unmatched"], 2)++ def test_uncovered_sets(self) -> None:+ result = self.build(block())+ self.assertEqual(result.uncovered, {"src/a.py": {3}})++ def test_counts(self) -> None:+ result = self.build(block())+ self.assertEqual(result.counts, {"passed": 4, "failed": 1, "errored": 1, "skipped": 1,+ "flaky": 1, "matched": 2, "unmatched": 2})++ def test_touched_files_skipped_artifacts_and_warnings(self) -> None:+ html = self.build(block()).section_html+ self.assertIn("<li><code>go.sum</code></li>", html)+ self.assertIn("<li><code>build-output</code> (412000000 bytes)</li>", html)+ self.assertIn("<h3>Warnings</h3>", html)+ self.assertIn("missing.xml", html.split("<h3>Warnings</h3>")[1])++ def test_path_map_is_applied_before_matching(self) -> None:+ (self.dir / "123-test-results-ubuntu--lcov.info").write_text(+ "SF:/ci/repo/src/a.py\nDA:2,1\nDA:3,0\nend_of_record\nSF:/ci/repo/util.py\nDA:1,1\nend_of_record\n",+ encoding="utf-8")+ b = block()+ b["path_map"] = {"strip": "/ci/repo", "prepend": None}+ result = self.build(b)+ self.assertEqual(result.uncovered, {"src/a.py": {3}})+ self.assertEqual(result.counts["matched"], 2)+++class NoDataCardTest(SectionCase):+ UPLOAD = "must upload a JUnit XML file as an artifact"++ def no_data(self, reason: str | None, ci_state: str = "artifacts usable",+ fallback: str = "not needed") -> str:+ b = block()+ b.update(junit=[], coverage=[], baseline_junit=[], baseline_coverage=[],+ jobs=[], artifacts=[], pending_runs=[], no_data_reason=reason)+ b["provenance"]["ci_state"] = ci_state+ b["provenance"]["fallback_state"] = fallback+ return self.build(b).section_html++ def test_card_uses_warning_border_treatment(self) -> None:+ html = self.no_data("no tests found")+ self.assertIn('<div class="card tests-nodata">', html)+ self.assertIn("<h3>No test results</h3>", html)++ def test_reasons(self) -> None:+ self.assertIn("No tests were found", self.no_data("no tests found"))+ self.assertIn("test runner could not be detected", self.no_data("runner not detected"))+ self.assertIn("required tool is missing", self.no_data("required tool missing"))+ self.assertIn("local test run failed", self.no_data("local run failed"))+ self.assertIn("local test run timed out", self.no_data("local run timed out"))++ def test_ci_reason_derives_from_states_with_upload_sentence(self) -> None:+ for state in ("no run", "artifacts absent", "artifacts expired"):+ html = self.no_data("ci", state, "blocked by fork PR")+ self.assertIn(f"CI state: <strong>{state}</strong>", html)+ self.assertIn("Local fallback: <strong>blocked by fork PR</strong>", html)+ self.assertIn(self.UPLOAD, html)+ self.assertIn("coverage file in a supported format", html)++ def test_ci_reason_without_upload_sentence(self) -> None:+ for state in ("run in progress or queued", "run failed before upload"):+ html = self.no_data("ci", state, "blocked by run in progress")+ self.assertIn(f"CI state: <strong>{state}</strong>", html)+ self.assertNotIn(self.UPLOAD, html)++ def test_no_reason_and_no_cases_still_shows_card(self) -> None:+ html = self.no_data(None)+ self.assertIn('<div class="card tests-nodata">', html)++ def test_card_absent_when_results_exist(self) -> None:+ self.assertNotIn("tests-nodata", self.build(block()).section_html)+++class RenderWiringTest(SectionCase):+ def data(self) -> dict:+ return {+ "repo": {"name": "x/y", "path": "/tmp/y"},+ "title": "t",+ "findings": [{"severity": "minor", "area": "a", "finding": "f", "resolution": "r"}],+ "unresolved_comments": [{"author": "a", "type": "review", "body": "hi"}],+ "files": copy.deepcopy(FILES),+ "tests": block(),+ }++ def stderr_lines(self) -> list[str]:+ return self._stderr.getvalue().splitlines()++ def test_card_section_and_toc(self) -> None:+ html = render(self.data(), self.dir)+ self.assertIn('<li><a href="#tests">Tests</a></li>', html)+ self.assertLess(html.index('<h3>Review findings</h3>'), html.index('<h3>Tests</h3>'))+ self.assertLess(html.index('<h3>Tests</h3>'), html.index('</section>'))+ self.assertLess(html.index('<section id="findings">'), html.index('<section id="tests">'))+ self.assertLess(html.index('<section id="tests">'), html.index('<section id="unresolved-comments">'))+ self.assertLess(html.index('<li><a href="#findings">'), html.index('<li><a href="#tests">'))+ self.assertLess(html.index('<li><a href="#tests">'), html.index('<li><a href="#unresolved-comments">'))++ def test_summary_lines_are_last_even_when_diagram_warns_later(self) -> None:+ data = self.data()+ data["diagram_file"] = "absent.json"+ render(data, self.dir)+ lines = self.stderr_lines()+ self.assertEqual(lines[-2], "summary coverage: matched=2 unmatched=2")+ self.assertEqual(lines[-1], "summary tests: passed=4 failed=1 errored=1 skipped=1 flaky=1")+ diagram = [i for i, l in enumerate(lines) if "absent.json" in l]+ self.assertTrue(diagram)+ self.assertLess(diagram[0], len(lines) - 2)+ missing = [i for i, l in enumerate(lines) if "missing.xml" in l]+ self.assertLess(missing[0], diagram[0])++ def test_summary_tests_excludes_baseline_cases(self) -> None:+ render(self.data(), self.dir)+ self.assertEqual(self.stderr_lines()[-1],+ "summary tests: passed=4 failed=1 errored=1 skipped=1 flaky=1")++ def test_uncovered_marks_reach_render_files(self) -> None:+ html = render(self.data(), self.dir)+ self.assertIn('<span class="diff-line diff-add diff-uncovered">+line 3</span>', html)+ self.assertIn('<span class="diff-line diff-add">+line 2</span>', html)++ def test_docs_only_omits_card_and_section_without_warnings(self) -> None:+ data = self.data()+ data["change_classification"] = "docs-only"+ html = render(data, self.dir)+ self.assertNotIn('id="tests"', html)+ self.assertNotIn("<h3>Tests</h3>", html)+ self.assertNotIn("Tests</a>", html)+ self.assertEqual(self._stderr.getvalue(), "")+ self.assertNotIn("diff-add diff-uncovered", html)++ def test_no_tests_block_is_silent(self) -> None:+ data = self.data()+ del data["tests"]+ html = render(data, self.dir)+ self.assertNotIn('id="tests"', html)+ self.assertEqual(self._stderr.getvalue(), "")+++if __name__ == "__main__":+ unittest.main()
diff --git a/scripts/tests/test_timing.py b/scripts/tests/test_timing.pynew file mode 100644index 0000000..a34b7f8--- /dev/null+++ b/scripts/tests/test_timing.py@@ -0,0 +1,99 @@+"""Timing tests (requirement 2.12): large generated inputs parse in under 5 s.++Skipped on hosts that look busy: when ``os.getloadavg`` is unavailable or+its one-minute average exceeds the CPU count.+"""+from __future__ import annotations++import contextlib+import io+import os+import tempfile+import time+import unittest+from pathlib import Path++from review_html.coverage import parse_coverage+from review_html.junit import parse_junit+from review_html.warnings import Warnings++LIMIT_SECONDS = 5.0+LCOV_BYTES = 10 * 1024 * 1024+JUNIT_CASES = 5_000+++def host_is_slow() -> bool:+ if not hasattr(os, "getloadavg"):+ return True+ try:+ load = os.getloadavg()[0]+ except OSError:+ return True+ return load > (os.cpu_count() or 1)+++def write_lcov(path: Path) -> None:+ record = ["SF:src/pkg/module_{n}.py"] + [f"DA:{i},{i % 3}" for i in range(1, 401)] + ["end_of_record"]+ chunk = "\n".join(record) + "\n"+ with path.open("w", encoding="utf-8") as fh:+ size = 0+ n = 0+ while size < LCOV_BYTES:+ text = chunk.format(n=n)+ fh.write(text)+ size += len(text)+ n += 1+++def write_junit(path: Path) -> None:+ parts = ['<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n<testsuite name="big">\n']+ for i in range(JUNIT_CASES):+ if i % 50 == 0:+ parts.append(f'<testcase classname="pkg.Class{i % 97}" name="test_{i}">'+ f'<failure message="assertion {i}">trace {i}</failure></testcase>\n')+ elif i % 50 == 1:+ parts.append(f'<testcase classname="pkg.Class{i % 97}" name="test_{i}"><skipped/></testcase>\n')+ else:+ parts.append(f'<testcase classname="pkg.Class{i % 97}" name="test_{i}" time="0.01"/>\n')+ parts.append("</testsuite>\n</testsuites>\n")+ path.write_text("".join(parts), encoding="utf-8")+++@unittest.skipIf(host_is_slow(), "host load exceeds CPU count or load average unavailable")+class TimingTest(unittest.TestCase):+ def setUp(self) -> None:+ self._tmp = tempfile.TemporaryDirectory()+ self.dir = Path(self._tmp.name)+ self.warnings = Warnings()+ self._stderr = contextlib.redirect_stderr(io.StringIO())+ self._stderr.__enter__()++ def tearDown(self) -> None:+ self._stderr.__exit__(None, None, None)+ self._tmp.cleanup()++ def test_ten_megabyte_lcov_parses_in_time(self) -> None:+ path = self.dir / "big.info"+ write_lcov(path)+ self.assertGreaterEqual(path.stat().st_size, LCOV_BYTES)+ start = time.perf_counter()+ cov = parse_coverage(path, self.warnings)+ elapsed = time.perf_counter() - start+ self.assertLess(elapsed, LIMIT_SECONDS, f"lcov parse took {elapsed:.2f}s")+ self.assertGreater(len(cov), 1000)+ self.assertEqual(self.warnings.items, [])++ def test_five_thousand_case_junit_parses_in_time(self) -> None:+ path = self.dir / "big.xml"+ write_junit(path)+ start = time.perf_counter()+ cases = parse_junit([path], self.warnings)+ elapsed = time.perf_counter() - start+ self.assertLess(elapsed, LIMIT_SECONDS, f"JUnit parse took {elapsed:.2f}s")+ self.assertEqual(len(cases), JUNIT_CASES)+ self.assertEqual(sum(1 for c in cases if c.outcome == "failed"), 100)+ self.assertEqual(self.warnings.items, [])+++if __name__ == "__main__":+ unittest.main()
diff --git a/specs/review-html-tests-diagram/decision_log.md b/specs/review-html-tests-diagram/decision_log.mdnew file mode 100644index 0000000..f3b70dc--- /dev/null+++ b/specs/review-html-tests-diagram/decision_log.md@@ -0,0 +1,238 @@+# Decision Log: review-html-tests-diagram++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-03 | Feature name `review-html-tests-diagram` | Matches the worktree branch already in use |+| Q2 | 2026-09-03 | Full spec workflow rather than smolspec | Layout engine, test source order, and contract shape are contested-approach decisions |+| Q3 | 2026-09-03 | Diagram nodes are files, clustered by package or directory | Universally derivable from imports; type-level and infrastructure views deferred |+| Q4 | 2026-09-03 | Aggregate results across all CI jobs, with a per-job row | Totals stay meaningful for matrix builds while failures remain attributable |+| Q5 | 2026-09-03 | Failing tests floor severity at `needs-changes` and the verdict tone at warning; coverage never changes severity | A red suite is a merge blocker regardless of findings; coverage is context, not a gate |+| Q6 | 2026-09-03 | Tests section and diagram omitted only for docs-only changes as defined in the requirements (documentation, named repo files, images, lockfiles, editor and VCS dotfiles); build config, CI workflows, and dependency manifests count as code | Absence of test data on a code change is review signal; on a docs change it is noise. Dependency bumps are where test results matter most |+| Q7 | 2026-09-03 | Show overall coverage delta when both base and head coverage exist; omit silently otherwise | Cheap when the data is there, and no fabricated baseline when it is not |+| Q8 | 2026-09-03 | Test files are excluded from the dependents and dependencies columns; changed test files stay as changed nodes; each changed node carries a count of test files importing it | Test files as dependents double the node count without saying anything about reach; a test-only PR still needs a diagram |+| Q9 | 2026-09-03 | Column cap of 15 nodes; overflow collapses into one node keeping the most-connected files, ranked by edge count then path | Keeps the diagram legible on wide-reaching changes without a general layout engine; the path tiebreak makes output deterministic |+| Q10 | 2026-09-03 | CI artifact retrieval is GitHub Actions only; GitLab is cut from this feature | The review skills are GitHub-only today and the forge adapter has no artifact operation; GitLab's JUnit reaches the API as JSON unless workflows also list it under `artifacts:paths`, which would need a fifth parser |+| Q11 | 2026-09-03 | One spec with two independently deliverable task phases (diagram, test results) rather than two specs | The halves share the renderer and the docs-only rule; ordering the diagram first keeps it deliverable if the test-collection phase stalls |+| Q12 | 2026-09-03 | Local test run timeout of 10 minutes; partial JUnit output is kept and labelled | Long enough for a cold build on a mid-size repo, short enough that a review does not hang; partial results beat none |+| Q13 | 2026-09-03 | Backward compatibility means identical page body excluding the style block and timestamp, checked against a golden fixture captured before the first edit | Byte identity would forbid adding any CSS; the style block is one unconditional constant |+| Q14 | 2026-09-03 | The label budget is the largest character count for which three columns plus gutters fit the 1036 px content width; the column cap drives height, not width | Three 40-character monospace columns exceed the content width; the design states the resulting budget and the advance constant |+| Q15 | 2026-09-03 | The skill, not the renderer, applies the severity floor for failing tests; the renderer's only rewriting of input is secret redaction in failure messages | Verdict and publish metadata are pass-through fields; a renderer that rewrites them would be new contract behaviour. Failure messages come from files only the renderer reads, so redaction has to live there |+| Q16 | 2026-09-03 | Failure messages are redacted for common secret patterns before rendering | The page is archived by pulsar and served to a feed reader; test output routinely contains tokens and connection strings |+| Q17 | 2026-09-03 | Baseline is the run for the merge-base commit, or the nearest successful base-branch run at or before it | A later base-branch run makes tests added on main appear as removed in the PR |+| Q18 | 2026-09-03 | Rerun and flaky JUnit elements never count as failures when the case ultimately passed | Surefire and pytest-rerunfailures emit these; counting them would floor severity on a green PR |+| Q19 | 2026-09-03 | Coverage path matching is a stated property (segment-wise suffix, ambiguity means no data, optional path mapping) rather than a list of mechanisms | Any unlisted case would otherwise be formally out of scope; a wrong match is worse than no data |+| Q20 | 2026-09-03 | Untrusted XML is parsed with the standard library after rejecting `DOCTYPE` declarations and capping inputs at 50 MB | `defusedxml` is not stdlib; rejecting DOCTYPE removes entity-expansion attacks |+| Q21 | 2026-09-03 | The skill supplies the full one-hop graph; the renderer applies the unit-collapse, cap, and ranking rules | Rules assigned to an agent following prose are neither reproducible nor testable; in the renderer the harness covers them |+| Q22 | 2026-09-03 | The JUnit-emitting test command is chosen before the verification run, never discovered by executing candidates | Probing would run the suite up to three times; a Makefile target that emits nothing structured is passed over for the ecosystem recipe |+| Q23 | 2026-09-03 | `pr-overview` pins the head commit SHA at the start and uses it for the diff, the artifact lookup, the fetch, and the worktree | A branch that moves mid-review would otherwise give diffs, coverage marks, and provenance from different trees |+| Q24 | 2026-09-03 | CI test counts are attributed to a job only when the artifact name contains the job name; otherwise they are shown per artifact | GitHub artifacts belong to a workflow run, not a job, so attribution is a convention rather than an API fact |+| Q25 | 2026-09-03 | Node label text declares `textLength`, and box width is at least the computed label width plus padding | The monospace font stack falls back to fonts with different advances; `textLength` makes the fit hold regardless |+| Q26 | 2026-09-03 | Provenance uses two orthogonal fields, CI state and fallback state, instead of one enum | A fork PR with expired artifacts is two facts; one enum cannot hold both, and the section shows the dimensions independently |+| Q27 | 2026-09-03 | The column cap applies to the side columns only; the centre column always shows every changed file | Changed files are what the reader came to see; a 40-file PR must still have a legal rendering |+| Q28 | 2026-09-03 | Nodes carry a test-file flag set by the skill; the renderer applies test exclusion, then unit collapse, then the cap | "Test file" is an ecosystem-table concept the renderer cannot derive; the order stops a mixed group from collapsing before test files are removed |+| Q29 | 2026-09-03 | `pr-overview` reads head and base trees from fetched git objects, or the forge tree and blob API when there is no clone, for fork PRs too | Fetching and reading blobs executes nothing, so the fork rule is not breached; it replaces the skill's current per-file `gh api` reads |+| Q30 | 2026-09-03 | All run outputs, the review JSON, and its referenced inputs live in the job directory | Outputs inside the working tree pollute `git status`; a worktree is removed after the run, so its outputs must be elsewhere and read first |+| Q31 | 2026-09-03 | The golden fixture is generated at commit `9da40cf` | That commit provably predates any renderer change for this feature, so the fixture is reproducible rather than a process instruction |+| Q32 | 2026-09-04 | Edge discovery is a script, `scripts/blast_radius.py`, driven by a machine-readable `scripts/ecosystems.json` that is also the skills' ecosystem table | Agent grep is neither reproducible nor testable; one JSON file serves both the script and the agent |+| Q33 | 2026-09-04 | The renderer becomes the package `scripts/review_html/` with `build_review_html.py` as a thin entry that resolves its own symlinked path | The file would otherwise double to about 1,800 lines; `sync-claude.sh` links the whole scripts directory so the package syncs unchanged |+| Q34 | 2026-09-04 | A `Makefile` with a `test` target is added to this repo; the harness is `unittest` under `scripts/tests/` | The user prefers `make test` as the single documented command despite it binding the repo to the Makefile convention |+| Q35 | 2026-09-04 | Modified nodes use `--accent-2` (magenta) rather than the badge's `--accent` | The badge colour equals the link colour, and every changed node is a link |+| Q36 | 2026-09-04 | Label budget is 37 characters at a 7.2 px advance, 10 px box padding, 8 px group padding, 56 px gutters, fixed 308 px columns | Largest budget fitting 1036 px; fixed column width keeps the declared width constant and the layout aligned |+| Q37 | 2026-09-04 | Hover emphasis uses per-node `:has()` rules in a `style` element inside the diagram section | Pure CSS with a flat SVG; browsers without `:has()` show the unchanged diagram |+| Q38 | 2026-09-04 | Tracked files touched by a local run are restored from pre-run copies of dirty files or `git checkout` for clean ones; `git stash` is never used | Stash would carry away the uncommitted fixes the run exists to verify |+| Q39 | 2026-09-04 | The local run budget is one Bash call at the tool's 600,000 ms maximum, covering install and tests | The harness enforces it, so no separate timer is needed |+| Q40 | 2026-09-04 | The severity floor is applied by rendering twice: the renderer prints a counts line to stderr, the skill adjusts verdict and severity, then renders again | Keeps verdict and publish metadata pass-through while giving the skill exact counts |+| Q41 | 2026-09-04 | pytest's `rerun` element joins the Surefire rerun and flaky element names as a flaky marker | Same semantics, different dialect |+| Q42 | 2026-09-04 | Coverage entries carry aliases for Cobertura source roots instead of being duplicated per root | Duplicates would make one file two candidates and trip the ambiguity rule |+| Q43 | 2026-09-04 | New template placeholders are appended to existing placeholder lines | An empty substitution then leaves no extra blank line, so the golden fixture compares without whitespace normalisation |+| Q44 | 2026-09-04 | Ambiguity is decided on the residual prefix of each candidate, not its first segment | Two files under the same module prefix share a first segment yet are different files |+| Q45 | 2026-09-04 | CI state is derived after downloading and sniffing, by an ordered rule list with "usable" first | "Usable" means a JUnit file exists, which is unknowable before sniffing; the order makes every state reachable |+| Q46 | 2026-09-04 | Job attribution uses token-set inclusion between job and artifact names, most tokens wins, ties unattributed | Matrix job names like `test (ubuntu)` are never substrings of artifact names like `test-results-ubuntu` |+| Q47 | 2026-09-04 | Artifacts over 100 MB are skipped by size before download; remote blob reads are capped at 500 calls with a column reason when hit | Build artifacts are routinely hundreds of MB, and the GitHub API rate limit is 5,000 calls per hour |+| Q48 | 2026-09-04 | The dependency-tool method runs inside the fallback's single Bash call, before the worktree is removed | The worktree is the only checkout `pr-overview` has, and it is gone by the render phase |+| Q49 | 2026-09-04 | Untracked files created by a local run are reported, never deleted; untracked files in the working tree count as added changes | Deleting would remove anything the user created during a 10-minute run; a fix that adds a file must appear in the diff |+| Q50 | 2026-09-04 | Ecosystem rows are keyed by language with a `runners` list selected by detection rules | One row cannot hold both vitest and jest recipes; detection makes tier 3 deterministic |+| Q51 | 2026-09-04 | Centre-column label budget reserves 4 characters for the test-count badge | The badge is a second text element inside the same box and must come out of the same width; the resulting number is in Q56 |+| Q52 | 2026-09-04 | Fetch uses `refs/pull/<n>/head` verified against the pinned SHA rather than fetching a bare SHA | Fetching unadvertised objects by SHA depends on server configuration; the pull ref always exists on GitHub, forks included |+| Q53 | 2026-09-04 | `overall()` merges entries by normalised primary path before counting | Repeated `SF:` records and per-job files would otherwise count instrumented lines twice |+| Q54 | 2026-09-04 | Stale worktree cleanup is `git worktree prune` alone | Prune removes exactly the entries whose directory is gone and skips locked ones; requirement 1.9 is worded to match |+| Q55 | 2026-09-04 | The fork-PR non-goal is scoped to `pr-overview`'s fallback; `pr-review-html` runs any PR it checks out and its skill text discloses that | `pr-review-html` already checks out and tests fork PRs today; the unqualified non-goal contradicted the applicability table |+| Q56 | 2026-09-04 | Centre-to-centre edges run in a 24 px lane inside the centre column; centre boxes are 268 px and the centre label budget is 30 | Arcs within the 8 px between box and column edge are indistinguishable, and bulging into the gutter breaches requirement 5.2 |+| Q57 | 2026-09-04 | The diagram and diff-derived tests are file references (`diagram_file`, `diff_tests_file`) written by `blast_radius.py`, never inline JSON | Transcribing a 70-node graph through the agent's context is the reproducibility failure Q32 exists to prevent |+| Q58 | 2026-09-04 | Coverage matching is five global passes: exact, pools, shared-removal once, residuals, merge | The per-file formulation was order-dependent and could report "no candidate" for a file that had a unique match |+| Q59 | 2026-09-04 | JUnit elements sharing an identity within one source collapse to the last element's outcome, flaky when an earlier attempt failed | pytest-rerunfailures emits one element per attempt; counting each inflates totals and can floor severity on a green suite |+| Q60 | 2026-09-04 | Makefile targets are inspected by reading recipe text, never via `make -n` | `make -n` still evaluates `$(shell …)` and `+`-prefixed lines, which executes branch code during selection |+| Q61 | 2026-09-04 | `change_classification` is a top-level JSON key | The diagram ships before the test-results phase; a classification inside the `tests` block would not exist yet |+| Q62 | 2026-09-04 | SVG fills and strokes use CSS variables with literal fallbacks | With the stylesheet removed a bare `var()` is invalid and edges vanish, breaching requirement 5.5 |+| Q63 | 2026-09-04 | Tool-derived edges carry a granularity from the ecosystem row, and the package-granularity note covers them | `go list` edges are package-level; Decision 4's honesty rule applies to them as much as to expansion |+| Q64 | 2026-09-04 | The diff directory is `$CLAUDE_JOB_DIR/review-inputs`, or `$(mktemp -d)/review-inputs` when the job directory is unset, always passed as an absolute path | A relative path inside the fallback's subshell resolved into the worktree or the user's clone |+| Q65 | 2026-09-04 | `read_guarded` returns `None` with a warning for a missing or unreadable file, not only for size, DOCTYPE, and encoding rejections | Requirement 2.10 asks for a warning naming the file; callers that need the historic silent placeholder (missing diff fragments) check existence first |+| Q66 | 2026-09-04 | `load_fragments` owns every diff placeholder (`missing`, `is not UTF-8`, `no diff provided`); `render_files` only looks paths up in the dict | One place decides what a file's diff text is, so the Tests section and the per-file blocks cannot disagree |+| Q67 | 2026-09-04 | The 15-node cap counts real nodes; the `+N more` node is a 16th box | Requirement 4.8 keeps 15 ranked nodes *and* collapses the rest into one node, so the overflow node sits outside the count |+| Q68 | 2026-09-04 | `+N more` counts files, not nodes, and lists the flattened member paths | Consistent with `<group> (N files)`; a collapsed group inside the overflow would otherwise hide its size |+| Q69 | 2026-09-04 | Go `test_decl` is `^func ((?:Test\|Fuzz\|Benchmark)\w+)\s*\(`, one capture group | The design's two-group form made group 1 the keyword, not the name |+| Q70 | 2026-09-04 | The `tool` row carries `name` and `format` (`go-list-json` or `pairs`) besides `deps` and `granularity` | `go list -json` needs its own parser and `tool:<name>` needs a name; `pairs` lets tests stub a tool with plain output |+| Q71 | 2026-09-04 | Python `from X import Y` captures both parts joined with `.`, relying on the `roots` drop-last retry | A single group cannot tell `from a.b import c` importing module `a/b/c.py` from importing symbol `c` out of `a/b.py` |+| Q72 | 2026-09-04 | With `--remote`, changed-file and module-file blob reads always run; the 500-call cap refuses only dependents-scan reads | The design says dependencies are unaffected by the cap, which is only true if their reads are never refused |+| Q73 | 2026-09-04 | A single changed file with two suffix candidates of different depth (`util.py` and `a/util.py` for `src/a/util.py`) is ambiguous; the design's example is the two-file case where pass 3 removes the shared entry | Pass 4 sees two distinct residuals, and per Q19 a wrong match is worse than no data; preferring the longer suffix would be a new rule |+| Q74 | 2026-09-04 | `redact.clean_message` redacts then truncates to 500 characters (499 plus `…`); `redact` alone never truncates | The section needs one call that does both in the required order; keeping `redact` pure keeps its tests simple |+| Q75 | 2026-09-04 | The availability line counts files actually read (`2 of 3 files read`), not files listed | A listed but unreadable file is not available data |+| Q76 | 2026-09-04 | The two `summary` lines print only when a `tests` block is present and the classification is not `docs-only` | Docs-only output must be silent; the skills treat absent lines as "no test data" and apply no floor |+| Q77 | 2026-09-04 | With no JUnit read and `no_data_reason` null, the no-data card still renders, derived from the CI states for `source: ci` and otherwise a generic sentence | A skill that forgot the reason should not produce a page with no Tests card at all |+| Q78 | 2026-09-04 | A runner's `junit_flags` may list several spellings and the schema test requires only one of them in the recipe | pytest accepts `--junitxml` and `--junit-xml`, and Makefile inspection must recognise either |+| Q79 | 2026-09-04 | The Rust runner is one `cargo llvm-cov nextest --lcov --output-path {coverage} --config-file {inputs}/nextest.toml` command; JUnit comes from the config template | A separate `cargo nextest run` followed by `cargo llvm-cov` would execute the suite twice |+| Q80 | 2026-09-04 | The Go runner requires `go` as well as `gotestsum`; the Swift coverage export is `&&`-chained after `swift test` | gotestsum cannot run without `go`; llvm-cov has no profile to export when the test binary did not build, while the JUnit file is written either way |+| Q81 | 2026-09-04 | Local-source `tests.provenance` in pr-review-html and pre-push-review omits `ci_state` and `fallback_state` | Neither skill queries head CI; the renderer reads both keys with `.get` |+| Q82 | 2026-09-04 | `git diff --no-index` exits 1 when the files differ and the skills say so | An agent that treats exit 1 as failure would drop every untracked file's fragment |+| Q83 | 2026-09-05 | With no coverage input parsed, the per-file diff-coverage table, overall coverage, and unmatched report are omitted and the stderr line reports matched=0 unmatched=0 | A table of all "no coverage data" rows says nothing the availability line does not already say |+| Q84 | 2026-09-05 | The stderr coverage line carries counts only; per-file unmatched reasons live in the Tests section | The skills grep one line for the floor; per-file reasons on stderr would be noise no skill reads |+| Q85 | 2026-09-05 | Files with no ecosystem row are test files only by whole-token name (`test_x`, `x_test`, `x.test.ts`, `x.spec.js`, `XTests.swift`, `conftest.py`) or a parent directory named `test`, `tests`, `__tests__`, or `spec` | A substring rule flagged every file under `specs/` and `docs/testing.md` as tests, excluding them from the diagram's side columns and listing them as unpatterned |++## Decision 1: Constrained pure-SVG layout instead of Graphviz++**Date**: 2026-09-03+**Status**: accepted++### Context++The review page is fully self-contained today: no external scripts, diffs highlighted in Python. The pulsar archive moves the file and serves it to a browser or feed reader, and the page may be opened offline. The diagram needs a layout engine somewhere, and the choice determines whether the page or the build machine gains a dependency.++### Decision++Generate the diagram as inline SVG from a constrained three-column layout (dependents, changed, dependencies) implemented in the renderer with the Python standard library. No Graphviz, no Mermaid, no scripts in the page.++### Rationale++A review diagram answers one question, "what does this change touch and what depends on it", which has a fixed shape. A three-column layout with grouped nodes and bezier edges is arithmetic, roughly 150 to 200 lines, and needs no general graph layout. That removes both the build-time dependency and the view-time dependency, so the diagram appears on any machine and in any viewer. The estimated extra effort over Graphviz glue is about half a day.++### Alternatives Considered++- **Graphviz `dot -Tsvg` inlined at build time**: Best layout quality for arbitrary graphs and about 30 lines of glue - Rejected because it is a binary that must exist wherever the review is built, so a fallback is needed anyway and the diagram silently vanishes without it. Conflicts with the goal of working on any repo and machine.+- **Mermaid loaded from a CDN**: No install and the pulsar contract allows pinned public URLs - Rejected because it adds the page's first external script, fails in feed readers and offline, and lays out poorly beyond roughly 15 nodes.+- **General layered layout (Sugiyama) in Python**: No dependencies and handles arbitrary graphs - Rejected because it is substantially more work than the constrained layout and still lays out worse than Graphviz.++### Consequences++**Positive:**+- The page stays dependency-free and works in every viewer.+- Layout is deterministic and testable with fixtures.+- Node links and hover highlighting fit the page's existing CSS-only interaction style.++**Negative:**+- The layout only suits the blast-radius shape; other diagram types (infrastructure, type-level) will need their own projection onto the same column machinery or a new layout.+- Wide-reaching changes must be capped and collapsed rather than laid out in full.++---++## Decision 2: The renderer parses formats; the skills map ecosystems++**Date**: 2026-09-03+**Status**: accepted++### Context++The feature must work on any repository and language, and the renderer already has a clear split: skills assemble JSON, the script renders it. Test runners differ per ecosystem, but their machine-readable outputs converge on a few formats.++### Decision++The renderer reads JUnit XML for test outcomes and lcov, Cobertura XML, and Go coverprofile for coverage, and knows nothing about runners or languages. A single shared ecosystem table, referenced by all three skills, tells the agent how to make each common runner emit those formats, with a Makefile target or the project's own instructions taking precedence.++### Rationale++Every mainstream runner can emit at least one of these formats: gotestsum and Go's coverprofile, pytest with `--junitxml` and `--cov-report=xml`, jest and vitest JUnit reporters with lcov, `swift test --xunit-output` with llvm-cov lcov, cargo nextest JUnit, JaCoCo and PHPUnit Cobertura, RSpec JUnit formatters. Parsing four formats once covers all of them, and adding a language means adding a table row, not code. The same parsers serve CI artifacts and local runs.++### Alternatives Considered++- **Per-runner collectors in the script**: The script invokes `go test -json`, pytest, and so on directly - Rejected because it couples the renderer to languages and grows a collector per ecosystem.+- **Parse runner logs from CI**: Works without any workflow changes - Rejected as brittle; every runner has a different summary line, and no log carries line coverage. Structured artifacts or a local run are the only sources that yield diff coverage.+- **diff-cover as a dependency**: Computes diff coverage from Cobertura and lcov - Rejected because it adds a pip dependency and needs a Cobertura conversion for Go, when the intersection itself is about 40 lines.++### Consequences++**Positive:**+- New languages need no renderer changes.+- CI and local data go through the same code path, so the page looks the same either way.++**Negative:**+- A repository whose CI does not upload structured artifacts gets no CI-sourced data until its workflow is changed; the fallback is a local run.+- Coverage path normalisation across formats (Go package paths, absolute lcov paths, Cobertura source roots) needs careful matching against diff paths.++---++## Decision 3: Test data source order per skill++**Date**: 2026-09-03+**Status**: accepted++### Context++The three skills have different relationships with the code. `pr-review-html` checks out the PR branch, applies fixes locally, and already runs the suite to verify them. `pr-overview` is read-only and never checks out. `pre-push-review` runs on the local branch before push and already runs the suite. CI results for the PR head may exist, may be missing, and in the fix-applying skill they describe a commit that no longer matches the working tree.++Running a PR's tests in a fresh worktree means installing dependencies first, and install scripts execute arbitrary code before any test runs. For a fork PR that code comes from someone outside the repository.++### Decision++`pr-review-html` and `pre-push-review` take test data from the single run their verification phase already performs, and take their diffs from the same working tree. `pr-overview` pins the head commit SHA and uses GitHub Actions artifacts for that SHA first. When none exist, no run is in progress, the PR is a same-repo PR, and the current directory is a clone of that repository, it fetches the SHA, runs the suite in a throwaway worktree in the job directory, and removes the worktree afterwards. For fork PRs, in-progress runs, and when there is no local clone, it never runs branch code and the page says which state applied. Every page states its source, and a baseline from a different source kind than the head is labelled as such.++### Rationale++In `pr-review-html` the page must describe the post-fix tree, and the diffs must come from that same tree or uncovered-line marks land on wrong lines. `pr-overview`'s read-only promise is about not modifying the user's checkout or the branch; a detached worktree in the job directory keeps that promise. The same-repo restriction is the trust boundary: code from the repository's own contributors is what the reviewer already runs day to day, and fork code is not. Fetching the head ref is required because the skill never checks out, so the ref does not otherwise exist locally.++### Alternatives Considered++- **CI only in `pr-overview`**: Keeps the skill strictly non-executing - Rejected because most repositories today upload no artifacts, so the section would usually show the no-data card. Kept as the behaviour for fork PRs.+- **Worktree fallback for all PRs**: Yields data on every PR - Rejected because it executes install scripts and tests from untrusted forks with the reviewer's credentials and network.+- **A consent prompt before running fork code**: Keeps the fallback available - Rejected because the skills run unattended in background jobs where a prompt blocks the review.+- **Show both CI and local in `pr-review-html`**: Gives the reader the pre-fix and post-fix views - Rejected because it doubles the section for a distinction that only matters when a fix changed test outcomes, which the verdict already describes.++### Consequences++**Positive:**+- Each page's test data matches the code it describes, and diffs and coverage share one snapshot.+- `pr-overview` yields test data on same-repo PRs in repositories with no CI artifacts.+- No new test run is added to `pr-review-html` or `pre-push-review`.++**Negative:**+- `pr-overview` executes same-repo branch code, including install scripts, which the skill text states explicitly.+- Fork PRs in repositories without artifacts show the no-data card until the workflow uploads results.+- A worktree run duplicates the checkout and installs dependencies, which is slow on large repositories and subject to the 10-minute timeout.+- In `pr-review-html` a local head run is compared against a CI baseline; tests that only run in CI (build tags, OS-gated suites) appear as removed unless the reader heeds the cross-source note.+- Fetching the head SHA writes refs into the user's `.git`, so `pr-overview`'s "never modifies" statement gains a caveat for repository metadata.++---++## Decision 4: Module-level imports expand to files with labelled granularity++**Date**: 2026-09-03+**Status**: accepted++### Context++The diagram's nodes are files (per Q3), and its edges come from imports. Python, Ruby, C, and most JavaScript imports resolve to a file. Swift, C#, Go, Java, and Kotlin imports name a package, module, namespace, or target, so a file-level "who imports this file" set does not exist in those languages. Rendering an empty dependents column there would read as "nothing depends on this", a confidently false statement on a review page.++### Decision++Edges are derived by one of three recorded methods: a dependency tool, a file-resolving import, or unit expansion, where a package-level import is expanded to every file in that unit. When a column contains expanded edges the page says those edges are at package granularity, and a unit contributing more than three expanded files collapses into one node. When no method is available the column is replaced by the reason, never left empty.++### Rationale++Expansion keeps the file-level model and gives module-language repositories a usable diagram, while the label and per-edge method keep the reader honest about what the edges mean. Collapsing large expanded units stops a one-line change in a big package from filling a column with fifteen siblings. Recording the method also lets the design test each method separately.++### Alternatives Considered++- **Strict file edges only**: Only tool-derived and file-resolving edges count - Rejected because most Swift, C#, and Go PRs would render a changed-column-only diagram, which is the common case for this user's repositories.+- **Package nodes for module languages**: Nodes become packages where imports are package-level - Rejected because it changes Q3's node model mid-diagram and mixes granularities across columns without a per-edge record of why.++### Consequences++**Positive:**+- Every supported language yields a diagram with the same visual model.+- The page never claims an absence it cannot establish.++**Negative:**+- Expanded edges overstate reach: a file in a package that imports another package may not use the changed file at all.+- The ecosystem table must name a unit-resolution rule per module language, which is more per-language content than the test-recipe rows.++---
diff --git a/specs/review-html-tests-diagram/design.md b/specs/review-html-tests-diagram/design.mdnew file mode 100644index 0000000..fbb5dfd--- /dev/null+++ b/specs/review-html-tests-diagram/design.md@@ -0,0 +1,432 @@+# Design: review-html-tests-diagram++## Overview++Two additions to the shared review renderer and the three review skills: a Tests section fed by JUnit and coverage files that the skills collect from GitHub Actions artifacts or a local run, and a blast-radius diagram rendered as inline SVG from a one-hop dependency graph that a new script derives from git trees. The renderer becomes a package with a thin entry point; the skills gain a shared machine-readable ecosystem file that both the agent and the new script read.++## Architecture++### Components++| Component | Location | Role |+|-----------|----------|------|+| Entry point | `scripts/build_review_html.py` | Argument parsing unchanged; inserts `Path(__file__).resolve().parent` on `sys.path` before `import review_html` (harmless on CPython, needed under `-P` or a file-level symlink) and calls `review_html.render` |+| Renderer package | `scripts/review_html/` | `__init__.py` (exports `render`), `common.py` (`escape`, `file_anchor`, `severity_pill`, `digest`), `inputs.py` (guarded reads), `warnings.py`, `sections.py` (existing renderers), `css.py`, `template.py`, `diffs.py`, `junit.py`, `coverage.py`, `redact.py`, `tests_section.py`, `diagram.py`, `render.py` (the `render()` orchestration). Every module starts with `from __future__ import annotations` |+| Edge discovery | `scripts/blast_radius.py` | Reads two trees, scans imports per `ecosystems.json`, emits `diagram.json` and `diff-tests.json` |+| Ecosystem file | `scripts/ecosystems.json` | One row per language with per-runner test recipes; read by the agent and by `blast_radius.py` |+| Harness | `scripts/tests/`, `Makefile` | `make test` runs `cd scripts && python3 -m unittest discover -s tests -t .`; tests import `review_html` under that one name and invoke the entry point by repo-relative path, never through `~/.claude/scripts` |+| Skills | `claude/skills/{pr-review-html,pr-overview,pre-push-review}/SKILL.md` | Collection steps, JSON blocks, severity floor, corrected rendering contract |++### Data flow++```mermaid+flowchart LR+ subgraph skill [Skill]+ A[Pin snapshot and base] --> B{Source}+ B -->|CI| C[gh run list / artifacts / download / jobs]+ B -->|local| D[Run chosen recipe once]+ A --> E[blast_radius.py]+ C --> F[review.json: tests block]+ D --> F+ E --> G[diagram.json and diff-tests.json]+ end+ F --> R[build_review_html.py]+ G --> R+ R --> H[HTML with Tests card, Tests section, diagram, marked diffs]+```++The skill never parses JUnit, coverage, or the diagram. Every input lives in the diff directory `$INPUTS`, where `INPUTS=$CLAUDE_JOB_DIR/review-inputs` or, when `$CLAUDE_JOB_DIR` is unset, `$(mktemp -d)/review-inputs`. All three skills pass `--diff-dir "$INPUTS"` explicitly and reference inputs from the JSON by file name.++### Renderer integration points++`render()` builds a `sections` dict and a `toc_labels` dict, then substitutes into `PAGE_TEMPLATE`. New placeholders are appended to existing placeholder lines, so an empty substitution leaves the old output byte-identical.++| Site | Change | Needs equivalent |+|------|--------|------------------|+| `sections` dict | add `"tests"` and `"diagram"` | yes |+| `toc_labels` | add `"tests": "Tests"`, `"diagram": "Blast radius"` | yes |+| `PAGE_TEMPLATE` body | `$findings_section$tests_section` and `$unresolved_comments_section$diagram_section` on the existing lines | yes |+| `PAGE_TEMPLATE` card grid | `$findings_summary$tests_card` on the existing line | yes |+| fragment loading | one `load_fragments(files, diff_dir, warnings)` call; `build_tests` and `render_files` both consume it | yes |+| `render_files` | takes the loaded fragments and `uncovered: dict[str, set[int]]` | yes |+| `render_important_links`, `build_toc`, footer | unchanged | no |+| `CSS` constant | new rules appended; the constant is a substituted value, so `$` inside it is safe; new rules never go into the template literal | yes |++Call order inside `render()`: load fragments → `build_tests` → `render_diagram` → `render_files` with the uncovered sets → template → print the two summary lines last. Page order: description, commits, explanation, important changes, decisions, findings, tests, unresolved comments, blast radius, per-file diffs, double-check.++`change_classification` is a top-level JSON key. With `docs-only` the Tests card, Tests section, and diagram are all omitted without warnings, whether or not a `tests` block or `diagram_file` is present. The diagram renders whenever `diagram_file` is present and the classification is not `docs-only`.++### Skill integration points++| Skill | Phase | Addition |+|-------|-------|----------|+| pr-review-html | 1 | Record `headRefOid`, `isCrossRepository`, `baseRefName`; after checkout, `git merge-base origin/<base> HEAD`. The skill text states that Phases 4 and 5 run the branch's install scripts and tests on the reviewer's machine, for fork PRs too |+| pr-review-html | 5 | Choose the recipe per §Recipe selection; run once into `$INPUTS`; restore per §Restore; on timeout set `run_outcome: timed_out` and add "fix verification incomplete: test run timed out" to `verdict.detail` |+| pr-review-html | 7 step 1 | Fragments from `git diff <merge-base> -- <path>` on the working tree, replacing `gh pr diff`; untracked files from `git ls-files --others --exclude-standard -z` get fragments from `git diff --no-index /dev/null <path>` and badge `Added` |+| pr-review-html | 7 step 1b | Baseline per §Baseline; `blast_radius.py --repo . --snapshot working-tree --base <merge-base> --tools --out "$INPUTS"` |+| pr-review-html | 7 step 2 | Populate `tests`, `diagram_file`, `change_classification`; apply §Severity floor |+| pr-overview | 1 | Pin `headRefOid`; with a clone, `git fetch origin refs/pull/<n>/head` and verify `git rev-parse FETCH_HEAD` equals the pinned SHA; merge base and fragments from `git diff <merge-base> <sha> -- <path>`; without a clone, merge base from the compare API's `merge_base_commit` and fragments from its `files[].patch`, noting files the API omits (over 300 files, or binary) as fragments missing |+| pr-overview | 1b (new) | §CI artifacts, then §Worktree fallback when permitted |+| pr-overview | 6 step 1b | Baseline; if 1b did not produce a diagram, `blast_radius.py --repo . --snapshot <sha> --base <merge-base>` (clone) or `--remote <owner>/<repo>` (no clone), without `--tools` |+| pr-overview | 6 step 2 | Populate blocks; severity floor |+| pre-push-review | 1 | Base is `origin/<branch>`, or `origin/main` without a tracking branch, unchanged |+| pre-push-review | 5 | As pr-review-html Phase 5 |+| pre-push-review | 7 | Fragments already come from `git diff $BASE -- <path>`; add untracked files as above; `blast_radius.py --repo . --snapshot working-tree --base $BASE --tools --out "$INPUTS"`; populate blocks; severity floor |++All three skills replace the `{repo-root}/.claude/*-diffs/` suggestion with `$INPUTS`, pass `--diff-dir` explicitly, and update their "when to edit the script" paragraph to name the package and `css.py`. pr-overview's paragraph on reading head files via `gh api` is replaced by the fetch and compare-API reads above, and its read-only statement gains the sentence required by requirement 1.3 plus a note that the fetch writes a ref into `.git`. All three rendering-contract sections drop the highlight.js claim and pre-push-review's claim that malformed JSON still renders. Every `gh api` call carries `-R <owner>/<repo>` so it works without a clone, and list endpoints use `--paginate`.++### Recipe selection++The agent reads `ecosystems.json`, detects the language row by the extensions of the changed files (most files wins), and picks a runner within the row by its `detect` rule. Then, reading text only and executing nothing:++1. A Makefile target whose literal recipe lines (read from the Makefile, not expanded) contain one of the runner's `junit_flags`. A recipe whose output path is a `$(VAR)` reference is passed over. The agent runs the target and copies the outputs named in the recipe into `$INPUTS`, reporting them under §Restore.+2. A command in CLAUDE.md or the README described as the test command, if it contains such a flag.+3. The runner's `recipe` with `{junit}`, `{coverage}`, and `{inputs}` substituted with absolute paths under `$INPUTS`, provided every binary in `requires` is on PATH and any `config_files` templates have been written under `$INPUTS`. A missing binary records `required tool missing`; no row or no runner detected records `runner not detected`.++`coverage_scope` is `repository` for tier 3 and `project-configured` for tiers 1 and 2, and the Tests section shows it.++### Restore++Before the run the agent records `git status --porcelain -z` and copies every dirty tracked file to `$INPUTS/pre-run/<path>`. After the run, for each tracked file whose content differs from before: a file clean pre-run is restored with `git checkout -- <path>`; a file dirty pre-run is copied back from `pre-run/`. Untracked files that appeared during the run outside `$INPUTS` are reported, not deleted. Touched and appeared paths go into `tests.run_touched_files`, which the Tests section lists as a warning. `git stash` is never used.++### CI artifacts++```+gh pr view <n> -R <owner>/<repo> --json headRefOid,isCrossRepository,baseRefName,headRepository+gh run list -R <owner>/<repo> --commit <sha> --json databaseId,status,conclusion,name,url+gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/actions/runs/<id>/artifacts # name, expired, size_in_bytes+gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/actions/runs/<id>/jobs # name, conclusion, html_url+gh run download <id> -R <owner>/<repo> -n <artifact> -D "$INPUTS/artifacts/<id>/<artifact>"+```++Only artifacts with `expired: false` and `size_in_bytes` at most 100 MB are downloaded; larger ones are recorded by name in `tests.skipped_artifacts`. Downloaded files are sniffed: JUnit is XML whose root element is `testsuites` or `testsuite`; Cobertura is XML whose root is `coverage`; lcov starts with `TN:` or `SF:`; coverprofile starts with `mode: `. Recognised files are copied into `$INPUTS` as `<run_id>-<artifact>--<basename>` so two artifacts or two runs cannot collide, and the JSON references those names.++CI state is derived after sniffing, first rule that matches:++1. Any completed run yielded a JUnit file → `artifacts usable`; runs still `in_progress` or `queued` go into `tests.pending_runs`.+2. Any run `in_progress` or `queued` → `run in progress or queued`.+3. No runs → `no run`.+4. At least one artifact exists across completed runs and every one is expired → `artifacts expired`.+5. Any completed run with `conclusion: failure` and zero artifacts → `run failed before upload`.+6. Otherwise → `artifacts absent` (covers artifacts that contain no JUnit).++Job attribution: tokenise job and artifact names to lowercase alphanumeric runs; a file is attributed to the job whose token set is a subset of the artifact's token set, choosing the job with the most tokens; a tie leaves it attributed to the artifact. `test (ubuntu)` → `{test, ubuntu}` matches `test-results-ubuntu`.++### Worktree fallback++Conditions from requirements 1.3, 1.4, and 1.14 hold. One Bash call with the tool's maximum timeout of 600,000 ms; `WT=$CLAUDE_JOB_DIR/wt-<n>-<sha7>`:++```+git worktree prune+git fetch origin refs/pull/<n>/head && test "$(git rev-parse FETCH_HEAD)" = <sha>+git worktree add --detach "$WT" <sha>+(cd "$WT" && <install> && <recipe with absolute $INPUTS paths>); status=$?+python3 ~/.claude/scripts/blast_radius.py --repo "$WT" --snapshot <sha> --base <merge-base> --tools --out "$INPUTS"+git worktree remove --force "$WT"; git worktree prune+echo "recipe-status=$status"+```++`git worktree prune` at the start drops entries whose directories are gone and skips locked ones. The recipe's exit status becomes `run_outcome`. If the call times out, the skill runs the removal lines separately.++### Baseline++Merge base from `git merge-base` with a clone, else the compare API. Candidate runs: `gh run list -R <owner>/<repo> --branch <base> --status success --limit 30 --json databaseId,headSha`. The first candidate whose `headSha` equals the merge base or is its ancestor wins: `git merge-base --is-ancestor <headSha> <merge-base>` with a clone, treating exit status 128 (commit not present locally) as "skip this candidate"; without a clone, `compare/<headSha>...<merge-base>` with `status` of `identical` or `ahead`. A winning run with no usable artifacts ends the search. Its artifacts are downloaded and sniffed as above and referenced as `baseline_junit` and `baseline_coverage`. pre-push-review has no forge and skips this.++### Severity floor++Skill-side rule in each SKILL.md. The renderer prints, as its last two stderr lines, `summary coverage: matched=N unmatched=N` and `summary tests: passed=N failed=N errored=N skipped=N flaky=N`, the latter computed from head JUnit only, whenever a `tests` block is present. The skill greps by the `summary tests:` prefix. If failed or errored is non-zero the skill sets `verdict.tone` to `warning` unless already `error`, prepends the failure count to `verdict.detail`, raises `publish_metadata.severity` to `needs-changes` unless already `blocking`, and renders again to the same output path.++## Components and Interfaces++### `review_html/common.py`++`escape(s)` is `html.escape(s, quote=True)`; `digest(s)` is `sha1(s)[:10]`; `file_anchor(path)` is `"file-" + digest(path)`; `severity_pill` moves here unchanged. Both `sections.py` and `diagram.py` import from here, nothing imports `sections.py` from `diagram.py`.++### `review_html/inputs.py`++```python+def read_guarded(path: Path, warnings: Warnings, xml: bool = False) -> str | None+```++Rejects by `stat` over 50 MB, decodes UTF-8 with a warning on failure, and when `xml` scans the first 64 KB for `<!DOCTYPE` (which must precede the root element) and rejects on a match. Every input read (JUnit, coverage, baseline files, diff fragments, `diagram.json`, `diff-tests.json`) goes through it.++### `review_html/warnings.py`++```python+class Warnings:+ items: list[str]+ def add(self, message: str) -> None # appends and prints "warning: …" to stderr immediately+```++### `review_html/diffs.py`++```python+def load_fragments(files: list[dict], diff_dir: Path | None, warnings: Warnings) -> dict[str, str]+def added_lines(diff: str) -> set[int] # new-file line numbers of '+' lines, from @@ headers+def is_binary(diff: str) -> bool # a line starting "Binary files " or "GIT binary patch"+def render_diff(diff: str, uncovered: set[int] | None) -> str+```++`render_diff` is the existing `_render_diff` plus hunk tracking: `@@ -a,b +c,d @@` sets the next new-file number to `c`; context and `+` lines advance it; `-` lines do not. A `+` line whose number is in `uncovered` gets class `diff-uncovered` as well as `diff-add`; the CSS draws a 3 px `--error` left border and a `▌` gutter marker. With `None` the output is identical to today. A missing fragment keeps the existing placeholder; an undecodable one gets `(diff fragment 'x' is not UTF-8)`.++### `review_html/junit.py`++```python+@dataclass+class Case:+ suite: str; name: str; outcome: str # passed|failed|skipped|errored+ flaky: bool; message: str; source: str # source = input file name, for job attribution++def parse_junit(paths: list[Path], warnings: Warnings) -> list[Case]+```++Per element, first rule that matches: a `failure` child → failed; an `error` child → errored; any of `flakyFailure`, `flakyError`, `rerunFailure`, `rerunError`, `rerun` → passed with `flaky=True`; `skipped` → skipped; else passed. Within one source file, elements sharing (suite, name) collapse to one case whose outcome is the last element's; if any earlier element failed or errored and the last passed, the case is flaky. Identities are never collapsed across sources, since one job each is allowed. `message` is the first failure or error element's `message` attribute, else its text.++### `review_html/coverage.py`++```python+@dataclass+class Entry:+ paths: list[str] # primary path first, then aliases+ hits: dict[int, int]++Coverage = list[Entry]++def parse_coverage(path: Path, warnings: Warnings) -> Coverage+def apply_path_map(cov: Coverage, strip: str | None, prepend: str | None) -> Coverage+def match(cov: Coverage, changed: list[str]) -> tuple[dict[str, dict[int, int]], dict[str, str]]+ # merged hits per matched changed file; reason per unmatched file: "no candidate" | "ambiguous"+def diff_coverage(added: set[int], hits: dict[int, int]) -> tuple[int, int] | None # (covered, measurable)+def overall(cov: Coverage) -> tuple[int, int] # merges entries by normalised primary path first+```++Parsers: lcov reads `SF:` and `DA:line,hits` until `end_of_record`; Cobertura reads `class/@filename` and `lines/line/@number,@hits`, adding `<source>/<filename>` as aliases when `sources/source` elements exist; coverprofile validates `mode:` then reads `file:sl.sc,el.ec stmts count`, assigning `count` to lines `sl..el` and taking the maximum where blocks overlap. `apply_path_map` runs after normalisation on every path including aliases, stripping or prepending whole segments.++`match` is five global passes over normalised paths (`\` → `/`, `posixpath.normpath`, `./` removed):++1. Exact: for each changed file, entries with any path equal to it. An entry whose aliases equal two changed files is ambiguous for both and removed. Matched files merge their entries and are done; matched entries leave the pool.+2. Pools: for each remaining changed file, candidates are remaining entries where the changed path is a whole-segment suffix of the entry path or vice versa. Each candidate records a residual: the direction plus the uncovered segments of the longer path.+3. Shared: an entry present in more than one pool is removed from every pool, once, without cascading. A file whose pool empties here is `ambiguous`.+4. Residuals: a file whose pool has more than one distinct residual is `ambiguous`; an empty pool is `no candidate`.+5. Merge: remaining pools merge by summing hits per line.++Merging happens only in pass 5 and in `overall`, so the order is mapping → matching → merging. `match` returns the unmatched reasons that feed the section and the `summary coverage:` line.++### `review_html/redact.py`++```python+PATTERNS: list[re.Pattern] # applied in order, each match replaced with "[redacted]"+def redact(text: str) -> str+```++Patterns: `Bearer\s+[A-Za-z0-9\-._~+/]+=*`; `AKIA[0-9A-Z]{16}`; `gh[pousr]_[A-Za-z0-9]{36,}`; `xox[abprs]-[A-Za-z0-9-]+`; `(?i)[A-Za-z0-9_]*(key|token|secret|password|passwd|pwd)["']?\s*[=:]\s*\S+`; `[a-z][a-z0-9+.-]*://[^/\s:@]+:[^@\s]+@`; `-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----`. Redaction runs before truncation to 500 characters.++### `review_html/tests_section.py`++```python+@dataclass+class TestsResult:+ card_html: str; section_html: str+ uncovered: dict[str, set[int]]+ counts: dict[str, int] # passed, failed, errored, skipped, flaky, matched, unmatched++def build_tests(block: dict, files: list[dict], fragments: dict[str, str], diff_dir: Path, warnings: Warnings) -> TestsResult+```++The card matches the existing `.card` markup with three stat `<p>` lines and a section link; with no data it shows `n/a` values and a link to the section. The section contains, in order: provenance with CI link, CI state and fallback state; an availability line (execution outcome, JUnit files found, coverage files found, baseline present); `coverage_scope`; totals with flaky alongside; pending runs; per-job or per-artifact table; failed tests; new and removed tests; per-file diff coverage table, excluding files whose badge is `Deleted` or whose fragment `is_binary`; overall coverage; the `match` unmatched report; `run_touched_files`, `skipped_artifacts`, and warnings. The no-data card reuses `.card` with a `--warning` left border, the treatment used by unresolved-comment cards; the renderer derives its text from `no_data_reason`, `ci_state`, and `fallback_state`, adding the "workflow must upload" sentence when the CI state is `no run`, `artifacts absent`, or `artifacts expired`.++### `review_html/diagram.py`++```python+@dataclass+class Projected: # nodes per column, collapsed nodes with members, edges, test counts, column status, granularity notes+@dataclass+class Layout: # boxes by node id, group frames, edge paths, width, height++def project(desc: dict) -> Projected+def layout(p: Projected) -> Layout+def render_diagram(desc: dict, warnings: Warnings) -> str # section HTML with <style>, scroll container, <svg>, legend, lists+```++Column assignment: a node with status other than `unchanged` is centre; an unchanged node with an edge into a changed node is a dependent; an unchanged node with an edge from a changed node is a dependency; a node with both goes to dependents and its incoming edge is drawn there.++Projection, in order:++1. Test exclusion: `is_test` nodes leave the side columns; each changed node's test count is the number of `is_test` nodes in any column, including changed ones, with an edge to it.+2. Expansion collapse: in each side column, within each group, the nodes whose every edge to the centre has `granularity: "package"` are collapsed into one node when there are more than 3 of them; nodes with any file-granular edge stay. The collapsed node's label is `<group> (N files)`, its id is `digest` of the member paths joined with `\n`, and its ranking key is the sum of its members' edges to changed files.+3. Cap: a side column still over 15 nodes keeps the 15 ranked by `(-edges_to_changed, path)`, path being the first member for a collapsed node, and collapses the rest into a `+N more` node.++Groups within a column are ordered by group name, nodes within a group by path. Same input, same output.++Layout constants:++| Constant | Value | Note |+|----------|-------|------|+| font | 12 px `ui-monospace, "SF Mono", Menlo, monospace` | every text element, including the badge and group labels, declares `textLength` and `lengthAdjust="spacingAndGlyphs"` |+| ADV | 7.2 px | assumed advance for budget arithmetic |+| PAD | 10 px | horizontal box padding each side |+| BOX_H | 26 px | |+| ROW_GAP | 8 px | |+| GROUP_PAD | 8 px | |+| GROUP_HEADER | 18 px | group label row, label budget as the column's |+| GROUP_GAP | 14 px | |+| GUTTER | 56 px | side edges live here |+| LANE | 24 px | centre-to-centre edge lane inside the centre column, right of the boxes, three tracks 6 px apart |+| CONTENT_W | 1036 px | |+| COL_W | 308 px | `(CONTENT_W − 2·GUTTER) / 3` |+| SIDE_BOX_W | 292 px | `COL_W − 2·GROUP_PAD` |+| CENTRE_BOX_W | 268 px | `SIDE_BOX_W − LANE` |+| **Side budget** | **37** | largest L with `L·ADV + 2·PAD ≤ SIDE_BOX_W` (37 → 286.4) |+| **Centre budget** | **30** | largest L with `(L + 4)·ADV + 2·PAD ≤ CENTRE_BOX_W` (30 → 264.8), the 4 reserving the `⚑N` badge |++Boxes are fixed width per column; columns are always `COL_W` wide, so the declared width is `CONTENT_W` and the height is the tallest column. A label over budget is shortened to `…` plus the trailing characters that fit. Each node is a `<g id="n-<digest>">` holding `<title>` with the full path, the `<rect>`, the label `<text>`, and on changed nodes a right-aligned `<text>` reading `⚑N` when N > 0; changed nodes' groups are wrapped in `<a href="#file-<digest>">`. Side edges are cubic beziers with control points at the gutter midpoint, from the source's right-middle to the target's left-middle when the target is to the right, and from the source's left-middle to the target's right-middle otherwise. Centre-to-centre edges leave the source's right-middle, run in the lane on the track chosen by edge index modulo 3, and enter the target's right-middle. Every edge has `marker-end`. Fills and strokes use CSS variables with literal fallbacks (`var(--success, #22C55E)`, `var(--error, #EF476F)`, `var(--accent-3, #4C6CBC)`, `var(--accent-2, #E474E4)`, `var(--surface-2, #142042)`, `var(--border, #26324F)`) so edges and boxes stay visible with the stylesheet removed; collapsed nodes are dashed. A side column with status `failed` shows the reason as wrapped text where its nodes would be; `partial` renders the nodes found plus the reason under the header. A column with any package-granular edge shows "edges at package granularity" under its header.++The SVG sits in `<div class="blast-scroll">` with `overflow-x: auto`. Below it: the legend as a swatch row, then the collapsed-member `<ul>`, then the `skipped` list.++Identifiers: `<digest>` is `common.digest(path)`, so `n-<digest>` and `file-<digest>` share the hash. Edges carry `class="edge e-<src> e-<dst>" data-from data-to`. Per-node hover rules go into a `<style>` inside the section: `.blast:has(#n-x:hover) .edge:not(.e-x){opacity:.15}` and `.blast:has(#n-x:hover) .edge.e-x{stroke-width:2}`. Every path and label passes through `common.escape`.++### `scripts/blast_radius.py`++```+blast_radius.py --repo DIR --snapshot (SHA|working-tree) --base SHA+ [--remote OWNER/REPO] [--ecosystems FILE] [--tools] --out DIR+```++Writes `DIR/diagram.json` and `DIR/diff-tests.json`. Steps:++1. Changed files: `git diff --name-status -M -C -z <base> [<snapshot>]`, mapping `C` to added and `T` to modified, plus `git ls-files --others --exclude-standard -z` as added for the working tree; with `--remote`, the compare API's `files[]` (`status`, `filename`, `previous_filename`).+2. Tree listing: `git ls-tree -r -l -z <sha>` parsed with `partition("\t")` (mode, size) or `git ls-files -z` plus untracked for the working tree; with `--remote`, `git/trees/<sha>?recursive=1`, and a `truncated: true` response sets both columns to `failed: tree listing truncated`. Skip modes `120000` and `160000`, blobs over 1 MB (recorded in `skipped`), and files whose extension matches no row.+3. Blob reads: one `git cat-file --batch` process for SHA trees; direct disk reads for the working tree; `git/blobs/<sha>` for `--remote`, capped at 500 calls, after which dependents scanning stops with status `partial: remote scan cap reached`. Dependencies need only the changed files' blobs and are unaffected.+4. Group per file from the row's `unit` rule; test flag from `test_files`.+5. Imports scanned with the row's patterns and resolved by the named resolver; edges kept only when they touch a changed file, each carrying `method`, `granularity` (`file` for `relative` and `roots`, `package` for `unit`), and `tree`. Deleted files and renamed old paths are scanned in the base tree with `tree: "base"`. A column with no edges because no row for the changed files has `imports` gets status `failed: no import patterns for <extensions>`; a column with no edges after a complete scan is `complete` and renders empty.+6. With `--tools`, the row's `tool.deps` runs in `--repo` and its edges replace scanned edges for the same pair with `method: "tool:<name>"` and the tool's `granularity` from the row.+7. Diff-derived tests: for each changed test file, the diff of that file is scanned with `test_decl`; names on added lines are `added`, on removed lines `removed`, and test files whose row has no `test_decl` are listed in `unpatterned_files`.+8. Write both files.++Resolvers: `relative` resolves a path relative to the importing file, trying `extension_map` substitutions, then `extensions`, then `index_files`; `roots` splits the import on `separator`, joins under each `source_roots` entry, and retries with the last segment dropped once (a symbol import); `unit` maps an import to a unit directory through the row's `unit` rule and expands to every file in it.++### `scripts/ecosystems.json`++Keys the script reads: `extensions`, `test_files`, `test_decl` (group 1 is the name, else the whole match with the leading keyword removed), `unit` (`kind`: `directory` | `target_root` | `module_file`; `module_file`, `module_regex`, `target_root`), `imports` (`regex`, `resolve`, `separator`), `source_roots`, `index_files`, `extension_map`, `tool` (`name`, `deps`, `format`: `go-list-json` | `pairs`, `granularity`). Keys the agent reads: `runners[]` with `name`, `detect` (`files` globs or `package_json_keys`), `recipe`, `requires`, `coverage_format`, `install`, `junit_flags`, `env`, `config_files`, and the row's `notes`.++```jsonc+{+ "go": {+ "extensions": [".go"], "test_files": ["_test\\.go$"], "test_decl": "^func ((?:Test|Fuzz|Benchmark)\\w+)\\s*\\(",+ "unit": {"kind": "module_file", "module_file": "go.mod", "module_regex": "^module\\s+(\\S+)"},+ "imports": [{"regex": "^\\s*(?:import\\s+)?(?:[\\w.]+\\s+)?\"([^\"]+)\"", "resolve": "unit"}],+ "tool": {"name": "go list", "deps": "go list -json ./...", "format": "go-list-json", "granularity": "package"},+ "runners": [{"name": "gotestsum", "detect": {"files": ["go.mod"]},+ "recipe": "gotestsum --junitfile {junit} -- -coverprofile={coverage} -coverpkg=./... ./...",+ "requires": ["go", "gotestsum"], "coverage_format": "coverprofile", "install": "go mod download",+ "junit_flags": ["--junitfile"]}]+ },+ "typescript": {+ "extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],+ "test_files": ["\\.(test|spec)\\.[tj]sx?$", "(^|/)__tests__/"],+ "test_decl": "^\\s*(?:it|test)\\(\\s*['\"`]([^'\"`]+)",+ "unit": {"kind": "directory"},+ "imports": [{"regex": "(?:from|import|require\\()\\s*['\"]([^'\"]+)['\"]", "resolve": "relative"}],+ "index_files": ["index.ts", "index.tsx", "index.js"],+ "extension_map": {".js": [".ts", ".tsx", ".js"], ".jsx": [".tsx", ".jsx"]},+ "runners": [+ {"name": "vitest", "detect": {"files": ["vitest.config.*"]},+ "recipe": "npx --no-install vitest run --reporter=junit --outputFile={junit} --coverage --coverage.reporter=lcov --coverage.reportsDirectory={inputs}/cov",+ "requires": ["npx"], "coverage_format": "lcov", "install": "npm ci", "junit_flags": ["--reporter=junit"]},+ {"name": "jest", "detect": {"files": ["jest.config.*"], "package_json_keys": ["jest"]},+ "recipe": "npx --no-install jest --ci --reporters=default --reporters=jest-junit --coverage --coverageReporters=lcov --coverageDirectory={inputs}/cov",+ "requires": ["npx"], "coverage_format": "lcov", "install": "npm ci", "junit_flags": ["--reporters=jest-junit"],+ "env": {"JEST_JUNIT_OUTPUT_FILE": "{junit}"}}+ ]+ }+}+```++`npx --no-install` fails rather than downloading a runner the project does not declare. Initial rows: Go; Python (`roots` resolver with `source_roots: [".", "src"]`, `separator: "."`, runner pytest with `--junitxml={junit} --cov --cov-report=xml:{coverage}`); TypeScript/JavaScript as above; Swift (`unit` resolver with `target_root: "Sources"`, runner `swift test --enable-code-coverage --xunit-output {junit}` followed by `xcrun llvm-cov export -format=lcov` into `{coverage}`); Rust (`mod\s+(\w+);` with `relative` and `index_files: ["mod.rs"]`, `use crate::` with `roots` and `separator: "::"`, runner nextest with a `config_files` template `nextest.toml` written to `{inputs}` and passed as `--config-file`, run as one `cargo llvm-cov nextest --lcov --output-path {coverage}` command so the suite executes once). Known holes, recorded on the row as `notes`: Swift files inside one target never import each other, so only cross-target edges appear; Xcode projects without `Sources/<Target>` yield no unit and fall to directory grouping; `go list` needs a resolvable module graph and its failure leaves the scanned edges in place with a warning. A row without `runners` still supports diagrams.++## Data Models++### Top-level keys++```jsonc+"change_classification": "code", // or "docs-only"+"diagram_file": "diagram.json", // relative to the diff directory+"tests": { … }+```++### `tests` block++```jsonc+"tests": {+ "provenance": {"source": "ci", // ci | local+ "run_ids": [123], "run_urls": ["…"], "timestamp": "…",+ "snapshot": {"sha": "…", "dirty": false},+ "ci_state": "artifacts usable", // no run | run in progress or queued | run failed before upload | artifacts expired | artifacts absent | artifacts usable+ "fallback_state": "not needed"}, // not needed | ran | blocked by fork PR | blocked by no local clone | blocked by run in progress | timed out+ "baseline_provenance": {"source": "ci", "run_id": 120, "run_url": "…", "sha": "…"}, // or null+ "coverage_scope": "repository", // repository | project-configured+ "run_outcome": "passed", // passed | failed | timed_out | not_run+ "partial": false,+ "junit": ["123-test-results-ubuntu--junit.xml"],+ "coverage": ["123-test-results-ubuntu--coverage.out"],+ "baseline_junit": [], "baseline_coverage": [],+ "path_map": {"strip": null, "prepend": null},+ "jobs": [{"run_id": 123, "name": "test (ubuntu)", "outcome": "success", "url": "…"}],+ "artifacts": [{"name": "test-results-ubuntu", "run_id": 123, "junit": ["123-test-results-ubuntu--junit.xml"],+ "coverage": ["123-test-results-ubuntu--coverage.out"], "job": "test (ubuntu)"}],+ "pending_runs": [{"run_id": 124, "name": "integration", "status": "in_progress", "url": "…"}],+ "skipped_artifacts": [{"name": "build-output", "size_in_bytes": 412000000}],+ "run_touched_files": [],+ "diff_tests_file": "diff-tests.json", // or null; written by blast_radius.py+ "no_data_reason": null // no tests found | runner not detected | required tool missing | local run failed | local run timed out | ci+}+```++`no_data_reason: "ci"` tells the renderer to derive the reason from the CI and fallback states.++### `diagram.json`++```jsonc+{+ "snapshot_tree": "abc123…", // or "working-tree"+ "base_tree": "def456…",+ "nodes": [{"path": "pkg/a.go", "status": "modified", "group": "pkg", "is_test": false, "old_path": null}],+ "edges": [{"from": "cmd/main.go", "to": "pkg/a.go", "method": "expansion", "granularity": "package", "tree": "snapshot"}],+ "column_status": {"dependents": "complete", "dependencies": "complete"}, // complete | partial: <reason> | failed: <reason>+ "skipped": [{"path": "vendor/big.go", "reason": "blob over 1 MB"}]+}+```++`status` is `unchanged` for side-column nodes. `method` is `tool:<name>`, `import`, or `expansion`.++### `diff-tests.json`++```jsonc+{"added": ["TestFoo"], "removed": [], "unpatterned_files": ["spec/foo_spec.rb"]}+```++## Error Handling++`Warnings` is threaded through parsing and rendering; each warning goes to stderr as it happens and is listed at the end of the Tests section. `read_guarded` rejections warn and skip the input. A missing fragment keeps its existing placeholder. An unreadable or invalid review JSON prints one line naming the file and the error and exits with status 2. An invalid or missing `diagram.json` warns and omits the section. The two `summary` lines are printed by `render()` after everything else.++## Testing Strategy++`make test` runs the unittest suite with fixtures in `scripts/tests/fixtures/`. Property-style tests use `random.Random(seed)` generators over 200 cases each.++| Area | Tests |+|------|-------|+| Golden fixture (6.1) | `fixtures/golden.json` exercises every existing section; `fixtures/golden.html` is generated once by running `git show 9da40cf:scripts/build_review_html.py` against it. The test renders with the current package, replaces the `<style>` contents and the `Generated …` footer line in both, and asserts equality |+| JUnit (2.1, 2.2) | nested `testsuites`, empty `classname`, Surefire `flakyFailure` and `rerunFailure`, pytest `rerun` and per-attempt duplicate elements collapsing to one flaky case, `skipped`, message fallback to text |+| Coverage parsers (2.3, 2.4) | one fixture per format, Cobertura with two `source` roots, coverprofile `set` and `count` with overlapping blocks, lcov with repeated `SF:`; `overall` counts each line once |+| Matching (2.5, 2.6) | the five passes, including `util.py` plus `a/util.py` against changed `src/a/util.py` and `src/util.py`, where the shared `util.py` leaves both pools and `a/util.py` matches uniquely; exact wins and leaves the pool; shared candidate removed once without cascade and reported ambiguous; distinct residuals → ambiguous; `path_map` on aliases; property: every changed file maps to at most one merged entry and every entry to at most one file, and the result is independent of input order |+| Diff coverage (2.7) | zero denominator → `None`; aggregate is line-weighted |+| Hunk parsing (3.8) | added line numbers across hunks, renames, `\ No newline` markers; binary detection; `/dev/null` fragments for untracked files |+| Redaction (3.4) | each pattern including `AWS_SECRET_ACCESS_KEY=` and bare `KEY=`; redaction precedes truncation |+| New/removed (2.9, 1.12) | baseline set difference with cross-source note; diff-derived names from `diff-tests.json` |+| Projection (4.6 to 4.8) | test counts include changed test files, collapse only above 3 and only for package-granular nodes, cap ranking with path ties, centre never capped; property: same description twice → identical bytes; random descriptions never yield a side column over 15 or an empty column without a `failed` status |+| Layout (5.4, 5.6, 5.7, 5.9) | property: every `textLength` + 2·PAD ≤ its box width including badges and group labels; budgets compute to 37 and 30; declared width equals 1036; centre-to-centre paths stay inside the lane; changed nodes wrapped in `<a>`; reverse-direction edges attach on the correct sides; every fill and stroke carries a literal fallback |+| Escaping (5.10) | paths containing `<`, `&`, `"`, `$` |+| Error handling (2.10, 2.11) | `read_guarded` on every input type: DOCTYPE rejection, 50 MB rejection via a sparse file, non-UTF-8 fragment; invalid JSON exits 2 |+| Stderr contract (3.12, 2.8) | the two `summary` lines are last even when a diagram warning fires; `summary tests:` excludes baseline cases |+| `blast_radius.py` | a temp repository with `git init` holding Go, Python, TypeScript, and Rust files; edges, methods, granularity, groups, deleted-file edges from the base tree, untracked files as added, copies and type changes, test flags, 1 MB skip recorded, `diff-tests.json` contents, `column_status` values |+| Timing (2.12) | generated 10 MB lcov and 5,000-case JUnit; skipped when `os.getloadavg()` is unavailable or its first value exceeds the CPU count |
diff --git a/specs/review-html-tests-diagram/implementation.md b/specs/review-html-tests-diagram/implementation.mdnew file mode 100644index 0000000..47e7dc8--- /dev/null+++ b/specs/review-html-tests-diagram/implementation.md@@ -0,0 +1,109 @@+# Implementation: review-html-tests-diagram++## Beginner Level++### What Changed++The review pages that three skills produce (`pr-review-html`, `pr-overview`, `pre-push-review`) used to say what changed and what the reviewers thought of it, but nothing about whether the tests pass or how far the change reaches. This branch adds two things to those pages.++The first is a **Tests card and section**. Think of a school report card: the top shows a headline (pass rate, new tests, coverage of the new lines) and the section below shows the detail (which run produced the numbers, which jobs ran, which tests failed and why, which tests are new, and a per-file table of how many added lines the tests executed). Inside each file's diff, added lines the tests never ran get a red mark in the margin.++The second is a **blast-radius diagram** with three columns. The middle holds the changed files; the left holds files that import them (what could break); the right holds files they import (what they lean on). Boxes are grouped by package or directory, and each changed box links to its diff.++To make this work, the 900-line `scripts/build_review_html.py` became a thin front door over a `scripts/review_html/` package, a new `scripts/blast_radius.py` reads git trees to find imports, `scripts/ecosystems.json` tells the script and the skills how each language works, and a `make test` harness of 226 tests checks it.++### Why It Matters++A reviewer no longer has to open CI in another tab to learn the suite is red, or guess whether a one-line change in a shared helper touches five files or fifty. The numbers come from the exact tree the diffs describe, so a coverage mark on line 42 refers to the line 42 the reader is looking at.++### Key Concepts++- **JUnit XML** is the common language most test runners can speak for results; **lcov**, **Cobertura**, and **Go coverprofile** are the common languages for coverage. The renderer reads those four formats and knows nothing about pytest, jest, or `go test`.+- **Snapshot and base**: the two trees being compared. Diffs, test results, and the diagram all describe the same snapshot.+- **Diff coverage**: of the lines this change added, how many did a test execute? It is a stricter question than overall coverage.+- **One-hop graph**: only direct importers and direct imports appear, never importers of importers.++---++## Intermediate Level++### Changes Overview++Nine commits in four phases, plus uncommitted review fixes.++**Phase 1 (`aa085b0`)** splits the renderer into `review_html/` (`common.py`, `sections.py`, `css.py`, `template.py`, `render.py`, `inputs.py`, `warnings.py`, `diffs.py`) and adds the harness. `scripts/tests/fixtures/golden.html` was generated by the renderer at `9da40cf` and the golden test asserts byte equality after blanking the `<style>` block and the timestamp (Q13, Q31). `inputs.read_guarded` is the only file reader: 50 MB cap by `stat`, DOCTYPE rejection for XML, UTF-8 check, each refusal a named warning (Q20, Q65).++**Phase 2 (`6db2bed`)** adds `diagram.py` (`project` → `layout` → `render_diagram`), `blast_radius.py`, and the script-read half of `ecosystems.json`. Projection applies test exclusion, expansion collapse above 3, and the 15-node cap in that order (Q28); layout uses fixed 308 px columns with label budgets 37 and 30 characters at a 7.2 px advance (Q36, Q56), every `<text>` declaring `textLength` (Q25).++**Phase 3 (`d46ef12`)** adds `junit.py`, `coverage.py`, `redact.py`, and `tests_section.py`, and wires the Tests card, section, uncovered marks, and two `summary` stderr lines into `render()`.++**Phase 4 (`21c51f7`)** adds the runner rows to `ecosystems.json` (gotestsum, pytest, vitest, jest, swift test, cargo nextest via llvm-cov) with a schema test, and rewrites the three SKILL.md files: pinned head SHA, CI artifact collection with an ordered CI-state rule list (Q45), the worktree fallback for same-repo PRs, recipe selection by reading Makefile text (Q60), pre-run copies for restore (Q38), and the severity floor applied by rendering twice (Q40).++**Uncommitted fixes** from this review: `diffs._walk` no longer treats an added line beginning `++` as a file header (it tracks whether it is inside a hunk); the credential pattern in `redact.py` accepts a closing quote before `=`/`:` so JSON-shaped secrets are caught; `coverage.match` indexes entries by path and by last segment, turning an O(files × entries) scan into a lookup (11 s → 0.1 s at 20k entries); `build_review_html.py` exits 2 with one `error:` line on malformed review JSON; `inputs.read_json` and `inputs.xml_root` replace four copies of the same guard; `pre-push-review` diffs against `git merge-base`; and `blast_radius.is_test_file`'s fallback rule became whole-token (Q85) after the substring rule flagged `specs/` files as tests.++### Implementation Approach++The renderer parses formats and the skills map ecosystems (Decision 2). Every new input is a file referenced by name from the review JSON and resolved against `--diff-dir`; the skill never transcribes a graph or a test list through its context (Q57). `blast_radius.py` reads trees through `git ls-tree` plus one `git cat-file --batch` process, the working tree, or the GitHub trees and blobs API (`--remote`, capped at 500 blob calls, Q47). Edges carry `method`, `granularity`, and `tree` so the page can say "edges at package granularity" (Decision 4, Q63). Deleted files and old rename paths are scanned in the base tree with `old_to_new` mapping so their edges land on the new node.++Coverage matching is five global passes (exact, pools with residuals, shared removal once, residual check, merge), so the result is order-independent and a doubtful match is reported as `ambiguous` rather than guessed (Q19, Q58, Q73).++### Trade-offs++- A three-column SVG with no layout engine keeps the page dependency-free but only fits this diagram shape; wide changes are capped and collapsed (Decision 1).+- Unit expansion gives Go and Swift a dependents column at the cost of overstating reach; the page labels it.+- `pr-overview` now executes same-repo branch code in a throwaway worktree, disclosed in its skill text; fork PRs get the no-data card (Decision 3).+- The severity floor lives in the skill, keeping verdict and publish metadata pass-through, at the price of a second render (Q15, Q40).++---++## Expert Level++### Technical Deep Dive++`render()` runs load fragments → `build_tests` → `build_diagram` → `render_files(files, fragments, uncovered)`, because the uncovered sets come out of the Tests build. `diffs._walk` is one generator shared by `added_lines` and `render_diff`, so the line numbers used for coverage lookup are the ones the marks land on; the `++` fix relies on `diff --git` resetting `next_new` to `None`, which keeps a fragment's `+++ b/x` line a header.++`junit._parse_one` keys cases by `(classname or suite name, name)` per source file and lets the last element win; a pass after an earlier failure or any rerun/flaky child sets `flaky` (Q41, Q59). `source` is the file name, which `_jobs_table` joins to `artifacts[].junit` and then to `jobs[].name` via `artifacts[].job`; attribution itself (Q46) is done by the skill.++`coverage.match` pass 2 only consults entries whose last path segment equals the changed file's, since a whole-segment suffix relation requires equal basenames; that is the speed-up, and it preserves the five-pass semantics. `overall()` merges by normalised primary path only, not aliases.++`diagram.project` decides column by edge direction (a node with edges both ways is a dependent, its dependency edge drawn in the left gutter per 5.2), folds `all_package` per path as vacuously true, and gives collapsed and `+N more` nodes ids that are `digest` of the newline-joined member list so hover rules and the member list agree. `+N more` is a 16th box counting files, not nodes (Q67, Q68).++`blast_radius._build` scans changed files with `essential=True` so the remote cap never refuses them (Q72); `--tools` edges replace scanned pairs through `add_edge(..., replace=True)`. `Resolver._roots` retries with the last segment dropped so `from a.b import c` resolves whether `c` is a module or a symbol (Q71).++### Architecture Impact++`~/.claude/scripts` is a symlink into the repo, so the entry point puts `Path(__file__).resolve().parent` on `sys.path` before importing the package (Q33). `read_guarded` is the single trust boundary for every external input, including `diagram.json`. The `tests` block, `diagram_file`, and `change_classification` are additive top-level keys; the golden test guarantees pages without them are unchanged. Rows added to `ecosystems.json` extend both the script and the agent without code changes; `test_ecosystems.py` validates both key sets.++### Potential Issues++- `blast_radius.diff_test_names` still drops lines starting `+++`/`---` (the rule just corrected in `diffs._walk`), so an added test whose line starts `++` is missed; multi-line `test_decl` patterns (Rust `#[test]\nfn`) only match when both lines are in the same added or removed set.+- `_availability` counts JUnit files that yielded at least one case, so a valid but empty JUnit file reads as "0 of 1 files read".+- `_jobs_table` attributes by job name only; two runs with a job of the same name merge into one row.+- `overall()` can double count a file that appears under two different primary paths across inputs (Cobertura with and without a source root).+- `--tools` runs `go list -json ./...` with `shell=True` in the checkout, which can trigger module downloads; it is only passed against trees where the suite already runs.+- Group 1 is skill prose; nothing in the harness exercises artifact collection, the worktree lifecycle, or the restore procedure.+- Requirement 1.10's text was amended during review to cite Q46; the spec moved toward the implementation rather than the reverse.++---++## Completeness Assessment++### Fully implemented++- **Group 2 (2.1 to 2.7, 2.9 to 2.12)**: nested suites, flaky elements, three coverage formats with per-line maxima across overlapping blocks, mapping → matching → merging, `path_map`, zero-denominator exclusion, cross-source note, malformed-input warnings with exit 0, exit 2 on bad review JSON, DOCTYPE and 50 MB rejection, Python 3.9 stdlib only (`str.removeprefix` is the newest call; verified on 3.9.6), timing tests with a load-average skip.+- **Group 3 (3.1 to 3.5, 3.7 to 3.12)**: card, section with provenance and independent availability states, per-job and per-artifact rows, failed-test table with `clean_message` (Q74), new/removed by identity or declaration name, overall delta, uncovered marks via `render_files`, no-data card with the upload sentence (Q77), docs-only suppression, severity floor documented in all three skills.+- **Group 4 (4.1 to 4.12)**: full one-hop graph, C→added and T→modified, base-tree edges for deleted and renamed files, `group` and `is_test` per node, three recorded methods, `tree` per edge, expansion collapse and package note, test exclusion with counts, cap ranked by `(-edges, path)`, no out-of-repo nodes, `failed:` reasons rendered in place, deterministic output (property-tested), `--remote` and fetch-based reads.+- **Group 5 (5.1 to 5.12)**: stdlib SVG, three grouped columns with in-column centre edges, `--accent-2` for modified (Q35), `n-<digest>` ids linking to `file-<digest>`, `data-from`/`data-to`, `textLength` labels shortened from the left with `<title>`, budgets 37 and 30 stated in the design, collapsed members as text, explicit width and height in `.blast-scroll`, escaping, own TOC entry before diffs, absent or invalid file warns and exits 0.+- **Group 6 (6.1 to 6.6)**: golden fixture at `9da40cf`, keys documented in all three skills with the highlight.js and malformed-JSON claims removed, unchanged command line, single `--diff-dir` input mechanism, `make test`.+- **Group 1**: every criterion has corresponding skill text (pinned SHA, ordered CI states, fallback and blocked states, tiered recipe selection reading text only, restore from pre-run copies, 600,000 ms budget with partial labelling, prune-only cleanup, token-set attribution, baseline at or before the merge base, separate provenance fields, pending runs).++### Partially implemented++- **2.8**: the Tests section lists each unmatched file with its reason, but stderr carries only `matched=N unmatched=N` (Q84). The requirement text asks for both places; the deviation is recorded, not resolved in the requirement.+- **3.6**: with no coverage input parsed the per-file table is omitted entirely (Q83) rather than shown with "no coverage data" rows. Reasonable, but it is a deviation from "SHALL show a per-file table".+- **1.12**: diff-derived names work for single-line declarations on ordinary lines; the `+++`/`---` prefix rule and multi-line patterns leave gaps noted above.+- **Group 1 verification**: implemented as instructions only; there is no test that exercises artifact download, worktree creation and removal, or the restore procedure, so correctness rests on the agent following the prose.++### Missing++Nothing from the numbered criteria is absent. The two items closest to missing are the `blast_radius.diff_test_names` header rule (an inconsistency with the corrected `diffs._walk`, not a requirement gap) and the lack of any executable check for group 1.
diff --git a/specs/review-html-tests-diagram/requirements.md b/specs/review-html-tests-diagram/requirements.mdnew file mode 100644index 0000000..2c29114--- /dev/null+++ b/specs/review-html-tests-diagram/requirements.md@@ -0,0 +1,163 @@+# Requirements: review-html-tests-diagram++## Introduction++The HTML review pages produced by `pr-review-html`, `pr-overview`, and `pre-push-review` describe a change through findings, explanations, and diffs, but say nothing about whether the tests pass, what the change adds in test coverage, or where the change sits in the codebase. This feature adds a test results section, sourced from GitHub Actions artifacts or a local run, and a blast-radius diagram showing the changed files with the files that depend on them and the files they depend on. Both must work on any repository and language: the renderer understands data formats, and the skills map each ecosystem onto those formats.++## Non-Goals++- Modifying any repository's CI configuration. The page tells the user what a workflow must upload; users make that change themselves.+- Type-level class diagrams and infrastructure diagrams. Nodes in this version are files.+- Parsing free-form test runner logs. Only structured formats are read.+- Retrieving CI artifacts from anything other than GitHub Actions. GitLab support arrives when the review skills become forge-agnostic, as a separate feature.+- Executing code from fork pull requests in `pr-overview`'s local fallback. `pr-review-html` checks out and runs any PR it is asked to review, fork or not, and its skill text says so.+- Coverage trends across reviews, or enforcing coverage thresholds.+- Any JavaScript in the review page. The page remains static HTML, CSS, and inline SVG.+- Committing, pushing, or resolving anything on the reviewed branch as part of collecting test data.+- Opening a collapsed per-file diff when its anchor is followed. Existing links have the same behaviour.++## Definitions++- **Snapshot**: the exact tree that the page's diffs, test results, and coverage all describe: a pinned commit SHA for `pr-overview`; the working tree for the other two skills, identified by the HEAD SHA plus a dirty flag.+- **Base tree**: the tree the change is compared against: the merge-base commit for a PR, the remote tracking branch for `pre-push-review`.+- **Baseline**: test results and coverage produced for the base tree, used for new/removed tests and the overall coverage delta.+- **Same-repo PR**: a pull request whose head branch lives in the same repository as its base. A PR from a fork is not a same-repo PR.+- **Job directory**: a scratch directory outside every repository working tree, such as the harness job directory, used for worktrees, fetched artifacts, generated outputs, and the review JSON with its referenced inputs.+- **Ecosystem table**: one shared reference file, used by all three skills, with one row per runner or language giving the test recipe that emits JUnit XML and, where the runner supports it, a supported coverage format with repository-wide scope; the test-file path pattern; the test-declaration pattern; the unit that groups files (package, module, or target) and how to resolve an import to it; and the dependency tool if one exists.+- **Test file**: a file matching the ecosystem table's test-file pattern for its language, or, with no matching row, a file whose name or nearest directory contains `test`, `tests`, `spec`, or `__tests__`.+- **Docs-only change**: every changed file is documentation (`.md`, `.rst`, `.adoc`, or a file at any depth under a `docs/` directory), a file named `README`, `CHANGELOG`, `LICENSE`, `CONTRIBUTING`, or `CODEOWNERS` with any extension, an image, a lockfile, or an editor or VCS dotfile such as `.gitignore` or `.editorconfig`. Any other changed file, including `.txt` files elsewhere, CI workflows, build configuration, and dependency manifests, makes the change a code change.+- **Usable artifacts**: at least one artifact of the run containing a JUnit XML file. Coverage files are used when present but are not required.+- **CI state**: one of no run, run in progress or queued, run failed before upload, artifacts expired, artifacts absent, or artifacts usable, for the runs of the head SHA.+- **Fallback state**: one of not needed, ran, blocked by fork PR, blocked by no local clone, blocked by run in progress, or timed out, for the local run in `pr-overview`.+- **Suite**: a JUnit test case's `classname` attribute, or the enclosing `testsuite` name when `classname` is absent or empty.+- **Test identity**: the tuple (suite, name). Across CI jobs the same identity may appear once per job with its own outcome.+- **Pass rate**: passed divided by (passed + failed + errored), shown as a percentage. Skipped tests are excluded from both terms. With a zero denominator the pass rate is shown as `n/a`.+- **Flaky**: a passed test that carries rerun or flaky elements. Flaky is a label on a passed test, not a fifth outcome; passed, failed, skipped, and errored sum to the case count.+- **Aggregate diff coverage**: the sum of covered added lines divided by the sum of measurable added lines across all changed files with coverage data, so it is line-weighted rather than a mean of per-file percentages.+- **Overall coverage**: covered lines divided by instrumented lines across every file in the coverage data, after summing hits per line across all inputs.+- **Group**: the unit from the ecosystem table that contains a file; the file's directory when no row applies.+- **Label budget**: the maximum number of characters a node label may occupy, derived per [5.7](#5.7).+- **Content width**: the page's maximum content width, currently 1036 px (1100 px page width minus 32 px padding each side).++## Applicability++Groups 2, 5, and 6 describe the renderer and apply to every page; a criterion whose WHEN guard never fires for a skill's input is satisfied vacuously. Group 1 and the skill-specific criteria elsewhere bind as follows.++| Skill | Group 1 | Skill-specific elsewhere |+|-------|---------|--------------------------|+| pr-review-html | 1.1, 1.5 to 1.8, 1.11 to 1.13 | 3.1 to 3.12 except 3.3; 4.1 to 4.11 |+| pr-overview | 1.2 to 1.15 | 3.1 to 3.12; 4.1 to 4.12 |+| pre-push-review | 1.1, 1.5 to 1.8, 1.12, 1.13 | 3.1 to 3.12 except 3.3; 4.1 to 4.11 |++## Requirements++### 1. Test Data Collection++**User Story:** As a reviewer, I want the review to gather test results and coverage for the exact code the page describes without altering the repository, so that the numbers on the page are true of the diff I am reading.++**Acceptance Criteria:**++1. <a name="1.1"></a>`pr-review-html` and `pre-push-review` SHALL choose the test command per [1.5](#1.5) before their existing verification run, so that one run both verifies the fixes and emits JUnit XML and, where the recipe supports it, coverage; the page's per-file diffs SHALL be generated from the working tree against the base tree after the fixes are applied, not from the remote PR diff +2. <a name="1.2"></a>`pr-overview` SHALL pin the PR head commit SHA at the start, take its diffs against the base tree from that SHA, and first look for test data in usable artifacts of the GitHub Actions runs for that SHA, identifying JUnit and coverage files inside artifacts by their content rather than by name +3. <a name="1.3"></a>IF the CI state permits a fallback per [1.14](#1.14), the PR is a same-repo PR, and the current directory is a clone of the PR's repository THEN `pr-overview` SHALL fetch the pinned SHA, create a detached git worktree at that SHA in the job directory, install dependencies and run the tests there, and its skill text SHALL state that this executes the branch's install scripts and tests on the reviewer's machine with the reviewer's environment and credentials +4. <a name="1.4"></a>IF the CI state permits a fallback and the PR is not a same-repo PR, or there is no local clone THEN `pr-overview` SHALL NOT run any code from the branch, and SHALL record the corresponding blocked fallback state +5. <a name="1.5"></a>The skill SHALL select the test command without executing candidates, in this order: a Makefile target that the project documents as emitting JUnit XML, the project's own instructions, then the ecosystem table; a Makefile target or project instruction that does not emit JUnit XML SHALL be passed over in favour of the ecosystem recipe; IF no candidate applies THEN the reason SHALL be recorded as runner not detected or required tool missing +6. <a name="1.6"></a>WHEN the ecosystem recipe is used, coverage SHALL count hits from every test in the repository, not only tests in the changed file's own package; WHEN a project-supplied command is used, coverage SHALL be used as produced and the page SHALL state that coverage scope is as the project configures it +7. <a name="1.7"></a>Every run SHALL write JUnit and coverage outputs into the job directory, outside any working tree, and the skill SHALL read them before any worktree is removed; IF a run in the user's checkout changes tracked files THEN the skill SHALL restore them to their pre-run content without discarding uncommitted changes, and report which files were touched +8. <a name="1.8"></a>IF a run, including dependency installation, exceeds a total budget of 10 minutes THEN the skill SHALL stop it, use any JUnit XML already written labelled as partial, and record the timeout; in `pr-review-html` and `pre-push-review` the verdict SHALL then state that fix verification is incomplete +9. <a name="1.9"></a>A worktree created under [1.3](#1.3) SHALL live in the job directory under a recognisable name and SHALL be removed with git's worktree removal and pruned after the run, including on failure or timeout; before creating one, the skill SHALL prune only worktree entries whose directory no longer exists, and SHALL never remove a worktree that still exists on disk or is locked +10. <a name="1.10"></a>WHEN the head SHA has several CI jobs, the skill SHALL record every job's name and outcome from the CI API; test counts SHALL be attributed to a job only when the artifact name contains the job name (token-set inclusion per Q46), and otherwise SHALL be attributed to the artifact +11. <a name="1.11"></a>`pr-overview` and `pr-review-html` SHALL look for a baseline in the artifacts of the run for the merge-base commit, or the nearest successful base-branch run at or before it, SHALL NOT use a later base-branch run, and SHALL record the baseline's provenance separately from the head's +12. <a name="1.12"></a>IF no baseline exists THEN new and removed tests SHALL be derived from the diff as the names of test declarations added and removed in test files, using the ecosystem table's declaration pattern, labelled as diff-derived; files with no applicable pattern SHALL yield nothing and the page SHALL say so +13. <a name="1.13"></a>The skill SHALL record provenance as separate fields: source kind (CI or local), run identifier and URL or local timestamp, the snapshot identifier, the CI state, and for `pr-overview` the fallback state +14. <a name="1.14"></a>The CI states no run, run failed before upload, artifacts expired, and artifacts absent SHALL each permit the fallback in [1.3](#1.3); the states run in progress or queued and artifacts usable SHALL NOT, and an in-progress run SHALL be recorded as the blocked fallback state +15. <a name="1.15"></a>WHEN some runs for the head SHA have finished with usable artifacts and others are still in progress or queued, `pr-overview` SHALL use the finished artifacts, record the CI state as artifacts usable, and the page SHALL note the pending runs ++### 2. Format Parsing++**User Story:** As a maintainer of the renderer, I want the script to read standard test and coverage formats rather than know about runners, so that new languages work without changing the script.++**Acceptance Criteria:**++1. <a name="2.1"></a>The renderer SHALL read JUnit XML from one or more files, including nested `testsuites` elements, and SHALL produce per-test outcomes of passed, failed, skipped, or errored with suite, name, and failure message +2. <a name="2.2"></a>A test case SHALL count as failed or errored only when its final outcome is a failure or error; rerun and flaky elements (`flakyFailure`, `flakyError`, `rerunFailure`, `rerunError`) recorded on a case that ultimately passed SHALL NOT make it failed, and SHALL mark it flaky +3. <a name="2.3"></a>The renderer SHALL read lcov, Cobertura XML, and Go coverprofile files in any cover mode, and SHALL produce per-file, per-line hit counts, treating a line as covered when any block containing it has a non-zero count +4. <a name="2.4"></a>The renderer SHALL apply the path mapping from [2.6](#2.6), then match entries to changed files per [2.5](#2.5), then sum per-line hit counts of every entry matched to the same changed file, including entries repeated within one input, so merged and per-job files give the same result as one combined file +5. <a name="2.5"></a>The renderer SHALL associate each changed file with the coverage entries that name it, after normalising separators and removing `./` prefixes: entries whose path equals the changed file's path SHALL match; otherwise an entry SHALL match when one path is a suffix of the other on whole path segments; WHEN an entry is a suffix candidate for more than one changed file it SHALL match none, and WHEN a changed file has suffix candidates with differing leading segments it SHALL be treated as unmatched rather than guessed +6. <a name="2.6"></a>The review JSON MAY supply a path mapping (a prefix to strip and a prefix to prepend) applied to coverage paths before matching, so that a repository unresolvable by suffix matching is handled without changing the renderer +7. <a name="2.7"></a>The renderer SHALL compute diff coverage per changed file as covered added lines divided by added lines present in the coverage data; a file with a zero denominator SHALL be reported as having no measurable added lines and excluded from the aggregate +8. <a name="2.8"></a>The renderer SHALL report on stderr and in the Tests section how many changed files matched, and for each unmatched file whether no candidate or an ambiguous match was the cause +9. <a name="2.9"></a>WHEN JUnit results exist for both baseline and head, the renderer SHALL list identities present only in the head as new and identities present only in the baseline as removed, and WHEN the two have different source kinds the list SHALL carry a note that the comparison crosses sources +10. <a name="2.10"></a>IF a test or coverage input or a diff fragment is malformed, missing, or not valid UTF-8 THEN the renderer SHALL render the rest of the page, show a warning naming the file, and exit successfully; IF the review JSON itself is unreadable THEN the renderer SHALL exit non-zero with a message naming the problem +11. <a name="2.11"></a>The renderer SHALL reject any XML input containing a `DOCTYPE` declaration and any single input larger than 50 MB, reporting each as a warning, and SHALL use only the Python standard library on Python 3.9 or later +12. <a name="2.12"></a>In the test harness, the renderer SHALL parse a 10 MB lcov file and a 5,000-case JUnit file in under 5 seconds each, with the harness allowed to skip the timing assertion on hosts it detects as slow ++### 3. Test Results Section++**User Story:** As a reviewer, I want to see pass rate, new tests, and coverage of the changed lines in the review page, so that I can judge test health without opening CI.++**Acceptance Criteria:**++1. <a name="3.1"></a>The overview grid SHALL include a Tests card showing pass rate, number of new tests, and aggregate diff coverage, styled like the existing verdict and findings cards +2. <a name="3.2"></a>The page SHALL include a Tests section, listed in the table of contents, showing the provenance fields from [1.13](#1.13) with a link to the CI run when there is one, totals of passed, failed, skipped, and errored tests, and the flaky count alongside +3. <a name="3.3"></a>WHEN the head SHA has CI jobs, the section SHALL show one row per job with its name and outcome, and counts on that row when attributed to it per [1.10](#1.10) or on a separate per-artifact row otherwise, in addition to the aggregate totals +4. <a name="3.4"></a>The section SHALL list each failed or errored test with suite, name, and job or artifact when known, and its failure message truncated to 500 characters after the renderer replaces strings that match common secret patterns (bearer tokens, cloud access keys, `KEY=`, `TOKEN=`, `SECRET=`, `PASSWORD=` assignments, and URLs carrying credentials) with `[redacted]` +5. <a name="3.5"></a>The section SHALL list new and removed tests, by identity when baseline-derived and by declaration name when diff-derived, labelled with which +6. <a name="3.6"></a>The section SHALL show a per-file table of changed files with added lines, covered added lines, and diff coverage percentage, and SHALL show "no coverage data" rather than 0% for a file that matched no entry, matched ambiguously, or has no measurable added lines; deleted and binary files SHALL NOT appear in the table +7. <a name="3.7"></a>WHEN overall coverage exists for both baseline and head, the section SHALL show both values and the delta, noting when the two come from different source kinds; WHEN only head coverage exists it SHALL show that alone; otherwise nothing +8. <a name="3.8"></a>Within each per-file diff, added lines that have coverage data and zero hits SHALL carry a visible mark distinct from the existing add/delete colouring; added lines in files with no coverage data SHALL carry no mark; renamed files SHALL be matched by their new path +9. <a name="3.9"></a>The section SHALL show execution outcome, JUnit availability, coverage availability, and baseline availability as independent states, so that a run that failed but wrote results still shows those results +10. <a name="3.10"></a>IF the change is not docs-only and no test results could be obtained THEN the section SHALL render a card giving the reason as one of: no tests found, runner not detected, required tool missing, local run failed, local run timed out, or the CI state and blocked fallback state from [1.13](#1.13); WHEN the CI state is no run, artifacts absent, or artifacts expired the card SHALL state that the workflow must upload a JUnit XML file as an artifact, and a coverage file in a supported format to enable coverage +11. <a name="3.11"></a>IF the change is docs-only THEN the Tests section, its overview card, and the diagram SHALL be omitted, and the skill SHALL record the classification in the JSON +12. <a name="3.12"></a>IF any test in any job is failed or errored THEN the skill SHALL set the verdict tone to at least warning, mention the failures in the verdict detail, and set the publish severity to at least `needs-changes`; flaky tests and coverage values SHALL NOT change verdict or severity ++### 4. Blast-Radius Diagram Data++**User Story:** As a reviewer, I want to see which files a change touches, which files depend on them, and which files they depend on, so that I can judge the reach of the change and know how much to trust that picture.++**Acceptance Criteria:**++1. <a name="4.1"></a>The skill SHALL produce a diagram description containing the full one-hop graph: the changed files, every file that reaches any changed file through one dependency edge (dependents), every file any changed file reaches through one edge (dependencies), and edges between changed files; the renderer, not the skill, SHALL apply [4.7](#4.7), then [4.6](#4.6), then [4.8](#4.8), in that order +2. <a name="4.2"></a>Each changed node SHALL carry a status of added, modified, deleted, or renamed taken from the diff, with copies treated as added and type changes as modified; edges of a deleted file SHALL be derived from the base tree, and dependents of a renamed file SHALL include files in the base tree importing its old path +3. <a name="4.3"></a>Each node SHALL carry its group and a flag stating whether it is a test file +4. <a name="4.4"></a>Each edge SHALL be derived by exactly one method and SHALL record it: a dependency tool named in the ecosystem table; a file-resolving import, meaning an import statement that names a file or a module resolving to exactly one file; or unit expansion, meaning an import that names a package, module, namespace, or target, expanded to every file in that unit +5. <a name="4.5"></a>Each edge SHALL record which tree it was derived from, the snapshot or the base tree, and the description SHALL carry both tree identifiers +6. <a name="4.6"></a>WHEN any edge in a column came from unit expansion, the page SHALL state that those edges are at package granularity; WHEN more than 3 nodes in a side column share a group and each is connected to the changed files only by expansion edges, the renderer SHALL collapse them into one node labelled with the group and file count +7. <a name="4.7"></a>Nodes flagged as test files SHALL NOT appear in the dependents or dependencies columns; the renderer SHALL show on each changed node the count of test-file nodes with an edge to it, including changed test files; changed test files SHALL remain changed nodes +8. <a name="4.8"></a>WHEN a side column would exceed 15 nodes after [4.6](#4.6), the renderer SHALL keep the 15 ranked by edges to changed files descending then path ascending, with a collapsed group node ranked by its members' combined edges, and collapse the rest into one node labelled `+N more`; the centre column SHALL show every changed node and is never capped +9. <a name="4.9"></a>Files outside the repository (standard library, third-party packages) SHALL NOT appear as nodes +10. <a name="4.10"></a>IF no edge could be derived for a column because no method was available or no tree was readable THEN the description SHALL say so with the reason, and the page SHALL show the reason in place of that column; an empty column SHALL never be rendered when derivation failed +11. <a name="4.11"></a>The same diagram description SHALL always produce identical SVG output +12. <a name="4.12"></a>`pr-overview` SHALL read the pinned head commit and the base tree from git objects without checking either out into the user's working tree, fetching the head SHA's objects for any PR including forks, since fetching and reading blobs executes nothing; WHEN there is no local clone it SHALL read both trees through the forge's tree and blob API; the dependency-tool method SHALL be used only while a worktree from [1.3](#1.3) exists ++### 5. Blast-Radius Diagram Rendering++**User Story:** As a reviewer, I want the diagram to render anywhere the page is opened, so that it works in a browser, a feed reader, and offline without installing anything.++**Acceptance Criteria:**++1. <a name="5.1"></a>The renderer SHALL emit the diagram as inline SVG generated with the Python standard library only, with no external assets and no scripts +2. <a name="5.2"></a>The diagram SHALL lay out nodes in three columns, dependents left, changed centre, dependencies right, with nodes in each column grouped by their group and each group visually enclosed and labelled; edges between two changed files SHALL be drawn within the centre column; a file that is both a dependent and a dependency SHALL appear in the dependents column only, with its dependency edge drawn to it there +3. <a name="5.3"></a>Changed nodes SHALL be filled per status using the existing file-badge colours, except that the modified fill SHALL be distinguishable from the page's link colour; unchanged nodes SHALL use a neutral fill; a legend SHALL explain the fills and the collapsed-node styles +4. <a name="5.4"></a>Every node SHALL carry an identifier derived from the existing anchor hash of its path, or of its member list for a collapsed node, so identifiers are valid CSS selectors; each changed node SHALL link to that file's per-file diff anchor and SHALL show its test-file count from [4.7](#4.7) when greater than zero +5. <a name="5.5"></a>Every edge element SHALL name its source and target node identifiers as attributes, so the graph is recoverable from the markup by a reader with no CSS and no pointer; pointer emphasis on hover SHALL be CSS-only and additive, so that with the stylesheet removed every label and edge remains visible +6. <a name="5.6"></a>Node labels SHALL be the repository-relative path in the page's monospace font; a path exceeding the label budget SHALL be shortened from the left to a leading ellipsis plus the trailing characters that fit, with the full path in an SVG `title` element; each text element SHALL declare a `textLength` equal to its computed width and each node box SHALL be at least that width plus padding, so the label cannot exceed the box in any font +7. <a name="5.7"></a>The label budget SHALL be the largest character count for which three columns at that budget, plus padding and the two edge gutters, declare a width no greater than the content width; the design SHALL state the resulting budget and the advance constant it assumes +8. <a name="5.8"></a>Collapsed nodes' member paths SHALL be listed as visible text beneath the diagram in path order, not only in hover text +9. <a name="5.9"></a>The emitted SVG SHALL declare explicit width and height from the laid-out geometry, inside a container that scrolls horizontally when the width exceeds it; the page body SHALL never scroll horizontally +10. <a name="5.10"></a>Labels and titles containing characters special to XML or HTML SHALL render literally +11. <a name="5.11"></a>The diagram SHALL appear as its own section listed in the table of contents, positioned before the per-file diffs +12. <a name="5.12"></a>IF the diagram description is absent or invalid THEN the renderer SHALL omit the section, print a warning to stderr naming the problem, and exit successfully ++### 6. Contract, Compatibility, and Verification++**User Story:** As a user of the existing skills, I want existing review JSON to keep rendering as before and the new behaviour to be tested, so that the additions are safe to adopt.++**Acceptance Criteria:**++1. <a name="6.1"></a>A golden fixture SHALL be generated by the renderer at commit `9da40cf` from a review JSON that exercises every existing section, and review JSON without the new blocks SHALL produce a document identical to that fixture apart from the contents of the `style` element and the generation timestamp +2. <a name="6.2"></a>The new blocks SHALL be optional top-level keys in the review JSON, documented in all three skills' schema, rendering-contract, diff-generation, and error-handling sections, and those sections SHALL be corrected where they describe behaviour the renderer does not have +3. <a name="6.3"></a>The rendered page SHALL continue to satisfy the pulsar agent contract: self-contained, one `review-meta` block, no relative asset paths +4. <a name="6.4"></a>The renderer SHALL keep working when invoked with only the existing flags, and new inputs (JUnit files, coverage files, baseline files) SHALL be referenced by path from the JSON relative to the diff directory, which the skills SHALL place in the job directory, with no second input mechanism +5. <a name="6.5"></a>The renderer SHALL remain invocable as `python3 ~/.claude/scripts/build_review_html.py` with the existing command line, whatever its internal file layout +6. <a name="6.6"></a>The repository SHALL contain a test harness, runnable with one documented command, covering JUnit parsing including rerun elements, each coverage format, path mapping, matching including exact-match precedence and ambiguity, entry merging, diff coverage arithmetic, new/removed test derivation, secret redaction, malformed and non-UTF-8 input handling, DOCTYPE and size rejection, the projection rules in [4.6](#4.6) to [4.8](#4.8), diagram layout determinism and label-budget arithmetic, and the golden fixture from [6.1](#6.1)
diff --git a/specs/review-html-tests-diagram/tasks.md b/specs/review-html-tests-diagram/tasks.mdnew file mode 100644index 0000000..5393407--- /dev/null+++ b/specs/review-html-tests-diagram/tasks.md@@ -0,0 +1,236 @@+---+references:+ - specs/review-html-tests-diagram/requirements.md+ - specs/review-html-tests-diagram/design.md+ - specs/review-html-tests-diagram/decision_log.md+---+# review-html-tests-diagram++## Foundation++- [x] 1. Write the golden-fixture regression test and the test harness scaffold <!-- id:vt4kkmn -->+ - Create scripts/tests/__init__.py and scripts/tests/fixtures/golden.json exercising every existing section (pr_description, commits, explanation, important_changes with rationale_unknown and rationale_inferred, decisions, findings with all statuses, unresolved_comments with replies, files with inline diff and diff_file, double_check, publish_metadata)+ - Generate fixtures/golden.html by running `git show 9da40cf:scripts/build_review_html.py` from a temp file against golden.json; commit the HTML+ - The golden test renders via `python3 scripts/build_review_html.py` by repo-relative path, replaces the `<style>` contents and the `Generated …` footer line in both documents, and asserts equality+ - Add a Makefile with `test: cd scripts && python3 -m unittest discover -s tests -t .` and document it in scripts/README.md alongside blast_radius.py and ecosystems.json entries added later+ - Stream: 1+ - Requirements: [6.1](requirements.md#6.1), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6)+ - References: scripts/build_review_html.py, scripts/tests/, Makefile++- [x] 2. Restructure the renderer into the review_html package with a thin entry point <!-- id:vt4kkmo -->+ - Modules: __init__.py exporting render; common.py with escape, digest (sha1[:10]), file_anchor, severity_pill; sections.py with the existing renderers; css.py; template.py; render.py with render(); every module starts with `from __future__ import annotations`+ - Entry point keeps argparse and the command line, inserts Path(__file__).resolve().parent on sys.path before `import review_html`+ - New placeholders are not added yet; the golden test must still pass+ - Do not move the CSS into the template literal; keep it as a substituted value+ - Blocked-by: vt4kkmn (Write the golden-fixture regression test and the test harness scaffold)+ - Stream: 1+ - Requirements: [6.1](requirements.md#6.1), [6.3](requirements.md#6.3), [6.5](requirements.md#6.5), [2.11](requirements.md#2.11)+ - References: scripts/build_review_html.py, scripts/review_html/++- [x] 3. Write tests for inputs.read_guarded and the Warnings collector <!-- id:vt4kkmp -->+ - read_guarded rejects a file over 50 MB by stat (use a sparse temp file), rejects xml=True inputs whose first 64 KB contain `<!DOCTYPE`, returns None with a warning for non-UTF-8 content, and returns text otherwise+ - Warnings.add appends and prints `warning: …` to stderr immediately; items preserves order+ - Blocked-by: vt4kkmo (Restructure the renderer into the review_html package with a thin entry point)+ - Stream: 1+ - Requirements: [2.10](requirements.md#2.10), [2.11](requirements.md#2.11)+ - References: scripts/review_html/inputs.py, scripts/review_html/warnings.py++- [x] 4. Implement inputs.read_guarded and Warnings to pass the tests <!-- id:vt4kkmq -->+ - Both modules are pure standard library; read_guarded is the only file reader the rest of the package uses+ - Blocked-by: vt4kkmp (Write tests for inputs.read_guarded and the Warnings collector)+ - Stream: 1+ - Requirements: [2.10](requirements.md#2.10), [2.11](requirements.md#2.11)++- [x] 5. Write tests for diffs: load_fragments, added_lines, is_binary, and render_diff with uncovered marks <!-- id:vt4kkmr -->+ - added_lines: hunk headers `@@ -a,b +c,d @@`, multiple hunks, renames, `\ No newline at end of file`, a `/dev/null` fragment from `git diff --no-index`+ - is_binary: lines starting `Binary files ` or `GIT binary patch`+ - load_fragments: inline diff wins over diff_file; missing file yields the existing `(diff fragment 'x' missing)` placeholder; non-UTF-8 yields `(diff fragment 'x' is not UTF-8)` plus a warning+ - render_diff with uncovered=None must equal today's _render_diff output; with a set, matching `+` lines carry `diff-add diff-uncovered` and context or `-` lines never do+ - Blocked-by: vt4kkmq (Implement inputs.read_guarded and Warnings to pass the tests)+ - Stream: 1+ - Requirements: [3.8](requirements.md#3.8), [2.10](requirements.md#2.10), [3.6](requirements.md#3.6)+ - References: scripts/review_html/diffs.py++- [x] 6. Implement diffs.py and wire load_fragments into render_files <!-- id:vt4kkms -->+ - render() calls load_fragments once and passes the dict to render_files together with an uncovered map (empty for now)+ - Append the `.diff-uncovered` rule to css.py: 3 px `--error` left border and a `▌` gutter marker via ::before+ - Golden test still passes+ - Blocked-by: vt4kkmr (Write tests for diffs: load_fragments, added_lines, is_binary, and render_diff with uncovered marks)+ - Stream: 1+ - Requirements: [3.8](requirements.md#3.8), [2.10](requirements.md#2.10), [6.1](requirements.md#6.1)++## Blast radius++- [x] 7. Write unit and property tests for diagram.project <!-- id:vt4kkmt -->+ - Column assignment: changed to centre, unchanged with an edge into changed to dependents, unchanged with an edge from changed to dependencies, both to dependents+ - Order: test exclusion, expansion collapse, cap; test counts include changed test files; collapse only groups with more than 3 nodes whose every centre edge has granularity package; nodes with any file-granular edge stay+ - Collapsed node: label `<group> (N files)`, id digest of member paths joined with newline, rank key sum of member edges; cap ranks by (-edges_to_changed, path) with first member path for collapsed nodes; centre never capped+ - Groups ordered by name, nodes by path; column_status partial keeps nodes, failed keeps none+ - Property tests with random.Random(seed) over 200 cases: identical output for identical input, no side column over 15, no empty side column without a failed status+ - Blocked-by: vt4kkmo (Restructure the renderer into the review_html package with a thin entry point)+ - Stream: 2+ - Requirements: [4.6](requirements.md#4.6), [4.7](requirements.md#4.7), [4.8](requirements.md#4.8), [4.10](requirements.md#4.10), [4.11](requirements.md#4.11)+ - References: scripts/review_html/diagram.py++- [x] 8. Implement diagram.project to pass the tests <!-- id:vt4kkmu -->+ - Define the Projected dataclass per the design; keep project() free of any SVG concerns so layout tests can build Projected directly+ - Blocked-by: vt4kkmt (Write unit and property tests for diagram.project)+ - Stream: 2+ - Requirements: [4.6](requirements.md#4.6), [4.7](requirements.md#4.7), [4.8](requirements.md#4.8), [4.10](requirements.md#4.10), [4.11](requirements.md#4.11)++- [x] 9. Write unit and property tests for diagram.layout and render_diagram <!-- id:vt4kkmv -->+ - Constants: ADV 7.2, PAD 10, BOX_H 26, ROW_GAP 8, GROUP_PAD 8, GROUP_HEADER 18, GROUP_GAP 14, GUTTER 56, LANE 24, CONTENT_W 1036, COL_W 308, SIDE_BOX_W 292, CENTRE_BOX_W 268; budgets 37 and 30+ - Property: every text element's textLength + 2·PAD ≤ its box width, including badges and group labels; declared width equals 1036; centre-to-centre path x coordinates stay within the lane+ - Markup: node `<g id="n-<digest>">` with `<title>`, rect, label text, `⚑N` badge only on changed nodes with N > 0; changed nodes wrapped in `<a href="#file-<digest>">`; edges with class `edge e-<src> e-<dst>`, data-from, data-to, marker-end; side edges attach right-middle to left-middle or the reverse when the target is left of the source+ - Fills and strokes use `var(--x, #literal)`; collapsed nodes dashed; failed column shows the reason text in place; partial shows nodes plus reason; package-granularity note under the header+ - Section: `<div class="blast-scroll">` container, legend swatch row, collapsed-member `<ul>`, skipped list, per-node `:has()` hover rules in a `<style>` element+ - Escaping of `<`, `&`, `"`, `$` in paths; absent or invalid description omits the section with a stderr warning and exit 0+ - Blocked-by: vt4kkmu (Implement diagram.project to pass the tests)+ - Stream: 2+ - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [5.10](requirements.md#5.10), [5.11](requirements.md#5.11), [5.12](requirements.md#5.12)+ - References: scripts/review_html/diagram.py, scripts/review_html/css.py++- [x] 10. Implement diagram.layout and render_diagram and wire the diagram section into render <!-- id:vt4kkmw -->+ - Wire into render(): `diagram_file` loaded through read_guarded relative to the diff directory; top-level `change_classification == "docs-only"` suppresses the section without warnings+ - Append `$unresolved_comments_section$diagram_section` on the existing template line; add `"diagram": "Blast radius"` to toc_labels+ - CSS for .blast-scroll (overflow-x auto) and legend; golden test still passes+ - Blocked-by: vt4kkmv (Write unit and property tests for diagram.layout and render_diagram)+ - Stream: 2+ - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [5.10](requirements.md#5.10), [5.11](requirements.md#5.11), [5.12](requirements.md#5.12), [3.11](requirements.md#3.11), [6.1](requirements.md#6.1), [6.4](requirements.md#6.4)++- [x] 11. Write tests for blast_radius.py against a generated git repository <!-- id:vt4kkmx -->+ - Build a repository with `git init` in tempfile holding Go (two packages, go.mod), Python (src layout, relative and absolute imports), TypeScript (relative imports, index.ts, .js-to-.ts mapping), and Rust (`mod foo;` and `use crate::`); commit a base, then a snapshot with added, modified, deleted, renamed, copied (-C), and type-changed files, one untracked file for working-tree mode, one 1 MB+ blob, a symlink, and test files importing changed files+ - Assert diagram.json: nodes with status, group, is_test, old_path; edges with method, granularity, tree (base for deleted and old renamed paths); column_status complete, failed for an extension without patterns, partial for the remote cap; skipped entries+ - Assert diff-tests.json: added and removed names from test_decl on the diff, unpatterned_files+ - Cover --snapshot working-tree (disk reads, untracked as added) and a SHA snapshot (cat-file --batch); --tools with a stub tool command; --remote by monkeypatching the gh calls+ - Blocked-by: vt4kkmo (Restructure the renderer into the review_html package with a thin entry point)+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.9](requirements.md#4.9), [4.10](requirements.md#4.10), [4.12](requirements.md#4.12), [1.12](requirements.md#1.12)+ - References: scripts/blast_radius.py, scripts/ecosystems.json, scripts/tests/++- [x] 12. Implement blast_radius.py and the script-read rows of ecosystems.json <!-- id:vt4kkmy -->+ - Steps and resolvers per the design: changed files via `git diff --name-status -M -C -z` with C as added and T as modified; tree via `git ls-tree -r -l -z` parsed with partition('\t'), skipping modes 120000 and 160000 and blobs over 1 MB; `git cat-file --batch` for SHA trees; trees and blobs API for --remote with the 500-call cap and truncated check+ - Resolvers relative, roots (separator, source_roots, one-segment retry), unit (module_file with module_regex, target_root, directory)+ - Write ecosystems.json with the script-read keys for go, python, typescript, swift, rust; runner keys come in task 23+ - Emit diff-tests.json from test_decl over the diff of changed test files+ - Write --out DIR/diagram.json and DIR/diff-tests.json; document the CLI in scripts/README.md+ - Blocked-by: vt4kkmx (Write tests for blast_radius.py against a generated git repository)+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.9](requirements.md#4.9), [4.10](requirements.md#4.10), [4.12](requirements.md#4.12), [1.12](requirements.md#1.12)++## Test results++- [x] 13. Write tests for junit.parse_junit <!-- id:vt4kkmz -->+ - Fixtures: nested testsuites, empty classname falling back to the testsuite name, Surefire flakyFailure and rerunFailure, pytest `rerun` with one element per attempt sharing (suite, name), skipped, error, message attribute versus element text+ - Assert per-source collapse: duplicates within one file become one case with the last element's outcome and flaky when an earlier attempt failed; identical identities across two source files stay separate+ - Assert source is the input file name+ - Blocked-by: vt4kkms (Implement diffs.py and wire load_fragments into render_files)+ - Stream: 1+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)+ - References: scripts/review_html/junit.py++- [x] 14. Implement junit.py to pass the tests <!-- id:vt4kkn0 -->+ - Use read_guarded with xml=True; outcome precedence failure, error, flaky elements, skipped, passed+ - Blocked-by: vt4kkmz (Write tests for junit.parse_junit)+ - Stream: 1+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)++- [x] 15. Write unit and property tests for coverage parsing, path mapping, matching, diff coverage, and overall <!-- id:vt4kkn1 -->+ - Parsers: lcov with repeated SF for one file, Cobertura with two source roots producing aliases, coverprofile in set and count modes with overlapping blocks taking the maximum+ - apply_path_map on primary paths and aliases after normalisation, whole segments only+ - match as five passes: the util.py plus a/util.py versus src/a/util.py case must match a/util.py; exact match leaves the pool; an entry in two pools is removed once and both files report ambiguous; distinct residuals report ambiguous; equal residuals merge by summing hits; an entry whose aliases equal two changed files is ambiguous for both+ - diff_coverage returns None on a zero denominator; overall merges by normalised primary path so repeated entries count once+ - Property over random path sets: each changed file maps to at most one merged entry, each entry to at most one file, and shuffling inputs does not change the result+ - Blocked-by: vt4kkms (Implement diffs.py and wire load_fragments into render_files)+ - Stream: 1+ - Requirements: [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8)+ - References: scripts/review_html/coverage.py++- [x] 16. Implement coverage.py to pass the tests <!-- id:vt4kkn2 -->+ - Entry dataclass with paths (primary first) and hits; Coverage = list[Entry]; parse_coverage sniffs the format from content and uses read_guarded (xml=True for Cobertura)+ - Blocked-by: vt4kkn1 (Write unit and property tests for coverage parsing, path mapping, matching, diff coverage, and overall)+ - Stream: 1+ - Requirements: [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8)++- [x] 17. Write tests for redact.redact <!-- id:vt4kkn3 -->+ - One assertion per pattern: Bearer tokens, AKIA keys, gh tokens, Slack tokens, `AWS_SECRET_ACCESS_KEY=…`, bare `KEY=…`, `password: …`, URLs with userinfo, PEM private key blocks+ - Assert that redaction happens before truncation to 500 characters by placing a secret at position 480+ - Blocked-by: vt4kkms (Implement diffs.py and wire load_fragments into render_files)+ - Stream: 1+ - Requirements: [3.4](requirements.md#3.4)+ - References: scripts/review_html/redact.py++- [x] 18. Implement redact.py to pass the tests <!-- id:vt4kkn4 -->+ - PATTERNS list in the design's order; redact applies them sequentially+ - Blocked-by: vt4kkn3 (Write tests for redact.redact)+ - Stream: 1+ - Requirements: [3.4](requirements.md#3.4)++- [x] 19. Write tests for tests_section.build_tests and the render wiring <!-- id:vt4kkn5 -->+ - Card: three lines with n/a values and a section link when there is no data+ - Section order: provenance with CI link and both states; availability line; coverage_scope; totals with flaky alongside; pending runs; per-job rows with counts only when attributed, per-artifact rows otherwise; failed tests with job or artifact; new and removed by identity from baseline or by name from diff_tests_file with the source label and the cross-source note; per-file table excluding Deleted badges and binary fragments and showing 'no coverage data' for no candidate, ambiguous, and zero-denominator files; overall coverage with delta and cross-source note; unmatched report; run_touched_files, skipped_artifacts, warnings+ - No-data card derived from no_data_reason, ci_state, and fallback_state, with the upload sentence for no run, artifacts absent, and artifacts expired+ - Render wiring: the two `summary` lines are the last stderr lines even when a diagram warning fires later; `summary tests:` excludes baseline cases; uncovered sets reach render_files; docs-only classification omits card and section+ - Blocked-by: vt4kkn0 (Implement junit.py to pass the tests), vt4kkn2 (Implement coverage.py to pass the tests), vt4kkn4 (Implement redact.py to pass the tests)+ - Stream: 1+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [2.8](requirements.md#2.8), [2.9](requirements.md#2.9), [1.6](requirements.md#1.6), [1.12](requirements.md#1.12), [3.12](requirements.md#3.12)+ - References: scripts/review_html/tests_section.py, scripts/review_html/render.py++- [x] 20. Implement tests_section.py and wire the Tests card, section, uncovered marks, and summary lines into render <!-- id:vt4kkn6 -->+ - TestsResult dataclass per the design; append `$findings_summary$tests_card` and `$findings_section$tests_section` on the existing template lines; add `"tests": "Tests"` to toc_labels; CSS for the Tests section tables and the warning-bordered no-data card matching the unresolved-comment card treatment+ - render() prints `summary coverage:` and `summary tests:` after all other output; golden test still passes+ - Blocked-by: vt4kkn5 (Write tests for tests_section.build_tests and the render wiring)+ - Stream: 1+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [2.8](requirements.md#2.8), [2.9](requirements.md#2.9), [1.6](requirements.md#1.6), [6.1](requirements.md#6.1), [6.4](requirements.md#6.4)++- [x] 21. Write the timing tests with generated large inputs <!-- id:vt4kkn7 -->+ - Generate a 10 MB lcov file and a 5,000-case JUnit file in a temp directory; assert each parses in under 5 seconds+ - Skip when os.getloadavg is unavailable or its first value exceeds os.cpu_count()+ - Blocked-by: vt4kkn0 (Implement junit.py to pass the tests), vt4kkn2 (Implement coverage.py to pass the tests)+ - Stream: 1+ - Requirements: [2.12](requirements.md#2.12)++## Ecosystem file and skills++- [x] 22. Write a schema validation test for ecosystems.json covering script and runner keys <!-- id:vt4kkn8 -->+ - Assert every row has extensions, test_files, unit, and either imports or notes; every regex compiles; test_decl has at most one group; every runner has name, detect, recipe, requires, coverage_format in {lcov, cobertura, coverprofile}, install, junit_flags; recipes only use the placeholders {junit}, {coverage}, {inputs}; env and config_files are objects when present; tool has deps and granularity+ - Blocked-by: vt4kkmy (Implement blast_radius.py and the script-read rows of ecosystems.json)+ - Stream: 3+ - Requirements: [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [4.4](requirements.md#4.4)+ - References: scripts/ecosystems.json++- [x] 23. Add the runners, notes, and detection rules to ecosystems.json to pass the schema test <!-- id:vt4kkn9 -->+ - Runners per the design: gotestsum for Go; pytest with --junitxml and --cov-report=xml; vitest and jest with detection rules, `npx --no-install`, and JEST_JUNIT_OUTPUT_FILE; swift test with --xunit-output plus llvm-cov export; cargo nextest with a nextest.toml config_files template and cargo llvm-cov+ - Add notes for the Swift same-target and Xcode holes and the go list failure behaviour+ - Blocked-by: vt4kkn8 (Write a schema validation test for ecosystems.json covering script and runner keys)+ - Stream: 3+ - Requirements: [1.5](requirements.md#1.5), [1.6](requirements.md#1.6)++- [x] 24. Update the pr-review-html skill for collection, diagram, JSON blocks, and severity floor <!-- id:vt4kkna -->+ - Phase 1: record headRefOid, isCrossRepository, baseRefName; merge base after checkout; disclosure sentence that Phases 4 and 5 execute the branch's install scripts and tests, fork PRs included+ - Phase 5: recipe selection tiers reading Makefile text, run once into $INPUTS with the 600,000 ms budget, restore procedure with pre-run copies, timed_out verdict wording+ - Phase 7: fragments from `git diff <merge-base> -- <path>` and `/dev/null` for untracked files; baseline lookup; blast_radius.py invocation with --tools; tests block, diagram_file, change_classification, diff_tests_file; severity floor via the `summary tests:` line and second render+ - Define $INPUTS with the mktemp fallback, pass --diff-dir explicitly, drop the highlight.js claim, update the when-to-edit paragraph to name the package and css.py+ - Blocked-by: vt4kkmw (Implement diagram.layout and render_diagram and wire the diagram section into render), vt4kkn6 (Implement tests_section.py and wire the Tests card, section, uncovered marks, and summary lines into render), vt4kkn9 (Add the runners, notes, and detection rules to ecosystems.json to pass the schema test)+ - Stream: 3+ - Requirements: [1.1](requirements.md#1.1), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.11](requirements.md#1.11), [1.12](requirements.md#1.12), [1.13](requirements.md#1.13), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [4.1](requirements.md#4.1), [6.2](requirements.md#6.2), [6.4](requirements.md#6.4)+ - References: claude/skills/pr-review-html/SKILL.md++- [x] 25. Update the pr-overview skill for CI artifacts, the worktree fallback, baseline, diagram, and JSON blocks <!-- id:vt4kknb -->+ - Phase 1: pin headRefOid; fetch refs/pull/<n>/head and verify FETCH_HEAD; diffs from the pinned SHA or the compare API without a clone, noting omitted patches; replace the `gh api` file-reading paragraph; read-only statement gains the 1.3 sentence and the .git ref note+ - Phase 1b: gh commands with -R and --paginate, artifact size cap, sniffing, `<run_id>-<artifact>--<basename>` naming, ordered CI-state rules, token-set job attribution, pending_runs; worktree fallback block with $WT, prune, status capture, blast_radius --tools before removal, removal on timeout+ - Phase 6: baseline with the is-ancestor exit-128 rule and compare-API fallback; blast_radius without --tools when 1b produced no diagram, --remote without a clone; JSON blocks; severity floor+ - $INPUTS definition, --diff-dir, rendering-contract fixes, when-to-edit paragraph+ - Blocked-by: vt4kkmw (Implement diagram.layout and render_diagram and wire the diagram section into render), vt4kkn6 (Implement tests_section.py and wire the Tests card, section, uncovered marks, and summary lines into render), vt4kkn9 (Add the runners, notes, and detection rules to ecosystems.json to pass the schema test)+ - Stream: 3+ - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [1.10](requirements.md#1.10), [1.11](requirements.md#1.11), [1.12](requirements.md#1.12), [1.13](requirements.md#1.13), [1.14](requirements.md#1.14), [1.15](requirements.md#1.15), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [4.1](requirements.md#4.1), [4.12](requirements.md#4.12), [6.2](requirements.md#6.2), [6.4](requirements.md#6.4)+ - References: claude/skills/pr-overview/SKILL.md++- [x] 26. Update the pre-push-review skill for the local run, diagram, JSON blocks, and severity floor <!-- id:vt4kknc -->+ - Phase 5: same recipe selection, single run, restore, and timeout wording as pr-review-html+ - Phase 7: untracked files as added with /dev/null fragments; blast_radius.py with --snapshot working-tree --base $BASE --tools; tests block without baseline; diff_tests_file; change_classification; severity floor+ - Remove the claim that malformed JSON still renders; $INPUTS definition; --diff-dir; drop the highlight.js claim; when-to-edit paragraph+ - Blocked-by: vt4kkmw (Implement diagram.layout and render_diagram and wire the diagram section into render), vt4kkn6 (Implement tests_section.py and wire the Tests card, section, uncovered marks, and summary lines into render), vt4kkn9 (Add the runners, notes, and detection rules to ecosystems.json to pass the schema test)+ - Stream: 3+ - Requirements: [1.1](requirements.md#1.1), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.12](requirements.md#1.12), [1.13](requirements.md#1.13), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [4.1](requirements.md#4.1), [6.2](requirements.md#6.2), [6.4](requirements.md#6.4)+ - References: claude/skills/pre-push-review/SKILL.md
The pytest row detects on pytest.ini, conftest.py, pyproject.toml, setup.cfg, or tox.ini. A unittest-only repository like this one gets runner not detected even with pytest installed. Decide whether a unittest recipe (or a JUnit-emitting wrapper) belongs in ecosystems.json.
The design never caps the centre column, so a branch this size renders a tall diagram. Check that the page stays readable and that the changed-node links resolve to the right diff anchors.
pre-push-review now diffs against the merge base. pr-review-html already computes MERGE_BASE; pr-overview takes it from git merge-base or the compare API. Confirm the three agree when a PR branch is behind its base.
Every test on this branch is new, so diff-tests.json lists 226 added names and the page prints them all. Consider a cap with a count once real branches produce long lists.
Twenty-one files carry the review fixes in the working tree. They are verified by the test run reported here but not yet committed.