Stop re-reporting the same conflicts as needing manual resolution - #47
Merged
TheValiantOne merged 2 commits intoAug 26, 2026
Merged
Conversation
A live 350-mod run reported three conflicts as "needs manual resolution" on every single pass, with no way to make it stop. The merge engine was right to decline all three - each has a source mod shipping a whole-file copy of a vanilla script taken from an older game build, so merging that side would delete vanilla code the game and the other mod still call. But nothing said which mod to fix, and two separate gaps guaranteed the same files came back. Four changes, all found together: - Tools/StaleBuildDetector.cs runs the vanilla-declaration comparison as a pre-flight, off the files, before any merge, so a warning names the stale mod and the remedy (update or disable it) instead of pointing at a 2500-line file to hand-merge. It's a diagnostic, never a gate - its message reports drift and its usual consequence rather than predicting this conflict will fail, because it can't know that: a real install had one mod missing 1 of 224 declarations whose conflict auto-solved every time, and another missing 13 that didn't. - MergeInventory.HasResolvedConflict now requires the merged output to exist. Every hash it checked belonged to a source mod - all present and unchanged when it's the output that was deleted - so a record whose merged file was gone answered "resolved" forever and nothing re-merged it, leaving the game to load exactly one of two conflicting mods with nothing saying so. The GUI already refused to trust such a record; Inventory/MergeInventoryHygiene.cs lifts that rule and its two siblings out so the CLI, MCP and Vortex extension see them too. They report rather than act: the GUI's prompts pass no defaultResult, so HeadlessMergeNotifier would answer its generic YesNo => No and prune nothing. - ModFileIndex now skips mod folders mods.settings marks Enabled=0. A scan is a filesystem glob, so a deployed-but-disabled mod counted as a full conflict participant and could keep a conflict permanently unmergeable - which is exactly what one of the three was. Only an explicit Enabled=0 excludes a mod; an absent or unreadable mods.settings disables nobody. MergeDisabledMods opts out, named for the opt-out so an older App.config yields the new behavior. - HeadlessMergeNotifier.RouteAllOutputToStandardError, set by both hosts' mcp verb. Only Error/Warning/Exclamation went to stderr, so any default-icon message a scan emits landed on stdout - mid-JSON-RPC-stream in MCP mode. Caught by a real scan_conflicts round-trip coming back unparseable, with the new disabled-mod notice spliced between two protocol frames. Pre-existing: BuildAsync's "Can't find any mods" had the same shape. Verified against the live install: 44 conflicts with 3 unmergeable and one inventory record with no output file, before; 43 conflicts and 43/43 resolved with zero skips, after. Re-running with WSM_MergeDisabledMods=true brings the phantom back and prints the stale-build warning naming the mod and 13 of the 224 declarations it lacks. 239 tests pass; format check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6y39x9F3SKcS4hhpw85Yi
…od skip
Core's CLAUDE.md gains three sections ("Disabled mods are excluded from the
conflict scan", "Stale-build pre-flight", "Inventory hygiene"), the folder map
and MCP tool return shapes are updated, and the IMergeNotifier section now
covers why MCP mode has to force notifier output to stderr.
docs/bugs/stale-mod-builds-and-phantom-merge-records.md records the episode in
the same shape as function-level-merge-gap-handling.md: what recurred, the three
real files and their blocking mods, why each gap kept it recurring, and the
before/after numbers from the live install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6y39x9F3SKcS4hhpw85Yi
TheValiantOne
deleted the
feature/stale-build-preflight-and-inventory-hygiene
branch
August 26, 2026 03:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What prompted this
A live 350-mod, game-build-4.04 install reported three conflicts as "needs manual resolution" on every run, with no way to make it stop:
game\vehicles\horse\states\exploration.wsmodFearlessRoachEnabled=0)game\gui\menus\mapMenu.wsmodFastTravelFromAnywheregame\r4Game.wsmodAlwaysFullExpThe engine was right to decline all three — each has a source mod shipping a whole-file copy of a vanilla script taken from an older game build, so merging that side would delete vanilla code the game and the other contributing mod still call. That's the
ValidateWholeFileMergeOutputinvariant from #42 doing its job. The problem was that nothing said which mod to fix, and two separate gaps guaranteed the same files came back next run.Changes
Tools/StaleBuildDetector.cs— runs the same vanilla-declaration comparison as a pre-flight, straight off the files with no merge involved, so the report names the stale mod and the remedy instead of pointing at a 2500-line file to hand-merge. UsesScriptUnitExtractor's ownScopedName, so a pre-flight warning and a post-hoc violation name the same thing.It is a diagnostic, never a gate — nothing here changes what does or doesn't merge, and the message deliberately stops short of predicting failure, because the check runs before any merge and can't know: on the live install one mod is missing 1 of 224 declarations and its conflict auto-solves every time, while another missing 13 reliably trips the invariant.
Inventory/MergeInventoryHygiene.cs+HasResolvedConflict— a record whose merged output file is gone was self-certifying: every hash it verifies belongs to a source mod, all present and unchanged when it's the output that was deleted. So it answeredalreadyResolved: trueforever, nothing ever re-merged it, and the game silently loaded exactly one of two conflicting mods. The GUI already refused to trust such a record (MainForm.RefreshMergeTree→ConfirmPruneMissingMergeFile, plus two siblings for a missing/disabled source mod) — all three rules were GUI-only. They now live in Core so the CLI, MCP tools and Vortex extension see them too.They report rather than act, deliberately: the GUI's three prompts all pass no
defaultResult, soHeadlessMergeNotifierwould answer its genericYesNo → Noto every one and prune nothing, silently — the same defect shape as theConfirmOutputOverwritefinding already filed indocs/bugs/function-level-merge-gap-handling.md.ModFileIndexdisabled-mod skip — a scan is a filesystem glob, so a deployed-but-disabled mod counted as a full conflict participant and could keep a conflict permanently unmergeable. Only an explicitEnabled=0excludes a mod; one absent frommods.settingscounts as enabled (the game appends unknown folders on next launch), and a missing/unreadablemods.settingsdisables nobody — so this can never make a scan miss conflicts on a fresh install or a Linux host.MergeDisabledModsopts out, named for the opt-out so its absence from an olderApp.configyields the new behavior rather than silently keeping the old one. Skipped folders are always reported, never silent.HeadlessMergeNotifier.RouteAllOutputToStandardError— found while testing the above. OnlyError/Warning/Exclamationrouted to stderr, so any default-icon message a scan emits went to stdout, which inmcpmode carries the JSON-RPC frame stream. Caught by a realscan_conflictsround-trip returning a payload the client couldn't parse, with the new disabled-mod notice spliced between two protocol frames. Pre-existing, not introduced here —BuildAsync's "Can't find any mods in the Mods directory." has the identical shape — so it's fixed at the root, set by both hosts'mcpverb. CLI mode is unaffected.Verification
Run against the real install (dry-run/MCP only; the deployed tool and live inventory were never touched — a scratch copy was used):
WSM_MergeDisabledMods=truethe phantom returns and the stale-build warning fires, namingmodFearlessRoachand 13 of the 224 declarations it lacks — both features confirmed on one run.dotnet buildclean, 239 tests pass,dotnet format whitespace --verify-no-changesclean.Test coverage
23 new tests. Both new test files stay inside the
AppState.Settings-safety constraint inWitcherScriptMerger.Tests/CLAUDE.md— the pure two-string core of the detector is covered fully; the hygiene rules are covered only on the branches that return before resolving a real path (bundle exemption, null/empty guards, message formatting), since the flat-file ones reachPaths.ModsDirectory→AppState.Settings→Environment.Exit(1)in a test host.ExcludeDisabledModPathswas split out as a pure function for the same reason, matching the existingBuildIgnoredModNamesprecedent, and is covered fully — including that a null predicate or null path list excludes nothing, so an unreadablemods.settingscan't make a scan silently report zero conflicts.AI-assisted development
This PR was produced with Claude Code (Opus 5), per
CONTRIBUTING.md. The diagnosis, the four fixes, the tests and the docs were all agent-written; every claim above was verified by actually running it against the real install and the real test suite, not inferred.🤖 Generated with Claude Code
https://claude.ai/code/session_01N6y39x9F3SKcS4hhpw85Yi