Forge
csharp4405de34
1using System.Collections.Immutable;
2using Microsoft.CodeAnalysis;
3using Microsoft.CodeAnalysis.CSharp;
4using Microsoft.CodeAnalysis.CSharp.Syntax;
5using Microsoft.CodeAnalysis.Diagnostics;
6
7namespace CascadeIDE.ArchitectureAnalyzers;
8
9/// <summary>
10/// ADR 0055 guards for Skia instrument pipeline boundaries and stage flow.
11/// </summary>
12[DiagnosticAnalyzer(LanguageNames.CSharp)]
13public 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
View only · write via MCP/CIDE