Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ The CLI path (`FileMerger.MergeConflictsHeadless`) is a separate, parallel orche

`WitcherScriptMerger.exe merge [--order-file <path.json>]` merges every auto-solvable conflict without opening any window, then exits. No-args still launches the GUI unchanged; passing `merge` is what selects the CLI path in `Program.cs`.

- **Orchestration**: `Cli/MergeOperations.ScanConflicts()` runs `ModFileIndex.BuildAsync` synchronously (via a `ManualResetEventSlim`) and returns the built index; `MergeOperations.RunMerge(inventory, conflicts, mergedModName, orderOverrides)` calls `FileMerger.MergeConflictsHeadless`. Both the `merge` CLI verb and the MCP tools (see "MCP mode" below) call these instead of duplicating the scan/wait/merge sequence. `MergeConflictsHeadless` iterates `ModFileIndex.Conflicts` directly — those are plain `ModFile`/`FileHash` objects (relative path, category, per-mod name and hash), so no `TreeNode`/`ConflictTree` is ever constructed for this path. `MergeFlatConflictHeadless` and `MergeBundleConflictHeadless` mirror `MergeFlatFileNode`/`MergeBundleFileNode`'s logic against that plain data instead of `TreeNode.GetMetadata()`; bundle merges reuse `GetUnpackedFiles`/`PackNewBundle` unchanged. Per-file mod order defaults to `LoadOrderComparer` (matching `ConflictTree`'s own default sort, `Controls/SMTreeSorter.cs`); the `--order-file` JSON (`{"relative\\path.ws": ["modA", "modB"]}`) overrides specific files without requiring every conflict to be listed.
- **Orchestration**: `Cli/MergeOperations.ScanConflicts()` runs `ModFileIndex.BuildAsync` synchronously (via a `ManualResetEventSlim`) and returns the built index; `MergeOperations.RunMerge(inventory, conflicts, mergedModName, orderOverrides)` calls `FileMerger.MergeConflictsHeadless`. Both the `merge` CLI verb and the MCP tools (see "MCP mode" below) call these instead of duplicating the scan/wait/merge sequence. `MergeConflictsHeadless` iterates `ModFileIndex.Conflicts` directly — those are plain `ModFile`/`FileHash` objects (relative path, category, per-mod name and hash), so no `TreeNode`/`ConflictTree` is ever constructed for this path. `MergeFlatConflictHeadless` and `MergeBundleConflictHeadless` mirror `MergeFlatFileNode`/`MergeBundleFileNode`'s logic against that plain data instead of `TreeNode.GetMetadata()`; bundle merges reuse `GetUnpackedFiles`/`PackNewBundle` unchanged. Per-file mod order defaults to `LoadOrderComparer` (matching `ConflictTree`'s own default sort, `Controls/SMTreeSorter.cs`); the `--order-file` JSON (`{"relative\\path.ws": ["modA", "modB"]}`) overrides specific files without requiring every conflict to be listed. Within a listed file's mod list, `FileMerger.ResolveMergeOrder` requires: no unknown mod names, no duplicate names, at least two entries, and every one of that file's *real* source mods present at least once — any violation rejects that one file with a clear error (via `AppState.Notifier.ShowError`) rather than silently merging an incomplete, self-paired, or single-entry (no-op) chain. This applies to both the CLI's `--order-file` and the MCP `merge_conflicts` tool's `orderOverrides`, since both funnel through this shared method. "Real source mods" deliberately excludes the configured merged-mod name itself: once a file has already been merged once, its own merged-mod folder re-enters `conflict.Mods` as if it were a source (see `scan_conflicts`'s tool description below), so re-merging after a source mod's file changes only needs to list the actual mods again, not the previous merge output too — the at-least-two-entries rule still applies, though, so listing only that one remaining real mod (with the merged-mod folder excluded from the requirement) is rejected rather than silently merged with nothing to merge against.
- **`IMergeNotifier`** (Core: `IMergeNotifier.cs`, `NotifyTypes.cs`, `HeadlessMergeNotifier.cs`; host: `MainForm.cs`'s implementation): replaces every direct `Program.MainForm.ShowMessage/ShowError/ShowModal` call in domain code with `AppState.Notifier.*` (host code still spells this `Program.Notifier.*`, via the pass-through property). The interface is defined against neutral `NotifyResult`/`NotifyButtons`/`DialogIcon` types, not `DialogResult`/`MessageBoxButtons`/`MessageBoxIcon` — Core can't reference `System.Windows.Forms` at all. `MainForm` translates those neutral types to/from real `MessageBox.Show(...)`/`DialogResult` calls; this is **not** a behavior-identical passthrough — one real prompt (`LoadOrderValidator`'s "Custom Load Order Problem" dialog) lost a `MessageBoxManager`-based custom button caption that used to mark its Cancel option as destructive/permanent (that caption mechanism was likely already silently broken pre-split — `MessageBoxManager.Register()`'s `AppDomain.GetCurrentThreadId()`-based hook doesn't reliably work on modern .NET — but the loss is real either way; the warning is now spelled out in the message body instead). `ShowModal(Form)` isn't part of `IMergeNotifier` at all — every call site is GUI-only, interactive code in the host project, which calls `MainForm.ShowModal` directly instead of going through the notifier abstraction. `HeadlessMergeNotifier` writes to the console and returns a fixed, non-destructive default for every decision: don't overwrite an existing merge output, don't use a merge name that's still conflicting, don't continue past a canceled/failed merge — except where a caller explicitly overrides that generic default via `ShowMessage`'s `defaultResult` parameter (added for `LoadOrderValidator`, whose YesNoCancel prompt has an inverted-from-usual safety shape: Cancel, not Yes/No, is the one destructive/permanent choice there). This is also what fixed a real null-ref hazard in `CustomLoadOrder.Refresh()`, which used to reach `Program.MainForm` at construction time.
- **`KDiff3.RunHeadless`**: see the "Verify KDiff3 process behavior..." and "KDiff3's pop-up window can't be suppressed" compatibility constraints above for the window-persistence detection this relies on and why the window itself is left alone rather than hidden. `-o` always targets a scratch path under `Paths.TempBundleContent\HeadlessOutput\`, copied to the real output only after a confirmed clean exit — a killed process can never leave a partial file at the real path.
- **Verification status**: the flat-file path (`Categories.Script`/`Categories.Xml`) is verified end-to-end against real conflicting files (including the encoding-mismatch scenario) and a synthetic guaranteed-conflict, in a scratch game/mods tree — never against a live install. The bundle path (`Categories.BundleText`) is code-reviewed and mirrors the proven flat-file orchestration, and its two building blocks (`GetUnpackedFiles`, `PackNewBundle`) are unchanged, already-exercised code — but it hasn't been round-tripped through a real bundle-vs-bundle conflict. If `tempbundlecontent` accumulates across many CLI runs during development, clear it between runs — one debugging session saw a single very slow run after a long buildup that didn't reproduce once it was cleared.
Expand All @@ -86,9 +86,10 @@ The CLI path (`FileMerger.MergeConflictsHeadless`) is a separate, parallel orche
`WitcherScriptMerger.exe mcp` runs an MCP (Model Context Protocol) server over stdio, using the official `ModelContextProtocol` NuGet package (`Host.CreateApplicationBuilder().Services.AddMcpServer().WithStdioServerTransport().WithToolsFromAssembly()`, wired up in `Program.cs`'s `RunMcp`). It's a third entry point alongside the GUI and the `merge` CLI verb — same `args.Length > 0` routing in `Main`, dispatched on `args[0] == "mcp"`. Lets an MCP client (e.g. a Claude Code session) inspect conflicts and drive merges directly instead of only through one-shot CLI invocations.

- **Why stdio, and why logging goes to stderr**: an MCP client spawns WSM as a child process and communicates over its redirected stdin/stdout pipes, so stdout must stay reserved for protocol frames — `RunMcp` configures `builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace)` to keep the SDK's own request-handler logging off stdout. `Program.MaybeAttachConsole()` also skips `AttachConsole` for the `mcp` verb specifically (checks `args[1]` before calling it) — there's no parent console to attach to in this scenario, and it's pointless at best. Verified: a hand-rolled stdio client (`initialize` → `tools/list` → `tools/call` for each tool) round-tripped clean JSON-RPC on stdout with all SDK logging correctly landing on stderr.
- **Tools** (`Mcp/WsmMcpTools.cs`, `[McpServerToolType]` static class): `scan_conflicts` (no args — scans and returns every conflict's relative path, category, per-mod hashes, default merge order, and whether it's already resolved), `merge_conflicts` (optional `relativePaths` to restrict to specific files, optional `orderOverrides` — same shape as the CLI's `--order-file`, minus the file; returns `{merged: [...], skipped: [...]}`), `get_status` (dependency validation, resolved game/mods directories, configured merged-mod name, current conflict count), `list_merges` (enumerates `MergeInventory.xml`'s existing `Merge` records). All four reuse `Cli/MergeOperations` and the same `IMergeNotifier`/`HeadlessMergeNotifier` machinery as the `merge` CLI verb — merge decisions get the same safe non-destructive defaults either way.
- **Tools** (`Mcp/WsmMcpTools.cs`, `[McpServerToolType]` static class): `scan_conflicts` (no args — scans and returns every conflict's relative path, category, per-mod hashes, default merge order, and whether it's already resolved), `merge_conflicts` (optional `relativePaths` to restrict to specific files, optional `orderOverrides` — same shape as the CLI's `--order-file`, minus the file, and validated per-file the same way (see "CLI mode" above — no duplicates, every real source mod covered), optional `dryRun` to preview which conflicts would auto-solve without writing merged output, repacking a bundle, or touching `MergeInventory.xml`; returns `{merged: [...], skipped: [...], unmatched: [...], dryRun}` — `unmatched` lists any requested `relativePaths` entry that's in-scope but no longer a detected conflict), `get_status` (dependency validation, resolved game/mods directories, configured merged-mod name, current conflict count), `list_merges` (enumerates `MergeInventory.xml`'s existing `Merge` records). All four reuse `Cli/MergeOperations` and the same `IMergeNotifier`/`HeadlessMergeNotifier` machinery as the `merge` CLI verb — merge decisions get the same safe non-destructive defaults either way.
- **Directory allow-listing**: `merge_conflicts`'s `relativePaths` and `orderOverrides` keys are validated (`WsmMcpTools.EnsureInScope`/`IsWithinModsDirectory`) to resolve inside `Paths.ModsDirectory` before any scan or merge runs — an absolute path, UNC path, or `..\`-escaping entry is rejected with a clear error instead of silently matching nothing. Neither value is actually joined into a filesystem path anywhere in this codebase today (`relativePaths` is only ever compared for equality against already-scanned `ModFile.RelativePath` values; `orderOverrides` values reach `Path.Combine` in `FileMerger.GetModFile` but only after being validated against `ModFile.ContainsMod`, a whitelist of real scanned mod folder names) — this check is defense-in-depth, not a fix for a live traversal. This check applies mods-directory-relative semantics uniformly to every category; for `Categories.BundleText` specifically, `conflict.RelativePath` is actually a path *internal to a bundle archive* (from `QuickBms.GetBundleContentPaths`), not one rooted at `Paths.ModsDirectory` — an ordinary internal path (e.g. `engine\foo.ws`) still validates fine, but a bundle whose internal listing itself contained a rooted or `..`-bearing entry would make that specific conflict unreachable via `relativePaths` (rejected as out-of-scope even though it's a legitimate conflict). Unexercised: every scratch config used to verify this unit has `CheckBundleContents=false`, consistent with the bundle path's existing "code-reviewed but not round-tripped" verification status below. See `Mcp/CLAUDE.md` for the minimal-permissions summary this unit added.
- **State per call, not cached across calls**: every tool call re-scans (`ModFileIndex.BuildAsync`) and re-loads `MergeInventory.Load(Paths.Inventory)` fresh rather than keeping a long-lived server-side cache, since the mods folder or `MergeInventory.xml` can change between calls (including from a concurrently-running GUI instance) and there's no test suite to catch a staleness bug.
- **Verification status**: smoke-tested end-to-end against the same scratch game/mods tree used for CLI mode verification — `initialize`, `tools/list`, and all four tools called via a hand-rolled stdio client, including a `merge_conflicts` call that exercised the real `KDiff3.RunHeadless` path (detected a genuine conflict, killed the stuck process, returned it in `skipped`). Never run against a live install.
- **Verification status**: smoke-tested end-to-end against the same scratch game/mods tree used for CLI mode verification — `initialize`, `tools/list`, and all four tools called via a hand-rolled stdio client, including a `merge_conflicts` call that exercised the real `KDiff3.RunHeadless` path (detected a genuine conflict, killed the stuck process, returned it in `skipped`). Never run against a live install. The directory-allow-listing and `dryRun` additions were verified against a real KDiff3 stand-in (dependency paths present but not a real KDiff3.exe, so merges reliably fail/skip) via the same stdio-client approach, plus an in-process harness against `WitcherScriptMerger.Core` directly with a fake `IMergeEngine` (real KDiff3/QuickBMS/wcc_lite weren't available in that verification environment) — see the PR that introduced them for exactly what each covered.

### Compatibility constraints

Expand Down
5 changes: 3 additions & 2 deletions WitcherScriptMerger.Core/Cli/MergeOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ public static FileMerger.HeadlessMergeSummary RunMerge(
MergeInventory inventory,
IEnumerable<ModFile> conflicts,
string mergedModName,
IReadOnlyDictionary<string, string[]> orderOverrides)
IReadOnlyDictionary<string, string[]> orderOverrides,
bool dryRun = false)
{
// AppState.MergeEngine is supplied once by the host project at startup
// (Program.cs) - see Tools/IMergeEngine.cs for why Core can't construct
// its one real implementation (KDiff3MergeEngine) itself.
var merger = new FileMerger(inventory, AppState.MergeEngine);
return merger.MergeConflictsHeadless(conflicts, mergedModName, orderOverrides);
return merger.MergeConflictsHeadless(conflicts, mergedModName, orderOverrides, dryRun);
}
}
}
Loading
Loading