Skip to content

Route LoadOrderValidator's prompt through Program.Notifier - #1

Merged
TheValiantOne merged 2 commits into
mainfrom
fix/loadordervalidator-messagebox-notifier
Aug 7, 2026
Merged

Route LoadOrderValidator's prompt through Program.Notifier#1
TheValiantOne merged 2 commits into
mainfrom
fix/loadordervalidator-messagebox-notifier

Conversation

@TheValiantOne

@TheValiantOne TheValiantOne commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • LoadOrderValidator.PromptToPrioritizeMergedMod called MessageBox.Show directly instead of Program.Notifier.ShowMessage, unlike every other domain call site in the codebase. Its sole caller (ValidateAndFix) is currently only ever invoked from Forms/MainForm.cs, so this was harmless today, but it's a landmine for any future headless (CLI/MCP) load-order validation path — an unmediated WinForms MessageBox.Show with no message pump watching it. This change routes it through Program.Notifier.ShowMessage, matching the pattern used everywhere else (e.g. LoadOrder/CustomLoadOrder.cs's ShowWarningForMalformedFile).
  • IMergeNotifier.ShowMessage gained a trailing optional MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1 parameter. The original call passed MessageBoxDefaultButton.Button2 (defaulting focus to "No"); dropping it silently would have flipped the Enter-key default from "leave my load order alone" to "rewrite mods.settings" — not behavior-preserving. MainForm.ShowMessage forwards it to the 6-arg MessageBox.Show overload; HeadlessMergeNotifier ignores it (no dialog is ever shown headlessly — see below). Only two types implement IMergeNotifier (MainForm, HeadlessMergeNotifier), both updated; the new parameter is a trailing optional so no existing call site needed changes.
  • The MessageBoxManager.Register()/Cancel = "Ne&ver"/Unregister() wrapping (a SetWindowsHookEx-based hack to relabel the Cancel button "Never") is removed, not preserved, and is a genuine, disclosed regression — not a no-op cleanup. The old hook worked because the old MessageBox.Show call ran directly on the same background thread Register() hooked (no owner window, no marshalling). Program.Notifier.ShowMessageMainForm.ShowMessage marshals the actual MessageBox.Show call onto the UI thread via Invoke whenever called off-thread — which this call always is, since LoadOrderValidator.ValidateAndFix runs inside MainForm's Task.Run. The hook (registered on the calling thread) can no longer see the dialog's window messages once routed through the notifier. Preserving it would require adding custom button-text support to IMergeNotifier, which is out of scope for this fix. User-visible effect: the Cancel button now reads "Cancel" instead of "Never" — clicking it still permanently disables this validation check (unchanged DialogResult semantics), just without a label saying so.
  • ValidateAndFix's Cancel branch is now additionally guarded on Program.Notifier.IsInteractive. HeadlessMergeNotifier's fixed non-destructive default for YesNoCancel is Cancel, which at this specific call site means "Never" → Settings.Set("ValidateCustomLoadOrder", false); Settings.Save(). Without the guard, a future headless caller reaching this code would silently persist a settings change to App.config — exactly the landmine this PR exists to defuse. With the guard, a headless run is a safe no-op instead.

Why

Closes a "safe only by accident" gap flagged for follow-on headless load-order validation work: LoadOrderValidator was the one remaining domain-layer file bypassing the IMergeNotifier abstraction that the rest of the codebase (CustomLoadOrder, FileMerger, AppSettings, Paths, the Tools/* wrappers) already routes through, which is what makes CLI/MCP mode possible for everything else.

Verified

  • dotnet build WitcherScriptMerger.sln succeeds with no new warnings (same 7 pre-existing warnings as main: NU1510, and CA1823 unused-field warnings in unrelated files).
  • dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes passes (exit 0).
  • No GUI automation harness is available in this environment, so this was verified by code inspection rather than an interactive run: confirmed the button set (YesNoCancel), icon (Exclamation), message text, and DialogResult handling in ValidateAndFix are byte-for-byte identical to the original MessageBox.Show call (the "Ne&ver" label change is the one disclosed exception, see above).
  • Confirmed by reading Program.cs that Program.Notifier is reassigned from the default HeadlessMergeNotifier to MainForm immediately after construction, before Application.Run starts the message loop — and confirmed by tracing callers that PromptToPrioritizeMergedMod's only reachable path (MainForm_ShownRefreshMergeInventoryLoadOrderValidator.ValidateAndFix) runs from the Shown event and later user-triggered handlers, never from MainForm's constructor. So this change can't introduce a window where the prompt silently goes to the headless notifier instead of showing a dialog to the interactive user.
  • Grepped the codebase for remaining unmediated MessageBox.Show calls: the only ones left are in Forms/MainForm.cs and Forms/DependencyForm.cs, both GUI-layer code where a direct call is correct (no Program.Notifier indirection needed there).
  • Ran the code-review skill against this branch. It surfaced 7 findings; addressed the 3 in scope (see commit "Address code-review findings..."): simplified enum literals back to unqualified for in-file consistency, strengthened the "Ne&ver" regression comment after review correctly identified it as a real behavior change rather than the inert cleanup an earlier, terser comment could have read as, and documented why HeadlessMergeNotifier accepts but ignores defaultButton. The other 4 findings are out of scope for this unit and are noted below for whoever picks up the relevant follow-on work.

Out of scope, flagged for other units

  • HeadlessMergeNotifier.Write routes MessageBoxIcon.None messages to stdout, which risks corrupting the MCP JSON-RPC stream if reached during an MCP session (pre-existing behavior, not introduced here, but live in a file this PR touches) — belongs to MCP-hardening work.
  • Forms/MainForm.cs's PromptToDeleteForChangedHash (an analogous "permanently disable a check" prompt for ValidateMergeSources) still uses the MessageBoxManager "Ne&ver" relabel and still works there (same-thread, no Program.Notifier involved) — after this PR, the app has one prompt that says "Never" and one that says "Cancel" for structurally similar choices. PromptToDeleteForChangedHash is GUI-layer code outside LoadOrderValidator.cs, so not touched here.

Heads-up for other in-flight units

This PR touches IMergeNotifier.cs, HeadlessMergeNotifier.cs, and MainForm.cs's ShowMessage — the interface a later unit is expected to refactor toward a UI-neutral return type. The added MessageBoxDefaultButton parameter is a trailing optional and can't break any existing caller, but whoever picks up that refactor will hit a textual merge conflict in these three files.

Disclosure

This PR was substantially produced with Claude Code (an AI coding agent), per this repo's AI-assisted-development policy in CONTRIBUTING.md. I've reviewed the diff and can explain any part of it if asked.

Chris Knight and others added 2 commits August 7, 2026 12:48
PromptToPrioritizeMergedMod called MessageBox.Show directly instead of
Program.Notifier.ShowMessage, unlike every other domain call site. Its
sole caller today is invoked only from MainForm.cs, so it was harmless
in practice, but it was a landmine for any future headless (CLI/MCP)
load-order validation path, which would otherwise hit an unmediated
WinForms MessageBox.Show with no message pump watching it.

IMergeNotifier.ShowMessage gained a trailing optional
MessageBoxDefaultButton parameter (default Button1, matching
MessageBox.Show's own default) so the prompt's Button2 (No) default
survives the move - dropping it silently would have flipped the
default action from "leave load order alone" to "rewrite
mods.settings". MainForm.ShowMessage forwards it to the 6-arg
MessageBox.Show overload; HeadlessMergeNotifier ignores it.

The MessageBoxManager relabeling of the Cancel button to "Ne&ver" is
removed rather than preserved: it depends on a SetWindowsHookEx hook
registered on the calling thread, but Program.Notifier.ShowMessage
(MainForm.ShowMessage) marshals the actual MessageBox.Show call onto
the UI thread via Invoke when called off-thread - as this call always
is, via MainForm's Task.Run - so the hook would never see the dialog's
window messages once routed through the notifier. Kept as dead code it
would look functional without being so. The Cancel button now reads
"Cancel" instead of "Never"; the DialogResult value and its handling
in ValidateAndFix are unchanged.

Also guarded ValidateAndFix's Cancel branch on
Program.Notifier.IsInteractive: HeadlessMergeNotifier's fixed
non-destructive default for YesNoCancel is Cancel, which previously
mapped to "Never" here and would have silently persisted
ValidateCustomLoadOrder=false to App.config on any future headless run
that reaches this code path - exactly the landmine this change exists
to defuse.

Verified: Program.Notifier is reassigned to MainForm in Program.cs
before Application.Run, and the only path that reaches
PromptToPrioritizeMergedMod (MainForm_Shown -> RefreshMergeInventory ->
LoadOrderValidator.ValidateAndFix) runs after Shown, never from
MainForm's constructor - so this change doesn't introduce a window
where the prompt silently goes to a HeadlessMergeNotifier instead of
the GUI.

No GUI automation harness is available in this environment; verified
by dotnet build (no new warnings), dotnet format whitespace
--verify-no-changes, and code inspection confirming button set, icon,
message text, and DialogResult handling are unchanged from the
original MessageBox.Show call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
- LoadOrderValidator.cs: simplify back to unqualified MessageBoxButtons/
  MessageBoxIcon/MessageBoxDefaultButton now that the fully-qualified
  form (matched literally to the task's example) reads as inconsistent
  next to the same file's own unqualified DialogResult usage and the
  rest of the codebase's convention wherever `using System.Windows.Forms;`
  is already present.
- LoadOrderValidator.cs: expand the comment on the dropped "Ne&ver"
  relabel. Review correctly pointed out the old MessageBoxManager hook
  genuinely worked before this change (MessageBox.Show ran directly on
  the same background thread Register() hooked, no Invoke involved) -
  this is a real, disclosed regression in how the Cancel button reads,
  not a no-op cleanup, and the comment now says so plainly along with
  why it can't be preserved through Program.Notifier without extending
  IMergeNotifier with custom button-text support (out of scope here).
- HeadlessMergeNotifier.cs: comment on why defaultButton is accepted
  but not consulted when choosing the headless DialogResult, so it
  doesn't read as an oversight to a future caller relying on it.

Not addressed here, flagged for other units instead: HeadlessMergeNotifier
.Write already routes MessageBoxIcon.None messages to stdout, which is a
pre-existing MCP stdout-hygiene risk unrelated to this change (belongs
to the MCP-hardening unit); MainForm.cs's PromptToDeleteForChangedHash
has an analogous still-"Ne&ver"-labeled prompt that now reads
inconsistently with this one, but that method is GUI-layer code outside
LoadOrderValidator.cs's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne
TheValiantOne merged commit 9ed9777 into main Aug 7, 2026
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