276 lines
10 KiB
C#
276 lines
10 KiB
C#
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);
|
||
}
|