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 0046: прямые присваивания intent-флагам кабины (P/M колонки главного окна) только в согласованных точках,
11/// чтобы политика CDS и VM не расходились незаметно.
12/// </summary>
13[DiagnosticAnalyzer(LanguageNames.CSharp)]
14public sealed class CockpitIntentPropertyAssignmentAnalyzer : DiagnosticAnalyzer
15{
16 public const string DiagnosticId = "CASCOPE003";
17
18 private const string MainVmMetadataName = "CascadeIDE.ViewModels.MainWindowViewModel";
19
20 private static readonly DiagnosticDescriptor Rule = new(
21 DiagnosticId,
22 "Intent раскладки кабины: присваивание вне белого списка",
23 "Прямое присваивание '{0}' вне разрешённых файлов; используйте ApplyPfdRegionExpanded / ApplyMfdRegionExpanded, relay-команды или слой UI-режима (см. ADR 0046, CockpitPresentationLayoutPolicy).",
24 "Architecture",
25 DiagnosticSeverity.Error,
26 isEnabledByDefault: true,
27 description: "Свойства видимости P/M — точка согласования с пресетом presentation; новые присваивания только через белый список или расширение анализатора.");
28
29 public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
30 ImmutableArray.Create(Rule);
31
32 public override void Initialize(AnalysisContext context)
33 {
34 context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
35 context.EnableConcurrentExecution();
36 context.RegisterSyntaxNodeAction(AnalyzeAssignment, SyntaxKind.SimpleAssignmentExpression);
37 }
38
39 private static void AnalyzeAssignment(SyntaxNodeAnalysisContext context)
40 {
41 if (context.Node is not AssignmentExpressionSyntax assign || assign.Kind() != SyntaxKind.SimpleAssignmentExpression)
42 return;
43
44 if (!IsMainWindowViewModelFile(context.Node.SyntaxTree.FilePath))
45 return;
46
47 var model = context.SemanticModel;
48 var symbol = model.GetSymbolInfo(assign.Left).Symbol;
49 if (symbol is null)
50 return;
51
52 if (!IsCockpitIntentMember(symbol, out var displayName))
53 return;
54
55 if (IsAllowedSourcePath(context.Node.SyntaxTree.FilePath))
56 return;
57
58 context.ReportDiagnostic(Diagnostic.Create(Rule, assign.GetLocation(), displayName));
59 }
60
61 private static bool IsMainWindowViewModelFile(string? path)
62 {
63 if (string.IsNullOrEmpty(path))
64 return false;
65 var n = path!.Replace('\\', '/');
66 return n.Contains("/ViewModels/MainWindowViewModel", StringComparison.OrdinalIgnoreCase);
67 }
68
69 private static bool IsCockpitIntentMember(ISymbol symbol, out string displayName)
70 {
71 displayName = symbol.Name;
72 if (symbol is IPropertySymbol or IFieldSymbol)
73 {
74 if (!IsMainWindowViewModel(symbol.ContainingType))
75 {
76 displayName = "";
77 return false;
78 }
79
80 return symbol.Name switch
81 {
82 "IsPfdRegionExpanded" or "IsMfdRegionExpanded" => true,
83 "_isPfdRegionExpanded" or "_isMfdRegionExpanded" => true,
84 _ => false,
85 };
86 }
87
88 displayName = "";
89 return false;
90 }
91
92 private static bool IsMainWindowViewModel(INamedTypeSymbol? type)
93 {
94 if (type is null)
95 return false;
96 return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == $"global::{MainVmMetadataName}";
97 }
98
99 private static bool IsAllowedSourcePath(string? path)
100 {
101 if (string.IsNullOrEmpty(path))
102 return false;
103
104 var p = path!.Replace('\\', '/');
105
106 // Тесты и сэмплы могут имитировать VM или поднимать фиктивные присваивания.
107 if (p.Contains("/CascadeIDE.Tests/", StringComparison.OrdinalIgnoreCase)
108 || p.Contains("/samples/", StringComparison.OrdinalIgnoreCase))
109 return true;
110
111 static bool EndsWithFile(string full, string fileName) =>
112 full.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase);
113
114 return EndsWithFile(p, "MainWindowViewModel.PresentationLayoutAuthority.cs")
115 || EndsWithFile(p, "MainWindowViewModel.RelayCommands.cs")
116 || EndsWithFile(p, "MainWindowViewModel.cs")
117 || EndsWithFile(p, "MainWindowViewModel.ShellConstruction.cs")
118 || EndsWithFile(p, "MainWindowViewModel.ShellState.cs")
119 || EndsWithFile(p, "MainWindowViewModel.UiGitWorkspace.cs");
120 }
121}
122
View only · write via MCP/CIDE