Skip to content

fix(harness): handle CRLF line endings in the patch fuzziness ladder - #3416

Open
wahllllll wants to merge 3 commits into
agentscope-ai:mainfrom
wahllllll:fix/fuzzy-matcher-crlf
Open

wahllllll wants to merge 3 commits into
agentscope-ai:mainfrom
wahllllll:fix/fuzzy-matcher-crlf

Conversation

@wahllllll

Copy link
Copy Markdown
Contributor

Background

SkillManageTool#patch reads the target skill file verbatim and hands it to FuzzyTextMatcher, whose whole reason to exist is absorbing whitespace differences the LLM cannot reproduce exactly. Its two normalisers, however, only split lines on '\n' and treat only ' ' / '\t' as whitespace — a '\r' from a CRLF terminator therefore survives normalisation as ordinary content.

Two consequences, both reachable with a skill file authored on Windows (or staged from a marketplace archive):

  1. patch can never match. A CRLF existing normalises to "foo\r\n" while an LF needle normalises to "foo\n", so no rung of the ladder matches — including WHITESPACE_COLLAPSED, whose entire purpose is to absorb exactly this kind of difference. The call fails with old_string not found in <path> at any fuzziness level (tried exact, trailing-whitespace, and collapsed-whitespace) and the model has to fall back to action=edit, which rewrites the whole file instead of the requested span.
  2. A match, had it been found, would have rewritten the line ending. The emitted newline was mapped back to the '\n' offset rather than to the start of the terminator, so a match ending at a line boundary would have swallowed the '\r' and silently converted that line to LF.

This is not something the parser already covers: patch reads raw bytes through WorkspaceSkillRepository#readSkillFile → filesystem.read(...), with no newline normalisation anywhere upstream. (MarkdownSkillParser does handle CRLF — "^\\uFEFF?---\\s*[\\r\\n]+(.*?)[\\r\\n]*---..." — but the patch path never goes through it.)

Solution

A shared lineBodyEnd(s, lineStart, lineEnd) helper returns lineEnd - 1 when a '\r' immediately precedes the line's '\n', and lineEnd otherwise. Both normalisers now use it as the line-body bound and map the emitted newline to that same offset, so a match ending at a line boundary stops before the whole CRLF sequence instead of swallowing the '\r'.

LF input is byte-for-byte unaffected: the helper returns lineEnd unchanged whenever there is no CRLF terminator, so every pre-existing LF test is untouched.

Test evidence

Four tests added to FuzzyTextMatcherTest. All four fail on main (Tests run: 14, Failures: 4) and pass with the fix (Tests run: 14, Failures: 0):

  • crlfExistingMatchesLfNeedle — the core repro.
  • crlfMatchRangePreservesTerminators — pins the offset mapping. On "PREFIX_KEEP\r\nalpha \r\nbeta\r\nSUFFIX_KEEP\r\n" with needle "alpha\nbeta", the mapped range is exactly "alpha \r\nbeta", and patching it with "ALPHA" yields "PREFIX_KEEP\r\nALPHA\r\nSUFFIX_KEEP\r\n" — no stray \r eaten, surrounding bytes verbatim.
  • crlfCollapseLevelHandlesIndentDrift — CRLF plus tab/space drift still reaches WHITESPACE_COLLAPSED.
  • crlfBehavesLikeLfTwin — a CRLF document and its LF twin agree on emptiness, level, match count and resolved byte range across two needles.

Full module, JDK 17: agentscope-harness — 1089 tests, 0 failures, 0 errors (SkillManageToolTest 24/24).

Known limitation (deliberately left alone)

The replacement text is still spliced in verbatim, so patching a CRLF file with LF replacement text produces mixed line endings. Normalising the replacement would contradict the class contract — "we never touch whitespace the LLM didn't ask to change" — so it is flagged here rather than folded into this change.

Migrations / config changes

None.

SkillManageTool#patch reads the skill file verbatim and hands the text to
FuzzyTextMatcher, but both of its normalisers split lines on '\n' only and
treat only ' '/'\t' as whitespace. A '\r' from a CRLF terminator therefore
survived normalisation as ordinary content, so a skill file authored on
Windows matched an LF-only needle at no level of the ladder -- including
the most lenient rung, whose whole purpose is to absorb exactly this kind
of whitespace difference. patch then failed with "old_string not found in
... at any fuzziness level" and the model had to fall back to action=edit.

The emitted newline was also mapped back to the '\n' offset rather than
the start of the terminator, so a match ending at a line boundary would
have swallowed the carriage return and silently rewritten that line
ending to LF had the match been found at all.

Both normalisers now derive the line body end from a shared lineBodyEnd()
helper that excludes a '\r' immediately preceding the '\n', and map the
emitted newline to that same offset. LF input is byte-for-byte unaffected:
the helper returns lineEnd unchanged whenever there is no CRLF terminator.

The replacement text itself is still spliced in verbatim, so patching a
CRLF file with LF replacement text yields mixed line endings. Normalising
the replacement would contradict the class contract ("we never touch
whitespace the LLM didn't ask to change") and is left as a separate call.
@codecov

codecov Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.66667% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...gentscope/harness/agent/tool/FuzzyTextMatcher.java 86.66% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The CRLF handling in both normalisers is correct for the reported failure and the fix is worth landing: I compiled main and this head side by side and confirmed that (a) pure-LF input is byte-for-byte unaffected — identical level, match count and resolved range before and after, so the existing LF tests really are untouched, and (b) every CRLF shape I tried ("alpha \r\nbeta\r\ngamma\r\n" + LF needle, blank CRLF lines, mixed CRLF/LF documents, indent drift at the collapsed level, multiple matches for replace_all, a final line with no terminator) matched nothing on main and now matches at the expected level with the trailing terminator preserved.

One asymmetry to settle before this merges: mapping the emitted newline to bodyEnd fixes the trailing boundary but shifts the problem to the leading one — when old_string starts with '\n', the resolved span begins on the previous line's '\r', so the replacement silently downgrades that line's terminator CRLF→LF. It does not regress anything (that needle matched nothing before), which is why I left this at COMMENT rather than CHANGES_REQUESTED, but it does write a byte outside the span the caller asked for, against the class's own "we never touch whitespace the LLM didn't ask to change" contract. Details plus two reproduction cases are pinned inline; a newline-prefixed-needle test would lock whichever resolution you pick.

Non-blocking: the reported symptom lives in SkillManageTool#patch, and SkillManageToolTest has no '\r' anywhere, so there is currently no tool-level regression guard for it.

CI at review time: Check License, Check Module Sync, build (ubuntu-latest), codecov/patch pass; build (windows-latest) pending — worth waiting for it given the subject matter. CLA signed.


Automated review by github-manager-bot

if (lineEnd < len) {
out.append('\n');
map[mapLen++] = lineEnd;
map[mapLen++] = bodyEnd;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Mapping the emitted newline to bodyEnd protects the trailing boundary, but it moves the leading boundary inside a terminator: when old_string itself starts with '\n', originalIndex[idx] for that newline resolves to the '\r', so the replaced span begins on the previous line's CR while the replacement only carries an LF — that line's terminator is silently downgraded CRLF→LF, which is the same class of rewrite that point 2 of the description exists to prevent, and it sits against the class contract "we never touch whitespace the LLM didn't ask to change".

Repro (compiled this PR's head standalone):

existing = "alpha\r\nbeta   \r\ngamma\r\n"
needle   = "\nbeta\ngamma"        // leading newline
// level = TRAILING_WS_STRIPPED, span = "\r\nbeta   \r\ngamma"
// patching with "\nBETA\nGAMMA" yields:
"alpha\nBETA\nGAMMA\r\n"          // first line lost its '\r'

On main this needle matches nothing at all, so it is not a regression — but the new leniency now reaches a path that rewrites a byte outside the requested span.

Suggested direction: give start and end offsets separate map targets (e.g. keep lineEnd for a start that lands on an emitted newline, bodyEnd for an end), or clamp origStart forward past a '\r' that is immediately followed by '\n' in findAllNormalized — the Normalized record would then need the source string (or a parallel offset array). A test pinning a newline-prefixed needle against a CRLF document would lock whichever choice is made.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the asymmetry is real, and I've taken your first suggested direction.

Leading boundary. findAllNormalized now resolves the span's start through a startOffset
helper: when the normalised character is a '\n' that a CRLF terminator produced, the start
resolves to the '\n' itself rather than to the '\r' in front of it. The '\r' therefore stays
outside the span, the caller's replacement supplies the line break, and the document's terminator
style survives. The end boundary is unchanged and still resolves to the terminator's start, so
neither boundary can split a terminator — the two are now mirror images. A '\n' that was already a
lone LF resolves exactly as before, so pure-LF input stays byte-for-byte identical.

Your repro now maps to span "\nbeta \r\ngamma" (charAt(start - 1) == '\r'), and patching it
with "\nBETA" yields "alpha\r\nBETA\r\n" — no downgraded terminator.

Tests. Both gaps you named are closed:

  • crlfLeadingNewlineKeepsTerminatorOutsideSpan — your exact repro, pinned at
    TRAILING_WS_STRIPPED. It fails on main, and with only startOffset neutered it is the single
    failure in the class, so it pins this fix specifically rather than the CRLF handling in general.
  • patchMatchesCrlfSkillFileWithLfNeedle — end-to-end SkillManageTool patch on a CRLF skill file
    with a multi-line LF needle. On main it fails with the user-visible old_string not found in SKILL.md at any fuzziness level (...), and it asserts the frontmatter's terminators survive the
    patch untouched.

Writing the second one turned up something worth recording: create round-trips content through
SkillUtil.createFrom and writes LF, so the tool never produces a CRLF file itself. A CRLF
SKILL.md only arrives from outside the tool — authored on Windows, unpacked from a marketplace
archive, copied in by hand. The test therefore writes the CRLF bytes onto disk directly; from there
readSkillFile → filesystem.read(...) returns them verbatim, which is the no-normalisation path
the description claims.

Info items. The javadoc now states both adjacent behaviours you flagged: a lone '\r' (classic
Mac endings, or a file where only some lines end that way) still matches nothing and is out of
scope, and because the replacement is spliced verbatim, a single patch over a mixed-terminator
document can leave both styles present.

Module run, JDK 17: agentscope-harness — 1091 tests, 0 failures on the last two full runs. One
run out of three additionally hit a background-task quiescence timeout
(HarnessBackgroundTaskQuiescenceExtension, in SandboxFilesystemIsolationScopeExampleTest); it
did not reproduce in the two runs after it, and that extension is unreachable from a pure static
string function, so I am treating it as a timing flake rather than something this change caused.

* of the ladder; the same offset doubles as the map target for the emitted newline, which is
* what keeps a match ending at a line boundary from swallowing the carriage return.
*/
private static int lineBodyEnd(String s, int lineStart, int lineEnd) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Helper reads well and the guards are correct (lineEnd < s.length() keeps a trailing \r with no \n as real content, lineEnd > lineStart keeps "\r\n" a genuine empty line). Two adjacent behaviours are worth a sentence here now that the class javadoc advertises CRLF support:

  • Lone-'\r' terminators (classic Mac, or a file where only some lines end in \r) still match nothing: "alpha\rbeta\rgamma" vs "alpha\nbeta" is empty at every level, unchanged from main. Out of scope is fine — saying so keeps the claim honest.
  • Mixed-terminator documents now do match ("alpha \nbeta\r\ngamma\n" hits TRAILING_WS_STRIPPED), which is the desired outcome, but combined with the spliced-verbatim limitation it means one patch can leave both styles in the same file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the class javadoc now states both adjacent behaviours: a lone '\r' (classic Mac endings, or a file where only some lines end that way) is still treated as ordinary content and matches nothing, and because the replacement is spliced verbatim, a single patch over a mixed-terminator document can leave both styles present.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my own message above: the lone-'\r' sentence was added to the javadoc, but the mixed-terminator consequence was not — I overstated that at the time. It is in now, with the reason the replacement is spliced verbatim, because reverting startOffset makes it reachable again (a newline-leading needle hands that terminator to the caller's new_string).


@Test
@DisplayName("A CRLF document behaves exactly like its LF twin")
void crlfBehavesLikeLfTwin() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] crlfBehavesLikeLfTwin comparing level / match count / resolved range against an LF twin is exactly the right guard, and I confirmed the claim independently: pure-LF input produces identical levels, counts and ranges on main and on this branch, so every pre-existing LF test really is untouched.

Two gaps worth closing while this area is open:

  1. a needle that starts with '\n' against a CRLF document (the leading-boundary case in my comment on FuzzyTextMatcher.java:208);
  2. an end-to-end SkillManageToolTest patch on a CRLF skill file — that test class currently contains no '\r', so the user-visible failure reported here (patch → "old_string not found … at any fuzziness level") is guarded at the matcher level but not at the tool level.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both gaps closed.

  • crlfLeadingNewlineKeepsTerminatorOutsideSpan — the newline-leading needle, using your repro and pinned at TRAILING_WS_STRIPPED.
  • patchMatchesCrlfSkillFileWithLfNeedle — the end-to-end SkillManageTool patch.

Writing the second one turned up something worth knowing: create and edit round-trip the content through SkillUtil.createFrom and always write LF, so the tool never produces a CRLF file itself — a CRLF SKILL.md only arrives from outside it. The test writes those bytes onto disk directly, and readSkillFile returns them verbatim.

Mapping the emitted newline to the start of the terminator fixed the
trailing boundary but left the leading one broken: a needle beginning
with a newline resolved its start to the '\r' in front of it, so the
replacement's LF stood in for a CRLF and silently downgraded that
line's terminator.

Resolve the start to the '\n' of the terminator instead, leaving the
'\r' outside the span. The replacement then supplies its own line
break and the document keeps its terminator style. The end boundary
still resolves to the terminator's start, so neither boundary can
split one, and a '\n' that was already a lone LF resolves as before.

Adds the two tests those boundaries were missing: a needle that starts
with a newline, and an end-to-end SkillManageTool patch on a CRLF
skill file. The latter writes the CRLF bytes to disk directly, since
create and edit round-trip the content through the parser and always
write LF -- a CRLF SKILL.md only arrives from outside the tool.
@wahllllll
wahllllll force-pushed the fix/fuzzy-matcher-crlf branch from d88a739 to c6c15a7 Compare October 4, 2026 12:31

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review after the follow-up commit. Both non-blocking findings from my last review are addressed and I verified the fix independently, not just from the tests:

  • Leading boundary — resolved. startOffset now keeps the previous line's '\r' outside the span, which was the asymmetry I flagged. Confirmed: on "alpha\r\nbeta \r\ngamma\r\n" with needle "\nbeta\ngamma" the span resolves to "\nbeta \r\ngamma" and patching yields "alpha\r\nBETA\r\n" — the CRLF survives. The same case on 24b6106 produced "alpha\nBETA\r\n", and on main it matched nothing, so this head is strictly better there.
  • Test gaps — closed. The newline-leading-needle test and the end-to-end SkillManageToolTest.patchMatchesCrlfSkillFileWithLfNeedle are exactly what was missing; the create-writes-LF discovery in the second one is a useful piece of documentation for the next person to touch this path.
  • Javadoc — done. Lone-'\r' and mixed-terminator behaviour are now stated as advertised, and I confirmed the claim ("alpha\rbeta\rgamma" vs "alpha\nbeta" still matches nothing at every rung).
  • LF parity — intact. Pure-LF documents keep identical level, match count and resolved range versus main across everything I threw at it (trailing-ws needles, multiple matches, no-terminator EOF, exact CRLF needles), so the existing suite really is untouched. The Normalized(source, text, originalIndex) refactor is also an improvement — originalLength can no longer disagree with the string it indexes.

One new finding before this merges (pinned inline on startOffset): stepping over a single '\r' fixes the one-newline case but leaves a stray '\r' in the document when the needle starts with two newlines, i.e. an old_string whose first line is blank — main preserved the terminators verbatim there, so that specific shape is a regression, and the LF twin and CRLF twin now patch to different bytes. Repro plus the three-way comparison table are on the hunk; a while over leading terminators looks like the whole fix.

CI at review time: Check License and Check Module Sync pass; build (ubuntu-latest) and build (windows-latest) were still in flight on c6c15a7 — given the subject matter, windows-latest is the one worth reading before merge. CLA signed.

Overall: the direction is right and this is close. Resolving the multi-newline leading-needle case (or explicitly documenting it as out of scope, in which case the two-newline WHITESPACE_COLLAPSED behaviour on main should get a pinning test so it does not drift a third time) is the only thing I'd want settled here.


Automated review by github-manager-bot

&& haystack.source.charAt(orig) == '\r'
&& orig + 1 < haystack.originalLength()
&& haystack.source.charAt(orig + 1) == '\n') {
return orig + 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] startOffset skips exactly one \r, but the condition it guards on is "the character at the normalised index is a newline" — which is true for every newline in the needle, not just the first. When a needle starts with more than one newline (a copy-pasted old_string spanning a blank line), only the first terminator is stepped over and the second match begins after the leading CR, so that CR is dropped outside the span and stays in the document as an orphan.

Repro against this head (existing = "one\r\n\r\nalpha beta\r\n", old_string = "\n\nalpha beta"):

version level resolved span patched output
main WHITESPACE_COLLAPSED "alpha beta" "one\r\n\r\nREPL\r\n" — clean CRLF
24b6106 TRAILING_WS_STRIPPED "\r\n\r\nalpha beta" "oneREPL\r\n" — leading CR swallowed
this head TRAILING_WS_STRIPPED "\n\r\nalpha beta" "one\rREPL\r\n" — orphan CR

The LF twin ("one\n\nalpha beta\n") yields "oneREPL\n", so neither 24b6106 nor this head agrees with the parity this PR exists to establish. Reaching TRAILING_WS_STRIPPED rather than WHITESPACE_COLLAPSED is the intended improvement; what regressed is the byte hygiene — main at least left the terminators verbatim.

Stepping over every leading terminator fixes it — advance while the normalised character at the current index is a newline:

while (haystack.text.charAt(idx) == '\n'
        && haystack.source.charAt(orig) == '\r'
        && orig + 1 < haystack.originalLength()
        && haystack.source.charAt(orig + 1) == '\n') {
    orig += 2;   // step past this terminator; the next one may also lead the span
    idx += 1;
}
return orig;

(advance idx alongside orig so multi-newline leading needles terminate correctly, and note idx + 1 must stay in range for haystack.text). Worth a crlfBlankLineLeadingNewlinesPatchedOutputMatchesLfTwin test pinning the patched bytes, not only the span, so all three versions above are frozen.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I went further than a while: I removed startOffset entirely. The single-step rule turns out to be the wrong shape rather than an under-sized version of the right one.

Measured, by installing each rule and running the new test (each cell is the patch result folded \r\n→\n, ✅ = equals the LF twin's output):

CRLF document + needle originalIndex[idx] (this head) single '\r' step (c6c15a7) while over leading terminators
alpha\r\nbeta \r\ngamma\r\n + "\nbeta\ngamma" alpha\nBETA\r\n ✅ alpha\r\nBETA\r\n ✅ alpha\r\n\nBETA\r\n ❌ added a blank line
one\r\n\r\nalpha beta\r\n + "\n\nalpha beta" oneREPL\r\n ✅ one\rREPL\r\n ❌ stray CR not reached
alpha\r\nbeta \r\ngamma\r\n + "\nbeta\ngamma" → "\nBETA\nGAMMA" ✅ ✅ not reached
alpha\r\nbeta\r\ngamma\r\n + "beta\ngamma" ✅ ✅ not reached

The while column is the shape the condition actually describes — it advances past the whole terminator, which for shape 1 hands the span's leading break entirely to the replacement and inserts a blank line. Both variants fail for the same underlying reason: they give the leading edge a different convention from the trailing edge, so the CRLF span covers strictly fewer terminators than its LF twin's span, and parity breaks.

Full reasoning in the top-level comment.

String patched = existing.substring(0, m.start()) + "\nBETA" + existing.substring(m.end());
assertEquals("alpha\r\nBETA\r\n", patched);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] This pins the single-leading-newline case exactly as discussed, including the charAt(m.start() - 1) == '\r' assertion and the patched-bytes check — good, and I confirmed all four of those assertions independently against this head.

One observation for whoever extends this file: the strongest guard here would compare the patched output against an LF twin instead of against hand-written expected bytes, because the hand-written expectations currently encode the one case the ladder handles (\n-leading) while the multi-newline variant documented on startOffset passes no assertion at all. Adding a "\n\n…" needle to crlfBehavesLikeLfTwin's needle list would have caught it for free.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is replaced by crlfLeadingNewlinePatchMatchesLfTwin, which asserts the patched output against the LF twin rather than a hand-written span, over four shapes (single leading newline, two leading newlines, a replacement re-supplying both breaks, and no leading newline). It also pins alpha\nBETA\r\n for the first shape's exact bytes.

Two things it fixes relative to this one:

  • The charAt(m.start() - 1) == '\r' assertion here encodes the convention that has now been dropped — the span's leading edge sits on the '\r' after all, so that assertion is no longer true and would be misleading if kept.
  • This test passed on c6c15a7 while the two-newline shape was broken, which is the same blind spot as crlfBehavesLikeLfTwin: both compare a folded view where a stray '\r' is invisible. The new one folds only well-formed CRLF pairs, so a stray one survives the comparison and fails it.

@wahllllll
wahllllll force-pushed the fix/fuzzy-matcher-crlf branch from 279667f to fd78216 Compare October 4, 2026 13:20
@wahllllll

Copy link
Copy Markdown
Contributor Author

Reverted startOffset rather than extending it. I installed each of the three boundary rules and ran them over the same four shapes; only the plain map lookup survives, and the while variant does not:

CRLF document + needle main originalIndex[idx] (this head) single '\r' step (c6c15a7) while over leading terminators
alpha\r\nbeta \r\ngamma\r\n + "\nbeta\ngamma" no match alpha\nBETA\r\n ✅ alpha\r\nBETA\r\n ✅ alpha\r\n\nBETA\r\n ❌ added a blank line
one\r\n\r\nalpha beta\r\n + "\n\nalpha beta" no match oneREPL\r\n ✅ one\rREPL\r\n ❌ stray CR not reached
alpha\r\nbeta \r\ngamma\r\n + "\nbeta\ngamma" → "\nBETA\nGAMMA" no match ✅ ✅ not reached
alpha\r\nbeta\r\ngamma\r\n + "beta\ngamma" no match ✅ ✅ not reached

Each cell is the patch result folded \r\n→\n; ✅ means it equals the LF twin's patched output for the same needle and replacement. The two right-hand columns are measured, not reasoned: I swapped each rule in and ran the new test, which aborts on the first mismatching shape — hence one entry for the while column.

Why the plain lookup is the rule. The map holds exactly one entry per emitted character, and a folded newline's entry points at the '\r' that starts its terminator. Both edges read that same entry, so a span covers whole terminators on the leading edge exactly as it does on the trailing edge, and covers the same number of terminators as the LF twin's span does. Advancing the start gives the leading edge a second, different convention, and the CRLF span then covers strictly fewer terminators than its LF twin — which is what breaks parity. When the terminator left outside has no '\n' from the replacement to pair with, as in shape 2 where new_string is not itself newline-prefixed, its '\r' stays in the document as a stray carriage return inside a line.

The trade-off is real, and I took the other side of it. Anchoring the start on the '\r' hands a newline-leading needle's terminator to the replacement text, so shape 1 lands as alpha\nBETA\r\n — mixed styles, where c6c15a7 produced uniform CRLF. That is the caller's own new_string taking effect, and it is what the LF twin does with the same input; nothing outside the span is rewritten. c6c15a7 bought uniformity on shape 1 by breaking shape 2 and parity. A stray CR inside a line is content corruption — it would land in the frontmatter this path writes — where a mixed terminator is cosmetic. Shape 1's exact bytes are now pinned alongside shape 2's parity, so neither convention can drift silently.

Correction to my previous message. I wrote that the javadoc stated both adjacent behaviours. The lone-'\r' sentence was there; the mixed-terminator consequence was not. It is now, with the reason (the replacement is spliced verbatim), because reverting makes it reachable again.

Tests. crlfLeadingNewlinePatchMatchesLfTwin replaces the span-shaped guard: it compares patched output against the LF twin, folding only well-formed CRLF pairs so a stray '\r' survives the comparison, across the four shapes above. The guard it replaces could not have caught your finding — it normalised the residue away with replace("\r", ""), which is exactly why shape 2 passed on c6c15a7. crlfBehavesLikeLfTwin now also takes "\nalpha\nbeta" and "\n\nalpha\nbeta" needles, as you suggested; worth noting both still pass under the broken rule, for that same reason.

agentscope-harness, JDK 17: 1091 tests, 0 failures.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review of c6c15a77..fd782160d. My blocking finding is resolved and this is now good to land — approving.

You went a better way than the while loop I sketched: dropping startOffset so both edges read the one position map makes the leading and trailing boundaries the same rule instead of two rules that have to stay in sync, and it is why the two-newline shape stops stranding a '\\r'. I re-ran your comparison table independently on this head and every cell matches what you reported (my suggested fix really would have added a blank line).

What I verified locally on fd782160d (compiled FuzzyTextMatcher standalone against origin/main, c6c15a77 and this head, then ran a 30-case parity / orphan-CR sweep on each):

  • The reported regression is gone. \"one\\r\\n\\r\\nalpha beta\\r\\n\" + \"\\n\\nalpha beta\" + \"REPL\" → oneREPL\\r\\n, folded output equals the LF twin, no orphan CR. On c6c15a77 the same call produced one\\rREPL\\r\\n; on main the needle only matched at the collapse level.
  • Your four twin cases and the pinned bytes reproduce, including alpha\\nBETA\\r\\n for the single-leading-newline shape — correct under the new convention, where the leading terminator is inside the span and the caller's text owns that break.
  • Same-rung parity holds across the further shapes I had not previously covered (blank line inside the needle, needle ending on a line break, three leading newlines, indent drift into the collapse level, last line with no terminator): wherever both the LF and CRLF document reach the same rung, the folded CRLF output equals the LF output and no CR is stranded. Where they land on different rungs the spans differ, which is the ladder's contract rather than a CRLF defect.
  • Cross-rung differences are the ladder, not the matcher: where an LF document matches EXACT and its CRLF twin can only match a normalising rung, the resolved spans differ (trailing whitespace gets consumed at TRAILING_WS_STRIPPED). That is the level being reported to the LLM for what it is, and it is unchanged from main.
  • LF input untouched still: identical level, count and range versus main on every pure-LF case I ran, and lone-'\\r' documents still match nothing, exactly as the javadoc now says.

Two things to be aware of, both non-blocking, neither a reason to hold this:

  1. The class javadoc's "every span edge lands on a terminator boundary" is true of the normalising rungs but not of EXACT, which is a verbatim indexOf and can still open on a terminator's '\\n' (\"\\r\\nalpha beta\\r\\n\" + \"\\nalpha beta\" → patched \"\\rQ\\r\\n\", identical to main). Pinning the scope in words is enough — pinned inline.
  2. The blank-line needle now resolves at TRAILING_WS_STRIPPED where main reached it only at WHITESPACE_COLLAPSED. Stricter is right here, and you pinned the level, but it does change what SkillManageTool#patch reports to the model for that shape, so it belongs in the commit message more than in the code — which it does.

CI at review time: Check License and Check Module Sync green; build (ubuntu-latest) and build (windows-latest) were still in flight when I posted. This repo surfaces no license/cla status context (CLA is enforced by the license check), so nothing there is blocking. Given the subject matter I would still read windows-latest before merging, and I did not need it to confirm the logic above.


Automated review by github-manager-bot

* needle. An emitted {@code '\n'} maps back to the {@code '\r'} that starts a CRLF terminator,
* and both the leading and the trailing boundary are resolved the same way, so every span edge
* lands on a terminator boundary and neither one splits a terminator: the terminators straddling
* a match survive it untouched, and nothing outside the span is rewritten.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Reverting startOffset and letting both edges read the one position map is the better shape — you are right that the single-step rule was the wrong kind of rule, not an undersized version of the right one, and my while suggestion would indeed have inserted a blank line (one\n\nBETA). I reproduced your table independently on fd782160d: the two-newline needle now patches to oneREPL\r\n (folded == the LF twin's oneREPL\n, no orphan CR), and all four cases of crlfLeadingNewlinePatchMatchesLfTwin plus the pinned alpha\nBETA\r\n hold here.

One sentence in this paragraph can be read more strongly than it holds, though: "every span edge lands on a terminator boundary and neither one splits a terminator" is true of the two normalising rungs, but EXACT is a raw indexOf and still opens mid-terminator. existing = "\r\nalpha beta\r\n", old_string = "\nalpha beta" matches at EXACT with start = 1 (the '\n' of the leading terminator), so new_string = "Q" yields "\rQ\r\n" — a stranded '\r'. Same shape for "x\n alpha \n" vs "x\n alpha": the LF document matches EXACT while its CRLF twin has to fall to TRAILING_WS_STRIPPED, so the two runs legitimately differ in which rung answers, and the CRLF one swallows the trailing spaces.

That behaviour is identical on main (\rQ\r\n there too), so it is out of scope for this PR and I am not asking for a code change here — just scoping the claim, e.g. "at the normalising levels every span edge lands on a terminator boundary; the exact level matches bytes verbatim and may open inside a terminator". If you would rather not widen the javadoc again, a one-line note on the EXACT bullet in the list above does the same job. Either way, non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed your example by measurement, and scoped the claim.

"\r\nalpha beta\r\n" + "\nalpha beta" → level EXACT, one match, resolved span "\nalpha beta" (start = 1, the terminator's '\n'), patched with "Q" → "\rQ\r\n". Its LF twin also answers at EXACT and patches to "Q\n", so the two rungs genuinely differ there — pre-existing, as you say, since EXACT is untouched by this change.

The javadoc now reads:

…both the leading and the trailing boundary resolve through that same map entry, so at the normalising levels a span edge always lands on a terminator boundary and never splits one. Level.EXACT carries no such guarantee: it is a raw indexOf, so a needle that begins with a newline can open on the '\n' of a CRLF terminator and leave its '\r' behind, exactly as it does before this change. Either way only the span itself is ever replaced, so nothing outside it is rewritten.

I left the "nothing outside the span is rewritten" half unscoped rather than dropping it: the splice only ever replaces [start, end), so what EXACT loses is the boundary property, not that one. Both rungs are named in the class javadoc rather than only on the list item above, since that is where the overclaim was.

On your second point — the rung change is now in the commit message, but only because you prompted it: it was not there before, so thanks for the nudge.

…-output parity

Resolving a match's start forward to the '\n' of a CRLF terminator left the span
beginning mid-terminator. The '\r' stayed inside the document, so any patched
replacement whose text does not itself begin with a newline stranded it and
silently downgraded that line's terminator to LF; a needle opening with two
newlines surfaced it as an orphan CR (`one\rREPL`).

Drop the start-offset helper. The position map already points an emitted '\n' at
the '\r' that starts the terminator, exactly as the trailing boundary does, so
both edges landed on terminator boundaries before the helper existed and a match
now patches a CRLF document to the same bytes as its LF twin.

Replace the span-shaped guard with one that compares patched output against an
LF twin, folding only well-formed CRLF pairs so a stray '\r' stays visible. The
old guard normalised the residue away with replace("\r", "") and so could not
have caught this. Cover a blank-line-leading needle and a leading-newline needle
whose replacement re-supplies both breaks, and add leading-newline needles to the
CRLF/LF twin test.

A leading newline is part of the span, so the replacement's own text supplies
that break and one patch can leave a document holding both styles. State that in
the class javadoc and pin the exact patched bytes for the single-newline shape,
so neither leading-edge convention can drift again unnoticed. The blank-line
shape also now resolves one rung stricter than before on a CRLF document,
TRAILING_WS_STRIPPED rather than WHITESPACE_COLLAPSED, which changes the level
SkillManageTool#patch reports to the model for that shape.
@wahllllll
wahllllll force-pushed the fix/fuzzy-matcher-crlf branch from fd78216 to 3ca107f Compare October 4, 2026 13:57

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review of the force-pushed head fd782160d..3ca107fa. The only delta is the class javadoc — the code and tests are byte-identical to the revision I approved, and the new wording is a correction I asked for, so this stays approved. CLA is satisfied (the author has two PRs already merged into main this week) and all five checks are green on 3ca107fa, including build (windows-latest), which is the run that actually exercises CRLF terminators.

I re-verified the three claims the rewritten javadoc and commit message now make, by compiling main and this head side by side and pushing the same shapes through both:

  • The EXACT caveat is true and pre-existing. "\r\nalpha beta\r\n" with needle "\nalpha beta" resolves at EXACT and patches to "\rQ\r\n" on both revisions. Scoping the "never splits a terminator" promise to the normalising levels, instead of claiming it class-wide, is the honest statement — EXACT is a raw indexOf and this PR does not touch it.
  • The normalising levels really are symmetric now. "\nbeta\ngamma" against "alpha\r\nbeta \r\ngamma\r\n" patches to "alpha\nBETA\r\n", which folds to the LF twin's "alpha\nBETA\n" exactly — leading and trailing edges both land on terminator boundaries via the single position map, which is what removing startOffset bought.
  • The rung shift is real and correctly disclosed. The two-newline needle now answers TRAILING_WS_STRIPPED on a CRLF document where main answered WHITESPACE_COLLAPSED, and patches to "oneREPL\r\n" rather than main's "one\r\n\r\nREPL\r\n". Because SkillManageTool#patch reports the matched level back to the model, that is a behaviour change a reader of patch needs to see, and naming it in the commit message is the right place.

The mixed-line-ending limitation remains documented rather than fixed, consistent with the class contract of not rewriting whitespace the model did not ask to change. Nothing further from me — good to land.

* needle. An emitted {@code '\n'} maps back to the {@code '\r'} that starts a CRLF terminator,
* and both the leading and the trailing boundary resolve through that same map entry, so at the
* normalising levels a span edge always lands on a terminator boundary and never splits one.
* {@link Level#EXACT} carries no such guarantee: it is a raw {@code indexOf}, so a needle that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] The reworded paragraph is accurate, and I checked the EXACT caveat independently rather than taking the prose on trust. Compiled main and 3ca107fa side by side and ran the same shapes through both:

  • "\r\nalpha beta\r\n" + "\nalpha beta" resolves at EXACT on both revisions and patches to "\rQ\r\n" on both — so "exactly as it does before this change" is literally true, the orphan CR is pre-existing and not a regression this PR introduced.
  • The normalising rungs are where the guarantee holds, as scoped: "alpha\r\nbeta \r\ngamma\r\n" + "\nbeta\ngamma" with "\nBETA" gives "alpha\nBETA\r\n" on head against "alpha\nBETA\n" on its LF twin — parity after folding well-formed CRLF pairs, no split terminator.
  • Your commit-message claim about the rung shift also measures out: "one\r\n\r\nalpha beta\r\n" + "\n\nalpha beta" now resolves at TRAILING_WS_STRIPPED on the CRLF document where main answered WHITESPACE_COLLAPSED, and patches to "oneREPL\r\n" instead of main's "one\r\n\r\nREPL\r\n". That is the model-visible report change worth having called out, and it is stated where a reader of patch will hit it.

Documenting the asymmetry instead of papering over it is the right call — the previous wording promised more than the code delivered at EXACT.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants