| 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 0055 guards for Skia instrument pipeline boundaries and stage flow. |
| 11 | /// </summary> |
| 12 | [DiagnosticAnalyzer(LanguageNames.CSharp)] |
| 13 | public sealed class SkiaPipelineArchitectureAnalyzer : DiagnosticAnalyzer |
| 14 | { |
| 15 | public const string SkiaBoundaryId = "CASCOPE007"; |
| 16 | public const string CodeNavigationMapCompositorStageFlowId = "CASCOPE008"; |
| 17 | public const string LayoutBypassId = "CASCOPE009"; |
| 18 | public const string SkiaViewDomainLeakId = "CASCOPE010"; |
| 19 | |
| 20 | private static readonly DiagnosticDescriptor SkiaBoundaryRule = new( |
| 21 | SkiaBoundaryId, |
| 22 | "Skia instruments layer must stay UI-agnostic", |
| 23 | "File under Services/SkiaInstruments must not import '{0}'. Keep UI dependencies in Views/Surface layer.", |
| 24 | "Architecture", |
| 25 | DiagnosticSeverity.Error, |
| 26 | isEnabledByDefault: true, |
| 27 | description: "ADR 0055 boundary: Skia instrument contracts do not reference Avalonia/ViewModels."); |
| 28 | |
| 29 | private static readonly DiagnosticDescriptor CodeNavigationMapCompositorStageFlowRule = new( |
| 30 | CodeNavigationMapCompositorStageFlowId, |
| 31 | "Skia surface compositor must execute Intent -> Declutter -> Layout", |
| 32 | "{0}.Compose(...) must call _intentStage.Resolve, _declutterStage.Apply and _layoutStage.Layout", |
| 33 | "Architecture", |
| 34 | DiagnosticSeverity.Error, |
| 35 | isEnabledByDefault: true, |
| 36 | description: "ADR 0055/0057 canonical stage chain must be explicit in graph/chat compositors."); |
| 37 | |
| 38 | private static readonly DiagnosticDescriptor LayoutBypassRule = new( |
| 39 | LayoutBypassId, |
| 40 | "Layout engines must be used only in CodeNavigationMapLayoutStage", |
| 41 | "Direct usage of '{0}' is allowed only inside CodeNavigationMapLayoutStage to avoid pipeline bypass", |
| 42 | "Architecture", |
| 43 | DiagnosticSeverity.Error, |
| 44 | isEnabledByDefault: true, |
| 45 | description: "ADR 0055: layout selection happens in Layout stage, not in VM/compositor callers."); |
| 46 | |
| 47 | private static readonly DiagnosticDescriptor SkiaViewDomainLeakRule = new( |
| 48 | SkiaViewDomainLeakId, |
| 49 | "Skia views must not depend on CodeNavigationMap stage-domain APIs", |
| 50 | "View under Views/*Skia* must not reference '{0}'. Keep stage-domain logic in Services/Navigation.", |
| 51 | "Architecture", |
| 52 | DiagnosticSeverity.Error, |
| 53 | isEnabledByDefault: true, |
| 54 | description: "ADR 0055 boundary: Views render scene and do not depend on pipeline stage-domain contracts."); |
| 55 | |
| 56 | public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => |
| 57 | ImmutableArray.Create(SkiaBoundaryRule, CodeNavigationMapCompositorStageFlowRule, LayoutBypassRule, SkiaViewDomainLeakRule); |
| 58 | |
| 59 | public override void Initialize(AnalysisContext context) |
| 60 | { |
| 61 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
| 62 | context.EnableConcurrentExecution(); |
| 63 | context.RegisterSyntaxNodeAction(AnalyzeUsingDirective, SyntaxKind.UsingDirective); |
| 64 | context.RegisterSyntaxNodeAction(AnalyzeIdentifier, SyntaxKind.IdentifierName); |
| 65 | context.RegisterSyntaxNodeAction(AnalyzeMethodDeclaration, SyntaxKind.MethodDeclaration); |
| 66 | context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression); |
| 67 | } |
| 68 | |
| 69 | private static void AnalyzeUsingDirective(SyntaxNodeAnalysisContext context) |
| 70 | { |
| 71 | if (context.Node is not UsingDirectiveSyntax u) |
| 72 | return; |
| 73 | var path = context.Node.SyntaxTree.FilePath?.Replace('\\', '/') ?? ""; |
| 74 | |
| 75 | if (!path.Contains("/Services/SkiaInstruments/", StringComparison.OrdinalIgnoreCase)) |
| 76 | return; |
| 77 | |
| 78 | var name = u.Name?.ToString() ?? ""; |
| 79 | if (name.StartsWith("Avalonia", StringComparison.Ordinal) |
| 80 | || name.StartsWith("CascadeIDE.ViewModels", StringComparison.Ordinal)) |
| 81 | { |
| 82 | context.ReportDiagnostic(Diagnostic.Create(SkiaBoundaryRule, u.GetLocation(), name)); |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | private static void AnalyzeIdentifier(SyntaxNodeAnalysisContext context) |
| 87 | { |
| 88 | if (context.Node is not IdentifierNameSyntax id) |
| 89 | return; |
| 90 | |
| 91 | var path = context.Node.SyntaxTree.FilePath?.Replace('\\', '/') ?? ""; |
| 92 | if (!path.Contains("/Views/", StringComparison.OrdinalIgnoreCase) |
| 93 | || !path.Contains("Skia", StringComparison.OrdinalIgnoreCase)) |
| 94 | return; |
| 95 | |
| 96 | var name = id.Identifier.ValueText; |
| 97 | if (name is "ICodeNavigationMapIntentStage" |
| 98 | or "ICodeNavigationMapDeclutterStage" |
| 99 | or "ICodeNavigationMapLayoutStage" |
| 100 | or "CodeNavigationMapPipelineState" |
| 101 | or "CodeNavigationMapPipelineContext") |
| 102 | { |
| 103 | context.ReportDiagnostic(Diagnostic.Create(SkiaViewDomainLeakRule, id.GetLocation(), name)); |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) |
| 108 | { |
| 109 | if (context.Node is not MethodDeclarationSyntax method) |
| 110 | return; |
| 111 | if (!string.Equals(method.Identifier.ValueText, "Compose", StringComparison.Ordinal)) |
| 112 | return; |
| 113 | |
| 114 | var type = method.Parent as TypeDeclarationSyntax; |
| 115 | if (type is null) |
| 116 | return; |
| 117 | |
| 118 | var compositorName = type.Identifier.ValueText; |
| 119 | if (!string.Equals(compositorName, "CodeNavigationMapCompositor", StringComparison.Ordinal) |
| 120 | && !string.Equals(compositorName, "ChatSurfaceCompositor", StringComparison.Ordinal)) |
| 121 | return; |
| 122 | |
| 123 | var hasIntent = method.DescendantNodes().OfType<MemberAccessExpressionSyntax>() |
| 124 | .Any(m => m.Expression is IdentifierNameSyntax left |
| 125 | && left.Identifier.ValueText == "_intentStage" |
| 126 | && m.Name.Identifier.ValueText == "Resolve"); |
| 127 | var hasDeclutter = method.DescendantNodes().OfType<MemberAccessExpressionSyntax>() |
| 128 | .Any(m => m.Expression is IdentifierNameSyntax left |
| 129 | && left.Identifier.ValueText == "_declutterStage" |
| 130 | && m.Name.Identifier.ValueText == "Apply"); |
| 131 | var hasLayout = method.DescendantNodes().OfType<MemberAccessExpressionSyntax>() |
| 132 | .Any(m => m.Expression is IdentifierNameSyntax left |
| 133 | && left.Identifier.ValueText == "_layoutStage" |
| 134 | && m.Name.Identifier.ValueText == "Layout"); |
| 135 | |
| 136 | if (!hasIntent || !hasDeclutter || !hasLayout) |
| 137 | context.ReportDiagnostic(Diagnostic.Create(CodeNavigationMapCompositorStageFlowRule, method.Identifier.GetLocation(), compositorName)); |
| 138 | } |
| 139 | |
| 140 | private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) |
| 141 | { |
| 142 | if (context.Node is not ObjectCreationExpressionSyntax create) |
| 143 | return; |
| 144 | var typeName = create.Type.ToString(); |
| 145 | if (!string.Equals(typeName, "StarGraphLayoutEngine", StringComparison.Ordinal) |
| 146 | && !string.Equals(typeName, "ControlFlowGraphLayoutEngine", StringComparison.Ordinal)) |
| 147 | return; |
| 148 | |
| 149 | var enclosingType = create.Ancestors().OfType<TypeDeclarationSyntax>().FirstOrDefault()?.Identifier.ValueText ?? ""; |
| 150 | if (string.Equals(enclosingType, "CodeNavigationMapLayoutStage", StringComparison.Ordinal)) |
| 151 | return; |
| 152 | |
| 153 | context.ReportDiagnostic(Diagnostic.Create(LayoutBypassRule, create.GetLocation(), typeName)); |
| 154 | } |
| 155 | } |
| 156 | |