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/// Enforces Workspace Health canonical pipeline:
11/// channel Build(context) -> surface compositor Compose(...).
12/// </summary>
13[DiagnosticAnalyzer(LanguageNames.CSharp)]
14public sealed class WorkspaceHealthPipelineAnalyzer : DiagnosticAnalyzer
15{
16 public const string LegacyGetSnapshotId = "CASCOPE004";
17 public const string LegacySegmentBuilderId = "CASCOPE005";
18 public const string DirectIdeHealthBuildOutsidePipelineFileId = "CASCOPE019";
19
20 private static readonly DiagnosticDescriptor LegacyGetSnapshotRule = new(
21 LegacyGetSnapshotId,
22 "Workspace Health: legacy GetSnapshot path is forbidden",
23 "Use Workspace Health channel Build(context) instead of legacy GetSnapshot() in MainWindowViewModel pipeline",
24 "Architecture",
25 DiagnosticSeverity.Error,
26 isEnabledByDefault: true,
27 description: "Legacy snapshot entrypoint was removed; use IWorkspaceHealthChannel.Build(...).");
28
29 private static readonly DiagnosticDescriptor LegacySegmentBuilderRule = new(
30 LegacySegmentBuilderId,
31 "Workspace Health: legacy SegmentBuilder usage is forbidden",
32 "Use WorkspaceHealthSurfaceCompositor.Compose(...) instead of WorkspaceHealthSegmentBuilder",
33 "Architecture",
34 DiagnosticSeverity.Error,
35 isEnabledByDefault: true,
36 description: "Legacy WorkspaceHealthSegmentBuilder was removed; compose via surface compositor.");
37
38 private static readonly DiagnosticDescriptor DirectIdeHealthBuildOutsidePipelineFileRule = new(
39 DirectIdeHealthBuildOutsidePipelineFileId,
40 "IDE Health: _workspaceHealth.Build only in MainWindowViewModel.IdeHealth",
41 "Call IIdeHealthChannel Build(...) only from MainWindowViewModel.IdeHealth (cached snapshot for getters); not from other MainWindowViewModel partials",
42 "Architecture",
43 DiagnosticSeverity.Error,
44 isEnabledByDefault: true,
45 description: "IDE Health input snapshot is built in RebuildIdeHealth; other files must use _lastIdeHealthInputSnapshot.");
46
47 public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
48 ImmutableArray.Create(LegacyGetSnapshotRule, LegacySegmentBuilderRule, DirectIdeHealthBuildOutsidePipelineFileRule);
49
50 public override void Initialize(AnalysisContext context)
51 {
52 context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
53 context.EnableConcurrentExecution();
54 context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
55 context.RegisterSyntaxNodeAction(AnalyzeIdentifier, SyntaxKind.IdentifierName);
56 }
57
58 private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
59 {
60 if (context.Node is not InvocationExpressionSyntax invocation)
61 return;
62 if (!IsMainWindowViewModelFile(context.Node.SyntaxTree.FilePath))
63 return;
64
65 if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
66 return;
67
68 if (string.Equals(memberAccess.Name.Identifier.ValueText, "Build", StringComparison.Ordinal)
69 && IsForbiddenWorkspaceHealthBuildInMainWindowPartial(context, memberAccess))
70 {
71 context.ReportDiagnostic(Diagnostic.Create(DirectIdeHealthBuildOutsidePipelineFileRule, memberAccess.Name.GetLocation()));
72 return;
73 }
74
75 if (!string.Equals(memberAccess.Name.Identifier.ValueText, "GetSnapshot", StringComparison.Ordinal))
76 return;
77 // DapDebug.GetSnapshot() is IdeDapDebugSession, not legacy workspace health.
78 if (memberAccess.Expression is IdentifierNameSyntax id &&
79 string.Equals(id.Identifier.ValueText, "DapDebug", StringComparison.Ordinal))
80 return;
81
82 context.ReportDiagnostic(Diagnostic.Create(LegacyGetSnapshotRule, memberAccess.Name.GetLocation()));
83 }
84
85 private static void AnalyzeIdentifier(SyntaxNodeAnalysisContext context)
86 {
87 if (context.Node is not IdentifierNameSyntax id)
88 return;
89 if (!string.Equals(id.Identifier.ValueText, "WorkspaceHealthSegmentBuilder", StringComparison.Ordinal))
90 return;
91
92 context.ReportDiagnostic(Diagnostic.Create(LegacySegmentBuilderRule, id.GetLocation()));
93 }
94
95 private static bool IsMainWindowViewModelFile(string? filePath)
96 {
97 if (string.IsNullOrWhiteSpace(filePath))
98 return false;
99 var normalized = filePath.Replace('\\', '/');
100 return normalized.Contains("/ViewModels/MainWindowViewModel", StringComparison.OrdinalIgnoreCase);
101 }
102
103 private static bool IsMainWindowViewModelIdeHealthPipelineFile(string? filePath)
104 {
105 if (string.IsNullOrWhiteSpace(filePath))
106 return false;
107 var n = filePath.Replace('\\', '/');
108 return n.EndsWith("MainWindowViewModel.IdeHealth.cs", StringComparison.OrdinalIgnoreCase);
109 }
110
111 private static bool IsForbiddenWorkspaceHealthBuildInMainWindowPartial(
112 SyntaxNodeAnalysisContext context,
113 MemberAccessExpressionSyntax buildAccess)
114 {
115 if (!IsMainWindowViewModelFile(context.Node.SyntaxTree.FilePath))
116 return false;
117 if (IsMainWindowViewModelIdeHealthPipelineFile(context.Node.SyntaxTree.FilePath))
118 return false;
119 if (buildAccess.Expression is not IdentifierNameSyntax { Identifier.ValueText: var id })
120 return false;
121 if (!string.Equals(id, "_workspaceHealth", StringComparison.Ordinal))
122 return false;
123 return true;
124 }
125}
126
View only · write via MCP/CIDE