From 69cc38c8276ed3344a4d977bbda79b6053b92f92 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 29 Aug 2026 17:26:27 +1000 Subject: [PATCH 1/2] Re-run WriteVerifyAttributes when the values change The up-to-date check compared only $(MSBuildAllProjects) timestamps against the generated file, but the content comes from $(SolutionDir), $(SolutionName) and $(TargetFrameworks). So rebuilding with a different /p:SolutionDir or /p:SolutionName, which is exactly what the DiscoverSolutionInfo warnings tell users to pass, or building the same project from another solution, skipped the target and CoreCompile and left stale metadata in the assembly for AttributeReader and DerivePaths to read at test time. Only a full Rebuild or touching the csproj cleared it. The values are now written to a cache file with WriteOnlyWhenDifferent, and that file is an input, so its timestamp moves only on a real change. Same pattern as the SDK's GenerateAssemblyInfo. Verified by building, then rebuilding with /p:SolutionName=OtherSolution: before this the emitted Verify.SolutionName stayed at the old value, now it updates. --- src/Verify/buildTransitive/Verify.props | 32 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Verify/buildTransitive/Verify.props b/src/Verify/buildTransitive/Verify.props index d2a0e208df..2ac2d64015 100644 --- a/src/Verify/buildTransitive/Verify.props +++ b/src/Verify/buildTransitive/Verify.props @@ -90,14 +90,21 @@ - + + DependsOnTargets="DiscoverSolutionInfo"> $(IntermediateOutputPath)$(VerifyAttributesFile) + $(IntermediateOutputPath)Verify.Attributes.cache $([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)')) @@ -132,6 +139,21 @@ + + + + + + + From 932f97ba8183ecf08b24b617f27fd1fd28b46975 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 30 Aug 2026 10:19:59 +1000 Subject: [PATCH 2/2] Update SolutionDiscoveryTests.cs --- src/Verify.Tests/SolutionDiscoveryTests.cs | 104 +++++++++++++++++---- 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/src/Verify.Tests/SolutionDiscoveryTests.cs b/src/Verify.Tests/SolutionDiscoveryTests.cs index 6d4a59e9e2..674a0da2b9 100644 --- a/src/Verify.Tests/SolutionDiscoveryTests.cs +++ b/src/Verify.Tests/SolutionDiscoveryTests.cs @@ -294,13 +294,75 @@ public async Task ExplicitSolutionName_OverridesDiscovery() Assert.Equal(explicitSolutionName, solutionName); } - static string CreateMinimalCsprojContent() + [Fact] + public async Task RebuildFromAnotherSolution_UpdatesMetadata() + { + using var directory = new TempDirectory(); + var tempDir = directory.Path; + + // Create directory structure + var projectDir = Path.Combine(tempDir, "TestProject"); + Directory.CreateDirectory(projectDir); + + // Two solution directories the same project can be built from + var firstSolutionDir = Path.Combine(tempDir, "First") + Path.DirectorySeparatorChar; + var secondSolutionDir = Path.Combine(tempDir, "Second") + Path.DirectorySeparatorChar; + Directory.CreateDirectory(firstSolutionDir); + Directory.CreateDirectory(secondSolutionDir); + + // Create .csproj file. It does not reference Verify.csproj: SolutionDir is a global + // property, so it would flow into that build too, and this repo derives its strong name + // key path from SolutionDir. Only the imported props are needed here anyway. + var csprojPath = Path.Combine(projectDir, "TestProject.csproj"); + await File.WriteAllTextAsync(csprojPath, CreateMinimalCsprojContent(referenceVerify: false)); + + // Build against the first solution + var (success, output) = await BuildProject(csprojPath, firstSolutionDir, "FirstSolution"); + Assert.True(success, $"Build failed: {output}"); + + var assemblyPath = GetAssemblyPath(projectDir); + var (solutionDir, solutionName) = LoadAssemblyAndGetMetadata(assemblyPath); + + Assert.Equal(firstSolutionDir, solutionDir); + Assert.Equal("FirstSolution", solutionName); + + // Rebuild the same intermediate directory against the second solution. No file the + // up-to-date check can see has changed, so only the attributes cache can stop + // WriteVerifyAttributes being skipped and the baked in metadata going stale. + (success, output) = await BuildProject(csprojPath, secondSolutionDir, "SecondSolution"); + Assert.True(success, $"Build failed: {output}"); + + (solutionDir, solutionName) = LoadAssemblyAndGetMetadata(assemblyPath); + + Assert.Equal(secondSolutionDir, solutionDir); + Assert.Equal("SecondSolution", solutionName); + Assert.DoesNotContain(skipMessage, output); + + // Build against the second solution again. The values are unchanged, so the target has + // to go back to being skipped rather than regenerating on every build. + (success, output) = await BuildProject(csprojPath, secondSolutionDir, "SecondSolution"); + Assert.True(success, $"Build failed: {output}"); + Assert.Contains(skipMessage, output); + } + + // MSBuild message, in the language BuildProject pins the build to + const string skipMessage = "Skipping target \"WriteVerifyAttributes\" because all output files are up-to-date"; + + static string CreateMinimalCsprojContent(bool referenceVerify = true) { // Get the path to Verify.csproj and Verify.props relative to test project var verifyProjectPath = Path.Combine(ProjectFiles.SolutionDirectory, "Verify", "Verify.csproj"); var verifyPropsPath = Path.Combine(ProjectFiles.SolutionDirectory, "Verify", "buildTransitive", "Verify.props"); + var reference = referenceVerify + ? $""" + + + + """ + : ""; + return $""" @@ -308,9 +370,7 @@ static string CreateMinimalCsprojContent() Library TestProject - - - + {reference} """; @@ -341,28 +401,38 @@ static string CreateMinimalSlnContent() => static async Task<(bool success, string output)> BuildProject(string csprojPath, string? solutionDir = null, string? solutionName = null) { - var args = $"build \"{csprojPath}\" --configuration Release --verbosity normal"; - - if (solutionDir != null) - { - args += $" \"/p:SolutionDir={solutionDir}\""; - } - - if (solutionName != null) - { - args += $" \"/p:SolutionName={solutionName}\""; - } - var startInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = args, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; + // MSBuild localizes its messages, and the assertions above match the English text + startInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en"; + + // ArgumentList quotes each value, so a SolutionDir ending in a separator is not + // mangled by that separator escaping the closing quote + var arguments = startInfo.ArgumentList; + arguments.Add("build"); + arguments.Add(csprojPath); + arguments.Add("--configuration"); + arguments.Add("Release"); + arguments.Add("--verbosity"); + arguments.Add("normal"); + + if (solutionDir != null) + { + arguments.Add($"/p:SolutionDir={solutionDir}"); + } + + if (solutionName != null) + { + arguments.Add($"/p:SolutionName={solutionName}"); + } + using var process = Process.Start(startInfo)!; var outputTask = process.StandardOutput.ReadToEndAsync();