Skip to content

fix(edit): keep the indentation a fuzzy match spans - #780

Merged
Ishaan Gangwani (ishaan1124) merged 2 commits into
synthetic-sciences:mainfrom
aniruddhaadak80:fix/edit-fuzzy-match-indent
Sep 29, 2026
Merged

Ishaan Gangwani (ishaan1124) merged 2 commits into
synthetic-sciences:mainfrom
aniruddhaadak80:fix/edit-fuzzy-match-indent

Conversation

@aniruddhaadak80

Copy link
Copy Markdown
Contributor

What does this PR do, and why?

edit has a chain of progressively looser replacers so a model that re-quoted a
line with slightly different spacing still lands its edit. One of them widens the
match from the quoted substring to the whole line:

if (normalizeWhitespace(line) === normalizedFind) {
  yield line          // ← the entire line, indentation included
}

replace then substitutes newString across that whole region:

return content.substring(0, index) + newString + content.substring(index + search.length)

newString was written for the text the model quoted, not for the padding it
never saw. So one divergence in internal spacing turns this:

        if self.ok:
            return x

into this, reported as Edit applied successfully.:

        if self.ok:
return 42

That is an IndentationError in a file the tool claims to have fixed, and in an
auto-approved run nobody reads the diff. LineTrimmedReplacer,
BlockAnchorReplacer, IndentationFlexibleReplacer and ContextAwareReplacer
all widen the same way, so the fix belongs where the candidate is accepted rather
than in each replacer.

The fix keeps the file's padding and matches the text itself:

const search = indent(candidate).length > quoted ? candidate.trimStart() : candidate

so the edit lands where the model looked, at the indentation the file already
had. On a CRLF file this also stops the widened match from swallowing the \r and
turning one line LF-terminated.

The comparison is against the quoted text, not the replacement

That detail is load-bearing. Comparing against newString instead would refuse
a deliberate outdent ( return x → return x), which is a valid edit.
Comparing against the quoted oldString means only a widened match is trimmed,
so an outdent the model asked for still applies. There is a test for exactly
that.

Linked issue

Self-identified; no upstream issue covers it. I did not open one because this
turn's issue budget went to the two apply_patch defects, which were the more
urgent "reported as applied but was not" cases. Happy to file one if you would
rather track it separately.

How did you verify it?

Four cases in backend/cli/test/tool/edit-replace.test.ts, all against the pure
replace function:

  • a fuzzy line match keeps the indentation it matched — the repro; on
    3e94875c the indentation is gone and the line sits at column 0
  • an explicit de-indent is still applied — the guard against over-rejecting
  • an indented line quoted with its indentation is replaced exactly — the normal
    path is untouched
  • the two pre-existing $-literalness tests, unchanged

Commands run:

  • bun test --timeout 60000 ./test/tool/edit-replace.test.ts → 5 pass, 0 fail
  • bun test --timeout 120000 ./test/tool/payload-integrity.test.ts ./test/tool/write-safety.test.ts
    → 9 pass, 0 fail (the other two suites that touch EditTool/replace)
  • bun run --cwd backend/cli typecheck → exit 0

workspace-file-tools.test.ts also drives EditTool. It reported a failure on
my first run at exactly 120 s — the per-test timeout — and passes in 27.8 s when
re-run alone, so that was load on this machine, not a behaviour change. Its edit
is at indent 0, which the guard cannot affect. I am flagging the timing because I
would rather you trust the numbers than find the timeout yourself.

Checklist

  • bun run check is green (format, typecheck, backend + frontend/ui + SDK tests) — blocked on this Windows checkout by the CRLF and symlink artifacts in my other pull requests; backend typecheck is clean and all four EditTool/replace suites are green
  • bun run --cwd frontend/workspace build succeeds if I touched frontend/workspace or frontend/ui — not touched
  • ./tooling/repo/generate.ts was run and the tooling/sdk output committed if I changed backend/cli/src/server — not touched
  • CHANGELOG.md has an Unreleased entry if the change is user-visible
  • The matching docs page under frontend/docs/src/content/openscience/ is updated if behavior changed — no doc change needed: the looser replacers are an internal convenience and their documented contract is unchanged
  • Screenshots or a short video are attached for UI changes — not a UI change
  • No version bumps (package.json versions and tags are written by the release workflow)
  • install and frontend/landing/public/install are still byte-identical if I touched either — not touched

@vercel

vercel Bot commented Sep 28, 2026

Copy link
Copy Markdown

ANIRUDDHA ADAK (@aniruddhaadak80) is attempting to deploy a commit to the InkVell Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks. The single-line case is real and fixed, but candidate.trimStart() in backend/cli/src/tool/edit.ts is only right when the model quoted the line with no indentation at all. Two cases still come out wrong (main is also wrong here, so it's incomplete rather than a regression):

  • A multi-line block quoted at column 0 against a file indented 8 spaces: the first line lands at 8 spaces and the next at 4, which is an IndentationError in Python.
  • A line quoted at 4 spaces in a file indented 12 ends up at 16.

Strip only the extra padding the fuzzy match added, and add it to every line of the replacement. Slice instead of trimStart(), which can also eat a leading newline:

const pad = indent(candidate).slice(0, Math.max(0, indent(candidate).length - quoted))
const search = candidate.slice(pad.length)
const replacement = pad ? newString.replaceAll("\n", `\n${pad}`) : newString

Please add tests for the multi-line and partly-indented cases.

A looser replacer widens the match from the quoted substring to the whole line,
and the replacement was written for the text that was quoted, not the padding
around it, so an indented statement was replaced at column 0. Match the text
itself and let the file keep the indentation it had, compared against the quoted
oldString so a deliberate outdent still applies.
Stripping the candidate's leading whitespace dropped the padding from the
first line of a multi-line block only, so a block quoted at column 0 landed
its first line at the file's depth and the rest at the quoted one, which is
an IndentationError in Python, and a line quoted at 4 and matched at 12
stacked its own 4 on top of the 12 the file already had. Slice off exactly
the padding the matcher added and add it back after every newline of the
replacement, which also keeps a leading newline from being trimmed away.
@aniruddhaadak80

Copy link
Copy Markdown
Contributor Author

Done in df7526b.

  • Replaced the trimStart() branch with a slice of exactly the padding the matcher added, and applied that pad after every newline of the replacement, so a multi-line block quoted at column 0 against an 8-space file now lands entirely at 12 and a line quoted at 4 in a 16-space file lands at 16 rather than 20. Slicing also leaves a leading newline alone, which trimStart() would have eaten.
  • The pad is derived from the quoted oldString as before, so a deliberate outdent still applies, and it feeds both the replaceAll and single-match branches.
  • Added the two cases you named: a multi-line block quoted at column 0 keeps every line at the file's depth and a partly-indented line lands back at the file's depth. Both fail against the previous implementation and pass against this one — I checked by running them against e8487cfd.
  • Rebased onto f1bdf252; the CHANGELOG conflict is resolved by keeping both sides' entries.

One note on the second case: a line quoted at 4 against a 12-space line is already matched by the exact replacer, because return x is a substring of return x. The test only reaches the padding path once the internal spacing diverges, so that line is at 16 and the quoted text is at 4 — that is the shape that produced your 16.

@aniruddhaadak80

Copy link
Copy Markdown
Contributor Author

All checks are green on df7526b3 — the rebase resolved the conflict and the new tests pass. Ishaan Gangwani (@ishaan1124) ready for another look whenever you have a moment.

@ishaan1124
Ishaan Gangwani (ishaan1124) dismissed their stale review September 29, 2026 15:33

The requested changes are in and verified; CI is green.

@ishaan1124
Ishaan Gangwani (ishaan1124) merged commit a94dc3b into synthetic-sciences:main Sep 29, 2026
8 of 9 checks passed
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