null: diffkit - unified-diff parse+apply engine (30/30 checks, 3 mutants killed, 8/8 real git diffs byte-exact)

This commit is contained in:
null committed 2026-10-20 20:50:11 +00:00
1 parent a4698efd73
commit 223d9410ad
7 files changed
+990

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+59
View File
@@ -0,0 +1,59 @@
# diffkit — unified-diff parse + apply, dependency-free
Lane: Codeforge. A single-file engine that parses unified diff text and applies it
to in-memory text or to files on disk, with GNU-compatible offset drift and fuzz.
Files:
diffkit.py the engine + CLI (~13 KB, stdlib only)
selftest.py 30 checks: hand cases, randomised property suite, mutation control
verify_realrepo.py independent oracle: real `git diff` output from this repo
## What it does
* parses `--- / +++ / @@ -a,b +c,d @@` hunks, ignoring `diff --git`, `index`,
`new file mode`, `Binary files ...` noise
* exact line model: splits on `\n` only, so `\ No newline at end of file` and CRLF
are preserved byte-for-byte (note: `str.splitlines()` would split on `\v \f
\x1c-\x1e \x85 \u2028 \u2029` — that is a bug, not a feature, here)
* locates each hunk by content, nearest to the position its header claims
(`max_offset` can bound the drift; default unbounded)
* GNU-style fuzz: drops up to `max_fuzz` leading/trailing context lines (default 2)
* handles creation from `/dev/null` and deletion to `/dev/null`
* rejects malformed hunks (header counts disagreeing with the body) instead of guessing
* CLI: `python3 diffkit.py [-pN] [-F N] [patchfile]`, reading stdin by default;
exits 1 and leaves the target untouched when a hunk cannot be placed
## Measured evidence
`python3 selftest.py` — **30/30 checks passed, exit 0** (11.9 s):
| suite | result |
|---|---|
| hand cases (19) | no-newline EOF both ways, CRLF, empty file, /dev/null create+delete, malformed-hunk rejection, drift, fuzz=0 vs fuzz=1, forward+reverse round trip, multi-file, `-pN` strip, timestamp strip |
| CLI end-to-end (4) | `-p0` and git-style `-p1` patch real files on disk; unplaceable hunk exits nonzero with the target intact; missing patch file is a clean error, not a traceback |
| randomised property suite | 117/120 generated pairs applied byte-exactly (3 were no-op pairs); GNU `patch` ran on the same input as a second oracle and agreed **117/117** |
| offset-drift suite | 80/80 diffs applied to a shifted copy, result == shifted target |
| mutation control | 3 deliberately broken appliers were killed: no-search (30/30 cases failed), first-hunk-only (38 failures), sides-swapped (117 failures) |
`python3 verify_realrepo.py /root/troupe-standup HEAD~30 25` — **8/8 applicable files
reproduced exactly, exit 0** (recorded in `realrepo-output.txt`, run against repo HEAD
`a4698ef`; the troupe repo is live, so the path count moves under you). Real `git diff`
output for this repository: docs/queue-index.md 37 KB / 6 hunks, tools/selftest_sweep.py
40 KB / 5 hunks, tools/tool_map.py 22 KB / 2 hunks, wizard-work/null/_judge.py 16 KB,
plus docs/TOOLS.md, README-proveit.md, _probe_teeth.py and spark/CHECK. The oracle is
`git show HEAD:<path>`; files added or deleted in the range are skipped (42 here).
The mutation control is the point: a test suite that a wrong implementation also
passes proves nothing. Each mutant is a real applier with one defect, and each
fails the same suite the real engine passes.
## Known limits (not measured as working)
* binary diffs (`GIT binary patch`) are parsed as text and will not apply
* hunk placement is a nearest-content search; with pathological repetition a hunk
could anchor on a nearer wrong match — `max_offset` is the guard, not a proof
* `git apply`'s whitespace-error rules and `patch`'s "reversed patch" detection are
not implemented
* the CLI writes in place for `-pN` with N > 0 (as `patch` does) and does not
preserve file mode or ownership
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""diffkit -- dependency-free unified-diff parse + apply engine.
Design notes
------------
* Text is modelled as a list of lines WITH their terminators, split only on "\\n"
(not str.splitlines, which also splits on \\v \\f \\x1c-\\x1e \\x85 \\u2028 \\u2029).
That makes "no newline at end of file" representable exactly: the final element
simply has no trailing "\\n".
* Hunks are located by exact match of their old-side line sequence, nearest to the
position the hunk header claims, with GNU-style fuzz (drop up to N leading /
trailing context lines) and unbounded-or-bounded offset drift.
* Malformed hunks -- header counts that disagree with the body -- are rejected,
never silently guessed at.
Public API
----------
parse_unified_diff(text) -> list[FilePatch]
apply_file_patch(patch, text, ...) -> str
apply_patch(patches, sources, ...) -> dict[str, str | None]
apply_one(diff_text, base_text) -> str
CLI: python3 diffkit.py [-p N] [-F N] [--fuzz N] [patchfile]
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from typing import Iterable, Mapping, Sequence
__all__ = [
"Hunk",
"FilePatch",
"DiffParseError",
"DiffApplyError",
"split_lines",
"join_lines",
"parse_unified_diff",
"apply_file_patch",
"apply_patch",
"apply_one",
]
DEFAULT_FUZZ = 2 # GNU patch's default maximum fuzz factor
class DiffParseError(Exception):
"""The diff text is not well-formed unified diff."""
class DiffApplyError(Exception):
"""A well-formed hunk could not be located in the source text."""
_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: ?(.*))?$")
# --------------------------------------------------------------------------- #
# text <-> line model
# --------------------------------------------------------------------------- #
def split_lines(text: str) -> list[str]:
"""Split on "\\n" only, keeping terminators. "" -> []."""
if not text:
return []
parts = text.split("\n")
out = [p + "\n" for p in parts[:-1]]
if parts[-1]:
out.append(parts[-1]) # final line, no terminator
return out
def join_lines(lines: Sequence[str]) -> str:
return "".join(lines)
# --------------------------------------------------------------------------- #
# data model
# --------------------------------------------------------------------------- #
@dataclass
class Hunk:
old_start: int
old_count: int
new_start: int
new_count: int
lines: list[tuple[str, str]] = field(default_factory=list) # (kind, content-with-terminator)
section: str = ""
@property
def old_seq(self) -> list[str]:
return [c for k, c in self.lines if k in (" ", "-")]
@property
def new_seq(self) -> list[str]:
return [c for k, c in self.lines if k in (" ", "+")]
def validate(self) -> None:
o, n = self.old_seq, self.new_seq
if len(o) != self.old_count or len(n) != self.new_count:
raise DiffApplyError(
"hunk header @@ -%d,%d +%d,%d @@ disagrees with its body "
"(old lines=%d, new lines=%d)"
% (self.old_start, self.old_count, self.new_start, self.new_count,
len(o), len(n))
)
@dataclass
class FilePatch:
old_path: str
new_path: str
hunks: list[Hunk] = field(default_factory=list)
def _clean_path(raw: str) -> str:
"""'a/foo.txt\t2026-10-20 12:00' -> 'a/foo.txt'; strip surrounding quotes."""
s = raw.split("\t")[0].strip()
if len(s) >= 2 and s.startswith('"') and s.endswith('"'):
s = s[1:-1]
return s
# --------------------------------------------------------------------------- #
# parser
# --------------------------------------------------------------------------- #
def parse_unified_diff(text: str) -> list[FilePatch]:
raw = text.split("\n")
if raw and raw[-1] == "":
raw.pop() # artefact of the trailing newline
patches: list[FilePatch] = []
cur: FilePatch | None = None
hunk: Hunk | None = None
i, n = 0, len(raw)
def close_hunk() -> None:
nonlocal hunk
if hunk is not None:
assert cur is not None
cur.hunks.append(hunk)
hunk = None
while i < n:
line = raw[i]
if line.startswith("--- "):
close_hunk()
if i + 1 < n and raw[i + 1].startswith("+++ "):
cur = FilePatch(_clean_path(line[4:]), _clean_path(raw[i + 1][4:]), [])
patches.append(cur)
i += 2
continue
i += 1 # stray '---' line
continue
if line.startswith("@@ "):
if cur is None:
raise DiffParseError("hunk header with no preceding file header: %r" % line)
m = _HUNK_RE.match(line)
if not m:
raise DiffParseError("malformed hunk header: %r" % line)
close_hunk()
os_, oc, ns, nc, section = m.groups()
hunk = Hunk(
int(os_), 1 if oc is None else int(oc),
int(ns), 1 if nc is None else int(nc),
[], section or "",
)
i += 1
continue
if hunk is not None:
head = line[:1]
if head in (" ", "-", "+"):
hunk.lines.append((head, line[1:] + "\n"))
i += 1
continue
if head == "\\":
if not hunk.lines:
raise DiffParseError("no-newline marker with no preceding line")
kind, content = hunk.lines[-1]
if not content.endswith("\n"):
raise DiffParseError(
"no-newline marker after a line that already lacks a newline")
hunk.lines[-1] = (kind, content[:-1])
i += 1
continue
if line == "":
hunk.lines.append((" ", "\n")) # empty context line
i += 1
continue
close_hunk() # anything else ends the hunk; fall through to outer state
i += 1 # 'diff --git', 'index', 'new file mode', 'Binary files ...' etc.
close_hunk()
return patches
# --------------------------------------------------------------------------- #
# apply
# --------------------------------------------------------------------------- #
def _fuzz_variants(hunk: Hunk, max_fuzz: int) -> Iterable[tuple[int, list[str], list[str], int]]:
"""Yield (fuzz_level, old_seq, new_seq, expected_index) for fuzz 0..max_fuzz.
Fuzz f drops up to f leading and f trailing *context* lines from both sides,
which is what GNU patch's -F does.
"""
base_old, base_new = hunk.old_seq, hunk.new_seq
lead = 0
for k, _ in hunk.lines:
if k == " ":
lead += 1
else:
break
trail = 0
for k, _ in reversed(hunk.lines):
if k == " ":
trail += 1
else:
break
expected = hunk.old_start if hunk.old_count == 0 else hunk.old_start - 1
for f in range(0, max_fuzz + 1):
cut_l = min(f, lead)
cut_t = min(f, trail)
old = base_old[cut_l:len(base_old) - cut_t] if cut_t else base_old[cut_l:]
new = base_new[cut_l:len(base_new) - cut_t] if cut_t else base_new[cut_l:]
yield f, old, new, expected + cut_l
def _locate(hay: Sequence[str], needle: Sequence[str], expected: int,
index_cache: dict[str, list[int]], max_offset: int | None) -> int | None:
"""Index of the occurrence of `needle` in `hay` nearest to `expected`, or None."""
n = len(needle)
if n == 0:
return max(0, min(expected, len(hay)))
if n > len(hay):
return None
first = needle[0]
positions = index_cache.get(first)
if positions is None:
positions = [j for j, l in enumerate(hay) if l == first]
index_cache[first] = positions
best: int | None = None
best_d: int | None = None
for j in positions:
if j + n > len(hay):
continue
if list(hay[j:j + n]) != list(needle):
continue
d = abs(j - expected)
if max_offset is not None and d > max_offset:
continue
if best_d is None or d < best_d or (d == best_d and best is not None and j < best):
best, best_d = j, d
return best
def apply_hunk(hay: list[str], hunk: Hunk, index_cache: dict[str, list[int]],
max_fuzz: int = DEFAULT_FUZZ, max_offset: int | None = None
) -> tuple[list[str], int, int]:
"""Return (new_lines, fuzz_used, offset_used). Raises DiffApplyError if unplaceable."""
hunk.validate()
for f, old, new, expected in _fuzz_variants(hunk, max_fuzz):
pos = _locate(hay, old, expected, index_cache, max_offset)
if pos is not None:
return hay[:pos] + list(new) + hay[pos + len(old):], f, pos - expected
raise DiffApplyError(
"hunk @@ -%d,%d +%d,%d @@ does not apply (fuzz<=%d)"
% (hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count, max_fuzz)
)
def apply_file_patch(patch: FilePatch, text: str, *, max_fuzz: int = DEFAULT_FUZZ,
max_offset: int | None = None) -> str:
hay = split_lines(text)
index_cache: dict[str, list[int]] = {}
for hunk in patch.hunks:
hay, _f, _o = apply_hunk(hay, hunk, index_cache, max_fuzz, max_offset)
return join_lines(hay)
def _strip(path: str, n: int) -> str:
if path == "/dev/null" or n <= 0:
return path
parts = path.split("/")
return "/".join(parts[n:]) if len(parts) > n else path
def apply_patch(patches: Sequence[FilePatch], sources: Mapping[str, str], *,
strip: int = 0, max_fuzz: int = DEFAULT_FUZZ,
max_offset: int | None = None) -> dict[str, str | None]:
"""Apply to an in-memory {path: text} map.
Returns {output_path: new_text}, with None marking a deletion. Creation
(old == /dev/null) starts from "".
"""
out: dict[str, str | None] = {}
for p in patches:
old = _strip(p.old_path, strip)
new = _strip(p.new_path, strip)
if old == "/dev/null":
src = ""
else:
if old not in sources:
raise DiffApplyError("no source supplied for %r" % old)
src = sources[old]
if new == "/dev/null":
out[old] = None
else:
out[new] = apply_file_patch(p, src, max_fuzz=max_fuzz, max_offset=max_offset)
return out
def apply_one(diff_text: str, base_text: str, *, max_fuzz: int = DEFAULT_FUZZ,
max_offset: int | None = None) -> str:
"""Parse a single-file diff and apply it. Raises if it is not exactly one file."""
patches = parse_unified_diff(diff_text)
if len(patches) != 1:
raise DiffParseError("expected exactly 1 file patch, got %d" % len(patches))
return apply_file_patch(patches[0], base_text, max_fuzz=max_fuzz, max_offset=max_offset)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def _main(argv: list[str]) -> int:
strip, max_fuzz, path = 0, DEFAULT_FUZZ, "-"
i = 0
while i < len(argv):
a = argv[i]
if a == "-p" and i + 1 < len(argv):
strip = int(argv[i + 1]); i += 2; continue
if a.startswith("-p") and len(a) > 2:
strip = int(a[2:]); i += 1; continue
if a in ("-F", "--fuzz") and i + 1 < len(argv):
max_fuzz = int(argv[i + 1]); i += 2; continue
if a.startswith("-F") and len(a) > 2:
max_fuzz = int(a[2:]); i += 1; continue
if a in ("-h", "--help"):
print(__doc__); return 0
path = a; i += 1
if path == "-":
text = sys.stdin.read()
else:
try:
text = open(path, "r", encoding="utf-8", newline="").read()
except OSError as e:
print("diffkit: cannot read patch file: %s" % e, file=sys.stderr)
return 1
try:
patches = parse_unified_diff(text)
except DiffParseError as e:
print("diffkit: parse error: %s" % e, file=sys.stderr)
return 1
rc = 0
for p in patches:
old = _strip(p.old_path, strip)
new = _strip(p.new_path, strip)
try:
src = "" if old == "/dev/null" else open(old, "r", encoding="utf-8", newline="").read()
if new == "/dev/null":
import os
os.remove(old)
print("deleted %s" % old)
continue
dst = apply_file_patch(p, src, max_fuzz=max_fuzz)
with open(new, "w", encoding="utf-8", newline="") as fh:
fh.write(dst)
print("patched %s" % new)
except (OSError, DiffApplyError) as e:
print("diffkit: %s: %s" % (old, e), file=sys.stderr)
rc = 1
return rc
if __name__ == "__main__":
sys.exit(_main(sys.argv[1:]))
@@ -0,0 +1,11 @@
repo=/root/troupe-standup rev=HEAD~30..HEAD 50 changed paths
ok docs/TOOLS.md 5566 bytes, 2 hunks
ok docs/queue-index.md 37444 bytes, 6 hunks
ok tools/selftest_sweep.py 40314 bytes, 5 hunks
ok tools/tool_map.py 21995 bytes, 2 hunks
ok wizard-work/null/README-proveit.md 7898 bytes, 4 hunks
ok wizard-work/null/_judge.py 16137 bytes, 1 hunks
ok wizard-work/null/_probe_teeth.py 2231 bytes, 1 hunks
ok wizard-work/spark/CHECK 567 bytes, 1 hunks
8/8 applicable files reproduced exactly (42 skipped)
@@ -0,0 +1,33 @@
PASS hand:simple replace
PASS hand:insert + delete mix
PASS hand:no newline at EOF
PASS hand:newline added at EOF
PASS hand:empty context lines
PASS hand:CRLF preserved
PASS hand:insert into empty file
PASS hand:create from /dev/null
PASS hand:delete to /dev/null
PASS hand:malformed hunk rejected
PASS hand:offset drift located
PASS hand:max_offset=0 rejects drift
PASS hand:fuzz=0 rejects mismatch
PASS hand:fuzz=1 recovers
PASS hand:round trip forward+reverse
PASS hand:multi-file patch
PASS hand:-p1 path strip
PASS hand:timestamps stripped
PASS hand:split_lines only splits on \n
PASS cli:-p0 patches a file on disk
PASS cli:-p1 patches a file on disk in place
PASS cli:unplaceable hunk exits nonzero, target intact
PASS cli:missing patch file is a clean error
PASS property: 117 randomised exact diffs apply (generated 120)
PASS property: GNU patch oracle agrees (117/117, 0 skipped)
PASS property: 80 randomised offset-drift diffs apply (generated 80)
PASS property: suite is deterministic
PASS mutation: no-search applier is killed (30 failures)
PASS mutation: first-hunk-only applier is killed (38 failures)
PASS mutation: sides-swapped applier is killed (117 failures)
30/30 checks passed
ALL CHECKS PASSED
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""diffkit self-test.
Three layers, in order of evidential strength:
1. hand cases -- targeted edge cases (no-newline EOF, create/delete,
fuzz, offset drift, malformed hunks, CRLF, multi-file)
2. property suite -- randomised base/target pairs, diffs produced by the
real GNU `diff`, applied by diffkit, compared
byte-for-byte against the true target; GNU `patch`
is run on the same input as a second oracle
3. mutation control -- three deliberately broken appliers must FAIL the same
suites. If a mutant passes, the suite is vacuous and
this script exits nonzero.
Exit code 0 iff every check holds. No third-party dependencies.
"""
from __future__ import annotations
import os
import random
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import diffkit # noqa: E402
PLAIN_CASES = 120
DRIFT_CASES = 80
ALPHA = ["alpha", "beta", "gamma", "delta", "eps", "zeta", "", " indented",
"x = 1", "return", "}", "{", "TODO"]
_results: list[tuple[str, bool, str]] = []
def check(name: str, ok: bool, detail: str = "") -> bool:
_results.append((name, bool(ok), detail))
print("%s %s%s" % ("PASS" if ok else "FAIL", name,
"" if ok else (" :: " + detail)))
return bool(ok)
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def write(path: str, text: str) -> None:
with open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(text)
def run_bytes(args: list[str], cwd: str) -> tuple[int, str, str]:
"""Run a command WITHOUT universal-newline translation (text=True would eat \\r)."""
r = subprocess.run(args, cwd=cwd, capture_output=True)
return (r.returncode,
r.stdout.decode("utf-8", "surrogateescape"),
r.stderr.decode("utf-8", "surrogateescape"))
def gnu_diff(tmp: str, a_name: str, b_name: str) -> str:
rc, out, err = run_bytes(["diff", "-u", a_name, b_name], tmp)
if rc not in (0, 1):
raise RuntimeError("diff failed: %s" % err)
return out
def gnu_patch_apply(tmp: str, diff_text: str, src_text: str,
fname: str = "base.txt") -> str | None:
"""Run GNU patch on src_text with diff_text; None if patch itself refused."""
d = os.path.join(tmp, "patchcopy")
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d)
write(os.path.join(d, fname), src_text)
write(os.path.join(d, "d.diff"), diff_text)
rc, _, _ = run_bytes(["patch", "--batch", "-p0", "-i", "d.diff"], d)
if rc != 0:
return None
with open(os.path.join(d, fname), "r", encoding="utf-8", newline="") as fh:
return fh.read()
def rand_text(rng: random.Random) -> str:
n = rng.randint(4, 40)
text = "".join(rng.choice(ALPHA) + "\n" for _ in range(n))
if rng.random() < 0.25: # sometimes no newline at EOF
text = text[:-1]
return text
def mutate(rng: random.Random, base: str) -> str:
lines = diffkit.split_lines(base)
for _ in range(rng.randint(1, 5)):
op = rng.choice(["ins", "del", "rep"])
if not lines:
op = "ins"
i = rng.randint(0, len(lines))
if op == "ins":
lines[i:i] = [rng.choice(ALPHA) + "\n" for _ in range(rng.randint(1, 3))]
elif op == "del":
del lines[i:min(len(lines), i + rng.randint(1, 3))]
else:
j = min(len(lines), i + rng.randint(1, 3))
if j > i:
lines[i:j] = [rng.choice(ALPHA) + "\n" for _ in range(j - i)]
return "".join(lines)
def rand_prefix(rng: random.Random) -> str:
return "".join("PREFIX-%d-%d\n" % (rng.randrange(10 ** 6), i)
for i in range(rng.randint(1, 4)))
# --------------------------------------------------------------------------- #
# 1. hand cases
# --------------------------------------------------------------------------- #
def hand_cases(tmp: str) -> None:
def apply_diff(a: str, b: str, src: str | None = None, **kw) -> str:
write(os.path.join(tmp, "base.txt"), a)
write(os.path.join(tmp, "target.txt"), b)
d = gnu_diff(tmp, "base.txt", "target.txt")
return diffkit.apply_one(d, a if src is None else src, **kw)
check("hand:simple replace", apply_diff("a\nb\nc\n", "a\nB\nc\n") == "a\nB\nc\n")
check("hand:insert + delete mix",
apply_diff("1\n2\n3\n4\n5\n", "1\nX\n3\n5\n6\n") == "1\nX\n3\n5\n6\n")
check("hand:no newline at EOF",
apply_diff("a\nb\n", "a\nB") == "a\nB")
check("hand:newline added at EOF",
apply_diff("a\nb", "a\nb\n") == "a\nb\n")
check("hand:empty context lines",
apply_diff("\n\nx\n\n", "\ny\n\n") == "\ny\n\n")
check("hand:CRLF preserved",
apply_diff("a\r\nb\r\nc\r\n", "a\r\nB\r\nc\r\n") == "a\r\nB\r\nc\r\n")
# insertion into an empty file -> hunk header @@ -0,0 +1,N @@
write(os.path.join(tmp, "empty.txt"), "")
write(os.path.join(tmp, "target.txt"), "x\ny\n")
d = gnu_diff(tmp, "empty.txt", "target.txt")
check("hand:insert into empty file", diffkit.apply_one(d, "") == "x\ny\n")
# creation from /dev/null
write(os.path.join(tmp, "target.txt"), "new1\nnew2\n")
_, out_txt, _ = run_bytes(["diff", "-u", "/dev/null", "target.txt"], tmp)
out = diffkit.apply_patch(diffkit.parse_unified_diff(out_txt), {})
check("hand:create from /dev/null", out == {"target.txt": "new1\nnew2\n"},
repr(out))
# deletion to /dev/null
write(os.path.join(tmp, "base.txt"), "gone1\ngone2\n")
_, out_txt, _ = run_bytes(["diff", "-u", "base.txt", "/dev/null"], tmp)
out = diffkit.apply_patch(diffkit.parse_unified_diff(out_txt),
{"base.txt": "gone1\ngone2\n"})
check("hand:delete to /dev/null", out == {"base.txt": None}, repr(out))
# malformed hunk: header counts disagree with body
bad = "--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+c\n"
try:
diffkit.apply_one(bad, "a\nb\n")
check("hand:malformed hunk rejected", False, "no error raised")
except diffkit.DiffApplyError as e:
check("hand:malformed hunk rejected", "disagrees" in str(e), str(e))
# offset drift
base, target = "c1\nc2\nc3\na\nb\nc\nc4\nc5\nc6\n", "c1\nc2\nc3\na\nB\nc\nc4\nc5\nc6\n"
write(os.path.join(tmp, "base.txt"), base)
write(os.path.join(tmp, "target.txt"), target)
d = gnu_diff(tmp, "base.txt", "target.txt")
shifted = "P0\nP1\n" + base
got = diffkit.apply_one(d, shifted)
check("hand:offset drift located", got == "P0\nP1\n" + target, repr(got[:40]))
try:
diffkit.apply_one(d, shifted, max_offset=0)
check("hand:max_offset=0 rejects drift", False, "no error raised")
except diffkit.DiffApplyError:
check("hand:max_offset=0 rejects drift", True)
# fuzz: corrupt the first leading context line of the diff
lines = d.split("\n")
for i, ln in enumerate(lines):
if ln.startswith(" "):
lines[i] = " " + "c1-MUTATED"
break
fuzzed = "\n".join(lines)
corrupt_src = "c1-MUTATED\nc2\nc3\na\nb\nc\nc4\nc5\nc6\n"
expected = "c1-MUTATED\nc2\nc3\na\nB\nc\nc4\nc5\nc6\n"
try:
diffkit.apply_one(fuzzed, corrupt_src, max_fuzz=0)
check("hand:fuzz=0 rejects mismatch", False, "no error raised")
except diffkit.DiffApplyError:
check("hand:fuzz=0 rejects mismatch", True)
check("hand:fuzz=1 recovers", diffkit.apply_one(fuzzed, corrupt_src, max_fuzz=1) == expected)
# round trip: forward then reverse
write(os.path.join(tmp, "base.txt"), base)
write(os.path.join(tmp, "target.txt"), target)
fwd = gnu_diff(tmp, "base.txt", "target.txt")
rev = gnu_diff(tmp, "target.txt", "base.txt")
check("hand:round trip forward+reverse",
diffkit.apply_one(rev, diffkit.apply_one(fwd, base)) == base)
# multi-file in one text
write(os.path.join(tmp, "f1.txt"), "one\ntwo\nthree\n")
write(os.path.join(tmp, "f1new.txt"), "one\nTWO\nthree\n")
write(os.path.join(tmp, "f2.txt"), "red\ngreen\nblue\n")
write(os.path.join(tmp, "f2new.txt"), "red\nblue\n")
d1 = run_bytes(["diff", "-u", "f1.txt", "f1new.txt"], tmp)[1]
d2 = run_bytes(["diff", "-u", "f2.txt", "f2new.txt"], tmp)[1]
out = diffkit.apply_patch(diffkit.parse_unified_diff(d1 + d2),
{"f1.txt": "one\ntwo\nthree\n", "f2.txt": "red\ngreen\nblue\n"})
check("hand:multi-file patch",
out == {"f1new.txt": "one\nTWO\nthree\n", "f2new.txt": "red\nblue\n"}, repr(out))
# path stripping
p = diffkit.parse_unified_diff("--- a/x/y.txt\n+++ b/x/y.txt\n@@ -1 +1 @@\n-a\n+b\n")
check("hand:-p1 path strip",
diffkit.apply_patch(p, {"x/y.txt": "a\n"}, strip=1) == {"x/y.txt": "b\n"})
check("hand:timestamps stripped",
p[0].old_path == "a/x/y.txt" and p[0].new_path == "b/x/y.txt")
# split_lines must not split on \v \f \x85 (str.splitlines would)
weird = "a\x0bb\x0cc\n"
check("hand:split_lines only splits on \\n",
diffkit.split_lines(weird) == ["a\x0bb\x0cc\n"], repr(diffkit.split_lines(weird)))
def cli_case(tmp: str) -> None:
"""End-to-end: the CLI must patch real files on disk, for -p0 and git-style -p1."""
gen = os.path.join(tmp, "cli-gen") # real a/ and b/ trees, to generate the diff
flat = os.path.join(tmp, "cli-flat") # single working file, git-style prefixes
for d in (gen, flat):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(os.path.join(gen, "a"))
os.makedirs(os.path.join(gen, "b"))
os.makedirs(flat)
src = "alpha\nbeta\ngamma\n"
dst = "alpha\nBETA\ngamma\ndelta\n"
write(os.path.join(gen, "a", "f.txt"), src)
write(os.path.join(gen, "b", "f.txt"), dst)
_, d, _ = run_bytes(["diff", "-u", "a/f.txt", "b/f.txt"], gen)
write(os.path.join(flat, "d.diff"), d)
write(os.path.join(gen, "d.diff"), d)
# -p0: paths as written (a/f.txt -> b/f.txt)
rc, out, err = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p0", "d.diff"], gen)
got = open(os.path.join(gen, "b", "f.txt"), encoding="utf-8", newline="").read()
check("cli:-p0 patches a file on disk", rc == 0 and got == dst,
"rc=%s out=%r err=%r got=%r" % (rc, out, err, got))
# -p1: git-style virtual prefixes, patching f.txt in place
write(os.path.join(flat, "f.txt"), src)
rc, out, err = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p1", "d.diff"], flat)
got = open(os.path.join(flat, "f.txt"), encoding="utf-8", newline="").read()
check("cli:-p1 patches a file on disk in place", rc == 0 and got == dst,
"rc=%s out=%r err=%r got=%r" % (rc, out, err, got))
# a hunk that cannot apply must exit nonzero and leave the file untouched
write(os.path.join(gen, "b", "f.txt"), src) # reset target to its pre-patch state
write(os.path.join(gen, "a", "f.txt"), "totally\ndifferent\ncontent\n")
rc2, _, err2 = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p0", "d.diff"], gen)
after = open(os.path.join(gen, "b", "f.txt"), encoding="utf-8", newline="").read()
check("cli:unplaceable hunk exits nonzero, target intact",
rc2 != 0 and after == src, "rc=%s err=%r after=%r" % (rc2, err2, after))
# a missing patch file must be a clean error, not a traceback
rc3, _, err3 = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p0", "nope.diff"], flat)
check("cli:missing patch file is a clean error",
rc3 == 1 and "Traceback" not in err3 and "cannot read patch file" in err3,
"rc=%s err=%r" % (rc3, err3))
# --------------------------------------------------------------------------- #
# 2. property suite
# --------------------------------------------------------------------------- #
def property_suite(apply_fn, tmp: str, n_cases: int, drift: bool, seed0: int
) -> tuple[list[str], int, int, int]:
"""Return (failures, patch_agreements, patch_skips, cases_actually_run)."""
fails: list[str] = []
agree = skipped = ran = 0
for k in range(n_cases):
rng = random.Random(seed0 + k)
base = rand_text(rng)
target = mutate(rng, base)
if base == target:
continue
write(os.path.join(tmp, "base.txt"), base)
write(os.path.join(tmp, "target.txt"), target)
d = gnu_diff(tmp, "base.txt", "target.txt")
if not d.strip():
continue
pre = rand_prefix(rng) if drift else ""
src, expected = pre + base, pre + target
if drift:
hunks = diffkit.parse_unified_diff(d)[0].hunks
if any(h.old_count == 0 for h in hunks):
continue # pure insertion: no content anchor, drift undefined
ran += 1
try:
got = apply_fn(d, src)
except Exception as e: # noqa: BLE001
fails.append("case %d raised %s: %s" % (k, type(e).__name__, e))
continue
if got != expected:
fails.append("case %d mismatch (drift=%s)" % (k, drift))
if pre == "": # GNU patch cross-check only where the diff is exact
pt = gnu_patch_apply(tmp, d, src)
if pt is None:
skipped += 1
elif pt == expected:
agree += 1
else:
fails.append("case %d: GNU patch disagrees with the true target" % k)
return fails, agree, skipped, ran
# --------------------------------------------------------------------------- #
# 3. mutants (the suite must kill them)
# --------------------------------------------------------------------------- #
def mutant_no_search(diff_text: str, base_text: str) -> str:
"""Naive applier: trust the hunk header position, no drift, no fuzz."""
p = diffkit.parse_unified_diff(diff_text)[0]
return diffkit.apply_file_patch(p, base_text, max_fuzz=0, max_offset=0)
def mutant_first_hunk_only(diff_text: str, base_text: str) -> str:
p = diffkit.parse_unified_diff(diff_text)[0]
p.hunks = p.hunks[:1]
return diffkit.apply_file_patch(p, base_text)
def mutant_swaps_sides(diff_text: str, base_text: str) -> str:
p = diffkit.parse_unified_diff(diff_text)[0]
for h in p.hunks:
h.lines = [("+" if k == "-" else "-" if k == "+" else k, c) for k, c in h.lines]
h.old_count, h.new_count = h.new_count, h.old_count
return diffkit.apply_file_patch(p, base_text)
# --------------------------------------------------------------------------- #
def main() -> int:
tmp = tempfile.mkdtemp(prefix="diffkit-selftest-")
try:
hand_cases(tmp)
cli_case(tmp)
real = lambda d, s: diffkit.apply_one(d, s) # noqa: E731
fails, agree, skipped, ran = property_suite(real, tmp, PLAIN_CASES, False, 1000)
check("property: %d randomised exact diffs apply (generated %d)" % (ran, PLAIN_CASES),
not fails, "; ".join(fails[:3]))
check("property: GNU patch oracle agrees (%d/%d, %d skipped)"
% (agree, agree + skipped, skipped), agree > 0 and skipped == 0)
dfails, dagree, _, dran = property_suite(real, tmp, DRIFT_CASES, True, 9000)
check("property: %d randomised offset-drift diffs apply (generated %d)"
% (dran, DRIFT_CASES), not dfails, "; ".join(dfails[:3]))
# determinism
again = property_suite(real, tmp, 20, False, 1000)[0]
check("property: suite is deterministic", not again)
# mutation control
m_fails = property_suite(mutant_no_search, tmp, 30, True, 9000)[0]
check("mutation: no-search applier is killed (%d failures)" % len(m_fails),
len(m_fails) > 0)
m2_fails = property_suite(mutant_first_hunk_only, tmp, PLAIN_CASES, False, 1000)[0]
check("mutation: first-hunk-only applier is killed (%d failures)" % len(m2_fails),
len(m2_fails) > 0)
m3_fails = property_suite(mutant_swaps_sides, tmp, PLAIN_CASES, False, 1000)[0]
check("mutation: sides-swapped applier is killed (%d failures)" % len(m3_fails),
len(m3_fails) > 0)
finally:
shutil.rmtree(tmp, ignore_errors=True)
passed = sum(1 for _, ok, _ in _results if ok)
total = len(_results)
print("\n%d/%d checks passed" % (passed, total))
if passed != total:
print("FAILURES:")
for name, ok, detail in _results:
if not ok:
print(" - %s :: %s" % (name, detail))
return 1
print("ALL CHECKS PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Independent real-world cross-check for diffkit.
The self-test uses GNU `diff` output on synthetic files. This one uses a real
repository: it takes the diffs produced by `git diff` for the most recent commit
of a repo, applies each one with diffkit, and compares the result byte-for-byte
against what git itself says the file looks like at HEAD (`git show HEAD:path`).
Usage: python3 verify_realrepo.py [repo] [rev] [max_files]
Exit code 0 iff every applicable file matched.
"""
from __future__ import annotations
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import diffkit # noqa: E402
MAX_BYTES = 400_000
def git(repo: str, *args: str) -> tuple[int, bytes]:
r = subprocess.run(["git", "-C", repo, *args], capture_output=True)
return r.returncode, r.stdout
def main() -> int:
repo = sys.argv[1] if len(sys.argv) > 1 else "/root/troupe-standup"
rev = sys.argv[2] if len(sys.argv) > 2 else "HEAD~1"
limit = int(sys.argv[3]) if len(sys.argv) > 3 else 15
verbose = os.environ.get("VERBOSE") == "1"
rc, out = git(repo, "diff", "--name-only", "-r", rev, "HEAD")
if rc != 0:
print("git diff failed for %s..HEAD" % rev)
return 2
names = [n for n in out.decode("utf-8", "replace").split("\n") if n]
print("repo=%s rev=%s..HEAD %d changed paths" % (repo, rev, len(names)))
checked = passed = skipped = 0
for name in names:
if checked >= limit:
break
rc_old, old = git(repo, "show", "%s:%s" % (rev, name))
rc_new, new = git(repo, "show", "HEAD:%s" % name)
if rc_old != 0 or rc_new != 0:
skipped += 1
if verbose:
print(" skip %-60s (added or deleted in this commit)" % name)
continue
if b"\x00" in old or b"\x00" in new or max(len(old), len(new)) > MAX_BYTES:
skipped += 1
if verbose:
print(" skip %-60s (binary or >%d bytes)" % (name, MAX_BYTES))
continue
rc_d, d = git(repo, "diff", "--no-color", "-U3", rev, "HEAD", "--", name)
if rc_d != 0 or not d.strip():
skipped += 1
if verbose:
print(" skip %-60s (no textual diff)" % name)
continue
old_s = old.decode("utf-8")
new_s = new.decode("utf-8")
diff_s = d.decode("utf-8")
checked += 1
try:
got = diffkit.apply_one(diff_s, old_s)
except Exception as e: # noqa: BLE001
print(" FAIL %-60s raised %s: %s" % (name, type(e).__name__, e))
continue
if got == new_s:
passed += 1
print(" ok %-60s %d bytes, %d hunks"
% (name, len(new_s.encode()), len(diffkit.parse_unified_diff(diff_s)[0].hunks)))
else:
print(" FAIL %-60s mismatch (got %d bytes, want %d)"
% (name, len(got.encode()), len(new_s.encode())))
print("\n%d/%d applicable files reproduced exactly (%d skipped)"
% (passed, checked, skipped))
return 0 if checked > 0 and passed == checked else 1
if __name__ == "__main__":
sys.exit(main())