namespace BuildFlow.Architecture.Tests;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Xunit;
///
/// 全源码架构护栏。通过 Roslyn 分析语法树,避免只约束少量已知文件或依赖易误报的正则表达式。
///
public class SourceArchitectureTests
{
private static readonly HashSet 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();
foreach (var source in EnumerateSources(repoRoot, endpointRoot))
{
foreach (var declaration in source.Root.DescendantNodes().OfType())
{
var parameters = declaration.ParameterList?.Parameters
?? default(SeparatedSyntaxList);
foreach (var parameter in parameters)
{
if (IsDbContextType(parameter.Type))
{
violations.Add(FormatLocation(source, parameter));
}
}
foreach (var constructor in declaration.Members.OfType())
{
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(StringComparer.Ordinal);
foreach (var source in EnumerateSources(repoRoot, hostRoot))
{
foreach (var declaration in source.Root.DescendantNodes().OfType())
{
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())
{
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();
foreach (var source in EnumerateSources(repoRoot, sourceRoot))
{
foreach (var creation in source.Root.DescendantNodes().OfType())
{
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();
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()
.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()
.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 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 = System.IO.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 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 (System.IO.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);
}