From 69cda1ac060726d4dc82decebb43f87d4bda46d2 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 7 Jul 2026 21:54:11 +1000 Subject: [PATCH 1/2] Fix chunk-boundary scrubber bugs and test-adapter defects Scrubbers (Verify core): - UserMachineScrubber: fix IndexOutOfRangeException when a match ends exactly on a chunk boundary with a following chunk (read the trailing char via the builder indexer instead of chunkSpan[neededFromCurrent]) - UserMachineScrubber, GuidScrubber, DirectoryReplacements: roll the carryover forward across chunks so a token spanning three or more chunks (with a middle chunk shorter than the token) is no longer silently left unscrubbed - DateFormatLengthCalculator: 'K' contributes 0 to the minimum length (it can render as "", "Z" or "+11:00"), so round-trip/"o" formats now scrub the Z and offset-less forms instead of only the offset form - LinesScrubber.ReplaceLines: guard the trailing-newline trim on the rebuilt builder length, not the original string length, fixing an ArgumentOutOfRangeException when every line is replaced with null Test-framework adapters: - MSTest Verifier: only apply TestData parameters when their count matches the method parameter count (matches XunitV3; params-array DataRows no longer break parameterized snapshot naming) - MSTest Verifier_Archive: forward the dropped archiveExtension in the VerifyZip(Stream) overload - MSTest TestExecutionContext: resolve Method lazily so the method scan runs only when a verifier is built - MSTest source generator: require an attribute list in the syntax predicate so the uncached CreateSyntaxProvider transform no longer runs semantic work for every class on every keystroke - NUnit VerifyBase_Tuple: pass sourceFile so snapshots resolve to the test's path rather than the package build path - NUnit VerifyBase_Stream: use settings ?? this.settings in the Task and ValueTask stream overloads so instance settings are honored Add repro tests for the scrubber fixes. --- .../UsesVerifyGenerator.cs | 6 ++- src/Verify.MSTest/TestExecutionContext.cs | 7 +++- src/Verify.MSTest/Verifier.cs | 7 +++- src/Verify.MSTest/Verifier_Archive.cs | 2 +- src/Verify.NUnit/VerifyBase_Stream.cs | 4 +- src/Verify.NUnit/VerifyBase_Tuple.cs | 2 +- .../DateFormatLengthCalculatorTests.cs | 7 +++- src/Verify.Tests/GuidScrubberTests.cs | 17 +++++++++ src/Verify.Tests/LinesScrubberTests.cs | 23 +++++++++++ .../DirectoryReplacementTests.cs | 16 ++++++++ .../UserMachineScrubberChunkTests.cs | 38 +++++++++++++++++++ .../Scrubbers/DateFormatLengthCalculator.cs | 4 +- .../DirectoryReplacements_StringBuilder.cs | 19 ++++++++-- .../Serialization/Scrubbers/GuidScrubber.cs | 19 ++++++++-- .../Serialization/Scrubbers/LinesScrubber.cs | 2 +- ...UserMachineScrubber_PerformReplacements.cs | 26 ++++++++++--- 16 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 src/Verify.Tests/UserMachineScrubberChunkTests.cs diff --git a/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs b/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs index 49dbf36cff..6257bac837 100644 --- a/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs +++ b/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs @@ -118,8 +118,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static bool HasTestClassAttribute(INamedTypeSymbol symbol, INamedTypeSymbol testClassType) => !symbol.HasAttributeOfType(testClassType, includeDerived: true); + // Require at least one attribute list: both paths only care about classes + // carrying [UsesVerify] or [TestClass]. Without this filter the uncached + // CreateSyntaxProvider transform runs semantic work for every class in the + // consuming project on every keystroke. static bool IsSyntaxEligibleForGeneration(SyntaxNode node, Cancel _) => - node is ClassDeclarationSyntax; + node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; static bool IsAssemblyEligibleForGeneration(IAssemblySymbol assembly, INamedTypeSymbol markerType) => assembly.HasAttributeOfType(markerType, includeDerived: false); diff --git a/src/Verify.MSTest/TestExecutionContext.cs b/src/Verify.MSTest/TestExecutionContext.cs index 849d4c67b5..6ef42c6de4 100644 --- a/src/Verify.MSTest/TestExecutionContext.cs +++ b/src/Verify.MSTest/TestExecutionContext.cs @@ -3,7 +3,12 @@ namespace VerifyMSTest; public record TestExecutionContext(TestContext TestContext, Type TestClass) { public Assembly Assembly { get; } = TestClass.Assembly; - public MethodInfo Method { get; } = FindMethod(TestClass, TestContext); + + MethodInfo? method; + + // Resolved lazily: the method scan is only needed when a Verify call actually + // builds a verifier, not for every test that constructs a context. + public MethodInfo Method => method ??= FindMethod(TestClass, TestContext); static MethodInfo FindMethod(Type type, TestContext context) { diff --git a/src/Verify.MSTest/Verifier.cs b/src/Verify.MSTest/Verifier.cs index f309cd1069..4cfc040e60 100644 --- a/src/Verify.MSTest/Verifier.cs +++ b/src/Verify.MSTest/Verifier.cs @@ -33,7 +33,12 @@ public static InnerVerifier BuildVerifier(VerifySettings settings, string source if (!settings.HasParameters) { var data = context.TestContext.TestData; - if (data != null) + // Only apply when the data length matches the method parameter count. + // A params-array DataRow exposes raw pre-binding data whose length does + // not match the parameter count, which would break parameterized + // snapshot file naming. XunitV3 applies the same guard. + if (data != null && + data.Length == method.ParameterNames()?.Count) { settings.SetParameters(data); } diff --git a/src/Verify.MSTest/Verifier_Archive.cs b/src/Verify.MSTest/Verifier_Archive.cs index 503b4d645c..909f5b9b04 100644 --- a/src/Verify.MSTest/Verifier_Archive.cs +++ b/src/Verify.MSTest/Verifier_Archive.cs @@ -47,7 +47,7 @@ public static SettingsTask VerifyZip( bool persistArchive = false, string? archiveExtension = null, [CallerFilePath] string sourceFile = "") => - Verify(settings, sourceFile, _ => _.VerifyZip(stream, include, info, fileScrubber, includeStructure, persistArchive), true); + Verify(settings, sourceFile, _ => _.VerifyZip(stream, include, info, fileScrubber, includeStructure, persistArchive, archiveExtension), true); /// /// Verifies the contents of a diff --git a/src/Verify.NUnit/VerifyBase_Stream.cs b/src/Verify.NUnit/VerifyBase_Stream.cs index 40cb05edf7..6a277a6bff 100644 --- a/src/Verify.NUnit/VerifyBase_Stream.cs +++ b/src/Verify.NUnit/VerifyBase_Stream.cs @@ -40,7 +40,7 @@ public SettingsTask Verify( VerifySettings? settings = null, object? info = null) where T : Stream => - Verifier.Verify(target, extension, settings, info, sourceFile); + Verifier.Verify(target, extension, settings ?? this.settings, info, sourceFile); [Pure] public SettingsTask Verify( @@ -49,7 +49,7 @@ public SettingsTask Verify( VerifySettings? settings = null, object? info = null) where T : Stream => - Verifier.Verify(target, extension, settings, info, sourceFile); + Verifier.Verify(target, extension, settings ?? this.settings, info, sourceFile); [Pure] public SettingsTask Verify( diff --git a/src/Verify.NUnit/VerifyBase_Tuple.cs b/src/Verify.NUnit/VerifyBase_Tuple.cs index e2b6778a69..c87d275f12 100644 --- a/src/Verify.NUnit/VerifyBase_Tuple.cs +++ b/src/Verify.NUnit/VerifyBase_Tuple.cs @@ -7,6 +7,6 @@ public partial class VerifyBase public SettingsTask VerifyTuple( Expression> target, VerifySettings? settings = null) => - Verifier.VerifyTuple(target, settings ?? this.settings); + Verifier.VerifyTuple(target, settings ?? this.settings, sourceFile); } #endif \ No newline at end of file diff --git a/src/Verify.Tests/DateFormatLengthCalculatorTests.cs b/src/Verify.Tests/DateFormatLengthCalculatorTests.cs index 1de9ef6c82..c5962203e3 100644 --- a/src/Verify.Tests/DateFormatLengthCalculatorTests.cs +++ b/src/Verify.Tests/DateFormatLengthCalculatorTests.cs @@ -43,8 +43,11 @@ [InlineData("zz", 3, 3)] [InlineData("zzz", 6, 6)] [InlineData("zzzz", 6, 6)] - [InlineData("K", 6, 6)] - [InlineData("KK", 12, 12)] + // K renders as "" (Unspecified), "Z" (Utc, 1 char) or "+11:00" (offset, 6 chars), + // so its minimum contribution is 0 (not 6) — otherwise round-trip/"o" formats + // scrub only the offset form and leak the Z / offset-less forms. + [InlineData("K", 6, 0)] + [InlineData("KK", 12, 0)] [InlineData(":", 1, 1)] [InlineData("':'", 1, 1)] [InlineData("/", 1, 1)] diff --git a/src/Verify.Tests/GuidScrubberTests.cs b/src/Verify.Tests/GuidScrubberTests.cs index ef0787a4b1..531b80d042 100644 --- a/src/Verify.Tests/GuidScrubberTests.cs +++ b/src/Verify.Tests/GuidScrubberTests.cs @@ -101,6 +101,23 @@ public void MultipleChunks() Assert.Equal("[Guid_1][Guid_2]", builder.ToString()); } + [Fact] + public void GuidSpanningThreeChunks() + { + // Capacity 4 forces small chunks [4][4][rest]; the 36-char guid spans all + // three, and the 4-char middle chunk is shorter than the 35-char carryover. + // The carryover must accumulate across chunks or the prefix is dropped and + // the guid is never found (silent leak). + var guid = "173535ae-995b-4cc6-a74e-8cd4be57039c"; + var builder = new StringBuilder(capacity: 4); + builder.Append(guid[..4]); // chunk0 + builder.Append(guid[4..8]); // chunk1 (short middle chunk) + builder.Append(guid[8..]); // chunk2 + using var counter = Counter.Start(); + GuidScrubber.ReplaceGuids(builder, counter); + Assert.Equal("Guid_1", builder.ToString()); + } + #region NamedGuidFluent [Fact] diff --git a/src/Verify.Tests/LinesScrubberTests.cs b/src/Verify.Tests/LinesScrubberTests.cs index b3c8773908..8bf274259e 100644 --- a/src/Verify.Tests/LinesScrubberTests.cs +++ b/src/Verify.Tests/LinesScrubberTests.cs @@ -78,6 +78,29 @@ public Task ScrubLinesContaining_case_sensitive() """); } + [Fact] + public void ReplaceLines_AllRemovedNoTrailingNewline() + { + // Single line, no trailing newline, and every line replaced with null. + // The trailing-newline trim must guard on the rebuilt builder length, not + // the original string length, otherwise Length -= 1 underflows. + var builder = new StringBuilder("single line"); + + builder.ReplaceLines(_ => null); + + Assert.Equal(string.Empty, builder.ToString()); + } + + [Fact] + public void ReplaceLines_ReplacesLines() + { + var builder = new StringBuilder("a\nb\nc"); + + builder.ReplaceLines(line => line == "b" ? "B" : line); + + Assert.Equal("a\nB\nc", builder.ToString()); + } + [Fact] public void FilterLines_RemovesSingleLine() { diff --git a/src/Verify.Tests/Serialization/DirectoryReplacementTests.cs b/src/Verify.Tests/Serialization/DirectoryReplacementTests.cs index 0f21ae77fa..2bf72fa5b1 100644 --- a/src/Verify.Tests/Serialization/DirectoryReplacementTests.cs +++ b/src/Verify.Tests/Serialization/DirectoryReplacementTests.cs @@ -45,6 +45,22 @@ public void MultipleChunks() Assert.Equal("{Child} {Parent} ", builder.ToString()); } + [Fact] + public void PathSpanningThreeChunks() + { + // Capacity 4 forces small chunks [4][4][rest]; the 16-char path spans all + // three, and the 4-char middle chunk is shorter than the carryover. The + // carryover must accumulate across chunks or the prefix is dropped and the + // path leaks unscrubbed. + List pairs = [new("C:/Parent/ChildX", "{replace}")]; + var builder = new StringBuilder(capacity: 4); + builder.Append("C:/P"); // chunk0 + builder.Append("aren"); // chunk1 (short middle chunk) + builder.Append("t/ChildX."); + DirectoryReplacements.Replace(builder, pairs); + Assert.Equal("{replace}.", builder.ToString()); + } + [Fact] public void ProcessLongerDirectoryFirst() { diff --git a/src/Verify.Tests/UserMachineScrubberChunkTests.cs b/src/Verify.Tests/UserMachineScrubberChunkTests.cs new file mode 100644 index 0000000000..05ae116999 --- /dev/null +++ b/src/Verify.Tests/UserMachineScrubberChunkTests.cs @@ -0,0 +1,38 @@ +public class UserMachineScrubberChunkTests +{ + [Fact] + public void CrossChunkMatchEndingExactlyAtChunkBoundary() + { + // "ABCD" fills the capacity-4 chunk; the next append lands in a fresh + // 16-char chunk holding the remaining 16 chars of the 20-char match, so + // the match ends exactly at that chunk's boundary with a chunk after it. + // The trailing-char check must not read chunkSpan[chunkSpan.Length]. + var builder = new StringBuilder(capacity: 4); + builder.Append("ABCD"); + builder.Append("EFGHIJKLMNOPQRST"); + builder.Append('.'); + + UserMachineScrubber.PerformReplacements(builder, "ABCDEFGHIJKLMNOPQRST", "TheUserName"); + + Assert.Equal("TheUserName.", builder.ToString()); + } + + [Fact] + public void TokenSpanningThreeChunks() + { + // Capacity 4 forces small chunks [4][4][…]. The 10-char match spans all + // three, and the 4-char middle chunk is shorter than the match. The + // carryover must accumulate across chunks; otherwise the first chunk's + // prefix is dropped when the short middle chunk overwrites it, and the + // match is never found (silent leak). + var find = "ABCDEFGHIJ"; // 10 chars + var builder = new StringBuilder(capacity: 4); + builder.Append("ABCD"); // chunk0 = find[0..4] + builder.Append("EFGH"); // chunk1 = find[4..8] (short middle chunk) + builder.Append("IJ."); // chunk2 = find[8..10] + wrapper + + UserMachineScrubber.PerformReplacements(builder, find, "TheUserName"); + + Assert.Equal("TheUserName.", builder.ToString()); + } +} diff --git a/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs b/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs index 00e9a6d5c7..83da6d81ee 100644 --- a/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs +++ b/src/Verify/Serialization/Scrubbers/DateFormatLengthCalculator.cs @@ -179,8 +179,10 @@ public static (int max, int min) InnerGetLength(scoped CharSpan format, Culture break; case 'K': + // K renders as "" (Unspecified), "Z" (Utc, 1 char) or + // "+11:00" (offset, 6 chars), so it can contribute as few as + // 0 chars. Only the maximum is 6; the minimum stays 0. tokenLen = 1; - minLength += 6; maxLength += 6; break; case ':': diff --git a/src/Verify/Serialization/Scrubbers/DirectoryReplacements_StringBuilder.cs b/src/Verify/Serialization/Scrubbers/DirectoryReplacements_StringBuilder.cs index 3484f62dbf..653eca376d 100644 --- a/src/Verify/Serialization/Scrubbers/DirectoryReplacements_StringBuilder.cs +++ b/src/Verify/Serialization/Scrubbers/DirectoryReplacements_StringBuilder.cs @@ -169,9 +169,22 @@ static List FindMatches(StringBuilder builder, List pairs) } } - // Save last N chars for next iteration - carryoverLength = Math.Min(carryoverSize, chunk.Length); - chunkSpan.Slice(chunk.Length - carryoverLength, carryoverLength).CopyTo(carryoverBuffer); + // Roll the carryover forward: keep the last carryoverSize chars of + // everything seen so far. Rebuilding it from the current chunk alone + // drops the prefix when a chunk is shorter than carryoverSize, so a + // path spanning three or more chunks would never be found. + if (chunk.Length >= carryoverSize) + { + chunkSpan.Slice(chunk.Length - carryoverSize, carryoverSize).CopyTo(carryoverBuffer); + carryoverLength = carryoverSize; + } + else + { + var keep = Math.Min(carryoverLength, carryoverSize - chunk.Length); + carryoverBuffer.Slice(carryoverLength - keep, keep).CopyTo(carryoverBuffer); + chunkSpan.CopyTo(carryoverBuffer[keep..]); + carryoverLength = keep + chunk.Length; + } previousChunkAbsoluteEnd = absolutePosition + chunk.Length; absolutePosition += chunk.Length; diff --git a/src/Verify/Serialization/Scrubbers/GuidScrubber.cs b/src/Verify/Serialization/Scrubbers/GuidScrubber.cs index 15310f152a..84162387cb 100644 --- a/src/Verify/Serialization/Scrubbers/GuidScrubber.cs +++ b/src/Verify/Serialization/Scrubbers/GuidScrubber.cs @@ -117,9 +117,22 @@ static List FindMatches(StringBuilder builder, Counter counter) } } - // Save last 35 chars for next iteration - carryoverLength = Math.Min(35, chunk.Length); - chunkSpan.Slice(chunk.Length - carryoverLength, carryoverLength).CopyTo(carryoverBuffer); + // Roll the carryover forward: keep the last 35 chars of everything seen + // so far. Rebuilding it from the current chunk alone drops the prefix + // when a chunk is shorter than 35, so a guid spanning three or more + // chunks would never be found. + if (chunk.Length >= 35) + { + chunkSpan.Slice(chunk.Length - 35, 35).CopyTo(carryoverBuffer); + carryoverLength = 35; + } + else + { + var keep = Math.Min(carryoverLength, 35 - chunk.Length); + carryoverBuffer.Slice(carryoverLength - keep, keep).CopyTo(carryoverBuffer); + chunkSpan.CopyTo(carryoverBuffer[keep..]); + carryoverLength = keep + chunk.Length; + } previousChunkAbsoluteEnd = absolutePosition + chunk.Length; absolutePosition += chunk.Length; diff --git a/src/Verify/Serialization/Scrubbers/LinesScrubber.cs b/src/Verify/Serialization/Scrubbers/LinesScrubber.cs index 8fe186e16a..56d480410d 100644 --- a/src/Verify/Serialization/Scrubbers/LinesScrubber.cs +++ b/src/Verify/Serialization/Scrubbers/LinesScrubber.cs @@ -31,7 +31,7 @@ public static void ReplaceLines(this StringBuilder input, Func } } - if (theString.Length > 0 && + if (input.Length > 0 && !theString.EndsWith('\n')) { input.Length -= 1; diff --git a/src/Verify/Serialization/Scrubbers/UserMachineScrubber_PerformReplacements.cs b/src/Verify/Serialization/Scrubbers/UserMachineScrubber_PerformReplacements.cs index 499f4ce8e8..0ff8489054 100644 --- a/src/Verify/Serialization/Scrubbers/UserMachineScrubber_PerformReplacements.cs +++ b/src/Verify/Serialization/Scrubbers/UserMachineScrubber_PerformReplacements.cs @@ -72,10 +72,13 @@ static List FindMatches(StringBuilder builder, string find) continue; } - // Check trailing character + // Check trailing character. Use the builder indexer rather + // than chunkSpan[neededFromCurrent], which is out of range when + // the match ends exactly at this chunk's boundary, so the check + // still works when there is a following chunk. var endPosition = startPosition + find.Length; var validEnd = endPosition >= builder.Length || - IsValidWrapper(chunkSpan[neededFromCurrent]); + IsValidWrapper(builder[endPosition]); if (!validEnd) { @@ -115,9 +118,22 @@ static List FindMatches(StringBuilder builder, string find) } } - // Save last N chars for next iteration - carryoverLength = Math.Min(carryoverSize, chunk.Length); - chunkSpan.Slice(chunk.Length - carryoverLength, carryoverLength).CopyTo(carryoverBuffer); + // Roll the carryover forward: keep the last carryoverSize chars of + // everything seen so far. Rebuilding it from the current chunk alone + // drops the prefix when a chunk is shorter than the search string, so + // a token spanning three or more chunks would never be found. + if (chunk.Length >= carryoverSize) + { + chunkSpan.Slice(chunk.Length - carryoverSize, carryoverSize).CopyTo(carryoverBuffer); + carryoverLength = carryoverSize; + } + else + { + var keep = Math.Min(carryoverLength, carryoverSize - chunk.Length); + carryoverBuffer.Slice(carryoverLength - keep, keep).CopyTo(carryoverBuffer); + chunkSpan.CopyTo(carryoverBuffer[keep..]); + carryoverLength = keep + chunk.Length; + } previousChunkAbsoluteEnd = absolutePosition + chunk.Length; absolutePosition += chunk.Length; From 812fd34d88067e9c3636fd3d27d0f85d1ac46168 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 7 Jul 2026 11:56:10 +0000 Subject: [PATCH 2/2] Docs changes --- docs/guids.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guids.md b/docs/guids.md index 16a66b1e86..a99d32809c 100644 --- a/docs/guids.md +++ b/docs/guids.md @@ -182,7 +182,7 @@ public Task NamedGuidFluent() .AddNamedGuid(guid, "instanceNamed"); } ``` -snippet source | anchor +snippet source | anchor @@ -217,7 +217,7 @@ public Task InferredNamedGuidFluent() .AddNamedGuid(namedGuid); } ``` -snippet source | anchor +snippet source | anchor Result: