test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

View File

@@ -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

View 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
};
}
}

View 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>
```

View File

@@ -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>