Skip to content

Commit 273fbb5

Browse files
committed
Add SettleAppliedInline for applied patches
Introduce `DiffRunner.SettleAppliedInline(InlinePatch)` to retire inline queue entries after a patch is applied outside the test run (IDE/plugin/staged fallback). Unlike `SettleInline`, it does not stamp the caller’s framework, avoiding silent misses when queued entries were created by another target framework, and it uses patch metadata (including `MemberName`) to keep settling reliable when line numbers move. Docs and inline engine tests were updated to explain and lock in this behavior.
1 parent 57cb77c commit 273fbb5

5 files changed

Lines changed: 110 additions & 6 deletions

File tree

claude.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ stdin and binds the port itself; if no viewer resolves (or `DiffEngine_InlineVie
7474
Verify stages `received`/`expected`/`.inlinepatch` files and the IDE plugin or a text diff tool
7575
becomes the review surface. Accepting anywhere runs `InlineApplier` against the source file
7676
(per-file cross-process mutex — safe concurrently from any process). A passing re-run calls
77-
`SettleInline`, and any surface that applies a patch itself must settle too, or the queue owner
78-
keeps offering a snapshot that is already in the source.
77+
`SettleInline`, and any surface that applies a patch itself must call `SettleAppliedInline` — not
78+
`SettleInline`, whose framework label is the running process's own and so never matches an entry
79+
some other process queued, missing silently — or the queue owner keeps offering a snapshot that is
80+
already in the source.
7981

8082
The source may be C# or F#, decided by the file's extension (`SourceLanguage.ForFile`) rather than
8183
stated on the patch. `InlinePatcher` walks the same structure either way — a name, an argument

docs/inline.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ DiffEngineViewer --inline --source <source file> --line <number> < the.inlinepat
7676
For the producing side — a test library with a failing inline snapshot:
7777

7878
* `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff.
79-
* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry.
79+
* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. That framework is the running process's, which makes this the test run's verb and only the test run's: a surface applying a patch of its own wants `SettleAppliedInline`, [below](#applying-a-patch-from-another-surface).
8080
* `AddInlineAsync` stamps `patch.Framework` with the consuming project's target framework ("net9.0", "net48") unless the caller already set it, which is what lets the owner tell a re-run from another framework disagreeing. The value is the `$(TargetFramework)` the package's build targets stamp into the project's runtimeconfig, read back rather than asked of the process — in a hosted test run the entry assembly is the runner (testhost, ReSharperTestRunner), whose framework is not the project's — with the running runtime's version as the fallback for consumers without the targets. Callers may also set `patch.TestName`, which the viewer uses to group and label the queue; without it, items are labeled by call site.
8181
* Set `patch.OriginalExpression` from `CallerArgumentExpression` where the language supplies one, and `patch.OriginalValue` — the previous expected argument's value — where it does not. One of the two is what stops a patch rewriting the wrong call site when the file has moved since the run. `patch.MemberName` from `CallerMemberName` narrows it further, and is supported everywhere including F#.
8282
* Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is how a user opts into reviewing in their IDE instead of a window.
@@ -207,7 +207,7 @@ Listing waits half a second, since an owner that cannot answer one in that time
207207
For the staging fallback, where no viewer could be resolved and the patch is a file on disk rather than an entry in a queue: read it with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules.
208208

209209
* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved, and its extension picks the language.
210-
* **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleInline(patch.SourceFile, patch.LineHint)` — otherwise the tray keeps offering a snapshot that is already in the source.
210+
* **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleAppliedInline(patch)` — otherwise the tray keeps offering a snapshot that is already in the source. Not `SettleInline`: that one labels the settle with the running process's framework, which here is the applier's rather than the test project's, so the owner finds no variant to strip and does nothing — and answers no differently than if it had, so the miss is silent. `SettleAppliedInline` carries no framework at all, which is the right statement to make: replacing the literal the source held leaves every framework's variant anchored to text that has gone, so none of them can apply any more. It also takes the whole patch, so `MemberName` goes along and the entry is still found once an earlier accept has pushed its line down the file.
211211

212212
`Apply` returns `Applied`, `AlreadyApplied` (the literal already matches), `NotFound` (the source changed since the test run — tell the user to re-run rather than retrying), or a failure with a message (locked file, unreadable source), which is retryable.
213213

docs/mdsource/inline.source.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ DiffEngineViewer --inline --source <source file> --line <number> < the.inlinepat
6969
For the producing side — a test library with a failing inline snapshot:
7070

7171
* `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff.
72-
* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry.
72+
* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. That framework is the running process's, which makes this the test run's verb and only the test run's: a surface applying a patch of its own wants `SettleAppliedInline`, [below](#applying-a-patch-from-another-surface).
7373
* `AddInlineAsync` stamps `patch.Framework` with the consuming project's target framework ("net9.0", "net48") unless the caller already set it, which is what lets the owner tell a re-run from another framework disagreeing. The value is the `$(TargetFramework)` the package's build targets stamp into the project's runtimeconfig, read back rather than asked of the process — in a hosted test run the entry assembly is the runner (testhost, ReSharperTestRunner), whose framework is not the project's — with the running runtime's version as the fallback for consumers without the targets. Callers may also set `patch.TestName`, which the viewer uses to group and label the queue; without it, items are labeled by call site.
7474
* Set `patch.OriginalExpression` from `CallerArgumentExpression` where the language supplies one, and `patch.OriginalValue` — the previous expected argument's value — where it does not. One of the two is what stops a patch rewriting the wrong call site when the file has moved since the run. `patch.MemberName` from `CallerMemberName` narrows it further, and is supported everywhere including F#.
7575
* Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is how a user opts into reviewing in their IDE instead of a window.
@@ -200,7 +200,7 @@ Listing waits half a second, since an owner that cannot answer one in that time
200200
For the staging fallback, where no viewer could be resolved and the patch is a file on disk rather than an entry in a queue: read it with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules.
201201

202202
* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved, and its extension picks the language.
203-
* **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleInline(patch.SourceFile, patch.LineHint)` — otherwise the tray keeps offering a snapshot that is already in the source.
203+
* **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleAppliedInline(patch)` — otherwise the tray keeps offering a snapshot that is already in the source. Not `SettleInline`: that one labels the settle with the running process's framework, which here is the applier's rather than the test project's, so the owner finds no variant to strip and does nothing — and answers no differently than if it had, so the miss is silent. `SettleAppliedInline` carries no framework at all, which is the right statement to make: replacing the literal the source held leaves every framework's variant anchored to text that has gone, so none of them can apply any more. It also takes the whole patch, so `MemberName` goes along and the entry is still found once an earlier accept has pushed its line down the file.
204204

205205
`Apply` returns `Applied`, `AlreadyApplied` (the literal already matches), `NotFound` (the source changed since the test run — tell the user to re-run rather than retrying), or a failure with a message (locked file, unreadable source), which is retryable.
206206

src/DiffEngine/DiffRunner_Inline.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,41 @@ public static void RetireInline(string sourceFile, int line, string? memberName
129129
ViewerClient.TrySend(new(ViewerVerb.Settle, InlineKey.For(sourceFile, line), null, memberName));
130130
}
131131

132+
/// <summary>
133+
/// Drops a pending inline snapshot whose literal is now in the source, put there by a surface
134+
/// that applied the patch itself rather than asking the queue owner to: the IDE plugin, or a
135+
/// tool applying what a test run staged. Call it after <see cref="InlineApplier" /> reports
136+
/// <see cref="InlineApplyStatus.Applied" /> or <see cref="InlineApplyStatus.AlreadyApplied" />.
137+
/// <para>
138+
/// Carries no framework, for the same reason <see cref="RetireInline" /> does not. Every
139+
/// variant of a call site is anchored to the literal the source was holding, so replacing that
140+
/// literal leaves none of them able to apply - the frameworks whose content differed included.
141+
/// The statement is "this call site has been written", which is true for all of them at once.
142+
/// </para>
143+
/// </summary>
144+
/// <remarks>
145+
/// <see cref="SettleInline" /> is the wrong verb here and fails silently at it. That one stamps
146+
/// the running process's own framework as the origin, which is right for the test run it was
147+
/// written for and wrong for an applier, because an applier is not the test run: an IDE backend
148+
/// or a dotnet tool reports its own moniker while the entry is labelled with the test project's.
149+
/// The owner then finds no variant carrying that label, strips nothing, and answers no
150+
/// differently than if it had - so the entry stays pending against source that already holds
151+
/// the snapshot, and nothing anywhere says so.
152+
/// </remarks>
153+
/// <param name="patch">
154+
/// The patch that was applied, which names the call site and the member it sits in.
155+
/// </param>
156+
public static void SettleAppliedInline(InlinePatch patch)
157+
{
158+
if (Disabled)
159+
{
160+
return;
161+
}
162+
163+
ViewerClient.TrySend(
164+
new(ViewerVerb.Settle, InlineKey.For(patch.SourceFile, patch.LineHint), null, patch.MemberName));
165+
}
166+
132167
static InlineResult CheckInline()
133168
{
134169
if (Disabled)

src/DiffEngineViewer.Tests/EngineInlineTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,73 @@ public async Task ARemovePatchIsRefused()
118118
await Assert.That(scope.Fixture.Host.State.Queue).IsEmpty();
119119
}
120120

121+
/// <summary>
122+
/// The trap <see cref="EngineRunner.SettleAppliedInline" /> exists for. A settle names the
123+
/// running process's framework, which is the test run's for the caller that verb was written
124+
/// for, and something else entirely for a surface that applies a patch of its own. The owner
125+
/// finds no variant carrying that label and answers no differently than if it had.
126+
/// </summary>
127+
[Test]
128+
public async Task SettleMissesAnEntryQueuedByAnotherFramework()
129+
{
130+
using var scope = new EngineScope();
131+
var patch = Patch("Sample.cs", 42, "\"old\"", "new");
132+
// A moniker no process can report, standing in for a test project this one is not.
133+
patch.Framework = "net99.0";
134+
await EngineRunner.AddInlineAsync(patch);
135+
136+
EngineRunner.SettleInline("Sample.cs", 42);
137+
138+
await Assert.That(scope.Fixture.Host.State.Queue).HasSingleItem();
139+
}
140+
141+
[Test]
142+
public async Task SettleAppliedDropsTheEntryWhateverFrameworkQueuedIt()
143+
{
144+
using var scope = new EngineScope();
145+
var patch = Patch("Sample.cs", 42, "\"old\"", "new");
146+
patch.Framework = "net99.0";
147+
await EngineRunner.AddInlineAsync(patch);
148+
await EngineRunner.AddInlineAsync(Patch("Other.cs", 7, "\"old\"", "new"));
149+
150+
EngineRunner.SettleAppliedInline(patch);
151+
152+
var queue = scope.Fixture.Host.State.Queue;
153+
await Assert.That(queue).HasSingleItem();
154+
await Assert.That(queue[0].Name).IsEqualTo("Other.cs:7");
155+
}
156+
157+
/// <summary>
158+
/// Applying one call site moves every later one in the file, so the line an applier reports is
159+
/// no longer the line the entry was queued at. The member is what survives that, and the patch
160+
/// is carrying it.
161+
/// </summary>
162+
[Test]
163+
public async Task SettleAppliedFindsAnEntryWhoseLineHasMoved()
164+
{
165+
using var scope = new EngineScope();
166+
var queued = Patch("Sample.cs", 42, "\"old\"", "new");
167+
queued.MemberName = "TheTest";
168+
await EngineRunner.AddInlineAsync(queued);
169+
170+
var applied = Patch("Sample.cs", 48, "\"old\"", "new");
171+
applied.MemberName = "TheTest";
172+
EngineRunner.SettleAppliedInline(applied);
173+
174+
await Assert.That(scope.Fixture.Host.State.Queue).IsEmpty();
175+
}
176+
177+
[Test]
178+
public async Task SettleAppliedForAnUnknownCallSiteIsHarmless()
179+
{
180+
using var scope = new EngineScope();
181+
await EngineRunner.AddInlineAsync(Patch("Sample.cs", 42, "\"old\"", "new"));
182+
183+
EngineRunner.SettleAppliedInline(Patch("Nothing.cs", 1, "\"old\"", "new"));
184+
185+
await Assert.That(scope.Fixture.Host.State.Queue).HasSingleItem();
186+
}
187+
121188
[Test]
122189
public async Task TheOptOutDoesNotReachTheViewer()
123190
{

0 commit comments

Comments
 (0)