PR self-review
The reviewer you are about to send this to will spend their first ten minutes working out what actually changed. Do that first, and send them a diff where the surprises are already labelled.
This is not a code-quality lecture. It is a search for the specific things a diff hides from its own author: the file you touched incidentally, the export you widened without meaning to, the behaviour you added without a test.
Procedure
1. Inventory the diff mechanically
<skill-dir> is the directory this SKILL.md was loaded from — the skill installs outside
your project, so its script is named by full path, never relatively.
python3 <skill-dir>/scripts/review_surface.py # working tree vs merge-base with main
python3 <skill-dir>/scripts/review_surface.py origin/dev # or an explicit baseThe script prints every changed file with its classification (source, test, config, docs, generated, lockfile), the added/removed line counts, and four flags per file:
NO-TEST— a source file changed and no test file for it changed in the same diffNEW-EXPORT— the diff adds an exported symbolDEL-TEST— the diff removes test casesWIDE— a hunk over 200 lines, which almost always contains a second change
2. Read every NO-TEST file and decide, per file
Three legitimate answers, and you must pick one out loud:
- the change is behaviour-preserving (rename, move, formatting) — say which
- the behaviour is covered by an existing test that did not need editing — name the test
- it needs a test — write it now, before the review, not after
Anything else is the gap the reviewer will find.
3. Read every NEW-EXPORT
A new export is a promise to everyone who imports it. Ask whether it needs to be exported at all — most do not — and whether its name will still be right in six months. This is the cheapest moment in the change's life to narrow it.
4. Split the diff in your head
State, in one sentence, what this change is for. Then find every hunk that is not that. Incidental refactors, drive-by formatting, an unrelated fix you noticed — each is fine on its own and each makes the diff harder to review and impossible to revert cleanly. Decide per hunk: keep it and mention it in the description, or move it out.
5. Write the description from the diff, not from memory
Cover what changed, what it is for, and what a reviewer should look at hardest. If step 4 found extra changes, they go in a "also in this change" list. The reviewer's attention is the scarce resource — spend it where the risk is.
Output
A short report, in this order:
- What this change is for — one sentence
- Untested behaviour — the
NO-TESTfiles with a decision each, or "none" - New public surface — the
NEW-EXPORTlist with a keep/narrow call each - Also in this diff — the hunks that are not the stated purpose
- Look here hardest — the two or three places a reviewer should spend their attention
What this skill deliberately does not do
- It does not review code it did not change. Pre-existing problems in a touched file
are out of scope; saying so is the point, since a review that wanders makes the diff bigger.
- It does not enforce style. The linter owns that, runs faster, and does not have
opinions. If a style point is not mechanically checked in this repo, it is not a review finding.
- It does not restate the diff. A summary of each hunk is what the diff is for.
- It does not gate on the
NO-TESTflag. A flag is a prompt for a decision, not averdict — behaviour-preserving changes are real and common, and a skill that demands a test for every touched file gets muted within a week.
- It does not open, push, or merge anything. It reports; the author acts.
When this is the wrong tool
- You want to know what else the change could reach, beyond the files in it. That
means following call sites out of the diff; this review deliberately stays inside it.
- You are looking for missing tests across the whole repository. This is diff-scoped
by design, and repository-wide coverage is a different search.
- You need the release note. Describing a change to a consumer is a different job from
checking it.
Supporting files
scripts/review_surface.pyPython
#!/usr/bin/env python3
"""Inventory a diff so the author reviews the surprises rather than the summary.
Deterministic half of self-review: what changed, what kind of file it is, and
four flags that reliably mark the places authors miss. Whether a flag matters is
a judgement call and is left to the reader — the script never says "this is
wrong", only "this is unusual, decide about it".
python3 review_surface.py [base-ref]
`base-ref` defaults to the merge base with the repository's default branch, so it
reports the change as a reviewer would see it rather than only the last commit.
Standard library only.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
WIDE_HUNK_LINES = 200
GENERATED_MARKERS = ("/generated/", "/dist/", "/.next/", "/node_modules/")
LOCKFILES = ("pnpm-lock.yaml", "package-lock.json", "yarn.lock", "Cargo.lock", "poetry.lock", "go.sum")
TEST_RE = re.compile(r"(^|/)(tests?|__tests__|spec)/|\.(test|spec)\.[jt]sx?$|_test\.(py|go)$|test_.*\.py$")
DOC_RE = re.compile(r"\.(md|mdx|rst|txt|adoc)$|(^|/)docs?/")
CONFIG_RE = re.compile(r"\.(json|ya?ml|toml|ini|cfg|env)$|(^|/)\.[^/]+rc|config\.[jt]s$")
SOURCE_RE = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|cs|php|scala)$")
# An exported symbol, across the languages this is likely to meet. Deliberately
# conservative: a missed export is a quieter failure than a false one, which
# would train the reader to ignore the flag.
EXPORT_RE = re.compile(
r"^\+\s*(?:export\s+(?:default\s+|const\s+|function\s+|class\s+|type\s+|interface\s+|enum\s+|async\s+)"
r"|pub\s+(?:fn|struct|enum|trait)\s+"
r"|func\s+[A-Z]"
r"|public\s+(?:static\s+)?(?:class|interface|[A-Za-z<>\[\]]+\s+[A-Z]))"
)
# A removed line that was a test case.
DEL_TEST_RE = re.compile(r"^-\s*(?:it|test|describe)\s*[(.]|^-\s*def test_|^-\s*func Test[A-Z]")
@dataclass
class FileChange:
path: str
added: int = 0
removed: int = 0
new_exports: list[str] = field(default_factory=list)
deleted_tests: int = 0
widest_hunk: int = 0
@property
def kind(self) -> str:
p = self.path
if any(m in f"/{p}" for m in GENERATED_MARKERS):
return "generated"
if os.path.basename(p) in LOCKFILES:
return "lockfile"
if TEST_RE.search(p):
return "test"
if DOC_RE.search(p):
return "docs"
if SOURCE_RE.search(p):
return "source"
if CONFIG_RE.search(p):
return "config"
return "other"
@property
def stem(self) -> str:
"""Path with test/spec markers and extension stripped, for pairing a
source file with the test file that covers it."""
base = os.path.basename(self.path)
base = re.sub(r"\.(test|spec)(?=\.)", "", base)
base = re.sub(r"^test_|_test$", "", os.path.splitext(base)[0])
return base
def git(args: list[str]) -> str:
out = subprocess.run(["git", *args], capture_output=True, text=True)
return out.stdout if out.returncode == 0 else ""
def diff_path(header: str) -> str | None:
"""Path from a `---`/`+++` header line, or None for `/dev/null`.
A **deleted** file's `+++` is `/dev/null` and its only name is on the `---`
line, so reading `+++ b/` alone does not merely lose the deletion — the
parser stays pointed at the previously-parsed file and charges every removed
line to it. Deleting a whole test file then reports DEL-TEST against an
unrelated source file, which is the flag pointing at the wrong place.
"""
rest = header.strip()
if rest == "/dev/null":
return None
return rest[2:] if rest[:2] in ("a/", "b/") else rest
def default_base() -> str:
"""Merge base with the default branch, falling back to HEAD~1.
Reviewers see the whole branch, not the last commit, so that is what gets
inventoried. A repo with no main/master (a fresh clone, a detached CI
checkout) still gets a useful answer rather than an error.
"""
for branch in ("origin/main", "main", "origin/master", "master"):
base = git(["merge-base", "HEAD", branch]).strip()
if base:
return base
return "HEAD~1"
def collect(base: str) -> list[FileChange]:
files: dict[str, FileChange] = {}
current: FileChange | None = None
old_path: str | None = None
in_hunks = False
hunk_len = 0
diff = git(["diff", "--unified=0", base, "--"])
for line in diff.splitlines():
# Headers are read only before a file's first `@@`. Position is what
# disambiguates them: a removed line whose own text begins with `---`
# (a YAML document marker, a comment) is otherwise indistinguishable
# from a file header, and treating it as one silently reassigns the
# rest of the hunk.
if line.startswith("diff --git "):
if current:
current.widest_hunk = max(current.widest_hunk, hunk_len)
current, old_path, in_hunks, hunk_len = None, None, False, 0
continue
if not in_hunks:
if line.startswith("--- "):
old_path = diff_path(line[4:])
elif line.startswith("+++ "):
if current:
current.widest_hunk = max(current.widest_hunk, hunk_len)
hunk_len = 0
path = diff_path(line[4:]) or old_path
current = files.setdefault(path, FileChange(path=path)) if path else None
elif line.startswith("@@"):
in_hunks = True
continue
if current is None:
continue
if line.startswith("@@"):
current.widest_hunk = max(current.widest_hunk, hunk_len)
hunk_len = 0
continue
# Past the first `@@` every `+`/`-` line is content, so no header guard
# is needed here — and the guard that used to be here was itself a
# miscount: it dropped any removed line whose own text starts with `---`
# (a YAML document marker, an ASCII rule), understating the churn of
# exactly the frontmatter-heavy files it most needs to measure.
if line.startswith("+"):
current.added += 1
hunk_len += 1
if EXPORT_RE.search(line):
current.new_exports.append(line[1:].strip()[:90])
elif line.startswith("-"):
current.removed += 1
if DEL_TEST_RE.search(line):
current.deleted_tests += 1
if current:
current.widest_hunk = max(current.widest_hunk, hunk_len)
# `git diff` cannot see a file that was never added, so a brand-new module
# sitting untracked in the working tree is invisible to it — which is
# precisely the surprise this script exists to surface. Treat each untracked
# file as wholly added and run the same flags over its contents.
for path in git(["ls-files", "--others", "--exclude-standard"]).splitlines():
path = path.strip()
if not path or path in files:
continue
change = files.setdefault(path, FileChange(path=path))
try:
with open(path, encoding="utf-8", errors="replace") as handle:
lines = handle.read().splitlines()
except OSError:
continue
change.added = len(lines)
change.widest_hunk = len(lines)
change.new_exports = [
ln.strip()[:90] for ln in lines if EXPORT_RE.search("+" + ln)
]
return sorted(files.values(), key=lambda f: f.path)
def main() -> int:
# Run from the repository root, as the sibling scripts do. `git diff` reports
# root-relative paths from anywhere, but `git ls-files --others` reports
# cwd-relative paths *and* only descends the cwd subtree — so invoked from a
# subdirectory the untracked scan silently drops new files elsewhere in the
# repo, which is precisely the blind spot that scan was added to close.
root = git(["rev-parse", "--show-toplevel"]).strip()
if root:
os.chdir(root)
base = sys.argv[1] if len(sys.argv) > 1 else default_base()
changes = collect(base)
if not changes:
print(f"No changes against {base}.")
return 0
changed_test_stems = {c.stem for c in changes if c.kind == "test"}
print(f"base: {base}")
print(f"{len(changes)} file(s) changed\n")
flagged: list[tuple[str, str]] = []
for c in changes:
flags = []
if c.kind == "source" and c.stem not in changed_test_stems:
flags.append("NO-TEST")
if c.new_exports:
flags.append("NEW-EXPORT")
if c.deleted_tests:
flags.append("DEL-TEST")
if c.widest_hunk > WIDE_HUNK_LINES:
flags.append("WIDE")
churn = f"+{c.added}/-{c.removed}"
print(f" [{c.kind:>9}] {churn:>12} {c.path}")
if flags:
print(f" {' '.join(flags)}")
flagged.append((c.path, " ".join(flags)))
for e in c.new_exports[:5]:
print(f" · {e}")
print("\n── decisions required ──\n")
if not flagged:
print(" None. Nothing in this diff carries a flag.")
for path, flags in flagged:
print(f" {path}: {flags}")
print(
"\nFor each NO-TEST file, state one of: behaviour-preserving / covered by an "
"existing test (name it) / needs a test (write it now).\n"
"A flag is a prompt for a decision, not a verdict."
)
return 0
if __name__ == "__main__":
sys.exit(main())
More skills from Contexory
- Doc Drift DetectorChecks a document's checkable claims against the code it describes — file paths that no longer exist, commands that are no longer defined, flags and symbols that have been renamed, version numbers…
- Flaky Test FinderProves whether a test is flaky instead of reasoning about it, by re-running it many times and reporting the observed failure rate, then narrowing the cause to ordering, shared state, timing or…
- Repo Onboarding MapProduces the first-thirty-minutes map of a whole repository — how it is laid out, how to run and test it, which files carry the most change, and where the decisions were written down. Reads what the…
- Test Gap FinderFinds the untested code that actually matters, by ranking coverage gaps against how often each file changes. Reads an existing coverage report when there is one and falls back to structural pairing…
- Regression Risk MapperMaps what a change can reach — a change being considered as readily as one already made, since the question is usually asked before the edit exists. Finds the symbols involved, traces every call site…
- Error TriageLocates the cause of a runtime failure inside this repository. Maps stack frames to real source files, separates first-party code from vendored frames, and surfaces the recent changes to the line…