Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a3c0a0a
Modernize project to SDK-style .csproj targeting net10.0-windows
Aug 6, 2026
77d2a5a
Replace hand-ported xxHash32 with System.IO.Hashing
Aug 6, 2026
e0fd59c
Actually apply the xxHash32 -> System.IO.Hashing content swap
Aug 6, 2026
ed1ae6f
Add CLAUDE.md and CONTRIBUTING.md; gitignore local-only Claude Code n…
Aug 6, 2026
fe8c827
Fix KDiff3 auto-merge failures caused by mod-file encoding mismatches
Aug 6, 2026
1415981
Correct CLAUDE.md: KDiff3 GUI-fallback claim was a bash-invocation ar…
Aug 6, 2026
a2d2ca7
Decouple domain code from Program.MainForm for headless CLI mode
Aug 6, 2026
510b445
Add non-blocking KDiff3.RunHeadless for CLI mode
Aug 6, 2026
0cd5728
Add headless CLI merge command for flat (.ws/.xml) conflicts
Aug 6, 2026
dfed1ba
Extend headless CLI merge to bundle-packed (.bundle) conflicts
Aug 6, 2026
e087564
Document CLI mode in CLAUDE.md
Aug 6, 2026
79f18ee
Restore focus after headless KDiff3 runs; add MCP server mode
Aug 7, 2026
137e348
Add .editorconfig; reformat C# source from spaces to tabs
Aug 7, 2026
274ba4e
Establish PR-based contribution workflow and AI-agent contribution rules
Aug 7, 2026
431f03d
Route LoadOrderValidator's prompt through Program.Notifier
Aug 7, 2026
c384a48
Add GitHub Actions CI workflow for build and format checks
Aug 7, 2026
611bb43
Add bundle-format replacement research spike (WolvenKit feasibility)
Aug 7, 2026
ac582c2
Add Vortex extension design doc (Unit 4, design only)
Aug 7, 2026
bff23d3
Address code-review findings on the notifier-routing change
Aug 7, 2026
d27b992
Split into WitcherScriptMerger.Core + host GUI/CLI/MCP project
Aug 7, 2026
99d935a
Address code-review findings on the Core/host split
Aug 7, 2026
04b47e2
Address adversarial-review findings on PR #5
Aug 7, 2026
9ed9777
Merge pull request #1 from TheValiantOne/fix/loadordervalidator-messa…
TheValiantOne Aug 7, 2026
570fcdb
Merge pull request #2 from TheValiantOne/chore/add-github-actions-ci
TheValiantOne Aug 7, 2026
48c082a
Merge pull request #3 from TheValiantOne/chore/bundle-format-spike
TheValiantOne Aug 7, 2026
17eb0c9
Merge pull request #4 from TheValiantOne/chore/vortex-extension-desig…
TheValiantOne Aug 7, 2026
45f3df9
Add CODEOWNERS to restrict required-review approvals to the maintainer
Aug 7, 2026
f35bbb4
Merge remote-tracking branch 'origin/main' into feature/split-core-pr…
Aug 7, 2026
2f3f1ee
Merge pull request #5 from TheValiantOne/feature/split-core-project
TheValiantOne Aug 7, 2026
e06d30c
Harden MCP tools for minimal-rights operation: directory allow-listin…
Aug 7, 2026
6f7505c
Merge pull request #6 from TheValiantOne/feature/mcp-directory-allowlist
TheValiantOne Aug 7, 2026
46afad4
Add DiffPlex-based merge engine and a test project
Aug 7, 2026
b2ac346
Merge pull request #7 from TheValiantOne/feature/diffplex-merge-engine
TheValiantOne Aug 7, 2026
aee24c4
Add WitcherScriptMerger.Headless: a Linux-capable CLI/MCP-only host
Aug 7, 2026
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
15 changes: 15 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
root = true

[*]
end_of_line = crlf
insert_final_newline = true
charset = utf-8

[*.cs]
indent_style = tab
indent_size = 4
charset = utf-8-bom

[*.{json,yml,yaml}]
indent_style = space
indent_size = 2
6 changes: 6 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Only reviews from listed code owners count toward the required approving
# review on protected branches (see branch protection settings on `main`).
# This exists because the repo is public - without it, any GitHub user's
# approval would count toward the review requirement, not just this project's
# maintainer(s).
* @TheValiantOne
42 changes: 42 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Build

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
build:
name: Build & format check
runs-on: windows-latest

steps:
# .editorconfig requires CRLF line endings, and .cs files are committed
# as LF (no .gitattributes normalizes this). Force checkout to produce
# CRLF so `dotnet format whitespace` sees the same line endings as a
# local Windows dev machine, regardless of the runner image's git default.
- name: Configure git to check out CRLF line endings
run: git config --global core.autocrlf true

- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
cache: true
# No packages.lock.json in this repo (setup-dotnet's cache default),
# so key the NuGet cache off the csproj's pinned package versions instead.
cache-dependency-path: WitcherScriptMerger/WitcherScriptMerger.csproj

- name: Restore
run: dotnet restore WitcherScriptMerger.sln

- name: Build
run: dotnet build WitcherScriptMerger.sln --no-restore --configuration Release

- name: Verify formatting
run: dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes
28 changes: 28 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,31 @@ FakesAssemblies/

# Visual Studio 6 workspace options file
*.opt

# Local-only Claude Code session/handoff notes (machine-specific paths,
# in-progress personal task context) - not meant to be shared in the repo.
HANDOFF*.md

# Claude Code runtime state (scheduled tasks, worktrees, checkpoints, etc.)
# Mirrors this machine's .git/info/exclude so the exclusion travels with the
# repo instead of depending on local, unshared git config. Deliberately
# scoped to runtime state, not a blanket .claude/ ignore - things like
# .claude/commands/ or .claude/agents/ may be intentionally committed.
.claude/scheduled_tasks.lock
.claude/scheduled_tasks.json
.claude/routines/.state/
.claude/worktrees/
.claude/checkpoints/
.claude/mailbox/
.claude/agent-registry.json
.claude/agent-memory-local
.claude/first-run
.claude/assistant-daemon-state.json

# Aider's local session/cache state - same reasoning as the .claude/ block above.
.aider*

# If you're using another agentic coding tool that writes local-only runtime
# state into this repo (session logs, caches, scratch indexes), add a scoped
# entry here rather than committing it - see the AI-assisted development
# section of CONTRIBUTING.md.
156 changes: 156 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Contributing

This is a public repository, and contributions — human or AI-agent-assisted — are welcome. See `CLAUDE.md` for build commands, architecture, and compatibility constraints; this file covers style and process. For the project's own fork lineage (this repo vs. the upstream `AnotherSymbiote/WitcherScriptMerger` project), see `CLAUDE.md`'s Project overview.

## Code style

Match the existing source (e.g. `Inventory/FileMerger.cs`, `Controls/SMTree.cs`) rather than introducing a different style in new code. Enforced via `.editorconfig` — run `dotnet format whitespace WitcherScriptMerger.sln` before opening a PR if you're not sure your editor is honoring it:

- **Tabs, not spaces**, for indentation (`.editorconfig`: `indent_style = tab`, `indent_size = 4`).
- Allman brace style (opening brace on its own line).
- Larger classes group members under `#region Types` / `#region Members` blocks.
- Private fields are `_camelCase` with the access modifier omitted (the codebase's own history includes a commit removing unnecessary explicit access modifiers — don't reintroduce them).
- Expression-bodied members for simple, single-expression methods/properties.
- Single-statement `if` bodies are sometimes left unbraced on the following line; this isn't universal, use judgment based on surrounding code.
- `.cs` files are UTF-8 **with a BOM**, CRLF line endings — matches the existing codebase and `.editorconfig`.

## Repository SOP

- **`main` is protected.** No direct commits or pushes — all changes land via pull request. Force-pushes and branch deletion are disabled on `main` at the GitHub level.
- **Branch per feature/fix**, off `main`: `feature/<short-description>` for new functionality, `fix/<short-description>` for bug fixes, `chore/<short-description>` for tooling/process/docs changes not tied to a feature or bug. Keep the description short and kebab-case (e.g. `fix/kdiff3-encoding-mismatch`).
- **Pull requests require 2 approving reviews** before merge (GitHub branch protection on `main`). This applies to everyone, including repository admins in normal circumstances — admin bypass exists at the platform level for genuine emergencies, not as a routine shortcut.
- **PR description should cover**: what changed and why, and — given there's no test suite (see Testing below) — specifically *how you verified it*. "Builds successfully" is necessary but not sufficient for anything touching hash output, `MergeInventory.xml` schema, KDiff3/QuickBMS/wcc_lite invocation, or encoding handling; see `CLAUDE.md`'s Compatibility constraints for why those are load-bearing, and its Tests section for the verification pattern this codebase uses in place of a test suite.
- Commit messages are short, descriptive sentences (e.g. `Fixed crash after canceling file-open.`, `Replace hand-ported xxHash32 with System.IO.Hashing`). A `Category:` prefix (`Fixed:`, etc.) shows up occasionally but isn't enforced. No Conventional Commits format required.
- GitHub Actions CI (`.github/workflows/build.yml`) runs `dotnet build --configuration Release` and `dotnet format whitespace --verify-no-changes` on every PR targeting `main`, but don't rely on it to catch problems for you — run both locally first: `dotnet build WitcherScriptMerger.sln --configuration Release` and `dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes` before opening a PR. Catching failures before CI does saves a round trip.
- External binary dependencies (KDiff3, QuickBMS, wcc_lite — see `CLAUDE.md`'s External tool dependencies) aren't in source control, so a fresh clone needs them sourced separately before the app runs end-to-end. PRs that only touch code not exercising those tools don't need them to build and review.

## Testing

There's no test project in this repo. For changes that touch hash output, `MergeInventory.xml` schema, or KDiff3 invocation, use a disposable, non-committed scratch console app: exercise synthetic edge cases plus a cross-check against a real value already recorded in a live `MergeInventory.xml`. See `CLAUDE.md`'s Tests section for the specifics of why this matters for this codebase. Describe what you actually ran in your PR description — see Repository SOP above.

## AI-assisted development

This repository is developed with AI coding agents (Claude Code, and expect others), openly — that's not hidden, and it's not discouraged. `CLAUDE.md` carries the operational guidance these tools use when working in this repo, kept up to date as the codebase changes; read it before pointing an agent at this repo. If these guidelines are silent on something and you're using an agent, defer to the explicit rules below over whatever the agent proposes on its own.

- **Disclose it.** If a PR was substantially produced or assisted by an AI coding agent, say so in the PR description. Commits already carry a `Co-Authored-By` trailer when an agent is involved (Claude Code does this automatically) — that's necessary but not sufficient; the PR description is where a reviewer looks first.
- **You own what you submit, regardless of how it was produced.** Be able to explain any part of your own PR if a reviewer asks — "the agent wrote it that way" isn't an answer to "why does this work." If you can't explain a change, that's a signal to understand it better before submitting, not to submit it anyway.
- **The verification bar doesn't move for AI-assisted changes — if anything, hold it higher.** This codebase has no test suite and several genuinely load-bearing, non-obvious compatibility constraints (hash format, KDiff3 encoding normalization, the window-persistence detection in headless mode — all documented in `CLAUDE.md`). Agents are good at producing code that looks plausible and compiles; they have no way to know these constraints exist unless `CLAUDE.md` tells them, and no way to know their fix actually works unless it's actually run against real data. "Should work" is not verification — see Testing above.
- **Scrub machine-specific state before submitting.** Agent-assisted sessions tend to accumulate absolute local paths, scratch config pointing at a personal install, or test artifacts from the working process — check your diff for anything like a `G:\SteamLibrary\...`-style path or a personal game install location before opening a PR. `.gitignore` excludes common agent runtime-state directories (`.claude/`, `.cursor/`, etc.) and session handoff notes (`HANDOFF*.md`) for the same reason — extend it rather than working around it if your tool of choice uses a different local-state convention.
- **You're responsible for license compatibility of anything an agent produces**, same as for hand-written code — this project cares about this already (see `CLAUDE.md`'s External tool dependencies section on why QuickBMS/wcc_lite specifically aren't bundled). Don't accept agent output that reproduces code from a source with an incompatible license.
- **Bulk or automated PRs still go through the same process.** A large refactor being agent-generated isn't a reason to skip branch-per-change, PR review, or the two-approval requirement — if anything, larger diffs benefit more from review, not less.
96 changes: 96 additions & 0 deletions WitcherScriptMerger.Core/AppSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System;
using System.Configuration;
using System.Reflection;

namespace WitcherScriptMerger
{
public class AppSettings
{
string _assemblyPath;

Configuration _cachedConfig;
Configuration CachedConfig
{
get
{
if (_cachedConfig == null)
_cachedConfig = ConfigurationManager.OpenExeConfiguration(_assemblyPath);
return _cachedConfig;
}
}

public bool HasConfigFile => CachedConfig.HasFile;

public AppSettings()
{
_assemblyPath = Assembly.GetEntryAssembly().Location;

if (!CachedConfig.HasFile)
{
AppState.Notifier.ShowError("Config file is missing.", "Script Merger Error");
Environment.Exit(1);
}
}

public void Set(string key, object value)
{
try
{
CachedConfig.AppSettings.Settings[key].Value = value.ToString();
}
catch
{
CachedConfig.AppSettings.Settings.Add(key, value.ToString());
}
}

public T Get<T>(string key)
{
try
{
if (CachedConfig.HasFile)
{
var valueString = CachedConfig.AppSettings.Settings[key].Value;
var parseMethod = typeof(T).GetMethod("Parse", new Type[] { typeof(string) });
var valueObject = parseMethod.Invoke(null, new object[] { valueString });
return (T)valueObject;
}

AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}");
return default(T);
}
catch
{
return default(T);
}
}

public string Get(string key)
{
try
{
if (CachedConfig.HasFile)
return CachedConfig.AppSettings.Settings[key].Value;

AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}");
return string.Empty;
}
catch
{
return string.Empty;
}
}

public void Save()
{
try
{
CachedConfig.Save(ConfigurationSaveMode.Minimal);
}
catch (Exception ex)
{
AppState.Notifier.ShowError($"Failed to save config due to error:\n\n{ex.Message}");
}
}
}
}
80 changes: 80 additions & 0 deletions WitcherScriptMerger.Core/AppState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Threading;
using WitcherScriptMerger.Inventory;
using WitcherScriptMerger.LoadOrder;
using WitcherScriptMerger.Tools;

namespace WitcherScriptMerger
{
// Shared mutable application state, previously held directly by the host
// project's Program class. It moved out to Core during the Core/host project
// split because domain code that now lives in Core (Paths, AppSettings,
// ModFileIndex, FileMerger, CustomLoadOrder, Cli/MergeOperations,
// Mcp/WsmMcpTools, ...) needs to read/write it, and Core can never reference
// the host assembly (that's the whole point of the split - the dependency only
// flows host -> Core). The host project's Program class re-exposes these as
// pass-through Notifier/Settings/LoadOrder/Inventory properties so none of its
// own call sites had to change.
//
// An explicit static constructor suppresses `beforefieldinit`, so this class's
// field initializers run at a precise, well-defined point (first member access)
// rather than at some unspecified point the CLR chooses - load-bearing here
// because Program.MaybeAttachConsole() must run before Settings' constructor
// can report a missing-config error to the invoking terminal (see CLAUDE.md's
// Startup flow). Paths.cs used to have the same beforefieldinit hazard one hop
// further out (its own static field initializers read Settings.Get(...)
// eagerly) - fixed by making those Paths properties compute on every access
// instead of caching via a field initializer, so Settings' laziness isn't
// undermined transitively; see Paths.cs.
public static class AppState
{
// Defaults to the headless implementation so it's safe to use from the very
// first line of Main() - the GUI path swaps it out for MainForm once
// constructed. See CLAUDE.md's IMergeNotifier section.
public static IMergeNotifier Notifier = new HeadlessMergeNotifier();

// Lazy rather than a field initializer: AppSettings' constructor calls
// Environment.Exit(1) if it can't find a config file next to the entry
// assembly (see AppSettings.cs) - appropriate for the real GUI/CLI/MCP entry
// points, where that's genuinely fatal, but not for WitcherScriptMerger.Tests,
// whose test host has no matching .config. Since C# runs ALL of a type's
// static field initializers together on first touch of ANY static member,
// Settings being a plain field-with-initializer meant merely reading
// AppState.Notifier (which Core code - e.g. DiffPlexMergeEngine's headless
// skip/guard messages - legitimately does on its own, unprompted by test code)
// silently also ran `new AppSettings()` and crashed the whole test process.
// Making Settings lazy decouples the two: touching Notifier alone no longer
// forces Settings to construct. Confirmed no call site assigns AppState.Settings
// or Program.Settings, so keeping this settable (for symmetry with the other
// fields here, and in case a future test wants to inject a stub) is a safe,
// behavior-preserving change for every existing GUI/CLI/MCP call site: first
// real access still runs the identical `new AppSettings()` and identical
// crash-on-missing-config behavior, just deferred to that first access instead
// of eagerly.
//
// LazyInitializer.EnsureInitialized (rather than the simpler
// `_settings ?? (_settings = new AppSettings())`) makes this thread-safe: the
// simpler form is a classic non-atomic check-then-act race that could, under
// concurrent first access, construct AppSettings() more than once (each with
// its own real side effects, including a possible Environment.Exit(1)).
// Currently unreachable from any shipped entry point or the test suite (all
// single-threaded at this point in startup) - flagged in code review as a
// latent risk anyway, since other Core statics (e.g. QuickBms.cs/WccLite.cs)
// also read AppState.Settings.Get(...) from their own static field
// initializers, and nothing prevents a future concurrent caller.
static AppSettings _settings;
public static AppSettings Settings
{
get => LazyInitializer.EnsureInitialized(ref _settings, () => new AppSettings());
set => _settings = value;
}

public static CustomLoadOrder LoadOrder = null;
public static MergeInventory Inventory = null;

// Set once by the host project at startup (see Program.cs) to a
// KDiff3MergeEngine - see Tools/IMergeEngine.cs for why this exists.
public static IMergeEngine MergeEngine = null;

static AppState() { }
}
}
42 changes: 42 additions & 0 deletions WitcherScriptMerger.Core/Cli/MergeOperations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using WitcherScriptMerger.FileIndex;
using WitcherScriptMerger.Inventory;

namespace WitcherScriptMerger.Cli
{
// Shared scan/merge orchestration behind both the `merge` CLI verb (Program.cs) and the
// MCP tools (Mcp/WsmMcpTools.cs) - see CLAUDE.md's CLI mode / MCP mode sections.
public static class MergeOperations
{
public static ModFileIndex ScanConflicts()
{
var modIndex = new ModFileIndex();
using (var scanComplete = new ManualResetEventSlim(false))
{
modIndex.BuildAsync(
AppState.Settings.Get<bool>("CheckScripts"),
AppState.Settings.Get<bool>("CheckXmlFiles"),
AppState.Settings.Get<bool>("CheckBundleContents"),
(s, e) => { },
(s, e) => scanComplete.Set());
scanComplete.Wait();
}
return modIndex;
}

public static FileMerger.HeadlessMergeSummary RunMerge(
MergeInventory inventory,
IEnumerable<ModFile> conflicts,
string mergedModName,
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, dryRun);
}
}
}
Loading