Guided review
A tool that explains pull requests, explaining itself
GitHub opens this pull request at .claude-plugin/plugin.json, then .gitignore, then LICENSE — three files carrying no idea at all — while the parser and the renderer everything is built on sit somewhere in the middle. That gap between the order files are listed in and the order they make sense in is the entire problem this code exists to close.
So it seemed fair to let the tool argue its own case. What follows is guided-review reviewing the pull request that adds guided-review.
Seventeen files, all new. We start with the split that makes everything else possible, then the two pieces that give a diff coordinates — parsing and highlighting — then the pairing that is the whole point, the checks that keep a narrative honest, the design, and the tests.
The split: analysis is not rendering
The first decision, and the one every other decision leans on.
A model reads the pull request and writes a story JSON. build.mjs parses the diff, resolves anchors to line numbers, and renders the page — and does no analysis whatsoever.
That line is drawn deliberately. It is why fixing a clumsy sentence is a re-render measured in milliseconds rather than a re-read of a 2,000-line diff.
build.mjsnew+230 −0
| @@ -0,0 +1,230 @@ | ||
| 1 | #!/usr/bin/env node | |
| 2 | // guided-review — turn a story JSON + a unified diff into one self-contained | |
| 3 | // HTML page for guided code review. | |
| 4 | // | |
| 5 | // Usage: | |
| 6 | // node build.mjs <story.json> <pr.diff> [--out <file.html>] [--lang pt|en] | |
| 7 | // | |
| 8 | // The story JSON is authored by the model (see the skill); this script does no | |
| 9 | // analysis. It parses the diff, pairs each narrative beat with the exact lines | |
| 10 | // it describes, and renders the page. Keeping the two apart means a wording fix | |
| 11 | // is a re-render, not a re-analysis. | |
| 12 | ||
| 13 | import { existsSync, readFileSync, writeFileSync } from 'node:fs'; | |
| 14 | import { basename } from 'node:path'; | |
| 15 | import { parseDiff, parseRange } from './lib/diff.mjs'; | |
| 16 | import { render } from './lib/render.mjs'; | |
| 17 | ||
| 18 | const USAGE = `guided-review — build a guided code review page | |
| 19 | ||
| 20 | Usage: | |
| 21 | node build.mjs <story.json> <diff-file> [options] | |
| 22 | ||
| 23 | Arguments: | |
| 24 | <story.json> Narrative written by the model (schema in the skill / README). | |
| 25 | <diff-file> Unified diff, e.g. \`gh pr diff <N> > pr.diff\`. | |
| 26 | ||
| 27 | Options: | |
| 28 | --out <file> Output HTML (default: guided-review-<pr>.html in the current directory). | |
| 29 | --lang <code> UI language for fixed labels: \`en\` (default) or \`pt\`. | |
| 30 | Only chrome — the narrative renders in whatever language it was written in. | |
| 31 | --help Show this help. | |
| 32 | ||
| 33 | Exit codes: 0 ok, 1 usage/validation error. | |
| 34 | `; | |
| 35 | ||
| 36 | function fail(msg) { | |
| 37 | console.error(`error: ${msg}`); | |
| 38 | process.exit(1); | |
| 39 | } | |
| 40 | ||
| 41 | function parseArgs(argv) { | |
| 42 | const positional = []; | |
| 43 | const opts = { lang: 'en' }; | |
| 44 | for (let i = 0; i < argv.length; i++) { | |
| 45 | const a = argv[i]; | |
| 46 | if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); } | |
| 47 | if (!a.startsWith('--')) { positional.push(a); continue; } | |
| 48 | const key = a.slice(2); | |
| 49 | if (key !== 'out' && key !== 'lang') fail(`unknown flag --${key}`); | |
| 50 | const next = argv[i + 1]; | |
| 51 | if (next === undefined || next.startsWith('--')) fail(`--${key} needs a value`); | |
| 52 | opts[key] = next; | |
| ⋯ 178 lines not shown | ||
The skill is the other half of that split — the part build.mjs deliberately knows nothing about. It tells the model how to read a PR, how to find the order the story wants to be told in, and how to write anchors that point at real lines.
Its firmest rule is a negative one: no findings, no severities, no suggested changes.
skills/guided-review/SKILL.mdnew+200 −0
| ⋯ 6 lines not shown | ||
| 7 | ||
| 8 | # guided-review — a pull request, told as a story | |
| 9 | ||
| 10 | Produces one self-contained HTML page: the PR narrated in **chapters**, in the order the | |
| 11 | decisions were actually made, with each paragraph paired against the exact diff lines it | |
| 12 | describes. | |
| 13 | ||
| 14 | The problem it solves: GitHub sorts files alphabetically, which is never the order the work | |
| 15 | happened in. The description explains the reasoning in one place; the code sits somewhere | |
| 16 | else. The reviewer reads a wall of prose, then a wall of diff, and reassembles the mapping in | |
| 17 | their head. This puts each part of the explanation next to the code it is about. | |
| 18 | ||
| 19 | **Scope: narrative only.** This does not hunt for bugs, rate severity, or suggest changes — | |
| 20 | that is `/cr` (or `/code-review`). Mixing the two produces a document that is neither: a | |
| 21 | reviewer reading for *intent* is doing something different from a reviewer reading for | |
| 22 | *defects*. Keep them separate. | |
| 23 | ||
| 24 | ## How it works | |
| 25 | ||
| 26 | You do the analysis and write a story JSON. `build.mjs` does the mechanical part: parsing the | |
| 27 | diff, resolving anchors to line numbers, rendering the page. Keeping them apart means a | |
| 28 | wording fix is a re-render, not a re-analysis. | |
| 29 | ||
| 30 | The script lives at the plugin root. Reference it with `${CLAUDE_PLUGIN_ROOT}`. | |
| 31 | ||
| ⋯ 32 lines not shown | ||
| 64 | While reading, hunt specifically for: | |
| 65 | ||
| 66 | - **Decisions with a visible alternative** — a rejected approach, a deliberate duplication, a | |
| 67 | `rescue` that isn't there, a scope that was widened instead of removed. These are what a | |
| 68 | reviewer cannot reconstruct from the diff alone, and they are the reason this document exists. | |
| 69 | - **Non-obvious constraints** — a measured timing, a database that refuses a query, a feature | |
| 70 | gate, a framework behavior being worked around. | |
| 71 | - **Load-bearing tests** — the spec that exists because the failure it prevents is silent. | |
| 72 | ||
| 73 | If the code contains a comment explaining *why*, that is your raw material. Quote its | |
| 74 | substance in the narrative; don't paraphrase it into vagueness. | |
| 75 | ||
| 76 | ### 3. Find the order the story wants to be told in | |
| 77 | ||
| 78 | Not alphabetical, not the diff order. Ask: **what does a reader need to believe first for the | |
| 79 | next part to make sense?** Common shapes: | |
| 80 | ||
| 81 | - *Definition → measurement → presentation → access* (a new feature) | |
| 82 | - *Symptom → root cause → fix → guard against regression* (a bug fix) | |
| 83 | - *What the old thing couldn't do → the new seam → migrating each caller* (a refactor) | |
| 84 | ||
| 85 | Group into **4–8 chapters**. Fewer than 4 usually means the chapters are too coarse to guide | |
| 86 | anyone; more than 8 means you're narrating files instead of decisions. Every chapter should be | |
| 87 | nameable as a decision or a movement, never as a directory (`"Services"` is not a chapter; | |
| 88 | `"One definition of pending"` is). | |
| 89 | ||
| 90 | ### 4. Write the story JSON | |
| 91 | ||
| 92 | ```jsonc | |
| 93 | { | |
| 94 | "number": 3649, | |
| 95 | "title": "…", // the PR title | |
| 96 | "url": "https://github.com/…/pull/3649", | |
| 97 | "author": "octocat", | |
| 98 | "branch": "feature-branch", | |
| 99 | "additions": 2790, // from gh; shown in the masthead | |
| 100 | "deletions": 7, | |
| 101 | "headSha": "abc123…", // headRefOid — stamps the page | |
| 102 | "headline": "…", // the one sentence that makes someone want to read | |
| ⋯ 98 lines not shown | ||
Giving a diff coordinates
Before prose can point at code, something has to make the code addressable.
The parser tracks line numbers on both sides of every hunk. That is what lets a paragraph say app/foo.rb:42 without knowing anything about hunk offsets — the foundation the entire pairing rests on.
The first thing it does is drop the trailing empty string that split('\n') produces for any newline-terminated diff, which is every diff.
lib/diff.mjsnew+141 −0
| ⋯ 17 lines not shown | ||
| 18 | /(^|\/)dist\//, | |
| 19 | ]; | |
| 20 | ||
| 21 | export function isGenerated(path) { | |
| 22 | return GENERATED.some((re) => re.test(path)); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * Parse a unified diff into structured files. | |
| 27 | * @param {string} text raw diff | |
| 28 | * @returns {Array<{path,oldPath,status,isBinary,isGenerated,additions,deletions,hunks}>} | |
| 29 | */ | |
| 30 | export function parseDiff(text) { | |
| 31 | const files = []; | |
| 32 | let file = null; | |
| 33 | let hunk = null; | |
| 34 | let oldLine = 0; | |
| 35 | let newLine = 0; | |
| 36 | ||
| 37 | const pushFile = () => { | |
| 38 | if (file) files.push(file); | |
| 39 | }; | |
| 40 | ||
| 41 | // A newline-terminated diff — which is every `git diff` / `gh pr diff` — | |
| 42 | // splits into a trailing '' that is not a line of the file. Consumed as | |
| 43 | // context it fabricates a row, bumps both line counters, and registers a | |
| 44 | // line number that does not exist as a valid anchor target. | |
| 45 | const lines = text.split('\n'); | |
| 46 | if (lines[lines.length - 1] === '') lines.pop(); | |
| 47 | ||
| 48 | for (const raw of lines) { | |
| 49 | const header = FILE_HEADER.exec(raw); | |
| 50 | if (header) { | |
| 51 | pushFile(); | |
| 52 | const [, oldPath, newPath] = header; | |
| 53 | file = { | |
| 54 | path: newPath, | |
| 55 | oldPath, | |
| 56 | status: 'modified', | |
| 57 | isBinary: false, | |
| 58 | isGenerated: isGenerated(newPath), | |
| 59 | additions: 0, | |
| 60 | deletions: 0, | |
| 61 | hunks: [], | |
| 62 | }; | |
| 63 | hunk = null; | |
| 64 | continue; | |
| 65 | } | |
| 66 | if (!file) continue; // preamble before the first file | |
| 67 | ||
| 68 | // Metadata only exists between the file header and the first hunk. Testing | |
| 69 | // for it inside a hunk eats real content: a deleted line `--- foo` in a | |
| 70 | // changelog is `--- foo` on the wire, indistinguishable from a `---` header | |
| 71 | // by prefix alone, and it would vanish from the diff without a trace. | |
| 72 | if (!hunk) { | |
| ⋯ 69 lines not shown | ||
Metadata prefixes — ---, +++, index — are only tested before the first hunk, because that is the only place they occur.
lib/diff.mjsnew+141 −0
| ⋯ 55 lines not shown | ||
| 56 | status: 'modified', | |
| 57 | isBinary: false, | |
| 58 | isGenerated: isGenerated(newPath), | |
| 59 | additions: 0, | |
| 60 | deletions: 0, | |
| 61 | hunks: [], | |
| 62 | }; | |
| 63 | hunk = null; | |
| 64 | continue; | |
| 65 | } | |
| 66 | if (!file) continue; // preamble before the first file | |
| 67 | ||
| 68 | // Metadata only exists between the file header and the first hunk. Testing | |
| 69 | // for it inside a hunk eats real content: a deleted line `--- foo` in a | |
| 70 | // changelog is `--- foo` on the wire, indistinguishable from a `---` header | |
| 71 | // by prefix alone, and it would vanish from the diff without a trace. | |
| 72 | if (!hunk) { | |
| 73 | if (raw.startsWith('new file mode')) { file.status = 'added'; continue; } | |
| 74 | if (raw.startsWith('deleted file mode')) { file.status = 'deleted'; continue; } | |
| 75 | if (raw.startsWith('rename from')) { file.status = 'renamed'; continue; } | |
| 76 | if (raw.startsWith('Binary files')) { file.isBinary = true; continue; } | |
| 77 | // `+++`/`---` carry no information the file header lacks. | |
| 78 | if (raw.startsWith('index ') || raw.startsWith('--- ') || raw.startsWith('+++ ') || | |
| 79 | raw.startsWith('similarity index') || raw.startsWith('rename to') || | |
| 80 | raw.startsWith('old mode') || raw.startsWith('new mode')) continue; | |
| 81 | } | |
| 82 | ||
| 83 | const hh = HUNK_HEADER.exec(raw); | |
| 84 | if (hh) { | |
| 85 | oldLine = Number(hh[1]); | |
| 86 | newLine = Number(hh[2]); | |
| 87 | hunk = { header: raw, lines: [] }; | |
| 88 | file.hunks.push(hunk); | |
| 89 | continue; | |
| 90 | } | |
| 91 | if (!hunk) continue; | |
| 92 | ||
| 93 | // `\ No newline at end of file` annotates the previous line; it occupies no | |
| 94 | // line number on either side. | |
| ⋯ 47 lines not shown | ||
Anchors are split by parseRange, which flags a colon suffix it cannot parse instead of quietly folding it into the path.
lib/diff.mjsnew+141 −0
| ⋯ 99 lines not shown | ||
| 100 | hunk.lines.push({ type: 'add', content, oldNo: null, newNo: newLine++ }); | |
| 101 | file.additions++; | |
| 102 | } else if (marker === '-') { | |
| 103 | hunk.lines.push({ type: 'del', content, oldNo: oldLine++, newNo: null }); | |
| 104 | file.deletions++; | |
| 105 | } else if (marker === ' ' || raw === '') { | |
| 106 | hunk.lines.push({ type: 'ctx', content, oldNo: oldLine++, newNo: newLine++ }); | |
| 107 | } | |
| 108 | } | |
| 109 | pushFile(); | |
| 110 | return files; | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Split an anchor into its parts. | |
| 115 | * | |
| 116 | * `path/to/f.rb:17-42` → `{ path, from: 17, to: 42 }` | |
| 117 | * `path/to/f.rb:17` → `{ path, from: 17, to: 17 }` | |
| 118 | * `path/to/f.rb` → `{ path, from: null, to: null }` — whole file | |
| 119 | * | |
| 120 | * A colon suffix that is not a valid range sets `malformed`, and the whole spec | |
| 121 | * is kept as the path. That distinction matters: without it, `f.rb:L17-L42` | |
| 122 | * (GitHub's own fragment syntax, an easy thing to write by mistake) is reported | |
| 123 | * as "no such file in the diff" — an error naming the wrong cause and pointing | |
| 124 | * the author at the filename instead of at the range they mistyped. | |
| 125 | * | |
| 126 | * A range is never reordered or clamped here; `validate` rejects `from > to` | |
| 127 | * and `from < 1` outright, since both are authoring errors with no sensible | |
| 128 | * interpretation. | |
| 129 | */ | |
| 130 | export function parseRange(spec) { | |
| 131 | const at = spec.lastIndexOf(':'); | |
| 132 | if (at === -1) return { path: spec, from: null, to: null }; | |
| 133 | const path = spec.slice(0, at); | |
| 134 | const range = spec.slice(at + 1); | |
| 135 | const m = /^(\d+)(?:-(\d+))?$/.exec(range); | |
| 136 | // Could be a path that legitimately contains a colon, or a typo'd range — | |
| 137 | // indistinguishable here, so flag it and let the caller decide. | |
| 138 | if (!m) return { path: spec, from: null, to: null, malformed: true }; | |
| 139 | const from = Number(m[1]); | |
| 140 | return { path, from, to: m[2] ? Number(m[2]) : from }; | |
| 141 | } | |
Some files are machine-written. structure.sql, lockfiles, dist/, vendor/ — reading them line by line teaches a reviewer nothing, so they are detected by path and collapsed on sight.
lib/diff.mjsnew+141 −0
| @@ -0,0 +1,141 @@ | ||
| 1 | // Parser for unified diffs (`git diff` / `gh pr diff` output). | |
| 2 | // | |
| 3 | // Produces one entry per file, each holding its hunks and lines. Line numbers | |
| 4 | // are tracked for both sides so the renderer can anchor a narrative beat to | |
| 5 | // "app/foo.rb:42" without the narrative having to know about hunk offsets. | |
| 6 | ||
| 7 | const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+)$/; | |
| 8 | const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; | |
| 9 | ||
| 10 | // Paths whose diff is machine-generated: shown collapsed by default, since | |
| 11 | // reading them line by line teaches a reviewer nothing. | |
| 12 | const GENERATED = [ | |
| 13 | /(^|\/)db\/structure\.sql$/, | |
| 14 | /(^|\/)db\/schema\.rb$/, | |
| 15 | /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Gemfile\.lock|poetry\.lock|composer\.lock|Cargo\.lock|uv\.lock)$/, | |
| 16 | /\.min\.(js|css)$/, | |
| 17 | /(^|\/)vendor\//, | |
| 18 | /(^|\/)dist\//, | |
| 19 | ]; | |
| 20 | ||
| 21 | export function isGenerated(path) { | |
| 22 | return GENERATED.some((re) => re.test(path)); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * Parse a unified diff into structured files. | |
| 27 | * @param {string} text raw diff | |
| 28 | * @returns {Array<{path,oldPath,status,isBinary,isGenerated,additions,deletions,hunks}>} | |
| 29 | */ | |
| 30 | export function parseDiff(text) { | |
| 31 | const files = []; | |
| 32 | let file = null; | |
| 33 | let hunk = null; | |
| 34 | let oldLine = 0; | |
| 35 | let newLine = 0; | |
| ⋯ 106 lines not shown | ||
Syntax highlighting is regex tokenization, not parsing. This looks like a shortcut and is the only thing that works here.
The rules are joined into one alternation, and each capture-group slot is mapped back to the rule that owns it.
lib/highlight.mjsnew+189 −0
| @@ -0,0 +1,189 @@ | ||
| 1 | // Minimal, dependency-free syntax highlighting. | |
| 2 | // | |
| 3 | // This is deliberately not a real parser. A diff shows fragments — half a | |
| 4 | // function, a hunk starting mid-string — so any parser strict enough to be | |
| 5 | // "correct" would fail on most input. Regex tokenization degrades gracefully: | |
| 6 | // the worst case is a missed keyword, never a broken line. | |
| 7 | // | |
| 8 | // Scope is limited to what actually shows up in pull requests. Unknown | |
| 9 | // extensions fall through to `escapeHtml`, which is always safe. | |
| 10 | ||
| 11 | const KEYWORDS = { | |
| 12 | ruby: 'def|end|class|module|if|elsif|else|unless|while|until|for|in|do|then|begin|rescue|ensure|raise|return|yield|self|nil|true|false|and|or|not|require|require_relative|include|extend|attr_accessor|attr_reader|attr_writer|private|public|protected|lambda|proc|case|when|next|break|super|new|defined', | |
| 13 | js: 'const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|super|this|import|export|from|default|async|await|try|catch|finally|throw|typeof|instanceof|null|undefined|true|false|delete|void|yield|static|get|set|of|in', | |
| 14 | python: 'def|class|return|if|elif|else|for|while|in|is|not|and|or|import|from|as|with|try|except|finally|raise|lambda|None|True|False|pass|break|continue|yield|global|nonlocal|assert|async|await|self', | |
| 15 | sql: 'SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|INDEX|ALTER|DROP|ADD|COLUMN|PRIMARY|KEY|FOREIGN|REFERENCES|NOT|NULL|DEFAULT|UNIQUE|CONSTRAINT|AS|AND|OR|IN|EXISTS|CASE|WHEN|THEN|ELSE|END|DISTINCT|COUNT|SUM|AVG|MAX|MIN|COALESCE|WITH|UNION|ALL', | |
| 16 | css: 'important|media|import|supports|keyframes|font-face|root|and|not|only', | |
| 17 | go: 'func|package|import|return|if|else|for|range|switch|case|default|break|continue|var|const|type|struct|interface|map|chan|go|defer|select|nil|true|false|error|string|int|bool|make|new|append|len|cap', | |
| 18 | rust: 'fn|let|mut|const|static|if|else|match|for|while|loop|break|continue|return|struct|enum|impl|trait|pub|use|mod|crate|self|Self|super|where|as|dyn|move|ref|type|unsafe|async|await|true|false|Some|None|Ok|Err', | |
| 19 | php: 'function|class|extends|implements|interface|trait|public|private|protected|static|return|if|else|elseif|foreach|for|while|do|switch|case|break|continue|new|echo|print|use|namespace|require|include|try|catch|finally|throw|null|true|false|array|const|abstract|final|global', | |
| 20 | java: 'public|private|protected|class|interface|extends|implements|static|final|void|return|if|else|for|while|do|switch|case|break|continue|new|try|catch|finally|throw|throws|import|package|abstract|synchronized|this|super|null|true|false|instanceof|enum|record', | |
| 21 | shell: 'if|then|else|elif|fi|for|while|do|done|case|esac|function|return|export|local|source|echo|cd|set|unset|readonly|shift|exit|trap', | |
| ⋯ 121 lines not shown | ||
| 143 | */ | |
| 144 | export function highlight(line, lang) { | |
| 145 | if (!lang || !line) return escapeHtml(line); | |
| 146 | // The string rules backtrack badly on pathological input — 5,000 escaped | |
| 147 | // quotes on one line costs ~90ms. Generated files are usually collapsed | |
| 148 | // before reaching here, but "usually" is not a guarantee, and no line this | |
| 149 | // long is being read by a human anyway. | |
| 150 | if (line.length > 2000) return escapeHtml(line); | |
| 151 | if (!RULE_CACHE.has(lang)) { | |
| 152 | const rules = rulesFor(lang); | |
| 153 | // One combined regex; the matched alternative tells us which class to use. | |
| 154 | const source = rules.map(([re]) => `(${re.source})`).join('|'); | |
| 155 | // Map each capture-group slot back to the rule that owns it. Rules are free | |
| 156 | // to contain groups of their own — several do — so slot N is not rule N, | |
| 157 | // and assuming it was silently emitted class="" for every rule declared | |
| 158 | // after the first one with an inner group. | |
| 159 | const owner = []; | |
| 160 | rules.forEach(([re], i) => { | |
| 161 | // The wrapper group, plus however many the rule declares itself. Counted | |
| 162 | // by compiling an always-matching pattern and reading its group count. | |
| 163 | const inner = new RegExp(`${re.source}|`).exec('').length - 1; | |
| 164 | for (let n = 0; n <= inner; n++) owner.push(i); | |
| 165 | }); | |
| 166 | RULE_CACHE.set(lang, { | |
| 167 | re: new RegExp(source, 'g' + (rules.some(([r]) => r.flags.includes('i')) ? 'i' : '')), | |
| 168 | classes: rules.map(([, cls]) => cls), | |
| 169 | owner, | |
| 170 | }); | |
| 171 | } | |
| 172 | const { re, classes, owner } = RULE_CACHE.get(lang); | |
| 173 | re.lastIndex = 0; | |
| 174 | ||
| 175 | let out = ''; | |
| 176 | let last = 0; | |
| 177 | let m; | |
| 178 | while ((m = re.exec(line)) !== null) { | |
| 179 | // A zero-length match would spin forever; step past it. | |
| 180 | if (m[0] === '') { re.lastIndex++; continue; } | |
| 181 | out += escapeHtml(line.slice(last, m.index)); | |
| 182 | const slot = m.slice(1).findIndex((g) => g !== undefined); | |
| ⋯ 7 lines not shown | ||
The pairing
The one feature everything else is in service of.
A beat is the unit: one passage of prose, plus the lines it is about. Anchors resolve to a set of new-side line numbers, and those lines are highlighted beside the paragraph discussing them.
Everything else in these 2,584 lines exists so that this reads well.
lib/render.mjsnew+509 −0
| ⋯ 212 lines not shown | ||
| 213 | body = `<div class="code"><table>${rows.join('')}</table></div>`; | |
| 214 | if (focused) { | |
| 215 | body += `<div class="elide">Showing the passage this section is about — ` + | |
| 216 | `<a href="${escapeHtml(prUrl)}">read the full file on GitHub</a>.</div>`; | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | const isOpen = open && !file.isGenerated ? ' open' : ''; | |
| 221 | return `<details class="file"${isOpen}>${head}${body}</details>`; | |
| 222 | } | |
| 223 | ||
| 224 | /** Collect NEW-side line numbers covered by a beat's anchors, per file. */ | |
| 225 | function marksFor(anchors, byPath) { | |
| 226 | const perFile = new Map(); | |
| 227 | for (const spec of anchors) { | |
| 228 | const { path, from, to } = parseRange(spec); | |
| 229 | const file = byPath.get(path); | |
| 230 | if (!file) continue; | |
| 231 | if (!perFile.has(path)) perFile.set(path, new Set()); | |
| 232 | if (from == null) continue; // whole-file anchor: no line highlight | |
| 233 | const set = perFile.get(path); | |
| 234 | for (let n = from; n <= to; n++) set.add(n); | |
| 235 | } | |
| 236 | return perFile; | |
| 237 | } | |
| 238 | ||
| 239 | function renderBeat(beat, byPath, used, labels, { key, prUrl }) { | |
| 240 | const anchors = Array.isArray(beat.files) ? beat.files : []; | |
| 241 | const perFile = marksFor(anchors, byPath); | |
| 242 | ||
| 243 | const prose = `<div class="beat-prose">${paragraphs(beat.text)}${renderNotes(beat.notes, labels)}</div>`; | |
| 244 | ||
| 245 | const files = []; | |
| 246 | for (const [path, marks] of perFile) { | |
| 247 | const file = byPath.get(path); | |
| 248 | used.add(path); | |
| 249 | files.push(renderFile(file, { marks, idScope: `${key}-L${slugPath(path)}`, prUrl })); | |
| 250 | } | |
| 251 | // The wrapper is emitted even with no files: it is a grid track, and dropping | |
| 252 | // it collapses the two-column beat layout. | |
| 253 | const code = `<div class="beat-code">${files.join('')}</div>`; | |
| 254 | ||
| 255 | return `<div class="beat">${prose}${code}</div>`; | |
| 256 | } | |
| 257 | ||
| 258 | function renderChapter(ch, n, byPath, used, L, prUrl) { | |
| 259 | const beats = (ch.beats || []) | |
| 260 | .map((b, i) => renderBeat(b, byPath, used, L.notes, { key: `c${n}b${i + 1}`, prUrl })) | |
| 261 | .join(''); | |
| 262 | const sub = ch.summary ? `<p class="sub">${inline(ch.summary)}</p>` : ''; | |
| 263 | return ` | |
| 264 | <section class="ch" id="ch-${n}"> | |
| 265 | <header class="ch-head"> | |
| 266 | <span class="ch-num">${escapeHtml(L.chapter)} ${String(n).padStart(2, '0')}</span> | |
| 267 | <h3>${inline(ch.title)}</h3> | |
| 268 | ${sub} | |
| ⋯ 241 lines not shown | ||
focusHunks is what makes the pairing survive a large file. It keeps a window around each anchored line and drops the rest — because an 800-line file has one or two passages the narrative points at, and rendering all of it buries them while collapsing it hides them entirely.
Every dropped line is announced. Leading gaps, gaps between kept regions, and the tail after the last one.
lib/render.mjsnew+509 −0
| ⋯ 91 lines not shown | ||
| 92 | return path.replace(/[^\w.-]/g, '_'); | |
| 93 | } | |
| 94 | ||
| 95 | // Only http(s) reaches an href. Anything else — `javascript:`, `data:` — is | |
| 96 | // replaced rather than sanitized, since there is no valid reason for a PR URL | |
| 97 | // to carry another scheme. | |
| 98 | function safeUrl(u) { | |
| 99 | return /^https?:\/\//i.test(String(u ?? '')) ? String(u) : '#'; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Reduce a file's hunks to the neighbourhoods around `marks`, dropping the rest. | |
| 104 | * | |
| 105 | * An 800-line new file has one or two passages the narrative actually points | |
| 106 | * at. Showing all of it buries them; collapsing the file hides them entirely. | |
| 107 | * Focusing keeps the anchored passage readable in place. | |
| 108 | * | |
| 109 | * EVERY dropped line is accounted for by a marker — leading, between, and | |
| 110 | * trailing. A segment with no lines is a pure trailing marker. Hiding code | |
| 111 | * without saying so is the one failure this tool must never commit: a reviewer | |
| 112 | * who reaches the end of a panel and sees no marker concludes the file ended | |
| 113 | * there, and approves what they never read. | |
| 114 | */ | |
| 115 | function focusHunks(file, marks) { | |
| 116 | const out = []; | |
| 117 | for (const hunk of file.hunks) { | |
| 118 | let keep = []; | |
| 119 | let skipped = 0; | |
| 120 | const flush = () => { | |
| 121 | if (!keep.length) return; | |
| 122 | out.push({ header: hunk.header, lines: keep, skippedBefore: skipped }); | |
| 123 | keep = []; | |
| 124 | skipped = 0; | |
| 125 | }; | |
| 126 | // Mark a window of FOCUS_PAD lines around every anchored line. | |
| 127 | const near = new Array(hunk.lines.length).fill(false); | |
| 128 | hunk.lines.forEach((l, i) => { | |
| 129 | if (l.newNo == null || !marks.has(l.newNo)) return; | |
| 130 | const from = Math.max(0, i - FOCUS_PAD); | |
| 131 | const to = Math.min(hunk.lines.length - 1, i + FOCUS_PAD); | |
| 132 | for (let n = from; n <= to; n++) near[n] = true; | |
| 133 | }); | |
| 134 | for (let i = 0; i < hunk.lines.length; i++) { | |
| 135 | if (near[i]) { keep.push(hunk.lines[i]); continue; } | |
| 136 | // flush() zeroes the counter, so this line is counted once — assigning 1 | |
| 137 | // here instead double-counted the first line of every gap. | |
| 138 | if (keep.length) flush(); | |
| 139 | skipped++; | |
| 140 | } | |
| 141 | flush(); | |
| 142 | // Lines trailing the last kept run: a marker with no lines of its own. | |
| 143 | if (skipped) out.push({ header: hunk.header, lines: [], skippedBefore: skipped }); | |
| 144 | } | |
| 145 | return out; | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Render a file's diff. `marks` is a Set of NEW-side line numbers to highlight. | |
| 150 | * Anchored files are focused to those anchors — MAX_LINES does not apply to | |
| 151 | * them. Un-anchored files over MAX_LINES, and generated files, collapse to a | |
| 152 | * summary line. | |
| 153 | * | |
| 154 | * `idScope` prefixes the per-line DOM ids; pass a value unique to this panel, | |
| 155 | * or omit it to emit no ids at all (appendix panels, which nothing links to). | |
| 156 | */ | |
| 157 | function renderFile(file, { marks = new Set(), open = true, idScope = '', prUrl = '#' } = {}) { | |
| 158 | const lang = languageOf(file.path); | |
| 159 | const counts = | |
| 160 | `<span class="counts"><span class="stat-add">+${file.additions}</span> ` + | |
| ⋯ 349 lines not shown | ||
Line ids are scoped to the beat that renders them.
lib/render.mjsnew+509 −0
| ⋯ 183 lines not shown | ||
| 184 | } else if (file.isGenerated || (total > MAX_LINES && marks.size === 0)) { | |
| 185 | const why = file.isGenerated | |
| 186 | ? 'Generated file — collapsed by default.' | |
| 187 | : `${total.toLocaleString('en-US')} changed lines — collapsed by default.`; | |
| 188 | body = `<div class="elide">${why} Open on <a href="${escapeHtml(prUrl)}">GitHub</a> to read it in full.</div>`; | |
| 189 | } else { | |
| 190 | const hunks = focused ? focusHunks(file, marks) : file.hunks; | |
| 191 | const rows = []; | |
| 192 | for (const hunk of hunks) { | |
| 193 | const label = focused && hunk.skippedBefore | |
| 194 | ? `⋯ ${hunk.skippedBefore.toLocaleString('en-US')} lines not shown` | |
| 195 | : escapeHtml(hunk.header); | |
| 196 | rows.push(`<tr class="hunk"><td colspan="3">${label}</td></tr>`); | |
| 197 | for (const l of hunk.lines) { | |
| 198 | const row = ROW[l.type]; | |
| 199 | const marked = l.newNo != null && marks.has(l.newNo) ? ' mark' : ''; | |
| 200 | // Only the panel that owns a line mints its id. The same file is | |
| 201 | // rendered once per beat that anchors it, so an unscoped id would be | |
| 202 | // emitted several times — invalid HTML, and getElementById would send | |
| 203 | // every deep link to whichever panel happened to render first. | |
| 204 | const id = l.newNo != null && idScope ? ` id="${idScope}-${l.newNo}"` : ''; | |
| 205 | rows.push( | |
| 206 | `<tr class="${row.cls}${marked}"${id}>` + | |
| 207 | `<td class="no">${l.oldNo ?? ''}</td>` + | |
| 208 | `<td class="no">${l.newNo ?? ''}</td>` + | |
| 209 | `<td class="src" data-m="${row.marker}">${highlight(l.content, lang)}</td></tr>` | |
| 210 | ); | |
| 211 | } | |
| 212 | } | |
| 213 | body = `<div class="code"><table>${rows.join('')}</table></div>`; | |
| 214 | if (focused) { | |
| 215 | body += `<div class="elide">Showing the passage this section is about — ` + | |
| 216 | `<a href="${escapeHtml(prUrl)}">read the full file on GitHub</a>.</div>`; | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | const isOpen = open && !file.isGenerated ? ' open' : ''; | |
| 221 | return `<details class="file"${isOpen}>${head}${body}</details>`; | |
| 222 | } | |
| 223 | ||
| 224 | /** Collect NEW-side line numbers covered by a beat's anchors, per file. */ | |
| 225 | function marksFor(anchors, byPath) { | |
| 226 | const perFile = new Map(); | |
| 227 | for (const spec of anchors) { | |
| ⋯ 282 lines not shown | ||
Author text is escaped before inline markup is applied, and code spans are lifted out before the emphasis passes run.
lib/render.mjsnew+509 −0
| @@ -0,0 +1,509 @@ | ||
| 1 | // Renders the story JSON + parsed diff into one self-contained HTML page. | |
| 2 | ||
| 3 | import { CSS, REST } from './theme.mjs'; | |
| 4 | import { escapeHtml, highlight, languageOf } from './highlight.mjs'; | |
| 5 | import { parseRange } from './diff.mjs'; | |
| 6 | ||
| 7 | // Inline markup for prose: `code`, **bold**, _italic_. Applied AFTER escaping, | |
| 8 | // so author text can never inject HTML. | |
| 9 | function inline(s) { | |
| 10 | const spans = []; | |
| 11 | // Code spans are lifted out before the emphasis passes and restored after. | |
| 12 | // Three independent sweeps cannot see each other's boundaries, so prose like | |
| 13 | // "see `a**b` and **x**" used to emit <code>a<strong>b</code> — tags crossing | |
| 14 | // each other, which some browsers recover from by swallowing the rest of the | |
| 15 | // paragraph. Prose about code containing ** (kwargs, pointers) is ordinary. | |
| 16 | // | |
| 17 | // The placeholder is delimited by U+E000 (private use area): a printable | |
| 18 | // sentinel would collide with author text (" 1 " occurs in ordinary prose), | |
| 19 | // and NUL would make this file read as binary to grep and diff. | |
| 20 | const SENTINEL = '\uE000'; | |
| 21 | const escaped = escapeHtml(s ?? '').replace(/`([^`]+)`/g, (_, code) => { | |
| 22 | spans.push(code); | |
| 23 | return `${SENTINEL}${spans.length - 1}${SENTINEL}`; | |
| 24 | }); | |
| 25 | return escaped | |
| 26 | .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') | |
| 27 | .replace(/(^|\s)_([^_]+)_(?=\s|$|[.,;:!?])/g, '$1<em>$2</em>') | |
| 28 | .replace(/\uE000(\d+)\uE000/g, (_, i) => `<code>${spans[Number(i)]}</code>`); | |
| 29 | } | |
| 30 | ||
| 31 | function paragraphs(text) { | |
| 32 | return String(text ?? '') | |
| 33 | .split(/\n\s*\n/) | |
| 34 | .map((p) => p.trim()) | |
| 35 | .filter(Boolean) | |
| 36 | .map((p) => `<p>${inline(p.replace(/\n/g, ' '))}</p>`) | |
| 37 | .join(''); | |
| 38 | } | |
| 39 | ||
| 40 | const NOTE_LABELS = { why: 'Why', tradeoff: 'Trade-off', rejected: 'Considered and rejected' }; | |
| 41 | ||
| 42 | // `kind` defaults to `why` when absent, but an unrecognized value is rejected by | |
| 43 | // validate() rather than silently relabelled — presenting an author's trade-off | |
| 44 | // as a justification invents intent they never expressed. | |
| 45 | function renderNotes(notes = [], labels = NOTE_LABELS) { | |
| 46 | return notes | |
| 47 | .filter((n) => n && n.text) | |
| 48 | .map((n) => { | |
| ⋯ 461 lines not shown | ||
Only http(s) reaches an href.
lib/render.mjsnew+509 −0
| ⋯ 83 lines not shown | ||
| 84 | add: { cls: 'add', marker: '+' }, | |
| 85 | del: { cls: 'del', marker: '−' }, | |
| 86 | ctx: { cls: '', marker: ' ' }, | |
| 87 | }; | |
| 88 | ||
| 89 | // Paths go into DOM ids and URL fragments, so anything outside [\w.-] has to | |
| 90 | // go — a space alone makes the fragment unaddressable and breaks deep links. | |
| 91 | function slugPath(path) { | |
| 92 | return path.replace(/[^\w.-]/g, '_'); | |
| 93 | } | |
| 94 | ||
| 95 | // Only http(s) reaches an href. Anything else — `javascript:`, `data:` — is | |
| 96 | // replaced rather than sanitized, since there is no valid reason for a PR URL | |
| 97 | // to carry another scheme. | |
| 98 | function safeUrl(u) { | |
| 99 | return /^https?:\/\//i.test(String(u ?? '')) ? String(u) : '#'; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Reduce a file's hunks to the neighbourhoods around `marks`, dropping the rest. | |
| 104 | * | |
| 105 | * An 800-line new file has one or two passages the narrative actually points | |
| 106 | * at. Showing all of it buries them; collapsing the file hides them entirely. | |
| 107 | * Focusing keeps the anchored passage readable in place. | |
| 108 | * | |
| 109 | * EVERY dropped line is accounted for by a marker — leading, between, and | |
| 110 | * trailing. A segment with no lines is a pure trailing marker. Hiding code | |
| 111 | * without saying so is the one failure this tool must never commit: a reviewer | |
| 112 | * who reaches the end of a panel and sees no marker concludes the file ended | |
| 113 | * there, and approves what they never read. | |
| 114 | */ | |
| 115 | function focusHunks(file, marks) { | |
| 116 | const out = []; | |
| ⋯ 393 lines not shown | ||
The build reviews the narrative
Two silent failures that would make the output worse than nothing.
An anchor pointing at a line that does not exist means the number was guessed rather than read off the diff. The build says so.
Ranges that cannot mean anything — running backwards, starting below line 1 — are rejected outright rather than warned about.
build.mjsnew+230 −0
| ⋯ 47 lines not shown | ||
| 48 | const key = a.slice(2); | |
| 49 | if (key !== 'out' && key !== 'lang') fail(`unknown flag --${key}`); | |
| 50 | const next = argv[i + 1]; | |
| 51 | if (next === undefined || next.startsWith('--')) fail(`--${key} needs a value`); | |
| 52 | opts[key] = next; | |
| 53 | i++; | |
| 54 | } | |
| 55 | return { positional, opts }; | |
| 56 | } | |
| 57 | ||
| 58 | const NOTE_KINDS = new Set(['why', 'tradeoff', 'rejected']); | |
| 59 | ||
| 60 | // Validate the story up front. A malformed field here would otherwise surface | |
| 61 | // as a silently empty chapter in the output, which is worse than an error. | |
| 62 | // | |
| 63 | // Problems abort the run; warnings print and rendering continues. The split is | |
| 64 | // deliberate: a wrong anchor still produces a usable page, a wrong structure | |
| 65 | // does not. Every problem is collected before exiting so an author fixing a | |
| 66 | // story sees the whole list, not one error per run. | |
| 67 | // | |
| 68 | // Returns the paths no beat anchored. | |
| 69 | function validate(story, files) { | |
| 70 | const byPath = new Map(files.map((f) => [f.path, f])); | |
| 71 | const problems = []; | |
| 72 | const warnings = []; | |
| 73 | // Valid new-side line numbers, computed once per file rather than per anchor. | |
| 74 | const validLines = new Map(); | |
| 75 | const linesOf = (path) => { | |
| 76 | if (!validLines.has(path)) { | |
| 77 | const set = new Set(); | |
| 78 | for (const h of byPath.get(path).hunks) { | |
| 79 | for (const l of h.lines) if (l.newNo != null) set.add(l.newNo); | |
| 80 | } | |
| 81 | validLines.set(path, set); | |
| 82 | } | |
| 83 | return validLines.get(path); | |
| 84 | }; | |
| 85 | ||
| 86 | for (const k of ['number', 'title', 'chapters']) { | |
| 87 | if (story[k] === undefined) problems.push(`missing required field: ${k}`); | |
| 88 | } | |
| 89 | // `number` becomes part of the default output filename, so a value carrying a | |
| 90 | // path separator would write somewhere the user did not ask for. | |
| 91 | if (story.number !== undefined && !Number.isInteger(story.number)) { | |
| 92 | problems.push(`number must be an integer, got ${JSON.stringify(story.number)}`); | |
| 93 | } | |
| 94 | if (!Array.isArray(story.chapters) || story.chapters.length === 0) { | |
| 95 | problems.push('chapters must be a non-empty array'); | |
| 96 | } | |
| 97 | ||
| 98 | const covered = new Set(); | |
| 99 | (story.chapters || []).forEach((ch, ci) => { | |
| 100 | const where = `chapters[${ci}]`; | |
| 101 | if (!ch.title) problems.push(`${where}: missing title`); | |
| 102 | if (!Array.isArray(ch.beats) || ch.beats.length === 0) { | |
| 103 | problems.push(`${where}: beats must be a non-empty array`); | |
| 104 | return; | |
| 105 | } | |
| 106 | ch.beats.forEach((b, bi) => { | |
| 107 | const w = `${where}.beats[${bi}]`; | |
| 108 | if (!b.text) problems.push(`${w}: missing text`); | |
| 109 | // An unrecognized kind used to fall back to "why", relabelling the | |
| 110 | // author's trade-off as a justification — inventing intent is worse than | |
| 111 | // refusing to render. | |
| 112 | for (const n of b.notes || []) { | |
| 113 | if (n && n.kind !== undefined && !NOTE_KINDS.has(n.kind)) { | |
| 114 | problems.push(`${w}: note kind "${n.kind}" — expected why, tradeoff or rejected`); | |
| 115 | } | |
| 116 | } | |
| 117 | for (const spec of b.files || []) { | |
| 118 | const { path, from, to, malformed } = parseRange(spec); | |
| 119 | if (malformed && !byPath.has(path)) { | |
| 120 | problems.push(`${w}: anchor "${spec}" — malformed range (expected path:N or path:N-M)`); | |
| 121 | continue; | |
| 122 | } | |
| 123 | if (!byPath.has(path)) { | |
| 124 | problems.push(`${w}: anchor "${spec}" — no such file in the diff`); | |
| 125 | continue; | |
| 126 | } | |
| 127 | covered.add(path); | |
| 128 | if (from == null) continue; | |
| 129 | // Both are authoring mistakes with no valid reading, and both would | |
| 130 | // otherwise make every `for (n = from; n <= to)` loop run zero times — | |
| 131 | // an empty highlight that never trips the "matches no line" warning. | |
| 132 | if (from < 1) { | |
| 133 | problems.push(`${w}: anchor "${spec}" — line numbers start at 1`); | |
| 134 | continue; | |
| 135 | } | |
| 136 | if (to < from) { | |
| 137 | problems.push(`${w}: anchor "${spec}" — range runs backwards`); | |
| 138 | continue; | |
| 139 | } | |
| 140 | // Anchors point at NEW-side lines. A range that hits nothing means the | |
| 141 | // model guessed line numbers instead of reading them off the diff — | |
| 142 | // the highlight would silently render nothing. | |
| 143 | const valid = linesOf(path); | |
| 144 | let hit = 0; | |
| 145 | for (let n = from; n <= to; n++) if (valid.has(n)) hit++; | |
| 146 | if (hit === 0 && valid.size > 0) { | |
| 147 | warnings.push(`${w}: anchor "${spec}" matches no line in the new file`); | |
| 148 | } | |
| 149 | } | |
| 150 | }); | |
| 151 | }); | |
| 152 | ||
| 153 | const uncovered = [...byPath.keys()].filter((p) => !covered.has(p)); | |
| 154 | if (problems.length) { | |
| 155 | console.error('story validation failed:'); | |
| 156 | for (const p of problems) console.error(` - ${p}`); | |
| 157 | process.exit(1); | |
| 158 | } | |
| 159 | for (const w of warnings) console.error(`warning: ${w}`); | |
| 160 | return { uncovered }; | |
| 161 | } | |
| 162 | ||
| ⋯ 68 lines not shown | ||
The second check reads the appendix as evidence. Files no beat anchored land there — right for structure.sql and one-line includes, wrong for a new 50-line helper, which the page would then present as carrying no decision worth explaining.
build.mjsnew+230 −0
| ⋯ 197 lines not shown | ||
| 198 | const html = render(story, files, { lang: opts.lang }); | |
| 199 | const out = opts.out || `guided-review-${story.number}.html`; | |
| 200 | try { | |
| 201 | writeFileSync(out, html); | |
| 202 | } catch (e) { | |
| 203 | // The skill drives this from a mktemp directory; when that is gone the raw | |
| 204 | // ENOENT stack trace is the least useful thing to hand back. | |
| 205 | fail(`cannot write ${out}: ${e.message}`); | |
| 206 | } | |
| 207 | ||
| 208 | const beats = story.chapters.reduce((n, c) => n + (c.beats || []).length, 0); | |
| 209 | console.log(`✓ ${out}`); | |
| 210 | console.log(` ${story.chapters.length} chapters · ${beats} beats · ${files.length} files · ${(html.length / 1024).toFixed(0)} KB`); | |
| 211 | if (uncovered.length) { | |
| 212 | console.log(` ${uncovered.length} file(s) in the appendix: ${uncovered.slice(0, 5).map((p) => basename(p)).join(', ')}${uncovered.length > 5 ? '…' : ''}`); | |
| 213 | ||
| 214 | // The appendix is for files that carry no decision: generated output, lockfiles, | |
| 215 | // one-line includes. Anything substantial landing there means the narrative | |
| 216 | // skipped it — which reads to the reviewer as "this file didn't matter". | |
| 217 | const substantial = uncovered | |
| 218 | .map((p) => files.find((f) => f.path === p)) | |
| 219 | .filter((f) => f && !f.isGenerated && f.additions + f.deletions >= 20); | |
| 220 | if (substantial.length) { | |
| 221 | console.error( | |
| 222 | `\nwarning: ${substantial.length} substantial file(s) have no place in the narrative.\n` + | |
| 223 | 'The appendix is meant for generated and mechanical changes. Either give these a beat,\n' + | |
| 224 | 'or accept that the review presents them as carrying no decision:' | |
| 225 | ); | |
| 226 | for (const f of substantial) { | |
| 227 | console.error(` - ${f.path} (+${f.additions} −${f.deletions})`); | |
| 228 | } | |
| 229 | } | |
| 230 | } | |
A note whose kind is not one of the three recognized values is a hard failure.
build.mjsnew+230 −0
| ⋯ 45 lines not shown | ||
| 46 | if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); } | |
| 47 | if (!a.startsWith('--')) { positional.push(a); continue; } | |
| 48 | const key = a.slice(2); | |
| 49 | if (key !== 'out' && key !== 'lang') fail(`unknown flag --${key}`); | |
| 50 | const next = argv[i + 1]; | |
| 51 | if (next === undefined || next.startsWith('--')) fail(`--${key} needs a value`); | |
| 52 | opts[key] = next; | |
| 53 | i++; | |
| 54 | } | |
| 55 | return { positional, opts }; | |
| 56 | } | |
| 57 | ||
| 58 | const NOTE_KINDS = new Set(['why', 'tradeoff', 'rejected']); | |
| 59 | ||
| 60 | // Validate the story up front. A malformed field here would otherwise surface | |
| 61 | // as a silently empty chapter in the output, which is worse than an error. | |
| 62 | // | |
| 63 | // Problems abort the run; warnings print and rendering continues. The split is | |
| 64 | // deliberate: a wrong anchor still produces a usable page, a wrong structure | |
| 65 | // does not. Every problem is collected before exiting so an author fixing a | |
| 66 | // story sees the whole list, not one error per run. | |
| 67 | // | |
| 68 | // Returns the paths no beat anchored. | |
| 69 | function validate(story, files) { | |
| 70 | const byPath = new Map(files.map((f) => [f.path, f])); | |
| ⋯ 23 lines not shown | ||
| 94 | if (!Array.isArray(story.chapters) || story.chapters.length === 0) { | |
| 95 | problems.push('chapters must be a non-empty array'); | |
| 96 | } | |
| 97 | ||
| 98 | const covered = new Set(); | |
| 99 | (story.chapters || []).forEach((ch, ci) => { | |
| 100 | const where = `chapters[${ci}]`; | |
| 101 | if (!ch.title) problems.push(`${where}: missing title`); | |
| 102 | if (!Array.isArray(ch.beats) || ch.beats.length === 0) { | |
| 103 | problems.push(`${where}: beats must be a non-empty array`); | |
| 104 | return; | |
| 105 | } | |
| 106 | ch.beats.forEach((b, bi) => { | |
| 107 | const w = `${where}.beats[${bi}]`; | |
| 108 | if (!b.text) problems.push(`${w}: missing text`); | |
| 109 | // An unrecognized kind used to fall back to "why", relabelling the | |
| 110 | // author's trade-off as a justification — inventing intent is worse than | |
| 111 | // refusing to render. | |
| 112 | for (const n of b.notes || []) { | |
| 113 | if (n && n.kind !== undefined && !NOTE_KINDS.has(n.kind)) { | |
| 114 | problems.push(`${w}: note kind "${n.kind}" — expected why, tradeoff or rejected`); | |
| 115 | } | |
| 116 | } | |
| 117 | for (const spec of b.files || []) { | |
| 118 | const { path, from, to, malformed } = parseRange(spec); | |
| 119 | if (malformed && !byPath.has(path)) { | |
| 120 | problems.push(`${w}: anchor "${spec}" — malformed range (expected path:N or path:N-M)`); | |
| 121 | continue; | |
| 122 | } | |
| 123 | if (!byPath.has(path)) { | |
| 124 | problems.push(`${w}: anchor "${spec}" — no such file in the diff`); | |
| 125 | continue; | |
| 126 | } | |
| 127 | covered.add(path); | |
| 128 | if (from == null) continue; | |
| 129 | // Both are authoring mistakes with no valid reading, and both would | |
| 130 | // otherwise make every `for (n = from; n <= to)` loop run zero times — | |
| 131 | // an empty highlight that never trips the "matches no line" warning. | |
| 132 | if (from < 1) { | |
| ⋯ 98 lines not shown | ||
Designing for reading
A technical essay, not a dashboard — and the details that decide which one you get.
Three typefaces for three voices: serif for the prose, sans for the interface, mono for code. The author's voice, the tool's, and the machine's — kept physically distinct so the eye never has to work out which it is reading.
No web fonts: the page has to open offline and from a file:// URL.
lib/theme.mjsnew+670 −0
| @@ -0,0 +1,670 @@ | ||
| 1 | // The stylesheet, as a single exported string. | |
| 2 | // | |
| 3 | // Design direction: a technical essay, not a dashboard. The prose is the | |
| 4 | // subject and the diff is the illustration beside it — so the narrative gets a | |
| 5 | // serif at reading size and generous measure, while the code stays visually | |
| 6 | // quiet until a beat points at it. | |
| 7 | // | |
| 8 | // Deliberate choices worth keeping: | |
| 9 | // - Serif prose / sans chrome / mono code. Three voices, three roles: the | |
| 10 | // author's, the tool's, the machine's. Never blur them. | |
| 11 | // - Diff colors are desaturated. Full-strength red/green on every changed line | |
| 12 | // turns the page into a Christmas tree and drowns the anchor highlight, which | |
| 13 | // is the one color that must cut through. | |
| 14 | // - No web fonts. The page must open offline and from a file:// URL, so the | |
| 15 | // stacks below are curated system faces, not a downloaded family. | |
| 16 | // - light-dark() with a [data-theme] override: follows the OS by default, and | |
| 17 | // the toggle wins in both directions. | |
| 18 | ||
| 19 | // Where a chapter comes to rest below the sticky masthead, in px. | |
| 20 | // | |
| 21 | // Shared with the page script deliberately: the scrollspy treats a chapter as | |
| 22 | // active once its top crosses this same line. When the two drifted apart, j/k | |
| 23 | // oscillated between two chapters forever — the destination landed just outside | |
| 24 | // the threshold that decides which chapter you are in. | |
| 25 | export const REST = 76; | |
| 26 | ||
| 27 | export const CSS = String.raw` | |
| 28 | :root { | |
| 29 | color-scheme: light dark; | |
| 30 | ||
| 31 | /* Neutrals are tinted — warm parchment in light, cool slate in dark. A pure | |
| 32 | gray next to the amber accent reads as dirty. */ | |
| 33 | --bg: light-dark(oklch(98.6% 0.006 85), oklch(19% 0.014 255)); | |
| 34 | --bg-sunk: light-dark(oklch(96.2% 0.009 85), oklch(16% 0.014 255)); | |
| 35 | --bg-raised: light-dark(oklch(100% 0 0), oklch(23% 0.015 255)); | |
| 36 | --bg-hover: light-dark(oklch(94.5% 0.012 85), oklch(27% 0.017 255)); | |
| 37 | --line: light-dark(oklch(89% 0.012 85), oklch(30% 0.016 255)); | |
| 38 | --line-soft: light-dark(oklch(93.5% 0.010 85), oklch(25.5% 0.015 255)); | |
| 39 | ||
| 40 | --ink: light-dark(oklch(24% 0.020 75), oklch(92% 0.012 250)); | |
| 41 | --ink-2: light-dark(oklch(44% 0.018 75), oklch(74% 0.012 250)); | |
| 42 | --ink-3: light-dark(oklch(58% 0.016 75), oklch(58% 0.012 250)); | |
| 43 | ||
| 44 | /* One accent, used sparingly: chapter numbers, progress, active nav. */ | |
| 45 | --accent: light-dark(oklch(52% 0.145 52), oklch(76% 0.135 62)); | |
| 46 | --accent-bg: light-dark(oklch(94% 0.045 62), oklch(30% 0.055 55)); | |
| 47 | ||
| 48 | --add: light-dark(oklch(45% 0.095 150), oklch(78% 0.105 155)); | |
| 49 | --add-bg: light-dark(oklch(95.5% 0.035 155), oklch(26% 0.045 158)); | |
| 50 | --add-gut: light-dark(oklch(88% 0.055 155), oklch(34% 0.055 158)); | |
| 51 | --del: light-dark(oklch(48% 0.115 22), oklch(76% 0.105 22)); | |
| 52 | --del-bg: light-dark(oklch(96% 0.028 22), oklch(26% 0.045 18)); | |
| 53 | --del-gut: light-dark(oklch(90% 0.045 22), oklch(34% 0.050 18)); | |
| 54 | ||
| 55 | /* The anchor highlight must beat every other color on the page. */ | |
| 56 | --mark: light-dark(oklch(85% 0.130 88), oklch(62% 0.115 88)); | |
| 57 | --mark-bg: light-dark(oklch(96% 0.070 92), oklch(32% 0.060 90)); | |
| 58 | ||
| 59 | --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Charter, "Source Serif 4", Georgia, serif; | |
| 60 | --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", Helvetica, sans-serif; | |
| 61 | --mono: ui-monospace, "SF Mono", "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace; | |
| 62 | ||
| 63 | /* Vertical rhythm: everything is a multiple of the prose line box (28px). */ | |
| 64 | --u: 0.5rem; | |
| 65 | --rail: 19rem; | |
| 66 | --radius: 10px; | |
| 67 | --ease: cubic-bezier(0.22, 0.9, 0.28, 1); | |
| 68 | } | |
| 69 | :root[data-theme="light"] { color-scheme: light; } | |
| 70 | :root[data-theme="dark"] { color-scheme: dark; } | |
| 71 | ||
| 72 | * { box-sizing: border-box; } | |
| ⋯ 598 lines not shown | ||
Diff colours are deliberately muted, and the anchor highlight carries its weight in the gutter rail rather than the fill.
lib/theme.mjsnew+670 −0
| ⋯ 27 lines not shown | ||
| 28 | :root { | |
| 29 | color-scheme: light dark; | |
| 30 | ||
| 31 | /* Neutrals are tinted — warm parchment in light, cool slate in dark. A pure | |
| 32 | gray next to the amber accent reads as dirty. */ | |
| 33 | --bg: light-dark(oklch(98.6% 0.006 85), oklch(19% 0.014 255)); | |
| 34 | --bg-sunk: light-dark(oklch(96.2% 0.009 85), oklch(16% 0.014 255)); | |
| 35 | --bg-raised: light-dark(oklch(100% 0 0), oklch(23% 0.015 255)); | |
| 36 | --bg-hover: light-dark(oklch(94.5% 0.012 85), oklch(27% 0.017 255)); | |
| 37 | --line: light-dark(oklch(89% 0.012 85), oklch(30% 0.016 255)); | |
| 38 | --line-soft: light-dark(oklch(93.5% 0.010 85), oklch(25.5% 0.015 255)); | |
| 39 | ||
| 40 | --ink: light-dark(oklch(24% 0.020 75), oklch(92% 0.012 250)); | |
| 41 | --ink-2: light-dark(oklch(44% 0.018 75), oklch(74% 0.012 250)); | |
| 42 | --ink-3: light-dark(oklch(58% 0.016 75), oklch(58% 0.012 250)); | |
| 43 | ||
| 44 | /* One accent, used sparingly: chapter numbers, progress, active nav. */ | |
| 45 | --accent: light-dark(oklch(52% 0.145 52), oklch(76% 0.135 62)); | |
| 46 | --accent-bg: light-dark(oklch(94% 0.045 62), oklch(30% 0.055 55)); | |
| 47 | ||
| 48 | --add: light-dark(oklch(45% 0.095 150), oklch(78% 0.105 155)); | |
| 49 | --add-bg: light-dark(oklch(95.5% 0.035 155), oklch(26% 0.045 158)); | |
| 50 | --add-gut: light-dark(oklch(88% 0.055 155), oklch(34% 0.055 158)); | |
| 51 | --del: light-dark(oklch(48% 0.115 22), oklch(76% 0.105 22)); | |
| 52 | --del-bg: light-dark(oklch(96% 0.028 22), oklch(26% 0.045 18)); | |
| 53 | --del-gut: light-dark(oklch(90% 0.045 22), oklch(34% 0.050 18)); | |
| 54 | ||
| 55 | /* The anchor highlight must beat every other color on the page. */ | |
| 56 | --mark: light-dark(oklch(85% 0.130 88), oklch(62% 0.115 88)); | |
| 57 | --mark-bg: light-dark(oklch(96% 0.070 92), oklch(32% 0.060 90)); | |
| 58 | ||
| 59 | --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Charter, "Source Serif 4", Georgia, serif; | |
| 60 | --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", Helvetica, sans-serif; | |
| 61 | --mono: ui-monospace, "SF Mono", "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace; | |
| 62 | ||
| 63 | /* Vertical rhythm: everything is a multiple of the prose line box (28px). */ | |
| 64 | --u: 0.5rem; | |
| 65 | --rail: 19rem; | |
| 66 | --radius: 10px; | |
| 67 | --ease: cubic-bezier(0.22, 0.9, 0.28, 1); | |
| 68 | } | |
| ⋯ 602 lines not shown | ||
Breakpoints are container queries. The beat responds to the column it occupies, not to the window.
lib/theme.mjsnew+670 −0
| ⋯ 242 lines not shown | ||
| 243 | font-variant-numeric: tabular-nums; | |
| 244 | } | |
| 245 | .rail .n { | |
| 246 | margin-right: calc(var(--u) * 0.875); | |
| 247 | font-family: var(--mono); | |
| 248 | font-size: 0.6875rem; | |
| 249 | color: var(--ink-3); | |
| 250 | font-variant-numeric: tabular-nums; | |
| 251 | } | |
| 252 | .rail a.on .n { color: var(--accent); } | |
| 253 | ||
| 254 | /* ─────────────────────────── Reading column ─────────────────────────── */ | |
| 255 | ||
| 256 | /* The reading column is the query container: the beat layout must react to the | |
| 257 | space it actually has, not to the viewport. With the rail taking ~19rem, a | |
| 258 | viewport-based breakpoint keeps a two-column beat long after the code panel | |
| 259 | has been squeezed to an unreadable sliver. */ | |
| 260 | .read { | |
| 261 | container: read / inline-size; | |
| 262 | padding: calc(var(--u) * 5) calc(var(--u) * 5) 40vh; | |
| 263 | min-width: 0; | |
| 264 | } | |
| 265 | ||
| 266 | .lede { | |
| 267 | max-width: 62ch; | |
| 268 | margin: 0 0 calc(var(--u) * 8); | |
| 269 | padding-bottom: calc(var(--u) * 5); | |
| 270 | border-bottom: 1px solid var(--line); | |
| 271 | } | |
| 272 | .lede .kicker { | |
| 273 | margin: 0 0 calc(var(--u) * 1.5); | |
| 274 | font-size: 0.6875rem; | |
| 275 | font-weight: 600; | |
| 276 | letter-spacing: 0.09em; | |
| 277 | text-transform: uppercase; | |
| ⋯ 65 lines not shown | ||
| 343 | describes, which is the whole point of the pairing. The trigger is the | |
| 344 | reading column, not the viewport; see the container query below. */ | |
| 345 | .beat { | |
| 346 | display: grid; | |
| 347 | grid-template-columns: minmax(0, 27rem) minmax(0, 1fr); | |
| 348 | gap: calc(var(--u) * 4); | |
| 349 | align-items: start; | |
| 350 | margin-bottom: calc(var(--u) * 5); | |
| 351 | } | |
| 352 | /* Sticky needs room to travel: when the prose is the taller half there is | |
| 353 | nothing to scroll past, and a sticky card just floats out of line with the | |
| 354 | panel it belongs to. Grid tracks report their own height, so the pairing | |
| 355 | stays aligned when the code is short. */ | |
| 356 | .beat-code { min-width: 0; } | |
| 357 | /* Below ~54rem of column there isn't room for prose AND a code panel wide | |
| 358 | enough to read: stack them, prose first, so each paragraph still sits | |
| 359 | directly above the code it describes. */ | |
| 360 | @container read (max-width: 54rem) { | |
| 361 | .beat { grid-template-columns: minmax(0, 1fr); gap: calc(var(--u) * 2.5); } | |
| 362 | /* Stacked, the prose sits directly above its code — there is no second | |
| 363 | column travelling behind it, so sticky would only detach it from the panel | |
| 364 | it introduces. */ | |
| 365 | .beat-prose { position: static; z-index: auto; max-width: 62ch; } | |
| 366 | } | |
| 367 | ||
| 368 | /* The prose column carries its own surface. It is sticky, so as the reader | |
| 369 | scrolls the code panel travels behind it — on a transparent background the | |
| 370 | two sets of text overlap and neither is readable. The z-index keeps it above | |
| 371 | that panel for the same reason. */ | |
| 372 | .beat-prose { | |
| 373 | position: sticky; | |
| 374 | top: ${REST}px; | |
| 375 | z-index: 1; | |
| 376 | padding: calc(var(--u) * 2.5) calc(var(--u) * 3) calc(var(--u) * 3); | |
| 377 | border-radius: var(--radius); | |
| 378 | background: var(--bg-raised); | |
| 379 | border: 1px solid var(--line-soft); | |
| 380 | font-family: var(--serif); | |
| 381 | font-size: 1.0625rem; | |
| 382 | line-height: 1.68; | |
| ⋯ 288 lines not shown | ||
REST — where a chapter comes to rest below the sticky masthead — is one exported constant, interpolated into both the stylesheet and the page script.
lib/theme.mjsnew+670 −0
| ⋯ 6 lines not shown | ||
| 7 | // | |
| 8 | // Deliberate choices worth keeping: | |
| 9 | // - Serif prose / sans chrome / mono code. Three voices, three roles: the | |
| 10 | // author's, the tool's, the machine's. Never blur them. | |
| 11 | // - Diff colors are desaturated. Full-strength red/green on every changed line | |
| 12 | // turns the page into a Christmas tree and drowns the anchor highlight, which | |
| 13 | // is the one color that must cut through. | |
| 14 | // - No web fonts. The page must open offline and from a file:// URL, so the | |
| 15 | // stacks below are curated system faces, not a downloaded family. | |
| 16 | // - light-dark() with a [data-theme] override: follows the OS by default, and | |
| 17 | // the toggle wins in both directions. | |
| 18 | ||
| 19 | // Where a chapter comes to rest below the sticky masthead, in px. | |
| 20 | // | |
| 21 | // Shared with the page script deliberately: the scrollspy treats a chapter as | |
| 22 | // active once its top crosses this same line. When the two drifted apart, j/k | |
| 23 | // oscillated between two chapters forever — the destination landed just outside | |
| 24 | // the threshold that decides which chapter you are in. | |
| 25 | export const REST = 76; | |
| 26 | ||
| 27 | export const CSS = String.raw` | |
| 28 | :root { | |
| 29 | color-scheme: light dark; | |
| 30 | ||
| 31 | /* Neutrals are tinted — warm parchment in light, cool slate in dark. A pure | |
| 32 | gray next to the amber accent reads as dirty. */ | |
| 33 | --bg: light-dark(oklch(98.6% 0.006 85), oklch(19% 0.014 255)); | |
| 34 | --bg-sunk: light-dark(oklch(96.2% 0.009 85), oklch(16% 0.014 255)); | |
| 35 | --bg-raised: light-dark(oklch(100% 0 0), oklch(23% 0.015 255)); | |
| 36 | --bg-hover: light-dark(oklch(94.5% 0.012 85), oklch(27% 0.017 255)); | |
| 37 | --line: light-dark(oklch(89% 0.012 85), oklch(30% 0.016 255)); | |
| ⋯ 633 lines not shown | ||
lib/render.mjsnew+509 −0
| ⋯ 292 lines not shown | ||
| 293 | }); | |
| 294 | ||
| 295 | var chapters = [].slice.call(document.querySelectorAll('.ch')); | |
| 296 | var links = [].slice.call(document.querySelectorAll('.rail a')); | |
| 297 | var bar = document.getElementById('progress'); | |
| 298 | // -1, not 0: the first onScroll() must be able to paint chapter 0 as active. | |
| 299 | // Starting at 0 makes that call a no-op and the rail loads with nothing marked. | |
| 300 | var current = -1; | |
| 301 | ||
| 302 | function setCurrent(i) { | |
| 303 | if (i === current) return; | |
| 304 | current = i; | |
| 305 | links.forEach(function (a, n) { a.classList.toggle('on', n === i); }); | |
| 306 | } | |
| 307 | ||
| 308 | // Where a chapter comes to rest below the masthead. Interpolated from the | |
| 309 | // REST constant in theme.mjs, which the stylesheet uses for the matching | |
| 310 | // scroll-margin — the two must not drift apart. | |
| 311 | var REST = ${REST}; | |
| 312 | ||
| 313 | // Active chapter = the last one whose top has crossed the masthead. Chapters | |
| 314 | // are taller than the viewport, so IntersectionObserver would flip between | |
| 315 | // neighbours mid-scroll; a position read is stable. | |
| 316 | // | |
| 317 | // Measured against the viewport, not offsetTop: the chapters sit inside a | |
| 318 | // positioned column, so offsetTop is relative to that column while scrollY is | |
| 319 | // absolute — comparing them is off by the column's own offset. | |
| 320 | function onScroll() { | |
| 321 | var i = 0; | |
| 322 | for (var n = 0; n < chapters.length; n++) { | |
| 323 | // +1 absorbs the sub-pixel rounding of a smooth scroll landing. | |
| 324 | if (chapters[n].getBoundingClientRect().top <= REST + 1) i = n; else break; | |
| 325 | } | |
| 326 | setCurrent(i); | |
| 327 | var max = document.body.scrollHeight - innerHeight; | |
| 328 | bar.style.width = (max > 0 ? Math.min(100, (scrollY / max) * 100) : 0) + '%'; | |
| 329 | } | |
| 330 | addEventListener('scroll', onScroll, { passive: true }); | |
| 331 | addEventListener('resize', onScroll, { passive: true }); | |
| 332 | onScroll(); | |
| 333 | ||
| 334 | // Scroll to an absolute position rather than scrollIntoView: the target is | |
| 335 | // computed from the viewport rect, so it lands under the masthead regardless | |
| 336 | // of the element's offset parent, and it honours prefers-reduced-motion. | |
| 337 | var SMOOTH = !matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| 338 | ||
| 339 | function go(i) { | |
| 340 | if (i < 0 || i >= chapters.length) return; | |
| 341 | var top = chapters[i].getBoundingClientRect().top + window.scrollY - REST; | |
| 342 | window.scrollTo({ top: Math.max(0, top), behavior: SMOOTH ? 'smooth' : 'auto' }); | |
| 343 | // Claim the destination immediately. A smooth scroll fires many scroll | |
| 344 | // events on the way, and reading "current" off them mid-flight makes the | |
| 345 | // next keypress compute its step from wherever the animation happens to be. | |
| 346 | setCurrent(i); | |
| 347 | } | |
| 348 | ||
| 349 | // One implementation for both the E key and the button: two copies of this | |
| 350 | // drift apart the moment either is touched. | |
| 351 | function toggleAll() { | |
| 352 | var all = document.querySelectorAll('.file'); | |
| 353 | var anyClosed = [].some.call(all, function (d) { return !d.open; }); | |
| 354 | [].forEach.call(all, function (d) { d.open = anyClosed; }); | |
| 355 | } | |
| 356 | ||
| 357 | addEventListener('keydown', function (e) { | |
| ⋯ 152 lines not shown | ||
What holds it together
The tests, and the reason they are the shape they are.
The parser tests assert line numbers against the hunk header's own declared counts — git's ground truth — across added, deleted, renamed and binary files, multiple hunks, and content that looks like diff metadata.
test/diff.test.mjsnew+179 −0
| @@ -0,0 +1,179 @@ | ||
| 1 | // Line-number tracking is the load-bearing contract of this tool: a narrative | |
| 2 | // anchored to the wrong lines points the reviewer at code the paragraph is not | |
| 3 | // about, and nothing crashes. These tests pin it. | |
| 4 | ||
| 5 | import { test } from 'node:test'; | |
| 6 | import assert from 'node:assert/strict'; | |
| 7 | import { parseDiff, parseRange, isGenerated } from '../lib/diff.mjs'; | |
| 8 | ||
| 9 | // Every `git diff` / `gh pr diff` ends in a newline. Splitting on '\n' leaves a | |
| 10 | // trailing '' that is not a line of the file — consumed as context, it invented | |
| 11 | // a row and registered a line number that does not exist as a valid anchor. | |
| 12 | test('a trailing newline does not fabricate a line', () => { | |
| 13 | const diff = [ | |
| 14 | 'diff --git a/x.rb b/x.rb', | |
| 15 | 'new file mode 100644', | |
| 16 | '--- /dev/null', | |
| 17 | '+++ b/x.rb', | |
| 18 | '@@ -0,0 +1,2 @@', | |
| 19 | '+a', | |
| 20 | '+b', | |
| 21 | '', | |
| 22 | ].join('\n'); | |
| 23 | ||
| 24 | const [file] = parseDiff(diff); | |
| 25 | assert.equal(file.hunks[0].lines.length, 2); | |
| 26 | assert.deepEqual(file.hunks[0].lines.map((l) => l.newNo), [1, 2]); | |
| 27 | assert.equal(file.additions, 2); | |
| 28 | }); | |
| 29 | ||
| 30 | test('line numbers match the hunk header on both sides', () => { | |
| 31 | const diff = [ | |
| 32 | 'diff --git a/x.rb b/x.rb', | |
| 33 | '--- a/x.rb', | |
| 34 | '+++ b/x.rb', | |
| 35 | '@@ -10,3 +10,4 @@ def existing', | |
| 36 | ' ctx', | |
| 37 | '-gone', | |
| 38 | '+kept', | |
| 39 | '+added', | |
| 40 | ' tail', | |
| 41 | '', | |
| 42 | ].join('\n'); | |
| 43 | ||
| 44 | const [file] = parseDiff(diff); | |
| 45 | const lines = file.hunks[0].lines; | |
| 46 | assert.deepEqual(lines.map((l) => [l.type, l.oldNo, l.newNo]), [ | |
| 47 | ['ctx', 10, 10], | |
| 48 | ['del', 11, null], | |
| 49 | ['add', null, 11], | |
| 50 | ['add', null, 12], | |
| 51 | ['ctx', 12, 13], | |
| 52 | ]); | |
| 53 | assert.equal(file.additions, 2); | |
| 54 | assert.equal(file.deletions, 1); | |
| 55 | }); | |
| 56 | ||
| 57 | test('a deleted file has no new-side line numbers', () => { | |
| 58 | const diff = [ | |
| 59 | 'diff --git a/old.rb b/old.rb', | |
| 60 | 'deleted file mode 100644', | |
| 61 | '--- a/old.rb', | |
| 62 | '+++ /dev/null', | |
| 63 | '@@ -1,2 +0,0 @@', | |
| 64 | '-class Old', | |
| 65 | '-end', | |
| 66 | '', | |
| 67 | ].join('\n'); | |
| 68 | ||
| 69 | const [file] = parseDiff(diff); | |
| 70 | assert.equal(file.status, 'deleted'); | |
| 71 | // newNo 0 is not a line number; every consumer tests `!= null`, so a zero | |
| 72 | // would pass as a real line and mint a dead deep-link target. | |
| 73 | assert.ok(file.hunks[0].lines.every((l) => l.newNo === null)); | |
| 74 | }); | |
| 75 | ||
| 76 | // `--- ` and `+++ ` are file metadata only before the first hunk. Inside one, | |
| 77 | // a deleted line reading `--- foo` is content, and skipping it drops real code. | |
| 78 | test('content that looks like diff metadata survives', () => { | |
| 79 | const diff = [ | |
| 80 | 'diff --git a/CHANGELOG.md b/CHANGELOG.md', | |
| 81 | '--- a/CHANGELOG.md', | |
| 82 | '+++ b/CHANGELOG.md', | |
| 83 | '@@ -1,2 +1,2 @@', | |
| 84 | '---- old heading', | |
| 85 | '+--- new heading', | |
| 86 | ' body', | |
| 87 | '', | |
| 88 | ].join('\n'); | |
| 89 | ||
| 90 | const [file] = parseDiff(diff); | |
| 91 | const lines = file.hunks[0].lines; | |
| 92 | assert.equal(lines.length, 3); | |
| 93 | assert.equal(lines[0].content, '--- old heading'); | |
| 94 | assert.equal(lines[1].content, '--- new heading'); | |
| 95 | }); | |
| 96 | ||
| 97 | test('binary, renamed and added files are recognised', () => { | |
| 98 | const diff = [ | |
| 99 | 'diff --git a/logo.png b/logo.png', | |
| 100 | 'new file mode 100644', | |
| 101 | 'Binary files /dev/null and b/logo.png differ', | |
| 102 | 'diff --git a/a.rb b/b.rb', | |
| 103 | 'similarity index 90%', | |
| 104 | 'rename from a.rb', | |
| 105 | 'rename to b.rb', | |
| 106 | '@@ -1,1 +1,1 @@', | |
| 107 | '-old', | |
| 108 | '+new', | |
| 109 | '', | |
| 110 | ].join('\n'); | |
| 111 | ||
| 112 | const files = parseDiff(diff); | |
| 113 | assert.equal(files.length, 2); | |
| 114 | assert.equal(files[0].status, 'added'); | |
| 115 | assert.equal(files[0].isBinary, true); | |
| 116 | assert.equal(files[1].status, 'renamed'); | |
| 117 | assert.equal(files[1].oldPath, 'a.rb'); | |
| 118 | assert.equal(files[1].path, 'b.rb'); | |
| 119 | }); | |
| 120 | ||
| 121 | test('"\\ No newline at end of file" occupies no line number', () => { | |
| 122 | const diff = [ | |
| 123 | 'diff --git a/x.txt b/x.txt', | |
| 124 | '--- a/x.txt', | |
| 125 | '+++ b/x.txt', | |
| 126 | '@@ -1 +1 @@', | |
| 127 | '-a', | |
| 128 | '\\ No newline at end of file', | |
| 129 | '+b', | |
| 130 | '\\ No newline at end of file', | |
| 131 | '', | |
| 132 | ].join('\n'); | |
| 133 | ||
| 134 | const [file] = parseDiff(diff); | |
| 135 | assert.equal(file.hunks[0].lines.length, 2); | |
| 136 | }); | |
| 137 | ||
| 138 | test('multiple hunks each restart from their own header', () => { | |
| 139 | const diff = [ | |
| 140 | 'diff --git a/x.rb b/x.rb', | |
| 141 | '--- a/x.rb', | |
| 142 | '+++ b/x.rb', | |
| 143 | '@@ -1,1 +1,1 @@', | |
| 144 | '+first', | |
| 145 | '@@ -50,1 +60,1 @@', | |
| 146 | '+second', | |
| 147 | '', | |
| 148 | ].join('\n'); | |
| 149 | ||
| 150 | const [file] = parseDiff(diff); | |
| 151 | assert.equal(file.hunks.length, 2); | |
| 152 | assert.equal(file.hunks[0].lines[0].newNo, 1); | |
| 153 | assert.equal(file.hunks[1].lines[0].newNo, 60); | |
| 154 | }); | |
| 155 | ||
| 156 | test('parseRange splits anchors, and flags the malformed ones', () => { | |
| 157 | assert.deepEqual(parseRange('app/foo.rb'), { path: 'app/foo.rb', from: null, to: null }); | |
| 158 | assert.deepEqual(parseRange('app/foo.rb:42'), { path: 'app/foo.rb', from: 42, to: 42 }); | |
| 159 | assert.deepEqual(parseRange('app/foo.rb:17-42'), { path: 'app/foo.rb', from: 17, to: 42 }); | |
| 160 | ||
| 161 | // GitHub's own fragment syntax is an easy thing to write by mistake. Reported | |
| 162 | // as malformed rather than as a missing file, which names the wrong cause. | |
| 163 | const bad = parseRange('app/foo.rb:L17-L42'); | |
| 164 | assert.equal(bad.malformed, true); | |
| 165 | assert.equal(bad.from, null); | |
| 166 | ||
| 167 | // A reversed range is kept as written; validate() rejects it. Silently | |
| 168 | // reordering would hide an authoring mistake behind a plausible highlight. | |
| 169 | assert.deepEqual(parseRange('app/foo.rb:42-17'), { path: 'app/foo.rb', from: 42, to: 17 }); | |
| 170 | }); | |
| 171 | ||
| 172 | test('isGenerated matches machine-written paths only', () => { | |
| 173 | assert.ok(isGenerated('db/structure.sql')); | |
| 174 | assert.ok(isGenerated('package-lock.json')); | |
| 175 | assert.ok(isGenerated('app/assets/x.min.js')); | |
| 176 | assert.ok(isGenerated('vendor/gems/thing.rb')); | |
| 177 | assert.ok(!isGenerated('app/models/user.rb')); | |
| 178 | assert.ok(!isGenerated('lib/distribution.js')); | |
| 179 | }); | |
The highlighter tests assert that no language emits an empty class, and that escaping holds for every language including the unknown-extension fallthrough.
test/highlight.test.mjsnew+73 −0
| @@ -0,0 +1,73 @@ | ||
| 1 | // The class-mapping bug these pin was invisible by inspection: JSON and YAML | |
| 2 | // numbers rendered with class="" — unhighlighted, but not obviously broken — | |
| 3 | // because a rule containing its own capture group shifted every index after it. | |
| 4 | ||
| 5 | import { test } from 'node:test'; | |
| 6 | import assert from 'node:assert/strict'; | |
| 7 | import { highlight, escapeHtml, languageOf } from '../lib/highlight.mjs'; | |
| 8 | ||
| 9 | const classesIn = (html) => [...html.matchAll(/<i class="([^"]*)">/g)].map((m) => m[1]); | |
| 10 | ||
| 11 | test('no language emits an empty class', () => { | |
| 12 | const samples = { | |
| 13 | ruby: 'def x; return 42; end', | |
| 14 | js: 'const a = 42;', | |
| 15 | python: 'def f(): return 42', | |
| 16 | sql: 'SELECT 42 FROM t', | |
| 17 | css: '.a { width: 42px; }', | |
| 18 | go: 'func f() int { return 42 }', | |
| 19 | rust: 'fn f() -> i32 { 42 }', | |
| 20 | php: 'function f() { return 42; }', | |
| 21 | java: 'public int f() { return 42; }', | |
| 22 | shell: 'if true; then echo 42; fi', | |
| 23 | json: '{"k": 12, "ok": true}', | |
| 24 | yaml: 'key: 5\nflag: true', | |
| 25 | html: '<b class="x">hi</b>', | |
| 26 | markdown: '# Title with `code`', | |
| 27 | }; | |
| 28 | for (const [lang, src] of Object.entries(samples)) { | |
| 29 | const classes = classesIn(highlight(src, lang)); | |
| 30 | assert.ok(classes.length > 0, `${lang}: nothing highlighted`); | |
| 31 | assert.ok(!classes.includes(''), `${lang}: emitted an empty class`); | |
| 32 | } | |
| 33 | }); | |
| 34 | ||
| 35 | // Both languages declare a rule with an inner group before the number rule. | |
| 36 | test('numbers are classed in JSON and YAML', () => { | |
| 37 | assert.match(highlight('{"k": 12}', 'json'), /<i class="n">12<\/i>/); | |
| 38 | assert.match(highlight('a: 5', 'yaml'), /<i class="n">5<\/i>/); | |
| 39 | }); | |
| 40 | ||
| 41 | test('escaping holds for every language, including the unknown fallthrough', () => { | |
| 42 | const hostile = '"<script>alert(1)</script>" & \'x\''; | |
| 43 | for (const lang of ['ruby', 'js', 'json', 'yaml', 'html', 'sql', null]) { | |
| 44 | const out = highlight(hostile, lang); | |
| 45 | assert.ok(!/<script>/.test(out), `${lang}: raw <script> survived`); | |
| 46 | assert.ok(!out.includes('alert(1)</script>'), `${lang}: tag reconstructed`); | |
| 47 | } | |
| 48 | }); | |
| 49 | ||
| 50 | test('escapeHtml covers every character that can break an attribute', () => { | |
| 51 | assert.equal(escapeHtml(`&<>"'`), '&<>"''); | |
| 52 | }); | |
| 53 | ||
| 54 | test('a keyword inside a string is not highlighted as a keyword', () => { | |
| 55 | // Rule order is precedence: strings must win over keywords. | |
| 56 | const out = highlight('const a = "return false";', 'js'); | |
| 57 | assert.match(out, /<i class="s">"return false"<\/i>/); | |
| 58 | }); | |
| 59 | ||
| 60 | test('very long lines skip tokenization but stay escaped', () => { | |
| 61 | const line = '"'.repeat(3000) + '<b>'; | |
| 62 | const out = highlight(line, 'js'); | |
| 63 | assert.ok(!out.includes('<b>')); | |
| 64 | assert.ok(!out.includes('<i class=')); | |
| 65 | }); | |
| 66 | ||
| 67 | test('languageOf reads the last meaningful extension', () => { | |
| 68 | assert.equal(languageOf('app/views/_bar.html.erb'), 'html'); | |
| 69 | assert.equal(languageOf('app/models/user.rb'), 'ruby'); | |
| 70 | assert.equal(languageOf('Gemfile'), 'ruby'); | |
| 71 | assert.equal(languageOf('Dockerfile'), 'shell'); | |
| 72 | assert.equal(languageOf('README'), null); | |
| 73 | }); | |
The renderer tests pin the failures that produce a page which *looks fine*: hidden lines with no marker, crossed tags, duplicate ids, a live javascript: href.
The accounting test is the important one — every hidden line plus every shown line must equal the file.
test/render.test.mjsnew+168 −0
| @@ -0,0 +1,168 @@ | ||
| 1 | // The failures pinned here all produced a page that looked fine: hidden lines | |
| 2 | // with no marker, crossed tags, duplicate ids, a live javascript: href. For a | |
| 3 | // tool whose value is "this paragraph is about exactly these lines", output | |
| 4 | // that is confidently wrong is the most expensive failure mode there is. | |
| 5 | ||
| 6 | import { test } from 'node:test'; | |
| 7 | import assert from 'node:assert/strict'; | |
| 8 | import { parseDiff } from '../lib/diff.mjs'; | |
| 9 | import { render } from '../lib/render.mjs'; | |
| 10 | ||
| 11 | function addedFile(path, count) { | |
| 12 | const lines = []; | |
| 13 | for (let i = 1; i <= count; i++) lines.push(`+line ${i}`); | |
| 14 | return [ | |
| 15 | `diff --git a/${path} b/${path}`, | |
| 16 | 'new file mode 100644', | |
| 17 | '--- /dev/null', | |
| 18 | `+++ b/${path}`, | |
| 19 | `@@ -0,0 +1,${count} @@`, | |
| 20 | ...lines, | |
| 21 | '', | |
| 22 | ].join('\n'); | |
| 23 | } | |
| 24 | ||
| 25 | const story = (chapters, extra = {}) => ({ | |
| 26 | number: 1, | |
| 27 | title: 'T', | |
| 28 | url: 'https://example.com/pr/1', | |
| 29 | chapters, | |
| 30 | ...extra, | |
| 31 | }); | |
| 32 | ||
| 33 | // Content rows only: the `hunk` class marks the "N lines not shown" separators. | |
| 34 | const countRows = (html) => | |
| 35 | [...html.matchAll(/<tr class="([^"]*)"/g)].filter((m) => !m[1].includes('hunk')).length; | |
| 36 | ||
| 37 | const skipTotals = (html) => | |
| 38 | [...html.matchAll(/⋯ ([\d,]+) lines not shown/g)] | |
| 39 | .reduce((n, m) => n + Number(m[1].replace(/,/g, '')), 0); | |
| 40 | ||
| 41 | // The marker is the one guardrail the focus feature depends on. Lines dropped | |
| 42 | // after the last kept region used to vanish with nothing announcing them, so a | |
| 43 | // reviewer reaching the end of a panel concluded the file ended there. | |
| 44 | test('every hidden line is announced, including the trailing ones', () => { | |
| 45 | const files = parseDiff(addedFile('big.rb', 200)); | |
| 46 | const html = render( | |
| 47 | story([{ title: 'C', beats: [{ text: 'x', files: ['big.rb:100'] }] }]), | |
| 48 | files | |
| 49 | ); | |
| 50 | ||
| 51 | const shown = countRows(html); | |
| 52 | assert.ok(shown < 200, 'the file should be focused, not rendered whole'); | |
| 53 | assert.equal(skipTotals(html) + shown, 200); | |
| 54 | }); | |
| 55 | ||
| 56 | test('a mark near the start still accounts for the tail', () => { | |
| 57 | const files = parseDiff(addedFile('big.rb', 120)); | |
| 58 | const html = render( | |
| 59 | story([{ title: 'C', beats: [{ text: 'x', files: ['big.rb:2'] }] }]), | |
| 60 | files | |
| 61 | ); | |
| 62 | assert.equal(skipTotals(html) + countRows(html), 120); | |
| 63 | }); | |
| 64 | ||
| 65 | // Three independent replace() passes could not see each other's boundaries. | |
| 66 | test('interleaved code and bold markers do not cross', () => { | |
| 67 | const html = render( | |
| 68 | story([{ title: 'C', beats: [{ text: 'see `a**b` and **x**' }] }]), | |
| 69 | parseDiff(addedFile('x.rb', 3)) | |
| 70 | ); | |
| 71 | assert.match(html, /<code>a\*\*b<\/code> and <strong>x<\/strong>/); | |
| 72 | assert.ok(!/<code>[^<]*<strong>[^<]*<\/code>/.test(html), 'tags crossed'); | |
| 73 | }); | |
| 74 | ||
| 75 | test('bare numbers in prose are not swallowed by the code-span placeholder', () => { | |
| 76 | const html = render( | |
| 77 | story([{ title: 'C', beats: [{ text: 'about 1 of 10 items, see `x`' }] }]), | |
| 78 | parseDiff(addedFile('x.rb', 3)) | |
| 79 | ); | |
| 80 | assert.match(html, /about 1 of 10 items, see <code>x<\/code>/); | |
| 81 | }); | |
| 82 | ||
| 83 | // The same file renders once per beat that anchors it, so an id built only from | |
| 84 | // path and line was emitted several times — invalid HTML, and getElementById | |
| 85 | // sent every deep link to whichever panel happened to render first. | |
| 86 | test('line ids are unique across beats citing the same file', () => { | |
| 87 | const files = parseDiff(addedFile('a.rb', 40)); | |
| 88 | const html = render( | |
| 89 | story([ | |
| 90 | { | |
| 91 | title: 'C', | |
| 92 | beats: [ | |
| 93 | { text: 'one', files: ['a.rb:5'] }, | |
| 94 | { text: 'two', files: ['a.rb:6'] }, | |
| 95 | ], | |
| 96 | }, | |
| 97 | ]), | |
| 98 | files | |
| 99 | ); | |
| 100 | const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]); | |
| 101 | assert.equal(new Set(ids).size, ids.length, 'duplicate ids emitted'); | |
| 102 | }); | |
| 103 | ||
| 104 | test('ids stay addressable when the path contains a space', () => { | |
| 105 | const files = parseDiff(addedFile('a b.rb', 5)); | |
| 106 | const html = render( | |
| 107 | story([{ title: 'C', beats: [{ text: 'x', files: ['a b.rb:2'] }] }]), | |
| 108 | files | |
| 109 | ); | |
| 110 | for (const [, id] of html.matchAll(/ id="(L[^"]*|c\d+b\d+-L[^"]*)"/g)) { | |
| 111 | assert.ok(!/\s/.test(id), `id contains whitespace: ${id}`); | |
| 112 | } | |
| 113 | }); | |
| 114 | ||
| 115 | // Escaping stops attribute breakout but says nothing about the scheme, and this | |
| 116 | // page is built to be handed to other reviewers. | |
| 117 | test('a javascript: story url never reaches an href', () => { | |
| 118 | const html = render( | |
| 119 | story([{ title: 'C', beats: [{ text: 'x' }] }], { url: 'javascript:alert(1)' }), | |
| 120 | parseDiff(addedFile('x.rb', 3)) | |
| 121 | ); | |
| 122 | assert.ok(!/href="javascript:/i.test(html)); | |
| 123 | assert.match(html, /href="#"/); | |
| 124 | }); | |
| 125 | ||
| 126 | test('an ordinary https url survives intact', () => { | |
| 127 | const html = render( | |
| 128 | story([{ title: 'C', beats: [{ text: 'x' }] }]), | |
| 129 | parseDiff(addedFile('x.rb', 3)) | |
| 130 | ); | |
| 131 | assert.match(html, /href="https:\/\/example\.com\/pr\/1"/); | |
| 132 | }); | |
| 133 | ||
| 134 | test('markup in narrative prose renders as text, not HTML', () => { | |
| 135 | const html = render( | |
| 136 | story([{ title: 'C', beats: [{ text: 'an <img src=x onerror=alert(1)> tag' }] }]), | |
| 137 | parseDiff(addedFile('x.rb', 3)) | |
| 138 | ); | |
| 139 | assert.ok(!/<img/.test(html)); | |
| 140 | assert.match(html, /<img src=x/); | |
| 141 | }); | |
| 142 | ||
| 143 | test('files no beat anchors land in the appendix', () => { | |
| 144 | const files = parseDiff(addedFile('used.rb', 5) + addedFile('spare.rb', 5)); | |
| 145 | const html = render( | |
| 146 | story([{ title: 'C', beats: [{ text: 'x', files: ['used.rb:1'] }] }]), | |
| 147 | files | |
| 148 | ); | |
| 149 | assert.match(html, /class="appendix"/); | |
| 150 | assert.match(html, /spare\.rb/); | |
| 151 | }); | |
| 152 | ||
| 153 | test('the pt locale switches the chrome, not the narrative', () => { | |
| 154 | const html = render( | |
| 155 | story([{ title: 'Capítulo um', beats: [{ text: 'texto' }] }]), | |
| 156 | parseDiff(addedFile('x.rb', 3)), | |
| 157 | { lang: 'pt' } | |
| 158 | ); | |
| 159 | assert.match(html, /lang="pt-BR"/); | |
| 160 | assert.match(html, /Capítulo 01/); | |
| 161 | }); | |
| 162 | ||
| 163 | test('render does not mutate the parsed diff it is given', () => { | |
| 164 | const files = parseDiff(addedFile('x.rb', 3)); | |
| 165 | const before = JSON.stringify(files); | |
| 166 | render(story([{ title: 'C', beats: [{ text: 'x', files: ['x.rb:1'] }] }]), files); | |
| 167 | assert.equal(JSON.stringify(files), before); | |
| 168 | }); | |
node:test, no dependencies, and a package.json that exists only to name the runner.
package.jsonnew+13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | { | |
| 2 | "name": "guided-review", | |
| 3 | "version": "1.0.0", | |
| 4 | "description": "Turn a pull request into a guided, narrated code review.", | |
| 5 | "type": "module", | |
| 6 | "license": "MIT", | |
| 7 | "scripts": { | |
| 8 | "test": "node --test \"test/*.test.mjs\"" | |
| 9 | }, | |
| 10 | "engines": { | |
| 11 | "node": ">=18" | |
| 12 | } | |
| 13 | } | |
The README carries the argument the code cannot make on its own: that reviewing got harder because writing got cheaper, that the bottleneck moved from producing code to understanding it, and that the fix is not asking reviewers to try harder.
It is also where the honest limits go — what this is not, which is a code review.
README.mdnew+251 −0
| @@ -0,0 +1,251 @@ | ||
| 1 | # guided-review | |
| 2 | ||
| 3 | **Make code review great again.** | |
| 4 | ||
| 5 | Turn a pull request into a guided, narrated code review — one self-contained HTML page where | |
| 6 | the PR is told as a story, in the order the decisions were made, with each paragraph sitting | |
| 7 | beside the exact diff lines it explains. | |
| 8 | ||
| 9 |  | |
| 10 | ||
| 11 | --- | |
| 12 | ||
| 13 | ## Let's be honest about code review | |
| 14 | ||
| 15 | Nobody's favourite part of the week. | |
| 16 | ||
| 17 | It used to be survivable. A couple of PRs, most of them small, written by someone whose | |
| 18 | reasoning you could reconstruct — or just ask about. Then something changed: **writing code got | |
| 19 | dramatically cheaper, and reading it didn't.** | |
| 20 | ||
| 21 | The bottleneck moved. It's no longer "how fast can we ship this" — it's "who has three | |
| 22 | uninterrupted hours to understand what this actually does." And here's the uncomfortable part: | |
| 23 | the code is often *fine*. Well structured, tested, idiomatic. That's not the problem. The | |
| 24 | problem is **volume against a fixed budget of human attention**. | |
| 25 | ||
| 26 | So what actually happens, in every team, every week: | |
| 27 | ||
| 28 | - You open a 40-file PR at 4pm. | |
| 29 | - GitHub greets you with `.eslintrc` and `.gitignore`, because it sorts alphabetically and has | |
| 30 | no idea which file carries the idea. | |
| 31 | - The description explains the reasoning beautifully — in one place, 800 words, scrolled far | |
| 32 | away from any of the code it describes. | |
| 33 | - You read the prose. You read the diff. You try to hold the mapping between them in your head. | |
| 34 | - Somewhere around file 18, the honest part of your brain goes quiet and you start **scrolling | |
| 35 | faster**. | |
| 36 | - ✅ LGTM. | |
| 37 | ||
| 38 | That last step is the expensive one. Not because reviewers are lazy — because **reconstructing | |
| 39 | intent from a diff is genuinely hard work**, and we ask people to redo it from scratch for every | |
| 40 | PR, after the author already did it and wrote it down somewhere else. | |
| 41 | ||
| 42 | Skimming doesn't miss the bugs a linter catches. It misses the ones that need someone awake | |
| 43 | enough to ask *"wait, why is it done this way?"* | |
| 44 | ||
| 45 | ### The bet | |
| 46 | ||
| 47 | The fix isn't a better diff viewer, and it isn't asking reviewers to try harder. | |
| 48 | ||
| 49 | It's putting the explanation **next to the code it explains**, in the order the decisions were | |
| 50 | actually made — so that following the reasoning costs *less* than skipping it. | |
| 51 | ||
| 52 | A big PR stops being a wall of unfamiliar code and becomes something closer to a well-written | |
| 53 | article about a codebase you happen to work on. You still review. You just get to enjoy it. | |
| 54 | ||
| 55 | --- | |
| 56 | ||
| 57 | ## What it does | |
| 58 | ||
| 59 | - 📖 **Chapters, not files** — ordered by what you need to understand first, never alphabetically | |
| 60 | - 🔗 **Prose anchored to lines** — each paragraph sits beside the exact passage it describes, highlighted | |
| 61 | - 💡 **The reasoning, made explicit** — `why`, `trade-off` and `considered and rejected` callouts | |
| 62 | - ✂️ **Focused panels** — an 800-line file shows the passage under discussion, and says how much it skipped | |
| 63 | - 🧭 **A reading order that exists** — chapter rail, progress, `J`/`K` navigation | |
| 64 | - 📦 **One HTML file** — no network, no assets, no build step; opens offline | |
| 65 | - 🤖 Installable as a [Claude Code](https://docs.claude.com/en/docs/claude-code) skill | |
| 66 | ||
| 67 |  | |
| 68 | ||
| 69 | Above: a `considered and rejected` callout next to the exact line where the decision lives — | |
| 70 | plus two `lines not shown` markers, because a tool that hides code from reviewers to save space | |
| 71 | would be arguing against itself. | |
| 72 | ||
| 73 | --- | |
| 74 | ||
| 75 | ## See it working | |
| 76 | ||
| 77 | This tool reviewing the pull request that adds this tool: | |
| 78 | ||
| 79 | ### **→ [Read the guided review](https://guided-review-example.pages.dev/)** | |
| 80 | ||
| 81 | Then open [the same PR on GitHub](https://github.com/MarceloCajueiro/guided-review/pull/1) and | |
| 82 | compare. The GitHub tab starts at `.claude-plugin/plugin.json`, `.gitignore`, `LICENSE` — three | |
| 83 | files carrying no idea at all. The guided one starts at the idea everything else is built on. | |
| 84 | ||
| 85 |  | |
| 86 | ||
| 87 | --- | |
| 88 | ||
| 89 | ## Requirements | |
| 90 | ||
| 91 | - [Node](https://nodejs.org) v18+ — no `npm install`, zero dependencies | |
| 92 | - [GitHub CLI](https://cli.github.com) (`gh`) authenticated with access to the repo | |
| 93 | ||
| 94 | ## Quick start | |
| 95 | ||
| 96 | As a Claude Code skill — the intended path, since writing the narrative is the part that needs | |
| 97 | a reader: | |
| 98 | ||
| 99 | ``` | |
| 100 | /guided-review 3649 | |
| 101 | ``` | |
| 102 | ||
| 103 | Claude reads the PR and its diff, works out the order the story should be told in, writes the | |
| 104 | narrative, builds the page, and opens it. | |
| 105 | ||
| 106 | Standalone, if you already have a story JSON: | |
| 107 | ||
| 108 | ```bash | |
| 109 | gh pr diff 3649 > pr.diff | |
| 110 | node build.mjs story.json pr.diff --out review.html | |
| 111 | ``` | |
| 112 | ||
| 113 | ### Options | |
| 114 | ||
| 115 | | Option | Default | What it does | | |
| 116 | |---|---|---| | |
| 117 | | `--out <file>` | `guided-review-<pr>.html` | Output path. | | |
| 118 | | `--lang <code>` | `en` | UI labels only (`en` or `pt`). The narrative renders in whatever language it was written in. | | |
| 119 | | `--help` | | Usage. | | |
| 120 | ||
| 121 | --- | |
| 122 | ||
| 123 | ## How it works | |
| 124 | ||
| 125 | A model reads the PR and writes a **story JSON**. `build.mjs` does the mechanical part — | |
| 126 | parsing the diff, resolving anchors to line numbers, rendering the page. Nothing in the script | |
| 127 | does analysis. | |
| 128 | ||
| 129 | That split is why a wording fix is a re-render instead of a re-read of a 2,000-line diff. It's | |
| 130 | also what lets the build *check* the narrative instead of just printing it. | |
| 131 | ||
| 132 | ### The unit is a beat | |
| 133 | ||
| 134 | A chapter groups beats; a beat is one passage of prose plus the lines it's about. | |
| 135 | ||
| 136 | ```jsonc | |
| 137 | { | |
| 138 | "number": 3649, | |
| 139 | "title": "Data readiness onboarding", | |
| 140 | "url": "https://github.com/org/repo/pull/3649", | |
| 141 | "headline": "The panel said \"1 family\" where there were hundreds", | |
| 142 | "lede": "The problem, and how to read this page.", | |
| 143 | "chapters": [ | |
| 144 | { | |
| 145 | "title": "One definition of \"pending\"", | |
| 146 | "beats": [ | |
| 147 | { | |
| 148 | "text": "Each check declares **two** forms of itself: how it is counted, and how it is listed…", | |
| 149 | "files": ["app/services/readiness_checks.rb:17-42"], | |
| 150 | "notes": [ | |
| 151 | { "kind": "why", "text": "Two representations of one rule diverge silently…" } | |
| 152 | ] | |
| 153 | } | |
| 154 | ] | |
| 155 | } | |
| 156 | ] | |
| 157 | } | |
| 158 | ``` | |
| 159 | ||
| 160 | **Anchors** are what make the pairing work: | |
| 161 | ||
| 162 | | Anchor | Effect | | |
| 163 | |---|---| | |
| 164 | | `path/to/file.rb:17-42` | Highlights those new-side lines; the panel focuses on that passage. | | |
| 165 | | `path/to/file.rb:42` | A single line. | | |
| 166 | | `path/to/file.rb` | The whole file, no highlight — for one-line includes. | | |
| 167 | ||
| 168 | **Notes** carry what a diff cannot: `why` (the reasoning behind a decision that had an | |
| 169 | alternative), `tradeoff` (what was knowingly given up), `rejected` (an approach dropped, and | |
| 170 | why). | |
| 171 | ||
| 172 | --- | |
| 173 | ||
| 174 | ## The build reviews your narrative | |
| 175 | ||
| 176 | It's not just a renderer. It refuses malformed input, and it flags the failures that would | |
| 177 | otherwise ship silently — the dangerous kind, because the output still *looks* trustworthy: | |
| 178 | ||
| 179 | ``` | |
| 180 | warning: anchor "app/foo.rb:120-140" matches no line in the new file | |
| 181 | ``` | |
| 182 | ||
| 183 | Somebody guessed a line number instead of reading it off the diff. A wrong-but-valid number | |
| 184 | confidently highlights the wrong code and nothing complains. | |
| 185 | ||
| 186 | ``` | |
| 187 | warning: 2 substantial file(s) have no place in the narrative. | |
| 188 | - app/helpers/readiness_helper.rb (+49 −0) | |
| 189 | ``` | |
| 190 | ||
| 191 | Uncovered files land in an appendix. Right for `db/structure.sql` and one-line includes — | |
| 192 | wrong for a new 49-line helper, which the page would then present as carrying no decision worth | |
| 193 | explaining. Silence about a file is itself a claim; this makes the narrator prove it. | |
| 194 | ||
| 195 | Pointed at a real 27-file PR, that check caught five skipped files on the first run. Pointed at | |
| 196 | this project's own PR, it caught the README. | |
| 197 | ||
| 198 | Ranges that cannot mean anything — running backwards, starting below line 1 — are rejected | |
| 199 | outright rather than warned about, because every one of them produces an empty highlight that | |
| 200 | looks exactly like a correct one. | |
| 201 | ||
| 202 | --- | |
| 203 | ||
| 204 | ## Reading the page | |
| 205 | ||
| 206 | - **J / K** (or ← / →) move between chapters; **E** expands or collapses every file | |
| 207 | - The left rail tracks where you are; the hairline at the top is reading progress | |
| 208 | - Light and dark follow the OS, with a toggle that overrides and persists | |
| 209 | - Line-anchored panels show that passage plus context, and **always say how much they skipped** | |
| 210 | - Generated files (`structure.sql`, lockfiles, `dist/`, `vendor/`) are detected and collapsed | |
| 211 | ||
| 212 | --- | |
| 213 | ||
| 214 | ## What it is not | |
| 215 | ||
| 216 | **Not a code review.** No findings, no severity ratings, no suggested changes. Reading for | |
| 217 | *intent* and reading for *defects* are different jobs, and a document that tries to be both | |
| 218 | serves neither. For defects, use [`agentic-cr`](https://github.com/MarceloCajueiro/agentic-cr) | |
| 219 | or Claude Code's built-in `/code-review`. | |
| 220 | ||
| 221 | **Not a substitute for reading the code.** It's a reading order and an explanation — you still | |
| 222 | review. That's the point: it makes the reviewing part worth doing properly. | |
| 223 | ||
| 224 | --- | |
| 225 | ||
| 226 | ## Tests | |
| 227 | ||
| 228 | ```bash | |
| 229 | npm test | |
| 230 | ``` | |
| 231 | ||
| 232 | `node:test`, no dependencies. The suite covers the places where a silent bug produces a | |
| 233 | confidently wrong page: line-number tracking in the parser, class mapping in the highlighter, | |
| 234 | and — most importantly — that **every hidden line is accounted for** by a marker. A guided | |
| 235 | review that quietly drops code is worse than no guided review at all. | |
| 236 | ||
| 237 | --- | |
| 238 | ||
| 239 | ## Install | |
| 240 | ||
| 241 | ```bash | |
| 242 | git clone https://github.com/MarceloCajueiro/guided-review.git | |
| 243 | ``` | |
| 244 | ||
| 245 | Then add it as a plugin directory in Claude Code, and `/guided-review` becomes available. | |
| 246 | ||
| 247 | --- | |
| 248 | ||
| 249 | ## License | |
| 250 | ||
| 251 | MIT © Marcelo Cajueiro — [cajueiro.tech](https://cajueiro.tech) | |
The screenshots exist because the argument is visual. A page whose whole claim is *"this paragraph is about exactly these lines"* has to be seen to be believed — a feature list cannot make that case.
docs/pairing-light.jpgnew+0 −0
docs/note-dark.jpgnew+0 −0
docs/opening-light.jpgnew+0 −0
Appendix — files not covered by the narrative
These changed with the PR but carry no decision of their own: generated output, mechanical renames, and boilerplate. Listed for completeness.
.claude-plugin/plugin.jsonnew+10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | { | |
| 2 | "name": "guided-review", | |
| 3 | "version": "1.0.0", | |
| 4 | "description": "Turn a pull request into a guided, narrated code review: a self-contained HTML page where the PR is told as a story, each paragraph paired with the exact diff lines it explains.", | |
| 5 | "author": { "name": "Marcelo Cajueiro" }, | |
| 6 | "homepage": "https://cajueiro.tech", | |
| 7 | "repository": "https://github.com/MarceloCajueiro/guided-review", | |
| 8 | "license": "MIT", | |
| 9 | "keywords": ["code-review", "pull-request", "github", "storytelling", "developer-experience", "documentation"] | |
| 10 | } | |
.gitignorenew+7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | .DS_Store | |
| 2 | node_modules/ | |
| 3 | .env | |
| 4 | # generated review pages and their working files | |
| 5 | guided-review-*.html | |
| 6 | *.diff | |
| 7 | story*.json | |
LICENSEnew+21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2026 Marcelo Cajueiro | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. | |