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
2 changes: 2 additions & 0 deletions Trax.Core.slnx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Trax.Core/Trax.Core.csproj" />
<Project Path="src/Trax.Core.Testing/Trax.Core.Testing.csproj" />
</Folder>
<Folder Name="/src/analyzers/">
<Project Path="src/Trax.Core.Analyzers/Trax.Core.Analyzers.csproj" />
Expand All @@ -11,5 +12,6 @@
<Project Path="tests/Trax.Core.Tests.Integration/Trax.Core.Tests.Integration.csproj" />
<Project Path="tests/Trax.Core.Tests.Analyzers/Trax.Core.Tests.Analyzers.csproj" />
<Project Path="tests/Trax.Core.Tests.Meta/Trax.Core.Tests.Meta.csproj" />
<Project Path="tests/Trax.Core.Testing.Tests/Trax.Core.Testing.Tests.csproj" />
</Folder>
</Solution>
46 changes: 46 additions & 0 deletions src/Trax.Core.Testing/ArchitectureGuardOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace Trax.Core.Testing;

/// <summary>
/// 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.
/// </summary>
public sealed record ArchitectureGuardOptions
{
/// <summary>
/// Overrides the repository root the guards scan. Defaults to the auto-detected root (walk up to a
/// <c>*.slnx</c>). Set this only to point guards at a specific tree (primarily for testing the
/// guards themselves against a synthetic fixture directory).
/// </summary>
public string? RepoRootOverride { get; init; }

/// <summary>Top-level folders containing test code (scanned by the test-hygiene guards).</summary>
public IReadOnlyList<string> TestScanRoots { get; init; } = ["tests"];

/// <summary>
/// Top-level folders containing production source (scanned by the data-layer / GraphQL / train
/// convention guards). Defaults to <c>src</c>; a consumer overrides (e.g. <c>["samples", "lib"]</c>).
/// </summary>
public IReadOnlyList<string> SourceScanRoots { get; init; } = ["src"];

/// <summary>Files exempt from the no-<c>[Ignore]</c> guard (each should carry a justification in source).</summary>
public IReadOnlySet<string> NoIgnoreKnownExceptions { get; init; } =
new HashSet<string>(StringComparer.Ordinal);

/// <summary>Files exempt from the no-fixed-delay guard.</summary>
public IReadOnlySet<string> FixedDelayKnownExceptions { get; init; } =
new HashSet<string>(StringComparer.Ordinal);

/// <summary>The exact <c>&lt;Version&gt;</c> the root <c>Directory.Build.props</c> must declare for local dev.</summary>
public string ExpectedDirectoryBuildPropsVersion { get; init; } = "1.99.99";

/// <summary>Package-name prefix treated as a cross-repo Trax dependency.</summary>
public string TraxPackagePrefix { get; init; } = "Trax.";

/// <summary>The floating version every cross-repo Trax package reference must use.</summary>
public string ExpectedTraxPackageVersion { get; init; } = "1.*";

/// <summary>Project files exempt from the cross-repo package-version guard.</summary>
public IReadOnlySet<string> CrossRepoPackageKnownExceptions { get; init; } =
new HashSet<string>(StringComparer.Ordinal);
}
75 changes: 75 additions & 0 deletions src/Trax.Core.Testing/Fixtures/HygieneGuardFixtures.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Pre-written test-hygiene guards. A consumer subclasses this, overrides <see cref="Options"/> if the
/// defaults do not fit, and runs <c>dotnet test</c>; the inherited <c>[Test]</c> methods are discovered
/// in the consumer's assembly. No test bodies to write.
/// </summary>
/// <remarks>
/// Example:
/// <code>
/// [TestFixture]
/// public sealed class MyHygieneGuards : HygieneGuardFixture
/// {
/// protected override ArchitectureGuardOptions Options => new() { TestScanRoots = ["tests"] };
/// }
/// </code>
/// </remarks>
[TestFixture]
public abstract class HygieneGuardFixture
{
/// <summary>Guard configuration. Defaults to scanning <c>tests/</c>; override to change roots or allowlists.</summary>
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);
}
}

/// <summary>
/// Pre-written repo-structure guards (<c>Directory.Build.props</c> version, cross-repo package
/// versions). Subclass and override <see cref="Options"/> as needed.
/// </summary>
[TestFixture]
public abstract class RepoConventionGuardFixture
{
/// <summary>Guard configuration. Override to change the expected versions or package prefix.</summary>
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);
}
}
23 changes: 23 additions & 0 deletions src/Trax.Core.Testing/GuardResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Trax.Core.Testing;

/// <summary>
/// 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.
/// </summary>
/// <param name="Offenders">Repo-relative offender descriptions (often <c>path:line (reason)</c>).</param>
/// <param name="Inspected">How many candidate items the guard examined.</param>
/// <param name="FailureMessage">A message explaining the rule and how to fix a violation, with the offender list appended.</param>
/// <remarks>
/// Consumers assert on this with their own test framework, e.g.
/// <c>result.Offenders.Should().BeEmpty(result.FailureMessage)</c> and, where a guard must find work,
/// <c>result.Inspected.Should().BeGreaterThan(0)</c>.
/// </remarks>
public sealed record GuardResult(
IReadOnlyList<string> Offenders,
int Inspected,
string FailureMessage
)
{
/// <summary>True when no offenders were found.</summary>
public bool Passed => Offenders.Count == 0;
}
171 changes: 171 additions & 0 deletions src/Trax.Core.Testing/Guards/HygieneGuards.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System.Text.RegularExpressions;
using Trax.Core.Testing.Infrastructure;

namespace Trax.Core.Testing.Guards;

/// <summary>
/// Test-hygiene guard checkers. Each scans the configured test roots and returns a
/// <see cref="GuardResult"/>; the consumer asserts <c>Offenders</c> is empty with its own framework.
/// </summary>
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:",
];

/// <summary>Flags <c>[Ignore]</c> attributes in test sources (they silently hide failures).</summary>
public static GuardResult NoIgnoreAttribute(ArchitectureGuardOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var root = options.RepoRootOverride ?? RepoRoot.Path;
var offenders = new List<string>();
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);
}

/// <summary>Flags classic NUnit asserts in test sources (the convention is one assertion library, exclusively).</summary>
public static GuardResult NoLegacyAsserts(ArchitectureGuardOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var root = options.RepoRootOverride ?? RepoRoot.Path;
var offenders = new List<string>();
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);
}

/// <summary>
/// Flags fixed-duration <c>Task.Delay</c> / <c>Thread.Sleep</c> in test sources unless the line
/// (or up to three lines above) carries a justification marker, or the file is allowlisted.
/// </summary>
public static GuardResult NoFixedDelays(ArchitectureGuardOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var root = options.RepoRootOverride ?? RepoRoot.Path;
var offenders = new List<string>();
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;
}
}
Loading
Loading