From 46001a9ba2c41dec644fcc75fb682fa3c71f8377 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 2 Jul 2026 06:29:45 +0200 Subject: [PATCH 1/4] chore: add AI plan 135 for source code transformation in .NET 8 Signed-off-by: Kenny Pflug --- ...source-code-transformation-for-dotnet-8.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md diff --git a/Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md b/Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md new file mode 100644 index 00000000..821a83de --- /dev/null +++ b/Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md @@ -0,0 +1,42 @@ +# Issue 135 - Source Code Transformation for .NET 8 + +## Rationale + +`Light.GuardClauses.SourceCodeTransformation` flattens the library into a single source file, and issue 61 added an opt-in assertion whitelist. Today the flatten/whitelist path is hard-wired to the `netstandard2.0` shape (source parsed with `NETSTANDARD2_0` defined, every `#if NET8_0_OR_GREATER` member dropped), while the non-whitelist full export parses with no symbols and keeps all `#if` directives. + +That "multi-target" full export is a fiction for the parts that matter: the committed `Light.GuardClauses.SingleFile.cs` keeps 80 tiny attribute-level `#if NET8_0_OR_GREATER` blocks but contains none of the 35 substantial net8-only public members (the `INumber` generic-math overloads, the `Span`/`Memory` overloads of `IsEmailAddress`/`MustBeEmailAddress`). They are dropped because the merger rebuilds classes from member nodes and discards the closing-brace trivia those tail members attach to. The single source file has therefore always been a netstandard2.0 floor in practice. + +We make that explicit and remove the illusion of multi-targeting. The tool always flattens to exactly one target framework, chosen by a new `TargetFramework` option (`NetStandard2_0` by default, `Net8_0` for consumers who want a net8 file). Positioning stays: the single source file is the portable netstandard2.0 floor; net8-specific API lives in the compiled NuGet package. Because the tool no longer preserves `#if` directives, the trailing-trivia member-drop bug disappears by construction and is out of scope here. + +Validation is reworked to match the chosen framework. The existing `Light.GuardClauses.Source` project is a pure compile-check harness pinned to `netstandard2.0`, so it cannot validate a `Net8_0` export (the net8-only members do not compile there) and it is redundant with the build validator. It is replaced by a multi-targeted `Light.GuardClauses.SourceValidation` project into which the validator injects the generated file, building it against the framework that matches `TargetFramework`. + +The whitelist keeps its fail-loud contract: a `Check..cs` file with no matching `AssertionWhitelist` property continues to throw, so the whitelist stays the authoritative catalog of exportable assertions and a newly added assertion cannot slip into a whitelisted export unvetted. + +## Acceptance Criteria + +- [ ] A new `SourceTargetFramework` enum with values `NetStandard2_0` and `Net8_0` is introduced, and `SourceFileMergeOptions` gets `public SourceTargetFramework TargetFramework { get; init; } = SourceTargetFramework.NetStandard2_0;`. Configuration binds the value by name. +- [ ] A single factory maps `SourceTargetFramework` to `CSharpParseOptions`, and both `SourceReachabilityAnalyzer` and `SourceFileMerger` use it so reachability analysis and emitted output always agree on which branch is active. The standalone `NetStandardParseOptions` field is removed. +- [ ] The export always flattens to the selected framework: source is parsed with that framework's symbols and all `#if` directives are removed from the output, in both whitelist and non-whitelist mode. +- [ ] When `TargetFramework == NetStandard2_0`, the non-whitelist full export reproduces today's netstandard2.0 API surface with no `#if` directives. +- [ ] When `TargetFramework == Net8_0`, the flattened export defines `NET8_0_OR_GREATER`, contains the net8-only members (for example `MustBeGreaterThanOrApproximately` with an `INumber` constraint and the `Span`/`Memory` email overloads), and contains no `#if` directives. +- [ ] Polyfill-emitting options that duplicate types built into net8 (`IncludeCallerArgumentExpressionAttribute`, `IncludeCodeAnalysisNullableAttributes`) are derived from `SourceTargetFramework`: emitted for `NetStandard2_0`, suppressed for `Net8_0`, so a net8 export never redefines framework types. +- [ ] The whitelist's fail-loud behavior is preserved: a `Check..cs` file with no matching `AssertionWhitelist` property throws a clear, actionable exception. +- [ ] The `Light.GuardClauses.Source` project is removed (and dropped from the solution) and replaced by a multi-targeted (`netstandard2.0;net8.0`) `Light.GuardClauses.SourceValidation` project that compiles a file supplied through a `GeneratedSourceFile` MSBuild property rather than a co-located source file. +- [ ] `GeneratedFileBuildValidator` builds `Light.GuardClauses.SourceValidation` with the framework matching `SourceTargetFramework` and the generated file path passed in; on build failure it prints the captured output, returns a non-zero exit code, and leaves the generated file on disk. +- [ ] Automated tests need to be written. + +## Technical Details + +- **Options and config**: add `SourceTargetFramework` and the `TargetFramework` property on `SourceFileMergeOptions`, defaulting to `NetStandard2_0`. The committed `settings.json` sets it explicitly. Whitelist mode continues to be gated by `AssertionWhitelist.IsEnabled` and simply layers member pruning on top of the flatten; no framework validation is needed because the framework is always concrete now. + +- **Parse-options factory**: replace `SourceReachabilityAnalyzer.NetStandardParseOptions` with `CreateParseOptions(SourceTargetFramework)` returning `CSharpParseOptions(LanguageVersion.CSharp12, preprocessorSymbols: …)`. Map `Net8_0` to the `net8.0` symbol set (the source only branches on `NET8_0_OR_GREATER`, so that is the essential symbol; include the usual `NET8_0` / `NETCOREAPP` for robustness) and `NetStandard2_0` to `["NETSTANDARD", "NETSTANDARD2_0"]`. The metadata references already come from the running `net8` runtime, so `INumber` and the span/memory overloads resolve for the `Net8_0` pass without any reference changes. + +- **Merger simplification**: `sourceParseOptions` always comes from the factory, and `RemoveConditionalCompilationTrivia` always runs (the previous `csharpParseOptions` / `AssertionWhitelist.IsEnabled` branching is removed). Reachability still runs only when the whitelist is enabled, using the same parse options so nodes and analysis stay consistent. Because directives are always stripped, tail members inside `#if NET8_0_OR_GREATER` are handled correctly by branch selection alone: included as real nodes under `Net8_0`, absent under `NetStandard2_0`. + +- **Polyfill options per framework**: the merger emits internal copies of `CallerArgumentExpressionAttribute` and the `System.Diagnostics.CodeAnalysis` nullable attributes, which are built into net8; emitting them under `Net8_0` produces `CS0436` type conflicts. Derive the `IncludeCallerArgumentExpressionAttribute` and `IncludeCodeAnalysisNullableAttributes` effective values from `TargetFramework` (on for `NetStandard2_0`, off for `Net8_0`) rather than requiring the user to keep `settings.json` in sync with the framework. + +- **Missing whitelist entry**: the existing throw in `EnqueueRootCheckMethods` is retained unchanged; the reflection-based name-to-entry mapping keeps the whitelist and the `Check.*.cs` files in sync by failing the export when they diverge. + +- **Build validation rework**: delete the `Light.GuardClauses.Source` project (and its solution entry) and add `Light.GuardClauses.SourceValidation` with `netstandard2.0;net8.0`, `false`, `true`, and ``. `GeneratedFileBuildValidator.Validate` no longer searches for a co-located `.csproj`; it runs `dotnet build .csproj -f -p:GeneratedSourceFile=`, where `` maps from `SourceTargetFramework`. Keeping the project in the repo lets it inherit central package management from `Directory.Packages.props` (the versionless `System.Collections.Immutable` reference); injecting the file by absolute path makes validation work for target files written anywhere, including the tests' `TemporaryDirectory`. Preserve the existing captured-output / non-zero-exit / leave-file-on-disk failure behavior. + +- **Tests**: extend `Light.GuardClauses.SourceCodeTransformation.Tests` using real files from `Light.GuardClauses`. Cover: a `Net8_0` export contains the `INumber` and span/memory members and no `#if`; a `NetStandard2_0` export omits them and contains no `#if`; a `Net8_0` export omits the polyfill attributes and validates against `net8.0`; a `NetStandard2_0` export validates against `netstandard2.0`; a build failure is surfaced through the exit code with the file left on disk; the existing whitelist behavior still holds under an explicit `NetStandard2_0` target; and a synthetic assertion file with no whitelist property throws. From 6c4c685ee808a7a53564e7f846e3e46d8c9b007f Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 2 Jul 2026 07:14:10 +0200 Subject: [PATCH 2/4] feat: initial implementation of .NET 8 support for Source Code Transformation Signed-off-by: Kenny Pflug --- Code/Light.GuardClauses.AllProjects.sln | 28 ++-- .../Light.GuardClauses.Source.csproj | 16 --- .../GeneratedFileBuildValidatorTests.cs | 96 ++----------- .../ParseCSharpFilesWithRoslynTests.cs | 25 +--- .../SourceFileMergerFrameworkTests.cs | 133 ++++++++++++++++++ .../SourceFileMergerWhitelistTests.cs | 43 +----- .../TestEnvironment.cs | 49 +++++++ .../GeneratedFileBuildValidator.cs | 63 ++++++--- .../Program.cs | 9 +- .../SourceFileMergeOptions.cs | 4 +- .../SourceFileMerger.cs | 24 ++-- .../SourceReachabilityAnalyzer.cs | 21 +-- .../SourceTargetFramework.cs | 7 + .../settings.json | 1 + ...Light.GuardClauses.SourceValidation.csproj | 22 +++ ...source-code-transformation-for-dotnet-8.md | 20 +-- Light.GuardClauses.SingleFile.cs | 124 ++-------------- 17 files changed, 336 insertions(+), 349 deletions(-) delete mode 100644 Code/Light.GuardClauses.Source/Light.GuardClauses.Source.csproj create mode 100644 Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs create mode 100644 Code/Light.GuardClauses.SourceCodeTransformation.Tests/TestEnvironment.cs create mode 100644 Code/Light.GuardClauses.SourceCodeTransformation/SourceTargetFramework.cs create mode 100644 Code/Light.GuardClauses.SourceValidation/Light.GuardClauses.SourceValidation.csproj diff --git a/Code/Light.GuardClauses.AllProjects.sln b/Code/Light.GuardClauses.AllProjects.sln index 591139a1..43889f58 100644 --- a/Code/Light.GuardClauses.AllProjects.sln +++ b/Code/Light.GuardClauses.AllProjects.sln @@ -21,7 +21,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SourceCodePublishing", "Sou EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Light.GuardClauses.SourceCodeTransformation", "Light.GuardClauses.SourceCodeTransformation\Light.GuardClauses.SourceCodeTransformation.csproj", "{ECE42390-E3B5-49D4-9452-3B0CC04C633A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Light.GuardClauses.Source", "Light.GuardClauses.Source\Light.GuardClauses.Source.csproj", "{A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Light.GuardClauses.SourceValidation", "Light.GuardClauses.SourceValidation\Light.GuardClauses.SourceValidation.csproj", "{B546B7B7-9CA5-456C-96CD-15C21E64B7DF}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Light.GuardClauses.SourceCodeTransformation.Tests", "Light.GuardClauses.SourceCodeTransformation.Tests\Light.GuardClauses.SourceCodeTransformation.Tests.csproj", "{A2067796-F167-47BE-B367-191152DCE230}" EndProject @@ -118,18 +118,18 @@ Global {ECE42390-E3B5-49D4-9452-3B0CC04C633A}.Release|x64.Build.0 = Release|Any CPU {ECE42390-E3B5-49D4-9452-3B0CC04C633A}.Release|x86.ActiveCfg = Release|Any CPU {ECE42390-E3B5-49D4-9452-3B0CC04C633A}.Release|x86.Build.0 = Release|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Debug|x64.ActiveCfg = Debug|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Debug|x64.Build.0 = Debug|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Debug|x86.ActiveCfg = Debug|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Debug|x86.Build.0 = Debug|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Release|Any CPU.Build.0 = Release|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Release|x64.ActiveCfg = Release|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Release|x64.Build.0 = Release|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Release|x86.ActiveCfg = Release|Any CPU - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843}.Release|x86.Build.0 = Release|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Debug|x64.ActiveCfg = Debug|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Debug|x64.Build.0 = Debug|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Debug|x86.Build.0 = Debug|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Release|Any CPU.Build.0 = Release|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Release|x64.ActiveCfg = Release|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Release|x64.Build.0 = Release|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Release|x86.ActiveCfg = Release|Any CPU + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF}.Release|x86.Build.0 = Release|Any CPU {A2067796-F167-47BE-B367-191152DCE230}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A2067796-F167-47BE-B367-191152DCE230}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2067796-F167-47BE-B367-191152DCE230}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -150,7 +150,7 @@ Global {DB4A3982-5312-4923-9C79-C39D18A5899C} = {A58A06DD-EEDC-4B1B-830F-0927739CFB9E} {2FE2D52E-21B8-4C51-9983-9C1812BDEBA0} = {A58A06DD-EEDC-4B1B-830F-0927739CFB9E} {ECE42390-E3B5-49D4-9452-3B0CC04C633A} = {0E0A86F9-C976-4FA1-8BF6-3A6A21D86BFD} - {A7B9AC78-FA4F-4BDE-9DAF-B854408AB843} = {0E0A86F9-C976-4FA1-8BF6-3A6A21D86BFD} + {B546B7B7-9CA5-456C-96CD-15C21E64B7DF} = {0E0A86F9-C976-4FA1-8BF6-3A6A21D86BFD} {A2067796-F167-47BE-B367-191152DCE230} = {0E0A86F9-C976-4FA1-8BF6-3A6A21D86BFD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/Code/Light.GuardClauses.Source/Light.GuardClauses.Source.csproj b/Code/Light.GuardClauses.Source/Light.GuardClauses.Source.csproj deleted file mode 100644 index 26e356d6..00000000 --- a/Code/Light.GuardClauses.Source/Light.GuardClauses.Source.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - netstandard2.0 - 12.0 - Kenny Pflug - Kenny Pflug - Copyright © Kenny Pflug 2016, 2025 - true - - - - - - - \ No newline at end of file diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs index 3861bc53..480d4964 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs @@ -1,5 +1,4 @@ -using System; -using System.IO; +using System.IO; using FluentAssertions; using Xunit; @@ -7,83 +6,42 @@ namespace Light.GuardClauses.SourceCodeTransformation.Tests; public static class GeneratedFileBuildValidatorTests { - [Fact] - public static void ValidateBuildsSingleProjectNextToGeneratedFile() - { - using var temporaryDirectory = new TemporaryDirectory(); - var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Generated.cs"); - File.WriteAllText(targetFile, "namespace ValidationSample; public static class Generated { }"); - File.WriteAllText( - Path.Combine(temporaryDirectory.DirectoryPath, "ValidationSample.csproj"), - """ - - - net8.0 - enable - enable - - - """ - ); - - GeneratedFileBuildValidator.Validate(targetFile).Should().Be(0); - } - [Fact] public static void ValidateReturnsNonZeroExitCodeAndLeavesFileOnBuildFailure() { using var temporaryDirectory = new TemporaryDirectory(); var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Generated.cs"); File.WriteAllText(targetFile, "namespace ValidationSample; public static class Generated { this is invalid }"); - File.WriteAllText( - Path.Combine(temporaryDirectory.DirectoryPath, "ValidationSample.csproj"), - """ - - - net8.0 - - - """ - ); - GeneratedFileBuildValidator.Validate(targetFile).Should().NotBe(0); + GeneratedFileBuildValidator.Validate(SourceTargetFramework.Net8_0, targetFile).Should().NotBe(0); File.Exists(targetFile).Should().BeTrue(); } [Fact] - public static void ValidateSkipsWhenNoProjectFileExists() + public static void ValidateReturnsZeroForCompilingFileOnNet8() { using var temporaryDirectory = new TemporaryDirectory(); var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Generated.cs"); File.WriteAllText(targetFile, "namespace ValidationSample; public static class Generated { }"); - GeneratedFileBuildValidator.Validate(targetFile).Should().Be(0); + GeneratedFileBuildValidator.Validate(SourceTargetFramework.Net8_0, targetFile).Should().Be(0); } [Fact] - public static void ValidateSkipsWhenMultipleProjectFilesExist() + public static void ValidateReturnsZeroForCompilingFileOnNetStandard() { using var temporaryDirectory = new TemporaryDirectory(); var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Generated.cs"); File.WriteAllText(targetFile, "namespace ValidationSample; public static class Generated { }"); - File.WriteAllText( - Path.Combine(temporaryDirectory.DirectoryPath, "First.csproj"), - "" - ); - File.WriteAllText( - Path.Combine(temporaryDirectory.DirectoryPath, "Second.csproj"), - "" - ); - GeneratedFileBuildValidator.Validate(targetFile).Should().Be(0); + GeneratedFileBuildValidator.Validate(SourceTargetFramework.NetStandard2_0, targetFile).Should().Be(0); } [Fact] - public static void ProgramRunsNonWhitelistTransformationAndSkipsValidationWithoutProject() + public static void ProgramRunsNonWhitelistNetStandardTransformationAndValidates() { using var temporaryDirectory = new TemporaryDirectory(); - var codeDirectory = FindCodeDirectory(); - var sourceFolder = Path.Combine(codeDirectory.FullName, "Light.GuardClauses"); + var sourceFolder = TestEnvironment.SourceDirectory.FullName; var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Generated.cs"); Program.Main( @@ -92,46 +50,10 @@ public static void ProgramRunsNonWhitelistTransformationAndSkipsValidationWithou $"TargetFile={targetFile}", "IncludeVersionComment=false", "AssertionWhitelist:IsEnabled=false", + "TargetFramework=NetStandard2_0", ] ).Should().Be(0); File.Exists(targetFile).Should().BeTrue(); } - - private static DirectoryInfo FindCodeDirectory() - { - var currentDirectory = new DirectoryInfo("."); - do - { - if (currentDirectory.Name == "Code") - { - return currentDirectory; - } - - currentDirectory = currentDirectory.Parent; - } while (currentDirectory != null); - - throw new InvalidOperationException( - "This test project does not reside in a folder called \"Code\" (directly or indirectly)." - ); - } - - private sealed class TemporaryDirectory : IDisposable - { - public TemporaryDirectory() - { - DirectoryPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(DirectoryPath); - } - - public string DirectoryPath { get; } - - public void Dispose() - { - if (Directory.Exists(DirectoryPath)) - { - Directory.Delete(DirectoryPath, true); - } - } - } } diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/ParseCSharpFilesWithRoslynTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/ParseCSharpFilesWithRoslynTests.cs index 9462f945..f047be66 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/ParseCSharpFilesWithRoslynTests.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/ParseCSharpFilesWithRoslynTests.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using FluentAssertions; using Microsoft.CodeAnalysis.CSharp; @@ -9,10 +8,6 @@ namespace Light.GuardClauses.SourceCodeTransformation.Tests; public static class ParseCSharpWithRoslynTests { - private static readonly DirectoryInfo CodeDirectory; - - static ParseCSharpWithRoslynTests() => CodeDirectory = FindCodeDirectory(); - [Fact] public static void ParseExpressionExtensionsFile() { @@ -36,25 +31,7 @@ public static void ParseSpanDelegatesFile() root.Members.Should().NotBeEmpty(); } - private static DirectoryInfo FindCodeDirectory() - { - var currentDirectory = new DirectoryInfo("."); - do - { - if (currentDirectory.Name == "Code") - { - return currentDirectory; - } - - currentDirectory = currentDirectory.Parent; - } while (currentDirectory != null); - - throw new InvalidOperationException( - "This test project does not reside in a folder called \"Code\" (directly or indirectly)." - ); - } - private static FileInfo GetLightGuardClausesFile(string relativeFilePath) => new ( - Path.Combine(CodeDirectory.FullName, "Light.GuardClauses", relativeFilePath) + Path.Combine(TestEnvironment.SourceDirectory.FullName, relativeFilePath) ); } diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs new file mode 100644 index 00000000..e9debbe4 --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using FluentAssertions; +using Xunit; + +namespace Light.GuardClauses.SourceCodeTransformation.Tests; + +public static class SourceFileMergerFrameworkTests +{ + [Fact] + public static void Net8ExportContainsNet8MembersAndNoDirectives() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Net8Export.cs"); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(targetFile, SourceTargetFramework.Net8_0)); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("using System.Numerics;"); + sourceCode.Should().Contain("INumber"); + sourceCode.Should().Contain("MustBeGreaterThanOrApproximately"); + sourceCode.Should().Contain("ReadOnlySpan MustBeEmailAddress"); + sourceCode.Should().Contain("Span MustBeEmailAddress"); + sourceCode.Should().Contain("Memory MustBeEmailAddress"); + sourceCode.Should().NotContain("#if"); + } + + [Fact] + public static void NetStandardExportOmitsNet8MembersAndNoDirectives() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "NetStandardExport.cs"); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(targetFile, SourceTargetFramework.NetStandard2_0)); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().NotContain("using System.Numerics;"); + sourceCode.Should().NotContain("INumber"); + sourceCode.Should().NotContain("ReadOnlySpan MustBeEmailAddress"); + sourceCode.Should().NotContain("Span MustBeEmailAddress"); + sourceCode.Should().NotContain("Memory MustBeEmailAddress"); + sourceCode.Should().NotContain("#if"); + } + + [Fact] + public static void Net8ExportOmitsPolyfillAttributes() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Net8Polyfill.cs"); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(targetFile, SourceTargetFramework.Net8_0)); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().NotContain("class AllowNullAttribute"); + sourceCode.Should().NotContain("class CallerArgumentExpressionAttribute"); + } + + [Fact] + public static void NetStandardExportIncludesPolyfillAttributes() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "NetStandardPolyfill.cs"); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(targetFile, SourceTargetFramework.NetStandard2_0)); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("class AllowNullAttribute"); + sourceCode.Should().Contain("class CallerArgumentExpressionAttribute"); + } + + [Fact] + public static void Net8ExportValidatesAgainstNet8() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Net8Validation.cs"); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(targetFile, SourceTargetFramework.Net8_0)); + + GeneratedFileBuildValidator.Validate(SourceTargetFramework.Net8_0, targetFile).Should().Be(0); + } + + [Fact] + public static void NetStandardExportValidatesAgainstNetStandard() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "NetStandardValidation.cs"); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(targetFile, SourceTargetFramework.NetStandard2_0)); + + GeneratedFileBuildValidator.Validate(SourceTargetFramework.NetStandard2_0, targetFile).Should().Be(0); + } + + [Fact] + public static void SyntheticAssertionFileWithNoWhitelistPropertyThrows() + { + using var temporaryDirectory = new TemporaryDirectory(); + var sourceFolder = Path.Combine(temporaryDirectory.DirectoryPath, "Source"); + Directory.CreateDirectory(sourceFolder); + File.WriteAllText( + Path.Combine(sourceFolder, "Check.SyntheticNotWhitelisted.cs"), + """ + namespace Light.GuardClauses; + + public static partial class Check + { + public static T SyntheticNotWhitelisted(T parameter) => parameter; + } + """ + ); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Synthetic.cs"); + var options = new SourceFileMergeOptions + { + SourceFolder = sourceFolder, + TargetFile = targetFile, + TargetFramework = SourceTargetFramework.NetStandard2_0, + IncludeVersionComment = false, + AssertionWhitelist = new AssertionWhitelist { IsEnabled = true }, + }; + + var act = () => SourceFileMerger.CreateSingleSourceFile(options); + + act.Should().Throw().WithMessage("*SyntheticNotWhitelisted*"); + } + + private static SourceFileMergeOptions CreateOptions(string targetFile, SourceTargetFramework targetFramework) => + new () + { + SourceFolder = TestEnvironment.SourceDirectory.FullName, + TargetFile = targetFile, + TargetFramework = targetFramework, + IncludeVersionComment = false, + }; +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index ec38d646..f25b43b6 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -9,10 +9,7 @@ namespace Light.GuardClauses.SourceCodeTransformation.Tests; public static class SourceFileMergerWhitelistTests { - private static readonly DirectoryInfo CodeDirectory = FindCodeDirectory(); - - private static readonly DirectoryInfo SourceDirectory = - new (Path.Combine(CodeDirectory.FullName, "Light.GuardClauses")); + private static readonly DirectoryInfo SourceDirectory = TestEnvironment.SourceDirectory; [Fact] public static void DisabledWhitelistIgnoresAllEntriesAndProducesEquivalentOutput() @@ -149,6 +146,7 @@ private static SourceFileMergeOptions CreateOptions( { SourceFolder = SourceDirectory.FullName, TargetFile = targetFile, + TargetFramework = SourceTargetFramework.NetStandard2_0, IncludeVersionComment = false, AssertionWhitelist = assertionWhitelist ?? new AssertionWhitelist(), }; @@ -183,45 +181,8 @@ params AssertionSelection[] includedAssertions return whitelist; } - private static DirectoryInfo FindCodeDirectory() - { - var currentDirectory = new DirectoryInfo("."); - do - { - if (currentDirectory.Name == "Code") - { - return currentDirectory; - } - - currentDirectory = currentDirectory.Parent; - } while (currentDirectory != null); - - throw new InvalidOperationException( - "This test project does not reside in a folder called \"Code\" (directly or indirectly)." - ); - } - private readonly record struct AssertionSelection( string AssertionName, bool IncludeExceptionFactoryOverload ); - - private sealed class TemporaryDirectory : IDisposable - { - public TemporaryDirectory() - { - DirectoryPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(DirectoryPath); - } - - public string DirectoryPath { get; } - - public void Dispose() - { - if (Directory.Exists(DirectoryPath)) - { - Directory.Delete(DirectoryPath, true); - } - } - } } diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/TestEnvironment.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/TestEnvironment.cs new file mode 100644 index 00000000..f80013be --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/TestEnvironment.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; + +namespace Light.GuardClauses.SourceCodeTransformation.Tests; + +internal static class TestEnvironment +{ + public static DirectoryInfo CodeDirectory { get; } = FindCodeDirectory(); + + public static DirectoryInfo SourceDirectory { get; } = + new (Path.Combine(CodeDirectory.FullName, "Light.GuardClauses")); + + public static DirectoryInfo FindCodeDirectory() + { + var currentDirectory = new DirectoryInfo("."); + do + { + if (currentDirectory.Name == "Code") + { + return currentDirectory; + } + + currentDirectory = currentDirectory.Parent; + } while (currentDirectory != null); + + throw new InvalidOperationException( + "This test project does not reside in a folder called \"Code\" (directly or indirectly)." + ); + } +} + +internal sealed class TemporaryDirectory : IDisposable +{ + public TemporaryDirectory() + { + DirectoryPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DirectoryPath); + } + + public string DirectoryPath { get; } + + public void Dispose() + { + if (Directory.Exists(DirectoryPath)) + { + Directory.Delete(DirectoryPath, true); + } + } +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs b/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs index 1580750d..617239ae 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs @@ -6,33 +6,34 @@ namespace Light.GuardClauses.SourceCodeTransformation; public static class GeneratedFileBuildValidator { - public static int Validate(string targetFile) + public static int Validate(SourceTargetFramework targetFramework, string targetFile) { - var targetDirectory = Path.GetDirectoryName(Path.GetFullPath(targetFile)); + var absoluteTargetPath = Path.GetFullPath(targetFile); + var targetDirectory = Path.GetDirectoryName(absoluteTargetPath); if (string.IsNullOrWhiteSpace(targetDirectory) || !Directory.Exists(targetDirectory)) { Console.WriteLine("Generated file build validation skipped because the target directory does not exist."); return 0; } - var projectFiles = Directory.GetFiles(targetDirectory, "*.csproj", SearchOption.TopDirectoryOnly); - switch (projectFiles.Length) + var projectPath = FindSourceValidationProject(); + if (projectPath == null) { - case 0: - Console.WriteLine( - "Generated file build validation skipped because no project file was found next to the target file." - ); - return 0; - - case > 1: - Console.WriteLine( - $"Warning: generated file build validation skipped because multiple project files were found in \"{targetDirectory}\"." - ); - return 0; + Console.WriteLine( + "Generated file build validation skipped because the Light.GuardClauses.SourceValidation project could not be found." + ); + return 0; } - Console.WriteLine($"Building generated project \"{projectFiles[0]}\"..."); - var startInfo = new ProcessStartInfo("dotnet", $"build \"{projectFiles[0]}\"") + var targetFrameworkMoniker = MapToTargetFrameworkMoniker(targetFramework); + + Console.WriteLine( + $"Building generated project \"{projectPath}\" targeting {targetFrameworkMoniker} with \"{absoluteTargetPath}\"..." + ); + var startInfo = new ProcessStartInfo( + "dotnet", + $"build \"{projectPath}\" -f {targetFrameworkMoniker} -p:GeneratedSourceFile=\"{absoluteTargetPath}\"" + ) { RedirectStandardError = true, RedirectStandardOutput = true, @@ -62,4 +63,32 @@ public static int Validate(string targetFile) return process.ExitCode; } + + private static string MapToTargetFrameworkMoniker(SourceTargetFramework targetFramework) => + targetFramework switch + { + SourceTargetFramework.Net8_0 => "net8.0", + _ => "netstandard2.0", + }; + + private static string? FindSourceValidationProject() + { + var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (currentDirectory != null && currentDirectory.Name != "Code") + { + currentDirectory = currentDirectory.Parent; + } + + if (currentDirectory == null) + { + return null; + } + + var projectPath = Path.Combine( + currentDirectory.FullName, + "Light.GuardClauses.SourceValidation", + "Light.GuardClauses.SourceValidation.csproj" + ); + return File.Exists(projectPath) ? projectPath : null; + } } diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs b/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs index a15c6194..1d887b75 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs @@ -20,7 +20,8 @@ public static int Main(string[] args) { Console.WriteLine("Merging source files..."); SourceFileMerger.CreateSingleSourceFile(options); - var buildValidationExitCode = GeneratedFileBuildValidator.Validate(options.TargetFile); + var buildValidationExitCode = + GeneratedFileBuildValidator.Validate(options.TargetFramework, options.TargetFile); if (buildValidationExitCode != 0) { return buildValidationExitCode; @@ -48,8 +49,10 @@ private static SourceFileMergeOptions ApplyDefaultPaths(SourceFileMergeOptions o var defaultOptions = new SourceFileMergeOptions(); return options with { - SourceFolder = string.IsNullOrWhiteSpace(options.SourceFolder) ? defaultOptions.SourceFolder : options.SourceFolder, - TargetFile = string.IsNullOrWhiteSpace(options.TargetFile) ? defaultOptions.TargetFile : options.TargetFile + SourceFolder = string.IsNullOrWhiteSpace(options.SourceFolder) ? + defaultOptions.SourceFolder : + options.SourceFolder, + TargetFile = string.IsNullOrWhiteSpace(options.TargetFile) ? defaultOptions.TargetFile : options.TargetFile, }; } } diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs index c96ff68e..d38b7c1e 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs @@ -19,7 +19,7 @@ public SourceFileMergeOptions() return; } - TargetFile = Path.Combine(currentDirectory.FullName, "Light.GuardClauses.Source", "Light.GuardClauses.cs"); + TargetFile = Path.Combine(currentDirectory.Parent!.FullName, "Light.GuardClauses.SingleFile.cs"); SourceFolder = Path.Combine(currentDirectory.FullName, "Light.GuardClauses"); } // ReSharper disable once EmptyGeneralCatchClause @@ -30,6 +30,8 @@ public SourceFileMergeOptions() public string TargetFile { get; init; } = string.Empty; + public SourceTargetFramework TargetFramework { get; init; } = SourceTargetFramework.NetStandard2_0; + public AssertionWhitelist AssertionWhitelist { get; init; } = new (); public bool ChangePublicTypesToInternalTypes { get; init; } = true; diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs index ff035d1a..3499071d 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs @@ -71,7 +71,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Runtime.Serialization; using System.Text; using System.Text.RegularExpressions; -{(options.IncludeJetBrainsAnnotationsUsing ? "using JetBrains.Annotations;" + Environment.NewLine : string.Empty)}using {options.BaseNamespace}.Exceptions; +{(options.TargetFramework == SourceTargetFramework.Net8_0 ? "using System.Numerics;" + Environment.NewLine : string.Empty)}{(options.IncludeJetBrainsAnnotationsUsing ? "using JetBrains.Annotations;" + Environment.NewLine : string.Empty)}using {options.BaseNamespace}.Exceptions; using {options.BaseNamespace}.ExceptionFactory; using {options.BaseNamespace}.FrameworkExtensions; {(options.IncludeJetBrainsAnnotationsUsing ? "using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute;" : "")} @@ -128,7 +128,13 @@ namespace JetBrains.Annotations ); } - if (options.IncludeCodeAnalysisNullableAttributes) + var includePolyfillAttributes = options.TargetFramework == SourceTargetFramework.NetStandard2_0; + var effectiveIncludeCodeAnalysisNullableAttributes = + options.IncludeCodeAnalysisNullableAttributes && includePolyfillAttributes; + var effectiveIncludeCallerArgumentExpressionAttribute = + options.IncludeCallerArgumentExpressionAttribute && includePolyfillAttributes; + + if (effectiveIncludeCodeAnalysisNullableAttributes) { stringBuilder.AppendLine().AppendLine( @" @@ -310,7 +316,7 @@ public NotNullWhenAttribute(bool returnValue) ); } - if (options.IncludeCallerArgumentExpressionAttribute) + if (effectiveIncludeCallerArgumentExpressionAttribute) { stringBuilder.AppendLine().AppendLine( @" @@ -330,11 +336,8 @@ public CallerArgumentExpressionAttribute(string parameterName) ); } - var csharpParseOptions = new CSharpParseOptions(LanguageVersion.CSharp12); - var sourceParseOptions = options.AssertionWhitelist.IsEnabled ? - SourceReachabilityAnalyzer.NetStandardParseOptions : - csharpParseOptions; - var targetSyntaxTree = CSharpSyntaxTree.ParseText(stringBuilder.ToString(), csharpParseOptions); + var sourceParseOptions = SourceReachabilityAnalyzer.CreateParseOptions(options.TargetFramework); + var targetSyntaxTree = CSharpSyntaxTree.ParseText(stringBuilder.ToString(), sourceParseOptions); var targetRoot = (CompilationUnitSyntax) targetSyntaxTree.GetRoot(); @@ -532,10 +535,7 @@ public CallerArgumentExpressionAttribute(string parameterName) // Update the target compilation unit targetRoot = targetRoot.ReplaceNodes(replacedNodes.Keys, (originalNode, _) => replacedNodes[originalNode]); - if (options.AssertionWhitelist.IsEnabled) - { - targetRoot = RemoveConditionalCompilationTrivia(targetRoot); - } + targetRoot = RemoveConditionalCompilationTrivia(targetRoot); targetRoot = targetRoot.NormalizeWhitespace(); diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/SourceReachabilityAnalyzer.cs b/Code/Light.GuardClauses.SourceCodeTransformation/SourceReachabilityAnalyzer.cs index 83bb5ddd..0dd14c2e 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/SourceReachabilityAnalyzer.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/SourceReachabilityAnalyzer.cs @@ -11,17 +11,20 @@ namespace Light.GuardClauses.SourceCodeTransformation; internal static class SourceReachabilityAnalyzer { - internal static readonly CSharpParseOptions NetStandardParseOptions = new ( - LanguageVersion.CSharp12, - preprocessorSymbols: ["NETSTANDARD", "NETSTANDARD2_0"] - ); + public static CSharpParseOptions CreateParseOptions(SourceTargetFramework targetFramework) => + new ( + LanguageVersion.CSharp12, + preprocessorSymbols: targetFramework == SourceTargetFramework.Net8_0 + ? ["NET8_0_OR_GREATER", "NET8_0", "NETCOREAPP"] + : ["NETSTANDARD", "NETSTANDARD2_0"] + ); public static SourceReachabilityAnalysis Analyze( SourceFileMergeOptions options, IEnumerable sourceFiles ) { - var catalog = SourceCatalog.Create(sourceFiles); + var catalog = SourceCatalog.Create(sourceFiles, CreateParseOptions(options.TargetFramework)); var analyzer = new Analyzer(options, catalog); return analyzer.Analyze(); } @@ -405,9 +408,9 @@ private SourceCatalog(CSharpCompilation compilation, IReadOnlyList s public Dictionary> TopLevelDeclarationsByName { get; } = new (StringComparer.Ordinal); - public static SourceCatalog Create(IEnumerable files) + public static SourceCatalog Create(IEnumerable files, CSharpParseOptions parseOptions) { - var sourceFiles = files.Select(SourceFile.Parse).ToArray(); + var sourceFiles = files.Select(file => SourceFile.Parse(file, parseOptions)).ToArray(); var compilation = CSharpCompilation.Create( "Light.GuardClauses.SourceExportReachability", sourceFiles.Select(sourceFile => sourceFile.SyntaxTree), @@ -657,11 +660,11 @@ string assemblyPath private sealed record SourceFile(FileInfo File, SyntaxTree SyntaxTree, CompilationUnitSyntax Root) { - public static SourceFile Parse(FileInfo file) + public static SourceFile Parse(FileInfo file, CSharpParseOptions parseOptions) { var syntaxTree = CSharpSyntaxTree.ParseText( file.ReadContent(), - NetStandardParseOptions, + parseOptions, file.FullName ); return new (file, syntaxTree, (CompilationUnitSyntax) syntaxTree.GetRoot()); diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/SourceTargetFramework.cs b/Code/Light.GuardClauses.SourceCodeTransformation/SourceTargetFramework.cs new file mode 100644 index 00000000..9ec157c5 --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation/SourceTargetFramework.cs @@ -0,0 +1,7 @@ +namespace Light.GuardClauses.SourceCodeTransformation; + +public enum SourceTargetFramework +{ + NetStandard2_0, + Net8_0, +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/settings.json b/Code/Light.GuardClauses.SourceCodeTransformation/settings.json index 4b3cca84..04dd265d 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/Code/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -1,6 +1,7 @@ { "SourceFolder": "", "TargetFile": "", + "TargetFramework": "NetStandard2_0", "ChangePublicTypesToInternalTypes": true, "BaseNamespace": "Light.GuardClauses", "RemoveContractAnnotations": false, diff --git a/Code/Light.GuardClauses.SourceValidation/Light.GuardClauses.SourceValidation.csproj b/Code/Light.GuardClauses.SourceValidation/Light.GuardClauses.SourceValidation.csproj new file mode 100644 index 00000000..71b193bf --- /dev/null +++ b/Code/Light.GuardClauses.SourceValidation/Light.GuardClauses.SourceValidation.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0;net8.0 + 12.0 + false + true + disable + Kenny Pflug + Kenny Pflug + Copyright © Kenny Pflug 2016, 2025 + + + + + + + + + + + diff --git a/Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md b/Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md index 821a83de..c9757b7e 100644 --- a/Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md +++ b/Code/ai-plans/0135-source-code-transformation-for-dotnet-8.md @@ -14,16 +14,16 @@ The whitelist keeps its fail-loud contract: a `Check..cs` file with no mat ## Acceptance Criteria -- [ ] A new `SourceTargetFramework` enum with values `NetStandard2_0` and `Net8_0` is introduced, and `SourceFileMergeOptions` gets `public SourceTargetFramework TargetFramework { get; init; } = SourceTargetFramework.NetStandard2_0;`. Configuration binds the value by name. -- [ ] A single factory maps `SourceTargetFramework` to `CSharpParseOptions`, and both `SourceReachabilityAnalyzer` and `SourceFileMerger` use it so reachability analysis and emitted output always agree on which branch is active. The standalone `NetStandardParseOptions` field is removed. -- [ ] The export always flattens to the selected framework: source is parsed with that framework's symbols and all `#if` directives are removed from the output, in both whitelist and non-whitelist mode. -- [ ] When `TargetFramework == NetStandard2_0`, the non-whitelist full export reproduces today's netstandard2.0 API surface with no `#if` directives. -- [ ] When `TargetFramework == Net8_0`, the flattened export defines `NET8_0_OR_GREATER`, contains the net8-only members (for example `MustBeGreaterThanOrApproximately` with an `INumber` constraint and the `Span`/`Memory` email overloads), and contains no `#if` directives. -- [ ] Polyfill-emitting options that duplicate types built into net8 (`IncludeCallerArgumentExpressionAttribute`, `IncludeCodeAnalysisNullableAttributes`) are derived from `SourceTargetFramework`: emitted for `NetStandard2_0`, suppressed for `Net8_0`, so a net8 export never redefines framework types. -- [ ] The whitelist's fail-loud behavior is preserved: a `Check..cs` file with no matching `AssertionWhitelist` property throws a clear, actionable exception. -- [ ] The `Light.GuardClauses.Source` project is removed (and dropped from the solution) and replaced by a multi-targeted (`netstandard2.0;net8.0`) `Light.GuardClauses.SourceValidation` project that compiles a file supplied through a `GeneratedSourceFile` MSBuild property rather than a co-located source file. -- [ ] `GeneratedFileBuildValidator` builds `Light.GuardClauses.SourceValidation` with the framework matching `SourceTargetFramework` and the generated file path passed in; on build failure it prints the captured output, returns a non-zero exit code, and leaves the generated file on disk. -- [ ] Automated tests need to be written. +- [x] A new `SourceTargetFramework` enum with values `NetStandard2_0` and `Net8_0` is introduced, and `SourceFileMergeOptions` gets `public SourceTargetFramework TargetFramework { get; init; } = SourceTargetFramework.NetStandard2_0;`. Configuration binds the value by name. +- [x] A single factory maps `SourceTargetFramework` to `CSharpParseOptions`, and both `SourceReachabilityAnalyzer` and `SourceFileMerger` use it so reachability analysis and emitted output always agree on which branch is active. The standalone `NetStandardParseOptions` field is removed. +- [x] The export always flattens to the selected framework: source is parsed with that framework's symbols and all `#if` directives are removed from the output, in both whitelist and non-whitelist mode. +- [x] When `TargetFramework == NetStandard2_0`, the non-whitelist full export reproduces today's netstandard2.0 API surface with no `#if` directives. +- [x] When `TargetFramework == Net8_0`, the flattened export defines `NET8_0_OR_GREATER`, contains the net8-only members (for example `MustBeGreaterThanOrApproximately` with an `INumber` constraint and the `Span`/`Memory` email overloads), and contains no `#if` directives. +- [x] Polyfill-emitting options that duplicate types built into net8 (`IncludeCallerArgumentExpressionAttribute`, `IncludeCodeAnalysisNullableAttributes`) are derived from `SourceTargetFramework`: emitted for `NetStandard2_0`, suppressed for `Net8_0`, so a net8 export never redefines framework types. +- [x] The whitelist's fail-loud behavior is preserved: a `Check..cs` file with no matching `AssertionWhitelist` property throws a clear, actionable exception. +- [x] The `Light.GuardClauses.Source` project is removed (and dropped from the solution) and replaced by a multi-targeted (`netstandard2.0;net8.0`) `Light.GuardClauses.SourceValidation` project that compiles a file supplied through a `GeneratedSourceFile` MSBuild property rather than a co-located source file. +- [x] `GeneratedFileBuildValidator` builds `Light.GuardClauses.SourceValidation` with the framework matching `SourceTargetFramework` and the generated file path passed in; on build failure it prints the captured output, returns a non-zero exit code, and leaves the generated file on disk. +- [x] Automated tests need to be written. ## Technical Details diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index ec5c0042..e1f6d1a5 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -4604,11 +4604,7 @@ public static ImmutableArray MustHaveLength(this ImmutableArray paramet /// Thrown when or is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] [ContractAnnotation("type:null => halt; baseClassOrInterfaceType:null => halt")] - public static bool InheritsFrom( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type baseClassOrInterfaceType) => baseClassOrInterfaceType.MustNotBeNull(nameof(baseClassOrInterfaceType)).IsInterface ? type.Implements(baseClassOrInterfaceType) : type.DerivesFrom(baseClassOrInterfaceType); + public static bool InheritsFrom([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type baseClassOrInterfaceType) => baseClassOrInterfaceType.MustNotBeNull(nameof(baseClassOrInterfaceType)).IsInterface ? type.Implements(baseClassOrInterfaceType) : type.DerivesFrom(baseClassOrInterfaceType); /// /// Checks if the given type derives from the specified base class or interface type. This overload uses the specified /// to compare the types. @@ -4619,11 +4615,7 @@ public static bool InheritsFrom( /// Thrown when , or , or is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] [ContractAnnotation("type:null => halt; baseClassOrInterfaceType:null => halt; typeComparer:null => halt")] - public static bool InheritsFrom( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type baseClassOrInterfaceType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) => baseClassOrInterfaceType.MustNotBeNull(nameof(baseClassOrInterfaceType)).IsInterface ? type.Implements(baseClassOrInterfaceType, typeComparer) : type.DerivesFrom(baseClassOrInterfaceType, typeComparer); + public static bool InheritsFrom([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type baseClassOrInterfaceType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) => baseClassOrInterfaceType.MustNotBeNull(nameof(baseClassOrInterfaceType)).IsInterface ? type.Implements(baseClassOrInterfaceType, typeComparer) : type.DerivesFrom(baseClassOrInterfaceType, typeComparer); /// /// Ensures that the specified URI is an absolute one, or otherwise throws a . /// @@ -4776,11 +4768,7 @@ private static bool CheckTypeEquivalency(Type type, Type other) /// Thrown when or is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] [ContractAnnotation("type:null => halt; otherType:null => halt")] - public static bool IsOrImplements( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType) => type.IsEquivalentTypeTo(otherType.MustNotBeNull(nameof(otherType))) || type.Implements(otherType); + public static bool IsOrImplements([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType) => type.IsEquivalentTypeTo(otherType.MustNotBeNull(nameof(otherType))) || type.Implements(otherType); /// /// Checks if the given is equal to the specified or if it implements it. This overload uses the specified /// to compare the types. @@ -4792,11 +4780,7 @@ public static bool IsOrImplements( /// Thrown when or is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] [ContractAnnotation("type:null => halt; otherType:null => halt")] - public static bool IsOrImplements( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) => typeComparer.MustNotBeNull(nameof(typeComparer)).Equals(type.MustNotBeNull(nameof(type)), otherType.MustNotBeNull(nameof(otherType))) || type.Implements(otherType, typeComparer); + public static bool IsOrImplements([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) => typeComparer.MustNotBeNull(nameof(typeComparer)).Equals(type.MustNotBeNull(nameof(type)), otherType.MustNotBeNull(nameof(otherType))) || type.Implements(otherType, typeComparer); /// /// Ensures that the specified is not less than the given value, or otherwise throws an . /// @@ -4891,11 +4875,7 @@ public static T MustBeGreaterThanOrEqualTo([NotNull][ValidatedNotNull] this T /// The interface type that should implement. /// Thrown when or is null. [ContractAnnotation("type:null => halt; interfaceType:null => halt")] - public static bool Implements( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type interfaceType) + public static bool Implements([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type interfaceType) { type.MustNotBeNull(); interfaceType.MustNotBeNull(); @@ -4920,11 +4900,7 @@ public static bool Implements( /// The equality comparer used to compare the interface types. /// Thrown when , or , or is null. [ContractAnnotation("type:null => halt; interfaceType:null => halt; typeComparer:null => halt")] - public static bool Implements( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type interfaceType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) + public static bool Implements([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type interfaceType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) { type.MustNotBeNull(); interfaceType.MustNotBeNull(); @@ -5187,11 +5163,7 @@ public static string MustBeEmailAddress([NotNull][ValidatedNotNull] this string? /// Thrown when or is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] [ContractAnnotation("type:null => halt; otherType:null => halt")] - public static bool IsOrInheritsFrom( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType) => type.IsEquivalentTypeTo(otherType.MustNotBeNull(nameof(otherType))) || type.InheritsFrom(otherType); + public static bool IsOrInheritsFrom([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType) => type.IsEquivalentTypeTo(otherType.MustNotBeNull(nameof(otherType))) || type.InheritsFrom(otherType); /// /// Checks if the given is equal to the specified or if it derives from it or implements it. /// This overload uses the specified to compare the types. @@ -5202,11 +5174,7 @@ public static bool IsOrInheritsFrom( /// Thrown when or is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] [ContractAnnotation("type:null => halt; otherType:null => halt; typeComparer:null => halt")] - public static bool IsOrInheritsFrom( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] -#endif - [NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) => typeComparer.MustNotBeNull(nameof(typeComparer)).Equals(type, otherType.MustNotBeNull(nameof(otherType))) || type.InheritsFrom(otherType, typeComparer); + public static bool IsOrInheritsFrom([NotNull][ValidatedNotNull] this Type type, [NotNull][ValidatedNotNull] Type otherType, [NotNull][ValidatedNotNull] IEqualityComparer typeComparer) => typeComparer.MustNotBeNull(nameof(typeComparer)).Equals(type, otherType.MustNotBeNull(nameof(otherType))) || type.InheritsFrom(otherType, typeComparer); /// /// Ensures that the string is shorter than the specified length, or otherwise throws a . /// @@ -5681,11 +5649,7 @@ public int GetHashCode(string @string) /// /// Provides regular expressions that are used in string assertions. /// -#if NET8_0_OR_GREATER -public static partial class RegularExpressions -#else internal static class RegularExpressions -#endif { /// /// Gets the string that represents the . @@ -5697,15 +5661,7 @@ internal static class RegularExpressions /// This pattern is based on https://www.rhyous.com/2010/06/15/csharp-email-regular-expression/ and /// was modified to satisfy all tests of https://blogs.msdn.microsoft.com/testing123/2009/02/06/email-address-test-cases/. /// - public static readonly Regex EmailRegex = -#if NET8_0_OR_GREATER - GenerateEmailRegex(); - - [GeneratedRegex(EmailRegexText, RegexOptions.ECMAScript | RegexOptions.CultureInvariant)] - private static partial Regex GenerateEmailRegex(); -#else - new(EmailRegexText, RegexOptions.ECMAScript | RegexOptions.CultureInvariant | RegexOptions.Compiled); -#endif + public static readonly Regex EmailRegex = new(EmailRegexText, RegexOptions.ECMAScript | RegexOptions.CultureInvariant | RegexOptions.Compiled); } [AttributeUsage(AttributeTargets.Parameter)] @@ -6139,11 +6095,7 @@ internal static class EnumInfo static EnumInfo() { -#if NET8_0_OR_GREATER - EnumConstantsArray = Enum.GetValues(); -#else EnumConstantsArray = (T[])Enum.GetValues(typeof(T)); -#endif EnumConstants = new ReadOnlyMemory(EnumConstantsArray); if (!IsFlagsEnum) { @@ -6226,12 +6178,10 @@ public EmptyCollectionException(string? parameterName = null, string? message = { } -#if !NET8_0_OR_GREATER /// protected EmptyCollectionException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6249,12 +6199,10 @@ public InvalidUriSchemeException(string? parameterName = null, string? message = { } -#if !NET8_0_OR_GREATER /// protected InvalidUriSchemeException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6272,12 +6220,10 @@ public InvalidDateTimeException(string? parameterName = null, string? message = { } -#if !NET8_0_OR_GREATER /// protected InvalidDateTimeException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6295,12 +6241,10 @@ public StringException(string? parameterName = null, string? message = null) : b { } -#if !NET8_0_OR_GREATER /// protected StringException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6318,12 +6262,10 @@ public EmptyStringException(string? parameterName = null, string? message = null { } -#if !NET8_0_OR_GREATER /// protected EmptyStringException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6341,12 +6283,10 @@ public ValuesNotEqualException(string? parameterName = null, string? message = n { } -#if !NET8_0_OR_GREATER /// protected ValuesNotEqualException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6364,12 +6304,10 @@ public EnumValueNotDefinedException(string? parameterName = null, string? messag { } -#if !NET8_0_OR_GREATER /// protected EnumValueNotDefinedException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6387,12 +6325,10 @@ public ExistingItemException(string? parameterName = null, string? message = nul { } -#if !NET8_0_OR_GREATER /// protected ExistingItemException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6410,12 +6346,10 @@ public MissingItemException(string? parameterName = null, string? message = null { } -#if !NET8_0_OR_GREATER /// protected MissingItemException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6433,12 +6367,10 @@ public AbsoluteUriException(string? parameterName = null, string? message = null { } -#if !NET8_0_OR_GREATER /// protected AbsoluteUriException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6456,12 +6388,10 @@ public EmptyGuidException(string? parameterName = null, string? message = null) { } -#if !NET8_0_OR_GREATER /// protected EmptyGuidException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6479,12 +6409,10 @@ public NullableHasNoValueException(string? parameterName = null, string? message { } -#if !NET8_0_OR_GREATER /// protected NullableHasNoValueException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6502,12 +6430,10 @@ public SameObjectReferenceException(string? parameterName = null, string? messag { } -#if !NET8_0_OR_GREATER /// protected SameObjectReferenceException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6525,12 +6451,10 @@ public TypeCastException(string? parameterName = null, string? message = null) : { } -#if !NET8_0_OR_GREATER /// protected TypeCastException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6548,12 +6472,10 @@ public StringLengthException(string? parameterName = null, string? message = nul { } -#if !NET8_0_OR_GREATER /// protected StringLengthException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6571,12 +6493,10 @@ public WhiteSpaceStringException(string? parameterName = null, string? message = { } -#if !NET8_0_OR_GREATER /// protected WhiteSpaceStringException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6594,12 +6514,10 @@ public ValueIsNotOneOfException(string? parameterName = null, string? message = { } -#if !NET8_0_OR_GREATER /// protected ValueIsNotOneOfException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6617,12 +6535,10 @@ public SubstringException(string? parameterName = null, string? message = null) { } -#if !NET8_0_OR_GREATER /// protected SubstringException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6640,12 +6556,10 @@ public InvalidConfigurationException(string? message = null, Exception? innerExc { } -#if !NET8_0_OR_GREATER /// protected InvalidConfigurationException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6663,12 +6577,10 @@ public InvalidStateException(string? message = null, Exception? innerException = { } -#if !NET8_0_OR_GREATER /// protected InvalidStateException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6686,12 +6598,10 @@ public ArgumentDefaultException(string? parameterName = null, string? message = { } -#if !NET8_0_OR_GREATER /// protected ArgumentDefaultException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6709,12 +6619,10 @@ public CollectionException(string? parameterName = null, string? message = null) { } -#if !NET8_0_OR_GREATER /// protected CollectionException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6732,12 +6640,10 @@ public UriException(string? parameterName = null, string? message = null) : base { } -#if !NET8_0_OR_GREATER /// protected UriException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6755,12 +6661,10 @@ public StringDoesNotMatchException(string? parameterName = null, string? message { } -#if !NET8_0_OR_GREATER /// protected StringDoesNotMatchException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6778,12 +6682,10 @@ public ValuesEqualException(string? parameterName = null, string? message = null { } -#if !NET8_0_OR_GREATER /// protected ValuesEqualException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6801,12 +6703,10 @@ public InvalidEmailAddressException(string? parameterName = null, string? messag { } -#if !NET8_0_OR_GREATER /// protected InvalidEmailAddressException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6824,12 +6724,10 @@ public RelativeUriException(string? parameterName = null, string? message = null { } -#if !NET8_0_OR_GREATER /// protected RelativeUriException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6847,12 +6745,10 @@ public ValueIsOneOfException(string? parameterName = null, string? message = nul { } -#if !NET8_0_OR_GREATER /// protected ValueIsOneOfException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } /// @@ -6870,12 +6766,10 @@ public InvalidCollectionCountException(string? parameterName = null, string? mes { } -#if !NET8_0_OR_GREATER /// protected InvalidCollectionCountException(SerializationInfo info, StreamingContext context) : base(info, context) { } -#endif } } From 0ad74ddf4589e3b996eac0a5aa3ce23848d65d88 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 2 Jul 2026 22:28:33 +0200 Subject: [PATCH 3/4] chore: GeneratedFileBuildValidator now finds root directory in different scenarios reliably Signed-off-by: Kenny Pflug --- .../GeneratedFileBuildValidatorTests.cs | 13 +++ .../GeneratedFileBuildValidator.cs | 88 +++++++++++++++---- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs index 480d4964..ca0f8119 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs @@ -6,6 +6,19 @@ namespace Light.GuardClauses.SourceCodeTransformation.Tests; public static class GeneratedFileBuildValidatorTests { + [Fact] + public static void TryFindSourceValidationProjectFindsProjectFromRepositoryRoot() + { + var repositoryRoot = TestEnvironment.CodeDirectory.Parent!.FullName; + var expectedProjectPath = Path.Combine( + TestEnvironment.CodeDirectory.FullName, + "Light.GuardClauses.SourceValidation", + "Light.GuardClauses.SourceValidation.csproj" + ); + + GeneratedFileBuildValidator.TryFindSourceValidationProject([repositoryRoot]).Should().Be(expectedProjectPath); + } + [Fact] public static void ValidateReturnsNonZeroExitCodeAndLeavesFileOnBuildFailure() { diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs b/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs index 617239ae..77cc1a52 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -6,6 +7,9 @@ namespace Light.GuardClauses.SourceCodeTransformation; public static class GeneratedFileBuildValidator { + private const string SourceValidationProjectDirectoryName = "Light.GuardClauses.SourceValidation"; + private const string SourceValidationProjectFileName = "Light.GuardClauses.SourceValidation.csproj"; + public static int Validate(SourceTargetFramework targetFramework, string targetFile) { var absoluteTargetPath = Path.GetFullPath(targetFile); @@ -16,14 +20,7 @@ public static int Validate(SourceTargetFramework targetFramework, string targetF return 0; } - var projectPath = FindSourceValidationProject(); - if (projectPath == null) - { - Console.WriteLine( - "Generated file build validation skipped because the Light.GuardClauses.SourceValidation project could not be found." - ); - return 0; - } + var projectPath = FindRequiredSourceValidationProject(); var targetFrameworkMoniker = MapToTargetFrameworkMoniker(targetFramework); @@ -71,23 +68,82 @@ private static string MapToTargetFrameworkMoniker(SourceTargetFramework targetFr _ => "netstandard2.0", }; - private static string? FindSourceValidationProject() + private static string FindRequiredSourceValidationProject() + { + var searchRoots = new[] + { + AppContext.BaseDirectory, + Directory.GetCurrentDirectory(), + }; + + var projectPath = TryFindSourceValidationProject(searchRoots); + if (projectPath != null) + { + return projectPath; + } + + throw new InvalidOperationException( + $"Could not find \"{SourceValidationProjectFileName}\" by searching from " + + $"\"{AppContext.BaseDirectory}\" and \"{Directory.GetCurrentDirectory()}\"." + ); + } + + public static string? TryFindSourceValidationProject(IEnumerable searchRoots) { - var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory()); - while (currentDirectory != null && currentDirectory.Name != "Code") + var searchedRoots = new HashSet(StringComparer.Ordinal); + foreach (var searchRoot in searchRoots) { - currentDirectory = currentDirectory.Parent; + if (string.IsNullOrWhiteSpace(searchRoot)) + { + continue; + } + + var fullSearchRoot = Path.GetFullPath(searchRoot); + if (!searchedRoots.Add(fullSearchRoot)) + { + continue; + } + + var currentDirectory = new DirectoryInfo(fullSearchRoot); + if (!currentDirectory.Exists) + { + continue; + } + + while (currentDirectory != null) + { + var projectPath = FindSourceValidationProjectIn(currentDirectory.FullName); + if (projectPath != null) + { + return projectPath; + } + + var codeDirectoryProjectPath = FindSourceValidationProjectIn( + Path.Combine(currentDirectory.FullName, "Code") + ); + if (codeDirectoryProjectPath != null) + { + return codeDirectoryProjectPath; + } + + currentDirectory = currentDirectory.Parent; + } } - if (currentDirectory == null) + return null; + } + + private static string? FindSourceValidationProjectIn(string directoryPath) + { + if (!Directory.Exists(directoryPath)) { return null; } var projectPath = Path.Combine( - currentDirectory.FullName, - "Light.GuardClauses.SourceValidation", - "Light.GuardClauses.SourceValidation.csproj" + directoryPath, + SourceValidationProjectDirectoryName, + SourceValidationProjectFileName ); return File.Exists(projectPath) ? projectPath : null; } From 454426ec902d714f91c56688bdc343bb03738004 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 2 Jul 2026 22:40:26 +0200 Subject: [PATCH 4/4] test: Net8ExportContainsNet8MembersAndNoDirectives now searches for Span methods more specifically Signed-off-by: Kenny Pflug --- .../SourceFileMergerFrameworkTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs index e9debbe4..805667a3 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerFrameworkTests.cs @@ -19,9 +19,9 @@ public static void Net8ExportContainsNet8MembersAndNoDirectives() sourceCode.Should().Contain("using System.Numerics;"); sourceCode.Should().Contain("INumber"); sourceCode.Should().Contain("MustBeGreaterThanOrApproximately"); - sourceCode.Should().Contain("ReadOnlySpan MustBeEmailAddress"); - sourceCode.Should().Contain("Span MustBeEmailAddress"); - sourceCode.Should().Contain("Memory MustBeEmailAddress"); + sourceCode.Should().Contain("public static ReadOnlySpan MustBeEmailAddress"); + sourceCode.Should().Contain("public static Span MustBeEmailAddress"); + sourceCode.Should().Contain("public static Memory MustBeEmailAddress"); sourceCode.Should().NotContain("#if"); } @@ -114,7 +114,7 @@ public static partial class Check TargetFile = targetFile, TargetFramework = SourceTargetFramework.NetStandard2_0, IncludeVersionComment = false, - AssertionWhitelist = new AssertionWhitelist { IsEnabled = true }, + AssertionWhitelist = new() { IsEnabled = true }, }; var act = () => SourceFileMerger.CreateSingleSourceFile(options);