This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
The Change Git Hidesfilter← all gifts

Hunkhole

Git tells you which FILES changed. It does not tell you when a stale working tree, a bad merge, or a clumsy restore quietly REVERTED part of a file while leaving the file itself in place — a file-presence check reads that as a clean recovery. Hunkhole is the one command that catches it: it diffs the set of named top-level definitions (function / const / exports / def) between two git revisions and reports the ones that vanished. A symbol present before and gone after, with nothing renamed to take its place, is the reverted-hunk shape. Read-only, deterministic, stdlib-only.

The honest edge
Every hit is a QUESTION, not a verdict — a symbol you renamed or retired reads exactly like one that was reverted away, so hunkhole hands you the finite list and you rule on each. And a clean run is NOT a clean bill: it sees NAMED TOP-LEVEL definitions only, so a hunk reverted inside a surviving function body is invisible to it. Visibility, not immunity.
Run it
python3 hunkhole.py --help test_hunkhole.py (30 checks / 8 tests, mutation-bitten, pinned golden vanished-set + throwaway-repo end-to-end) Python standard library only, read-only, deterministic
The code — every file that ships
hunkhole.py177 lineson GitHub →
#!/usr/bin/env python3
"""hunkhole — find the change that git's own tools hide: a reverted hunk.

THE HOLE THIS FILLS

Git tells you which *files* changed. It does not tell you when a stale working
tree, a bad merge, or a clumsy restore quietly *reverted* part of a file while
leaving the file itself in place. A file-presence check reads that as a clean
recovery — the file is there, so nothing looks wrong — while a load-bearing
definition that used to live inside it is simply gone.

That failure is real and it hides well: a stale tree can revert tens of
thousands of lines across dozens of files, a later restore returns every *file*,
and because every file is present, no one notices the definitions that never
came back. hunkhole is the one command that would have found it.

THE PROBE

Not "did this file change" (forward work changes files constantly) but "is a
named, top-level definition that existed at BEFORE absent at AFTER." A symbol
present in the old revision and gone in the new one, with nothing renamed to
take its place, is the reverted-hunk shape. hunkhole diffs the *set of defined
names* between two git revisions and reports the ones that vanished.

WHY IT'S HONEST — read this before trusting a clean run

- **Every hit is a QUESTION, not a verdict.** A symbol you deliberately renamed
  or retired reads exactly like one that was reverted away. hunkhole hands you
  the finite list of vanished names; you rule on each. It never claims a symbol
  "should" still be there.

- **A clean run is NOT a clean bill.** hunkhole sees *named top-level*
  definitions (function / const / exports / def). A hunk reverted *inside* a
  surviving function body is invisible to it — the function name is still there.
  Absence of a finding is not proof of a clean restore, and the tool says so in
  its own output.

- **It is read-only.** It writes nothing, changes nothing, fixes nothing. It
  reads two revisions out of git and compares symbol sets.

Visibility, not immunity. You are the witness.

USAGE
    python3 hunkhole.py <BEFORE> [AFTER]
        BEFORE   a git revision (sha, tag, branch) — the "known-good" side
        AFTER    a git revision to compare against (default: HEAD)

    python3 hunkhole.py <BEFORE> <AFTER> --against <CLOBBER>
        --against   limit the sweep to files touched by one suspect commit,
                    instead of every file that differs between BEFORE and AFTER

EXIT  0 = no vanished symbols · 3 = findings (the alarm) · 2 = error/usage

MIT licensed. Python standard library only. Read-only. Deterministic.
"""
from __future__ import annotations

import re
import subprocess
import sys

# Named top-level definitions across the common scripting languages.
# JS: function foo / const foo = / foo: function / exports.foo   ·   Python: def foo
_DEF_PATTERN = re.compile(
    r"^\s*(?:"
    r"function\s+(\w+)"
    r"|const\s+(\w+)\s*="
    r"|(\w+)\s*:\s*function"
    r"|exports\.(\w+)"
    r"|def\s+(\w+)"
    r")",
    re.M,
)
_CODE_SUFFIXES = (".js", ".py", ".sh", ".mjs", ".cjs")


def git(*args: str) -> str | None:
    """Run a git command; return stdout on success, None on failure."""
    r = subprocess.run(["git", *args], capture_output=True, text=True)
    return r.stdout if r.returncode == 0 else None


def symbols(text: str) -> set[str]:
    """The set of named top-level definitions found in a blob of source text."""
    return {name for groups in _DEF_PATTERN.findall(text) for name in groups if name}


def vanished_symbols(before_text: str, after_text: str) -> list[str]:
    """Names defined in `before_text` but not in `after_text`, sorted."""
    return sorted(symbols(before_text) - symbols(after_text))


def is_code(path: str) -> bool:
    """True iff the path has a suffix hunkhole probes for symbols."""
    return path.endswith(_CODE_SUFFIXES)


def scan(before: str, after: str = "HEAD", against: str | None = None):
    """Compare two revisions; return (files_swept, holes, absent).

    holes  — list of (path, [vanished names]) for surviving code files.
    absent — list of paths present at `before` and absent at `after`
             (a whole-file drop; reported alongside hunk holes).
    Returns (None, None, None) if the underlying diff can't be produced.
    """
    listing = git("diff", "--name-only", before, against or after)
    if listing is None:
        return None, None, None
    files = sorted(listing.split())

    holes: list[tuple[str, list[str]]] = []
    absent: list[str] = []
    for f in files:
        a = git("show", f"{before}:{f}")
        if a is None:
            continue  # born after BEFORE — not hunkhole's business
        b = git("show", f"{after}:{f}")
        if b is None:
            absent.append(f)  # whole-file drop — reported, not hunkhole's core probe
            continue
        if not is_code(f):
            continue  # symbols are a code probe; data files need a different lens
        gone = vanished_symbols(a, b)
        if gone:
            holes.append((f, gone))
    return files, holes, absent


def render(before: str, after: str, against: str | None, files, holes, absent) -> str:
    """Render a scan result as the human-readable report text."""
    header = f"hunkhole: {before[:9]} -> {after}"
    if against:
        header += f"  (files touched by {against[:9]})"
    header += f"  ·  {len(files)} file(s) swept"
    lines = [header, "-" * 78]
    for f in absent:
        lines.append(f"FILE ABSENT   {f}")
    for f, gone in holes:
        lines.append(f"HOLE          {f}")
        for s in gone:
            lines.append(f"                 gone: {s}()")
    if not holes and not absent:
        lines.append("no named definition present at BEFORE is missing at AFTER.")
        lines.append("(NOT a clean bill — a hunk inside a surviving function body is invisible here.)")
    lines.append("-" * 78)
    n = len(holes) + len(absent)
    lines.append(
        f"{n} finding(s). Each is a QUESTION, not a verdict — a rename reads the same as a revert."
    )
    return "\n".join(lines)


def main(argv: list[str]) -> int:
    if not argv or argv[0] in ("-h", "--help"):
        sys.stdout.write(__doc__)
        return 2
    before = argv[0]
    after = argv[1] if len(argv) > 1 and not argv[1].startswith("--") else "HEAD"
    against = None
    if "--against" in argv:
        i = argv.index("--against")
        if i + 1 >= len(argv):
            sys.stderr.write("hunkhole: --against needs a revision\n")
            return 2
        against = argv[i + 1]

    files, holes, absent = scan(before, after, against)
    if files is None:
        sys.stderr.write(f"hunkhole: cannot diff {before}..{against or after}\n")
        return 2

    sys.stdout.write(render(before, after, against, files, holes, absent) + "\n")
    return 3 if (holes or absent) else 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
test_hunkhole.py185 lineson GitHub →
#!/usr/bin/env python3
"""test_hunkhole.py — the certifying properties of the hunkhole gift.

Run:  python3 test_hunkhole.py    (exit 0 = all pass, 1 = a failure)

hunkhole's git-facing shell is exercised end-to-end in a throwaway repo; its
symbol-diff CORE (symbols / vanished_symbols / is_code / render) is pure and is
tested directly on fixtures. Each test is MUTATION-BITTEN: it is here because a
plausible mutation of hunkhole.py makes it fail loud. The core-diff test pins a
GOLDEN sorted vanished-set — a rename reading identically to a revert is the
exact property that must hold, and a golden proves the set, not just its size.
"""
import hashlib
import os
import subprocess
import sys
import tempfile

import hunkhole as h

_FAILURES: list[str] = []
_PASSES = 0


def check(cond: bool, msg: str) -> None:
    global _PASSES
    if cond:
        _PASSES += 1
    else:
        _FAILURES.append(msg)


# ---- fixtures: before/after source blobs -----------------------------------
BEFORE_JS = """\
function alpha() { return 1; }
const beta = () => 2;
gamma: function () { return 3; }
exports.delta = function () {};
function survivor() {
  let innerReverted = 9;     // a hunk inside a surviving body (not def-shaped)
  return innerReverted;
}
"""
AFTER_JS = """\
const beta = () => 2;
exports.delta = function () {};
function survivor() {
  return 0;                  // innerReverted hunk reverted, name survives
}
"""

BEFORE_PY = """\
def one(): pass
def two(): pass
def three(): pass
"""
AFTER_PY = """\
def one(): pass
def three(): pass
"""


# ---- 1. the core diff: vanished top-level names, pinned golden --------------
def test_vanished_golden():
    gone_js = h.vanished_symbols(BEFORE_JS, AFTER_JS)
    # alpha (function) and gamma (obj-method) vanished; beta/delta/survivor stayed.
    check(gone_js == ["alpha", "gamma"], f"JS vanished set wrong: {gone_js}")
    # the hunk INSIDE survivor() is invisible — survivor is not reported gone
    check("survivor" not in gone_js, "survivor should NOT be flagged (in-body hunk is invisible)")
    # pin a golden signature over a combined fixture run
    gone_py = h.vanished_symbols(BEFORE_PY, AFTER_PY)
    check(gone_py == ["two"], f"PY vanished set wrong: {gone_py}")
    sig = hashlib.sha256(("|".join(gone_js) + "#" + "|".join(gone_py)).encode()).hexdigest()
    golden = "72e9237f911294dc57a66b67e296fb888a0d43373cbb485118d4f73a4df09f8b"
    check(sig == golden, f"golden vanished-set signature drifted: got {sig[:16]}...")


# ---- 2. a rename reads the same as a revert (the honest-edge property) ------
def test_rename_reads_as_revert():
    before = "def compute(): pass\n"
    after = "def calculate(): pass\n"  # renamed
    gone = h.vanished_symbols(before, after)
    check(gone == ["compute"], f"rename should surface old name as gone: {gone}")
    # kills a mutation that tried to be 'smart' and suppress renames


# ---- 3. no false hole when nothing vanished --------------------------------
def test_no_false_hole():
    same = "def a(): pass\ndef b(): pass\n"
    check(h.vanished_symbols(same, same) == [], "identical source must yield no vanished names")
    # a purely ADDED symbol is not a hole
    added = "def a(): pass\ndef b(): pass\ndef c(): pass\n"
    check(h.vanished_symbols(same, added) == [], "an added symbol must not read as vanished")


# ---- 4. symbols() sees each definition form; kills a dropped alternative ----
def test_symbol_forms():
    src = "function f(){}\nconst g=1\nh: function(){}\nexports.i=1\ndef j(): pass\n"
    got = h.symbols(src)
    for name in ("f", "g", "h", "i", "j"):
        check(name in got, f"symbol form not detected: {name} (pattern lost an alternative)")


# ---- 5. is_code gate: symbols probed only on code suffixes -----------------
def test_is_code():
    for ok in ("x.js", "y.py", "z.sh", "a.mjs", "b.cjs"):
        check(h.is_code(ok), f"{ok} should be code")
    for no in ("data.json", "notes.md", "image.png", "LICENSE"):
        check(not h.is_code(no), f"{no} should NOT be treated as code")


# ---- 6. render marks a clean run as NOT a clean bill ------------------------
def test_render_clean_caveat():
    out = h.render("abc123def", "HEAD", None, files=["a.py"], holes=[], absent=[])
    check("NOT a clean bill" in out, "clean render must carry the not-a-clean-bill caveat")
    check("0 finding(s)" in out, "clean render must report 0 findings")


# ---- 7. render shows holes and absent files distinctly ---------------------
def test_render_findings():
    out = h.render(
        "abc123def", "HEAD", None,
        files=["a.py", "b.py"],
        holes=[("a.py", ["gone_one", "gone_two"])],
        absent=["b.py"],
    )
    check("HOLE          a.py" in out, "hole line missing")
    check("gone: gone_one()" in out, "vanished symbol not rendered")
    check("FILE ABSENT   b.py" in out, "absent-file line missing")
    check("2 finding(s)" in out, "finding count wrong (1 hole + 1 absent = 2)")
    check("QUESTION, not a verdict" in out, "the question-not-verdict disclaimer must render")


# ---- 8. END-TO-END against a real throwaway git repo -----------------------
def _run_git(cwd, *args):
    subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True)


def test_end_to_end_reverted_hunk():
    with tempfile.TemporaryDirectory() as repo:
        _run_git(repo, "init", "-q")
        _run_git(repo, "config", "user.email", "t@t")
        _run_git(repo, "config", "user.name", "t")
        p = os.path.join(repo, "mod.py")
        with open(p, "w") as fh:
            fh.write("def keeper(): pass\ndef doomed(): pass\n")
        _run_git(repo, "add", "mod.py")
        _run_git(repo, "commit", "-qm", "before")
        before_sha = subprocess.run(
            ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True
        ).stdout.strip()
        # revert the 'doomed' definition, keep the file
        with open(p, "w") as fh:
            fh.write("def keeper(): pass\n")
        _run_git(repo, "commit", "-aqm", "after (doomed reverted)")

        cwd0 = os.getcwd()
        os.chdir(repo)
        try:
            files, holes, absent = h.scan(before_sha, "HEAD")
        finally:
            os.chdir(cwd0)
        check(holes == [("mod.py", ["doomed"])], f"end-to-end hole not found: {holes}")
        check(absent == [], f"nothing should be absent: {absent}")


def run() -> int:
    tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
    for t in tests:
        try:
            t()
        except Exception as exc:
            _FAILURES.append(f"{t.__name__} raised: {exc!r}")
    total = _PASSES + len(_FAILURES)
    if _FAILURES:
        print(f"FAIL — {len(_FAILURES)} of {total} checks failed:")
        for f in _FAILURES:
            print(f"  ✗ {f}")
        return 1
    print(f"OK — {_PASSES}/{total} checks passed ({len(tests)} tests).")
    return 0


if __name__ == "__main__":
    sys.exit(run())
Take the whole folder → MIT Python standard library only, read-only, deterministic