Forge
csharpdeeb25a2
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 0036: слои <c>Cockpit/Channels</c>, <c>Cockpit/Cds</c>, <c>Cockpit/Composition</c> не тянут Avalonia и не тянут <c>Features.UiChrome</c> для семантики зон;
11/// поверхность остаётся в <c>Cockpit/Surface</c> и <c>Views</c>.
12/// </summary>
13[DiagnosticAnalyzer(LanguageNames.CSharp)]
14public sealed class CockpitLayerArchitectureAnalyzer : DiagnosticAnalyzer
15{
16 public const string AvaloniaNamespaceId = "CASCOPE001";
17 public const string FeaturesUiChromeId = "CASCOPE002";
18
19 private static readonly DiagnosticDescriptor AvaloniaRule = new(
20 AvaloniaNamespaceId,
21 "Cockpit слой не должен ссылаться на Avalonia",
22 "Слой '{0}' (ADR 0036) не должен ссылаться на типы Avalonia UI; используйте Cockpit/Surface или Views (найдено: {1})",
23 "Architecture",
24 DiagnosticSeverity.Error,
25 isEnabledByDefault: true,
26 description: "Канал, CDS и композитор не импортируют знание о рендеринге Avalonia.");
27
28 private static readonly DiagnosticDescriptor FeaturesUiChromeRule = new(
29 FeaturesUiChromeId,
30 "Cockpit слой не должен импортировать CascadeIDE.Features.UiChrome",
31 "Слой '{0}' не должен использовать using CascadeIDE.Features.UiChrome; семантика зон для снимка дерева — через границу Cockpit/Surface (ADR 0036)",
32 "Architecture",
33 DiagnosticSeverity.Error,
34 isEnabledByDefault: true,
35 description: "Импорт Features.UiChrome в этих слоях смешивает канал/CDS/композитор с контролами зон.");
36
37 public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
38 ImmutableArray.Create(AvaloniaRule, FeaturesUiChromeRule);
39
40 public override void Initialize(AnalysisContext context)
41 {
42 context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
43 context.EnableConcurrentExecution();
44 context.RegisterSymbolAction(AnalyzeNamedTypeSymbol, SymbolKind.NamedType);
45 context.RegisterSyntaxNodeAction(AnalyzeUsingDirective, SyntaxKind.UsingDirective);
46 }
47
48 private static void AnalyzeUsingDirective(SyntaxNodeAnalysisContext context)
49 {
50 if (context.Node is not UsingDirectiveSyntax u)
51 return;
52 if (!TryGetRestrictedLayerFromPath(context.Node.SyntaxTree.FilePath, out var layer))
53 return;
54
55 var name = u.Name?.ToString() ?? "";
56 if (name.StartsWith("Avalonia", StringComparison.Ordinal) || name == "Avalonia")
57 {
58 context.ReportDiagnostic(Diagnostic.Create(AvaloniaRule, u.GetLocation(), layer, name));
59 return;
60 }
61
62 if (name.StartsWith("CascadeIDE.Features.UiChrome", StringComparison.Ordinal))
63 context.ReportDiagnostic(Diagnostic.Create(FeaturesUiChromeRule, u.GetLocation(), layer));
64 }
65
66 private static void AnalyzeNamedTypeSymbol(SymbolAnalysisContext context)
67 {
68 if (context.Symbol is not INamedTypeSymbol named)
69 return;
70 if (named.TypeKind == TypeKind.Enum)
71 return;
72 if (!IsRestrictedNamespace(named.ContainingNamespace))
73 return;
74
75 foreach (var iface in named.AllInterfaces)
76 CheckType(context, named, iface, named.DeclaringSyntaxReferences);
77
78 if (named.BaseType is { SpecialType: not SpecialType.System_Object })
79 CheckType(context, named, named.BaseType, named.DeclaringSyntaxReferences);
80
81 foreach (var member in named.GetMembers())
82 {
83 switch (member)
84 {
85 case IFieldSymbol f when !f.IsImplicitlyDeclared:
86 CheckType(context, named, f.Type, member.DeclaringSyntaxReferences);
87 break;
88 case IPropertySymbol p when !p.IsImplicitlyDeclared:
89 CheckType(context, named, p.Type, member.DeclaringSyntaxReferences);
90 break;
91 case IMethodSymbol m when m is { MethodKind: MethodKind.Ordinary, IsImplicitlyDeclared: false }:
92 CheckType(context, named, m.ReturnType, member.DeclaringSyntaxReferences);
93 foreach (var par in m.Parameters)
94 CheckType(context, named, par.Type, member.DeclaringSyntaxReferences);
95 break;
96 }
97 }
98 }
99
100 private static void CheckType(
101 SymbolAnalysisContext context,
102 INamedTypeSymbol declaring,
103 ITypeSymbol type,
104 ImmutableArray<Microsoft.CodeAnalysis.SyntaxReference> fallbackLocations)
105 {
106 VisitTypesForAvaloniaBan(type, context, declaring, fallbackLocations);
107 }
108
109 private static void VisitTypesForAvaloniaBan(
110 ITypeSymbol type,
111 SymbolAnalysisContext context,
112 INamedTypeSymbol declaring,
113 ImmutableArray<Microsoft.CodeAnalysis.SyntaxReference> fallbackLocations)
114 {
115 switch (type)
116 {
117 case IArrayTypeSymbol arr:
118 VisitTypesForAvaloniaBan(arr.ElementType, context, declaring, fallbackLocations);
119 return;
120 case ITypeParameterSymbol:
121 return;
122 case INamedTypeSymbol named:
123 if (named.IsGenericType)
124 {
125 foreach (var arg in named.TypeArguments)
126 VisitTypesForAvaloniaBan(arg, context, declaring, fallbackLocations);
127 }
128
129 if (IsAvaloniaUiFamily(named))
130 {
131 var location = PickLocation(fallbackLocations);
132 if (location is not null)
133 {
134 context.ReportDiagnostic(Diagnostic.Create(
135 AvaloniaRule,
136 location,
137 NamespaceLayer(declaring),
138 named.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)));
139 }
140 }
141
142 return;
143 }
144 }
145
146 private static Location? PickLocation(ImmutableArray<Microsoft.CodeAnalysis.SyntaxReference> refs)
147 {
148 foreach (var r in refs)
149 {
150 var syntax = r.GetSyntax();
151 if (syntax is null)
152 continue;
153 var loc = syntax.GetLocation();
154 if (loc.IsInSource)
155 return loc;
156 }
157
158 return null;
159 }
160
161 private static bool IsAvaloniaUiFamily(ITypeSymbol type)
162 {
163 if (type is not INamedTypeSymbol named)
164 return false;
165
166 for (var ns = type.ContainingNamespace; ns != null && !ns.IsGlobalNamespace; ns = ns.ContainingNamespace)
167 {
168 if (ns.Name == "Avalonia")
169 return true;
170 }
171
172 var asm = named.ContainingAssembly?.Name ?? "";
173 if (asm.Equals("Avalonia", StringComparison.OrdinalIgnoreCase) || asm.StartsWith("Avalonia.", StringComparison.OrdinalIgnoreCase))
174 return true;
175 if (asm.StartsWith("Dock.Avalonia", StringComparison.OrdinalIgnoreCase))
176 return true;
177
178 return false;
179 }
180
181 private static bool IsRestrictedNamespace(INamespaceSymbol? ns)
182 {
183 if (ns is null)
184 return false;
185 var display = ns.ToDisplayString();
186 return display.StartsWith("CascadeIDE.Cockpit.Channels", StringComparison.Ordinal)
187 || display.StartsWith("CascadeIDE.Cockpit.Cds", StringComparison.Ordinal)
188 || display.StartsWith("CascadeIDE.Cockpit.Composition", StringComparison.Ordinal);
189 }
190
191 private static string NamespaceLayer(INamedTypeSymbol named)
192 {
193 var d = named.ContainingNamespace?.ToDisplayString() ?? "";
194 if (d.StartsWith("CascadeIDE.Cockpit.Channels", StringComparison.Ordinal))
195 return "Channels";
196 if (d.StartsWith("CascadeIDE.Cockpit.Cds", StringComparison.Ordinal))
197 return "Cds";
198 if (d.StartsWith("CascadeIDE.Cockpit.Composition", StringComparison.Ordinal))
199 return "Composition";
200 return "Cockpit";
201 }
202
203 private static bool TryGetRestrictedLayerFromPath(string? filePath, out string layer)
204 {
205 layer = "";
206 if (string.IsNullOrEmpty(filePath))
207 return false;
208
209 var normalized = filePath!.Replace('\\', '/');
210 if (normalized.Contains("/Cockpit/Channels/", StringComparison.OrdinalIgnoreCase))
211 {
212 layer = "Channels";
213 return true;
214 }
215
216 if (normalized.Contains("/Cockpit/Cds/", StringComparison.OrdinalIgnoreCase))
217 {
218 layer = "Cds";
219 return true;
220 }
221
222 if (normalized.Contains("/Cockpit/Composition/", StringComparison.OrdinalIgnoreCase))
223 {
224 layer = "Composition";
225 return true;
226 }
227
228 return false;
229 }
230}
231
View only · write via MCP/CIDE