diff --git a/.gitignore b/.gitignore index edb4e452..0296e0c1 100644 --- a/.gitignore +++ b/.gitignore @@ -218,10 +218,12 @@ ModelManifest.xml # TargetFrameworks.props files **/TargetFrameworks.props +# Local SourceCodeTransformation settings +/Code/Light.GuardClauses.SourceCodeTransformation/settings.local.json + # JetBrains Rider *.sln.iml .idea/ # The source code redistributable of Light.GuardClauses /Code/Light.GuardClauses.Source/Light.GuardClauses.cs -/Code/Light.GuardClauses.SourceCodeTransformation/settings.json diff --git a/Code/.idea/.idea.Light.GuardClauses.AllProjects/.idea/indexLayout.xml b/Code/.idea/.idea.Light.GuardClauses.AllProjects/.idea/indexLayout.xml new file mode 100644 index 00000000..95beddb6 --- /dev/null +++ b/Code/.idea/.idea.Light.GuardClauses.AllProjects/.idea/indexLayout.xml @@ -0,0 +1,10 @@ + + + + + ai-plans + + + + + \ No newline at end of file diff --git a/Code/AGENTS.md b/Code/AGENTS.md new file mode 100644 index 00000000..df13986f --- /dev/null +++ b/Code/AGENTS.md @@ -0,0 +1,15 @@ +# Root AGENTS.md + +Light.GuardClauses is a lightweight library for writing GuardClauses in C#/.NET + +## Implementation rules + +Plans typically have acceptance criteria with check boxes. Check each box when you are finished with the corresponding criterion. + +## Plan Rules + +Read ./ai-plans/AGENTS.md for details on how to write plans. + +## Here is Your Space + +If you encounter something worth noting while you are working on this code base, write it down here in this section. Once you are finished, I will discuss it with you, and we can decide where to put your notes. diff --git a/Code/CLAUDE.md b/Code/CLAUDE.md new file mode 100644 index 00000000..d1755dbb --- /dev/null +++ b/Code/CLAUDE.md @@ -0,0 +1 @@ +Please read AGENTS.md diff --git a/Code/Light.GuardClauses.AllProjects.sln b/Code/Light.GuardClauses.AllProjects.sln index 474b4241..591139a1 100644 --- a/Code/Light.GuardClauses.AllProjects.sln +++ b/Code/Light.GuardClauses.AllProjects.sln @@ -25,11 +25,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Light.GuardClauses.Source", 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 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plans", "Plans", "{664661A0-3861-486B-87B5-A35A5CC5F7B7}" - ProjectSection(SolutionItems) = preProject - Plans\issue-114-must-not-be-default-or-empty-for-immutable-array.md = Plans\issue-114-must-not-be-default-or-empty-for-immutable-array.md - EndProjectSection -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionItems", "{8A42A536-E597-454B-BE18-4F62311FA158}" ProjectSection(SolutionItems) = preProject ..\CONTRIBUTING.md = ..\CONTRIBUTING.md diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs new file mode 100644 index 00000000..3861bc53 --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/GeneratedFileBuildValidatorTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using FluentAssertions; +using Xunit; + +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); + File.Exists(targetFile).Should().BeTrue(); + } + + [Fact] + public static void ValidateSkipsWhenNoProjectFileExists() + { + 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); + } + + [Fact] + public static void ValidateSkipsWhenMultipleProjectFilesExist() + { + 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); + } + + [Fact] + public static void ProgramRunsNonWhitelistTransformationAndSkipsValidationWithoutProject() + { + using var temporaryDirectory = new TemporaryDirectory(); + var codeDirectory = FindCodeDirectory(); + var sourceFolder = Path.Combine(codeDirectory.FullName, "Light.GuardClauses"); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "Generated.cs"); + + Program.Main( + [ + $"SourceFolder={sourceFolder}", + $"TargetFile={targetFile}", + "IncludeVersionComment=false", + "AssertionWhitelist:IsEnabled=false", + ] + ).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/Light.GuardClauses.SourceCodeTransformation.Tests.csproj b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/Light.GuardClauses.SourceCodeTransformation.Tests.csproj index 7b64377f..3ba26b23 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/Light.GuardClauses.SourceCodeTransformation.Tests.csproj +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/Light.GuardClauses.SourceCodeTransformation.Tests.csproj @@ -1,16 +1,20 @@ - - net8.0 - false - + + net8.0 + false + - - - - - - - + + + + + + + - \ No newline at end of file + + + + + diff --git a/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs new file mode 100644 index 00000000..ec38d646 --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -0,0 +1,227 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using Xunit; + +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")); + + [Fact] + public static void DisabledWhitelistIgnoresAllEntriesAndProducesEquivalentOutput() + { + using var temporaryDirectory = new TemporaryDirectory(); + var baselineFile = Path.Combine(temporaryDirectory.DirectoryPath, "Baseline.cs"); + var disabledWhitelistFile = Path.Combine(temporaryDirectory.DirectoryPath, "DisabledWhitelist.cs"); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(baselineFile)); + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + disabledWhitelistFile, + CreateWhitelist( + false, + [new ("MustBeGreaterThan", false)] + ) + ) + ); + + File.ReadAllText(disabledWhitelistFile).Should().Be(File.ReadAllText(baselineFile)); + } + + [Fact] + public static void WhitelistModeTrimsUnrelatedHelpersAndPullsCrossAssertionDependencies() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeGreaterThan.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: [new ("MustBeGreaterThan", false)] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("MustBeGreaterThan"); + sourceCode.Should().Contain("MustNotBeNullReference"); + sourceCode.Should().Contain("public static void MustBeGreaterThan"); + sourceCode.Should().NotContain("class EnumerableExtensions"); + sourceCode.Should().NotContain("struct Range"); + sourceCode.Should().NotContain("Func exceptionFactory"); + } + + [Fact] + public static void WhitelistModePrunesBundledThrowMembersAndRetainsExceptionInheritance() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAbsoluteUri.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: [new ("MustBeAbsoluteUri", false)] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("MustBeAbsoluteUri("); + sourceCode.Should().Contain("public static void MustBeAbsoluteUri"); + sourceCode.Should().NotContain("public static void MustBeRelativeUri"); + sourceCode.Should().NotContain("public static void UriMustHaveScheme"); + sourceCode.Should().Contain("class RelativeUriException : UriException"); + sourceCode.Should().Contain("class UriException : ArgumentException"); + sourceCode.Should().NotContain("class AbsoluteUriException : UriException"); + } + + [Fact] + public static void WhitelistModeRetainsSupportClosures() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "SupportClosures.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: + [ + new ("MustBeIn", false), + new ("MustBeValidEnumValue", false), + new ("MustBeEmailAddress", false), + new ("MustHaveLength", true), + ] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("struct Range"); + sourceCode.Should().Contain("class EnumInfo"); + sourceCode.Should().Contain("class Types"); + sourceCode.Should().Contain("class RegularExpressions"); + sourceCode.Should().Contain("delegate Exception SpanExceptionFactory"); + sourceCode.Should().Contain("delegate Exception ReadOnlySpanExceptionFactory"); + sourceCode.Should().NotContain("GeneratedRegex"); + } + + [Fact] + public static void PerAssertionExceptionFactoryTrimmingOnlyRemovesSelectedAssertionOverloads() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "ExceptionFactoryTrimming.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: + [ + new ("MustHaveLength", false), + new ("MustBeGreaterThan", true), + ] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("SpanExceptionFactory exceptionFactory"); + sourceCode.Should().Contain("Func exceptionFactory"); + sourceCode.Should().Contain("public static void CustomException"); + } + + private static SourceFileMergeOptions CreateOptions( + string targetFile, + AssertionWhitelist assertionWhitelist = null + ) => + new () + { + SourceFolder = SourceDirectory.FullName, + TargetFile = targetFile, + IncludeVersionComment = false, + AssertionWhitelist = assertionWhitelist ?? new AssertionWhitelist(), + }; + + private static AssertionWhitelist CreateWhitelist( + bool isEnabled = true, + params AssertionSelection[] includedAssertions + ) + { + var whitelist = new AssertionWhitelist { IsEnabled = isEnabled }; + var selectedAssertions = includedAssertions.ToDictionary( + selection => selection.AssertionName, + StringComparer.Ordinal + ); + + foreach (var property in typeof(AssertionWhitelist).GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where( + property => property.PropertyType == + typeof(AssertionWhitelist.AssertionEntry) + )) + { + var entry = selectedAssertions.TryGetValue(property.Name, out var selection) ? + new AssertionWhitelist.AssertionEntry + { + Include = true, + IncludeExceptionFactoryOverload = selection.IncludeExceptionFactoryOverload, + } : + new AssertionWhitelist.AssertionEntry { Include = false }; + property.SetValue(whitelist, entry); + } + + 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/AssertionWhitelist.cs b/Code/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs new file mode 100644 index 00000000..509ab936 --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Light.GuardClauses.SourceCodeTransformation; + +public sealed class AssertionWhitelist +{ + public bool IsEnabled { get; init; } = false; + + public AssertionEntry DerivesFrom { get; init; } = new(); + + public new AssertionEntry Equals { get; init; } = new(); + + public AssertionEntry Implements { get; init; } = new(); + + public AssertionEntry InheritsFrom { get; init; } = new(); + + public AssertionEntry InvalidArgument { get; init; } = new(); + + public AssertionEntry InvalidOperation { get; init; } = new(); + + public AssertionEntry InvalidState { get; init; } = new(); + + public AssertionEntry IsApproximately { get; init; } = new(); + + public AssertionEntry IsDigit { get; init; } = new(); + + public AssertionEntry IsEmailAddress { get; init; } = new(); + + public AssertionEntry IsEmpty { get; init; } = new(); + + public AssertionEntry IsEmptyOrWhiteSpace { get; init; } = new(); + + public AssertionEntry IsEquivalentTypeTo { get; init; } = new(); + + public AssertionEntry IsFileExtension { get; init; } = new(); + + public AssertionEntry IsGreaterThanOrApproximately { get; init; } = new(); + + public AssertionEntry IsIn { get; init; } = new(); + + public AssertionEntry IsLessThanOrApproximately { get; init; } = new(); + + public AssertionEntry IsLetter { get; init; } = new(); + + public AssertionEntry IsLetterOrDigit { get; init; } = new(); + + public AssertionEntry IsNewLine { get; init; } = new(); + + public AssertionEntry IsNotIn { get; init; } = new(); + + public AssertionEntry IsNullOrEmpty { get; init; } = new(); + + public AssertionEntry IsNullOrWhiteSpace { get; init; } = new(); + + public AssertionEntry IsOneOf { get; init; } = new(); + + public AssertionEntry IsOpenConstructedGenericType { get; init; } = new(); + + public AssertionEntry IsOrDerivesFrom { get; init; } = new(); + + public AssertionEntry IsOrImplements { get; init; } = new(); + + public AssertionEntry IsOrInheritsFrom { get; init; } = new(); + + public AssertionEntry IsSameAs { get; init; } = new(); + + public AssertionEntry IsSubstringOf { get; init; } = new(); + + public AssertionEntry IsTrimmed { get; init; } = new(); + + public AssertionEntry IsTrimmedAtEnd { get; init; } = new(); + + public AssertionEntry IsTrimmedAtStart { get; init; } = new(); + + public AssertionEntry IsValidEnumValue { get; init; } = new(); + + public AssertionEntry IsWhiteSpace { get; init; } = new(); + + public AssertionEntry MustBe { get; init; } = new(); + + public AssertionEntry MustBeAbsoluteUri { get; init; } = new(); + + public AssertionEntry MustBeApproximately { get; init; } = new(); + + public AssertionEntry MustBeEmailAddress { get; init; } = new(); + + public AssertionEntry MustBeFileExtension { get; init; } = new(); + + public AssertionEntry MustBeGreaterThan { get; init; } = new(); + + public AssertionEntry MustBeGreaterThanOrApproximately { get; init; } = new(); + + public AssertionEntry MustBeGreaterThanOrEqualTo { get; init; } = new(); + + public AssertionEntry MustBeHttpOrHttpsUrl { get; init; } = new(); + + public AssertionEntry MustBeHttpUrl { get; init; } = new(); + + public AssertionEntry MustBeHttpsUrl { get; init; } = new(); + + public AssertionEntry MustBeIn { get; init; } = new(); + + public AssertionEntry MustBeLessThan { get; init; } = new(); + + public AssertionEntry MustBeLessThanOrApproximately { get; init; } = new(); + + public AssertionEntry MustBeLessThanOrEqualTo { get; init; } = new(); + + public AssertionEntry MustBeLocal { get; init; } = new(); + + public AssertionEntry MustBeLongerThan { get; init; } = new(); + + public AssertionEntry MustBeLongerThanOrEqualTo { get; init; } = new(); + + public AssertionEntry MustBeNewLine { get; init; } = new(); + + public AssertionEntry MustBeOfType { get; init; } = new(); + + public AssertionEntry MustBeOneOf { get; init; } = new(); + + public AssertionEntry MustBeRelativeUri { get; init; } = new(); + + public AssertionEntry MustBeShorterThan { get; init; } = new(); + + public AssertionEntry MustBeShorterThanOrEqualTo { get; init; } = new(); + + public AssertionEntry MustBeSubstringOf { get; init; } = new(); + + public AssertionEntry MustBeTrimmed { get; init; } = new(); + + public AssertionEntry MustBeTrimmedAtEnd { get; init; } = new(); + + public AssertionEntry MustBeTrimmedAtStart { get; init; } = new(); + + public AssertionEntry MustBeUnspecified { get; init; } = new(); + + public AssertionEntry MustBeUtc { get; init; } = new(); + + public AssertionEntry MustBeValidEnumValue { get; init; } = new(); + + public AssertionEntry MustContain { get; init; } = new(); + + public AssertionEntry MustEndWith { get; init; } = new(); + + public AssertionEntry MustHaveCount { get; init; } = new(); + + public AssertionEntry MustHaveLength { get; init; } = new(); + + public AssertionEntry MustHaveLengthIn { get; init; } = new(); + + public AssertionEntry MustHaveMaximumCount { get; init; } = new(); + + public AssertionEntry MustHaveMaximumLength { get; init; } = new(); + + public AssertionEntry MustHaveMinimumCount { get; init; } = new(); + + public AssertionEntry MustHaveMinimumLength { get; init; } = new(); + + public AssertionEntry MustHaveOneSchemeOf { get; init; } = new(); + + public AssertionEntry MustHaveScheme { get; init; } = new(); + + public AssertionEntry MustHaveValue { get; init; } = new(); + + public AssertionEntry MustMatch { get; init; } = new(); + + public AssertionEntry MustNotBe { get; init; } = new(); + + public AssertionEntry MustNotBeApproximately { get; init; } = new(); + + public AssertionEntry MustNotBeDefault { get; init; } = new(); + + public AssertionEntry MustNotBeDefaultOrEmpty { get; init; } = new(); + + public AssertionEntry MustNotBeEmpty { get; init; } = new(); + + public AssertionEntry MustNotBeGreaterThan { get; init; } = new(); + + public AssertionEntry MustNotBeGreaterThanOrEqualTo { get; init; } = new(); + + public AssertionEntry MustNotBeIn { get; init; } = new(); + + public AssertionEntry MustNotBeLessThan { get; init; } = new(); + + public AssertionEntry MustNotBeLessThanOrEqualTo { get; init; } = new(); + + public AssertionEntry MustNotBeNull { get; init; } = new(); + + public AssertionEntry MustNotBeNullOrEmpty { get; init; } = new(); + + public AssertionEntry MustNotBeNullOrWhiteSpace { get; init; } = new(); + + public AssertionEntry MustNotBeNullReference { get; init; } = new(); + + public AssertionEntry MustNotBeOneOf { get; init; } = new(); + + public AssertionEntry MustNotBeSameAs { get; init; } = new(); + + public AssertionEntry MustNotBeSubstringOf { get; init; } = new(); + + public AssertionEntry MustNotContain { get; init; } = new(); + + public AssertionEntry MustNotEndWith { get; init; } = new(); + + public AssertionEntry MustNotStartWith { get; init; } = new(); + + public AssertionEntry MustStartWith { get; init; } = new(); + + internal IReadOnlyDictionary GetEntriesByAssertionName() => + GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.PropertyType == typeof(AssertionEntry)) + .ToDictionary( + property => property.Name, + property => (AssertionEntry) property.GetValue(this)!, + StringComparer.Ordinal + ); + + public sealed record AssertionEntry + { + public bool Include { get; init; } = true; + + public bool IncludeExceptionFactoryOverload { get; init; } = true; + } +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs b/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs new file mode 100644 index 00000000..1580750d --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation/GeneratedFileBuildValidator.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace Light.GuardClauses.SourceCodeTransformation; + +public static class GeneratedFileBuildValidator +{ + public static int Validate(string targetFile) + { + var targetDirectory = Path.GetDirectoryName(Path.GetFullPath(targetFile)); + 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) + { + 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($"Building generated project \"{projectFiles[0]}\"..."); + var startInfo = new ProcessStartInfo("dotnet", $"build \"{projectFiles[0]}\"") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + + using var process = Process.Start(startInfo) ?? + throw new InvalidOperationException("Could not start dotnet build process."); + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + var standardOutput = standardOutputTask.GetAwaiter().GetResult(); + var standardError = standardErrorTask.GetAwaiter().GetResult(); + + if (process.ExitCode == 0) + { + Console.WriteLine("Generated project build validation completed successfully."); + return 0; + } + + Console.WriteLine("Generated project build validation failed."); + Console.WriteLine(standardOutput); + if (!string.IsNullOrWhiteSpace(standardError)) + { + Console.WriteLine(standardError); + } + + return process.ExitCode; + } +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj b/Code/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj index d8183864..663ae060 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj +++ b/Code/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj @@ -1,25 +1,29 @@ - + - - Exe - net8.0 - enable - + + Exe + net8.0 + enable + - - - - - - - + + + + + + + + - - - PreserveNewest - - + + + PreserveNewest + + + PreserveNewest + + - \ No newline at end of file + diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs b/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs index 76d4c43e..a15c6194 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/Program.cs @@ -9,15 +9,23 @@ public static int Main(string[] args) { Console.WriteLine("Creating configuration..."); var configuration = new ConfigurationBuilder().AddJsonFile("settings.json", true) + .AddJsonFile("settings.local.json", true) .AddCommandLine(args) .Build(); var options = new SourceFileMergeOptions(); configuration.Bind(options); + options = ApplyDefaultPaths(options); try { Console.WriteLine("Merging source files..."); SourceFileMerger.CreateSingleSourceFile(options); + var buildValidationExitCode = GeneratedFileBuildValidator.Validate(options.TargetFile); + if (buildValidationExitCode != 0) + { + return buildValidationExitCode; + } + Console.WriteLine("Source file export completed successfully."); return 0; } @@ -28,4 +36,20 @@ public static int Main(string[] args) return -1; } } -} \ No newline at end of file + + private static SourceFileMergeOptions ApplyDefaultPaths(SourceFileMergeOptions options) + { + if (!string.IsNullOrWhiteSpace(options.SourceFolder) && + !string.IsNullOrWhiteSpace(options.TargetFile)) + { + return options; + } + + 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 + }; + } +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs index f65f28aa..c96ff68e 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMergeOptions.cs @@ -10,10 +10,14 @@ public SourceFileMergeOptions() { var currentDirectory = new DirectoryInfo("."); while (currentDirectory != null && currentDirectory.Name != "Code") + { currentDirectory = currentDirectory.Parent; + } if (currentDirectory == null) + { return; + } TargetFile = Path.Combine(currentDirectory.FullName, "Light.GuardClauses.Source", "Light.GuardClauses.cs"); SourceFolder = Path.Combine(currentDirectory.FullName, "Light.GuardClauses"); @@ -26,6 +30,8 @@ public SourceFileMergeOptions() public string TargetFile { get; init; } = string.Empty; + public AssertionWhitelist AssertionWhitelist { get; init; } = new (); + public bool ChangePublicTypesToInternalTypes { get; init; } = true; public string BaseNamespace { get; init; } = "Light.GuardClauses"; @@ -53,4 +59,4 @@ public SourceFileMergeOptions() public bool IncludeCallerArgumentExpressionAttribute { get; init; } = true; public bool RemoveCallerArgumentExpressions { get; init; } = false; -} \ No newline at end of file +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs index 6b162b9a..ff035d1a 100644 --- a/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs +++ b/Code/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs @@ -15,7 +15,7 @@ public static class SourceFileMerger { public static void CreateSingleSourceFile(SourceFileMergeOptions options) { - options.MustNotBeNull(nameof(options)); + options.MustNotBeNull(); // Prepare the target syntax var stringBuilder = new StringBuilder(); @@ -331,6 +331,9 @@ 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 targetRoot = (CompilationUnitSyntax) targetSyntaxTree.GetRoot(); @@ -367,11 +370,21 @@ public CallerArgumentExpressionAttribute(string parameterName) !f.FullName.Contains("bin") ) .ToDictionary(f => f.Name); + SourceReachabilityAnalysis? reachabilityAnalysis = null; + if (options.AssertionWhitelist.IsEnabled) + { + Console.WriteLine("Analyzing assertion whitelist reachability..."); + reachabilityAnalysis = SourceReachabilityAnalyzer.Analyze(options, allSourceFiles.Values); + } // Start with Check.cs before all other files to prepare the Check class Console.WriteLine("Preparing Check class..."); var currentFile = allSourceFiles["Check.cs"]; - var sourceSyntaxTree = CSharpSyntaxTree.ParseText(currentFile.ReadContent(), csharpParseOptions); + var sourceSyntaxTree = CSharpSyntaxTree.ParseText( + currentFile.ReadContent(), + sourceParseOptions, + currentFile.FullName + ); var checkClassDeclaration = (ClassDeclarationSyntax) sourceSyntaxTree.GetRoot() .DescendantNodes() .First( @@ -388,7 +401,11 @@ public CallerArgumentExpressionAttribute(string parameterName) // Do the same thing for the Throw class Console.WriteLine("Preparing Throw class..."); currentFile = allSourceFiles["Throw.cs"]; - sourceSyntaxTree = CSharpSyntaxTree.ParseText(currentFile.ReadContent(), csharpParseOptions); + sourceSyntaxTree = CSharpSyntaxTree.ParseText( + currentFile.ReadContent(), + sourceParseOptions, + currentFile.FullName + ); var throwClassDeclaration = (ClassDeclarationSyntax) sourceSyntaxTree.GetRoot() .DescendantNodes() .First( @@ -406,18 +423,17 @@ public CallerArgumentExpressionAttribute(string parameterName) Console.WriteLine("Merging remaining files..."); foreach (var fileName in allSourceFiles.Keys) { - if (fileName == "SpanDelegates.cs") - { - - } - if (!CheckIfFileShouldBeProcessed(options, fileName)) { continue; } currentFile = allSourceFiles[fileName]; - sourceSyntaxTree = CSharpSyntaxTree.ParseText(currentFile.ReadContent(), csharpParseOptions); + sourceSyntaxTree = CSharpSyntaxTree.ParseText( + currentFile.ReadContent(), + sourceParseOptions, + currentFile.FullName + ); var originalNamespace = DetermineOriginalNamespace( options, defaultNamespace, @@ -438,8 +454,15 @@ public CallerArgumentExpressionAttribute(string parameterName) SyntaxKind.ClassDeclaration ) ); + var checkMembersToAdd = reachabilityAnalysis == null ? + classDeclaration.Members : + new ( + classDeclaration.Members.Where( + member => reachabilityAnalysis.ShouldIncludeCheckOrThrowMember(currentFile, member) + ) + ); checkClassDeclaration = - checkClassDeclaration.WithMembers(checkClassDeclaration.Members.AddRange(classDeclaration.Members)); + checkClassDeclaration.WithMembers(checkClassDeclaration.Members.AddRange(checkMembersToAdd)); continue; } @@ -453,8 +476,15 @@ public CallerArgumentExpressionAttribute(string parameterName) SyntaxKind.ClassDeclaration ) ); + var throwMembersToAdd = reachabilityAnalysis == null ? + classDeclaration.Members : + new ( + classDeclaration.Members.Where( + member => reachabilityAnalysis.ShouldIncludeCheckOrThrowMember(currentFile, member) + ) + ); throwClassDeclaration = - throwClassDeclaration.WithMembers(throwClassDeclaration.Members.AddRange(classDeclaration.Members)); + throwClassDeclaration.WithMembers(throwClassDeclaration.Members.AddRange(throwMembersToAdd)); continue; } @@ -466,6 +496,19 @@ public CallerArgumentExpressionAttribute(string parameterName) } var membersToAdd = ((FileScopedNamespaceDeclarationSyntax) sourceCompilationUnit.Members[0]).Members; + if (reachabilityAnalysis != null && !ShouldAlwaysProcessWholeFileInWhitelistMode(currentFile.Name)) + { + membersToAdd = new ( + membersToAdd.Where( + member => reachabilityAnalysis.ShouldIncludeTopLevelDeclaration(currentFile, member) + ) + ); + } + + if (membersToAdd.Count == 0) + { + continue; + } var currentlyEditedNamespace = replacedNodes[originalNamespace]; replacedNodes[originalNamespace] = @@ -488,8 +531,13 @@ public CallerArgumentExpressionAttribute(string parameterName) ); // Update the target compilation unit - targetRoot = targetRoot.ReplaceNodes(replacedNodes.Keys, (originalNode, _) => replacedNodes[originalNode]) - .NormalizeWhitespace(); + targetRoot = targetRoot.ReplaceNodes(replacedNodes.Keys, (originalNode, _) => replacedNodes[originalNode]); + if (options.AssertionWhitelist.IsEnabled) + { + targetRoot = RemoveConditionalCompilationTrivia(targetRoot); + } + + targetRoot = targetRoot.NormalizeWhitespace(); // Make types internal if necessary if (options.ChangePublicTypesToInternalTypes) @@ -618,6 +666,23 @@ private static bool CheckIfFileShouldBeProcessed(SourceFileMergeOptions options, (fileName != "ReSharperAnnotations.cs" || options.IncludeJetBrainsAnnotations) && (fileName != "ValidatedNotNullAttribute.cs" || options.IncludeValidatedNotNullAttribute); + private static bool ShouldAlwaysProcessWholeFileInWhitelistMode(string fileName) => + fileName is "ReSharperAnnotations.cs" or "ValidatedNotNullAttribute.cs"; + + private static CompilationUnitSyntax RemoveConditionalCompilationTrivia(CompilationUnitSyntax targetRoot) + { + var triviaToRemove = targetRoot.DescendantTrivia(descendIntoTrivia: true) + .Where(IsConditionalCompilationTrivia); + return targetRoot.ReplaceTrivia(triviaToRemove, (_, _) => default); + } + + private static bool IsConditionalCompilationTrivia(SyntaxTrivia trivia) => + trivia.IsKind(SyntaxKind.DisabledTextTrivia) || + trivia.IsKind(SyntaxKind.IfDirectiveTrivia) || + trivia.IsKind(SyntaxKind.ElifDirectiveTrivia) || + trivia.IsKind(SyntaxKind.ElseDirectiveTrivia) || + trivia.IsKind(SyntaxKind.EndIfDirectiveTrivia); + private static NamespaceDeclarationSyntax DetermineOriginalNamespace( SourceFileMergeOptions options, NamespaceDeclarationSyntax defaultNamespace, @@ -631,9 +696,15 @@ private static NamespaceDeclarationSyntax DetermineOriginalNamespace( var originalNamespace = defaultNamespace; switch (currentFile.Directory?.Name) { - case "FrameworkExtensions": originalNamespace = extensionsNamespace; break; - case "Exceptions": originalNamespace = exceptionsNamespace; break; - case "ExceptionFactory": originalNamespace = exceptionFactoryNamespace; break; + case "FrameworkExtensions": + originalNamespace = extensionsNamespace; + break; + case "Exceptions": + originalNamespace = exceptionsNamespace; + break; + case "ExceptionFactory": + originalNamespace = exceptionFactoryNamespace; + break; default: { if (options.IncludeJetBrainsAnnotations && currentFile.Name == "ReSharperAnnotations.cs") diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/SourceReachabilityAnalyzer.cs b/Code/Light.GuardClauses.SourceCodeTransformation/SourceReachabilityAnalyzer.cs new file mode 100644 index 00000000..83bb5ddd --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation/SourceReachabilityAnalyzer.cs @@ -0,0 +1,718 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Light.GuardClauses.SourceCodeTransformation; + +internal static class SourceReachabilityAnalyzer +{ + internal static readonly CSharpParseOptions NetStandardParseOptions = new ( + LanguageVersion.CSharp12, + preprocessorSymbols: ["NETSTANDARD", "NETSTANDARD2_0"] + ); + + public static SourceReachabilityAnalysis Analyze( + SourceFileMergeOptions options, + IEnumerable sourceFiles + ) + { + var catalog = SourceCatalog.Create(sourceFiles); + var analyzer = new Analyzer(options, catalog); + return analyzer.Analyze(); + } + + private static bool TryGetAssertionName(string fileName, out string assertionName) + { + const string prefix = "Check."; + const string suffix = ".cs"; + + if (fileName.StartsWith(prefix, StringComparison.Ordinal) && + fileName.EndsWith(suffix, StringComparison.Ordinal)) + { + assertionName = fileName.Substring(prefix.Length, fileName.Length - prefix.Length - suffix.Length); + return true; + } + + assertionName = string.Empty; + return false; + } + + private sealed class Analyzer + { + private readonly SourceCatalog _catalog; + private readonly Queue _declarationsToScan = new (); + private readonly SourceFileMergeOptions _options; + private readonly HashSet _reachableCheckAndThrowMembers = new (); + private readonly HashSet _reachableTopLevelDeclarations = new (); + private readonly IReadOnlyDictionary _whitelistEntries; + + public Analyzer(SourceFileMergeOptions options, SourceCatalog catalog) + { + _options = options; + _catalog = catalog; + _whitelistEntries = options.AssertionWhitelist.GetEntriesByAssertionName(); + } + + public SourceReachabilityAnalysis Analyze() + { + EnqueueRootCheckMethods(); + + while (_declarationsToScan.TryDequeue(out var declaration)) + { + ScanDeclaration(declaration); + } + + return new ( + _reachableCheckAndThrowMembers, + _reachableTopLevelDeclarations + ); + } + + private void EnqueueRootCheckMethods() + { + foreach (var declaration in _catalog.CheckMembers) + { + if (!TryGetAssertionName(declaration.File.Name, out var assertionName)) + { + continue; + } + + if (!_whitelistEntries.TryGetValue(assertionName, out var entry)) + { + throw new InvalidOperationException( + $"The assertion whitelist does not expose an entry named \"{assertionName}\" for file \"{declaration.File.Name}\"." + ); + } + + if (!entry.Include) + { + continue; + } + + if (ShouldExcludeDeclaration(declaration)) + { + continue; + } + + EnqueueDeclaration(declaration); + } + } + + private void ScanDeclaration(SourceDeclaration declaration) + { + var semanticModel = _catalog.GetSemanticModel(declaration.Syntax.SyntaxTree); + + foreach (var node in declaration.Syntax.DescendantNodesAndSelf()) + { + var foundSourceSymbol = MarkSymbols(node, semanticModel); + if (!foundSourceSymbol) + { + MarkFallbackSourceSymbols(node); + } + } + } + + private bool MarkSymbols(SyntaxNode node, SemanticModel semanticModel) + { + var foundSourceSymbol = false; + + var symbolInfo = GetSymbolInfo(node, semanticModel); + foundSourceSymbol |= MarkSymbol(symbolInfo.Symbol); + foreach (var candidateSymbol in symbolInfo.CandidateSymbols) + { + foundSourceSymbol |= MarkSymbol(candidateSymbol); + } + + var typeInfo = GetTypeInfo(node, semanticModel); + foundSourceSymbol |= MarkType(typeInfo.Type); + foundSourceSymbol |= MarkType(typeInfo.ConvertedType); + + return foundSourceSymbol; + } + + private bool MarkSymbol(ISymbol? symbol) + { + if (symbol == null) + { + return false; + } + + if (symbol is IMethodSymbol { ReducedFrom: { } reducedFrom }) + { + return MarkSymbol(reducedFrom); + } + + switch (symbol) + { + case IMethodSymbol methodSymbol: + if (_catalog.TryGetCheckOrThrowMember(methodSymbol, out var declaration)) + { + if (_options.RemoveOverloadsWithExceptionFactory && + declaration.HasExceptionFactoryParameter) + { + return true; + } + + EnqueueDeclaration(declaration); + return true; + } + + var foundMethodSourceSymbol = MarkSymbol(methodSymbol.AssociatedSymbol); + foundMethodSourceSymbol |= MarkType(methodSymbol.ContainingType); + foundMethodSourceSymbol |= MarkType(methodSymbol.ReturnType); + foreach (var parameter in methodSymbol.Parameters) + { + foundMethodSourceSymbol |= MarkType(parameter.Type); + } + + foreach (var typeArgument in methodSymbol.TypeArguments) + { + foundMethodSourceSymbol |= MarkType(typeArgument); + } + + return foundMethodSourceSymbol; + + case INamedTypeSymbol namedTypeSymbol: return MarkType(namedTypeSymbol); + + case IFieldSymbol fieldSymbol: return MarkType(fieldSymbol.ContainingType) | MarkType(fieldSymbol.Type); + + case IPropertySymbol propertySymbol: + return MarkType(propertySymbol.ContainingType) | MarkType(propertySymbol.Type); + + case IEventSymbol eventSymbol: return MarkType(eventSymbol.ContainingType) | MarkType(eventSymbol.Type); + + case IParameterSymbol parameterSymbol: return MarkType(parameterSymbol.Type); + + case ILocalSymbol localSymbol: return MarkType(localSymbol.Type); + } + + return MarkType(symbol.ContainingType); + } + + private bool MarkType(ITypeSymbol? typeSymbol) => + MarkType(typeSymbol, new (SymbolEqualityComparer.Default)); + + private bool MarkType(ITypeSymbol? typeSymbol, HashSet visitedTypes) + { + if (typeSymbol == null) + { + return false; + } + + if (!visitedTypes.Add(typeSymbol)) + { + return false; + } + + switch (typeSymbol) + { + case IArrayTypeSymbol arrayTypeSymbol: return MarkType(arrayTypeSymbol.ElementType, visitedTypes); + + case IPointerTypeSymbol pointerTypeSymbol: + return MarkType(pointerTypeSymbol.PointedAtType, visitedTypes); + + case ITypeParameterSymbol typeParameterSymbol: + var foundConstraintSourceSymbol = false; + foreach (var constraintType in typeParameterSymbol.ConstraintTypes) + { + foundConstraintSourceSymbol |= MarkType(constraintType, visitedTypes); + } + + return foundConstraintSourceSymbol; + + case INamedTypeSymbol namedTypeSymbol: + var foundSourceSymbol = false; + if (_catalog.TryGetTopLevelDeclarationForType(namedTypeSymbol, out var declaration)) + { + EnqueueDeclaration(declaration); + foundSourceSymbol = true; + } + + foreach (var typeArgument in namedTypeSymbol.TypeArguments) + { + foundSourceSymbol |= MarkType(typeArgument, visitedTypes); + } + + if (namedTypeSymbol.ContainingType != null) + { + foundSourceSymbol |= MarkType(namedTypeSymbol.ContainingType, visitedTypes); + } + + return foundSourceSymbol; + } + + return false; + } + + private void MarkFallbackSourceSymbols(SyntaxNode node) + { + var name = GetSimpleName(node); + if (name == null) + { + return; + } + + if (_catalog.TopLevelDeclarationsByName.TryGetValue(name, out var declarations)) + { + foreach (var declaration in declarations) + { + EnqueueDeclaration(declaration); + } + } + + if (!IsInvocationName(node)) + { + return; + } + + if (_catalog.CheckMembersByName.TryGetValue(name, out var checkMembers)) + { + foreach (var checkMember in checkMembers) + { + EnqueueDeclaration(checkMember); + } + } + + if (_catalog.ThrowMembersByName.TryGetValue(name, out var throwMembers)) + { + foreach (var throwMember in throwMembers) + { + EnqueueDeclaration(throwMember); + } + } + } + + private void EnqueueDeclaration(SourceDeclaration declaration) + { + if (ShouldExcludeDeclaration(declaration)) + { + return; + } + + var reachableSet = declaration.IsCheckOrThrowMember ? + _reachableCheckAndThrowMembers : + _reachableTopLevelDeclarations; + + if (reachableSet.Add(declaration.Key)) + { + _declarationsToScan.Enqueue(declaration); + } + } + + private bool ShouldExcludeDeclaration(SourceDeclaration declaration) + { + if (!declaration.IsCheckOrThrowMember || !declaration.HasExceptionFactoryParameter) + { + return false; + } + + if (_options.RemoveOverloadsWithExceptionFactory) + { + return true; + } + + if (declaration.Kind != SourceDeclarationKind.CheckMember || + !TryGetAssertionName(declaration.File.Name, out var assertionName) || + !_whitelistEntries.TryGetValue(assertionName, out var entry)) + { + return false; + } + + return !entry.IncludeExceptionFactoryOverload; + } + + private static SymbolInfo GetSymbolInfo(SyntaxNode node, SemanticModel semanticModel) => + node switch + { + AttributeSyntax attribute => semanticModel.GetSymbolInfo(attribute), + ConstructorInitializerSyntax constructorInitializer => semanticModel.GetSymbolInfo( + constructorInitializer + ), + ExpressionSyntax expression => semanticModel.GetSymbolInfo(expression), + OrderingSyntax ordering => semanticModel.GetSymbolInfo(ordering), + SelectOrGroupClauseSyntax selectOrGroupClause => semanticModel.GetSymbolInfo(selectOrGroupClause), + _ => default, + }; + + private static TypeInfo GetTypeInfo(SyntaxNode node, SemanticModel semanticModel) => + node switch + { + TypeSyntax type => semanticModel.GetTypeInfo(type), + ExpressionSyntax expression => semanticModel.GetTypeInfo(expression), + _ => default, + }; + + private static string? GetSimpleName(SyntaxNode node) => + node switch + { + IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText, + GenericNameSyntax genericName => genericName.Identifier.ValueText, + MemberAccessExpressionSyntax memberAccess => GetSimpleName(memberAccess.Name), + QualifiedNameSyntax qualifiedName => GetSimpleName(qualifiedName.Right), + AliasQualifiedNameSyntax aliasQualifiedName => GetSimpleName(aliasQualifiedName.Name), + AttributeSyntax attribute => GetSimpleName(attribute.Name), + _ => null, + }; + + private static bool IsInvocationName(SyntaxNode node) + { + if (node is not SimpleNameSyntax) + { + return false; + } + + return node.Parent switch + { + InvocationExpressionSyntax => true, + MemberAccessExpressionSyntax { Parent: InvocationExpressionSyntax } => true, + MemberBindingExpressionSyntax { Parent: InvocationExpressionSyntax } => true, + _ => false, + }; + } + } + + private sealed class SourceCatalog + { + private readonly Dictionary _checkAndThrowMembers = + new (SymbolEqualityComparer.Default); + + private readonly Dictionary _semanticModels = new (); + + private readonly Dictionary _topLevelDeclarationsByType = + new (SymbolEqualityComparer.Default); + + private SourceCatalog(CSharpCompilation compilation, IReadOnlyList sourceFiles) + { + Compilation = compilation; + SourceFiles = sourceFiles; + } + + public CSharpCompilation Compilation { get; } + + public IReadOnlyList SourceFiles { get; } + + public List CheckMembers { get; } = new (); + + public Dictionary> CheckMembersByName { get; } = new (StringComparer.Ordinal); + + public Dictionary> ThrowMembersByName { get; } = new (StringComparer.Ordinal); + + public Dictionary> TopLevelDeclarationsByName { get; } = + new (StringComparer.Ordinal); + + public static SourceCatalog Create(IEnumerable files) + { + var sourceFiles = files.Select(SourceFile.Parse).ToArray(); + var compilation = CSharpCompilation.Create( + "Light.GuardClauses.SourceExportReachability", + sourceFiles.Select(sourceFile => sourceFile.SyntaxTree), + CreateMetadataReferences(), + new ( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + allowUnsafe: true + ) + ); + + var catalog = new SourceCatalog(compilation, sourceFiles); + catalog.IndexDeclarations(); + return catalog; + } + + public SemanticModel GetSemanticModel(SyntaxTree syntaxTree) + { + if (!_semanticModels.TryGetValue(syntaxTree, out var semanticModel)) + { + semanticModel = Compilation.GetSemanticModel(syntaxTree, true); + _semanticModels.Add(syntaxTree, semanticModel); + } + + return semanticModel; + } + + public bool TryGetCheckOrThrowMember(IMethodSymbol methodSymbol, out SourceDeclaration declaration) + { + if (methodSymbol.ReducedFrom != null && + TryGetCheckOrThrowMember(methodSymbol.ReducedFrom, out declaration!)) + { + return true; + } + + if (_checkAndThrowMembers.TryGetValue(methodSymbol, out declaration!)) + { + return true; + } + + return _checkAndThrowMembers.TryGetValue(methodSymbol.OriginalDefinition, out declaration!); + } + + public bool TryGetTopLevelDeclarationForType( + INamedTypeSymbol typeSymbol, + out SourceDeclaration declaration + ) + { + if (_topLevelDeclarationsByType.TryGetValue(typeSymbol, out declaration!)) + { + return true; + } + + return _topLevelDeclarationsByType.TryGetValue(typeSymbol.OriginalDefinition, out declaration!); + } + + private void IndexDeclarations() + { + foreach (var sourceFile in SourceFiles) + { + var semanticModel = GetSemanticModel(sourceFile.SyntaxTree); + + foreach (var member in GetNamespaceMembers(sourceFile.Root)) + { + if (member is ClassDeclarationSyntax { Identifier.ValueText: "Check" } checkClass) + { + IndexCheckOrThrowMembers(sourceFile, checkClass, semanticModel, true); + continue; + } + + if (member is ClassDeclarationSyntax { Identifier.ValueText: "Throw" } throwClass) + { + IndexCheckOrThrowMembers(sourceFile, throwClass, semanticModel, false); + continue; + } + + if (IsTypeDeclaration(member)) + { + IndexTopLevelType(sourceFile, member, semanticModel); + } + } + } + } + + private void IndexCheckOrThrowMembers( + SourceFile sourceFile, + ClassDeclarationSyntax classDeclaration, + SemanticModel semanticModel, + bool isCheck + ) + { + foreach (var member in classDeclaration.Members) + { + if (GetDeclaredSymbol(semanticModel, member) is not IMethodSymbol methodSymbol) + { + continue; + } + + var declaration = new SourceDeclaration( + sourceFile.File, + member, + isCheck ? SourceDeclarationKind.CheckMember : SourceDeclarationKind.ThrowMember, + methodSymbol.Name, + HasExceptionFactoryParameter(member) + ); + AddCheckOrThrowMember(methodSymbol, declaration); + AddCheckOrThrowMember(methodSymbol.OriginalDefinition, declaration); + AddByName(isCheck ? CheckMembersByName : ThrowMembersByName, methodSymbol.Name, declaration); + + if (isCheck) + { + CheckMembers.Add(declaration); + } + } + } + + private void IndexTopLevelType( + SourceFile sourceFile, + MemberDeclarationSyntax topLevelMember, + SemanticModel semanticModel + ) + { + if (GetDeclaredSymbol(semanticModel, topLevelMember) is not INamedTypeSymbol topLevelSymbol) + { + return; + } + + var declaration = new SourceDeclaration( + sourceFile.File, + topLevelMember, + SourceDeclarationKind.TopLevelType, + topLevelSymbol.Name, + false + ); + AddByName(TopLevelDeclarationsByName, topLevelSymbol.Name, declaration); + + foreach (var typeMember in topLevelMember.DescendantNodesAndSelf().Where(IsTypeDeclarationNode)) + { + if (GetDeclaredSymbol(semanticModel, typeMember) is INamedTypeSymbol typeSymbol) + { + AddTopLevelDeclarationForType(typeSymbol, declaration); + AddTopLevelDeclarationForType(typeSymbol.OriginalDefinition, declaration); + } + } + } + + private void AddCheckOrThrowMember(ISymbol symbol, SourceDeclaration declaration) => + _checkAndThrowMembers[symbol] = declaration; + + private void AddTopLevelDeclarationForType(ISymbol symbol, SourceDeclaration declaration) => + _topLevelDeclarationsByType[symbol] = declaration; + + private static void AddByName( + Dictionary> declarationsByName, + string name, + SourceDeclaration declaration + ) + { + if (!declarationsByName.TryGetValue(name, out var declarations)) + { + declarations = new (); + declarationsByName.Add(name, declarations); + } + + declarations.Add(declaration); + } + + private static IEnumerable GetNamespaceMembers(CompilationUnitSyntax root) + { + foreach (var member in root.Members) + { + switch (member) + { + case FileScopedNamespaceDeclarationSyntax fileScopedNamespace: + foreach (var namespaceMember in fileScopedNamespace.Members) + { + yield return namespaceMember; + } + + break; + + case NamespaceDeclarationSyntax namespaceDeclaration: + foreach (var namespaceMember in namespaceDeclaration.Members) + { + yield return namespaceMember; + } + + break; + } + } + } + + private static bool IsTypeDeclaration(MemberDeclarationSyntax member) => + member is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax; + + private static bool IsTypeDeclarationNode(SyntaxNode node) => + node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax; + + private static ISymbol? GetDeclaredSymbol(SemanticModel semanticModel, SyntaxNode declaration) => + declaration switch + { + BaseMethodDeclarationSyntax method => semanticModel.GetDeclaredSymbol(method), + BasePropertyDeclarationSyntax property => semanticModel.GetDeclaredSymbol(property), + BaseTypeDeclarationSyntax type => semanticModel.GetDeclaredSymbol(type), + DelegateDeclarationSyntax @delegate => semanticModel.GetDeclaredSymbol(@delegate), + _ => null, + }; + + private static bool HasExceptionFactoryParameter(MemberDeclarationSyntax member) => + member is BaseMethodDeclarationSyntax method && + method.ParameterList.Parameters.Any(parameter => parameter.Identifier.ValueText == "exceptionFactory"); + + private static IReadOnlyList CreateMetadataReferences() + { + var referencesByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string trustedPlatformAssemblies) + { + foreach (var assemblyPath in trustedPlatformAssemblies.Split(Path.PathSeparator)) + { + AddReference(referencesByPath, assemblyPath); + } + } + + AddReference(referencesByPath, typeof(object).Assembly.Location); + AddReference(referencesByPath, typeof(Enumerable).Assembly.Location); + AddReference(referencesByPath, typeof(ImmutableArray).Assembly.Location); + + return referencesByPath.Values.ToArray(); + } + + private static void AddReference( + Dictionary referencesByPath, + string assemblyPath + ) + { + if (string.IsNullOrWhiteSpace(assemblyPath) || + !File.Exists(assemblyPath) || + referencesByPath.ContainsKey(assemblyPath)) + { + return; + } + + referencesByPath.Add(assemblyPath, MetadataReference.CreateFromFile(assemblyPath)); + } + } + + private sealed record SourceFile(FileInfo File, SyntaxTree SyntaxTree, CompilationUnitSyntax Root) + { + public static SourceFile Parse(FileInfo file) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + file.ReadContent(), + NetStandardParseOptions, + file.FullName + ); + return new (file, syntaxTree, (CompilationUnitSyntax) syntaxTree.GetRoot()); + } + } + + private sealed record SourceDeclaration( + FileInfo File, + MemberDeclarationSyntax Syntax, + SourceDeclarationKind Kind, + string Name, + bool HasExceptionFactoryParameter + ) + { + public SourceDeclarationKey Key => SourceDeclarationKey.From(File, Syntax); + + public bool IsCheckOrThrowMember => + Kind is SourceDeclarationKind.CheckMember or SourceDeclarationKind.ThrowMember; + } + + private enum SourceDeclarationKind + { + CheckMember, + ThrowMember, + TopLevelType, + } +} + +internal sealed class SourceReachabilityAnalysis +{ + private readonly HashSet _reachableCheckAndThrowMembers; + private readonly HashSet _reachableTopLevelDeclarations; + + public SourceReachabilityAnalysis( + HashSet reachableCheckAndThrowMembers, + HashSet reachableTopLevelDeclarations + ) + { + _reachableCheckAndThrowMembers = reachableCheckAndThrowMembers; + _reachableTopLevelDeclarations = reachableTopLevelDeclarations; + } + + public bool ShouldIncludeCheckOrThrowMember(FileInfo file, MemberDeclarationSyntax member) => + _reachableCheckAndThrowMembers.Contains(SourceDeclarationKey.From(file, member)); + + public bool ShouldIncludeTopLevelDeclaration(FileInfo file, MemberDeclarationSyntax member) => + _reachableTopLevelDeclarations.Contains(SourceDeclarationKey.From(file, member)); +} + +internal readonly record struct SourceDeclarationKey(string FilePath, int SpanStart) +{ + public static SourceDeclarationKey From(FileInfo file, SyntaxNode syntax) => + new (file.FullName, syntax.SpanStart); +} diff --git a/Code/Light.GuardClauses.SourceCodeTransformation/settings.json b/Code/Light.GuardClauses.SourceCodeTransformation/settings.json new file mode 100644 index 00000000..4b3cca84 --- /dev/null +++ b/Code/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -0,0 +1,121 @@ +{ + "SourceFolder": "", + "TargetFile": "", + "ChangePublicTypesToInternalTypes": true, + "BaseNamespace": "Light.GuardClauses", + "RemoveContractAnnotations": false, + "IncludeJetBrainsAnnotations": true, + "IncludeJetBrainsAnnotationsUsing": true, + "IncludeVersionComment": true, + "RemoveOverloadsWithExceptionFactory": false, + "IncludeCodeAnalysisNullableAttributes": true, + "IncludeValidatedNotNullAttribute": true, + "RemoveValidatedNotNull": false, + "RemoveDoesNotReturn": false, + "RemoveNotNullWhen": false, + "IncludeCallerArgumentExpressionAttribute": true, + "RemoveCallerArgumentExpressions": false, + "AssertionWhitelist": { + "IsEnabled": false, + "DerivesFrom": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "Equals": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "Implements": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "InheritsFrom": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "InvalidArgument": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "InvalidOperation": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "InvalidState": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsDigit": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsEmailAddress": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsEmpty": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsEmptyOrWhiteSpace": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsEquivalentTypeTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsFileExtension": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsGreaterThanOrApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsIn": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsLessThanOrApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsLetter": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsLetterOrDigit": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsNewLine": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsNotIn": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsNullOrEmpty": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsNullOrWhiteSpace": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsOneOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsOpenConstructedGenericType": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsOrDerivesFrom": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsOrImplements": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsOrInheritsFrom": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsSameAs": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsSubstringOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsTrimmed": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsTrimmedAtEnd": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsTrimmedAtStart": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsValidEnumValue": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "IsWhiteSpace": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBe": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeAbsoluteUri": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeEmailAddress": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeFileExtension": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeGreaterThan": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeGreaterThanOrApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeGreaterThanOrEqualTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeHttpOrHttpsUrl": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeHttpUrl": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeHttpsUrl": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeIn": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeLessThan": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeLessThanOrApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeLessThanOrEqualTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeLocal": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeLongerThan": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeLongerThanOrEqualTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeNewLine": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeOfType": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeOneOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeRelativeUri": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeShorterThan": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeShorterThanOrEqualTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeSubstringOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeTrimmed": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeTrimmedAtEnd": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeTrimmedAtStart": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeUnspecified": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeUtc": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeValidEnumValue": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustContain": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustEndWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveCount": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveLength": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveLengthIn": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveMaximumCount": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveMaximumLength": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveMinimumCount": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveMinimumLength": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveOneSchemeOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveScheme": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveValue": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustMatch": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBe": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeDefault": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeDefaultOrEmpty": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeEmpty": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeGreaterThan": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeGreaterThanOrEqualTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeIn": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeLessThan": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeLessThanOrEqualTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeNull": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeNullOrEmpty": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeNullOrWhiteSpace": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeNullReference": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeOneOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeSameAs": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotBeSubstringOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotContain": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotEndWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotStartWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustStartWith": { "Include": true, "IncludeExceptionFactoryOverload": true } + } +} diff --git a/Code/ai-plans/0061-whitelist-for-source-transformation.md b/Code/ai-plans/0061-whitelist-for-source-transformation.md new file mode 100644 index 00000000..7f410f66 --- /dev/null +++ b/Code/ai-plans/0061-whitelist-for-source-transformation.md @@ -0,0 +1,43 @@ +# Issue 61 - Whitelist for Source Transformation + +## Rationale + +`Light.GuardClauses.SourceCodeTransformation` merges the library sources into a single file for consumers who want to embed Light.GuardClauses as source code instead of referencing the DLL. Today, the export always includes the complete source-export surface. Some consumers only need a small subset of assertions, so we introduce an opt-in assertion whitelist on `SourceFileMergeOptions`. + +When the whitelist is enabled, the export is assertion-only: selected `Check.*` methods are the roots, dependency declarations are retained, and unrelated public helper APIs may be omitted. When the whitelist is disabled, merge output remains unchanged from today. + +File-name trimming is not sufficient: only 14 of the 100 current assertions have a same-named `Throw..cs` file, and several `Throw.*.cs` files contain methods for unrelated assertions. A Roslyn semantic reachability analysis, targeting the `netstandard2.0` source-export shape used by `Light.GuardClauses.Source`, should compute the minimal safe dependency closure. The implementation must prefer conservative over-inclusion over broken output, with a post-generation project build as a safety net. + +## Acceptance Criteria + +- [x] `SourceFileMergeOptions` gets exactly one new top-level property: `public AssertionWhitelist AssertionWhitelist { get; init; } = new();`. +- [x] A new `AssertionWhitelist` record exposes `bool IsEnabled { get; init; } = false;` plus one `AssertionEntry` property per assertion, named after the `` portion of its `Check..cs` file. `AssertionEntry` exposes `bool Include { get; init; } = true;` and `bool IncludeExceptionFactoryOverload { get; init; } = true;`. +- [x] When `AssertionWhitelist.IsEnabled` is `false`, all whitelist entries are ignored: no assertion filtering runs, per-assertion overload flags do not affect output, and the generated file remains equivalent to today's output for the same existing options. +- [x] When `AssertionWhitelist.IsEnabled` is `true`, the generated file is assertion-only: selected active `Check` methods are roots, required source dependencies are retained, unrelated public helper APIs may be omitted, and analysis uses the `netstandard2.0` source shape without `NET8_0` / `NET8_0_OR_GREATER` code. +- [x] Reachability includes symbols referenced from signatures, bodies, initializers, inheritance, attributes, generic constraints, delegates, nested types, and expressions. If a source dependency cannot be resolved precisely, the implementation keeps the relevant containing declaration conservatively. +- [x] `Check` and `Throw` are pruned by member where possible; support declarations may be retained at whole-type granularity when that is the smallest safe unit. Cross-assertion dependencies are pulled in automatically, and bundled `Throw.*.cs` files are trimmed by reachable member rather than by file name. +- [x] The existing global `RemoveOverloadsWithExceptionFactory` behavior remains unchanged. In whitelist mode, an assertion entry with `IncludeExceptionFactoryOverload == false` removes only that assertion's `Check` overloads with an `exceptionFactory` parameter before reachability runs; shared `Throw.CustomException` members and span delegate types are retained only if still reachable. +- [x] `Check.cs` / `Throw.cs` partial class shells and attribute files remain governed by their existing options. +- [x] After writing the target file, the tool builds the `.csproj` found in the same directory as `options.TargetFile`; if none is found, validation is skipped. If multiple project files are found, the tool prints a warning and skips validation. On build failure, the tool prints captured output, returns a non-zero code, and leaves the generated file on disk. +- [x] `settings.json` is committed for `Light.GuardClauses.SourceCodeTransformation` with whitelist mode enabled and every assertion entry set to include both normal and exception-factory overloads, producing the assertion-only export for all assertions. `settings.local.json` is ignored, loaded after `settings.json` and before command-line arguments, and both settings files are copied to the output directory when present. +- [x] Automated tests need to be written. + +## Technical Details + +- **Options and config**: add `AssertionWhitelist.cs` with a sealed `AssertionWhitelist` record and nested `AssertionEntry` record. Use reflection to map assertion names to whitelist properties so assertion names are not maintained twice. Per-entry flags are consulted only when `IsEnabled` is `true`. Update `.gitignore`, `Program.cs`, and the source transformation project file for `settings.json` / `settings.local.json` layering and copy-to-output behavior. + +- **Source catalog and compilation**: parse every source file with `CSharpSyntaxTree.ParseText(text, parseOptions, path: file.FullName)` using `CSharpParseOptions(LanguageVersion.CSharp12, preprocessorSymbols: ["NETSTANDARD", "NETSTANDARD2_0"])`; do not define `NET8_0` or `NET8_0_OR_GREATER`. Build a catalog of source declarations and a `CSharpCompilation` with trusted platform assemblies plus required package references such as `System.Collections.Immutable`. This compilation supports reachability only; the generated project build is the source of truth for final preprocessor and compile correctness. + +- **Root selection**: in whitelist mode, roots are active `Check` methods from assertion files whose entries have `Include == true`. Remove an assertion's `Check` methods with an `exceptionFactory` parameter before root selection when that entry has `IncludeExceptionFactoryOverload == false`; also apply the existing global `RemoveOverloadsWithExceptionFactory` option when set. + +- **Reachability traversal**: resolve source symbols with Roslyn and compare them via `SymbolEqualityComparer.Default`, normalizing with `OriginalDefinition` where appropriate. Inspect declaration shape and executable code, including type syntax, attributes, base lists, constraints, parameters, return types, field/property initializers, accessors, constructor initializers, operators, expression-bodied members, invocations, object creations, identifiers, and member access. Treat candidate source symbols as reachable when binding is ambiguous. + +- **Retention policy**: retain `Check` and `Throw` by reachable member where possible. For exceptions, delegates, enums, structs, nested types, comparers, `Range`, `EnumInfo`, `RegularExpressions`, and framework extension helper types, prefer correctness over perfect minimality; retaining the whole containing type is acceptable once any part is reachable. + +- **Merger integration**: refactor `SourceFileMerger` enough to apply reachability before inserting members into the final namespaces. In whitelist mode, do not add root-namespace helpers, exception types, framework extension types, or `Throw` members just because their files exist. In non-whitelist mode, preserve the current merge behavior. + +- **Build validation**: add `GeneratedFileBuildValidator` and call it from `Program.Main` after `SourceFileMerger.CreateSingleSourceFile` writes `options.TargetFile`. It should run `dotnet build` with captured stdout / stderr when exactly one project file exists next to the target file, skip when none exists, warn and skip when multiple exist, and report build failure through `Program.Main`'s exit code. + +- **Tests**: add tests in `Light.GuardClauses.SourceCodeTransformation.Tests` using real files from `Light.GuardClauses`. Cover disabled-mode output equivalence and ignored per-entry flags; assertion-only trimming of unrelated public helpers; cross-assertion reachability (`MustBeGreaterThan` retains `MustNotBeNullReference`); bundled `Throw` pruning (`MustBeAbsoluteUri` omits unrelated URI throw members); exception inheritance (`RelativeUriException` retains `UriException`); support closures for `Range`, `EnumInfo` / `Types`, `RegularExpressions`, and span delegate dependencies under `netstandard2.0`; per-assertion exception-factory trimming; and build validation success, failure, non-whitelist execution, and skip behavior. + +- No benchmarks needed. This feature runs once per source export, not in a hot path. diff --git a/Code/Plans/issue-114-must-not-be-default-or-empty-for-immutable-array.md b/Code/ai-plans/0114-must-not-be-default-or-empty-for-immutable-array.md similarity index 100% rename from Code/Plans/issue-114-must-not-be-default-or-empty-for-immutable-array.md rename to Code/ai-plans/0114-must-not-be-default-or-empty-for-immutable-array.md diff --git a/Code/Plans/issue-116-must-not-contain-for-immutable-array.md b/Code/ai-plans/0116-must-not-contain-for-immutable-array.md similarity index 100% rename from Code/Plans/issue-116-must-not-contain-for-immutable-array.md rename to Code/ai-plans/0116-must-not-contain-for-immutable-array.md diff --git a/Code/Plans/issue-117-must-have-length-for-immutable-array.md b/Code/ai-plans/0117-must-have-length-for-immutable-array.md similarity index 100% rename from Code/Plans/issue-117-must-have-length-for-immutable-array.md rename to Code/ai-plans/0117-must-have-length-for-immutable-array.md diff --git a/Code/Plans/issue-118-must-have-length-in-for-immutable-array.md b/Code/ai-plans/0118-must-have-length-in-for-immutable-array.md similarity index 100% rename from Code/Plans/issue-118-must-have-length-in-for-immutable-array.md rename to Code/ai-plans/0118-must-have-length-in-for-immutable-array.md diff --git a/Code/Plans/issue-119-must-contain-for-immutable-array.md b/Code/ai-plans/0119-must-contain-for-immutable-array.md similarity index 100% rename from Code/Plans/issue-119-must-contain-for-immutable-array.md rename to Code/ai-plans/0119-must-contain-for-immutable-array.md diff --git a/Code/Plans/issue-120-must-have-maximum-length-for-immutable-array.md b/Code/ai-plans/0120-must-have-maximum-length-for-immutable-array.md similarity index 100% rename from Code/Plans/issue-120-must-have-maximum-length-for-immutable-array.md rename to Code/ai-plans/0120-must-have-maximum-length-for-immutable-array.md diff --git a/Code/Plans/issue-121-must-have-minimum-length-for-immutable-array.md b/Code/ai-plans/0121-must-have-minimum-length-for-immutable-array.md similarity index 100% rename from Code/Plans/issue-121-must-have-minimum-length-for-immutable-array.md rename to Code/ai-plans/0121-must-have-minimum-length-for-immutable-array.md diff --git a/Code/ai-plans/AGENTS.md b/Code/ai-plans/AGENTS.md new file mode 100644 index 00000000..f35c99f1 --- /dev/null +++ b/Code/ai-plans/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS.md for AI Plans + +In this folder, we only keep Markdown files for AI plans. Each file has a four-digit prefix which corresponds to the GitHub issue number. + +## How to Write Plans + +1. Each plan consists of exactly the three following elements: a rationale, acceptance criteria, and technical details. +2. A rationale describes the overarching goal of the plan. Keep this concise, only elaborate when special circumstances require it. Use free text in this section of the plan. +3. Acceptance criteria is a list of requirements that must be met for the plan to be considered complete. Use check marks `- [ ]` for these in Markdown. +4. Technical details describes the changes on a technical level. Which classes and members should be extended, which design patterns should be used? How do we ensure performance? Avoid going into too much detail (do not write the finished implementation), but focus on the high-level design and how parts of the codebase are affected. It should give the implementer a clear picture of what needs to be done. +5. Regarding automated tests: it is usually enough to note that 'automated tests need to be written' as one check mark in the acceptance criteria list. If there are special requirements for the tests, these can be elaborated in the technical details section. But normally, you can simply leave these technical details out if the automated tests can be written in a straightforward way. +6. When a plan should involve micro benchmarks, these need to be included in the acceptance criteria list and optionally in the technical details section. As with automated tests, if the BenchmarkDotNet benchmarks can be written in a straightforward way, leave out the technical details. +7. In general, keep the plan concise and to the point. Do not include general information that the implementer already knows.