diff --git a/CLAUDE.md b/CLAUDE.md index 1c917e3..c9e3048 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,10 @@ The library consists of a single class `Invoker` in `Invoker/Invoker.cs`: - **Invoker/** - Main library (multi-targets via ktsu.Sdk) - **Invoker.Test/** - MSTest unit tests (targets net10.0 only) -- **Sample/** - Console app demonstrating usage +- **Invoker.Demo/** - Console app demonstrating usage (not packable) ## SDK Configuration Projects use `ktsu.Sdk` which provides centralized build configuration. Package versions are managed centrally in `Directory.Packages.props`. + +`ktsu.Sdk` derives each project's assembly name and package ID from its solution-relative folder path, so a project folder is an identity claim: `Invoker.Demo/` produces `ktsu.Invoker.Demo`, while a folder without the family prefix would claim a name this repository does not own. `ProjectNamingTests` fails the build if a project folder, its `.csproj` filename, or its solution entry stops agreeing with that convention. diff --git a/Sample/AssemblyInfo.cs b/Invoker.Demo/AssemblyInfo.cs similarity index 100% rename from Sample/AssemblyInfo.cs rename to Invoker.Demo/AssemblyInfo.cs diff --git a/Sample/Sample.cs b/Invoker.Demo/Demo.cs similarity index 96% rename from Sample/Sample.cs rename to Invoker.Demo/Demo.cs index c0815df..c0c0e1c 100644 --- a/Sample/Sample.cs +++ b/Invoker.Demo/Demo.cs @@ -1,8 +1,8 @@ // Copyright (c) 2023-2026 ktsu-dev contributors -namespace ktsu.Invoker.Sample; +namespace ktsu.Invoker.Demo; -internal sealed class Sample +internal sealed class Demo { [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "")] diff --git a/Sample/Sample.csproj b/Invoker.Demo/Invoker.Demo.csproj similarity index 53% rename from Sample/Sample.csproj rename to Invoker.Demo/Invoker.Demo.csproj index 4be78c7..5b3bd2e 100644 --- a/Sample/Sample.csproj +++ b/Invoker.Demo/Invoker.Demo.csproj @@ -3,6 +3,12 @@ + + + false + + diff --git a/Invoker.Test/ProjectNamingTests.cs b/Invoker.Test/ProjectNamingTests.cs new file mode 100644 index 0000000..4cabdd9 --- /dev/null +++ b/Invoker.Test/ProjectNamingTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Invoker.Test; + +using System.IO; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Guards the identity every project derives from its solution-relative folder path. +/// ktsu.Sdk builds the assembly name and package ID from that path, so a project folder without the +/// repository's family name claims a name that belongs to no repository in particular - which is how +/// the demo project came to build as ktsu.Sample. +/// +[TestClass] +public partial class ProjectNamingTests +{ + private const string Family = "Invoker"; + private const string SolutionFileName = "Invoker.sln"; + + /// + /// Matches a solution project entry, capturing its display name and its project path. + /// + [GeneratedRegex("""^Project\("\{[^}]+\}"\) = "(?[^"]+)", "(?[^"]+\.csproj)""", RegexOptions.Multiline | RegexOptions.CultureInvariant)] + private static partial Regex SolutionProjectEntry(); + + [TestMethod] + public void EveryProjectFolderCarriesTheFamilyName() + { + DirectoryInfo root = FindSolutionRoot(); + List projects = [.. EnumerateProjects(root)]; + + Assert.IsTrue(projects.Count > 0, $"No projects found under {root.FullName}; the test is not looking where it thinks it is."); + + List offenders = [.. projects + .Select(project => Path.GetFileNameWithoutExtension(project.Name)) + .Where(identity => !IsFamilyName(identity))]; + + Assert.AreEqual( + 0, + offenders.Count, + $"These projects derive an identity outside the ktsu.{Family} family: {string.Join(", ", offenders)}. " + + $"A project folder must be named {Family} or {Family}.."); + } + + [TestMethod] + public void EveryProjectFileMatchesItsFolderName() + { + DirectoryInfo root = FindSolutionRoot(); + + List mismatches = [.. EnumerateProjects(root) + .Where(project => Path.GetFileNameWithoutExtension(project.Name) != project.Directory!.Name) + .Select(project => $"{project.Directory!.Name}/{project.Name}")]; + + Assert.AreEqual( + 0, + mismatches.Count, + $"A project file must be named for its folder, because the folder is what decides the derived identity: {string.Join(", ", mismatches)}."); + } + + [TestMethod] + public void EverySolutionEntryMatchesItsProjectFile() + { + DirectoryInfo root = FindSolutionRoot(); + string solution = File.ReadAllText(Path.Join(root.FullName, SolutionFileName)); + MatchCollection entries = SolutionProjectEntry().Matches(solution); + + Assert.IsTrue(entries.Count > 0, $"No project entries found in {SolutionFileName}; the test is not reading what it thinks it is."); + + List offenders = []; + foreach (Match entry in entries) + { + string name = entry.Groups["name"].Value; + string path = entry.Groups["path"].Value; + // A solution always writes Windows separators, which are an ordinary character to + // Path on Linux, where CI also runs. + string localPath = path.Replace('\\', Path.DirectorySeparatorChar); + string identity = Path.GetFileNameWithoutExtension(localPath); + + if (name != identity) + { + offenders.Add($"{name} is listed at {path}"); + } + else if (!IsFamilyName(identity)) + { + offenders.Add($"{name} is outside the ktsu.{Family} family"); + } + // Path.Join, not Path.Combine: the path comes from the solution's own text, and a rooted + // entry would make Combine drop the repository root and test a file outside it. + else if (!File.Exists(Path.Join(root.FullName, localPath))) + { + offenders.Add($"{name} points at {path}, which does not exist"); + } + } + + Assert.AreEqual(0, offenders.Count, $"Stale or misnamed {SolutionFileName} entries: {string.Join("; ", offenders)}."); + } + + private static bool IsFamilyName(string identity) => + identity == Family || identity.StartsWith($"{Family}.", StringComparison.Ordinal); + + /// + /// Projects sit one folder below the solution, so this deliberately does not recurse into bin and + /// obj, where a build leaves copies of project files. + /// + private static IEnumerable EnumerateProjects(DirectoryInfo root) => + root.EnumerateDirectories() + .Where(directory => !directory.Name.StartsWith('.')) + .SelectMany(directory => directory.EnumerateFiles("*.csproj")); + + private static DirectoryInfo FindSolutionRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Join(directory.FullName, SolutionFileName))) + { + directory = directory.Parent; + } + + Assert.IsNotNull(directory, $"Could not find {SolutionFileName} above {AppContext.BaseDirectory}."); + return directory; + } +} diff --git a/Invoker.sln b/Invoker.sln index a92edab..146bed2 100644 --- a/Invoker.sln +++ b/Invoker.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 17.8.34316.72 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Invoker", "Invoker\Invoker.csproj", "{C0C477B1-8C6A-7715-A025-C0E0B48802D1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "Sample\Sample.csproj", "{5A7F8005-C165-6259-8009-1115B9A95056}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Invoker.Demo", "Invoker.Demo\Invoker.Demo.csproj", "{5A7F8005-C165-6259-8009-1115B9A95056}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Invoker.Test", "Invoker.Test\Invoker.Test.csproj", "{97DD3E20-FCE6-19CC-3578-8172883F1E02}" EndProject