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