test fixes and new product advisories work
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Testing;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Analyzers.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IntentAnalyzer"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IntentAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TrivialTest_NoWarning()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
public void TrivialTest()
|
||||
{
|
||||
Assert.True(true);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyNoWarningsAsync(code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonTrivialTest_WithIntent_NoWarning()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
[Intent("Safety", "Test security")]
|
||||
public void NonTrivialTest()
|
||||
{
|
||||
var a = 1;
|
||||
var b = 2;
|
||||
var c = 3;
|
||||
var d = 4;
|
||||
var e = 5;
|
||||
var f = 6;
|
||||
Assert.Equal(21, a + b + c + d + e + f);
|
||||
}
|
||||
}
|
||||
|
||||
[System.AttributeUsage(System.AttributeTargets.Method)]
|
||||
public class IntentAttribute : System.Attribute
|
||||
{
|
||||
public IntentAttribute(string intent, string rationale = "") { }
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyNoWarningsAsync(code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonTrivialTest_WithTraitIntent_NoWarning()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Intent", "Safety")]
|
||||
public void NonTrivialTest()
|
||||
{
|
||||
var a = 1;
|
||||
var b = 2;
|
||||
var c = 3;
|
||||
var d = 4;
|
||||
var e = 5;
|
||||
var f = 6;
|
||||
Assert.Equal(21, a + b + c + d + e + f);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyNoWarningsAsync(code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonTrivialTest_WithIntentExempt_NoWarning()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("IntentExempt", "true")]
|
||||
public void NonTrivialTest()
|
||||
{
|
||||
var a = 1;
|
||||
var b = 2;
|
||||
var c = 3;
|
||||
var d = 4;
|
||||
var e = 5;
|
||||
var f = 6;
|
||||
Assert.Equal(21, a + b + c + d + e + f);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyNoWarningsAsync(code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonTrivialTest_WithoutIntent_Warning()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
public void {|#0:NonTrivialTest|}()
|
||||
{
|
||||
var a = 1;
|
||||
var b = 2;
|
||||
var c = 3;
|
||||
var d = 4;
|
||||
var e = 5;
|
||||
var f = 6;
|
||||
Assert.Equal(21, a + b + c + d + e + f);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = new DiagnosticResult(IntentAnalyzer.MissingIntentDiagnosticId, DiagnosticSeverity.Warning)
|
||||
.WithLocation(0)
|
||||
.WithArguments("NonTrivialTest");
|
||||
|
||||
await VerifyWarningAsync(code, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleAssertions_WithoutIntent_Warning()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
public void {|#0:MultiAssertTest|}()
|
||||
{
|
||||
var result = 42;
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = new DiagnosticResult(IntentAnalyzer.MissingIntentDiagnosticId, DiagnosticSeverity.Warning)
|
||||
.WithLocation(0)
|
||||
.WithArguments("MultiAssertTest");
|
||||
|
||||
await VerifyWarningAsync(code, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IntentWithoutRationale_Info()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
{|#0:[Intent("Safety")]|}
|
||||
public void TestWithIntent()
|
||||
{
|
||||
var a = 1;
|
||||
var b = 2;
|
||||
var c = 3;
|
||||
var d = 4;
|
||||
var e = 5;
|
||||
var f = 6;
|
||||
Assert.Equal(21, a + b + c + d + e + f);
|
||||
}
|
||||
}
|
||||
|
||||
[System.AttributeUsage(System.AttributeTargets.Method)]
|
||||
public class IntentAttribute : System.Attribute
|
||||
{
|
||||
public IntentAttribute(string intent, string rationale = "") { }
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = new DiagnosticResult(IntentAnalyzer.MissingRationaleDiagnosticId, DiagnosticSeverity.Info)
|
||||
.WithLocation(0)
|
||||
.WithArguments("TestWithIntent");
|
||||
|
||||
await VerifyWarningAsync(code, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IntentWithRationale_NoInfo()
|
||||
{
|
||||
var code = """
|
||||
using Xunit;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
[Intent("Safety", "Security requirement per OWASP")]
|
||||
public void TestWithIntent()
|
||||
{
|
||||
var a = 1;
|
||||
var b = 2;
|
||||
var c = 3;
|
||||
var d = 4;
|
||||
var e = 5;
|
||||
var f = 6;
|
||||
Assert.Equal(21, a + b + c + d + e + f);
|
||||
}
|
||||
}
|
||||
|
||||
[System.AttributeUsage(System.AttributeTargets.Method)]
|
||||
public class IntentAttribute : System.Attribute
|
||||
{
|
||||
public IntentAttribute(string intent, string rationale = "") { }
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyNoWarningsAsync(code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonTestMethod_NoWarning()
|
||||
{
|
||||
var code = """
|
||||
public class MyClass
|
||||
{
|
||||
public void NonTestMethod()
|
||||
{
|
||||
var a = 1;
|
||||
var b = 2;
|
||||
var c = 3;
|
||||
var d = 4;
|
||||
var e = 5;
|
||||
var f = 6;
|
||||
System.Console.WriteLine(a + b + c + d + e + f);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyNoWarningsAsync(code);
|
||||
}
|
||||
|
||||
private static async Task VerifyNoWarningsAsync(string code)
|
||||
{
|
||||
var test = new CSharpAnalyzerTest<IntentAnalyzer, DefaultVerifier>
|
||||
{
|
||||
TestCode = code,
|
||||
ReferenceAssemblies = ReferenceAssemblies.Net.Net80
|
||||
};
|
||||
|
||||
test.TestState.AdditionalReferences.Add(
|
||||
MetadataReference.CreateFromFile(typeof(Xunit.FactAttribute).Assembly.Location));
|
||||
|
||||
await test.RunAsync();
|
||||
}
|
||||
|
||||
private static async Task VerifyWarningAsync(string code, DiagnosticResult expected)
|
||||
{
|
||||
var test = new CSharpAnalyzerTest<IntentAnalyzer, DefaultVerifier>
|
||||
{
|
||||
TestCode = code,
|
||||
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
|
||||
ExpectedDiagnostics = { expected }
|
||||
};
|
||||
|
||||
test.TestState.AdditionalReferences.Add(
|
||||
MetadataReference.CreateFromFile(typeof(Xunit.FactAttribute).Assembly.Location));
|
||||
|
||||
await test.RunAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.TestKit.Analyzers\StellaOps.TestKit.Analyzers.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
; Shipped analyzer releases
|
||||
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
@@ -0,0 +1,9 @@
|
||||
; Unshipped analyzer releases
|
||||
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
|
||||
### New Rules
|
||||
|
||||
Rule ID | Category | Severity | Notes
|
||||
--------|----------|----------|-------
|
||||
TESTKIT0100 | Testing | Warning | Test method missing intent tag
|
||||
TESTKIT0101 | Testing | Info | Intent attribute missing rationale
|
||||
379
src/__Analyzers/StellaOps.TestKit.Analyzers/IntentAnalyzer.cs
Normal file
379
src/__Analyzers/StellaOps.TestKit.Analyzers/IntentAnalyzer.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
|
||||
namespace StellaOps.TestKit.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Roslyn analyzer that detects test methods without intent tags.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reports TESTKIT0100 for non-trivial tests (>5 lines or >1 assertion) without Intent attribute.
|
||||
/// Reports TESTKIT0101 for Intent attributes missing rationale.
|
||||
/// </remarks>
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public sealed class IntentAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic ID for missing intent tag.
|
||||
/// </summary>
|
||||
public const string MissingIntentDiagnosticId = "TESTKIT0100";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for missing rationale.
|
||||
/// </summary>
|
||||
public const string MissingRationaleDiagnosticId = "TESTKIT0101";
|
||||
|
||||
private const string Category = "Testing";
|
||||
|
||||
private static readonly LocalizableString MissingIntentTitle =
|
||||
"Test method missing intent tag";
|
||||
|
||||
private static readonly LocalizableString MissingIntentMessageFormat =
|
||||
"Non-trivial test method '{0}' should have an [Intent] attribute declaring its business purpose";
|
||||
|
||||
private static readonly LocalizableString MissingIntentDescription =
|
||||
"Non-trivial test methods (with more than 5 statements or multiple assertions) should " +
|
||||
"declare their intent using [Intent(TestIntents.X)] to enable intent-based CI gating " +
|
||||
"and coverage tracking. Use [Trait(\"IntentExempt\", \"true\")] to suppress for utility tests.";
|
||||
|
||||
private static readonly LocalizableString MissingRationaleTitle =
|
||||
"Intent attribute missing rationale";
|
||||
|
||||
private static readonly LocalizableString MissingRationaleMessageFormat =
|
||||
"Consider adding rationale to [Intent] on '{0}' to explain why this test has this intent";
|
||||
|
||||
private static readonly LocalizableString MissingRationaleDescription =
|
||||
"Intent attributes should include a rationale parameter explaining why the test has " +
|
||||
"this intent, linking to requirements, compliance controls, or security advisories.";
|
||||
|
||||
private static readonly DiagnosticDescriptor MissingIntentRule = new(
|
||||
MissingIntentDiagnosticId,
|
||||
MissingIntentTitle,
|
||||
MissingIntentMessageFormat,
|
||||
Category,
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: MissingIntentDescription,
|
||||
helpLinkUri: "https://docs.stellaops.io/testing/intent-tagging");
|
||||
|
||||
private static readonly DiagnosticDescriptor MissingRationaleRule = new(
|
||||
MissingRationaleDiagnosticId,
|
||||
MissingRationaleTitle,
|
||||
MissingRationaleMessageFormat,
|
||||
Category,
|
||||
DiagnosticSeverity.Info,
|
||||
isEnabledByDefault: true,
|
||||
description: MissingRationaleDescription,
|
||||
helpLinkUri: "https://docs.stellaops.io/testing/intent-tagging#rationale");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
|
||||
ImmutableArray.Create(MissingIntentRule, MissingRationaleRule);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration);
|
||||
}
|
||||
|
||||
private static void AnalyzeMethod(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
var methodDeclaration = (MethodDeclarationSyntax)context.Node;
|
||||
|
||||
// Only analyze methods with test attributes
|
||||
if (!HasTestAttribute(methodDeclaration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for IntentExempt trait
|
||||
if (HasIntentExemptTrait(methodDeclaration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Intent attribute
|
||||
var intentAttribute = GetIntentAttribute(methodDeclaration);
|
||||
|
||||
if (intentAttribute == null)
|
||||
{
|
||||
// Check for Trait("Intent", "...") as alternative
|
||||
if (HasIntentTrait(methodDeclaration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only warn for non-trivial tests
|
||||
if (IsNonTrivialTest(methodDeclaration))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(
|
||||
MissingIntentRule,
|
||||
methodDeclaration.Identifier.GetLocation(),
|
||||
methodDeclaration.Identifier.Text);
|
||||
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if rationale is provided
|
||||
if (!HasRationaleArgument(intentAttribute))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(
|
||||
MissingRationaleRule,
|
||||
intentAttribute.GetLocation(),
|
||||
methodDeclaration.Identifier.Text);
|
||||
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasTestAttribute(MethodDeclarationSyntax method)
|
||||
{
|
||||
foreach (var attributeList in method.AttributeLists)
|
||||
{
|
||||
foreach (var attribute in attributeList.Attributes)
|
||||
{
|
||||
var name = GetAttributeName(attribute);
|
||||
if (name is "Fact" or "FactAttribute" or
|
||||
"Theory" or "TheoryAttribute" or
|
||||
"Test" or "TestAttribute")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasIntentExemptTrait(MethodDeclarationSyntax method)
|
||||
{
|
||||
foreach (var attributeList in method.AttributeLists)
|
||||
{
|
||||
foreach (var attribute in attributeList.Attributes)
|
||||
{
|
||||
var name = GetAttributeName(attribute);
|
||||
if (name is "Trait" or "TraitAttribute" &&
|
||||
attribute.ArgumentList?.Arguments.Count >= 2)
|
||||
{
|
||||
var args = attribute.ArgumentList.Arguments;
|
||||
if (GetStringLiteralValue(args[0].Expression) == "IntentExempt")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasIntentTrait(MethodDeclarationSyntax method)
|
||||
{
|
||||
foreach (var attributeList in method.AttributeLists)
|
||||
{
|
||||
foreach (var attribute in attributeList.Attributes)
|
||||
{
|
||||
var name = GetAttributeName(attribute);
|
||||
if (name is "Trait" or "TraitAttribute" &&
|
||||
attribute.ArgumentList?.Arguments.Count >= 2)
|
||||
{
|
||||
var args = attribute.ArgumentList.Arguments;
|
||||
if (GetStringLiteralValue(args[0].Expression) == "Intent")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static AttributeSyntax? GetIntentAttribute(MethodDeclarationSyntax method)
|
||||
{
|
||||
foreach (var attributeList in method.AttributeLists)
|
||||
{
|
||||
foreach (var attribute in attributeList.Attributes)
|
||||
{
|
||||
var name = GetAttributeName(attribute);
|
||||
if (name is "Intent" or "IntentAttribute")
|
||||
{
|
||||
return attribute;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasRationaleArgument(AttributeSyntax attribute)
|
||||
{
|
||||
if (attribute.ArgumentList == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var args = attribute.ArgumentList.Arguments;
|
||||
|
||||
// Check for named rationale argument
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.NameEquals?.Name.Identifier.Text == "rationale" ||
|
||||
arg.NameColon?.Name.Identifier.Text == "rationale")
|
||||
{
|
||||
var value = GetStringLiteralValue(arg.Expression);
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for positional second argument
|
||||
if (args.Count >= 2)
|
||||
{
|
||||
var value = GetStringLiteralValue(args[1].Expression);
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNonTrivialTest(MethodDeclarationSyntax method)
|
||||
{
|
||||
if (method.Body == null && method.ExpressionBody == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count statements
|
||||
var statementCount = 0;
|
||||
var assertionCount = 0;
|
||||
|
||||
if (method.Body != null)
|
||||
{
|
||||
statementCount = CountStatements(method.Body);
|
||||
assertionCount = CountAssertions(method.Body);
|
||||
}
|
||||
else if (method.ExpressionBody != null)
|
||||
{
|
||||
// Expression-bodied methods are typically trivial
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-trivial: >5 statements OR >1 assertion
|
||||
return statementCount > 5 || assertionCount > 1;
|
||||
}
|
||||
|
||||
private static int CountStatements(BlockSyntax block)
|
||||
{
|
||||
var count = 0;
|
||||
|
||||
foreach (var statement in block.Statements)
|
||||
{
|
||||
count++;
|
||||
|
||||
// Count nested statements in blocks
|
||||
if (statement is BlockSyntax nestedBlock)
|
||||
{
|
||||
count += CountStatements(nestedBlock);
|
||||
}
|
||||
else if (statement is IfStatementSyntax ifStatement)
|
||||
{
|
||||
if (ifStatement.Statement is BlockSyntax ifBlock)
|
||||
{
|
||||
count += CountStatements(ifBlock);
|
||||
}
|
||||
if (ifStatement.Else?.Statement is BlockSyntax elseBlock)
|
||||
{
|
||||
count += CountStatements(elseBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountAssertions(BlockSyntax block)
|
||||
{
|
||||
var count = 0;
|
||||
|
||||
foreach (var node in block.DescendantNodes())
|
||||
{
|
||||
if (node is InvocationExpressionSyntax invocation)
|
||||
{
|
||||
var methodName = GetInvocationMethodName(invocation);
|
||||
if (IsAssertionMethod(methodName))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string? GetInvocationMethodName(InvocationExpressionSyntax invocation)
|
||||
{
|
||||
return invocation.Expression switch
|
||||
{
|
||||
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text,
|
||||
IdentifierNameSyntax identifier => identifier.Identifier.Text,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsAssertionMethod(string? methodName)
|
||||
{
|
||||
if (methodName == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// xUnit assertions
|
||||
if (methodName.StartsWith("Assert", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("Equal", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("NotEqual", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("True", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("False", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("Null", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("NotNull", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("Throws", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("Contains", StringComparison.Ordinal) ||
|
||||
methodName.StartsWith("DoesNotContain", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// FluentAssertions
|
||||
if (methodName is "Should" or "Be" or "NotBe" or "BeNull" or "NotBeNull" or
|
||||
"BeTrue" or "BeFalse" or "BeEquivalentTo" or "HaveCount" or "Contain" or
|
||||
"NotContain" or "BeEmpty" or "NotBeEmpty" or "Throw" or "NotThrow")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetAttributeName(AttributeSyntax attribute)
|
||||
{
|
||||
return attribute.Name switch
|
||||
{
|
||||
IdentifierNameSyntax identifier => identifier.Identifier.Text,
|
||||
QualifiedNameSyntax qualified => qualified.Right.Identifier.Text,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetStringLiteralValue(ExpressionSyntax expression)
|
||||
{
|
||||
return expression switch
|
||||
{
|
||||
LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.StringLiteralExpression)
|
||||
=> literal.Token.ValueText,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
103
src/__Analyzers/StellaOps.TestKit.Analyzers/README.md
Normal file
103
src/__Analyzers/StellaOps.TestKit.Analyzers/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# StellaOps.TestKit.Analyzers
|
||||
|
||||
Roslyn analyzers for enforcing intent tagging on test methods.
|
||||
|
||||
## Rules
|
||||
|
||||
| ID | Severity | Description |
|
||||
|----|----------|-------------|
|
||||
| TESTKIT0100 | Warning | Test method missing intent tag |
|
||||
| TESTKIT0101 | Info | Intent attribute missing rationale |
|
||||
|
||||
## TESTKIT0100: Test method missing intent tag
|
||||
|
||||
Non-trivial test methods (>5 statements or >1 assertion) should declare their intent using `[Intent]` attribute or `[Trait("Intent", "...")]`.
|
||||
|
||||
### Examples
|
||||
|
||||
```csharp
|
||||
// BAD: Non-trivial test without intent
|
||||
[Fact]
|
||||
public void TestComplexLogic()
|
||||
{
|
||||
var service = new MyService();
|
||||
var input = CreateTestInput();
|
||||
var result = service.Process(input);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expected, result.Value);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// GOOD: Intent declared
|
||||
[Fact]
|
||||
[Intent(TestIntents.Safety, "Validates input sanitization for SQL injection prevention")]
|
||||
public void TestComplexLogic()
|
||||
{
|
||||
// Same test body
|
||||
}
|
||||
|
||||
// GOOD: Using Trait directly
|
||||
[Fact]
|
||||
[Trait("Intent", "Safety")]
|
||||
public void TestComplexLogic()
|
||||
{
|
||||
// Same test body
|
||||
}
|
||||
```
|
||||
|
||||
### Suppressing the Warning
|
||||
|
||||
For utility/helper tests that don't need intent classification:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
[Trait("IntentExempt", "true")]
|
||||
public void TestHelperMethod()
|
||||
{
|
||||
// Helper test
|
||||
}
|
||||
```
|
||||
|
||||
## TESTKIT0101: Intent attribute missing rationale
|
||||
|
||||
Intent attributes should include a rationale explaining why the test has this intent.
|
||||
|
||||
### Examples
|
||||
|
||||
```csharp
|
||||
// INFO: No rationale
|
||||
[Fact]
|
||||
[Intent(TestIntents.Regulatory)]
|
||||
public void TestAuditLog() { }
|
||||
|
||||
// GOOD: With rationale
|
||||
[Fact]
|
||||
[Intent(TestIntents.Regulatory, "Required for SOC2 AU-12 control")]
|
||||
public void TestAuditLog() { }
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to `.editorconfig` to adjust severity:
|
||||
|
||||
```ini
|
||||
# Disable TESTKIT0100 for specific paths
|
||||
[**/Helpers/**/*.cs]
|
||||
dotnet_diagnostic.TESTKIT0100.severity = none
|
||||
|
||||
# Make TESTKIT0101 a warning
|
||||
[*.cs]
|
||||
dotnet_diagnostic.TESTKIT0101.severity = warning
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Reference this analyzer in test projects:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Analyzers\StellaOps.TestKit.Analyzers\StellaOps.TestKit.Analyzers.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<RootNamespace>StellaOps.TestKit.Analyzers</RootNamespace>
|
||||
<Description>Roslyn analyzer enforcing intent tagging on test methods (TESTKIT0100).</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Visible="false" />
|
||||
<None Include="AnalyzerReleases.Shipped.md" Visible="false" />
|
||||
<None Include="AnalyzerReleases.Unshipped.md" Visible="false" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user