| 1 | using System.Collections.Immutable; |
| 2 | using Microsoft.CodeAnalysis; |
| 3 | using Microsoft.CodeAnalysis.CSharp; |
| 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 5 | using Microsoft.CodeAnalysis.Diagnostics; |
| 6 | |
| 7 | namespace CascadeIDE.ArchitectureAnalyzers; |
| 8 | |
| 9 | /// <summary> |
| 10 | /// ADR 0097 + ADR 0102: |
| 11 | /// <c>Cockpit/ComputingUnits/*</c> — слой вычисления снимков/DTO, без прямой добычи внешних данных и без UI-зависимостей. |
| 12 | /// </summary> |
| 13 | [DiagnosticAnalyzer(LanguageNames.CSharp)] |
| 14 | public sealed class CockpitComputeUnitBoundaryAnalyzer : DiagnosticAnalyzer |
| 15 | { |
| 16 | public const string ForbiddenExternalAccessId = "CASCOPE020"; |
| 17 | public const string ForbiddenLayerDependencyId = "CASCOPE021"; |
| 18 | |
| 19 | private static readonly ImmutableHashSet<string> ForbiddenExternalTypeNames = |
| 20 | ImmutableHashSet.Create( |
| 21 | StringComparer.Ordinal, |
| 22 | "File", |
| 23 | "Directory", |
| 24 | "FileInfo", |
| 25 | "DirectoryInfo", |
| 26 | "Process", |
| 27 | "HttpClient", |
| 28 | "JsonDocument", |
| 29 | "JsonSerializer"); |
| 30 | |
| 31 | private static readonly DiagnosticDescriptor ForbiddenExternalAccessRule = new( |
| 32 | ForbiddenExternalAccessId, |
| 33 | "CCU не должен добывать внешние данные напрямую", |
| 34 | "В Cockpit/ComputingUnits запрещён прямой доступ к внешнему источнику через '{0}' (ADR 0097 + ADR 0102 DAL)", |
| 35 | "Architecture", |
| 36 | DiagnosticSeverity.Error, |
| 37 | isEnabledByDefault: true, |
| 38 | description: "CCU должен работать с подготовленными входными данными; fs/process/http/json parsing — в Data Acquisition Layer."); |
| 39 | |
| 40 | private static readonly DiagnosticDescriptor ForbiddenLayerDependencyRule = new( |
| 41 | ForbiddenLayerDependencyId, |
| 42 | "CCU не должен зависеть от UI-слоёв", |
| 43 | "В Cockpit/ComputingUnits запрещён using '{0}' (CCU должен быть независим от ViewModels/Views/UI)", |
| 44 | "Architecture", |
| 45 | DiagnosticSeverity.Error, |
| 46 | isEnabledByDefault: true, |
| 47 | description: "Compute units не импортируют UI-представления и UI-фреймворки."); |
| 48 | |
| 49 | public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => |
| 50 | ImmutableArray.Create(ForbiddenExternalAccessRule, ForbiddenLayerDependencyRule); |
| 51 | |
| 52 | public override void Initialize(AnalysisContext context) |
| 53 | { |
| 54 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
| 55 | context.EnableConcurrentExecution(); |
| 56 | context.RegisterSyntaxNodeAction(AnalyzeUsingDirective, SyntaxKind.UsingDirective); |
| 57 | context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); |
| 58 | context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression); |
| 59 | } |
| 60 | |
| 61 | private static void AnalyzeUsingDirective(SyntaxNodeAnalysisContext context) |
| 62 | { |
| 63 | if (!IsComputingUnitFile(context.Node.SyntaxTree.FilePath)) |
| 64 | return; |
| 65 | if (context.Node is not UsingDirectiveSyntax u) |
| 66 | return; |
| 67 | |
| 68 | var ns = u.Name?.ToString() ?? ""; |
| 69 | if (IsForbiddenUiNamespace(ns)) |
| 70 | context.ReportDiagnostic(Diagnostic.Create(ForbiddenLayerDependencyRule, u.GetLocation(), ns)); |
| 71 | } |
| 72 | |
| 73 | private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) |
| 74 | { |
| 75 | if (!IsComputingUnitFile(context.Node.SyntaxTree.FilePath)) |
| 76 | return; |
| 77 | if (context.Node is not InvocationExpressionSyntax invocation) |
| 78 | return; |
| 79 | if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) |
| 80 | return; |
| 81 | |
| 82 | if (memberAccess.Expression is IdentifierNameSyntax id && |
| 83 | ForbiddenExternalTypeNames.Contains(id.Identifier.ValueText)) |
| 84 | { |
| 85 | context.ReportDiagnostic(Diagnostic.Create( |
| 86 | ForbiddenExternalAccessRule, |
| 87 | memberAccess.GetLocation(), |
| 88 | id.Identifier.ValueText)); |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) |
| 93 | { |
| 94 | if (!IsComputingUnitFile(context.Node.SyntaxTree.FilePath)) |
| 95 | return; |
| 96 | if (context.Node is not ObjectCreationExpressionSyntax creation) |
| 97 | return; |
| 98 | var typeName = ExtractSimpleTypeName(creation.Type); |
| 99 | if (typeName is null || !ForbiddenExternalTypeNames.Contains(typeName)) |
| 100 | return; |
| 101 | |
| 102 | context.ReportDiagnostic(Diagnostic.Create( |
| 103 | ForbiddenExternalAccessRule, |
| 104 | creation.GetLocation(), |
| 105 | typeName)); |
| 106 | } |
| 107 | |
| 108 | private static string? ExtractSimpleTypeName(TypeSyntax typeSyntax) => |
| 109 | typeSyntax switch |
| 110 | { |
| 111 | IdentifierNameSyntax i => i.Identifier.ValueText, |
| 112 | QualifiedNameSyntax q => q.Right.Identifier.ValueText, |
| 113 | GenericNameSyntax g => g.Identifier.ValueText, |
| 114 | AliasQualifiedNameSyntax a => a.Name.Identifier.ValueText, |
| 115 | _ => null |
| 116 | }; |
| 117 | |
| 118 | private static bool IsComputingUnitFile(string? filePath) |
| 119 | { |
| 120 | if (string.IsNullOrWhiteSpace(filePath)) |
| 121 | return false; |
| 122 | var normalized = filePath.Replace('\\', '/'); |
| 123 | return normalized.Contains("/Cockpit/ComputingUnits/", StringComparison.OrdinalIgnoreCase); |
| 124 | } |
| 125 | |
| 126 | private static bool IsForbiddenUiNamespace(string namespaceName) |
| 127 | { |
| 128 | if (namespaceName.StartsWith("CascadeIDE.ViewModels", StringComparison.Ordinal)) |
| 129 | return true; |
| 130 | if (namespaceName.StartsWith("CascadeIDE.Views", StringComparison.Ordinal)) |
| 131 | return true; |
| 132 | if (namespaceName.StartsWith("CascadeIDE.Features.Ui", StringComparison.Ordinal)) |
| 133 | return true; |
| 134 | if (namespaceName.StartsWith("Avalonia", StringComparison.Ordinal)) |
| 135 | return true; |
| 136 | return false; |
| 137 | } |
| 138 | } |
| 139 | |