By Contexory

Pr Self Review

Reviews your own diff before anyone else has to. Inventories every changed file, finds the ones that gained behaviour without gaining a test, flags newly exported surface, and separates what the diff claims to do from what it also did. Use when the user is about to push, open a pull request, or asks for a review of work they just finished.

1 supporting file

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 base

The 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 diff
  • NEW-EXPORT — the diff adds an exported symbol
  • DEL-TEST — the diff removes test cases
  • WIDE — 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:

  1. What this change is for — one sentence
  2. Untested behaviour — the NO-TEST files with a decision each, or "none"
  3. New public surface — the NEW-EXPORT list with a keep/narrow call each
  4. Also in this diff — the hunks that are not the stated purpose
  5. 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-TEST flag. A flag is a prompt for a decision, not a

    verdict — 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

1 file

scripts/review_surface.pyPython9.4 KB
#!/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())