10 Commits

Author SHA1 Message Date
kylehgc 27abf4ad7f fix(diff): round 8 -- silent-loss and name-fidelity sweep across git, GNU diff, hg and svn
Review round 7 asked for two things; a producer enumeration and two
adversarial review passes answered for thirty. Every item below has a
real capture in the corpus (81 fixtures now: git 2.47/2.54, diffutils
3.10 and 3.12, Mercurial 7.0, Subversion 1.14, PowerShell) and a test
that fails without the fix.

Silent loss (a file or content line missing while the output looked
complete) -- each now returns `None`, byte-exact raw passthrough:

- GNU sorts its output, so a fact the parser cannot read (`File X is a
  fifo ...`, `Symbolic links ... differ`, a translated `Only in`) can
  come before the first `Only in` / `Binary files` / `Files` line, and
  `-q` streams carry no echo at all. The latch is now `dropped_line` --
  any column-0 line dropped as prose outside a message region -- and is
  judged once after the loop against `gnu_stream`, which GNU's fact arms
  set as well as its echo (hg's echo does not: an `hg log -p --template`
  prologue is prose, not a fact).
- `hg export`'s region stayed open until the first header pair and
  swallowed `Binary file bin.dat has changed` on a binary-first
  changeset. hg's `diff -r` echo (one `-r` from `hg diff`, two from
  `hg export` / `hg log -p`) now closes the region, counts as reaching
  its body, and the line is a binary fact; rule 6 stays live inside an
  hg region. `hg log -p`'s `changeset:` line opens the region like
  `# HG changeset patch` does.
- `git diff --word-diff` / `--color-words` hunks whose lines are all
  indented carry their `[-x-]{+y+}` markers behind the indent, so the
  hunk was booked as context and the file vanished. A hunk that closes
  with no `-`/`+` line is not a unified-diff shape (whitespace-ignoring
  modes suppress the hunk) -> `None`.
- `git format-patch --submodule=log` puts `Submodule sub a..b:` where a
  file section would go, inside the message region where rule 6 is
  suppressed. The strict `<hex>..<hex>[ (<how>)]:` shape is admitted
  there (`is_submodule_range`); prose never ends a line that way.
- A stream cut off right after `diff --git` / `index` dropped the file
  it named: git never emits a header-only section, so one that closes
  with nothing behind it -> `None` (`FileEntry::from_git`; `flush`
  returns `Option`). git's `index` / `similarity index` lines are
  structural and no longer count as dropped prose.
- A plain `svn diff` names a binary only as `Index: b.dat` + `Cannot
  display: file marked as a binary type.`, both prose before: the
  binary vanished beside its text siblings. `Index: <path>` opens a
  section (rule 3c); the notice is its binary note; an `Index:` section
  that closes empty is `(no content)` (a copy/move target) unless a
  column-0 line was dropped inside it (a localized notice) -> `None`.
- A binary section followed by another producer's header pair was
  renamed in place and its `binary` note landed on the next file; a
  pair after a binary section opens its own section.

A crash: the shared-tail scan behind `diff --git X Y` and `Binary files
X and Y` compared bytes, and two different multi-byte characters can
share trailing bytes (`😀`/`🙀`, `é`/`©`): the slice landed mid-character
and the process aborted with no output at all. The scan backs off to a
character boundary on both sides.

Names, byte-equal to the real filename under the producer's own root:

- `dequote` decodes git's C quoting (`"caf\303\251.txt"` -> `café.txt`)
  instead of stripping the wrapper; a name that would split a `[file]`
  line (newline) or is not UTF-8 keeps git's own quoted spelling, with
  the prefix stripped inside the quotes, so `rename to`, `diff --cc` and
  the header pair agree on one spelling and two Latin-1 names stay two.
- `unquote_shell` decodes diffutils >= 3.11's shell quoting on `Only in`
  / `Binary files` names -- `'…'`, `$'…'` and the `"…"` form it picks
  for a name holding a `'`; a quoted directory may itself hold `: `, so
  the `Only in` split follows the quoting (`shell_word_len`). 3.10
  prints those names bare; a bare `Only in` with more than one `: `
  splits at the root the stream already named on its echo, header pairs
  or `Binary files` lines. Both generations are in the corpus.
- `diff --git X Y` is split where the sides share the longest tail at a
  `/` boundary (`shared_tail`); the first path component before the
  tail on each side is that side's prefix (`FileEntry::prefixes`
  replaces `prefixed`): `a/`/`b/`, `i/`/`w/` under
  `diff.mnemonicPrefix`, `--src-prefix`/`--dst-prefix`, none under
  `--no-prefix`. The header pair strips exactly those, so `* Unmerged
  path b/inb.txt` folds into its `i/b/inb.txt w/b/inb.txt` section, a
  binary under a directory named `x b/` keeps its path, and `git diff
  --no-index d1/f d2/f` names `d2/f`. `diff --cc <path>` is kept whole;
  `--no-index --no-prefix x.bin y.bin` names `y.bin`.
- GNU diff names files under the roots it was given, never prefixes:
  header pairs after a GNU echo and the standalone `Binary files` /
  `Files` arms keep them (`diff -ru a b` -> `b/c.txt`, `diff -ru .
  ../new` -> `../new/x`), splitting `X and Y` by the same shared tail.
- Timestamps are stripped from `---`/`+++` lines only, at the LAST tab
  and only when shaped like one of the real producers' (GNU ISO date,
  hg `Mon Sep 07 …`, svn `(revision 1)`, git's bare tab), so a name
  containing a tab survives even under `hg diff --nodates`.
- An `* Unmerged path` fact git printed unquoted and cut at a newline
  folds into its quoted section instead of a phantom entry.

Spurious raw fallbacks on well-formed streams, now condensed: `git log
--stat -p` / `git show --stat -p` (bare `---` followed by a diffstat
line is the separator -- which also drops the old ` name | 3` bound),
`git log --numstat -p` (`-<TAB>-<TAB>` binary rows), `hg log -p` with
and without `--template`, an hg binary-only changeset whose message has
a marked non-prose line, `--format=%B` prose starting with `diff ` (the
GNU echo arm keys on `diff -`), `diff -u` on directories (`Common
subdirectories:` dropped), an svn copy/move target.

Also: GNU `-s` / `-q` facts are entries; a PowerShell-written stream's
UTF-8 BOM is stripped; a submodule both dirty and moved is one entry;
the budget replay in property (a) asserts under-consumption; the docs
list rule 1b and every bound is executable in `documented_bounds_hold`.
2026-09-07 15:56:00 -04:00
kylehgc 9634a9a82b fix(diff): round 7 -- no-newline marker placement, hg export region, dequoted renames
`\ No newline at end of file` describes the line directly above it. The
parser never renders context lines, so a marker git attached to an
unchanged last line landed under the last `-`/`+` line rendered and
claimed THAT line lacked a newline -- the one place the output stated
something untrue about the diff. The marker is now kept exactly when the
line above it was emitted (in-hunk after a `-`/`+`, or on the line right
after the budget closed on one), which keeps the witness of a
newline-only change and drops a marker that would describe the wrong
line. `emitted_at` replaces `closed_at`: the position test is the same
for both rules. Real fixture `git_diff_no_newline_raw.txt` carries both
shapes.

`# HG changeset patch` opens a message region like an mbox `From`, so
`hg export`'s column-0 headers and message no longer latch
`dropped_prologue` and send the whole changeset raw through its own
`diff -r <a> -r <b> <file>` echo. Real fixture `hg_export_raw.txt`
(Mercurial 7.0.1) joins the corpus.

`rename to`/`copy to` (and their `from`) go through `dequote`, so a
`core.quotepath` name has one spelling across a stream. The submodule
note keeps the range's direction qualifier:
`[file] sub (submodule e139196..b0ac9b1 rewind)`.

The body-line replay in `corpus_every_marked_body_line_survives` shares
the parser's per-parent presence rule (there is no second formula for
combined-diff columns); its doc now says so and names the oracle -- git's
header counts -- and it asserts a budget is never still owed at a
non-body line or at EOF, so under-consumption fails there too.
2026-09-05 19:44:03 -04:00
kylehgc 4ccb5aab3d fix(diff): round 5 -- translated GNU fact lines fall back raw, submodule range kept
GNU diff translates `Only in` and `Binary files … differ` (git translates
none of its own lines). Rule 6 read the English spelling only, so on a
French machine `diff -ru g1 g2 | rtk diff -` dropped the three translated
fact lines as prose and, because a text section still parsed, returned
`Some` -- four files became one, silently.

Rule 3b now reads GNU diff's per-file `diff <opts> X Y` echo (printed
whenever it compares directories) as the mark of a `diff -r` stream. Such
a stream carries no prose, so a column-0 line no arm read there is a fact
in a language the parser does not speak, and rule 9 returns `None`
instead of dropping it. A line dropped before the echo settled the
question counts the same way once it does -- `diff -r` may list an
`Only in` first. Real fixture `diff_ru_fr_raw.txt` (`LC_ALL=fr_FR.UTF-8`,
diffutils 3.10) pins the raw fallback; the English `diff_ru_raw.txt`
still parses in full, as do `git log -p` streams whose prologue is prose.

The `Submodule <path> <a>..<b>` note now carries the range --
`[file] sub (submodule e139196..b0ac9b1)` -- for parity with the dirty
arm's `(submodule, modified content)`.
2026-09-03 12:45:07 -04:00
kylehgc 613e78558d fix(diff): round 4 -- fact-arm names, dirty submodules, no-prefix renames, quoted paths
Review round 3 on the file-level-fact arms, plus a cold pass on top:

- `Only in <dir>: <file>` splits on the FIRST `: ` (GNU diff's separator
  follows the directory; a filename may carry its own).
- Standalone `Binary files X and Y differ` splits at the ` and ` where
  both sides agree past their first component, so a filename containing
  ` and ` is not cut, and is named through `header_name` so the `a/`/`b/`
  strip follows the same decision as a header pair.
- `* Unmerged path X` folds into the section git emits for X right after
  it: one `[file] X (unmerged) (+A -B)` entry instead of two.
- `Submodule <path> <a>..<b>` keeps only the path in the name slot (the
  tokens before the LAST `..` token, so a path containing `..` survives),
  and the sha-less `Submodule <path> contains ... content` dirty line is a
  fact too -- it used to be dropped silently beside a condensed sibling.
  Real fixture `git_diff_submodule_dirty_raw.txt` joins the corpus.
- `rename to` / `copy to` is git's exact path; the `+++` pair no longer
  re-derives it, which stripped a real `b/` directory under `--no-prefix`.
- `core.quotepath` wraps non-ASCII paths in quotes on every header line;
  names are dequoted before the prefix strip.
- The header-pair hunk gate applies to open hunkless sections as well:
  git only emits `---`/`+++` before the first `@@`, so a stray pair there
  is lost content and falls back raw instead of renaming the section.
- `diff --cc X` carries one path and leaves `prefixed` to its header pair.
- `past_diffstat` comment states the first-section-mangled shape it alone
  catches; the ` name | 3` bound says it fires always (the `---` separator
  is the marked line that follows); the stdin doc says the never-worse
  check is inlined and why.

A/B against 9106d48: 400 `git show` streams byte-identical; 290
format-patch series identical except 12 that fell back raw through a
prose pipe and now condense, zero new fallbacks.
2026-09-01 22:07:55 -04:00
kylehgc 120450b480 fix(diff): round 3 -- content bytes through ANSI, mbox diffstat gate, --no-prefix names
Structure is read through a per-line ANSI/CR-stripped view and content is
pushed raw, so escapes that are part of the user's content survive (the
file path was already byte-faithful on the same input). Rule 8's mbox
tolerance now ends at the first diffstat line: an orphaned hunk body in a
hand-edited series falls back raw instead of being dropped as prose, while
version notes between `---` and the diffstat stay prose. `diff --git X Y`
with X == Y marks a --no-prefix stream whose header names keep their
leading a/ b/ (creations and deletions included); the check is confined
to the two-path form. The new-side `\ No newline` marker is kept only on
the line right after its hunk closes. The dead `!in_prologue` conjunct
and the variable behind it are gone.

Real `git diff --cached --no-prefix` fixture added to the corpus.
2026-09-01 17:46:41 -04:00
kylehgc eb088f2f65 fix(diff): preclear round 3 -- keep the no-newline marker, strict UTF-8 gate
- '\ No newline at end of file' survives to the output (in-hunk and the
  common new-side position just after the budget closes): it is the only
  witness of a trailing-newline-only change, which otherwise renders as
  two byte-identical -/+ lines. Real fixture; proven load-bearing by
  revert.
- Non-UTF-8 stdin now always takes the raw-bytes branch, even when the
  stream parses: condensing would rewrite content bytes to U+FFFD, and
  byte fidelity outranks savings. (Base hard-errored here; raw
  passthrough is strictly better on both counts.)
- Doc-comment bounds recorded for the mbox header-pair spoof and the
  raw-fallback newline append.
2026-08-30 22:29:17 +00:00
kylehgc 7689020ca5 fix(diff): preclear round 2 -- file-level facts, sha256 mbox, contexts
- File-level facts outside hunks become note-only entries instead of
  vanishing while the stream condenses: 'Only in <dir>: <file>' and
  standalone 'Binary files ... differ' (GNU diff -r), '* Unmerged path'
  (git diff --ours during a merge), and 'Submodule <a>..<b>' headers.
  Suppressed inside mbox message regions, where column-0 prose is
  indistinguishable by value. Real fixtures captured for all four shapes;
  fix proven load-bearing by revert.
- is_mbox_from accepts 64-hex separators: SHA-256 repos' format-patch
  streams no longer fall back raw wholesale.
- .context() on the stdin read and raw-fallback write paths.
- Savings comment corrected to the measured corpus numbers (52-87%
  metadata-heavy, single digits content-heavy); exemption escalation
  unchanged.
2026-08-30 22:29:17 +00:00
kylehgc 970b3b20dc fix(diff): preclear round 1 -- report hunkless empty-file sections, tighten guards
- new file mode / deleted file mode sections (an empty file added or
  removed) get their own notes; previously they vanished silently whenever
  another file in the stream parsed cleanly. Real fixture captured, fix
  proven load-bearing by revert.
- Signature tolerance (rule 6) now requires the stream to have carried an
  mbox From separator; a bare '--' in a plain diff falls back raw.
- never_worse discrimination by token estimate instead of pointer
  identity; stdin decoded once (Cow) instead of twice; hunk-body prefix
  checked on the byte view without a per-line allocation.
2026-08-30 22:29:17 +00:00
kylehgc f42eaf92bd fix(diff): review round 1 -- byte-raw fallback, strict prefix width, fixture hygiene
- Structural fallback now emits the caller's exact bytes (a lossy UTF-8
  decode or ANSI-stripped 'raw' is not raw); the decode-strip-parse-guard
  pipeline moved into condense_stdin so it is unit-testable.
- Dropped the mailer-padding tolerance: a line shorter than the hunk's
  prefix width now falls back instead of being guessed into context.
- Plain-diff fixtures recaptured with relative paths (no local usernames
  or session ids); copy-only and mode-only sections get real fixtures and
  a vanish-class test.
2026-08-30 22:29:17 +00:00
kylehgc ee614e4c63 fix(diff): region parser for condense_unified_diff -- budget-owned hunks, raw fallback
Parse the stream into (prologue)(file-header)(hunk)* regions before
classifying: hunk extent comes from the @@ line budget (one old-budget per
parent, so @@@ combined headers parse too), content is classified only
inside hunks, and any structural disagreement (invalid body prefix, budget
over/under-run, budget owed at EOF, malformed @@) falls back to raw
passthrough. Detector precedence is a documented total order on
condense_unified_diff_strict.

Fixes, each with its reproducer from
claudedocs/diff-classifier-review-2026-08-29.md as a test:
- SQL/Lua '-- comment' removals (--- on the wire) no longer dropped
- added '++ text' lines no longer parsed as file headers
- format-patch mbox prose and the '-- ' signature no longer counted
- +++ /dev/null deletions named by their --- side
- binary / rename-only / mode-only sections reported instead of vanishing
- --color input stripped (was: silently empty output), non-UTF-8 stdin
  read lossily (was: hard error, no fallback)
- CRLF content bytes survive verbatim (split('\n'), not lines())
- b/ prefix stripped exactly once; timestamps tab-split from header names
- the '... +N more' trailer removed: it claimed truncation that never
  happened (all lines were printed above it)

Fixture corpus: 13 real-producer captures (git 2.54, GNU diff) in
tests/fixtures/diff -- multi-file, -U0, -W, rename/delete/binary,
log -p, show --cc, format-patch single/series/cover, diff -u/-ru/-rN,
CRLF. Property tests: every marked body line survives to the output,
counters equal rendered lines, the fallback fires on zero corpus fixtures.
2026-08-30 22:29:17 +00:00