Skip to content

Fix correctness bugs found by review in the function-level merge engine - #22

Merged
TheValiantOne merged 1 commit into
mainfrom
fix/function-level-merge-review-findings
Aug 10, 2026
Merged

Fix correctness bugs found by review in the function-level merge engine#22
TheValiantOne merged 1 commit into
mainfrom
fix/function-level-merge-review-findings

Conversation

@TheValiantOne

Copy link
Copy Markdown
Owner

Summary

A multi-angle code review of the just-merged function-level merge engine (#18) surfaced several real bugs, all fixed here:

  • stdout corruption (critical for MCP): TryFunctionLevelRescue's success notification used DialogIcon.Information, which HeadlessMergeNotifier routes to stdout — corrupting the MCP JSON-RPC stream on every rescue that had decisions to report. Now DialogIcon.Warning (stderr), matching every other message in the class.
  • Crash on zero-function files: a .ws file with no extracted functions/fields (e.g. only top-level consts/enums) crashed with IndexOutOfRangeException in GetSideGapIndex, silently swallowed by a bare catch — the rescue just never fired for exactly this file shape. Fixed the index bug, and separately made TryMerge decline outright when vanilla has zero units: the whole document is one "gap" in that case, and gap content always reverts to vanilla on reassembly — so "succeeding" there would have silently discarded both mods' real edits instead of the safe decline this now is.
  • Crash on duplicate insertion names: ReconcileInsertions threw via ToDictionary when one side inserted two same-named units at the same slot — now declines cleanly.
  • NBSP regression: NormalizeGap used plain Trim() instead of the NBSP-safe Trim(WhitespaceChars), reintroducing a bug DiffPlexMergeEngine.NormalizeWhitespace was already fixed for. Now reuses that method directly (promoted to internal) instead of a drifted second copy.
  • Silent content loss: a gap slot made "ineligible" for precise comparison by a nearby deletion got zero signal even when its content actually differed — contradicting the engine's own "never empty content silently" contract. Now emits a conservative, location-described caveat note.
  • Missing description threading: MergeBundleConflictHeadless and MergeTextInteractive (both flat and bundle) never threaded the accumulated-mod-names description MergeFlatConflictHeadless already built, so a bundle-packed or interactively-merged multi-mod chain got a misleading "kept MergedBundleContent's edit" instead of naming the real mods — including in a real GUI modal on the interactive path. All four call sites now share one DescribeAccumulated helper.
  • Decisions misattributed to skipped files: function-level decisions recorded during an earlier, successful chain step still got reported even when a later step failed (or a bundle repack failed) and the file ended up in summary.Skipped, not summary.Merged — plus a casing mismatch between merge.RelativePath and conflict.RelativePath could break exact-match correlation downstream. Decisions are now tracked per relative path and only drained for paths that survived to summary.Merged.
  • Silent bug-swallowing: the bare catch around the new engine swallowed every exception with zero trace, making a genuine bug indistinguishable from an expected decline. Narrowed to the one anticipated exception type; anything else is now reported via the notifier instead of vanishing silently.
  • Performance: ScriptUnitExtractor's @addField lookup rescanned from the current position to EOF on every extracted unit — for a large vanilla file with zero @addField annotations (a mod-only construct), that's millions of wasted line scans per rescue attempt. Now a single forward pass computed once, walked with a monotonic pointer.
  • Docs: fixed a stale CLAUDE.md claim that ScriptUnitExtractor reuses FileEncoding.cs for file I/O — it takes pre-read strings and does none.

Not changed (flagged, not acted on): two review angles raised a philosophical concern about the "most distinct from vanilla wins" tiebreak silently resolving genuine edit-vs-edit collisions with no separate review tier. That's the explicitly-requested, approved design from #18, not a bug — left as-is.

AI-assisted development disclosure (per CONTRIBUTING.md): this PR, including the review that found these issues and the fixes themselves, was developed with Claude Code.

Test plan

  • dotnet build WitcherScriptMerger.sln --configuration Release — clean
  • dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes — clean
  • dotnet test WitcherScriptMerger.sln — 128/128 passing, including 4 new regression tests (zero-vanilla-units decline, duplicate-insertion-name decline, plus updated assertions on 3 existing tests to cover the new ineligible-deletion caveat notes)
  • Real-data re-validation: re-ran the same isolated-scratch-copy end-to-end check used to validate Add function-level merge fallback for whole-file merge failures #18 (real vanilla + real mod files for the 5 conflicts that don't auto-solve today) — all 5 files, including actor.ws, still merge successfully after these fixes, with well-formed UTF-16LE+BOM output and no conflict markers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah

A multi-angle code review of the just-merged function-level merge engine
(PR #18) surfaced several real bugs:

- The function-level rescue's success notification used DialogIcon.
  Information, which HeadlessMergeNotifier routes to stdout - corrupting
  the MCP JSON-RPC stream on every rescue with decisions to report. Now
  DialogIcon.Warning (stderr), matching every other message in this class.
- A file with zero extracted functions/fields (e.g. only top-level
  consts/enums) crashed with IndexOutOfRangeException in GetSideGapIndex,
  silently swallowed by a bare catch - the rescue just never fired for
  exactly this file shape. Fixed the index bug, and separately made
  TryMerge decline outright when vanilla has no units at all: the entire
  document is one "gap" in that case, and gap content always reverts to
  vanilla on reassembly, so "succeeding" here would have silently
  discarded both mods' real edits instead of the safe decline this now is.
- ReconcileInsertions crashed via ToDictionary when one side inserted two
  same-named units at the same slot - now declines cleanly instead.
- NormalizeGap used plain Trim() instead of the NBSP-safe
  Trim(WhitespaceChars), reintroducing a bug DiffPlexMergeEngine.
  NormalizeWhitespace was already fixed for. Now reuses that method
  directly (promoted to internal) instead of a second, drifted copy.
- A gap slot made "ineligible" for precise comparison by a nearby deletion
  got no signal at all when its content actually differed - silently
  contradicting this engine's own "never empty content silently"
  contract. Now emits a conservative, location-described caveat note.
- MergeBundleConflictHeadless and MergeTextInteractive (both flat and
  bundle) never threaded the accumulated-mod-names description
  MergeFlatConflictHeadless already built, so a bundle-packed or
  interactively-merged multi-mod chain got a misleading "kept
  MergedBundleContent's edit" instead of naming the real mods -
  including in a real GUI modal on the interactive path. All four call
  sites now share one DescribeAccumulated helper.
- Function-level decisions recorded during an earlier, successful chain
  step still got reported even when a later step failed (or, for a
  bundle, the repack failed) and the file ended up in summary.Skipped,
  not summary.Merged - plus a casing mismatch between merge.RelativePath
  and conflict.RelativePath could break exact-match correlation by a
  downstream consumer. Decisions are now tracked per relative path and
  only drained for paths that survived to summary.Merged.
- The bare catch around the new engine swallowed every exception with
  zero trace, making a genuine bug indistinguishable from an expected
  decline. Narrowed to the one anticipated exception type; anything else
  is now reported via the notifier instead of vanishing silently.
- ScriptUnitExtractor's @addField lookup rescanned from the current
  position to EOF on every extracted unit - for a large vanilla file with
  zero @addField annotations (a mod-only construct), this meant millions
  of wasted line scans per rescue attempt. Now a single forward pass
  computed once, walked with a monotonic pointer.
- Fixed a stale CLAUDE.md claim that ScriptUnitExtractor reuses
  FileEncoding.cs for file I/O - it takes pre-read strings and does none.

Re-validated end-to-end against an isolated copy of the same 5 real
previously-failing files used to validate PR #18: all 5 still merge
successfully after these fixes, with well-formed output and no conflict
markers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne
TheValiantOne merged commit 0160ed0 into main Aug 10, 2026
1 check passed
@TheValiantOne
TheValiantOne deleted the fix/function-level-merge-review-findings branch August 10, 2026 23:46
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.

1 participant