Anchors are always a diff: drop the !side selector

A whole-file !new anchor used to drop deleted lines — its callout showed only the new side and mark_full left no backfill — so deletions vanished from the page. Rather than patch !new, this removes the !new/!old/!diff selector entirely: every anchor now renders as a diff, which puts deletions on the page by construction and lets the per-line coverage machinery go. It bumps the spec to v0.4 and moves the docs in lockstep.

The grammar loses its side dimension — an anchor is now path[:range] [@focus] — and a legacy !side token from an older message is accepted and ignored, so old commits still render as anchors rather than prose.

src/anchor.rs
@@ -4,35 +4,16 @@4 //! than as prose. The grammar is:5 //!6 //! ```text-//! <path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>] [!<side>]7+//! <path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>]8 //! ```9 //!-//! See `docs/SPEC.md` for the full recognition and disambiguation rules.10+//! An anchor always renders as a diff (or, for code outside the diff, as plain11+//! context); there is no side selector. A legacy `!new`/`!old`/`!diff` token12+//! from an older commit message is accepted and ignored, so old messages still13+//! render. See `docs/SPEC.md` for the full recognition and disambiguation rules.14 15 use std::fmt;16 -/// Where an anchor pulls its code from.-#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]-pub enum Side {-    /// Final state at the commit (the default).-    #[default]-    New,-    /// State at the parent commit, before the change.-    Old,-    /// The hunk(s) themselves, with `+`/`-` lines.-    Diff,-}--impl fmt::Display for Side {-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {-        f.write_str(match self {-            Side::New => "new",-            Side::Old => "old",-            Side::Diff => "diff",-        })-    }-}-17 /// An inclusive, 1-indexed line range.18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]19 pub struct LineRange {@@ -67,8 +48,8 @@48     }49 }50 -/// A parsed reference to code: a path, an optional line range, an optional focus-/// sub-range to emphasize, and which side to read from.51+/// A parsed reference to code: a path, an optional line range, and an optional52+/// focus sub-range to emphasize.53 #[derive(Debug, Clone, PartialEq, Eq)]54 pub struct AnchorRef {55     /// Repository-relative path.@@ -77,8 +58,6 @@58     pub range: Option<LineRange>,59     /// A sub-range to visually emphasize.60     pub focus: Option<LineRange>,-    /// Where the code comes from.-    pub side: Side,61 }62 63 impl AnchorRef {@@ -101,20 +80,15 @@80         }81 82         let mut focus = None;-        let mut side = Side::New;-        let mut saw_side = false;83         for tok in tokens {84             if let Some(rest) = tok.strip_prefix('@') {85                 if focus.is_some() {86                     return None;87                 }88                 focus = Some(LineRange::parse(rest)?);-            } else if let Some(rest) = tok.strip_prefix('!') {-                if saw_side {-                    return None;-                }-                side = parse_side(rest)?;-                saw_side = true;89+            } else if tok.starts_with('!') {90+                // A legacy `!new`/`!old`/`!diff` side selector. Anchors are91+                // always diffs now, so accept and ignore it for back-compat.92             } else {93                 // Anything else means this is prose that merely begins with a94                 // path-shaped token.@@ -126,7 +100,6 @@100             path: path.to_string(),101             range,102             focus,-            side,103         })104     }105 }@@ -140,9 +113,6 @@113         if let Some(r) = self.focus {114             write!(f, " @{r}")?;115         }-        if self.side != Side::New {-            write!(f, " !{}", self.side)?;-        }116         Ok(())117     }118 }@@ -156,15 +126,6 @@126     }127 }128 -fn parse_side(s: &str) -> Option<Side> {-    match s {-        "new" => Some(Side::New),-        "old" => Some(Side::Old),-        "diff" => Some(Side::Diff),-        _ => None,-    }-}-129 /// The recognition heuristic: a token "looks like a path" if it contains a `/`130 /// or ends in a recognizable `.extension`.131 fn looks_like_path(token: &str) -> bool {@@ -197,7 +158,6 @@158         assert_eq!(a.path, "src/auth/middleware.rs");159         assert_eq!(a.range, None);160         assert_eq!(a.focus, None);-        assert_eq!(a.side, Side::New);161     }162 163     #[test]@@ -213,18 +173,23 @@173     }174 175     #[test]-    fn focus_and_side() {-        let a = anchor("docs/api.md:10-20 @14-16 !new");176+    fn range_and_focus() {177+        let a = anchor("docs/api.md:10-20 @14-16");178         assert_eq!(a.range, Some(LineRange { start: 10, end: 20 }));179         assert_eq!(a.focus, Some(LineRange { start: 14, end: 16 }));-        assert_eq!(a.side, Side::New);180     }181 182     #[test]-    fn diff_side_no_range() {183+    fn legacy_side_token_is_accepted_and_ignored() {184+        // Anchors are always diffs now; a `!new`/`!old`/`!diff` token left in an185+        // older commit message still parses (the line stays an anchor) but the186+        // selector carries no meaning.187         let a = anchor("src/config.rs !diff");188+        assert_eq!(a.path, "src/config.rs");189         assert_eq!(a.range, None);-        assert_eq!(a.side, Side::Diff);190+        let b = anchor("docs/api.md:10-20 @14-16 !old");191+        assert_eq!(b.range, Some(LineRange { start: 10, end: 20 }));192+        assert_eq!(b.focus, Some(LineRange { start: 14, end: 16 }));193     }194 195     #[test]@@ -263,7 +228,6 @@228             "src/auth/validator.rs:12-38",229             "src/auth/validator.rs:50",230             "src/auth/middleware.rs",-            "src/config.rs !diff",231             "docs/api.md:10-20 @14-16",232         ] {233             let a = anchor(line);

Resolution collapses to a single path. When the anchor's range overlaps changed hunks it shows the diff (the file's whole change, or the clip the range selects); otherwise the code is not in the diff, so it is read from the commit and shown as plain "unmodified" context. A hunk is marked covered only when an anchor shows it in full.

src/render.rs
@@ -1,7 +1,7 @@1 //! Render orchestration: resolve anchors against Git, track coverage, and build2 //! the ordered document model that the HTML emitter consumes.3 -use crate::anchor::{AnchorRef, LineRange, Side};4+use crate::anchor::{AnchorRef, LineRange};5 use crate::diff::{Coverage, DeltaMarker, Diff, Hunk};6 use crate::git::Repo;7 use crate::message::{self, Block};@@ -30,6 +30,10 @@30     /// `true` if the anchor could not be resolved. The renderer then shows a31     /// warning in place of the code (degrade, don't fail) and marks no hunks.32     pub degraded: bool,33+    /// `true` if the code is *not* part of the diff — a whole unchanged file or34+    /// an unchanged range pulled in as context. Shown as plain lines and35+    /// labeled "unmodified"; marks no hunks.36+    pub context: bool,37     /// Human-readable explanation, shown for degraded blocks.38     pub note: Option<String>,39 }@@ -97,30 +101,17 @@101         }102     }103 -    // Backfill: everything not already shown, in natural diff order. This is-    // the mechanism that enforces the completeness invariant.104+    // Backfill: every hunk not shown in full, in natural diff order — emitted105+    // whole, even if an anchor already showed part of it (repetition is allowed,106+    // and re-showing the whole hunk is what guarantees every deleted line107+    // appears). This is the mechanism that enforces the completeness invariant.108     for (fi, file) in diff.files.iter().enumerate() {109         for (hi, hunk) in file.hunks.iter().enumerate() {-            // A wholly-shown hunk (whole-file or `!diff` anchor) needs nothing.-            if coverage.is_full(fi, hi) {-                continue;-            }-            let covered = coverage.covered_intervals(fi, hi);-            if covered.is_empty() {-                // Untouched: backfill the hunk verbatim.110+            if !coverage.is_full(fi, hi) {111                 sections.push(Section::Backfill {112                     path: file.path.clone(),113                     hunk: hunk.clone(),114                 });-            } else {-                // A ranged `!new` anchor showed part of this hunk; split it and-                // backfill only the new-side runs it left unshown.-                for sub in hunk.uncovered_subhunks(covered) {-                    sections.push(Section::Backfill {-                        path: file.path.clone(),-                        hunk: sub,-                    });-                }115             }116         }117         // Surface the file's delta-level marker after its hunks: a hunk-less@@ -140,7 +131,14 @@131     })132 }133 -/// Resolve one anchor to a [`CodeBlock`], marking any diff hunks it covers.134+/// Resolve one anchor to a [`CodeBlock`], marking any hunks it shows in full.135+///136+/// If the anchor's path is in the diff and its range overlaps changed hunks it137+/// renders as a diff — the file's whole change, or the slice the range selects.138+/// A hunk shown in full is marked so the backfill skips it; a hunk shown only in139+/// part is left for the backfill, which re-emits it whole. Otherwise the code is140+/// not part of the diff (a whole unchanged file, or an unchanged range), so it141+/// renders as plain context read from the commit and marks nothing.142 fn resolve_anchor(143     repo: &Repo,144     rev: &str,@@ -148,25 +146,48 @@146     a: &AnchorRef,147     coverage: &mut Coverage,148 ) -> CodeBlock {-    match a.side {-        Side::New => resolve_blob(repo, rev, diff, a, coverage, /* mark */ true),-        // The pre-change version is context: show it, but mark no hunks so the-        // real diff still appears in full via backfill (spec: "marks no hunks").-        Side::Old => resolve_blob(repo, rev, diff, a, coverage, /* mark */ false),-        Side::Diff => resolve_diff(diff, a, coverage),149+    let covering = covering_hunks(diff, a);150+    if covering.is_empty() {151+        return resolve_context(repo, rev, a);152+    }153+154+    let mut lines = Vec::new();155+    for (fi, hi) in covering {156+        let hunk = &diff.files[fi].hunks[hi];157+        match a.range {158+            // Whole-file anchor: show every hunk of the file in full.159+            None => {160+                lines.extend(hunk_to_code_lines(hunk, a.focus));161+                coverage.mark_full(fi, hi);162+            }163+            // Ranged anchor: show the slice the range selects. Mark the hunk164+            // full only when the range spans its entire new side; otherwise the165+            // whole hunk is re-emitted by the backfill (completeness).166+            Some(r) => {167+                if let Some(clip) = hunk.clip_to_new_range(r.start, r.end) {168+                    lines.extend(hunk_to_code_lines(&clip, a.focus));169+                }170+                if hunk.contained_in_new_range(r.start, r.end) {171+                    coverage.mark_full(fi, hi);172+                }173+            }174+        }175+    }176+177+    CodeBlock {178+        path: a.path.clone(),179+        lines,180+        degraded: false,181+        context: false,182+        note: None,183     }184 }185 -/// Resolve a `New`/`Old` anchor by reading the blob and slicing the range.-fn resolve_blob(-    repo: &Repo,-    rev: &str,-    diff: &Diff,-    a: &AnchorRef,-    coverage: &mut Coverage,-    mark: bool,-) -> CodeBlock {-    let all = match repo.blob_lines(rev, &a.path, a.side) {186+/// Resolve an anchor whose code is not part of the diff: read the file at the187+/// commit and show the requested lines (or the whole file) as plain, unmodified188+/// context. Marks no hunks — the real diff still appears in full via backfill.189+fn resolve_context(repo: &Repo, rev: &str, a: &AnchorRef) -> CodeBlock {190+    let all = match repo.blob_lines(rev, &a.path) {191         Ok(lines) => lines,192         Err(e) => return degraded(&a.path, format!("could not read `{}`: {e}", a.path)),193     };@@ -190,51 +211,11 @@211         })212         .collect();213 -    // A new-side callout accounts for the hunks it overlaps; an unchanged-context-    // (`!old`) callout overlaps none and marks nothing. A whole-file `!new`-    // anchor shows every hunk in full; a ranged one shows only its new-side-    // lines, so it marks just that interval and the rest is split into backfill.-    if mark {-        for (fi, hi) in covering_hunks(diff, a) {-            match a.range {-                None => coverage.mark_full(fi, hi),-                Some(r) => coverage.mark_new_range(fi, hi, r.start, r.end),-            }-        }-    }--    CodeBlock {-        path: a.path.clone(),-        lines,-        degraded: false,-        note: None,-    }-}--/// Resolve a `Diff` anchor by showing the covering hunk(s) as a diff.-fn resolve_diff(diff: &Diff, a: &AnchorRef, coverage: &mut Coverage) -> CodeBlock {-    let covering = covering_hunks(diff, a);-    if covering.is_empty() {-        return degraded(-            &a.path,-            format!("the diff of this commit does not cover `{a}`"),-        );-    }--    // A `!diff` anchor displays each covering hunk in full (header and all its-    // `+`/`-` lines), so it marks the whole hunk — there is no remainder to-    // split out, even when the anchor's range is narrower than the hunk.-    let mut lines = Vec::new();-    for (fi, hi) in &covering {-        let hunk = &diff.files[*fi].hunks[*hi];-        lines.extend(hunk_to_code_lines(hunk, a.focus));-        coverage.mark_full(*fi, *hi);-    }-214     CodeBlock {215         path: a.path.clone(),216         lines,217         degraded: false,218+        context: true,219         note: None,220     }221 }@@ -339,6 +320,7 @@320         path: path.to_string(),321         lines: Vec::new(),322         degraded: true,323+        context: false,324         note: Some(note),325     }326 }

Coverage drops to a per-hunk set of fully-shown hunks — no more intervals, no sub-hunk splitting. A ranged anchor clips the hunk for display (clip_to_new_range); any hunk it did not cover whole is re-emitted whole by the backfill, and that repetition is what guarantees every line, deletions included, appears at least once.

src/diff.rs
@@ -1,6 +1,6 @@1 //! The diff model: files, hunks, and per-hunk coverage tracking.2 -use std::collections::HashMap;3+use std::collections::HashSet;4 use std::fmt;5 6 /// A single line within a hunk.@@ -31,69 +31,51 @@31     /// The last new-side line number this hunk spans. For a pure-deletion hunk32     /// (`new_lines == 0`) this collapses to `new_start`, the position the33     /// deletion sits *before* — which is exactly where its `-` lines probe.-    fn new_end(&self) -> u32 {34+    pub(crate) fn new_end(&self) -> u32 {35         self.new_start + self.new_lines.saturating_sub(1)36     }37 -    /// Split this hunk into the sub-hunks whose new-side lines are **not** in-    /// `covered` (a set of inclusive, 1-indexed new-line intervals), so the-    /// backfill can show the remainder a ranged `!new` anchor left unshown.-    ///-    /// Every line is preserved — nothing is dropped, that is the whole point.-    /// A `-` line has no new-side number, so it travels with the new-side-    /// position it sits *before* (clamped to the last new line when it trails-    /// the hunk); a deletion inside a covered run is therefore suppressed with-    /// that run, while one bordering an uncovered run is carried into it.38+    /// Whether the inclusive, 1-indexed new-side range `[start, end]` spans this39+    /// hunk's entire new side — i.e. a ranged anchor over it shows the hunk in40+    /// full, so the backfill can skip it.41+    pub fn contained_in_new_range(&self, start: u32, end: u32) -> bool {42+        start <= self.new_start && self.new_end() <= end43+    }44+45+    /// The contiguous sub-hunk of lines whose new-side position falls within the46+    /// inclusive, 1-indexed range `[start, end]`, renumbered to its real47+    /// position — what a ranged anchor shows as a diff. A `-` line has no48+    /// new-side number, so it travels with the new-side position it sits49+    /// *before* (clamped to the last new line when it trails the hunk), so a50+    /// deletion bordering the range is carried in and never orphaned.51     ///-    /// With `covered` empty the hunk is wholly uncovered and the result is one-    /// sub-hunk identical to `self`; with the whole new side covered the result-    /// is empty.-    pub fn uncovered_subhunks(&self, covered: &[(u32, u32)]) -> Vec<Hunk> {-        let is_covered = |n: u32| covered.iter().any(|&(s, e)| s <= n && n <= e);52+    /// Returns `None` when the range selects no line of the hunk; when the range53+    /// spans the whole new side the result equals the hunk itself.54+    pub fn clip_to_new_range(&self, start: u32, end: u32) -> Option<Hunk> {55         let new_end = self.new_end();56+        let in_range = |n: u32| start <= n && n <= end;57+58+        let mut old_start = 0;59+        let mut new_start = 0;60+        let (mut old_lines, mut new_lines) = (0u32, 0u32);61+        let mut lines = Vec::new();62 -        // Per line, in order: whether its new-side position is covered, and the-        // (old, new) counters as they stood *before* it — i.e. the 1-indexed-        // start numbers a sub-hunk beginning at this line would carry.-        let mut meta: Vec<(bool, u32, u32)> = Vec::with_capacity(self.lines.len());63         let mut old_no = self.old_start;64         let mut new_no = self.new_start;65         for line in &self.lines {-            let at = (old_no, new_no);-            let covered = match line.origin {-                '+' => {-                    let c = is_covered(new_no);-                    new_no += 1;-                    c-                }-                '-' => {-                    let c = is_covered(new_no.min(new_end));-                    old_no += 1;-                    c-                }-                _ => {-                    let c = is_covered(new_no);-                    old_no += 1;-                    new_no += 1;-                    c-                }66+            // The new-side position to test, and how this line advances the67+            // (old, new) counters.68+            let (probe, adv_old, adv_new) = match line.origin {69+                '+' => (new_no, false, true),70+                '-' => (new_no.min(new_end), true, false),71+                _ => (new_no, true, true),72             };-            meta.push((covered, at.0, at.1));-        }--        // Emit a sub-hunk for each maximal run of uncovered lines.-        let mut out = Vec::new();-        let mut i = 0;-        while i < self.lines.len() {-            if meta[i].0 {-                i += 1;-                continue;-            }-            let (_, old_start, new_start) = meta[i];-            let (mut old_lines, mut new_lines) = (0, 0);-            let mut lines = Vec::new();-            while i < self.lines.len() && !meta[i].0 {-                match self.lines[i].origin {73+            if in_range(probe) {74+                if lines.is_empty() {75+                    old_start = old_no;76+                    new_start = new_no;77+                }78+                match line.origin {79                     '+' => new_lines += 1,80                     '-' => old_lines += 1,81                     _ => {@@ -101,18 +83,26 @@83                         new_lines += 1;84                     }85                 }-                lines.push(self.lines[i].clone());-                i += 1;86+                lines.push(line.clone());87+            }88+            if adv_old {89+                old_no += 1;90             }-            out.push(Hunk {-                old_start,-                old_lines,-                new_start,-                new_lines,-                lines,-            });91+            if adv_new {92+                new_no += 1;93+            }94+        }95+96+        if lines.is_empty() {97+            return None;98         }-        out99+        Some(Hunk {100+            old_start,101+            old_lines,102+            new_start,103+            new_lines,104+            lines,105+        })106     }107 }108 @@ -181,29 +171,16 @@171     pub files: Vec<FileDiff>,172 }173 -/// What an anchor has shown of one hunk.-///-/// A whole-file or `!diff` anchor shows a hunk in its entirety (`full`); a-/// ranged `!new` anchor shows only some new-side lines, recorded as intervals-/// so the backfill can split out the rest (sub-hunk splitting).-#[derive(Debug, Default)]-struct HunkCoverage {-    /// The whole hunk has been shown, so it needs no backfill at all —-    /// including any deletions it contains.-    full: bool,-    /// Inclusive, 1-indexed new-side intervals shown by ranged `!new` anchors.-    new_lines: Vec<(u32, u32)>,-}--/// Tracks what has been shown of each hunk, so the backfill can collect the-/// rest. Keyed by `(file_index, hunk_index)` — coverage is per hunk, never per-/// file (a spec invariant), and within a hunk per new-side line for ranged-/// `!new` anchors. An anchor marks exactly the hunks its range overlaps,-/// leaving a partially-annotated file's other hunks (and a partially-annotated-/// hunk's other lines) for the backfill.174+/// Tracks which hunks an anchor has shown **in full**, so the backfill can175+/// collect the rest. Keyed by `(file_index, hunk_index)` — coverage is per176+/// hunk, never per file (a spec invariant). A hunk is "full" when an anchor177+/// displayed it whole: a whole-file anchor, or a ranged anchor whose range178+/// spans the hunk's entire new side. A hunk shown only in part is left out, so179+/// the backfill emits the whole hunk again — repetition is allowed, and it is180+/// how every deleted line is guaranteed to appear.181 #[derive(Debug, Default)]182 pub struct Coverage {-    hunks: HashMap<(usize, usize), HunkCoverage>,183+    full: HashSet<(usize, usize)>,184 }185 186 impl Coverage {@@ -211,34 +188,15 @@188         Coverage::default()189     }190 -    /// Mark the whole hunk shown (a whole-file or `!diff` anchor): it is-    /// displayed in full, so the backfill skips it entirely.191+    /// Mark the whole hunk shown: it is displayed in full, so the backfill192+    /// skips it entirely.193     pub fn mark_full(&mut self, file: usize, hunk: usize) {-        self.hunks.entry((file, hunk)).or_default().full = true;-    }--    /// Mark an inclusive new-side interval of a hunk shown (a ranged `!new`-    /// anchor). The hunk's uncovered new-side runs are split into the backfill.-    pub fn mark_new_range(&mut self, file: usize, hunk: usize, start: u32, end: u32) {-        self.hunks-            .entry((file, hunk))-            .or_default()-            .new_lines-            .push((start, end));194+        self.full.insert((file, hunk));195     }196 197     /// Whether the whole hunk has been shown and needs no backfill.198     pub fn is_full(&self, file: usize, hunk: usize) -> bool {-        self.hunks.get(&(file, hunk)).is_some_and(|c| c.full)-    }--    /// The covered new-side intervals for a hunk — empty when it is untouched-    /// (the backfill then emits the whole hunk).-    pub fn covered_intervals(&self, file: usize, hunk: usize) -> &[(u32, u32)] {-        self.hunks-            .get(&(file, hunk))-            .map(|c| c.new_lines.as_slice())-            .unwrap_or(&[])199+        self.full.contains(&(file, hunk))200     }201 }202 @@ -281,22 +239,30 @@239         assert!(c.is_full(0, 1));240         assert!(!c.is_full(0, 0));241         assert!(!c.is_full(1, 1));-        // A `full` hunk carries no intervals; an untouched one carries none either.-        assert!(c.covered_intervals(0, 1).is_empty());-        assert!(c.covered_intervals(2, 2).is_empty());242+        // An untouched hunk is never full.243+        assert!(!c.is_full(2, 2));244     }245 246     #[test]-    fn coverage_records_new_line_intervals() {-        let mut c = Coverage::new();-        c.mark_new_range(0, 0, 5, 8);-        c.mark_new_range(0, 0, 12, 12);-        assert!(!c.is_full(0, 0));-        assert_eq!(c.covered_intervals(0, 0), &[(5, 8), (12, 12)]);247+    fn range_containment_decides_full_coverage() {248+        // `@@ -0,0 +1,10 @@`: new-side lines 1..=10.249+        let h = Hunk {250+            old_start: 0,251+            old_lines: 0,252+            new_start: 1,253+            new_lines: 10,254+            lines: (1..=10).map(|i| add(&format!("line {i:02}"))).collect(),255+        };256+        // A range that spans the whole new side covers the hunk in full…257+        assert!(h.contained_in_new_range(1, 10));258+        assert!(h.contained_in_new_range(1, 99));259+        // …a narrower one does not, so it will be backfilled whole.260+        assert!(!h.contained_in_new_range(4, 6));261+        assert!(!h.contained_in_new_range(2, 10));262     }263 264     #[test]-    fn split_backfills_the_uncovered_runs_of_a_new_file_hunk() {265+    fn clip_to_new_range_extracts_the_middle() {266         // A brand-new file: one hunk of 10 additions, `@@ -0,0 +1,10 @@`.267         let h = Hunk {268             old_start: 0,@@ -306,42 +272,40 @@272             lines: (1..=10).map(|i| add(&format!("line {i:02}"))).collect(),273         };274 -        // A ranged `!new` callout showed new lines 4..=6; backfill the rest.-        let subs = h.uncovered_subhunks(&[(4, 6)]);-        assert_eq!(subs.len(), 2, "two runs flank the covered middle");--        // Leading run: lines 1..=3, still a pure addition (`@@ -0,0 +1,3 @@`).275+        // A ranged anchor over new lines 4..=6 shows exactly that slice,276+        // renumbered to its real position (`@@ -0,0 +4,3 @@`).277+        let clip = h.clip_to_new_range(4, 6).unwrap();278         assert_eq!(279             (-                subs[0].old_start,-                subs[0].old_lines,-                subs[0].new_start,-                subs[0].new_lines280+                clip.old_start,281+                clip.old_lines,282+                clip.new_start,283+                clip.new_lines284             ),-            (0, 0, 1, 3)285+            (0, 0, 4, 3)286         );-        assert_eq!(subs[0].lines.first().unwrap().content, "line 01");-        assert_eq!(subs[0].lines.last().unwrap().content, "line 03");287+        assert_eq!(clip.lines.first().unwrap().content, "line 04");288+        assert_eq!(clip.lines.last().unwrap().content, "line 06");289+        // Lines outside the range are not in the clip (the whole hunk repeats in290+        // the backfill instead).291+        assert!(!clip.lines.iter().any(|l| l.content == "line 03"));292+    }293 -        // Trailing run: lines 7..=10, renumbered from 7.-        assert_eq!(-            (subs[1].new_start, subs[1].new_lines),-            (7, 4),-            "the trailing run keeps its real new-side numbering"-        );-        assert_eq!(subs[1].lines.first().unwrap().content, "line 07");--        // The covered middle (the callout's job) appears in no sub-hunk.-        let backfilled: Vec<&str> = subs-            .iter()-            .flat_map(|s| &s.lines)-            .map(|l| l.content.as_str())-            .collect();-        assert!(!backfilled.contains(&"line 05"));294+    #[test]295+    fn clip_spanning_the_whole_new_side_reproduces_the_hunk() {296+        let h = Hunk {297+            old_start: 0,298+            old_lines: 0,299+            new_start: 1,300+            new_lines: 5,301+            lines: (1..=5).map(|i| add(&format!("l{i}"))).collect(),302+        };303+        assert_eq!(h.clip_to_new_range(1, 5).as_ref(), Some(&h));304+        assert_eq!(h.clip_to_new_range(1, 99).as_ref(), Some(&h));305     }306 307     #[test]-    fn split_carries_a_bordering_deletion_into_the_uncovered_run() {308+    fn clip_carries_a_bordering_deletion() {309         // `@@ -1,5 +1,3 @@`: delete two leading lines, keep three context lines.310         let h = Hunk {311             old_start: 1,@@ -372,49 +336,27 @@336             ],337         };338 -        // Cover new lines 2..=3 (ctx4, ctx5). The deletions sit before new line-        // 1 (uncovered), so they must travel into the leading run, not vanish.-        let subs = h.uncovered_subhunks(&[(2, 3)]);-        assert_eq!(subs.len(), 1);-        let s = &subs[0];339+        // A clip to new line 1 (ctx3) must carry the two deletions that sit340+        // before it — a `-` line travels with the new-side position it borders.341+        let clip = h.clip_to_new_range(1, 1).unwrap();342         assert_eq!(-            (s.old_start, s.old_lines, s.new_start, s.new_lines),343+            (344+                clip.old_start,345+                clip.old_lines,346+                clip.new_start,347+                clip.new_lines348+            ),349             (1, 3, 1, 1)350         );351         assert_eq!(-            s.lines.iter().map(|l| l.origin).collect::<Vec<_>>(),352+            clip.lines.iter().map(|l| l.origin).collect::<Vec<_>>(),353             ['-', '-', ' ']354         );-        assert_eq!(s.lines[0].content, "old1");-    }--    #[test]-    fn split_with_nothing_covered_reproduces_the_original_hunk() {-        let h = Hunk {-            old_start: 4,-            old_lines: 2,-            new_start: 4,-            new_lines: 3,-            lines: vec![-                DiffLine {-                    origin: ' ',-                    content: "ctx".into(),-                },-                DiffLine {-                    origin: '-',-                    content: "gone".into(),-                },-                add("added1"),-                add("added2"),-            ],-        };-        // An untouched hunk round-trips through the splitter unchanged, so the-        // backfill of a partially-annotated file stays byte-identical.-        assert_eq!(h.uncovered_subhunks(&[]), vec![h.clone()]);355+        assert_eq!(clip.lines[0].content, "old1");356     }357 358     #[test]-    fn split_with_the_whole_new_side_covered_yields_nothing() {359+    fn clip_outside_the_hunk_yields_none() {360         let h = Hunk {361             old_start: 0,362             old_lines: 0,@@ -422,7 +364,7 @@364             new_lines: 5,365             lines: (1..=5).map(|i| add(&format!("l{i}"))).collect(),366         };-        assert!(h.uncovered_subhunks(&[(1, 5)]).is_empty());367+        assert!(h.clip_to_new_range(6, 10).is_none());368     }369 370     #[test]

With no side left to choose, blob_lines simply reads the file at the commit — used only to show context that is not part of the diff.

src/git.rs
@@ -2,7 +2,6 @@2 3 use std::path::Path;4 -use crate::anchor::Side;5 use crate::diff::{DeltaMarker, DeltaStatus, Diff, DiffLine, FileDiff, Hunk};6 use crate::{Error, Result};7 @@ -169,24 +168,14 @@168         Ok(Diff { files })169     }170 -    /// Read the lines of `path` on the given [`Side`] at `rev`.171+    /// Read the lines of `path` as it stands at `rev` (the commit's own tree).172     ///-    /// `New` (and `Diff`, defensively) read the blob at the commit; `Old` reads-    /// it at the first parent. Returns an error if the path is absent on the-    /// requested side — the renderer turns that into a degraded callout.-    pub fn blob_lines(&self, rev: &str, path: &str, side: Side) -> Result<Vec<String>> {173+    /// Used to show code that is *not* part of the diff — a whole file or an174+    /// unchanged range pulled in as context. Returns an error if the path is175+    /// absent at the commit — the renderer turns that into a degraded callout.176+    pub fn blob_lines(&self, rev: &str, path: &str) -> Result<Vec<String>> {177         let commit = self.inner.revparse_single(rev)?.peel_to_commit()?;-        let tree = match side {-            Side::New | Side::Diff => commit.tree()?,-            Side::Old => match commit.parent_count() {-                0 => {-                    return Err(Error::Other(-                        "the root commit has no parent; `old` side is unavailable".to_string(),-                    ))-                }-                _ => commit.parent(0)?.tree()?,-            },-        };178+        let tree = commit.tree()?;179 180         let entry = tree.get_path(Path::new(path))?;181         let object = entry.to_object(&self.inner)?;

The emitter tags an out-of-diff callout "unmodified", so a reader never mistakes plain context for added code.

src/html.rs
@@ -60,6 +60,9 @@60 .line.del .sign { color: #cf222e; }61 .line.focus { box-shadow: inset 3px 0 0 #e3b341; }62 .line.meta { color: var(--muted); background: rgba(127,127,127,0.06); }63+figcaption .tag { font-weight: 400; text-transform: uppercase; letter-spacing: 0.04em;64+     font-size: 0.7rem; color: var(--muted); border: 1px solid var(--rule);65+     border-radius: 4px; padding: 0.05em 0.4em; margin-left: 0.4rem; }66 .degraded { padding: 0.8rem; border-color: #d4a72c; }67 .warn { color: #b08800; font-style: italic; margin: 0; }68 .callout.marker .marker-note { margin: 0; padding: 0.5rem 0.8rem; color: var(--muted);@@ -245,9 +248,20 @@248         );249     }250 251+    // Code pulled in as context (a whole unchanged file, or an unchanged range)252+    // is not a diff, so flag it "unmodified" — the reader shouldn't read its253+    // plain lines as "added".254+    let (class, caption) = if cb.context {255+        (256+            "callout context",257+            format!("{} <span class=\"tag\">unmodified</span>", escape(&cb.path)),258+        )259+    } else {260+        ("callout", escape(&cb.path))261+    };262+263     let mut out = format!(-        "<figure class=\"callout\"><figcaption>{}</figcaption><pre class=\"code\"><code>",-        escape(&cb.path),264+        "<figure class=\"{class}\"><figcaption>{caption}</figcaption><pre class=\"code\"><code>",265     );266     for line in &cb.lines {267         out.push_str(&line_html(line));

The completeness test now asserts every deleted line appears, not only additions, over a whole-file anchor on a modified file — the case that used to fail. A new ranged-anchor test pins the clip-plus-whole-hunk-repeat behavior and a context test covers code referenced outside the diff; sub-hunk splitting and its test are gone.

tests/completeness.rs
@@ -4,9 +4,9 @@4 //! backfill. This is the hard guarantee from the spec (and CLAUDE.md #1).5 //!6 //! The test builds a small fixture repository with a non-root commit that-//! modifies, adds, and deletes files (so the `Old`/deletion paths are exercised-//! alongside `New`/`Diff`), annotates only some of it, and then asserts the-//! invariant over the whole diff.7+//! modifies (with deletions), adds, and deletes files, annotates only some of8+//! it, and then asserts the invariant over the whole diff — checking that every9+//! added *and* deleted line appears, either in a diff callout or the backfill.10 11 use std::fs;12 use std::path::Path;@@ -55,6 +55,11 @@55     write(dir.path(), "a.txt", &a_v1);56     write(dir.path(), "b.txt", "bravo one\nbravo two\n");57     write(dir.path(), "c.txt", "charlie to be deleted\n");58+    write(59+        dir.path(),60+        "e.txt",61+        "echo one\necho two\necho GONE\necho four\n",62+    );63     write(dir.path(), "f.txt", &f_v1);64 65     let mut index = git.index().unwrap();@@ -73,12 +78,16 @@78     let f_v2 = replace_lines(&f_v1, &[(3, "FOXTROT_NEW_3"), (18, "FOXTROT_NEW_18")]);79     write(dir.path(), "a.txt", &a_v2);80     write(dir.path(), "b.txt", "BRAVO_NEW one\nBRAVO_NEW two\n");81+    // e.txt is modified with a real deletion: "echo GONE" is removed and a line82+    // is changed, so its single hunk carries `-` lines. Annotated whole-file, it83+    // is the case the old `!new` whole-file anchor used to drop.84+    write(dir.path(), "e.txt", "echo one\nECHO_NEW_two\necho four\n");85     write(dir.path(), "f.txt", &f_v2);86     write(dir.path(), "d.txt", "delta brand new file\n");87     fs::remove_file(dir.path().join("c.txt")).unwrap();88 89     let mut index = git.index().unwrap();-    for p in ["a.txt", "b.txt", "f.txt", "d.txt"] {90+    for p in ["a.txt", "b.txt", "e.txt", "f.txt", "d.txt"] {91         index.add_path(Path::new(p)).unwrap();92     }93     index.remove_path(Path::new("c.txt")).unwrap();@@ -86,18 +95,22 @@95     let tree = git.find_tree(index.write_tree().unwrap()).unwrap();96     let parent_commit = git.find_commit(parent).unwrap();97 -    // Annotate only b.txt (whole file) and the first hunk of f.txt; a.txt,-    // c.txt's deletion, and d.txt are left entirely for the backfill.98+    // Annotate b.txt (whole file), e.txt (whole file, has a deletion), and one99+    // line of f.txt; a.txt, c.txt's deletion, and d.txt are left entirely for100+    // the backfill.101     let message = "\102 Exercise the renderer103 104 A small commit that touches several files to exercise coverage and backfill.105 -b.txt !diff106+b.txt107 The whole change to b, shown as a diff.108 -f.txt:3 !diff-Only the first hunk of f; its second hunk is left for the backfill.109+e.txt110+The whole change to e — its removed line must appear in this diff callout.111+112+f.txt:3113+A single line of f; the rest of its hunk and its second hunk are backfilled.114 ";115     let v2 = git116         .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])@@ -154,20 +167,23 @@167 168     assert!(!diff.files.is_empty(), "the fixture must have a diff");169 -    // The core invariant: each hunk is either backfilled verbatim, or its added-    // lines are all present in a callout for that file.170+    // The core invariant: each hunk is either backfilled verbatim, or it was171+    // shown in full as a diff callout — in which case BOTH its added and its172+    // deleted lines are present in that callout. Checking `-` lines (not just173+    // `+`) is what guards the bug a whole-file `!new` anchor used to have.174     for file in &diff.files {175         let code_text = code_text_for(&doc, &file.path);176         for (hi, hunk) in file.hunks.iter().enumerate() {177             let in_backfill = backfill_has(&doc, &file.path, hunk);-            let added_shown = hunk-                .lines-                .iter()-                .filter(|l| l.origin == '+' && !l.content.is_empty())-                .all(|l| code_text.contains(&l.content));178+            let lines_shown = |origin: char| {179+                hunk.lines180+                    .iter()181+                    .filter(|l| l.origin == origin && !l.content.is_empty())182+                    .all(|l| code_text.contains(&l.content))183+            };184             assert!(-                in_backfill || added_shown,-                "completeness violated: hunk {hi} of `{}` (new @{}) appears nowhere",185+                in_backfill || (lines_shown('+') && lines_shown('-')),186+                "completeness violated: hunk {hi} of `{}` (new @{}) drops a line",187                 file.path,188                 hunk.new_start,189             );@@ -188,14 +204,22 @@204     assert!(!has_code(&doc, "a.txt"));205     assert_eq!(count_backfill(&doc, "a.txt"), 2);206 -    // b.txt: whole-file `!diff` anchor -> shown as code, never backfilled.207+    // b.txt: whole-file anchor -> shown in full as a diff, never backfilled.208     assert!(has_code(&doc, "b.txt"));209     assert_eq!(count_backfill(&doc, "b.txt"), 0);210 -    // f.txt: two hunks, only the first anchored -> one covered, one backfilled.211+    // e.txt: whole-file anchor on a modified file -> shown in full (deletion and212+    // all), marked full, so it is not backfilled.213+    assert_eq!(file("e.txt").hunks.len(), 1, "e.txt should have one hunk");214+    assert!(has_code(&doc, "e.txt"));215+    assert_eq!(count_backfill(&doc, "e.txt"), 0);216+217+    // f.txt: two hunks. A single-line anchor (`:3`) does not span the whole218+    // first hunk, so neither hunk is marked full -> both are backfilled whole,219+    // even though the callout already showed a slice of the first.220     assert_eq!(file("f.txt").hunks.len(), 2, "f.txt should have two hunks");221     assert!(has_code(&doc, "f.txt"));-    assert_eq!(count_backfill(&doc, "f.txt"), 1);222+    assert_eq!(count_backfill(&doc, "f.txt"), 2);223 224     // c.txt deletion and d.txt addition are unannotated -> backfilled.225     assert_eq!(count_backfill(&doc, "c.txt"), 1);@@ -203,3 +227,17 @@227 228     assert_eq!(doc.title, "Exercise the renderer");229 }230+231+#[test]232+fn a_whole_file_anchor_shows_deletions_in_its_callout() {233+    // The bug this redesign closes: a whole-file anchor on a modified file used234+    // to show only the new side (a `!new` callout), dropping deleted lines.235+    // Now every callout is a diff, so the removed line appears in the callout.236+    let fx = build_fixture();237+    let doc = render::render(&fx.repo, &fx.rev).unwrap();238+    let shown = code_text_for(&doc, "e.txt");239+    assert!(240+        shown.contains("echo GONE"),241+        "the removed line must appear in e.txt's diff callout, got:\n{shown}"242+    );243+}

The format spec is the source of truth, so it leads: one kind of anchor, always a diff, with whole-hunk repeat in place of sub-hunk splitting.

docs/SPEC.md
@@ -1,4 +1,4 @@-# Commit Throughline — PoC Specification (v0.3)1+# Commit Throughline — PoC Specification (v0.4)2 3 > Canonical specification for the format. The reference implementation is the4 > `throughline` CLI in this repository.@@ -28,8 +28,8 @@28 3. **Repetition is allowed.** A hunk may appear more than once — e.g. once as an author's29    focused callout and again in its natural position. That is not a conflict; it's the30    completeness guarantee doing its job. There is no "exactly once" rule.-4. **Don't duplicate code in prose.** Anchors reference coordinates (path, line range,-   side); the renderer extracts the real content from Git at render time, so the narrative31+4. **Don't duplicate code in prose.** Anchors reference coordinates (path, line range);32+   the renderer extracts the real content from Git at render time, so the narrative33    can't drift out of sync.34 5. **It's just Markdown with embedded diff.** Apart from anchor injection, the message is35    rendered by an ordinary Markdown engine. Interleaving prose and code is unremarkable —@@ -60,31 +60,32 @@60 ## Anchors61 62 An anchor is a line, by itself, that the renderer recognizes as a reference to code.-Everything else is ordinary Markdown prose. There is only one kind of anchor: it shows-some code, led in by the prose above it, and marks any diff hunks it overlaps as "already63+Everything else is ordinary Markdown prose. There is only one kind of anchor, and it64+always shows the **diff** of the code it names — the changed region with its `+`/`-`65+lines — led in by the prose above it, and marks the hunks it shows in full as "already66 shown" so the backfill won't be required to repeat them (though repetition is allowed).67 -An anchor may point at code that is part of the diff (a changed region) or at code that is-not (unchanged surrounding lines, a function elsewhere, the pre-change version) to provide-context. Both are the same construct — pulling in non-diff context simply shows that code-and marks no hunks as seen, and the real diff still appears in full via backfill.68+An anchor may also point at code that is **not part of the diff** — a whole file the69+commit never touched, or an unchanged range of a file it did — to provide context. There70+is no diff to show there, so that code renders as **plain, unmodified lines** (labeled as71+such) and marks no hunks, and the real diff still appears in full via backfill. The author72+chooses *which* code to surface and *in what order*; whether it shows as a diff or as plain73+context follows from the diff itself, not from a modifier.74 75 ### Recognition rule76 77 A line is an anchor if, after trimming, it begins with a token that **looks like a path**78 — it contains a `/` or ends in a recognizable `.extension` — optionally followed by a line-specifier and modifiers, and nothing else of substance on the line.79+specifier and a focus range, and nothing else of substance on the line.80 81 ```-<path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>] [!<side>]82+<path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>]83 ```84 85 - `<path>` — file path relative to the repository root.-- `:<start>` or `:<start>-<end>` — a single line or an inclusive line range (1-indexed).-  **Omitted entirely → the whole change to that file** is shown.86+- `:<start>` or `:<start>-<end>` — a single line or an inclusive line range (1-indexed),87+  on the new side. **Omitted entirely → the whole change to that file** is shown.88 - `@<focusStart>-<focusEnd>` — a sub-range to visually emphasize. Optional.-- `!<side>` — where the code comes from: `new` (final state, default), `old` (state-  before the commit), `diff` (the hunk with its `+`/`-` lines). Optional.89 90 Valid anchors:91 @@ -92,10 +93,14 @@93 src/auth/validator.rs:12-3894 src/auth/validator.rs:5095 src/auth/middleware.rs-src/config.rs !diff-docs/api.md:10-20 @14-16 !new96+docs/api.md:10-20 @14-1697 ```98 99+> **Legacy side modifier.** Earlier versions accepted a trailing `!new`/`!old`/`!diff`100+> selector. It no longer carries meaning — anchors are always diffs — but it is still101+> *accepted and ignored*, so a commit message written against an older version still102+> renders as an anchor rather than as prose.103+104 ### Disambiguation against prose105 106 The path-shaped pattern is strict (must contain `/` or a `.ext`) so it rarely collides@@ -106,9 +111,11 @@111 112 ## Coverage and backfill113 -Coverage is tracked **per hunk**, not per file. An anchor whose range overlaps one or more-diff hunks marks exactly those hunks as shown. A file annotated only in part leaves its-other hunks unplaced, so they still appear in the backfill.114+Coverage is tracked **per hunk**, not per file. A hunk counts as covered only when an115+anchor shows it **in full**: a whole-file anchor shows every hunk of the file, and a ranged116+anchor shows a hunk in full when its range spans the hunk's entire new side. A file117+annotated only in part leaves its other hunks unplaced, so they still appear in the118+backfill.119 120 - **Natural order** for the backfill is diff order as Git emits it: alphabetical by path,121   hunks top-to-bottom within each file.@@ -117,16 +124,15 @@124   guarantee is completeness; a reader loses nothing by seeing the full hunk again in125   context.126 -An anchor's range need not align with hunk boundaries. A ranged `!new` anchor that falls-inside a larger hunk marks only the new-side lines it actually shows; the hunk's uncovered-remainder is **split out and backfilled** as one or more sub-hunks, each renumbered to its-real position. (A removed line has no new-side number, so it travels with the run it-borders — a deletion inside the shown range is suppressed with it, one bordering an-uncovered run is carried into the backfill — and nothing is dropped.) A whole-file anchor-and a `!diff` anchor instead mark the enclosing hunk(s) as a unit: they display each hunk-in full, so there is no remainder to recover. Splitting keeps completeness at the level of-the changed *line*, not merely the hunk — most visibly on new files and big rewrites,-where an entire file is a single hunk.127+An anchor's range need not align with hunk boundaries. A ranged anchor that falls inside a128+larger hunk shows just the **clip** its range selects — the diff of those lines, renumbered129+to its real position (a removed line, having no new-side number, travels with the line it130+borders). Because the clip did not show the hunk in full, the **whole hunk is re-emitted in131+the backfill**, even though that repeats the lines the clip already showed. Re-showing the132+whole hunk — rather than splitting out only the unshown remainder — is deliberate: it keeps133+the model simple and guarantees every line, **including deletions**, appears at least once.134+This matters most on new files and big rewrites, where an entire file is a single hunk:135+the clip narrates a slice, and the full change still lands in the backfill.136 137 ### Delta-level markers138 @@ -152,7 +158,7 @@158 **Whole-file** — omit the range; the section shows everything that changed in that file.159 160 ```-src/config.rs !diff161+src/config.rs162 Bumped the default timeout from 5s to 30s.163 ```164 @@ -184,15 +190,16 @@190 src/auth/validator.rs:12-38 @20-24191 192 With validation gone, the middleware just delegates. Here's the whole change.-src/auth/middleware.rs !diff193+src/auth/middleware.rs194 ```195 196 Renders to: the title as `<h1>`; the first paragraph as the **TL;DR** lede; the lead-in-paragraph ("Here is the validator…"); a callout of lines 12-38 of `validator.rs` (20-24-emphasized); the next lead-in ("With validation gone…"); the diff of `middleware.rs`; then-the backfill — every remaining hunk of the commit (e.g. updated tests, and any hunks of-the two files above not covered by the anchors) in natural diff order, shown plainly. The-prose always precedes the code it describes, and no hunk is missing.197+paragraph ("Here is the validator…"); a diff clip of lines 12-38 of `validator.rs` (20-24198+emphasized); the next lead-in ("With validation gone…"); the whole diff of `middleware.rs`;199+then the backfill — every hunk not already shown in full (e.g. updated tests, and the rest200+of `validator.rs`'s hunk, since the `:12-38` clip did not span it whole) in natural diff201+order, shown plainly. The prose always precedes the code it describes, and no hunk is202+missing.203 204 ## Conventional Commits interop205 @@ -211,7 +218,7 @@218 src/auth/validator.rs:12-38 @20-24219 220 With validation gone, the middleware just delegates…-src/auth/middleware.rs !diff221+src/auth/middleware.rs222 223 BREAKING CHANGE: session tokens are no longer validated inline224 Refs: #142@@ -255,25 +262,27 @@262    and compute the full diff against the parent (the complete set of hunks).263 2. Parse the message: split off the title and the TL;DR (the first paragraph), collect the264    intro/lead-in, then walk the rest into an ordered list of blocks — each either *prose*-   (raw Markdown) or *anchor* (unresolved path + range + focus + side). Prose that precedes265+   (raw Markdown) or *anchor* (unresolved path + range + focus). Prose that precedes266    an anchor is its lead-in.267 3. Resolve each anchor against Git:-   - `new` -> blob at the commit; `old` -> blob at the parent; `diff` -> the hunk(s)-     covering the range.-   - Mark what the anchor shows: a whole-file or `diff` anchor marks each overlapped-     hunk whole; a ranged `new` anchor marks only its new-side lines.-4. Backfill: collect everything not shown, in natural diff order — each fully-unshown-   hunk whole, and the uncovered remainder of a partially-shown `new` hunk split into-   sub-hunks at the anchor boundary.-5. Emit HTML: render prose with a Markdown library; for each anchor emit a-   syntax-highlighted code block (focus lines marked; `+`/`-` colored in `diff` mode);268+   - If the path/range overlaps changed hunks, show the diff — every hunk of the file269+     (whole-file anchor), or the clip the range selects (ranged anchor).270+   - Otherwise the code is not in the diff (a whole unchanged file, or an unchanged271+     range): read the file at the commit and show those lines as plain context.272+   - Mark a hunk shown *in full* (a whole-file anchor, or a ranged anchor whose range273+     spans the hunk's whole new side); a clip that did not cover its hunk marks nothing.274+4. Backfill: emit every hunk not shown in full, **whole**, in natural diff order — even275+   one an anchor showed only a clip of (repetition guarantees every line appears).276+5. Emit HTML: render prose with a Markdown library; for each anchor emit a code block —277+   a diff (focus lines marked; `+`/`-` colored) or plain context labeled "unmodified";278    then emit the backfill hunks as a normal diff. Inline all CSS so the file is standalone.279 280 ### Degraded anchors281 -If a range is out of bounds, the path is missing on the requested side, or the diff-doesn't cover the range: emit the prose intact and a visible warning in place of the code.-Never abort. (A degraded anchor marks no hunks shown, so nothing is lost from the backfill.)282+If the path is missing at the commit, or a range is out of bounds for the file: emit the283+prose intact and a visible warning in place of the code. Never abort. (A degraded anchor284+marks no hunks shown, so nothing is lost from the backfill.) A range that simply isn't part285+of the diff is **not** a failure — it falls back to plain, unmodified context.286 287 ## Out of scope (PoC)288 @@ -298,15 +307,17 @@307   code already shown:308 309       <1-3 sentences leading into the code>-      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>] [!new|old|diff]310+      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>]311 -- The FULL diff is always shown regardless of what you annotate; you are choosing order-  and adding explanation, not selecting what to include. Anything you don't mention is-  appended automatically at the end in natural order.312+- Each reference shows the DIFF of the code it names (the +/- change). The FULL diff is313+  always shown regardless of what you annotate; you are choosing order and adding314+  explanation, not selecting what to include. Anything you don't mention is appended315+  automatically at the end in natural order.316 - Omit the range to show a file's whole change; repeat a path with narrow ranges to walk317   through it piece by piece. Both styles can mix.-- You may reference unchanged code (or !old) purely to give context; the real diff still-  appears in full.318+- You may reference code that DIDN'T change — a whole untouched file, or an unchanged319+  range — purely to give context; it shows as plain "unmodified" code, and the real diff320+  still appears in full.321 - Order references in the order best for reading, not alphabetically.322 - Never paste code into the prose; just reference the lines — the renderer injects it.323 ```

The rest of the docs follow into their homes: the always-diff model and the simplified coverage in ARCHITECTURE, the resolved item in ROADMAP, the authoring guidance in the LLM guide, a rewritten coverage-and-backfill field note, and a new field note on inspecting the HTML output — the line-packing and CSS-class-name gotchas that cost a wrong turn here.

Remaining changes

CLAUDE.md
@@ -40,10 +40,10 @@40 |------|----------------|41 | `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` argument parsing. |42 | `src/message.rs` | Split a raw message into title, TL;DR (first paragraph), intro (lead-in to the first anchor), and an ordered list of prose/anchor blocks. |-| `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus] [!side]`. |43+| `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus]` (a legacy trailing `!side` is accepted and ignored). |44 | `src/conventional.rs` | Conventional Commits header (`type(scope): …`) and footer-trailer passthrough. |-| `src/git.rs` | Open the repo; resolve a commit and its parent; compute the full diff; read blob lines by side. |-| `src/diff.rs` | Hunk / file-diff model, per-hunk (and per-line) coverage tracking, sub-hunk splitting, and backfill collection. |45+| `src/git.rs` | Open the repo; resolve a commit and its parent; compute the full diff; read a file's lines at the commit (for context outside the diff). |46+| `src/diff.rs` | Hunk / file-diff model, per-hunk coverage tracking, range clipping (`clip_to_new_range`), and backfill collection. |47 | `src/render.rs` | Resolve anchors against Git, track coverage, build the ordered `Document` model. |48 | `src/html.rs` | Emit a single self-contained HTML file with inline CSS. |49 @@ -52,11 +52,11 @@52 These are the engineering form of the spec's guiding principles. Any change to53 the render pipeline must preserve all of them:54 -1. **Completeness.** Every diff hunk of the commit appears **at least once** in-   the output. Coverage is tracked **per hunk** — and **per line** within a hunk-   for a ranged `!new` anchor — never per file. The backfill exists solely to-   enforce this: a partially-shown hunk is split at the anchor boundary so its-   uncovered remainder is still emitted.55+1. **Completeness.** Every diff hunk of the commit — and every line within it,56+   **deletions included** — appears **at least once** in the output. Coverage is57+   tracked **per hunk**, never per file, and a hunk counts as covered only when an58+   anchor shows it *in full*. The backfill enforces this: any hunk not shown in59+   full is re-emitted **whole**, even if an anchor already showed a clip of it.60 2. **Repetition is allowed.** A hunk may appear both as an authored callout and61    again in the backfill. Do *not* add "shown above" markers or dedupe the62    backfill copy — completeness wins over tidiness.@@ -79,11 +79,14 @@79 - **Lead-in** — prose placed immediately *before* an anchor; it prepares the reader for80   the code that follows (explanation first, then code).81 - **Hunk** — a contiguous changed region as Git emits it; the unit of coverage.-- **Coverage** — which hunks (and, for a ranged `!new` anchor, which lines within-  a hunk) an anchor has marked "shown".-- **Backfill** — the trailing section: every not-yet-shown hunk (or the uncovered-  sub-hunks split from a partially-shown one), in natural order.-- **Side** — where an anchor's code comes from: `new` (default), `old`, `diff`.82+- **Coverage** — which hunks an anchor has shown **in full** (the only ones the83+  backfill skips). Tracked per hunk.84+- **Clip** — the diff slice a ranged anchor shows (`Hunk::clip_to_new_range`); it85+  does not mark the hunk full, so the whole hunk still backfills.86+- **Backfill** — the trailing section: every hunk not shown in full, emitted87+  **whole**, in natural order (repetition is fine).88+- **Context** — code shown *outside* the diff (an unchanged file or range),89+  rendered as plain lines labeled "unmodified"; marks no hunks.90 91 ## Conventions in this repo92 
docs/ARCHITECTURE.md
@@ -41,8 +41,8 @@41 | Module | Role |42 |--------|------|43 | `cli` | Parse `throughline render <commit>` and `throughline branch <range>` (clap, derive). |-| `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read blob lines by side. |-| `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus, side }`. Pure, no I/O. |44+| `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read a file's lines at the commit (for context outside the diff). |45+| `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus }`. Pure, no I/O. |46 | `message` | Split a raw message into `title`, `tldr` (the first paragraph), `intro` (the lead-in to the first anchor), and an ordered `Vec<Block>` of prose/anchor blocks. Pure, no I/O. |47 | `conventional` | Conventional Commits interop: parse the `type(scope)!: subject` header and pass footer trailers through untouched. |48 | `diff` | The diff data model (`Diff` → `FileDiff` → `Hunk` → `DiffLine`) and the per-hunk `Coverage` tracker. |@@ -62,8 +62,7 @@62     first paragraph (the summary); `intro` is the remaining prose that leads into the first63     anchor.64   - `Block = Prose(String) | Anchor(AnchorRef)`-  - `AnchorRef { path, range: Option<LineRange>, focus: Option<LineRange>, side: Side }`-  - `Side = New | Old | Diff` (default `New`)65+  - `AnchorRef { path, range: Option<LineRange>, focus: Option<LineRange> }`66 - **Diff** — produced by `git::Repo::diff_against_parent`:67   - `Diff { files: Vec<FileDiff> }` in natural order (alphabetical by path)68   - `FileDiff { path, hunks: Vec<Hunk>, marker: Option<DeltaMarker> }`@@ -75,7 +74,9 @@74   - `Document { title, sections: Vec<Section> }`75   - `Section = Tldr(String) | Prose(String) | Code(CodeBlock) | Backfill { path, hunk }76     | Marker { path, marker }` — `Tldr` is the lede; `Prose` is a lead-in (or the intro).-  - `CodeBlock { path, lines: Vec<CodeLine>, degraded, note }`77+  - `CodeBlock { path, lines: Vec<CodeLine>, degraded, context, note }` — `context` is78+    `true` for code shown outside the diff (an unchanged file or range), rendered plain79+    and labeled "unmodified".80   - `CodeLine { origin, content, focus: bool, number: Option<u32> }` — focus and81     new-side line number are resolved per line here, where the line numbers are82     known, so the emitter stays a dumb formatter. `origin` is `'+'`/`'-'`/`' '`@@ -96,52 +97,55 @@97    intro (as a `Section::Prose`).98 4. Walk `msg.body` in order. For each block:99    - `Block::Prose(p)` → push `Section::Prose(p)` (a lead-in to the anchor that follows).-   - `Block::Anchor(a)` → resolve it:-     - Determine the lines to show from `a.side` (`New`/`Old` read a blob; `Diff`-       shows the covering hunks).-     - Mark what the anchor shows of every hunk it overlaps on the new side: a-       whole-file or `Diff` anchor calls `coverage.mark_full`; a ranged `New`-       anchor calls `coverage.mark_new_range` with just its new-side interval.-       Coverage is **per hunk**, and within a hunk **per line** for ranged `New`.-     - Push `Section::Code(CodeBlock { … })`. If resolution fails, push a-       degraded `CodeBlock` (`degraded = true`, a `note`) and mark **nothing**.-5. **Backfill**: iterate `diff` in natural order. For every `(file, hunk)`:-   skip it if `coverage.is_full`; emit it whole if untouched; otherwise split it-   with `Hunk::uncovered_subhunks` against its covered intervals and emit a-   `Section::Backfill` for each uncovered sub-hunk. After a file's hunks the loop-   emits its `Section::Marker` when `FileDiff::marker` is set — a hunk-less100+   - `Block::Anchor(a)` → resolve it (`resolve_anchor`):101+     - If the anchor's path/range overlaps changed hunks, show the diff — every102+       hunk of the file (whole-file anchor), or the clip the range selects103+       (ranged anchor, via `Hunk::clip_to_new_range`). Otherwise the code is not104+       in the diff (a whole unchanged file, or an unchanged range), so read it at105+       the commit and show those lines as plain context (`context = true`).106+     - Mark a hunk shown *in full*: `coverage.mark_full` for each hunk of a107+       whole-file anchor, and for a ranged anchor's hunk only when the range spans108+       its whole new side (`Hunk::contained_in_new_range`). A clip that did not109+       cover its hunk marks **nothing**, so the backfill re-emits the hunk whole.110+     - Push `Section::Code(CodeBlock { … })`. If resolution fails (missing path,111+       out-of-bounds range), push a degraded `CodeBlock` and mark **nothing**.112+5. **Backfill**: iterate `diff` in natural order. For every `(file, hunk)`: skip113+   it if `coverage.is_full`, else emit it **whole** as a `Section::Backfill` —114+   untouched hunks and partially-clipped hunks alike. After a file's hunks the115+   loop emits its `Section::Marker` when `FileDiff::marker` is set — a hunk-less116    delta's sole row (binary, mode-only, empty add/delete) or a `Mode` change117    riding alongside content hunks — keeping those facts visible (completeness at118    the *delta* level).119 -Because step 5 visits every hunk and emits whatever was not shown — the whole-hunk, or just its uncovered sub-hunks — plus a marker for every delta-level fact-a hunk can't carry, the union of authored callouts and backfill always covers the-whole diff at line granularity: the completeness invariant. The implementation never repeats a line-(a covered line is split out of the backfill), but the model permits it — a hunk-may be shown by *multiple* anchors, and the spec allows a hunk to recur; that is-fine and intended.--### Coverage is per hunk, and per line within a hunk--`diff::Coverage` maps `(file_index, hunk_index)` to what an anchor has shown of-that hunk: a `full` flag (a whole-file or `Diff` anchor displays the hunk whole)-or a list of covered new-side intervals (a ranged `New` anchor). An anchor never-marks a *file*; it marks exactly the hunks its range overlaps (`Hunk::overlaps_new`),-so a file annotated only in part leaves its other hunks for the backfill.--For a ranged `New` anchor the granularity is finer still: it shows only the-new-side lines in its range, so `Hunk::uncovered_subhunks` splits the hunk at-the anchor boundary and the backfill emits the uncovered runs as renumbered-sub-hunks. A `-` line, having no new-side number, travels with the run it borders-so no deletion is dropped. This closes the gap where a narrow `!new` anchor over-a large hunk (a new file, a big rewrite) used to suppress the whole hunk.120+Because step 5 emits every hunk not shown in full — whole — plus a marker for121+every delta-level fact a hunk can't carry, the union of authored callouts and122+backfill always covers the whole diff, every line (including deletions) and123+delta. A hunk an anchor only clipped is repeated whole in the backfill: the model124+allows — and here relies on — repetition rather than splitting out the unshown125+remainder. That keeps the mechanism simple and the guarantee airtight.126+127+### Coverage is per hunk128+129+`diff::Coverage` is a set of `(file_index, hunk_index)` pairs an anchor has shown130+**in full**. An anchor never marks a *file*; it marks exactly the hunks it shows131+whole, so a file annotated only in part leaves its other hunks for the backfill.132+133+A whole-file anchor shows — and marks — every hunk of the file. A ranged anchor134+shows the clip its range selects (`Hunk::clip_to_new_range`, which carries a135+bordering `-` line along so deletions render in the clip too) and marks the hunk136+full only when the range spans its whole new side (`Hunk::contained_in_new_range`).137+A clip narrower than its hunk marks nothing, so the backfill re-emits that hunk138+**whole** — repetition that guarantees every line, deletions included, appears.139+This is simpler than splitting out the unshown remainder, and it is what closes140+the gap where a whole-file or wide anchor over a modified file used to drop the141+deleted lines a new-side-only callout never showed.142 143 ### Degrade, don't fail144 -Anchor resolution returns a `CodeBlock` even on failure: out-of-bounds range,-missing path on the requested side, or a diff that doesn't cover the range all-produce `degraded = true` with an explanatory `note`, and mark no hunks shown.145+Anchor resolution returns a `CodeBlock` even on failure: a path missing at the146+commit, or an out-of-bounds range when reading a file as context, produce147+`degraded = true` with an explanatory `note`, and mark no hunks shown. (A range148+that simply isn't in the diff is not a failure — it falls back to plain context.)149 `render::render` itself only returns `Err` for whole-repo failures (bad150 revision, Git errors) — never for a single bad anchor.151 @@ -200,34 +204,35 @@204   and encode the format. The spec's worked example and its list of valid anchors205   are mirrored directly as test cases.206 - **`diff`** unit-tests the pure pieces: hunk/range overlap, coverage marking,-  `DeltaMarker` phrasing, and `Hunk::uncovered_subhunks` splitting.207+  `DeltaMarker` phrasing, `Hunk::contained_in_new_range`, and208+  `Hunk::clip_to_new_range`.209 - **Integration tests** (`tests/`) build a small fixture repository and assert210   end-to-end properties — above all the **completeness invariant**: for any-  commit and any set of anchors, every hunk of the diff appears at least once in-  the rendered `Document` (`tests/completeness.rs`), every hunk-*less* delta-  appears as a marker row (`tests/hunkless.rs`), a mode change riding alongside-  content hunks still surfaces its marker (`tests/mode_change.rs`), and a ranged-  `!new` anchor over a large hunk backfills its uncovered remainder as sub-hunks-  so completeness holds per *line* (`tests/subhunk.rs`).211+  commit and any set of anchors, every hunk (and every added *and deleted* line)212+  appears at least once in the rendered `Document` (`tests/completeness.rs`),213+  every hunk-*less* delta appears as a marker row (`tests/hunkless.rs`), a mode214+  change riding alongside content hunks still surfaces its marker215+  (`tests/mode_change.rs`), a ranged anchor shows a clip while the whole hunk is216+  repeated in the backfill (`tests/ranged_anchor.rs`), and code referenced217+  outside the diff renders as plain "unmodified" context (`tests/context_anchor.rs`).218 219 ## Status & roadmap220 221 Implemented: the full pipeline. The CLI surface, the `anchor` grammar and222 `message::parse`, `git::diff_against_parent` / `blob_lines`, the `render`-orchestration (anchor resolution, per-hunk coverage with ranged-`!new` sub-hunk-splitting, backfill, and delta-level markers), and the `html` emitter — with-the completeness invariant covered by integration tests (`tests/completeness.rs`,-`tests/hunkless.rs`, `tests/mode_change.rs`, `tests/subhunk.rs`). The `branch`-view composes a commit range into one navigable page on top of that pipeline-(`tests/branch.rs`).223+orchestration (always-diff anchor resolution, per-hunk coverage, whole-hunk224+backfill, and delta-level markers), and the `html` emitter — with the225+completeness invariant covered by integration tests (`tests/completeness.rs`,226+`tests/hunkless.rs`, `tests/mode_change.rs`, `tests/ranged_anchor.rs`,227+`tests/context_anchor.rs`). The `branch` view composes a commit range into one228+navigable page on top of that pipeline (`tests/branch.rs`).229 230 Stubbed: `conventional::Header::parse` (returns `None`) and footer-trailer231 splitting in `message::parse` (`trailers` is empty); both fall through harmlessly232 today.233 234 See [`ROADMAP.md`](ROADMAP.md) for the prioritized plan: completeness hardening-(hunk-less deltas, mode-beside-content markers, and sub-hunk splitting done),-Conventional Commits interop,-notation interop (accept the `#L12-L38` fragment and Markdown-link anchor forms),-and the spec's later items (interactive UI, content-based anchoring, non-HTML-export).235+(hunk-less deltas, mode-beside-content markers done; the always-diff redesign236+closed the deletion holes), Conventional Commits interop, notation interop237+(accept the `#L12-L38` fragment and Markdown-link anchor forms), and the spec's238+later items (interactive UI, content-based anchoring, non-HTML export).
docs/ROADMAP.md
@@ -14,7 +14,9 @@14 - [x] `git::diff_against_parent` — tree-to-parent diff lowered into the model via15   `git2::Patch`, natural order (alphabetical files, top-to-bottom hunks); root16   commits diff against the empty tree.-- [x] `git::blob_lines` — read a file by side (`new`@commit / `old`@parent).17+- [x] `git::blob_lines` — read a file's lines at the commit. *(Originally took a18+  `new`/`old` side; the side parameter was removed in the always-diff redesign — it now19+  reads the commit's tree, for showing context outside the diff.)*20 - [x] `render::render` — intro, body in message order, anchor resolution, per-hunk21   coverage, and backfill of every unmarked hunk.22 - [x] `html::to_html` — self-contained page (inline CSS, no JS): prose via@@ -46,38 +48,37 @@48   paths; guarded by `tests/mode_change.rs`.)*49 - [x] **Sub-hunk splitting** (the spec's documented v2). A ranged `!new` anchor over50   a large single hunk used to mark the *whole* hunk shown, so the un-annotated-  remainder was not backfilled — most visible on new files and big rewrites (e.g. a-  root commit, where each file is one hunk). A ranged `!new` anchor now marks only its-  new-side interval; `diff::Coverage` tracks covered intervals per hunk and-  `Hunk::uncovered_subhunks` splits the hunk at the anchor boundary, so the backfill-  emits the uncovered runs as renumbered sub-hunks (deletions travel with the run they-  border). Whole-file and `!diff` anchors still mark a hunk whole. Completeness now-  holds per *line*, not just per hunk. *(`diff.rs` model + `render.rs` coverage;-  guarded by `tests/subhunk.rs`.)*--## Next — completeness: a whole-file `!new` anchor drops deletions--A hole in the one hard guarantee, surfaced by a `throughline branch` render of doc-commits. A **bare** anchor (`path`, no `!side`) defaults to `!new`, which shows the-*post-change* blob as plain context; a **whole-file** `!new` anchor (no range) then-calls `Coverage::mark_full`, so the hunk never backfills. But a `!new` callout shows-only the new side — so any **deleted** lines in that hunk appear **nowhere**.-Verified on a real commit (its removed line was absent from the page);-`tests/completeness.rs` misses it because it only checks `+` lines.--- [ ] **Correctness — deletions must survive a `!new` anchor.** A whole-file `!new`-  anchor must not `mark_full` a hunk that has deletions; the `-` lines have to reach-  the backfill. (The ranged-`!new` path keeps deletions that border an *uncovered*-  run via `Hunk::uncovered_subhunks`; the whole-file path's `mark_full` leaves no-  remainder at all.) Then **extend `tests/completeness.rs` to assert every `-` line-  appears**, not only `+`. *(`render.rs` `resolve_blob`; mechanism in51+  remainder was not backfilled. The first fix split the hunk at the anchor boundary52+  (`Hunk::uncovered_subhunks` + per-line coverage) and backfilled the uncovered runs.53+  **Superseded by the always-diff redesign below**, which removed sub-hunk splitting54+  and per-line coverage entirely: a ranged anchor now shows a clip and the *whole* hunk55+  is repeated in the backfill (simpler, and it closed a deletion hole the split path56+  still had).57+58+## Done — anchors are always a diff (the redesign that closed the deletion holes)59+60+A whole-file `!new` anchor used to drop deletions: a `!new` callout showed only the61+*post-change* blob, so a modified file's `-` lines reached neither the callout (new62+side only) nor the backfill (`mark_full` left no remainder). The ranged-`!new` path63+hid the same root cause. Rather than patch `!new`, the whole `!side` selector was64+removed — every anchor now renders as a **diff**, so deletions are always on the page65+and the bug class is gone. This also resolved the UX concern (a bare anchor no longer66+renders as flat, uncolored text — it shows the diff with `+`/`-` and hunk headers).67+68+- [x] **Anchors always render as a diff** (old + new lines together). The grammar69+  dropped `!new`/`!old`/`!diff` (a legacy trailing token is accepted and ignored), and70+  `git`/`render`/`diff` collapsed to one resolution path. *(spec bumped to v0.4;71+  `anchor.rs` + `git.rs` + `diff.rs` + `render.rs` + `html.rs`.)*72+- [x] **Reference code outside the diff** — a whole unchanged file, or an unchanged73+  range — renders as plain code labeled "unmodified", the one place non-diff code74+  appears; it marks no hunks, so the real diff still backfills. *(guarded by75+  `tests/context_anchor.rs`.)*76+- [x] **Completeness by whole-hunk repeat.** A ranged anchor shows a *clip*77+  (`Hunk::clip_to_new_range`); a hunk it did not cover in full is re-emitted **whole**78+  in the backfill. Sub-hunk splitting and per-line coverage were deleted. *(guarded by79+  `tests/completeness.rs` — which now asserts every `-` line, not just `+` — and80+  `tests/ranged_anchor.rs`; mechanism in81   [`coverage-and-backfill.md`](wiki/field-notes/coverage-and-backfill.md).)*-- [ ] **UX — a `!new` callout gives no sign of what changed.** Even once deletions-  survive, a bare-anchor commit still renders as flat, uncolored new-file text — no-  `+`/`-`, no hunk headers — which surprises a reader expecting a diff. Decide: mark-  added lines inside `!new` callouts, default bare anchors to `!diff`, or treat it as-  authoring guidance in [`llm-commit-guide.md`](llm-commit-guide.md) /-  [`SPEC.md`](SPEC.md).82 83 ## Done — reading model: TL;DR + lead-ins84 @@ -139,9 +140,8 @@140   line is *already* a valid, clickable permalink on GitHub/GitLab and in many141   editors, so it degrades perfectly: a plain Markdown renderer shows a working link;142   Throughline injects the real code. Keep the terse bare-line form for authoring.-- [ ] **Document the lineage in [`SPEC.md`](SPEC.md)** — call out that `!old`/`!new`-  is the same notion as a review comment's *side* (`LEFT`/`RIGHT`), and that the-  hunk header we emit is the standard unified-diff `@@ -a,b +c,d @@` coordinate.143+- [ ] **Document the lineage in [`SPEC.md`](SPEC.md)** — call out that the hunk header144+  we emit is the standard unified-diff `@@ -a,b +c,d @@` coordinate.145 146 Prior art the grammar already leans on (for reference):147 @@ -149,12 +149,11 @@149 |---|---|---|150 | `path:12-38` | `file:line` / line range | compiler/`rustc`/`grep`/`rg`, editor "go to file:line" |151 | line ranges | `#L12-L38` URL fragment | GitHub & GitLab permalinks |-| `!old` / `!new` | `side: LEFT` / `RIGHT` | GitHub review-comment API |-| hunk display | `@@ -a,b +c,d @@` | unified diff (already emitted) |152+| hunk display | `@@ -a,b +c,d @@` | unified diff (always emitted — anchors are diffs) |153 | pulling real code by range into Markdown | `{{#include file:start:end}}` | mdBook include preprocessor |154 -`@focus` and `!side` have no forge-standard fragment spelling, so they remain a-Throughline extension regardless.155+`@focus` has no forge-standard fragment spelling, so it remains a Throughline156+extension regardless.157 158 ## Confidence & developer experience159 
docs/llm-commit-guide.md
@@ -17,15 +17,17 @@17   code already shown:18 19       <1-3 sentences leading into the code>-      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>] [!new|old|diff]20+      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>]21 -- The FULL diff is always shown regardless of what you annotate; you are choosing order-  and adding explanation, not selecting what to include. Anything you don't mention is-  appended automatically at the end in natural order.22+- Each reference shows the DIFF of the code it names (the +/- change). The FULL diff is23+  always shown regardless of what you annotate; you are choosing order and adding24+  explanation, not selecting what to include. Anything you don't mention is appended25+  automatically at the end in natural order.26 - Omit the range to show a file's whole change; repeat a path with narrow ranges to walk27   through it piece by piece. Both styles can mix.-- You may reference unchanged code (or !old) purely to give context; the real diff still-  appears in full.28+- You may reference code that DIDN'T change — a whole untouched file, or an unchanged29+  range — purely to give context; it shows as plain "unmodified" code, and the real diff30+  still appears in full.31 - Order references in the order best for reading, not alphabetically.32 - Never paste code into the prose; just reference the lines — the renderer injects it.33 ```
docs/wiki/README.md
@@ -38,6 +38,9 @@38     — the completeness-critical core: how coverage and backfill keep the guarantee.39   - [`field-notes/reading-model.md`](field-notes/reading-model.md)40     — how a message maps to the page: TL;DR, lead-ins, and why order is convention.41+  - [`field-notes/html-output.md`](field-notes/html-output.md)42+    — how `html.rs` shapes the page, and the two gotchas (line-packing, CSS class43+    names) that mislead you when you grep or split rendered output.44 - **How-to** — acting on this codebase.45   - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of46     commits as one navigable page (`throughline branch`); the input forms and the
docs/wiki/field-notes/coverage-and-backfill.md
@@ -2,88 +2,98 @@2 3 Read this before you touch anything that decides *what gets shown*. Coverage and the4 backfill are the machinery behind the project's one hard guarantee —-**completeness**: every diff hunk appears at least once. It is the easiest thing in-the codebase to break without noticing, because a regression here doesn't crash; it-silently *drops* a hunk. [`ARCHITECTURE.md`](../../ARCHITECTURE.md#the-render-algorithm)-has the design; this page is the "mind the gap" companion for editing it.5+**completeness**: every diff hunk (and every line within it, deletions included) appears6+at least once. It is the easiest thing in the codebase to break without noticing, because7+a regression here doesn't crash; it silently *drops* a line.8+[`ARCHITECTURE.md`](../../ARCHITECTURE.md#the-render-algorithm) has the design; this page9+is the "mind the gap" companion for editing it.10 11 ## The mental model12 -An anchor doesn't choose what to *hide*; it chooses what to show *early*, and marks-those lines covered so the backfill won't repeat them. Everything unmarked is emitted-at the end in natural order. So completeness is automatic *as long as marking and-backfill stay in agreement about what "covered" means.* That agreement is the whole-ballgame.13+Every anchor renders as a **diff** — the changed region with its `+`/`-` lines. (The one14+exception: code that isn't in the diff at all renders as plain *context*; see below.) An15+anchor doesn't choose what to *hide*; it chooses what to show *early*, and marks a hunk16+covered **only when it shows that hunk in full**. Everything not shown in full is re-emitted17+**whole** in the backfill, in natural order. So completeness is automatic: the backfill18+always re-shows the entire hunk, so no line can go missing.19 -Coverage is tracked at two granularities (`Coverage` in `src/diff.rs`):20+That last point is the whole ballgame, and it's deliberately *blunt*. We do **not** split a21+hunk to emit only the part an anchor didn't show. A hunk an anchor merely clipped is22+repeated whole — even though that duplicates the clipped lines. Repetition is the price of a23+guarantee that can't leak.24 -- **Per hunk.** `Coverage` keys on `(file_index, hunk_index)` — never on a file. A-  file annotated in part leaves its other hunks for the backfill.-- **Per line within a hunk**, for a ranged `!new` anchor. The covered new-side-  intervals are stored per hunk so a narrow anchor over a big hunk doesn't suppress-  the rest.25+Coverage is tracked **per hunk** (`Coverage` in `src/diff.rs`): a `HashSet` of26+`(file_index, hunk_index)` pairs shown in full. Never per file — a file annotated in part27+leaves its other hunks for the backfill.28 -A hunk is marked one of two ways, by anchor *side* (`resolve_blob` / `resolve_diff`-in `src/render.rs`):29+A hunk gets marked full in exactly two cases (`resolve_anchor` in `src/render.rs`):30 -| Anchor | Call | Effect |-|--------|------|--------|-| `!diff` (whole hunk) | `Coverage::mark_full` | the hunk is shown whole — `+`, `-`, and context; backfill skips it |-| whole-file `!new` (no range) | `Coverage::mark_full` | ⚠️ marks the hunk whole, but the callout shows only the **new side**, so its **deleted** lines are dropped — a known completeness hole ([ROADMAP](../../ROADMAP.md)) |-| ranged `!new` | `Coverage::mark_new_range` | only that new-side interval is covered; the remainder is backfilled |-| `!old` | *nothing* | old-side context only; the real diff still backfills in full |31+| Anchor | Shows | Marks full? |32+|--------|-------|-------------|33+| whole-file (no range) | every hunk of the file, as a diff | yes — every hunk |34+| ranged, spanning a hunk's whole new side | the hunk, as a diff | yes — that hunk (`Hunk::contained_in_new_range`) |35+| ranged, narrower than the hunk | a **clip** of the range (`Hunk::clip_to_new_range`) | **no** — the whole hunk backfills |36+| path/range **not in the diff** | the file's lines as plain context (`context = true`) | no — marks nothing |37+38+## The clip39+40+A ranged anchor inside a larger hunk shows just `Hunk::clip_to_new_range(start, end)` — the41+contiguous run of lines whose new-side position is in `[start, end]`, renumbered to its real42+offset. A `-` line has no new-side number, so it travels with the new-side position it sits43+*before* (clamped to the last new line when it trails the hunk), so deletions render inside44+the clip too. The clip is *display only*: it never affects the backfill, which always works45+from the original hunk.46 47 ## The backfill48 -The loop in `render::render` (`src/render.rs`, the step after the body) visits every-`(file, hunk)` in natural order and, per hunk:--1. `Coverage::is_full` → skip (already shown whole).-2. untouched → emit the hunk verbatim as `Section::Backfill`.-3. partially covered → `Hunk::uncovered_subhunks(covered)` splits it at the anchor-   boundary and emits each uncovered run as a renumbered sub-hunk.--Then, after a file's hunks, it emits the file's `DeltaMarker` if set — see below.--## The two gotchas that will bite you--1. **Deletions have no new-side line number.** Coverage and splitting reason in-   new-side coordinates, but a `-` line has no new-side number to compare. The rule-   (`Hunk::uncovered_subhunks`): **a deletion travels with the run it borders**, so it-   is never orphaned and never dropped. If you rewrite the splitter, this is the case-   the tests check hardest — a `-` line must land in exactly one emitted run.-2. **Some facts have no hunk to mark or backfill.** A binary file, a mode-only-   change, or an empty-file add/delete carries no text hunk at all; a mode change-   *beside* content has hunks for the edit but none for the bit it flips. The per-hunk-   machinery never sees these, so completeness for them holds at the *delta* level-   instead: `FileDiff::marker` carries a `DeltaMarker` and the backfill emits a-   one-line marker row for it, after the file's hunks. The marker is gated on the-   delta being hunk-less *only* for binary/empty; a `Mode` marker is attached whether-   or not hunks accompany it. If you add a new delta-level fact a hunk can't carry, it-   must get a marker or it vanishes from the page.-3. **A whole-file `!new` anchor currently drops deletions.** `mark_full` assumes the-   hunk was shown whole, but a `!new` callout shows only the new side — so a modified-   file's `-` lines reach neither the callout nor the backfill. The guards miss it-   because they only assert `+` lines appear. Tracked in-   [`ROADMAP.md`](../../ROADMAP.md); when you fix it, add the `-`-line assertion to-   `tests/completeness.rs`.49+The loop in `render::render` (`src/render.rs`, after the body) visits every `(file, hunk)`50+in natural order and, per hunk: skip it if `Coverage::is_full`, else emit it **whole** as a51+`Section::Backfill`. That's it — no splitting, no intervals. Then, after a file's hunks, it52+emits the file's `DeltaMarker` if set (see gotcha 3).53+54+## The gotchas that will bite you55+56+1. **Repetition is the mechanism, not a wart.** It is tempting to "optimize" the backfill to57+   emit only the lines a clip didn't already show. Don't. Splitting out the remainder is58+   exactly what this redesign *deleted* (it carried a deletion-drop bug: a clip showing only59+   the new side could strand a `-` line so it appeared nowhere). Whole-hunk repeat is what60+   makes the guarantee airtight. If duplication ever needs trimming, do it without61+   reintroducing per-line coverage — and re-run every guard below.62+2. **Deletions live in the diff, so they can't be dropped — keep it that way.** Both the63+   callout (a clip or a whole-hunk diff) and the backfill carry `-` lines. There is no64+   "new-side-only" rendering of a *changed* hunk anymore. If you add a code path that shows a65+   changed hunk without its `-` lines, you have re-opened the original hole.66+3. **Some facts have no hunk at all.** A binary file, a mode-only change, or an empty-file67+   add/delete carries no text hunk; a mode change *beside* content has hunks for the edit but68+   none for the bit it flips. The per-hunk machinery never sees these, so completeness for69+   them holds at the *delta* level: `FileDiff::marker` carries a `DeltaMarker` and the70+   backfill emits a one-line marker row after the file's hunks. The `Mode` marker rides71+   alongside content hunks; binary/empty markers are the delta's sole representation. Add a72+   new delta-level fact a hunk can't carry, and it must get a marker or it vanishes.73+4. **Out-of-diff context marks nothing.** An anchor pointing at code the commit didn't touch74+   (a whole unchanged file, or an unchanged range) has no hunk to mark; it reads the blob at75+   the commit, renders plain lines with `context = true` (the emitter labels it76+   "unmodified"), and the real diff still backfills in full.77 78 ## Before you change coverage — run the guards79 -These integration tests *are* the completeness invariant. Run all four after any-change to `Coverage`, `uncovered_subhunks`, the marking logic, or the backfill loop:80+These integration tests *are* the completeness invariant. Run all of them after any change81+to `Coverage`, `Hunk::clip_to_new_range` / `contained_in_new_range`, the marking logic, or82+the backfill loop:83 84 | Test file | What it pins down |85 |-----------|-------------------|-| `tests/completeness.rs` | `every_hunk_appears_at_least_once`; `coverage_is_per_hunk_and_backfill_collects_the_rest`. |-| `tests/subhunk.rs` | ranged `!new` splits its hunk and backfills the remainder; **every added line survives**; `!diff`/untouched anchors are *not* split. |86+| `tests/completeness.rs` | every hunk appears, and every added **and deleted** line appears — in a diff callout or the backfill. |87+| `tests/ranged_anchor.rs` | a ranged anchor shows a clip; a partial range repeats the **whole** hunk in the backfill; a full-span range is not repeated. |88+| `tests/context_anchor.rs` | code referenced outside the diff renders as plain "unmodified" context and marks nothing. |89 | `tests/hunkless.rs` | every hunk-less delta keeps a marker and appears in the document. |90 | `tests/mode_change.rs` | a mode change *beside* content hunks still surfaces its marker; a content-only delta gets none. |91 92 ```sh-cargo test --test completeness --test subhunk --test hunkless --test mode_change93+cargo test --test completeness --test ranged_anchor --test context_anchor \94+           --test hunkless --test mode_change95 ```96 -If you are adding a new way for an anchor to mark coverage, add a fixture that proves-the *unmarked remainder still appears* — that is the property that protects the-guarantee, and it is exactly the property a subtle bug here removes.97+If you are adding a new way for an anchor to mark coverage, add a fixture that proves the98+*unmarked remainder still appears whole* — that is the property that protects the guarantee,99+and it is exactly the property a subtle bug here removes.
docs/wiki/field-notes/git-lowering.md
@@ -102,10 +102,11 @@102   (`=` / `>` / `<`) are skipped rather than treated as code (the line loop in103   `diff_against_parent`, `src/git.rs`). Downstream code never has to think about104   EOF markers.-- **`blob_lines` reads whole-file content by side, not the diff.**-  `src/git.rs:139` reads the new blob for `New`/`Diff` and the parent blob for-  `Old`, splitting on `lines()`. A root commit has no parent, so an `!old` anchor-  there degrades. Because a `!new` slice comes from the new blob, it can never-  contain a deleted line — deletions exist only inside hunks (this is why a `!new`-  anchor marks coverage but a deletion is reasoned about separately; see-  [coverage-and-backfill](coverage-and-backfill.md#the-two-gotchas-that-will-bite-you)).105+- **`blob_lines` reads whole-file content at the commit, not the diff.**106+  `Repo::blob_lines` (`src/git.rs`) reads the file from the commit's own tree and107+  splits on `lines()`. It is used only to show code that is *not* part of the diff108+  — a whole unchanged file, or an unchanged range — as plain "unmodified" context;109+  everything that changed comes from the lowered hunks instead. (There is no longer110+  an old/new/diff *side*: anchors always render as a diff, and deletions live inside111+  the hunks, so they are never reasoned about separately — see112+  [coverage-and-backfill](coverage-and-backfill.md#the-mental-model).)
docs/wiki/field-notes/html-output.md
@@ -0,0 +1,68 @@1+# Field note: the HTML output2+3+How `src/html.rs` shapes the rendered page, and the two gotchas that will mislead you4+the moment you `grep` or split that page — whether you're writing an assertion in a5+test or eyeballing a render during verification.6+7+## Shape of the page8+9+`to_html` (`src/html.rs:102`) emits `<h1>` then `document_body`; `document_body`10+(`src/html.rs:163`) walks the `Document`'s sections in order, then renders the backfill11+region (collected hunks and markers) after them. `page` (`src/html.rs:193`) wraps the12+body in the standalone chrome with the **inline** stylesheet (`STYLESHEET`,13+`src/html.rs:26`). The branch view, `to_html_branch` (`src/html.rs:113`), composes14+several documents through the same `document_body`, so a chapter is a page minus its15+`<h1>`.16+17+Callouts come in four shapes, all from `code_html` (`src/html.rs:236`) and18+`backfill_html` (`src/html.rs:277`):19+20+- **diff callout** — `<figure class="callout">`, lines colored `ins`/`del` with a21+  `@@` header line (`meta`).22+- **context callout** — code shown outside the diff: the figcaption carries a23+  `<span class="tag">unmodified</span>` (`cb.context`, `src/html.rs:236`).24+- **degraded** — `<div class="callout degraded">` with a warning, no code.25+- **marker** — `<figure class="callout marker">`, a one-line `DeltaMarker` row.26+27+The "Remaining changes" backfill heading (`<h2 class="backfill-heading">`) is emitted28+**only when there is authored code above it** (`src/html.rs:184`); a plain commit with29+no anchors is just its full diff with no heading.30+31+## Gotcha 1: code lines are packed onto one physical line32+33+`line_html` (`src/html.rs:319`) emits each code line as a single34+`<span class="line …">…</span>` with **no trailing newline**, and `code_html` /35+`backfill_html` concatenate them. So an entire callout — every `+`/`-`/context line —36+is one physical line of HTML.37+38+Consequence: `grep -c 'class="line del"'` counts **callouts that contain a deletion**,39+not deletions. It will wildly undercount. To count occurrences, count substrings:40+41+```sh42+grep -o 'class="line del"' page.html | wc -l   # actual deletions43+```44+45+## Gotcha 2: class names recur in the inline CSS46+47+The stylesheet is inlined in `<head>` and names the very classes you'd grep for —48+`backfill-heading`, `callout`, `tag`, `line del`, … (`src/html.rs:26`). So splitting the49+page into "authored vs. backfill" on the bare string `backfill-heading` lands on the CSS50+rule `h2.backfill-heading { … }` (`src/html.rs:33`) in the `<head>`, **before any51+callout** — making the authored region look empty. Split on the element instead:52+53+```sh54+# everything before this is authored; everything after is the backfill region55+csplit page.html '/<h2 class="backfill-heading">/'56+```57+58+…and remember the heading is absent when there's no authored content (Gotcha above), so59+handle the "no split point" case.60+61+## Doing it right: assert through the parsed model when you can62+63+The integration tests assert on the HTML with plain `page.contains("…")` for *presence*64+(robust to packing) and reach into the `Document` model for *structure* — see65+`tests/ranged_anchor.rs` (`html_renders_the_clip_and_the_repeated_whole_hunk`),66+`tests/context_anchor.rs` (the "unmodified" label), `tests/hunkless.rs`, and67+`tests/mode_change.rs`. Prefer asserting on `render::Document` sections for structure and68+on the page only for what's genuinely HTML-level.
src/message.rs
@@ -156,7 +156,7 @@156 src/auth/validator.rs:12-38 @20-24157 The extracted validator.158 -src/auth/middleware.rs !diff159+src/auth/middleware.rs160 The middleware now delegates.161 ";162         let m = parse(raw);@@ -185,7 +185,7 @@185 src/auth/validator.rs:12-38186 187 The middleware now just delegates.-src/auth/middleware.rs !diff188+src/auth/middleware.rs189 ";190         let m = parse(raw);191         // The first paragraph is the TL;DR; the rest of the intro leads into the
tests/context_anchor.rs
@@ -0,0 +1,139 @@1+//! Anchoring code that is *not* part of the diff. An anchor may point at a whole2+//! file the commit never touched, or at an unchanged range of a file it did, to3+//! give the reader context. Such code has no hunk, so it renders as plain4+//! lines, is flagged `context` (the emitter labels it "unmodified"), and marks5+//! nothing — the commit's real diff still appears in full via the backfill.6+7+use std::fs;8+use std::path::Path;9+10+use commit_throughline::git::Repo;11+use commit_throughline::html;12+use commit_throughline::render::{self, CodeBlock, Document, Section};13+use git2::{IndexAddOption, Repository, Signature};14+use tempfile::TempDir;15+16+fn numbered(prefix: &str, n: usize) -> String {17+    (1..=n).map(|i| format!("{prefix}{i:02}\n")).collect()18+}19+20+fn write(dir: &Path, name: &str, content: &str) {21+    fs::write(dir.join(name), content).unwrap();22+}23+24+struct Fixture {25+    _dir: TempDir,26+    repo: Repo,27+    rev: String,28+}29+30+fn build_fixture() -> Fixture {31+    let dir = TempDir::new().unwrap();32+    let git = Repository::init(dir.path()).unwrap();33+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();34+35+    // --- v1 -------------------------------------------------------------36+    write(dir.path(), "changed.txt", &numbered("c", 20));37+    write(dir.path(), "stable.txt", &numbered("s", 5));38+    let mut index = git.index().unwrap();39+    index40+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)41+        .unwrap();42+    index.write().unwrap();43+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();44+    let parent = git45+        .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])46+        .unwrap();47+48+    // --- v2: change one line deep in changed.txt; stable.txt untouched --49+    let mut c: Vec<String> = numbered("c", 20).lines().map(str::to_string).collect();50+    c[9] = "CHANGED_10".to_string();51+    write(dir.path(), "changed.txt", &format!("{}\n", c.join("\n")));52+53+    let mut index = git.index().unwrap();54+    index.add_path(Path::new("changed.txt")).unwrap();55+    index.write().unwrap();56+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();57+    let parent_commit = git.find_commit(parent).unwrap();58+59+    // Reference a whole untouched file, and an unchanged range of the changed60+    // file (lines 1-3, well clear of the hunk at line 10) — both are context.61+    let message = "\62+Reference unchanged code for context63+64+Explaining a change with reference to code that did not change.65+66+stable.txt67+stable.txt is untouched by this commit; shown here only for context.68+69+changed.txt:1-370+The head of the changed file is unchanged; shown as plain context.71+";72+    let v2 = git73+        .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])74+        .unwrap();75+76+    Fixture {77+        repo: Repo::open(dir.path()).unwrap(),78+        rev: v2.to_string(),79+        _dir: dir,80+    }81+}82+83+fn code_block<'a>(doc: &'a Document, path: &str) -> Option<&'a CodeBlock> {84+    doc.sections.iter().find_map(|s| match s {85+        Section::Code(cb) if cb.path == path => Some(cb),86+        _ => None,87+    })88+}89+90+fn count_backfill(doc: &Document, path: &str) -> usize {91+    doc.sections92+        .iter()93+        .filter(|s| matches!(s, Section::Backfill { path: p, .. } if p == path))94+        .count()95+}96+97+#[test]98+fn a_file_outside_the_diff_renders_as_unmodified_context() {99+    let fx = build_fixture();100+    let doc = render::render(&fx.repo, &fx.rev).unwrap();101+102+    let cb = code_block(&doc, "stable.txt").expect("stable.txt callout");103+    assert!(!cb.degraded);104+    assert!(cb.context, "an out-of-diff file must be flagged as context");105+    // Its content is shown, plain (origin ' ', no diff markers).106+    let text: String = cb.lines.iter().map(|l| l.content.clone()).collect();107+    assert!(text.contains("s01") && text.contains("s05"));108+    assert!(cb.lines.iter().all(|l| l.origin == ' '));109+}110+111+#[test]112+fn an_unchanged_range_of_a_changed_file_is_context() {113+    let fx = build_fixture();114+    let doc = render::render(&fx.repo, &fx.rev).unwrap();115+116+    let cb = code_block(&doc, "changed.txt").expect("changed.txt callout");117+    assert!(cb.context, "an unchanged range overlaps no hunk -> context");118+    assert!(!cb.lines.iter().any(|l| l.origin == '+' || l.origin == '-'));119+}120+121+#[test]122+fn the_real_diff_still_backfills_in_full() {123+    // Neither context anchor marked a hunk, so the commit's actual change to124+    // changed.txt is preserved in the backfill.125+    let fx = build_fixture();126+    let doc = render::render(&fx.repo, &fx.rev).unwrap();127+    assert_eq!(count_backfill(&doc, "changed.txt"), 1);128+}129+130+#[test]131+fn the_page_labels_context_as_unmodified() {132+    let fx = build_fixture();133+    let doc = render::render(&fx.repo, &fx.rev).unwrap();134+    let page = html::to_html(&doc);135+    assert!(136+        page.contains("unmodified"),137+        "context callouts must be labeled"138+    );139+}
tests/grammar.rs
@@ -1,7 +1,7 @@1 //! Integration tests over the public parsing surface — the spec's worked2 //! example and the "every plain commit is valid" baseline.3 -use commit_throughline::anchor::{AnchorRef, Side};4+use commit_throughline::anchor::AnchorRef;5 use commit_throughline::message::{parse, Block};6 7 #[test]@@ -39,7 +39,10 @@39     assert_eq!(anchors.len(), 2);40     assert_eq!(anchors[0].path, "src/auth/validator.rs");41     assert_eq!(anchors[0].focus.expect("a focus range").start, 20);-    assert_eq!(anchors[1].side, Side::Diff);42+    // The trailing `!diff` is a legacy side token: the line is still an anchor43+    // for the whole file, and the selector is ignored.44+    assert_eq!(anchors[1].path, "src/auth/middleware.rs");45+    assert_eq!(anchors[1].range, None);46 }47 48 #[test]
tests/ranged_anchor.rs
@@ -0,0 +1,228 @@1+//! Ranged anchors and the whole-hunk-repeat rule. A ranged anchor shows a diff2+//! *clip* of the lines it names; if that did not cover the hunk in full, the3+//! **whole hunk is repeated** in the backfill — even though it duplicates the4+//! lines the clip already showed. This is how completeness holds at line5+//! granularity without sub-hunk splitting (the classic trigger is a brand-new6+//! file or a big rewrite, where the entire file is one hunk).7+//!8+//! The fixture adds a 12-line file and annotates only its middle, alongside a9+//! small rewrite whose range spans the whole hunk (so it is *not* repeated) and10+//! an untouched modification (backfilled verbatim).11+12+use std::fs;13+use std::path::Path;14+15+use commit_throughline::diff::Hunk;16+use commit_throughline::git::Repo;17+use commit_throughline::html;18+use commit_throughline::render::{self, Document, Section};19+use git2::{IndexAddOption, Repository, Signature};20+use tempfile::TempDir;21+22+fn numbered(prefix: &str, n: usize) -> String {23+    (1..=n).map(|i| format!("{prefix}{i:02}\n")).collect()24+}25+26+fn write(dir: &Path, name: &str, content: &str) {27+    fs::write(dir.join(name), content).unwrap();28+}29+30+struct Fixture {31+    _dir: TempDir,32+    repo: Repo,33+    rev: String,34+}35+36+fn build_fixture() -> Fixture {37+    let dir = TempDir::new().unwrap();38+    let git = Repository::init(dir.path()).unwrap();39+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();40+41+    // --- v1: the parent -------------------------------------------------42+    write(dir.path(), "keep.txt", &numbered("k", 10));43+    write(dir.path(), "rewrite.txt", "r1\nr2\nr3\n");44+    let mut index = git.index().unwrap();45+    index46+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)47+        .unwrap();48+    index.write().unwrap();49+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();50+    let parent = git51+        .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])52+        .unwrap();53+54+    // --- v2: add big.txt (one 12-line hunk), rewrite rewrite.txt whole,55+    //         tweak one line of keep.txt --------------------------------56+    write(dir.path(), "big.txt", &numbered("b", 12));57+    write(dir.path(), "rewrite.txt", "R1\nR2\nR3\n");58+    let mut keep: Vec<String> = numbered("k", 10).lines().map(str::to_string).collect();59+    keep[4] = "KEEP_CHANGED".to_string();60+    write(dir.path(), "keep.txt", &format!("{}\n", keep.join("\n")));61+62+    let mut index = git.index().unwrap();63+    index64+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)65+        .unwrap();66+    index.write().unwrap();67+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();68+    let parent_commit = git.find_commit(parent).unwrap();69+70+    // Show only the middle of big.txt (a partial range); rewrite.txt with a71+    // range that spans its whole hunk; leave keep.txt entirely to the backfill.72+    let message = "\73+Walk a new file in pieces74+75+A brand-new file shown only in part, plus a small rewrite shown whole.76+77+big.txt:5-878+The middle of the file; the whole hunk is repeated below in full.79+80+rewrite.txt:1-381+The rewrite, its range spanning the whole hunk — so it is not repeated.82+";83+    let v2 = git84+        .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])85+        .unwrap();86+87+    Fixture {88+        repo: Repo::open(dir.path()).unwrap(),89+        rev: v2.to_string(),90+        _dir: dir,91+    }92+}93+94+/// Concatenated content of every non-degraded code callout for `path`.95+fn code_text_for(doc: &Document, path: &str) -> String {96+    let mut text = String::new();97+    for section in &doc.sections {98+        if let Section::Code(cb) = section {99+            if cb.path == path && !cb.degraded {100+                for line in &cb.lines {101+                    text.push_str(&line.content);102+                    text.push('\n');103+                }104+            }105+        }106+    }107+    text108+}109+110+/// The backfilled hunks for `path`, in document order.111+fn backfill_hunks_for<'a>(doc: &'a Document, path: &str) -> Vec<&'a Hunk> {112+    doc.sections113+        .iter()114+        .filter_map(|s| match s {115+            Section::Backfill { path: p, hunk } if p == path => Some(hunk),116+            _ => None,117+        })118+        .collect()119+}120+121+fn has_code(doc: &Document, path: &str) -> bool {122+    doc.sections123+        .iter()124+        .any(|s| matches!(s, Section::Code(cb) if cb.path == path && !cb.degraded))125+}126+127+#[test]128+fn a_partial_range_shows_a_clip_and_repeats_the_whole_hunk() {129+    let fx = build_fixture();130+    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();131+    let doc = render::render(&fx.repo, &fx.rev).unwrap();132+133+    // big.txt is a brand-new file: a single hunk spanning all 12 new lines.134+    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();135+    assert_eq!(big.hunks.len(), 1, "a new file is one hunk");136+    assert_eq!((big.hunks[0].new_start, big.hunks[0].new_lines), (1, 12));137+138+    // The callout shows only the annotated middle (lines 5..=8).139+    let shown = code_text_for(&doc, "big.txt");140+    for i in 5..=8 {141+        assert!(142+            shown.contains(&format!("b{i:02}")),143+            "callout missing b{i:02}"144+        );145+    }146+    assert!(!shown.contains("b04") && !shown.contains("b09"));147+148+    // Because the range did not span the whole hunk, the hunk is repeated in the149+    // backfill *whole* — one hunk, identical to the original, lines and all.150+    let subs = backfill_hunks_for(&doc, "big.txt");151+    assert_eq!(152+        subs.len(),153+        1,154+        "the whole hunk is repeated as a single backfill"155+    );156+    assert_eq!(subs[0], &big.hunks[0], "the repeat is byte-identical");157+}158+159+#[test]160+fn every_line_of_the_partial_file_survives() {161+    // The hard guarantee at line granularity: every line of the diff appears at162+    // least once across the callout and the backfill.163+    let fx = build_fixture();164+    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();165+    let doc = render::render(&fx.repo, &fx.rev).unwrap();166+167+    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();168+    let mut corpus = code_text_for(&doc, "big.txt");169+    for hunk in backfill_hunks_for(&doc, "big.txt") {170+        for line in &hunk.lines {171+            corpus.push_str(&line.content);172+            corpus.push('\n');173+        }174+    }175+    for line in big.hunks.iter().flat_map(|h| &h.lines) {176+        assert!(177+            corpus.contains(&line.content),178+            "line `{}` vanished from big.txt",179+            line.content180+        );181+    }182+}183+184+#[test]185+fn a_full_span_range_is_not_repeated() {186+    let fx = build_fixture();187+    let doc = render::render(&fx.repo, &fx.rev).unwrap();188+189+    // `rewrite.txt:1-3` spans the whole hunk, so the hunk is marked full and is190+    // never backfilled — a range that covers a hunk entirely is not repeated.191+    assert!(has_code(&doc, "rewrite.txt"));192+    assert!(backfill_hunks_for(&doc, "rewrite.txt").is_empty());193+}194+195+#[test]196+fn an_untouched_hunk_is_backfilled_verbatim() {197+    let fx = build_fixture();198+    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();199+    let doc = render::render(&fx.repo, &fx.rev).unwrap();200+201+    // keep.txt has no anchor, so its hunk is backfilled byte-identical.202+    let keep = diff.files.iter().find(|f| f.path == "keep.txt").unwrap();203+    let backfill = backfill_hunks_for(&doc, "keep.txt");204+    assert_eq!(backfill.len(), keep.hunks.len());205+    assert_eq!(206+        backfill[0], &keep.hunks[0],207+        "untouched hunk must be identical"208+    );209+}210+211+#[test]212+fn html_renders_the_clip_and_the_repeated_whole_hunk() {213+    let fx = build_fixture();214+    let doc = render::render(&fx.repo, &fx.rev).unwrap();215+    let page = html::to_html(&doc);216+217+    // Every line of the partial file is present somewhere in the page.218+    for i in 1..=12 {219+        assert!(page.contains(&format!("b{i:02}")), "page missing b{i:02}");220+    }221+    // The clip emits its own `@@` header at the real offset; the repeated whole222+    // hunk emits the original header.223+    assert!(page.contains("@@ -0,0 +5,4 @@"), "missing the clip header");224+    assert!(225+        page.contains("@@ -0,0 +1,12 @@"),226+        "missing the whole-hunk header"227+    );228+}
tests/subhunk.rs
@@ -1,244 +0,0 @@-//! Sub-hunk splitting — a ranged `!new` anchor over a large hunk used to mark-//! the *whole* hunk shown, so the un-annotated remainder silently vanished from-//! the backfill (CLAUDE.md #1, at the *line* level). The classic trigger is a-//! brand-new file or a big rewrite, where the entire file is a single hunk.-//!-//! The fixture adds a 12-line file as one hunk and annotates only its middle-//! with `!new`, alongside a `!diff` rewrite (shown whole, never split) and an-//! untouched modification (backfilled verbatim). It then asserts the head and-//! tail of the split file reappear as backfilled sub-hunks, that every added-//! line survives, and that the non-split paths are unchanged.--use std::fs;-use std::path::Path;--use commit_throughline::diff::Hunk;-use commit_throughline::git::Repo;-use commit_throughline::html;-use commit_throughline::render::{self, Document, Section};-use git2::{IndexAddOption, Repository, Signature};-use tempfile::TempDir;--fn numbered(prefix: &str, n: usize) -> String {-    (1..=n).map(|i| format!("{prefix}{i:02}\n")).collect()-}--fn write(dir: &Path, name: &str, content: &str) {-    fs::write(dir.join(name), content).unwrap();-}--struct Fixture {-    _dir: TempDir,-    repo: Repo,-    rev: String,-}--fn build_fixture() -> Fixture {-    let dir = TempDir::new().unwrap();-    let git = Repository::init(dir.path()).unwrap();-    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();--    // --- v1: the parent --------------------------------------------------    write(dir.path(), "keep.txt", &numbered("k", 10));-    write(dir.path(), "rewrite.txt", "r1\nr2\nr3\n");-    let mut index = git.index().unwrap();-    index-        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)-        .unwrap();-    index.write().unwrap();-    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();-    let parent = git-        .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])-        .unwrap();--    // --- v2: add big.txt (one 12-line hunk), rewrite rewrite.txt whole,-    //         tweak one line of keep.txt ---------------------------------    write(dir.path(), "big.txt", &numbered("b", 12));-    write(dir.path(), "rewrite.txt", "R1\nR2\nR3\n");-    let mut keep: Vec<String> = numbered("k", 10).lines().map(str::to_string).collect();-    keep[4] = "KEEP_CHANGED".to_string();-    write(dir.path(), "keep.txt", &format!("{}\n", keep.join("\n")));--    let mut index = git.index().unwrap();-    index-        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)-        .unwrap();-    index.write().unwrap();-    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();-    let parent_commit = git.find_commit(parent).unwrap();--    // Show only the middle of big.txt with `!new`; rewrite.txt whole with-    // `!diff`; leave keep.txt entirely to the backfill.-    let message = "\-Walk a new file in pieces--A brand-new file shown only in part, to exercise sub-hunk splitting.--big.txt:5-8 !new-The middle of the file; its head and tail must still be backfilled.--rewrite.txt:2 !diff-A small rewrite shown whole as a diff — never split.-";-    let v2 = git-        .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])-        .unwrap();--    Fixture {-        repo: Repo::open(dir.path()).unwrap(),-        rev: v2.to_string(),-        _dir: dir,-    }-}--/// Concatenated content of every non-degraded code callout for `path`.-fn code_text_for(doc: &Document, path: &str) -> String {-    let mut text = String::new();-    for section in &doc.sections {-        if let Section::Code(cb) = section {-            if cb.path == path && !cb.degraded {-                for line in &cb.lines {-                    text.push_str(&line.content);-                    text.push('\n');-                }-            }-        }-    }-    text-}--/// The backfilled hunks for `path`, in document order.-fn backfill_hunks_for<'a>(doc: &'a Document, path: &str) -> Vec<&'a Hunk> {-    doc.sections-        .iter()-        .filter_map(|s| match s {-            Section::Backfill { path: p, hunk } if p == path => Some(hunk),-            _ => None,-        })-        .collect()-}--fn has_code(doc: &Document, path: &str) -> bool {-    doc.sections-        .iter()-        .any(|s| matches!(s, Section::Code(cb) if cb.path == path && !cb.degraded))-}--#[test]-fn a_ranged_new_anchor_splits_its_hunk_and_backfills_the_remainder() {-    let fx = build_fixture();-    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();--    // big.txt is a brand-new file: a single hunk spanning all 12 new lines.-    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();-    assert_eq!(big.hunks.len(), 1, "a new file is one hunk");-    assert_eq!((big.hunks[0].new_start, big.hunks[0].new_lines), (1, 12));--    // The callout shows only the annotated middle (lines 5..=8).-    let shown = code_text_for(&doc, "big.txt");-    for i in 5..=8 {-        assert!(-            shown.contains(&format!("b{i:02}")),-            "callout missing b{i:02}"-        );-    }-    assert!(!shown.contains("b04") && !shown.contains("b09"));--    // The head and tail come back as two split sub-hunks, renumbered to their-    // real new-side positions — this is the behavior that used to be missing.-    let subs = backfill_hunks_for(&doc, "big.txt");-    assert_eq!(-        subs.len(),-        2,-        "head and tail are backfilled as two sub-hunks"-    );-    assert_eq!((subs[0].new_start, subs[0].new_lines), (1, 4));-    assert_eq!((subs[1].new_start, subs[1].new_lines), (9, 4));--    // The split sub-hunks carry exactly the un-annotated lines, none of the-    // annotated middle (no duplication across callout and backfill here).-    let backfilled: String = subs-        .iter()-        .flat_map(|h| &h.lines)-        .map(|l| l.content.clone())-        .collect::<Vec<_>>()-        .join("\n");-    for i in (1..=4).chain(9..=12) {-        assert!(-            backfilled.contains(&format!("b{i:02}")),-            "backfill missing b{i:02}"-        );-    }-    for i in 5..=8 {-        assert!(-            !backfilled.contains(&format!("b{i:02}")),-            "b{i:02} leaked into backfill"-        );-    }-}--#[test]-fn every_added_line_of_the_split_hunk_survives() {-    // The hard guarantee at line granularity: each added line of the diff-    // appears at least once across the callout and the backfilled sub-hunks.-    let fx = build_fixture();-    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();--    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();-    let mut corpus = code_text_for(&doc, "big.txt");-    for hunk in backfill_hunks_for(&doc, "big.txt") {-        for line in &hunk.lines {-            corpus.push_str(&line.content);-            corpus.push('\n');-        }-    }-    for line in big.hunks.iter().flat_map(|h| &h.lines) {-        if line.origin == '+' {-            assert!(-                corpus.contains(&line.content),-                "added line `{}` vanished from big.txt",-                line.content-            );-        }-    }-}--#[test]-fn diff_and_untouched_anchors_are_not_split() {-    let fx = build_fixture();-    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();--    // `rewrite.txt:2 !diff` shows the whole hunk, so it is never backfilled —-    // a narrow range does not split a `!diff` callout.-    assert!(has_code(&doc, "rewrite.txt"));-    assert!(backfill_hunks_for(&doc, "rewrite.txt").is_empty());--    // keep.txt has no anchor, so its hunk is backfilled verbatim — the split-    // path must not perturb an untouched hunk.-    let keep = diff.files.iter().find(|f| f.path == "keep.txt").unwrap();-    let backfill = backfill_hunks_for(&doc, "keep.txt");-    assert_eq!(backfill.len(), keep.hunks.len());-    assert_eq!(-        backfill[0], &keep.hunks[0],-        "untouched hunk must be byte-identical"-    );-}--#[test]-fn html_renders_the_callout_and_both_split_sub_hunks() {-    let fx = build_fixture();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();-    let page = html::to_html(&doc);--    // Every line of the split file is present somewhere in the page: the middle-    // via the callout, the head and tail via the two backfilled sub-hunks.-    for i in 1..=12 {-        assert!(page.contains(&format!("b{i:02}")), "page missing b{i:02}");-    }-    // The two split sub-hunks emit their own `@@` headers at the real offsets.-    assert!(page.contains("@@ -0,0 +1,4 @@"));-    assert!(page.contains("@@ -0,0 +9,4 @@"));-}