@@ -44,6 +44,14 @@ type PatchHunk struct {
4444 // Added and Removed count what this hunk does, for the size limits.
4545 Added int
4646 Removed int
47+ // Anchor is file text this hunk is inside, used only to say which of
48+ // several identical places is meant. It is never applied and never
49+ // required to match; it narrows where Before is looked for and nothing
50+ // else.
51+ //
52+ // It comes from the two forms a patch uses to name a scope: a heading on
53+ // the "@@" line, and a preceding hunk of nothing but context.
54+ Anchor string
4755}
4856
4957// PatchOutcome is what applying a patch did, for the record and the reply.
@@ -95,6 +103,17 @@ func ParsePatch(raw string) (PatchRequest, error) {
95103 current , before , after = nil , nil , nil
96104 }
97105
106+ // Whether the patch has already said it is over.
107+ //
108+ // It matters because an unprefixed line is accepted as context, which is
109+ // right inside a hunk and wrong after the end marker: a run that wrote
110+ // "*** End Patch" and then a stray "EOF" — the shape a shell heredoc
111+ // leaves behind, and one models reproduce — had EOF folded into the hunk's
112+ // context, so the hunk was searched for as the file's own text plus a line
113+ // no file has ever contained and could not match anything. Ladder rung 9 on
114+ // 2026-08-03 failed 24 of its 34 patches, and this is what the tool
115+ // reported it was looking for.
116+ ended := false
98117 for _ , line := range lines {
99118 trimmed := strings .TrimSpace (line )
100119 switch {
@@ -103,6 +122,7 @@ func ParsePatch(raw string) (PatchRequest, error) {
103122 strings .HasPrefix (trimmed , "--- " ),
104123 strings .HasPrefix (trimmed , "+++ " ):
105124 closeHunk ()
125+ ended = false
106126 if named := patchHeaderPath (trimmed ); named != "" {
107127 if request .Path == "" {
108128 request .Path = named
@@ -111,14 +131,25 @@ func ParsePatch(raw string) (PatchRequest, error) {
111131 alsoNamed = append (alsoNamed , named )
112132 }
113133 }
114- case strings .HasPrefix (trimmed , "*** Begin Patch" ),
115- strings .HasPrefix (trimmed , "*** End Patch" ),
116- strings .HasPrefix (trimmed , "diff --git" ),
134+ case strings .HasPrefix (trimmed , "*** Begin Patch" ):
135+ // Envelope, and the start of a fresh one: whatever came before is
136+ // finished and anything after this belongs to the patch again.
137+ closeHunk ()
138+ ended = false
139+ case strings .HasPrefix (trimmed , "*** End Patch" ):
140+ closeHunk ()
141+ ended = true
142+ case strings .HasPrefix (trimmed , "diff --git" ),
117143 strings .HasPrefix (trimmed , "index " ):
118144 // Envelope. Carries nothing this needs.
145+ case ended :
146+ // Past the end marker. Trailing text is not part of any hunk.
119147 case strings .HasPrefix (trimmed , "@@" ):
120148 closeHunk ()
121- current = & PatchHunk {}
149+ // "@@ func TestFoo(t *testing.T) {" names the scope the change is
150+ // inside, and the whole line was being discarded. Kept as an
151+ // anchor, it is what tells a "}" which "}" is meant.
152+ current = & PatchHunk {Anchor : hunkHeaderAnchor (trimmed )}
122153 case current == nil :
123154 // Text outside any hunk. Prose, or a stray line; either way it
124155 // changes nothing and saying so would be noise.
@@ -173,18 +204,37 @@ func ParsePatch(raw string) (PatchRequest, error) {
173204 "lines to add prefixed \" +\" , and enough unprefixed lines " +
174205 "around them to say where the change goes" )
175206 }
176- // A hunk that changes nothing is dropped rather than refused.
207+ // A hunk that changes nothing is not applied — but it is not noise either.
208+ //
209+ // Models emit them for orientation, and refusing the whole patch for one
210+ // costs the round, so they were dropped. Dropping was too much: a block of
211+ // context immediately before a change is how a patch names the scope the
212+ // change is inside, and it is exactly what makes " }\n}" mean one closing
213+ // brace rather than every closing brace in the file. Ladder rung 9 on
214+ // 2026-08-03 sent the same patch six times in a row and was told to add
215+ // more context each time, with the context it needed in the hunk above;
216+ // 103 of that run's 109 patch failures were "matches N places".
177217 //
178- // Models emit them for orientation — a block of context around the part
179- // they are about to describe, or a trailing block after the last change —
180- // and refusing the whole patch for one costs the round. Skipping it cannot
181- // change the file, which is the only thing that makes forgiveness safe
182- // here. Ladder rung 8 lost roughly fifteen patches to this.
218+ // So it carries forward as the next hunk's anchor instead. A context block
219+ // after the last change still anchors nothing and is still dropped, which
220+ // is the case forgiveness was added for.
183221 changing := request .Hunks [:0 :0 ]
222+ pending := ""
184223 for _ , hunk := range request .Hunks {
185224 if hunk .Added == 0 && hunk .Removed == 0 {
225+ // The nearest preceding one wins: it is the innermost scope named,
226+ // and an outer scope cannot contradict it.
227+ if strings .TrimSpace (hunk .Before ) != "" {
228+ pending = hunk .Before
229+ }
186230 continue
187231 }
232+ // A heading on the hunk's own marker line is more specific than a
233+ // block that preceded it, so it is not overwritten.
234+ if hunk .Anchor == "" {
235+ hunk .Anchor = pending
236+ }
237+ pending = ""
188238 changing = append (changing , hunk )
189239 }
190240 if len (changing ) == 0 {
@@ -251,6 +301,26 @@ func ApplyPatch(existing string, request PatchRequest) (string, PatchOutcome, er
251301 // exactly as before.
252302 cursor := 0
253303 for index , hunk := range request .Hunks {
304+ // The scope the hunk named, if it named one and the file has it.
305+ //
306+ // Searching from just after it is what makes " }\n}" mean this
307+ // function's closing brace rather than every function's. An anchor that
308+ // resolves to nothing is ignored rather than refused: it only ever
309+ // narrows where Before is looked for, so failing on it would turn a
310+ // stale scope name into a lost patch.
311+ from := cursor
312+ if hunk .Anchor != "" {
313+ if at := uniqueIndexIn (patched [cursor :], hunk .Anchor ); at >= 0 {
314+ from = cursor + at + len (hunk .Anchor )
315+ }
316+ }
317+ if at := uniqueIndexIn (patched [from :], hunk .Before ); at >= 0 {
318+ absolute := from + at
319+ patched = patched [:absolute ] + hunk .After +
320+ patched [absolute + len (hunk .Before ):]
321+ cursor = absolute + len (hunk .After )
322+ continue
323+ }
254324 if at := uniqueIndexIn (patched [cursor :], hunk .Before ); at >= 0 {
255325 absolute := cursor + at
256326 patched = patched [:absolute ] + hunk .After +
@@ -293,6 +363,32 @@ func ApplyPatch(existing string, request PatchRequest) (string, PatchOutcome, er
293363 }, nil
294364}
295365
366+ // hunkHeaderAnchor is the file text a "@@" line names, or empty when it names
367+ // none.
368+ //
369+ // A hunk header carries up to two things after its marker: a line range, which
370+ // is bookkeeping about a file this tool does not have, and a section heading,
371+ // which is a line copied from the file and is exactly the anchor an ambiguous
372+ // hunk is missing. The range is dropped and the heading is kept.
373+ //
374+ // Empty for a bare "@@", and empty for a header that is only a range: an anchor
375+ // that is not file text would match nothing and turn a good patch into a
376+ // refusal, which is worse than the ambiguity it was meant to resolve.
377+ func hunkHeaderAnchor (line string ) string {
378+ rest := strings .TrimSpace (strings .TrimPrefix (strings .TrimSpace (line ), "@@" ))
379+ // A trailing "@@" closes a range, and everything after it is the heading.
380+ if closing := strings .Index (rest , "@@" ); closing >= 0 {
381+ // One leading space is the format's separator, not indentation.
382+ return strings .TrimRight (
383+ strings .TrimPrefix (rest [closing + 2 :], " " ), " \t " )
384+ }
385+ // An unclosed range is bookkeeping and nothing else.
386+ if strings .HasPrefix (rest , "-" ) || strings .HasPrefix (rest , "+" ) {
387+ return ""
388+ }
389+ return strings .TrimRight (rest , " \t " )
390+ }
391+
296392// indentForMessage renders a hunk's target inside a refusal, bounded.
297393func indentForMessage (text string ) string {
298394 lines := strings .Split (text , "\n " )
0 commit comments