From c4b732475867c9727b4f9de5eb26dfa4f423872e Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 21 Aug 2026 20:35:29 +1000 Subject: [PATCH] Show the macOS viewer window on a normal launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeWindow() built the NSWindow, centred it and attached the scroller, but never ordered it front. The only makeKeyAndOrderFront was in show(), reached from deview_set_hidden(0) and deview_focus, which the managed loop issues only while draining window commands off the socket. So the ordinary launch — a patch on stdin, no second patch behind it — presented at 60 fps into a window that was never on screen: a Dock icon with nothing under it until another patch happened to arrive. CI never saw it because the pixel tests capture with hidden: true, which is the one path that deliberately builds no window at all. A visible open now goes through show() as well, so ordering front lives in one place rather than being a property of how the process was driven. Note that the committed dylibs under src/DiffEngineViewer.Mac/runtimes are not rebuilt by this commit; they need a build-native run. --- native/swift/Sources/Deview/Runtime.swift | 13 ++- todo.md | 128 ++++++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 todo.md diff --git a/native/swift/Sources/Deview/Runtime.swift b/native/swift/Sources/Deview/Runtime.swift index 2660dcad..c89df882 100644 --- a/native/swift/Sources/Deview/Runtime.swift +++ b/native/swift/Sources/Deview/Runtime.swift @@ -50,8 +50,12 @@ final class Runtime { // touching AppKit at all in that case is what lets the pixel tests run: NSWindow may only // be instantiated on the main thread, and a test host runs them on whatever thread it // likes. The app itself always starts visible, from Main, which is the main thread. + // show() rather than makeWindow(), because a window that is built but never ordered + // front is a Dock icon with nothing under it: the app would then present at 60 fps into + // an invisible window until a second patch arrived over the socket and drove + // deview_set_hidden/deview_focus, which is the only other path to makeKeyAndOrderFront. if !hidden { - makeWindow() + show() } measureGrid() @@ -255,9 +259,10 @@ final class Runtime { input.rows = grid.rows } - /// Builds the window if this runtime started headless, so a hidden start is still only a - /// deferral rather than a different contract from the other heads. Only reachable from the - /// managed loop's thread, which is the main one. + /// Presents the window, building it first if this runtime started headless, so a hidden start + /// is still only a deferral rather than a different contract from the other heads. Also how a + /// visible `open` presents, so ordering front happens in exactly one place. Only reachable + /// from the managed loop's thread, which is the main one. func show() { makeWindow() window?.makeKeyAndOrderFront(nil) diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..7afc07fb --- /dev/null +++ b/todo.md @@ -0,0 +1,128 @@ +# DiffEngine bug audit — todo + +Audit of the working tree at 9d39435c (2026-08-21). Eight area reviews (inline patcher and literals; inline queue, staging and applier; wire protocol and tray client; core runner and tool definitions; DiffEngineTray; viewer core; viewer Windows head; native shim and interop), each candidate then re-verified against the code. Items tagged **ran** were reproduced by executing code (a temporary TUnit probe against `InlinePatcher`, DiffPlex loaded directly); **read** items were confirmed by tracing the cited code paths and their callers; **plausible** items rest on documented platform behaviour that could not be executed here (no Mac or Linux box). Baseline: all three suites green (DiffEngine.Tests 1105 passed / 9 skipped, DiffEngineTray.Tests 185 / 0, DiffEngineViewer.Tests 223 / 9 — the skips are platform and tray gates). The Release build break in `InlineStaging.Clear` (CS1573, introduced by f87854b2) was fixed during the audit in 9d39435c and is not listed. + +## Start here + +The ten with the widest blast radius: + +2. macOS viewer never shows its window on a normal launch (native) +3. `DiffRows` ignores whitespace-only differences, so such a failure renders as "no change" (viewer core) +4. C# lexer reads `@"""` as a raw string and loses every call after it (patcher) +5. An idiomatic hand-written F# triple-quoted literal can never be patched (patcher) +6. `TrySendAsync` has no deadline, so an unresponsive queue owner hangs the failing test forever (protocol) +7. Tray: a failed accept leaves a disposed `Process` behind; the hotkey and "Open diff tool" then throw (tray) +8. Tray: "Purge verified files" kills the whole tray on any inaccessible subdirectory (tray) +9. Windows viewer refuses `WM_QUERYENDSESSION`, blocking shutdown (Windows head) +10. `ProcessCleanup` process list is captured once per process, so in-process `Kill` and relaunch detection never see a tool launched after first use (core) + +## Inline patcher and literals (`src/DiffEngine/Inline`) + +- [ ] **High · ran** — [CsLanguage.cs:209](src/DiffEngine/Inline/CsLanguage.cs:209) `TrySkipStringLike` measures the quote run before checking `verbatim`, so `@"""C:\tools\run.exe"" --flag"` (a verbatim string opening on an escaped quote) is lexed as a 3-quote raw string that runs to EOF and hides every call after it. Probe: that statement above `await Snapshot("old");` → `NotFound` "Could not find a Snapshot call near line 6". [FsLanguage.cs:366](src/DiffEngine/Inline/FsLanguage.cs:366) and [CsStringLiteral.cs:97-104](src/DiffEngine/Inline/CsStringLiteral.cs:97) already guard this shape; add `&& !verbatim` to the `quotes >= 3` branch. +- [ ] **High · ran** — [FsStringLiteral.cs:134](src/DiffEngine/Inline/FsStringLiteral.cs:134) → [StringLiteral.cs:227](src/DiffEngine/Inline/StringLiteral.cs:227): an F# `"""…"""` whose content starts on the opening line (`Snapshot("""{\n "a": 1\n}""")`, the idiomatic hand-written shape) fails `TryStripLayout`, so `TryParse` rejects it while `StripLayout` (what the test library uses for the value) returns it verbatim; the producer's `OriginalValue` therefore can never match. Probe → `NotFound` "The expected argument of the Snapshot call near line 5 is not a string literal". Fall back to the verbatim newline-normalised content when layout stripping fails, mirroring `StripLayout`; `FsStringLiteralTests.ParseRejectsMalformedIndent` pins the bad-indent case and needs revisiting with it. +- [ ] **Medium · ran** — [InlinePatcher.cs:916-932](src/DiffEngine/Inline/InlinePatcher.cs:916) `TrimSpan`'s trailing loop trims whitespace before looking for a comment, which eats the `\n` that ends a line-comment skip span (comment spans include their newline, [CsLanguage.cs:133](src/DiffEngine/Inline/CsLanguage.cs:133)), so `""" // TODO` followed by `)` on the next line keeps the comment inside the argument. Probe → `NotFound` "is not a string literal". Check `TryGetCommentEndingAt(end)` before trimming whitespace. +- [ ] **Medium · ran** — [InlinePatcher.cs:81-84](src/DiffEngine/Inline/InlinePatcher.cs:81) `TryRemove` ignores `originalExpression`/`originalValue` and deletes the `Snapshot` call nearest the hint. Probe: two calls (`"one"` line 5, `"two"` line 6), Remove with expression `"one"` and hint 6 → removed `"two"`, status `Applied`. When an anchor is supplied, require the removed call's argument to match it. +- [ ] **Medium · ran** — [InlinePatcher.cs:582-592](src/DiffEngine/Inline/InlinePatcher.cs:582) `FindCalls` probes the recorded line first whenever it is at or below the member's declaration, with no ceiling at the next member. Probe: `A` and `B` both snapshot `"dup"`, patch for member `A` with a stale hint on `B`'s line → `B` rewritten, `A` untouched, `Applied`. Only honour the hint when no other declaration sits between the member and the hint. +- [ ] **Medium · ran** — [InlinePatcher.cs:457-507](src/DiffEngine/Inline/InlinePatcher.cs:457) `WalkChain` has a chain terminator only for F# (`ToTask`, [FsLanguage.cs:31](src/DiffEngine/Inline/FsLanguage.cs:31)); C# ([SourceLanguage.cs:92](src/DiffEngine/Inline/SourceLanguage.cs:92)) appends after the last call. Probe: Append onto `Verify(x).GetAwaiter().GetResult();` → `Verify(x).GetAwaiter().GetResult()\n .Snapshot("new");`, which does not compile. Give C# a terminator set (`GetAwaiter`, `ToTask`, `ConfigureAwait`, `AsTask`). +- [ ] **Low · ran** — [InlinePatcher.cs:102](src/DiffEngine/Inline/InlinePatcher.cs:102) normalises the needle to the file's dominant EOL and `Matches` ([:301-305](src/DiffEngine/Inline/InlinePatcher.cs:301)) compares bytes, so a raw literal whose own lines use the minority ending never matches. Probe: CRLF file, LF literal, LF expression → `NotFound` "The previous expected expression was not found near line 5". Compare newline-normalised on both sides. + +## Inline queue, staging, applier, launcher (`src/DiffEngine`) + +- [ ] **Medium · read** — [BundledViewerDirectory.cs:85-89](src/DiffEngine/Viewer/BundledViewerDirectory.cs:85) says the probe misses on Alpine so the caller falls through to the dotnet tool, but [:113-116](src/DiffEngine/Viewer/BundledViewerDirectory.cs:113) then yields `linux-{arch}` for any Linux, so the glibc apphost resolves on musl. Inline falls back to staging (the launch failure is swallowed), but a file-snapshot `DiffRunner.Launch` that selected the viewer throws from `LaunchProcess` ([DiffRunner.cs:262-297](src/DiffEngine/DiffRunner.cs:262)). Same shape in `NativeResolver.Rids` ([NativeResolver.cs:122-153](src/DiffEngineViewer/Native/NativeResolver.cs:122)). Skip the synthesised RID when `RuntimeInformation.RuntimeIdentifier` is available (net6+), or at least when it contains `-musl-`. +- [ ] **Medium · read (design call)** — [InlineStaging.cs:74-121](src/DiffEngine/Inline/InlineStaging.cs:74) `Clear` is call-site scoped (no framework), while `SettleInline` ([DiffRunner_Inline.cs:87-96](src/DiffEngine/DiffRunner_Inline.cs:87)) and `InlineQueue.Settle` are origin scoped. In a `net8.0;net9.0` run with no owner where net8 fails and net9 passes, net9's settle deletes net8's staged trio and the still-failing snapshot is pending nowhere. `InlineStagingTests.ClearRemovesEveryFrameworksTrio` pins the current behaviour as intended, so this is a decision rather than a slip: add an optional origin to `Clear` (retire passes null) or document why staging differs from the queue. +- [ ] **Low · read** — [InlineApplier.cs:202-229](src/DiffEngine/Inline/InlineApplier.cs:202) writes through a temporary and `File.Replace`s it; on Linux/macOS that is a `rename`, so a symlinked source file becomes a regular file (the link target keeps the old literal) and a non-default mode/group is replaced by the temporary's umask defaults. Resolve the link target before patching and copy the mode onto the temporary (`File.GetUnixFileMode`/`SetUnixFileMode` on net7+). +- [ ] **Low · read** — [BundledViewerDirectory.cs:45-80](src/DiffEngine/Viewer/BundledViewerDirectory.cs:45) (.NET Framework only) returns the first loaded assembly's `DiffEngine.ViewerDirectory` value without checking that it exists; prebuilt dependencies (the cached `Verify.dll` for net48, for one) carry the path of the machine that built them. Load order makes the stamped test assembly win today, which is not a guarantee. Prefer the entry assembly and only accept a root that exists. + +## Wire protocol and tray client (`src/DiffEngine/Protocol`, `src/DiffEngine/Tray`) + +- [ ] **High · read** — [ViewerClient.cs:113-128](src/DiffEngine/Protocol/ViewerClient.cs:113) `TrySendAsync` bounds nothing: `Configure` sets `Socket.SendTimeout`/`ReceiveTimeout`, which apply only to synchronous calls, and every async read and write uses the caller's token, which is `default` from `DiffRunner.AddInlineAsync(patch)` (Verify passes none) and from `AddDeleteAsync`/`InnerLaunchAsync` ([DiffRunner.cs:131,188-214](src/DiffEngine/DiffRunner.cs:131)). An owner that accepts the connection but does not answer (mid "Accept all" under the applier's 10 s mutex, a debugger-suspended viewer, or the dead accept loop in the next item) hangs the failing test indefinitely, where the sync `TrySend` gives up after 3 s. Link a `CancellationTokenSource.CancelAfter` (15–30 s, like `InlineQueueClient.acceptWait`) on every framework branch and report a timeout as "owner present but unresponsive" rather than as absence. +- [ ] **Medium · read** — [ViewerServer.cs:79-82](src/DiffEngine/Protocol/ViewerServer.cs:79) `Listen` returns on any `SocketException` from `Accept` without checking cancellation or `SocketErrorCode`; a peer that resets a connection while it sits in the backlog (`WSAECONNRESET` on Windows, `ECONNABORTED` on BSD/macOS) surfaces exactly that way, which is why Kestrel retries it. The listener stays bound, so nobody else can take the queue and every later connect lands in the backlog and times out (or hangs, per the item above). [PiperServer.cs:63-76](src/DiffEngineTray/PiperServer.cs:63) already continues. Return only when cancelled or on `OperationAborted`/`Interrupted`. +- [ ] **Medium · read** — a refused `inline` is indistinguishable from "no owner", and the launch-and-forward path cannot report failure: `TrySendAsync` collapses an error reply to `false` ([ViewerClient.cs:129-130](src/DiffEngine/Protocol/ViewerClient.cs:129)), `AddInlineAsync` then launches a viewer and returns `Queued` as soon as stdin is written ([DiffRunner_Inline.cs:64-70](src/DiffEngine/DiffRunner_Inline.cs:64), [ViewerLauncher.cs:12-40](src/DiffEngine/Viewer/ViewerLauncher.cs:12)), and a launched viewer that loses the bind forwards with `out _` and exits 0 without checking `response.Ok` ([ViewerProgram.cs:65-77](src/DiffEngineViewer/ViewerProgram.cs:65); `RunDelete` at [:99-101](src/DiffEngineViewer/ViewerProgram.cs:99) does check). An older owner that rejects the payload, a handler exception, or a forward that times out during a slow first start all lose the snapshot while Verify is told it is queued, so nothing is staged either. Check `Ok` in `RunInline`, persist the patch through `InlineStaging` on any forward failure, and let `AddInlineAsync` return `NoViewerFound` on an explicit refusal. +- [ ] **Medium · read** — [PendingFiles.cs:30-44](src/DiffEngine/Tray/PendingFiles.cs:30) routes on `DiffEngineTray.IsRunning`, cached by a type initialiser, and `PiperClient.Send`/`SendAsync` swallow a refused connection into `Trace` and return `void` ([PiperClient.cs:82-105](src/DiffEngine/Tray/PiperClient.cs:82)). A tray that exits after a long-lived test host started means every later move and delete goes nowhere: no fallback to the queue owner, no `LaunchDelete`. CLAUDE.md documents the late-starting tray, not this mirror case. Have the piper send report whether it connected and fall through to the `ViewerClient`/`LaunchDelete` branch (or re-probe `TrayDetector.IsRunning()`) on refusal. +- [ ] **Medium · read** — [TrackedKeys.cs:15-19](src/DiffEngine/Protocol/TrackedKeys.cs:15) lower-cases unconditionally, while `InlineKey.For` ([InlineKey.cs:24-29](src/DiffEngine/Inline/InlineKey.cs:24)) folds only on Windows/macOS and documents why Linux must not. On Linux two received files differing only by case (`value=a` / `value=A` from a parameterised test) share one `move:` key and `ViewerSession.EnqueueTracked` ([ViewerSession.cs:94-98](src/DiffEngineViewer/ViewerSession.cs:94)) drops the first. Reuse `InlineKey`'s platform switch. +- [ ] **Low · read** — [DiffEngineTray.cs:17](src/DiffEngine/Tray/DiffEngineTray.cs:17) and [TrayDetector.cs:15](src/DiffEngine/Tray/TrayDetector.cs:15) catch only `IOException` around `Mutex.TryOpenExisting`, which is documented to throw `UnauthorizedAccessException` when the mutex exists but is not accessible (tray under one account, tests under another in the same session). In the static constructor that becomes a `TypeInitializationException` on every `DiffRunner.Launch`/`AddDelete` for the life of the process. Catch it and treat as "not running". +- [ ] **Low · read** — [ViewerClient.cs:106-112](src/DiffEngine/Protocol/ViewerClient.cs:106) and [PiperClient.cs:150-158](src/DiffEngine/Tray/PiperClient.cs:150) (net462/472/48) cancel by `client.Close()`; .NET Framework's `TcpClient.Dispose` nulls `Client`, so a token firing around the connect surfaces as `ObjectDisposedException` (swallowed as "no owner", after which a viewer is launched under a cancelled token) or `NullReferenceException` from `Configure`/`HalfClose` (escapes) instead of `OperationCanceledException`. Call `ThrowIfCancellationRequested` after the await and map ODE/NRE under a cancelled token to OCE. + +## Core runner and tool definitions (`src/DiffEngine`) + +- [ ] **High · read** — [P4Merge.cs:10,19](src/DiffEngine/Implementation/P4Merge.cs:10): the text branches emit `"{temp}" "{target}"` for `Left` and `"{target}" "{temp}"` for `Right`, the inverse of the binary branches three lines below and of every other tool, so a default launch (`Right`) opens with the target on the left and `DiffEngine_TargetOnLeft=true` flips it the wrong way. The generated docs show it (`src/DiffEngine.Tests/diffTools.include.md`, "target on left arguments for text: `-C utf8-bom "tempFile.txt" "targetFile.txt"`"). Swap the two paths in both text branches. +- [ ] **High · read** — [DiffTool.cs:14-17,29-31](src/DiffEngine/DiffTool.cs:14) and [Definitions.cs:18-21,33-35](src/DiffEngine/Definitions.cs:18) both say "keep in sync" and disagree (`TortoiseMerge` before vs after the TortoiseGit pair; `VisualStudio, Cursor` vs `Cursor, VisualStudio`). The real default order is the enum (`OrderReader.defaultResult`); the documented order (`docs/diff-tool.order.md`, generated from `Definitions.Tools`) and the remainder after a `DiffEngine_ToolOrder` prefix follow `Definitions`. `DefinitionsTest.ToolOrderMatchesEnumOrder` ([DefinitionsTest.cs:78-83](src/DiffEngine.Tests/DefinitionsTest.cs:78)) uses `IsEquivalentTo`, which is order-insensitive by default — it passes on this tree. Align one side and assert with `CollectionOrdering.Matching`, or derive the default order from one source. +- [ ] **High · read** — [WindowsProcess.cs:300-303](src/DiffEngine/Process/WindowsProcess.cs:300): for a WOW64 target, `NtQueryInformationProcess(ProcessBasicInformation)` from a 64-bit caller returns the 64-bit PEB, which is then read with 32-bit offsets in `ReadCommandLine32`, so every 32-bit diff tool (the `%ProgramFiles(x86)%` installs the resolver goes out of its way to find) yields no command line: never detected as running (duplicate windows for AutoRefresh tools), never killed. Query `ProcessWow64Information` (class 26) for the 32-bit PEB when `isTarget32Bit`, and return null for a 32-bit caller inspecting a 64-bit target. +- [ ] **High · read** — [DiffTools_Add.cs:11](src/DiffEngine/DiffTools_Add.cs:11) `AddToolBasedOn` defaults `useShellExecute` to `true` where every other flag defaults to `null`, so the `?? existing.UseShellExecute` at [:29](src/DiffEngine/DiffTools_Add.cs:29) is dead and a tool based on DiffEngineViewer, VS Code, Cursor, MsWordDiff or MsExcelDiff silently flips to ShellExecute, which also disables the inherited `CreateNoWindow` (the console flash the viewer definition exists to prevent). `bool? useShellExecute = null`. +- [ ] **Medium · read** — [ProcessCleanup.cs:27,33](src/DiffEngine/Process/ProcessCleanup.cs:27): `commands` is filled once by the static constructor and `Refresh()` has no other caller in the library, so `Kill` ([:63](src/DiffEngine/Process/ProcessCleanup.cs:63)) and `TryGetProcessInfo` ([:94](src/DiffEngine/Process/ProcessCleanup.cs:94)) match against a snapshot taken at first use. In one process, `Launch` then `Kill` for the same pair logs "No matching commands" and leaves the tool open, a relaunch never sees the running instance (duplicate windows, another `MaxInstance` slot), and a PID captured at start may belong to another process by the time `Kill` terminates it. `DiffRunnerTests.LaunchAndKill` passes only because the test calls `ProcessCleanup.Refresh()` itself. Long-standing (it predates ec92cb13). Refresh at the top of `Kill` and before `TryGetProcessInfo` in `InnerLaunch` (cheap now that `CandidateExeNames` filters), and re-read the PID's command line before terminating. +- [ ] **Medium · read** — [DiffRunner.cs:46-47](src/DiffEngine/DiffRunner.cs:46) (sync `Launch`) and [DiffRunner_Kill.cs:15-16](src/DiffEngine/DiffRunner_Kill.cs:15) resolve with `TryFindByExtension`, which can only ask `IsTextExtension`; `LaunchAsync` ([:63-64](src/DiffEngine/DiffRunner.cs:63)) uses `TryFindForInputFilePath`, which honours `FileExtensions.AddTextFileConvention`. A convention-matched file launches asynchronously, returns `NoDiffToolFound` synchronously, and `Kill` logs "Extension not found" for the pair `LaunchAsync` opened. Use `TryFindForInputFilePath` in both. +- [ ] **Medium · read** — [DiffTools.cs:5-6](src/DiffEngine/DiffTools.cs:5) and [ResolvedTool.cs:65](src/DiffEngine/ResolvedTool.cs:65) use ordinal comparers, so `.PNG`, `.JPG`, `.Docx` never match the lowercase registrations on the case-insensitive file systems that produce them; `Viewer/ImageExtensions.cs` deliberately uses `OrdinalIgnoreCase` for the same kind of lookup. Use `StringComparer.OrdinalIgnoreCase` for `ExtensionLookup`, `PathLookup` and the `ToFrozenSet`. +- [ ] **Medium · read** — [DiffRunner.cs:161-176](src/DiffEngine/DiffRunner.cs:161) (and the async twin) runs `KillIfNotMdi` before `MaxInstance.Reached()`, so once the per-process counter is spent a re-failing test kills its existing window and relaunches nothing (`TooManyRunningDiffTools`, the move sent with `processId: null`). Check the limit first, or exempt a kill-and-relaunch of the same pair from the count. +- [ ] **Medium · read** — [ToolsOrder.cs:8-17](src/DiffEngine/ToolsOrder.cs:8) looks each requested tool up in `Definitions.Tools`, which holds every enum member, so `throwForNoTool` can never fire for a tool that is merely not installed; what does trigger it is a repeated name (removed from `allTools` on first hit): `DiffEngine_ToolOrder=VisualStudio,VisualStudio` throws "is not installed" from `DiffTools`'s static constructor ([DiffTools.cs:27](src/DiffEngine/DiffTools.cs:27) passes `UsedToolOrderEnvVar` as `throwForNoTool`) and every later use is a `TypeInitializationException`. `Distinct()` the order and throw after `AddTool` returns null instead. +- [ ] **Medium · read** — [DiffEngineViewer.cs:26,30](src/DiffEngine/Implementation/DiffEngineViewer.cs:26) searches `$HOME/.dotnet/tools/` on Linux/macOS, but [WildcardFileFinder.cs:60](src/DiffEngine/WildcardFileFinder.cs:60) expands with `Environment.ExpandEnvironmentVariables`, which only understands `%NAME%` (Rider uses `%HOME%`), so the globally installed tool is never found when the bundled copy is absent. Use `%HOME%`. +- [ ] **Medium · plausible** — [Cursor.cs:26](src/DiffEngine/Implementation/Cursor.cs:26) scans only `%ProgramFiles%\Cursor\` and looks for `Cursor.exe` on PATH; Cursor's default per-user setup installs to `%LOCALAPPDATA%\Programs\cursor\Cursor.exe` with a `cursor.cmd` shim on PATH (VS Code's definition handles the equivalent layout). Not verified against an install here. +- [ ] **Medium · plausible** — [SublimeMerge.cs:30](src/DiffEngine/Implementation/SublimeMerge.cs:30) scans `/Applications/smerge.app/Contents/MacOS/`; the bundle is `Sublime Merge.app` and the CLI lives at `Contents/SharedSupport/bin/smerge`. Not verified on a Mac here. +- [ ] **Medium · read** — [LinuxOsxProcess.cs:110,128-135](src/DiffEngine/Process/LinuxOsxProcess.cs:110): `process.Start()` is unguarded (`Win32Exception` when `ps` is absent) and a non-zero exit throws; both propagate out of `ProcessCleanup`'s static constructor, so a minimal container without procps (and without `DOTNET_RUNNING_IN_CONTAINER`) gets a permanent `TypeInitializationException` on every launch and kill rather than "no running processes". The timeout path already degrades; do the same for these. +- [ ] **Low · read** — [ExamDiff.cs:9,16](src/DiffEngine/Implementation/ExamDiff.cs:9) interpolates `/dn1:{title} /dn2:{title}` unquoted while the paths beside them are quoted; a snapshot name with a space (parameterised tests) splits into extra positional arguments. WinMerge and VisualStudio quote the same titles. +- [ ] **Low · read** — [Guard.cs:13](src/DiffEngine/Guard.cs:13) calls `AgainstEmpty(argumentName, path)` with the arguments reversed, so the empty check validates the literal parameter name and an empty path falls through to `ArgumentException("File not found. Path: ")` without a `ParamName`. +- [ ] **Low · read** — [LinuxOsxProcess.cs:73-78](src/DiffEngine/Process/LinuxOsxProcess.cs:73): the three-space branch slices from `firstSpace` (the PID's digit count) and applies the found index to the unsliced span, so `123 /usr/bin/x y` parses as command `/x y`, and a 7-digit PID with a short command throws `ArgumentOutOfRangeException` out of the type initialiser. `ps -o pid,command` has exactly one separator; delete the branch. +- [ ] **Low · read** — [DiffRunner.cs:9](src/DiffEngine/DiffRunner.cs:9) computes `Disabled` once at type initialisation, so the `BuildServerDetector.Detected` setter ([BuildServerDetector.cs:104-108](src/DiffEngine/BuildServerDetector.cs:104), an `AsyncLocal` override) and `AiCliDetector.Detected` are inert once anything has touched `DiffRunner`, and a per-async-context value can never be honoured by a process-wide static anyway. Either compute on read or document that overrides must precede first use. +- [ ] **Low · read** — [OsSettingsResolver.cs:7](src/DiffEngine/OsSettingsResolver.cs:7) dereferences `GetEnvironmentVariable("PATH")!`; an unset `PATH` (`env -i`, some service launchers) is a `NullReferenceException` in a static constructor. +- [ ] **Low · read** — definition data: [BeyondCompare.cs:47-53](src/DiffEngine/Implementation/BeyondCompare.cs:47) lists `.tbz2` and `.iso` twice (the generated docs repeat them); [Neovim.cs:23](src/DiffEngine/Implementation/Neovim.cs:23) hardcodes `C:\Program Files\Neovim\bin` instead of `%ProgramFiles%\Neovim\bin\`, so it gets no `ProgramW6432`/`(x86)` expansion. +- [ ] **Low · read** — [Directory.Build.props:4,17](src/Directory.Build.props:4): the second `` (`CA1416;CS1591`) replaces the first (`CS1591;CS0649;NU1608;NU1109`), so three of those are not suppressed and would become errors under `TreatWarningsAsErrors`; [DiffEngine.csproj:3-4](src/DiffEngine/DiffEngine.csproj:3) lists `net9.0;net10.0` twice on Windows (MSBuild collapses the duplicates, so harmless today). + +## DiffEngineTray (`src/DiffEngineTray`) + +- [ ] **High · read** — [Tracker.cs:633-648](src/DiffEngineTray/Tracker.cs:633) `KillProcesses` → `KillAndDispose` never clears `move.Process`; when the move then fails (locked target, user picks Ignore or the 8 retries run out) `AcceptMove` ([:459-471](src/DiffEngineTray/Tracker.cs:459)) and `AcceptWithoutPrompting` ([:881-898](src/DiffEngineTray/Tracker.cs:881)) re-add the same object with a disposed `Process`. The Accept-open hotkey ([:677-684](src/DiffEngineTray/Tracker.cs:677), `_.Process is { HasExited: false }`) and "Open diff tool" ([DiffToolLauncher.cs:11](src/DiffEngineTray/DiffToolLauncher.cs:11)) then throw `InvalidOperationException("No process is associated with this object")` on the UI thread. Null the property after disposing. +- [ ] **High · read** — [Tracker.cs:407-424](src/DiffEngineTray/Tracker.cs:407) and [:701-709](src/DiffEngineTray/Tracker.cs:701): the menu and hotkey delete paths call `File.Delete` unguarded after `TryRemove`, unlike the wire path `AcceptTracked` ([:860-879](src/DiffEngineTray/Tracker.cs:860)), which catches and re-tracks. A read-only or open verified file throws out of the click handler (nothing hooks `Application.ThreadException`), the entry is already untracked, and in `AcceptAll` the first bad delete skips `deletes.Clear()`, `AcceptMoves` and `AcceptAllSnapshots` entirely. Route them through `AcceptTracked` and surface the message. +- [ ] **High · read** — [FilePurger.cs:3-37](src/DiffEngineTray/FilePurger.cs:3): `Inner` runs on a bare `Thread` with no try/catch, and `Directory.GetFiles(path, "*.verified.*", SearchOption.AllDirectories)` uses the compat enumeration (`IgnoreInaccessible = false`, reparse points followed), so choosing a profile folder or drive root hits a deny-ACL junction (`Application Data`, `$Recycle.Bin`) → `UnauthorizedAccessException` → unhandled on a non-UI thread → the tray process exits, taking every pending move, delete and owned inline queue with it. Wrap `Inner` and enumerate with `EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = ReparsePoint }`. +- [ ] **Medium · read** — [ProcessEx.cs:21](src/DiffEngineTray/ProcessEx.cs:21): `Process.GetProcessById` holds no handle, so `Kill()`/`HasExited`/`MainWindowHandle` re-open the PID at call time; a move whose diff tool was closed hours ago (`HandleScanMove` keeps it) can kill whatever now owns that PID from "Accept all" ([Tracker.cs:647](src/DiffEngineTray/Tracker.cs:647)) or "Open diff tool". Touch `process.Handle` at track time, or compare `StartTime` before killing. +- [ ] **Medium · read** — [Startup.cs:5-11](src/DiffEngineTray/Startup.cs:5) registers a guessed, unquoted `%USERPROFILE%\.dotnet\tools\DiffEngineTray.exe` rather than `Environment.ProcessPath`; a `--tool-path`/`DOTNET_CLI_HOME` install or a local build silently never starts at login while the Options checkbox (driven by the JSON flag) still reads checked. Register `"\"{Environment.ProcessPath}\""` and reflect `Startup.Exists()` in the checkbox. +- [ ] **Medium · read** — [RemoteInlineHost.cs:99-100,113-114](src/DiffEngineTray/RemoteInlineHost.cs:99): `Discard` and `DiscardAll` use the 500 ms `ShortTimeout` although they no longer run on the UI thread, and `DiscardAll`'s result is dropped, so a viewer busy applying (up to 10 s on the applier mutex) produces "The snapshot viewer is not running." for a single discard, and "Discard (n)" silently does nothing while `Tracker.Clear` sets `snapshots = []` — everything reappears on the next scan. Use `acceptWait` and return the outcome. +- [ ] **Medium · read** — [OwnedInlineHost.cs:206-226](src/DiffEngineTray/OwnedInlineHost.cs:206) hands the stashed `Focus`/`Close` window command to whichever lister arrives first, including a plain `List` from `InlineQueueClient.TryListKeys` (the documented IDE-plugin API), which has no window; the attached viewer then never raises for the new snapshot. Take the stash only for `withPatches` listings. +- [ ] **Medium · read** — [OptionsForm.Designer.cs:259-274](src/DiffEngineTray/Settings/OptionsForm.Designer.cs:259) leaves `maxInstancesNumericUpDown.Maximum` at the default 100 while `MaxInstance` ([MaxInstance.cs:24](src/DiffEngine/MaxInstance.cs:24)) accepts any `ushort`; with `DiffEngine_MaxInstances=500` the `OptionsForm` constructor ([OptionsForm.cs:23](src/DiffEngineTray/Settings/OptionsForm.cs:23)) throws `ArgumentOutOfRangeException`, so Options can never open to fix it. Set `Maximum = ushort.MaxValue`. +- [ ] **Medium · read** — [KeyRegister.cs:40](src/DiffEngineTray/HotKey/KeyRegister.cs:40) `Enum.Parse(key, true)` is unguarded and `SettingsValidator` only checks for non-empty, so a hand-edited `"Key": "Ctrl+A"` throws `ArgumentException` from `ReBindKeys` ([Program.cs:136-149](src/DiffEngineTray/Program.cs:136)) after the "Cannot start" dialog's guard has passed — the tray dies at every login until the file is deleted; `"Key": "1"` silently binds `Keys.LButton`. Validate with `TryParse` and treat a bad key as unbound. +- [ ] **Low · read** — [OptionsFormLauncher.cs:22-52](src/DiffEngineTray/Settings/OptionsFormLauncher.cs:22) clears and re-registers all three hotkeys before checking `saveErrors`, so a collision on the second leaves the first new binding live, the second unbound, and settings.json unchanged. +- [ ] **Low · read** — [MenuBuilder.cs:10-21,53-69](src/DiffEngineTray/MenuBuilder.cs:10): `Closed` removes the per-open items without disposing and `Opening` disposes only what is still in the collection (nothing), so `DisposePreviousItems` is dead and every open leaks `ToolStripDropDownButton`s to the finaliser; [:37-51](src/DiffEngineTray/MenuBuilder.cs:37) also identifies the fixed items by `Text`, so a solution named `Options` (or any other fixed label) produces a group header that is never removed. +- [ ] **Low · read** — [IssueLauncher.cs:46,74](src/DiffEngineTray/IssueLauncher.cs:46) puts the raw message in `title=` while encoding `body=`; a `#` in a path starts a fragment and drops the body, an `&` truncates the title. +- [ ] **Low · read** — [SettingsHelper.cs:32-39](src/DiffEngineTray/Settings/SettingsHelper.cs:32) deletes then writes settings.json, so a kill mid-serialise leaves a truncated file and the "Cannot start. Failed to read settings" dialog on every launch. Write to a temp file and `File.Move(..., overwrite: true)`. + +## Viewer core (`src/DiffEngineViewer`) + +- [ ] **High · ran** — [DiffRows.cs:13](src/DiffEngineViewer/DiffRows.cs:13) calls `SideBySideDiffBuilder.Diff(rightText, leftText)`, whose `ignoreWhiteSpace` parameter defaults to `true` in DiffPlex 1.9.0 (checked by reflection and by running it): `"the quick\n brown fox\ndog "` vs `"the quick\nbrown fox\ndog"` comes back `Unchanged, Unchanged, Unchanged`; with `ignoreWhiteSpace: false` it is `Unchanged, Modified, Modified`. A snapshot failing only on indentation or trailing space (exactly what the F# layout convention is about) renders with no markers, `NextChange` finds nothing, and the reviewer sees a test that "fails with no difference". Pass `ignoreWhiteSpace: false, ignoreCase: false` and add a whitespace-only snapshot test. +- [ ] **High · read** — [QueueEntry.cs:197-201](src/DiffEngineViewer/QueueEntry.cs:197) `Expected` decides "new snapshot" from `OriginalExpression is null` and never reads `OriginalValue`, the anchor an F# producer sends instead (FS0202); `InlineApplier.cs:137` anchors on it and `InlineStaging.cs:342` writes it as `expected.txt`, but the viewer shows `expected (new snapshot)` with an empty right pane and every received line as `Added`. Return `("expected", NormalizeNewlines(OriginalValue), null)` when the value is present. +- [ ] **High · read** — [ViewerSession.cs:1042](src/DiffEngineViewer/ViewerSession.cs:1042) `PreviousChange` starts at `from - 1` and then unconditionally steps off any change there, so a block ending at `ScrollTop - 1` is treated as the current block and skipped: from 17 with a change at 16 lands on 2; from 3 with a change at 2 does not move. `NextChange` starts at `from` and is fine; nothing tests `PreviousChange`. Start at `Math.Min(from, rows.Count - 1)` and step off only when `rows[from]` itself is a change. +- [ ] **High · read** — [ViewerSession.cs:873-882](src/DiffEngineViewer/ViewerSession.cs:873) `Remove` keeps `Selected` as an index and zeroes `ScrollTop`, unlike `EnqueueInline`/`EnqueueTracked`/`Sync`, which re-find the current key. Any removal above the entry being read — a `settle` from a now-passing test, "Accept all in " from a header, a bulk accept that skips conflicts — silently switches what is on screen to the next entry at scroll 0. Re-find the current key and keep its scroll; fall back to the existing advance-to-next only when the current entry itself went. +- [ ] **Medium · read** — [ViewerSession.cs:884-898](src/DiffEngineViewer/ViewerSession.cs:884) `Select` resets `ScrollTop` even when `index == state.Selected`, and `OpenMenu` ([:211](src/DiffEngineViewer/ViewerSession.cs:211)) calls it for the right-clicked row, so right-clicking the highlighted entry (to reveal the source file, say) throws the reader to row 1 before the menu opens; same for a left click on it and a socket `focus` of the entry already shown. Short-circuit on the same index. +- [ ] **Medium · read** — [ViewerSession.cs:628-644](src/DiffEngineViewer/ViewerSession.cs:628) `InlineRefused` reads any non-null `Status` on an inline entry, but a targeted accept of one variant that fails keeps the conflicted entry with `Status = outcome` ([InlineQueue.cs:437-439](src/DiffEngine/Inline/InlineQueue.cs:437)) and `AcceptAll` hands conflicted entries back untouched ([:476-484](src/DiffEngine/Inline/InlineQueue.cs:476)), so every later "Accept all" holds all pending deletes citing a refusal "in this batch" that did not happen. Compute refusal from the sweep's own outcomes, as `AcceptGroup` does with `notWritten`. +- [ ] **Medium · read** — [ViewerSession.cs:32,44](src/DiffEngineViewer/ViewerSession.cs:32) `EnqueueInline` resets the scroll for any arrival with the current key, including an identical re-send that `InlineQueue.Fold` reports as unchanged and `Project` reuses verbatim — a continuous runner re-sending the same failing patch bounces the reader to the top every few seconds (the attached path pins the opposite in `SyncKeepsTheScrollWhenNothingChanged`). Reset only when the entry instance changed. +- [ ] **Medium · read** — [OwnerLink.cs:64-68](src/DiffEngineViewer/Ipc/OwnerLink.cs:64) `Pump` returns false on `!response.Ok` as well as on a refused connection, and `ViewerServer.Respond` converts any exception in the owner's listing handler into an error reply, so one transient throw closes the attached window with "The queue owner is no longer running." Only treat a failed connect as gone; show `response.Message` otherwise. +- [ ] **Medium · read** — [ViewerSession.cs:458](src/DiffEngineViewer/ViewerSession.cs:458) reveals `TargetFile` for a move, which for a brand-new snapshot does not exist yet; [RevealFile.cs:14,23](src/DiffEngineViewer/RevealFile.cs:14) never checks, so `explorer /select,""` opens the default folder and `open -R` errors (Linux happens to work because it reveals the directory). Fall back to the directory, or to `LeftFile`, when the path is absent. +- [ ] **Low · read** — [ViewerActions.cs:69-77](src/DiffEngineViewer/ViewerActions.cs:69) guards the post-move `Directory.Delete` for `IOException` only; an `UnauthorizedAccessException` there reports a move that already succeeded as failed, and the retry then fails with file-not-found on the temp path. +- [ ] **Low · read** — [QueueProjection.cs:337-381](src/DiffEngineViewer/QueueProjection.cs:337): `Collisions` skips non-inline entries, so two tracked moves or deletes with the same file name in one solution are never disambiguated, and the depth-3 fallback `"{Test} ({File.cs})"` is identical for two files sharing their last three directories and name. The tooltip still carries the full path, hence low. + +## Viewer, Windows head (`src/DiffEngineViewer.Windows`) + +- [ ] **High · read** — [QueueTips.cs:42-45,62-63](src/DiffEngineViewer.Windows/QueueTips.cs:42): `Forget()` resets `row` to -1 without clearing the registered caption, and `Apply(owner, -1, null)` then returns early because `-1 == -1`, so after any screen change or mouse-leave (both call `Forget`, [ViewerCanvas.cs:105,501](src/DiffEngineViewer.Windows/ViewerCanvas.cs:105)) the previous row's tip stays registered on the whole canvas and pops up over the diff panes — the exact bug the class doc says it exists to prevent. `QueueTipsTests` covers `Forget → Apply(3, null)` and `Apply(2) → Apply(-1, null)` but not `Forget → Apply(-1, null)`. Compare on the registered text, or clear the caption in `Forget`. +- [ ] **High · read** — [ViewerForm.cs:313-324](src/DiffEngineViewer.Windows/ViewerForm.cs:313) `OnFormClosing` sets `e.Cancel = true` for every `CloseReason`; WinForms answers `WM_QUERYENDSESSION` with `!e.Cancel`, so a viewer open at shutdown makes Windows report "DiffEngineViewer is preventing shutdown", and with a tray running the loop merely hides the window so the process keeps blocking until "Shut down anyway". Set `closingForReal = true` for `CloseReason.WindowsShutDown`. +- [ ] **High · read** — sub-notch scroll deltas are dropped in every head: [ViewerCanvas.cs:504-512](src/DiffEngineViewer.Windows/ViewerCanvas.cs:504) does `e.Delta / 120` with no remainder (precision touchpads send ±10…±60 per message, so two-finger scrolling over the canvas does nothing while the docked scrollbar, which accumulates internally, works); [ViewerView.swift:137-141](native/swift/Sources/Deview/ViewerView.swift:137) rounds each event to an integer before accumulating (slow trackpad motion → 0) and the managed side then multiplies by 3 ([ViewerProgram.cs:302-310](src/DiffEngineViewer/ViewerProgram.cs:302)), so ordinary motion overshoots; [deview.cpp:1168](native/src/deview.cpp:1168) truncates fractional GLFW offsets (Wayland/touchpad) to 0. Accumulate the raw delta and convert once per poll. +- [ ] **Medium · read** — [ViewerCanvas.cs:109-121,130-141](src/DiffEngineViewer.Windows/ViewerCanvas.cs:109) caches `Cell` and materialises `queueWidth` in pixels once; `ViewerForm.OnDpiChanged` ([ViewerForm.cs:164-174](src/DiffEngineViewer.Windows/ViewerForm.cs:164)) rescales only the footer and scrollbar. Under `PerMonitorV2` a drag to a 150 % display draws 11 pt glyphs ~1.5× larger on the stale row pitch and gutter (overlap, clipped labels, wrong `Rows`); the other way leaves gaps. Invalidate the metrics in `OnDpiChangedAfterParent`. +- [ ] **Medium · read** — [ViewerForm.cs:411-415](src/DiffEngineViewer.Windows/ViewerForm.cs:411) `Same(Pane)` never compares `Image`, and `ImagePane` ([ImagePane.cs:16](src/DiffEngineViewer/Model/ImagePane.cs:16)) carries only path/width/height anyway; `ImageRows` emits format, dimensions and byte count, so a re-run that rewrites a received image at the same size (always true for BMP) produces an identical `Screen`, `Apply` returns before `canvas.Invalidate()`, and the pane keeps the stale picture — the stamp-based `ImageCache` invalidation is never consulted. Put a content stamp (`ImageFile.Hash`) on `ImagePane` and compare it. +- [ ] **Low · read** — [FormsViewerWindow.cs:85-95](src/DiffEngineViewer.Windows/FormsViewerWindow.cs:85) `Focus()` never touches `WindowState`, so a socket `focus` for a new snapshot leaves a minimised window minimised (taskbar flash only). Restore to `Normal` first. +- [ ] **Low · read** — [ViewerForm.cs:21-27](src/DiffEngineViewer.Windows/ViewerForm.cs:21) (status `Label`, `UseMnemonic` default) and [ViewerMenu.cs:44](src/DiffEngineViewer.Windows/ViewerMenu.cs:44) (`ToolStripMenuItem` text) render user-derived text, so `&` in a path or a solution name (`R&D.sln` → "Accept all in R_D", with `D` a live accelerator) is eaten as a mnemonic. `UseMnemonic = false` and escape `&&`. + +## Native heads (`native/`, `src/DiffEngineViewer/Native`) + +- [ ] **High · read (not executed — no Mac here)** — [Runtime.swift:61-100](native/swift/Sources/Deview/Runtime.swift:61) `makeWindow()` builds the `NSWindow`, centres it and attaches the scroller but never orders it front; the only `makeKeyAndOrderFront` is in `show()` ([:261-265](native/swift/Sources/Deview/Runtime.swift:261)), reached from `deview_set_hidden(0)`/`deview_focus`, and the managed loop issues those only while draining socket window commands ([ViewerProgram.cs:167,244,248](src/DiffEngineViewer/ViewerProgram.cs:167)). A normal launch with a patch on stdin therefore presents at 60 fps into an invisible window (Dock icon, no window) until a *second* patch arrives over the socket. CI only exercises `Capture` (`hidden: true`). Order the window front at the end of a non-hidden `open`, or have `Run` call `SetHidden(false)` once before the loop. +- [ ] **High · read** — [deview.cpp:501](native/src/deview.cpp:501) maps Escape to `DEVIEW_KEY_QUIT` regardless of an open context menu, and `ViewerSession.Apply` ([ViewerSession.cs:220-226,295-299](src/DiffEngineViewer/ViewerSession.cs:220)) closes the menu *and* executes the command, so Esc-to-dismiss the Linux menu quits the viewer (no tray on Linux, so the window closes and the queue is persisted to staging). Clicks outside a reported item also produce no input, so the menu floats until a row, button or key, contrary to `docs/viewer.md` ("any other click or key closes it"). With `menuCount > 0`, turn Esc and any outside click into `menuClosed`. +- [ ] **High · read** — [test.yml](.github/workflows/test.yml): the Ubuntu job copies a fresh build over `runtimes/linux-x64/native/libdiffengine_viewer.so` before testing, and the `native` job's matrix holds only `osx-arm64` and `linux-arm64`, so the committed linux-x64 binary — the RID most users run — is never loaded by any job, which is the exact class of failure the job's comment says it exists to catch. Add `linux-x64 / ubuntu-24.04` to the matrix. +- [ ] **Medium · read** — [Renderer.swift:176-177](native/swift/Sources/Deview/Renderer.swift:176) derives "Pending (N)" from `frame.queue`, which is the body-sized, fold-filtered slice (`Screen.PendingCount` is not in the ABI), so 30 pending in a 16-row body reads "Pending (16)" beside "inline 1 of 30", and folding a group lowers it; the ASCII and WinForms heads use `PendingCount`. Add `pendingCount` to `DeviewScreen` (bump `DEVIEW_VERSION`) or drop the count. +- [ ] **Medium · read** — [deview.cpp:1073,1122](native/src/deview.cpp:1073): the config flags are only `FLAG_WINDOW_RESIZABLE`, and raylib's `WindowShouldClose()` spins `glfwWaitEvents()` while the window is minimised unless `FLAG_WINDOW_ALWAYS_RUN` is set, so `deview_present` blocks the managed loop and a socket `Focus` for a new snapshot is never dequeued until the user restores the window by hand (the listener still accepts, so the queue updates unseen). Add `FLAG_WINDOW_ALWAYS_RUN` and clear `FLAG_WINDOW_MINIMIZED` in `deview_focus`. +- [ ] **Medium · read** — [deview.cpp:384-412](native/src/deview.cpp:384) `RenderTriangles` indexes `vertices[indices[...]]` without `command.VtxOffset` and the backend does not set `ImGuiBackendFlags_RendererHasVtxOffset` ([:1092](native/src/deview.cpp:1092)); with 16-bit `ImDrawIdx` a single draw list over 65 535 vertices (a maximised 4K window of dense long lines: ~40k glyphs × 4) wraps its indices and the release build, with `IM_ASSERT` compiled out, draws scrambled panes. Set the flag and add `VtxOffset`. +- [ ] **Medium · plausible** — [ViewerView.swift:55-61](native/swift/Sources/Deview/ViewerView.swift:55) `refreshToolTips` does `removeAllToolTips()` and re-adds every rect, and `present()` calls it every frame ([Runtime.swift:137-138](native/swift/Sources/Deview/Runtime.swift:137)); AppKit's tooltip delay runs from the tracking rect's mouse-entered, which a removal under a resting pointer restarts, so queue tooltips on macOS likely never appear. Rebuild only when `layout.queueItems` or the tooltip set changed. +- [ ] **Low · read** — [deview.cpp:797,814,976,768](native/src/deview.cpp:797): `Selectable`, `TableSetupColumn` and the menu labels pass user text straight through ImGui's `##` ID convention, so a test name containing `##` renders truncated on Linux only. Draw an empty-ID selectable plus `TextUnformatted`. +- [ ] **Low · read** — [deview.cpp:808-818](native/src/deview.cpp:808): the Linux head colours a failed row red but never appends the `" !"` marker that the ASCII, WinForms and Swift heads (and `docs/viewer.md`) show. + +## Repo hygiene + +- [ ] **Low** — [ViewerLaunchTests.cs](ViewerLaunchTests.cs) is an empty file at the repository root, committed by accident in 77cc3027 alongside the real `src/DiffEngineViewer.Tests/ViewerLaunchTests.cs`. +- [ ] **Low** — [CLAUDE.md](CLAUDE.md) documents `dotnet test … --filter "FullyQualifiedName~ClassName"`, which on this tree exits 5 with "Zero tests ran" under the Microsoft.Testing.Platform runner; `-- --treenode-filter "/*/*/ClassName/*"` is what works. + +## Checked and found sound + +Not bugs, recorded so they are not re-audited: protocol framing and base64 escaping on both ends; `InlineApplier` mutex, BOM/EOL/encoding round trip and atomic swap; `InlineQueue` fold/settle/conflict invariants; `InlinePatchFile` parsing; `PiperServer` resilience and `PiperClient` JSON escaping; `Tracker` dictionary races and `AsyncTimer` re-entrancy; tray shutdown order and single-instance mutex; `ViewerSession` clamping, folding, menu invalidation and the Rebuild/Sync split; `ImageHeader` sniffing for every format; `CommandLine` rejections; C# raw-string delimiter widening and escapes; F# nested comments and char literals; patcher index arithmetic; native struct layouts, enum numbering, `DEVIEW_VERSION` parity and buffer lifetimes; `PaneScroll` arithmetic; WinForms GDI disposal; the CI workflow scripts apart from the coverage gap above.