diff --git a/Trax.Core.slnx b/Trax.Core.slnx
index 6ee19ef..fa22ffd 100644
--- a/Trax.Core.slnx
+++ b/Trax.Core.slnx
@@ -1,6 +1,7 @@
+
@@ -11,5 +12,6 @@
+
diff --git a/src/Trax.Core.Testing/ArchitectureGuardOptions.cs b/src/Trax.Core.Testing/ArchitectureGuardOptions.cs
new file mode 100644
index 0000000..2de1aea
--- /dev/null
+++ b/src/Trax.Core.Testing/ArchitectureGuardOptions.cs
@@ -0,0 +1,46 @@
+namespace Trax.Core.Testing;
+
+///
+/// Configuration for the architecture-guard checkers. Defaults match the Trax conventions; a consumer
+/// overrides only what differs (scan roots, allowlists, expected versions). Allowlist paths are
+/// repo-relative and use forward slashes.
+///
+public sealed record ArchitectureGuardOptions
+{
+ ///
+ /// Overrides the repository root the guards scan. Defaults to the auto-detected root (walk up to a
+ /// *.slnx). Set this only to point guards at a specific tree (primarily for testing the
+ /// guards themselves against a synthetic fixture directory).
+ ///
+ public string? RepoRootOverride { get; init; }
+
+ /// Top-level folders containing test code (scanned by the test-hygiene guards).
+ public IReadOnlyList TestScanRoots { get; init; } = ["tests"];
+
+ ///
+ /// Top-level folders containing production source (scanned by the data-layer / GraphQL / train
+ /// convention guards). Defaults to src; a consumer overrides (e.g. ["samples", "lib"]).
+ ///
+ public IReadOnlyList SourceScanRoots { get; init; } = ["src"];
+
+ /// Files exempt from the no-[Ignore] guard (each should carry a justification in source).
+ public IReadOnlySet NoIgnoreKnownExceptions { get; init; } =
+ new HashSet(StringComparer.Ordinal);
+
+ /// Files exempt from the no-fixed-delay guard.
+ public IReadOnlySet FixedDelayKnownExceptions { get; init; } =
+ new HashSet(StringComparer.Ordinal);
+
+ /// The exact <Version> the root Directory.Build.props must declare for local dev.
+ public string ExpectedDirectoryBuildPropsVersion { get; init; } = "1.99.99";
+
+ /// Package-name prefix treated as a cross-repo Trax dependency.
+ public string TraxPackagePrefix { get; init; } = "Trax.";
+
+ /// The floating version every cross-repo Trax package reference must use.
+ public string ExpectedTraxPackageVersion { get; init; } = "1.*";
+
+ /// Project files exempt from the cross-repo package-version guard.
+ public IReadOnlySet CrossRepoPackageKnownExceptions { get; init; } =
+ new HashSet(StringComparer.Ordinal);
+}
diff --git a/src/Trax.Core.Testing/Fixtures/HygieneGuardFixtures.cs b/src/Trax.Core.Testing/Fixtures/HygieneGuardFixtures.cs
new file mode 100644
index 0000000..8e57607
--- /dev/null
+++ b/src/Trax.Core.Testing/Fixtures/HygieneGuardFixtures.cs
@@ -0,0 +1,75 @@
+using NUnit.Framework;
+using Trax.Core.Testing.Guards;
+
+// The [Test] method names are the documentation; XML doc comments on them would be pure redundancy.
+#pragma warning disable CS1591
+
+namespace Trax.Core.Testing.Fixtures;
+
+///
+/// Pre-written test-hygiene guards. A consumer subclasses this, overrides if the
+/// defaults do not fit, and runs dotnet test; the inherited [Test] methods are discovered
+/// in the consumer's assembly. No test bodies to write.
+///
+///
+/// Example:
+///
+/// [TestFixture]
+/// public sealed class MyHygieneGuards : HygieneGuardFixture
+/// {
+/// protected override ArchitectureGuardOptions Options => new() { TestScanRoots = ["tests"] };
+/// }
+///
+///
+[TestFixture]
+public abstract class HygieneGuardFixture
+{
+ /// Guard configuration. Defaults to scanning tests/; override to change roots or allowlists.
+ protected virtual ArchitectureGuardOptions Options => new();
+
+ [Test]
+ public void Tests_do_not_use_the_Ignore_attribute()
+ {
+ var result = HygieneGuards.NoIgnoreAttribute(Options);
+ Assert.That(result.Offenders, Is.Empty, result.FailureMessage);
+ }
+
+ [Test]
+ public void Tests_do_not_use_legacy_asserts()
+ {
+ var result = HygieneGuards.NoLegacyAsserts(Options);
+ Assert.That(result.Offenders, Is.Empty, result.FailureMessage);
+ }
+
+ [Test]
+ public void Tests_do_not_use_fixed_delays()
+ {
+ var result = HygieneGuards.NoFixedDelays(Options);
+ Assert.That(result.Offenders, Is.Empty, result.FailureMessage);
+ }
+}
+
+///
+/// Pre-written repo-structure guards (Directory.Build.props version, cross-repo package
+/// versions). Subclass and override as needed.
+///
+[TestFixture]
+public abstract class RepoConventionGuardFixture
+{
+ /// Guard configuration. Override to change the expected versions or package prefix.
+ protected virtual ArchitectureGuardOptions Options => new();
+
+ [Test]
+ public void Directory_build_props_pins_the_expected_version()
+ {
+ var result = RepoConventionGuards.DirectoryBuildPropsVersion(Options);
+ Assert.That(result.Offenders, Is.Empty, result.FailureMessage);
+ }
+
+ [Test]
+ public void Cross_repo_package_references_use_the_floating_version()
+ {
+ var result = RepoConventionGuards.CrossRepoPackageVersions(Options);
+ Assert.That(result.Offenders, Is.Empty, result.FailureMessage);
+ }
+}
diff --git a/src/Trax.Core.Testing/GuardResult.cs b/src/Trax.Core.Testing/GuardResult.cs
new file mode 100644
index 0000000..56acc7e
--- /dev/null
+++ b/src/Trax.Core.Testing/GuardResult.cs
@@ -0,0 +1,23 @@
+namespace Trax.Core.Testing;
+
+///
+/// The outcome of an architecture-guard check. A guard returns the offenders it found, how many items
+/// it inspected (so a misconfigured scan can't silently pass), and a ready-to-use failure message.
+///
+/// Repo-relative offender descriptions (often path:line (reason)).
+/// How many candidate items the guard examined.
+/// A message explaining the rule and how to fix a violation, with the offender list appended.
+///
+/// Consumers assert on this with their own test framework, e.g.
+/// result.Offenders.Should().BeEmpty(result.FailureMessage) and, where a guard must find work,
+/// result.Inspected.Should().BeGreaterThan(0).
+///
+public sealed record GuardResult(
+ IReadOnlyList Offenders,
+ int Inspected,
+ string FailureMessage
+)
+{
+ /// True when no offenders were found.
+ public bool Passed => Offenders.Count == 0;
+}
diff --git a/src/Trax.Core.Testing/Guards/HygieneGuards.cs b/src/Trax.Core.Testing/Guards/HygieneGuards.cs
new file mode 100644
index 0000000..6f3630e
--- /dev/null
+++ b/src/Trax.Core.Testing/Guards/HygieneGuards.cs
@@ -0,0 +1,171 @@
+using System.Text.RegularExpressions;
+using Trax.Core.Testing.Infrastructure;
+
+namespace Trax.Core.Testing.Guards;
+
+///
+/// Test-hygiene guard checkers. Each scans the configured test roots and returns a
+/// ; the consumer asserts Offenders is empty with its own framework.
+///
+public static class HygieneGuards
+{
+ // Matches an [Ignore] attribute whether standalone ([Ignore] / [Ignore("...")]) or combined with
+ // others ([Test, Ignore(...)]), i.e. preceded by an open bracket or a comma.
+ private static readonly Regex IgnoreAttribute = new(
+ @"(?:\[|,)\s*Ignore(\s*\(|\s*\])",
+ RegexOptions.Compiled
+ );
+
+ private static readonly (string Name, Regex Pattern)[] LegacyAssertPatterns =
+ [
+ ("Assert.That", new Regex(@"\bAssert\.That\b", RegexOptions.Compiled)),
+ ("Assert.AreEqual", new Regex(@"\bAssert\.AreEqual\b", RegexOptions.Compiled)),
+ ("Assert.AreNotEqual", new Regex(@"\bAssert\.AreNotEqual\b", RegexOptions.Compiled)),
+ ("Assert.AreSame", new Regex(@"\bAssert\.AreSame\b", RegexOptions.Compiled)),
+ ("Assert.AreNotSame", new Regex(@"\bAssert\.AreNotSame\b", RegexOptions.Compiled)),
+ ("Assert.IsTrue", new Regex(@"\bAssert\.IsTrue\b", RegexOptions.Compiled)),
+ ("Assert.IsFalse", new Regex(@"\bAssert\.IsFalse\b", RegexOptions.Compiled)),
+ ("Assert.IsNull", new Regex(@"\bAssert\.IsNull\b", RegexOptions.Compiled)),
+ ("Assert.IsNotNull", new Regex(@"\bAssert\.IsNotNull\b", RegexOptions.Compiled)),
+ ("Assert.IsEmpty", new Regex(@"\bAssert\.IsEmpty\b", RegexOptions.Compiled)),
+ ("Assert.IsNotEmpty", new Regex(@"\bAssert\.IsNotEmpty\b", RegexOptions.Compiled)),
+ ("Assert.Contains", new Regex(@"\bAssert\.Contains\b", RegexOptions.Compiled)),
+ ];
+
+ private static readonly Regex FixedDelay = new(
+ @"\b(Task\.Delay|Thread\.Sleep)\s*\(",
+ RegexOptions.Compiled
+ );
+
+ private static readonly string[] DelayJustifications =
+ [
+ "determinism:",
+ "allowed-delay:",
+ "measuring-interval:",
+ "negative-wait:",
+ ];
+
+ /// Flags [Ignore] attributes in test sources (they silently hide failures).
+ public static GuardResult NoIgnoreAttribute(ArchitectureGuardOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var root = options.RepoRootOverride ?? RepoRoot.Path;
+ var offenders = new List();
+ var inspected = 0;
+
+ foreach (var file in SourceFiles.CSharpUnder(root, [.. options.TestScanRoots]))
+ {
+ inspected++;
+ var rel = Rel(root, file);
+ if (options.NoIgnoreKnownExceptions.Contains(rel))
+ continue;
+
+ var stripped = SourceText.StripCommentsAndStrings(File.ReadAllText(file));
+ foreach (var (line, _) in SourceText.MatchingLines(stripped, IgnoreAttribute))
+ offenders.Add($"{rel}:{line}");
+ }
+
+ var message =
+ "[Ignore] silently hides failing tests. Fix the underlying code or the test premise, or "
+ + "use Assert.Ignore(\"reason\") at runtime with a reachability check. If a file must be "
+ + "opt-in via [Ignore] (e.g. a placeholder gated on an upstream feature), add it to "
+ + "NoIgnoreKnownExceptions with a justification. Offenders:\n "
+ + string.Join("\n ", offenders);
+
+ return new GuardResult(offenders, inspected, message);
+ }
+
+ /// Flags classic NUnit asserts in test sources (the convention is one assertion library, exclusively).
+ public static GuardResult NoLegacyAsserts(ArchitectureGuardOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var root = options.RepoRootOverride ?? RepoRoot.Path;
+ var offenders = new List();
+ var inspected = 0;
+
+ foreach (var file in SourceFiles.CSharpUnder(root, [.. options.TestScanRoots]))
+ {
+ inspected++;
+ var rel = Rel(root, file);
+ var stripped = SourceText.StripCommentsAndStrings(File.ReadAllText(file));
+
+ foreach (var (name, pattern) in LegacyAssertPatterns)
+ {
+ foreach (var (line, _) in SourceText.MatchingLines(stripped, pattern))
+ offenders.Add($"{rel}:{line} ({name})");
+ }
+ }
+
+ var message =
+ "Use the project's chosen assertion library exclusively. Replace classic NUnit asserts "
+ + "with the fluent equivalents. Assert.Pass / Assert.Fail / Assert.Ignore remain "
+ + "acceptable. Offenders:\n "
+ + string.Join("\n ", offenders);
+
+ return new GuardResult(offenders, inspected, message);
+ }
+
+ ///
+ /// Flags fixed-duration Task.Delay / Thread.Sleep in test sources unless the line
+ /// (or up to three lines above) carries a justification marker, or the file is allowlisted.
+ ///
+ public static GuardResult NoFixedDelays(ArchitectureGuardOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var root = options.RepoRootOverride ?? RepoRoot.Path;
+ var offenders = new List();
+ var inspected = 0;
+
+ foreach (var file in SourceFiles.CSharpUnder(root, [.. options.TestScanRoots]))
+ {
+ inspected++;
+ var rel = Rel(root, file);
+ if (options.FixedDelayKnownExceptions.Contains(rel))
+ continue;
+
+ var raw = File.ReadAllText(file).Replace("\r\n", "\n").Split('\n');
+ var stripped = SourceText
+ .StripCommentsAndStrings(File.ReadAllText(file))
+ .Replace("\r\n", "\n")
+ .Split('\n');
+
+ for (var i = 0; i < stripped.Length && i < raw.Length; i++)
+ {
+ if (!FixedDelay.IsMatch(stripped[i]))
+ continue;
+ if (HasJustification(raw, i))
+ continue;
+ offenders.Add($"{rel}:{i + 1} -> {raw[i].Trim()}");
+ }
+ }
+
+ var message =
+ "Fixed-duration Task.Delay / Thread.Sleep make tests flaky. Synchronise on the completion "
+ + "signal (TaskCompletionSource, polling) with a generous timeout. If a fixed delay is "
+ + "genuinely required, add a same-line or up-to-3-lines-above comment containing one of: "
+ + string.Join(", ", DelayJustifications)
+ + ". Offenders:\n "
+ + string.Join("\n ", offenders);
+
+ return new GuardResult(offenders, inspected, message);
+ }
+
+ private static string Rel(string root, string file) =>
+ Path.GetRelativePath(root, file).Replace('\\', '/');
+
+ private static bool HasJustification(string[] rawLines, int delayLineIndex)
+ {
+ var start = Math.Max(0, delayLineIndex - 3);
+ for (var j = start; j <= delayLineIndex && j < rawLines.Length; j++)
+ {
+ var lower = rawLines[j].ToLowerInvariant();
+ foreach (var marker in DelayJustifications)
+ {
+ if (lower.Contains(marker, StringComparison.Ordinal))
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Trax.Core.Testing/Guards/RepoConventionGuards.cs b/src/Trax.Core.Testing/Guards/RepoConventionGuards.cs
new file mode 100644
index 0000000..bdc5ef3
--- /dev/null
+++ b/src/Trax.Core.Testing/Guards/RepoConventionGuards.cs
@@ -0,0 +1,104 @@
+using System.Xml.Linq;
+using Trax.Core.Testing.Infrastructure;
+
+namespace Trax.Core.Testing.Guards;
+
+///
+/// Repo-structure / packaging guard checkers (read .csproj and Directory.Build.props).
+///
+public static class RepoConventionGuards
+{
+ ///
+ /// Asserts the root Directory.Build.props pins <Version> to the expected
+ /// local-dev value, so locally-packed feed packages always win over nuget.org.
+ ///
+ public static GuardResult DirectoryBuildPropsVersion(ArchitectureGuardOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var root = options.RepoRootOverride ?? RepoRoot.Path;
+ var path = Path.Combine(root, "Directory.Build.props");
+ var offenders = new List();
+
+ if (!File.Exists(path))
+ {
+ offenders.Add("Directory.Build.props (missing at repo root)");
+ }
+ else
+ {
+ var version = XDocument
+ .Load(path)
+ .Descendants("Version")
+ .FirstOrDefault()
+ ?.Value.Trim();
+
+ if (version != options.ExpectedDirectoryBuildPropsVersion)
+ offenders.Add(
+ $"Directory.Build.props is '{version ?? ""}', expected "
+ + $"'{options.ExpectedDirectoryBuildPropsVersion}'"
+ );
+ }
+
+ var message =
+ $"The root Directory.Build.props must be '{options.ExpectedDirectoryBuildPropsVersion}' "
+ + "for local development; CI overrides it via -p:Version=. Changing it breaks the "
+ + "local-feed-wins-over-nuget.org guarantee. Offenders:\n "
+ + string.Join("\n ", offenders);
+
+ return new GuardResult(offenders, 1, message);
+ }
+
+ ///
+ /// Asserts every cross-repo Trax package reference uses the expected floating version, so local
+ /// feed builds resolve consistently.
+ ///
+ public static GuardResult CrossRepoPackageVersions(ArchitectureGuardOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var root = options.RepoRootOverride ?? RepoRoot.Path;
+ var offenders = new List();
+ var inspected = 0;
+
+ foreach (var csproj in SourceFiles.ProjectsUnder(root))
+ {
+ inspected++;
+ var rel = Path.GetRelativePath(root, csproj).Replace('\\', '/');
+ if (options.CrossRepoPackageKnownExceptions.Contains(rel))
+ continue;
+
+ XDocument doc;
+ try
+ {
+ doc = XDocument.Load(csproj);
+ }
+ catch (Exception ex)
+ {
+ offenders.Add($"{rel} (could not parse: {ex.Message})");
+ continue;
+ }
+
+ foreach (var reference in doc.Descendants("PackageReference"))
+ {
+ var include = reference.Attribute("Include")?.Value;
+ if (
+ include is null
+ || !include.StartsWith(options.TraxPackagePrefix, StringComparison.Ordinal)
+ )
+ continue;
+
+ var version =
+ reference.Attribute("Version")?.Value ?? reference.Element("Version")?.Value;
+
+ if (version != options.ExpectedTraxPackageVersion)
+ offenders.Add($"{rel} -> {include} Version=\"{version ?? ""}\"");
+ }
+ }
+
+ var message =
+ $"Cross-repo Trax package references (Include starts with '{options.TraxPackagePrefix}') must use "
+ + $"Version=\"{options.ExpectedTraxPackageVersion}\" so the local feed resolves correctly. "
+ + "Offenders:\n "
+ + string.Join("\n ", offenders);
+
+ return new GuardResult(offenders, inspected, message);
+ }
+}
diff --git a/src/Trax.Core.Testing/Infrastructure/RepoRoot.cs b/src/Trax.Core.Testing/Infrastructure/RepoRoot.cs
new file mode 100644
index 0000000..bf7afbf
--- /dev/null
+++ b/src/Trax.Core.Testing/Infrastructure/RepoRoot.cs
@@ -0,0 +1,36 @@
+namespace Trax.Core.Testing.Infrastructure;
+
+///
+/// Locates the repository root by walking up from the test assembly's base directory until it finds a
+/// directory containing a *.slnx solution file. Architecture guards scan the tree from here.
+///
+public static class RepoRoot
+{
+ private static readonly Lazy Cached = new(Resolve);
+
+ /// The absolute path of the repository root (the directory containing a *.slnx).
+ public static string Path => Cached.Value;
+
+ /// Combines path segments onto the repository root.
+ public static string Combine(params string[] segments) =>
+ System.IO.Path.Combine([Path, .. segments]);
+
+ /// Returns as a path relative to the repository root.
+ public static string Relative(string absolute) =>
+ System.IO.Path.GetRelativePath(Path, absolute);
+
+ private static string Resolve()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null)
+ {
+ if (dir.EnumerateFiles("*.slnx").Any())
+ return dir.FullName;
+ dir = dir.Parent;
+ }
+
+ throw new InvalidOperationException(
+ $"Could not locate repository root: no .slnx found walking up from '{AppContext.BaseDirectory}'."
+ );
+ }
+}
diff --git a/src/Trax.Core.Testing/Infrastructure/SourceFiles.cs b/src/Trax.Core.Testing/Infrastructure/SourceFiles.cs
new file mode 100644
index 0000000..e3c625f
--- /dev/null
+++ b/src/Trax.Core.Testing/Infrastructure/SourceFiles.cs
@@ -0,0 +1,60 @@
+namespace Trax.Core.Testing.Infrastructure;
+
+///
+/// Enumerates source files under the repository root, excluding build output and dependency
+/// directories. Pass subdirectories to scope the scan (e.g. "src", "tests"); pass none
+/// to scan the whole repo.
+///
+public static class SourceFiles
+{
+ /// Enumerates *.cs files under the given subdirectories of the detected repo root (or the whole repo).
+ public static IEnumerable CSharp(params string[] subdirs) =>
+ CSharpUnder(RepoRoot.Path, subdirs);
+
+ /// Enumerates *.csproj files under the detected repo root.
+ public static IEnumerable Projects(params string[] subdirs) =>
+ ProjectsUnder(RepoRoot.Path, subdirs);
+
+ /// Enumerates *.md files under the detected repo root.
+ public static IEnumerable Markdown(params string[] subdirs) =>
+ Enumerate(RepoRoot.Path, "*.md", subdirs);
+
+ /// Enumerates *.cs files under an explicit root (for testing or custom roots).
+ public static IEnumerable CSharpUnder(string root, params string[] subdirs) =>
+ Enumerate(root, "*.cs", subdirs);
+
+ /// Enumerates *.csproj files under an explicit root (for testing or custom roots).
+ public static IEnumerable ProjectsUnder(string root, params string[] subdirs) =>
+ Enumerate(root, "*.csproj", subdirs);
+
+ private static IEnumerable Enumerate(string repoRoot, string pattern, string[] subdirs)
+ {
+ var roots =
+ subdirs.Length == 0
+ ? [repoRoot]
+ : subdirs.Select(s => Path.Combine(repoRoot, s)).ToArray();
+
+ foreach (var root in roots)
+ {
+ if (!Directory.Exists(root))
+ continue;
+
+ foreach (
+ var file in Directory.EnumerateFiles(root, pattern, SearchOption.AllDirectories)
+ )
+ {
+ if (!IsExcluded(file))
+ yield return file;
+ }
+ }
+ }
+
+ private static bool IsExcluded(string path)
+ {
+ var s = Path.DirectorySeparatorChar;
+ return path.Contains($"{s}bin{s}", StringComparison.Ordinal)
+ || path.Contains($"{s}obj{s}", StringComparison.Ordinal)
+ || path.Contains($"{s}node_modules{s}", StringComparison.Ordinal)
+ || path.Contains($"{s}.git{s}", StringComparison.Ordinal);
+ }
+}
diff --git a/src/Trax.Core.Testing/Infrastructure/SourceText.cs b/src/Trax.Core.Testing/Infrastructure/SourceText.cs
new file mode 100644
index 0000000..8b54a55
--- /dev/null
+++ b/src/Trax.Core.Testing/Infrastructure/SourceText.cs
@@ -0,0 +1,59 @@
+using System.Text.RegularExpressions;
+
+namespace Trax.Core.Testing.Infrastructure;
+
+///
+/// Helpers for scanning C# source as text: stripping comments and string literals before a regex
+/// match (so a pattern doesn't false-positive on a comment or string), and reporting matching lines.
+///
+public static class SourceText
+{
+ private static readonly Regex BlockComment = new(
+ "/\\*.*?\\*/",
+ RegexOptions.Singleline | RegexOptions.Compiled
+ );
+ private static readonly Regex LineComment = new("//[^\\r\\n]*", RegexOptions.Compiled);
+ private static readonly Regex VerbatimString = new(
+ "@\"(?:[^\"]|\"\")*\"",
+ RegexOptions.Compiled
+ );
+ private static readonly Regex InterpolatedVerbatim = new(
+ "\\$@\"(?:[^\"]|\"\")*\"",
+ RegexOptions.Compiled
+ );
+ private static readonly Regex RegularString = new(
+ "\"(?:\\\\.|[^\"\\\\])*\"",
+ RegexOptions.Compiled
+ );
+
+ ///
+ /// Removes block/line comments and string literals (replacing strings with "") so a
+ /// keyword scan ignores commented-out or quoted occurrences.
+ ///
+ public static string StripCommentsAndStrings(string source)
+ {
+ var s = BlockComment.Replace(source, " ");
+ s = LineComment.Replace(s, " ");
+ s = InterpolatedVerbatim.Replace(s, "\"\"");
+ s = VerbatimString.Replace(s, "\"\"");
+ s = RegularString.Replace(s, "\"\"");
+ return s;
+ }
+
+ /// Returns the 1-based line number and text of every line in matching .
+ public static IReadOnlyList<(int LineNumber, string Line)> MatchingLines(
+ string source,
+ Regex pattern
+ )
+ {
+ var hits = new List<(int, string)>();
+ var lines = source.Replace("\r\n", "\n").Split('\n');
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (pattern.IsMatch(lines[i]))
+ hits.Add((i + 1, lines[i]));
+ }
+
+ return hits;
+ }
+}
diff --git a/src/Trax.Core.Testing/Trax.Core.Testing.csproj b/src/Trax.Core.Testing/Trax.Core.Testing.csproj
new file mode 100644
index 0000000..ce26303
--- /dev/null
+++ b/src/Trax.Core.Testing/Trax.Core.Testing.csproj
@@ -0,0 +1,18 @@
+
+
+ net10.0
+ enable
+ enable
+ Trax.Core.Testing
+ Theauxm,mark-keaton
+ Reusable architecture-guard infrastructure and hygiene checkers for Trax codebases, with NUnit base fixtures: subclass a fixture, supply options, and run dotnet test (no test bodies to write). The underlying checkers return offender lists if you prefer your own framework.
+ trax;testing;architecture;guards;conventions;nunit
+ https://github.com/TraxSharp/Trax.Core
+ true
+
+
+
+
+
+
+
diff --git a/tests/Trax.Core.Testing.Tests/FixturesAndInfraTests.cs b/tests/Trax.Core.Testing.Tests/FixturesAndInfraTests.cs
new file mode 100644
index 0000000..849b6b9
--- /dev/null
+++ b/tests/Trax.Core.Testing.Tests/FixturesAndInfraTests.cs
@@ -0,0 +1,85 @@
+using Trax.Core.Testing;
+using Trax.Core.Testing.Guards;
+using Trax.Core.Testing.Infrastructure;
+
+namespace Trax.Core.Testing.Tests;
+
+///
+/// Direct coverage for the repo-root resolution and the source-file / repo-convention edge branches
+/// the checker tests do not reach. The NUnit base fixtures themselves are covered by the self-test
+/// fixtures in SelfTestFixtures.cs.
+///
+[TestFixture]
+public class FixturesAndInfraTests
+{
+ #region RepoRoot
+
+ [Test]
+ public void RepoRoot_resolves_to_a_directory_containing_a_solution()
+ {
+ var root = RepoRoot.Path;
+
+ Directory
+ .EnumerateFiles(root, "*.slnx")
+ .Should()
+ .NotBeEmpty("RepoRoot walks up to the directory holding the .slnx");
+ }
+
+ [Test]
+ public void RepoRoot_Combine_and_Relative_round_trip()
+ {
+ var absolute = RepoRoot.Combine("src", "Example.cs");
+
+ RepoRoot.Relative(absolute).Replace('\\', '/').Should().Be("src/Example.cs");
+ }
+
+ #endregion
+
+ #region SourceFiles
+
+ [Test]
+ public void SourceFiles_excludes_build_output()
+ {
+ using var repo = new TempRepo()
+ .Write("src/A.cs", "// a")
+ .Write("src/bin/B.cs", "// b")
+ .Write("src/obj/C.cs", "// c");
+
+ var names = SourceFiles.CSharpUnder(repo.Root, "src").Select(Path.GetFileName).ToList();
+
+ names.Should().Contain("A.cs");
+ names.Should().NotContain("B.cs");
+ names.Should().NotContain("C.cs");
+ }
+
+ #endregion
+
+ #region RepoConventionGuards edge branches
+
+ [Test]
+ public void DirectoryBuildPropsVersion_flags_a_missing_file()
+ {
+ using var repo = new TempRepo();
+
+ var result = RepoConventionGuards.DirectoryBuildPropsVersion(
+ new() { RepoRootOverride = repo.Root }
+ );
+
+ result.Passed.Should().BeFalse();
+ result.Offenders.Should().ContainSingle(o => o.Contains("missing"));
+ }
+
+ [Test]
+ public void CrossRepoPackageVersions_flags_an_unparseable_project()
+ {
+ using var repo = new TempRepo().Write("src/X/X.csproj", " o.Contains("could not parse"));
+ }
+
+ #endregion
+}
diff --git a/tests/Trax.Core.Testing.Tests/GlobalUsings.cs b/tests/Trax.Core.Testing.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..91a038c
--- /dev/null
+++ b/tests/Trax.Core.Testing.Tests/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using FluentAssertions;
+global using NUnit.Framework;
diff --git a/tests/Trax.Core.Testing.Tests/HygieneGuardsTests.cs b/tests/Trax.Core.Testing.Tests/HygieneGuardsTests.cs
new file mode 100644
index 0000000..379d5f6
--- /dev/null
+++ b/tests/Trax.Core.Testing.Tests/HygieneGuardsTests.cs
@@ -0,0 +1,118 @@
+using Trax.Core.Testing;
+using Trax.Core.Testing.Guards;
+
+namespace Trax.Core.Testing.Tests;
+
+[TestFixture]
+public class HygieneGuardsTests
+{
+ private static ArchitectureGuardOptions OptionsFor(TempRepo repo) =>
+ new() { RepoRootOverride = repo.Root, TestScanRoots = ["tests"] };
+
+ #region NoIgnoreAttribute
+
+ [Test]
+ public void NoIgnoreAttribute_FlagsIgnoredTest()
+ {
+ using var repo = new TempRepo().Write(
+ "tests/Sample/FooTests.cs",
+ "public class FooTests { [Test, Ignore(\"x\")] public void A() {} }"
+ );
+
+ var result = HygieneGuards.NoIgnoreAttribute(OptionsFor(repo));
+
+ result.Passed.Should().BeFalse();
+ result.Offenders.Should().ContainSingle(o => o.Contains("tests/Sample/FooTests.cs"));
+ result.Inspected.Should().Be(1);
+ }
+
+ [Test]
+ public void NoIgnoreAttribute_IgnoresCommentedOccurrence()
+ {
+ using var repo = new TempRepo().Write(
+ "tests/Sample/FooTests.cs",
+ "public class FooTests { /* [Ignore] in a comment */ public void A() {} }"
+ );
+
+ HygieneGuards.NoIgnoreAttribute(OptionsFor(repo)).Passed.Should().BeTrue();
+ }
+
+ [Test]
+ public void NoIgnoreAttribute_RespectsAllowlist()
+ {
+ using var repo = new TempRepo().Write(
+ "tests/Sample/FooTests.cs",
+ "public class FooTests { [Ignore(\"gated\")] public void A() {} }"
+ );
+
+ var options = OptionsFor(repo) with
+ {
+ NoIgnoreKnownExceptions = new HashSet { "tests/Sample/FooTests.cs" },
+ };
+
+ HygieneGuards.NoIgnoreAttribute(options).Passed.Should().BeTrue();
+ }
+
+ #endregion
+
+ #region NoLegacyAsserts
+
+ [Test]
+ public void NoLegacyAsserts_FlagsClassicAssert()
+ {
+ using var repo = new TempRepo().Write(
+ "tests/Sample/FooTests.cs",
+ "public class FooTests { public void A() { Assert.AreEqual(1, 1); } }"
+ );
+
+ var result = HygieneGuards.NoLegacyAsserts(OptionsFor(repo));
+
+ result.Passed.Should().BeFalse();
+ result.Offenders.Should().ContainSingle(o => o.Contains("Assert.AreEqual"));
+ }
+
+ [Test]
+ public void NoLegacyAsserts_AllowsFluentStyle()
+ {
+ using var repo = new TempRepo().Write(
+ "tests/Sample/FooTests.cs",
+ "public class FooTests { public void A() { result.Should().Be(1); } }"
+ );
+
+ HygieneGuards.NoLegacyAsserts(OptionsFor(repo)).Passed.Should().BeTrue();
+ }
+
+ #endregion
+
+ #region NoFixedDelays
+
+ [Test]
+ public void NoFixedDelays_FlagsUnjustifiedDelay()
+ {
+ using var repo = new TempRepo().Write(
+ "tests/Sample/FooTests.cs",
+ "public class FooTests { public async Task A() { await Task.Delay(2000); } }"
+ );
+
+ var result = HygieneGuards.NoFixedDelays(OptionsFor(repo));
+
+ result.Passed.Should().BeFalse();
+ result.Offenders.Should().ContainSingle(o => o.Contains("Task.Delay"));
+ }
+
+ [Test]
+ public void NoFixedDelays_AllowsJustifiedDelay()
+ {
+ using var repo = new TempRepo().Write(
+ "tests/Sample/FooTests.cs",
+ "public class FooTests { public async Task A() {\n"
+ + " // measuring-interval: prove two stamps are 50ms apart\n"
+ + " await Task.Delay(50);\n"
+ + "} }"
+ );
+
+ HygieneGuards.NoFixedDelays(OptionsFor(repo)).Passed.Should().BeTrue();
+ }
+
+ #endregion
+}
diff --git a/tests/Trax.Core.Testing.Tests/RepoConventionGuardsTests.cs b/tests/Trax.Core.Testing.Tests/RepoConventionGuardsTests.cs
new file mode 100644
index 0000000..9b103e2
--- /dev/null
+++ b/tests/Trax.Core.Testing.Tests/RepoConventionGuardsTests.cs
@@ -0,0 +1,83 @@
+using Trax.Core.Testing;
+using Trax.Core.Testing.Guards;
+
+namespace Trax.Core.Testing.Tests;
+
+[TestFixture]
+public class RepoConventionGuardsTests
+{
+ #region DirectoryBuildPropsVersion
+
+ [Test]
+ public void DirectoryBuildPropsVersion_PassesAtExpectedVersion()
+ {
+ using var repo = new TempRepo().Write(
+ "Directory.Build.props",
+ "1.99.99"
+ );
+
+ RepoConventionGuards
+ .DirectoryBuildPropsVersion(new() { RepoRootOverride = repo.Root })
+ .Passed.Should()
+ .BeTrue();
+ }
+
+ [Test]
+ public void DirectoryBuildPropsVersion_FlagsWrongVersion()
+ {
+ using var repo = new TempRepo().Write(
+ "Directory.Build.props",
+ "2.0.0"
+ );
+
+ var result = RepoConventionGuards.DirectoryBuildPropsVersion(
+ new() { RepoRootOverride = repo.Root }
+ );
+
+ result.Passed.Should().BeFalse();
+ result.Offenders.Should().ContainSingle(o => o.Contains("2.0.0"));
+ }
+
+ #endregion
+
+ #region CrossRepoPackageVersions
+
+ [Test]
+ public void CrossRepoPackageVersions_PassesWithFloatingVersion()
+ {
+ using var repo = new TempRepo().Write(
+ "src/App/App.csproj",
+ ""
+ + ""
+ + ""
+ + ""
+ );
+
+ RepoConventionGuards
+ .CrossRepoPackageVersions(new() { RepoRootOverride = repo.Root })
+ .Passed.Should()
+ .BeTrue("only Trax.* refs are checked; NUnit is ignored");
+ }
+
+ [Test]
+ public void CrossRepoPackageVersions_FlagsPinnedTraxReference()
+ {
+ using var repo = new TempRepo().Write(
+ "src/App/App.csproj",
+ ""
+ + ""
+ + ""
+ );
+
+ var result = RepoConventionGuards.CrossRepoPackageVersions(
+ new() { RepoRootOverride = repo.Root }
+ );
+
+ result.Passed.Should().BeFalse();
+ result
+ .Offenders.Should()
+ .ContainSingle(o => o.Contains("Trax.Effect") && o.Contains("2.0.0"));
+ }
+
+ #endregion
+}
diff --git a/tests/Trax.Core.Testing.Tests/SelfTestFixtures.cs b/tests/Trax.Core.Testing.Tests/SelfTestFixtures.cs
new file mode 100644
index 0000000..ef6e483
--- /dev/null
+++ b/tests/Trax.Core.Testing.Tests/SelfTestFixtures.cs
@@ -0,0 +1,50 @@
+using Trax.Core.Testing;
+using Trax.Core.Testing.Fixtures;
+
+namespace Trax.Core.Testing.Tests;
+
+// Self-tests that run the shipped base fixtures exactly as a consumer would: subclass, configure
+// against a deterministic synthetic repo, and let NUnit discover and run the inherited [Test] methods.
+// These exercise the fixture bodies end to end and double as the dogfood for the turnkey path.
+
+[TestFixture]
+public sealed class HygieneGuardFixtureSelfTest : HygieneGuardFixture
+{
+ private TempRepo _repo = null!;
+
+ protected override ArchitectureGuardOptions Options =>
+ new() { RepoRootOverride = _repo.Root, TestScanRoots = ["tests"] };
+
+ [OneTimeSetUp]
+ public void CreateCleanRepo() =>
+ _repo = new TempRepo().Write(
+ "tests/CleanTests.cs",
+ "public class CleanTests { public void A() { result.Should().Be(1); } }"
+ );
+
+ [OneTimeTearDown]
+ public void Cleanup() => _repo.Dispose();
+}
+
+[TestFixture]
+public sealed class RepoConventionGuardFixtureSelfTest : RepoConventionGuardFixture
+{
+ private TempRepo _repo = null!;
+
+ protected override ArchitectureGuardOptions Options => new() { RepoRootOverride = _repo.Root };
+
+ [OneTimeSetUp]
+ public void CreateConformingRepo() =>
+ _repo = new TempRepo()
+ .Write(
+ "Directory.Build.props",
+ "1.99.99"
+ )
+ .Write(
+ "src/App/App.csproj",
+ ""
+ );
+
+ [OneTimeTearDown]
+ public void Cleanup() => _repo.Dispose();
+}
diff --git a/tests/Trax.Core.Testing.Tests/TempRepo.cs b/tests/Trax.Core.Testing.Tests/TempRepo.cs
new file mode 100644
index 0000000..9d95cf6
--- /dev/null
+++ b/tests/Trax.Core.Testing.Tests/TempRepo.cs
@@ -0,0 +1,36 @@
+namespace Trax.Core.Testing.Tests;
+
+///
+/// Builds a throwaway directory tree on disk so the guard checkers can be exercised against
+/// synthetic fixtures via , with no dependency
+/// on the real repository contents. Disposing deletes the tree.
+///
+public sealed class TempRepo : IDisposable
+{
+ public string Root { get; } =
+ Path.Combine(Path.GetTempPath(), "trax-guard-tests", Guid.NewGuid().ToString("N"));
+
+ public TempRepo() => Directory.CreateDirectory(Root);
+
+ /// Writes a file at a repo-relative path, creating directories as needed.
+ public TempRepo Write(string relativePath, string content)
+ {
+ var full = Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+ File.WriteAllText(full, content);
+ return this;
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(Root))
+ Directory.Delete(Root, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup; a leaked temp dir must never fail a test.
+ }
+ }
+}
diff --git a/tests/Trax.Core.Testing.Tests/Trax.Core.Testing.Tests.csproj b/tests/Trax.Core.Testing.Tests/Trax.Core.Testing.Tests.csproj
new file mode 100644
index 0000000..1eb407f
--- /dev/null
+++ b/tests/Trax.Core.Testing.Tests/Trax.Core.Testing.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+