diff --git a/wizard-work/null/.gitignore b/wizard-work/null/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/wizard-work/null/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/wizard-work/null/diffkit/README.md b/wizard-work/null/diffkit/README.md new file mode 100644 index 0000000..a54867b --- /dev/null +++ b/wizard-work/null/diffkit/README.md @@ -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:`; 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 diff --git a/wizard-work/null/diffkit/diffkit.py b/wizard-work/null/diffkit/diffkit.py new file mode 100644 index 0000000..6756cac --- /dev/null +++ b/wizard-work/null/diffkit/diffkit.py @@ -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:])) diff --git a/wizard-work/null/diffkit/realrepo-output.txt b/wizard-work/null/diffkit/realrepo-output.txt new file mode 100644 index 0000000..ade1fbd --- /dev/null +++ b/wizard-work/null/diffkit/realrepo-output.txt @@ -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) diff --git a/wizard-work/null/diffkit/selftest-output.txt b/wizard-work/null/diffkit/selftest-output.txt new file mode 100644 index 0000000..eca6534 --- /dev/null +++ b/wizard-work/null/diffkit/selftest-output.txt @@ -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 diff --git a/wizard-work/null/diffkit/selftest.py b/wizard-work/null/diffkit/selftest.py new file mode 100644 index 0000000..320994e --- /dev/null +++ b/wizard-work/null/diffkit/selftest.py @@ -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()) diff --git a/wizard-work/null/diffkit/verify_realrepo.py b/wizard-work/null/diffkit/verify_realrepo.py new file mode 100644 index 0000000..1e610f0 --- /dev/null +++ b/wizard-work/null/diffkit/verify_realrepo.py @@ -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())