Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
File renamed without changes.
4 changes: 2 additions & 2 deletions Sample/Sample.cs → Invoker.Demo/Demo.cs
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 5 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add a 'private' constructor or the 'static' keyword to the class declaration.

Check warning on line 5 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add a 'private' constructor or the 'static' keyword to the class declaration.

Check warning on line 5 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a 'private' constructor or the 'static' keyword to the class declaration.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIS&open=AaDDINgeaJrdmu2XNlIS&pullRequest=48
{
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "<Pending>")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "<Pending>")]
Expand All @@ -11,10 +11,10 @@
ContextDependent contextDependent = new();

// Invoking from the owning thread will execute the delegate immediately, bypassing the queue
contextDependent.Invoker.Invoke(ContextDependent.DoWork);

Check warning on line 14 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await InvokeAsync instead.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIX&open=AaDDINgeaJrdmu2XNlIX&pullRequest=48

// Invoking from a different thread will queue the delegate to be executed by the owning thread
Task queuedTask = Task.Run(() => contextDependent.Invoker.Invoke(ContextDependent.DoWork));

Check warning on line 17 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused local variable 'queuedTask'.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIZ&open=AaDDINgeaJrdmu2XNlIZ&pullRequest=48

// Call DoInvokes() on the owning thread to consume and execute queued tasks
contextDependent.Invoker.DoInvokes();
Expand All @@ -24,14 +24,14 @@
// Attempting to consume queued tasks from a different thread will throw an InvalidOperationException
await Task.Run(contextDependent.Invoker.DoInvokes);
}
catch (InvalidOperationException) { }

Check warning on line 27 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Either remove or fill this block of code.

Check warning on line 27 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Either remove or fill this block of code.

Check warning on line 27 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either remove or fill this block of code.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIW&open=AaDDINgeaJrdmu2XNlIW&pullRequest=48

// Invoking a delegate with a return value
int result = contextDependent.Invoker.Invoke(ContextDependent.DoWorkAndReturn);

Check warning on line 30 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this useless assignment to local variable 'result'.

Check warning on line 30 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this useless assignment to local variable 'result'.

Check warning on line 30 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await InvokeAsync instead.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIY&open=AaDDINgeaJrdmu2XNlIY&pullRequest=48

Check warning on line 30 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to local variable 'result'.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIV&open=AaDDINgeaJrdmu2XNlIV&pullRequest=48

// Async overloads are also available
await contextDependent.Invoker.InvokeAsync(ContextDependent.DoWork);
result = await contextDependent.Invoker.InvokeAsync(ContextDependent.DoWorkAndReturn);

Check warning on line 34 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this useless assignment to local variable 'result'.

Check warning on line 34 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this useless assignment to local variable 'result'.

Check warning on line 34 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to local variable 'result'.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIU&open=AaDDINgeaJrdmu2XNlIU&pullRequest=48
}
}

Expand All @@ -41,5 +41,5 @@

internal static void DoWork() => Console.WriteLine("Hello, world!");

internal static int DoWorkAndReturn() => 42;

Check warning on line 44 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 44 in Invoker.Demo/Demo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 44 in Invoker.Demo/Demo.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this method and declare a constant for this value.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINgeaJrdmu2XNlIT&open=AaDDINgeaJrdmu2XNlIT&pullRequest=48
}
6 changes: 6 additions & 0 deletions Sample/Sample.csproj → Invoker.Demo/Invoker.Demo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
<Sdk Name="ktsu.Sdk" />
<Sdk Name="ktsu.Sdk.ConsoleApp" />

<PropertyGroup>
<!-- A demonstration project, not a deliverable. ktsu.Sdk already keeps an OutputType=Exe
project out of the pack, but saying so here does not rely on that staying true. -->
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Polyfill" PrivateAssets="all" />
</ItemGroup>
Expand Down
122 changes: 122 additions & 0 deletions Invoker.Test/ProjectNamingTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[TestClass]
public partial class ProjectNamingTests
{
private const string Family = "Invoker";
private const string SolutionFileName = "Invoker.sln";

/// <summary>
/// Matches a solution project entry, capturing its display name and its project path.
/// </summary>
[GeneratedRegex("""^Project\("\{[^}]+\}"\) = "(?<name>[^"]+)", "(?<path>[^"]+\.csproj)""", RegexOptions.Multiline | RegexOptions.CultureInvariant)]
private static partial Regex SolutionProjectEntry();

[TestMethod]
public void EveryProjectFolderCarriesTheFamilyName()
{
DirectoryInfo root = FindSolutionRoot();
List<FileInfo> projects = [.. EnumerateProjects(root)];

Assert.IsTrue(projects.Count > 0, $"No projects found under {root.FullName}; the test is not looking where it thinks it is.");

Check warning on line 33 in Invoker.Test/ProjectNamingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsNotEmpty' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINlzaJrdmu2XNlIa&open=AaDDINlzaJrdmu2XNlIa&pullRequest=48

List<string> 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}.<Something>.");

Check warning on line 43 in Invoker.Test/ProjectNamingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINlzaJrdmu2XNlIb&open=AaDDINlzaJrdmu2XNlIb&pullRequest=48
}

[TestMethod]
public void EveryProjectFileMatchesItsFolderName()
{
DirectoryInfo root = FindSolutionRoot();

List<string> 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)}.");

Check warning on line 58 in Invoker.Test/ProjectNamingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINlzaJrdmu2XNlIc&open=AaDDINlzaJrdmu2XNlIc&pullRequest=48
}

[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.");

Check warning on line 68 in Invoker.Test/ProjectNamingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsNotEmpty' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINlzaJrdmu2XNlId&open=AaDDINlzaJrdmu2XNlId&pullRequest=48

List<string> 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)}.");

Check warning on line 96 in Invoker.Test/ProjectNamingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Invoker&issues=AaDDINlzaJrdmu2XNlIe&open=AaDDINlzaJrdmu2XNlIe&pullRequest=48
}

private static bool IsFamilyName(string identity) =>
identity == Family || identity.StartsWith($"{Family}.", StringComparison.Ordinal);

/// <summary>
/// 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.
/// </summary>
private static IEnumerable<FileInfo> 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;
}
}
2 changes: 1 addition & 1 deletion Invoker.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading