2026-07-24 11:39:44 +08:00

276 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

namespace BuildFlow.Architecture.Tests;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Xunit;
/// <summary>
/// 全源码架构护栏。通过 Roslyn 分析语法树,避免只约束少量已知文件或依赖易误报的正则表达式。
/// </summary>
public class SourceArchitectureTests
{
private static readonly HashSet<string> BusinessExceptionNames =
[
"BusinessException",
"NotFoundException",
"UnauthorizedException",
];
[Fact]
public void EndpointConstructorsShouldNotDependOnDbContexts()
{
var repoRoot = FindRepoRoot();
var endpointRoot = Path.Combine(repoRoot.FullName, "src", "BuildFlow.Host", "Endpoints");
var violations = new List<string>();
foreach (var source in EnumerateSources(repoRoot, endpointRoot))
{
foreach (var declaration in source.Root.DescendantNodes().OfType<ClassDeclarationSyntax>())
{
var parameters = declaration.ParameterList?.Parameters
?? default(SeparatedSyntaxList<ParameterSyntax>);
foreach (var parameter in parameters)
{
if (IsDbContextType(parameter.Type))
{
violations.Add(FormatLocation(source, parameter));
}
}
foreach (var constructor in declaration.Members.OfType<ConstructorDeclarationSyntax>())
{
foreach (var parameter in constructor.ParameterList.Parameters)
{
if (IsDbContextType(parameter.Type))
{
violations.Add(FormatLocation(source, parameter));
}
}
}
}
}
AssertEmpty(
violations,
"Endpoint 构造函数不得依赖 DbContext;请把业务逻辑迁移到 Application Command/Query Handler。");
}
[Fact]
public void HostShouldNotContainInProcessBridges()
{
var repoRoot = FindRepoRoot();
var hostRoot = Path.Combine(repoRoot.FullName, "src", "BuildFlow.Host");
var violations = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var source in EnumerateSources(repoRoot, hostRoot))
{
foreach (var declaration in source.Root.DescendantNodes().OfType<ClassDeclarationSyntax>())
{
if (declaration.Identifier.ValueText.StartsWith("InProcess", StringComparison.Ordinal))
{
violations.TryAdd(
declaration.Identifier.ValueText,
FormatLocation(source, declaration.Identifier));
}
}
foreach (var invocation in source.Root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
if (invocation.Expression is not MemberAccessExpressionSyntax
{
Name: GenericNameSyntax genericName,
})
{
continue;
}
if (genericName.Identifier.ValueText is not ("AddScoped" or "AddSingleton" or "AddTransient"))
{
continue;
}
foreach (var typeArgument in genericName.TypeArgumentList.Arguments)
{
var typeName = GetSimpleTypeName(typeArgument);
if (typeName.StartsWith("InProcess", StringComparison.Ordinal))
{
violations.TryAdd(typeName, FormatLocation(source, typeArgument));
}
}
}
}
AssertEmpty(
violations.Values,
"Host 不得定义或注册 InProcess 跨模块桥接;调用方应发送目标模块 Application 的 MediatR 契约。");
}
[Fact]
public void BusinessExceptionsShouldSpecifySemanticErrorCodes()
{
var repoRoot = FindRepoRoot();
var sourceRoot = Path.Combine(repoRoot.FullName, "src");
var violations = new List<string>();
foreach (var source in EnumerateSources(repoRoot, sourceRoot))
{
foreach (var creation in source.Root.DescendantNodes().OfType<ObjectCreationExpressionSyntax>())
{
if (!BusinessExceptionNames.Contains(GetSimpleTypeName(creation.Type)))
{
continue;
}
var arguments = creation.ArgumentList?.Arguments;
if (arguments is not { Count: >= 2 }
|| !IsErrorCodeMember(arguments.Value[0].Expression))
{
violations.Add(FormatLocation(source, creation));
}
}
}
AssertEmpty(
violations,
"BusinessException、NotFoundException、UnauthorizedException 必须把 ErrorCode.<语义> 作为第一个参数。");
}
[Fact]
public void SourceFilesShouldContainAtMostOneTopLevelClassRecordOrEnum()
{
var repoRoot = FindRepoRoot();
var violations = new List<string>();
foreach (var rootName in new[] { "src", "tests" })
{
var sourceRoot = Path.Combine(repoRoot.FullName, rootName);
foreach (var source in EnumerateSources(repoRoot, sourceRoot))
{
var declarations = source.Root.DescendantNodes()
.OfType<BaseTypeDeclarationSyntax>()
.Where(declaration => declaration.Parent is CompilationUnitSyntax or BaseNamespaceDeclarationSyntax)
.Where(declaration => declaration is ClassDeclarationSyntax
or RecordDeclarationSyntax
or EnumDeclarationSyntax)
.ToArray();
if (declarations.Length > 1)
{
violations.Add(
$"{source.RelativePath}:1 ({declarations.Length} 个顶级类型: " +
string.Join(", ", declarations.Select(GetDeclarationName)) + ")");
}
}
}
AssertEmpty(
violations,
"每个源文件最多只能包含一个顶级 class、record 或 enum;嵌套类型不受此限制。");
}
private static bool IsDbContextType(TypeSyntax? type)
=> type is not null
&& type.DescendantNodesAndSelf()
.OfType<IdentifierNameSyntax>()
.Any(identifier => identifier.Identifier.ValueText.EndsWith("DbContext", StringComparison.Ordinal));
private static bool IsErrorCodeMember(ExpressionSyntax expression)
{
if (expression is not MemberAccessExpressionSyntax memberAccess)
{
return false;
}
return memberAccess.Expression switch
{
IdentifierNameSyntax { Identifier.ValueText: "ErrorCode" } => true,
MemberAccessExpressionSyntax
{
Name: IdentifierNameSyntax { Identifier.ValueText: "ErrorCode" },
} => true,
_ => false,
};
}
private static string GetSimpleTypeName(TypeSyntax type)
=> type switch
{
IdentifierNameSyntax identifier => identifier.Identifier.ValueText,
GenericNameSyntax generic => generic.Identifier.ValueText,
QualifiedNameSyntax qualified => GetSimpleTypeName(qualified.Right),
AliasQualifiedNameSyntax alias => alias.Name.Identifier.ValueText,
_ => type.ToString(),
};
private static string GetDeclarationName(MemberDeclarationSyntax declaration)
=> declaration switch
{
ClassDeclarationSyntax type => type.Identifier.ValueText,
RecordDeclarationSyntax type => type.Identifier.ValueText,
EnumDeclarationSyntax type => type.Identifier.ValueText,
_ => declaration.Kind().ToString(),
};
private static IEnumerable<SourceFile> EnumerateSources(
DirectoryInfo repoRoot,
string sourceRoot)
{
return Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories)
.Where(path => !ContainsPathSegment(path, "bin") && !ContainsPathSegment(path, "obj"))
.Order(StringComparer.Ordinal)
.Select(path =>
{
var text = File.ReadAllText(path);
var tree = CSharpSyntaxTree.ParseText(
text,
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview),
path);
return new SourceFile(
Path.GetRelativePath(repoRoot.FullName, path).Replace('\\', '/'),
tree.GetCompilationUnitRoot());
});
}
private static string FormatLocation(SourceFile source, SyntaxNode node)
{
var line = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
return $"{source.RelativePath}:{line}";
}
private static string FormatLocation(SourceFile source, SyntaxToken token)
{
var line = token.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
return $"{source.RelativePath}:{line}";
}
private static void AssertEmpty(IEnumerable<string> violations, string message)
{
var ordered = violations.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
Assert.True(
ordered.Length == 0,
message + Environment.NewLine + string.Join(Environment.NewLine, ordered));
}
private static DirectoryInfo FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx")))
{
return current;
}
current = current.Parent;
}
throw new InvalidOperationException("无法定位 BuildFlow.slnx,架构测试无法确定仓库边界。");
}
private static bool ContainsPathSegment(string path, string segment)
=> path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Any(part => string.Equals(part, segment, StringComparison.Ordinal));
private sealed record SourceFile(string RelativePath, CompilationUnitSyntax Root);
}