Forge
csharpdeeb25a2
1using CascadeIDE.Models;
2using CascadeIDE.Services;
3using Microsoft.CodeAnalysis;
4
5namespace CascadeIDE.Features.Agent.Environment;
6
7public sealed record DiagnoseFilesOutcome(
8 bool Green,
9 int ErrorCount,
10 int WarningCount,
11 string Detail);
12
13/// <summary>
14/// In-proc Roslyn diagnostics for <see cref="VerifyRung.DiagnoseFiles"/> (ADR 0148): syntax/lexis via parser
15/// (as <see cref="CSharpLanguageService.GetDiagnosticsForFile"/>).
16/// Default — open tabs; when <c>diagnose_files_cs_scope = open_tabs_and_git_dirty_cs</c> adds <c>.cs</c> from
17/// <c>git diff --name-only</c> and staged (<c>--cached</c>) within workspace.
18/// </summary>
19public sealed class AgentRoslynDiagnoseFilesDiagnostics
20{
21 private readonly CSharpLanguageService? _language;
22 private readonly Func<IReadOnlyList<(string Path, string Content)>>? _openCsDocuments;
23 private readonly AgentEnvironmentLadderSettings _ladder;
24 private readonly IGitCommandRunner? _gitRunner;
25 private readonly Func<string?>? _getWorkspaceRoot;
26 private readonly Func<IReadOnlyList<string>>? _getWarmupCsFilePaths;
27
28 public AgentRoslynDiagnoseFilesDiagnostics(
29 CSharpLanguageService? language,
30 Func<IReadOnlyList<(string Path, string Content)>>? openCsDocuments,
31 AgentEnvironmentLadderSettings? ladderSettings = null,
32 IGitCommandRunner? gitRunner = null,
33 Func<string?>? getWorkspaceRoot = null,
34 Func<IReadOnlyList<string>>? getWarmupCsFilePaths = null)
35 {
36 _language = language;
37 _openCsDocuments = openCsDocuments;
38 _ladder = ladderSettings ?? new AgentEnvironmentLadderSettings();
39 _gitRunner = gitRunner;
40 _getWorkspaceRoot = getWorkspaceRoot;
41 _getWarmupCsFilePaths = getWarmupCsFilePaths;
42 }
43
44 public async Task<DiagnoseFilesOutcome> RunAsync(CancellationToken cancellationToken = default)
45 {
46 if (_language is null || _openCsDocuments is null)
47 return new(true, 0, 0, $"{VerifyRung.DiagnoseFiles} skipped (no language service)");
48
49 var docsDict = BuildDocumentMap(await CollectPathsAndContentAsync(cancellationToken).ConfigureAwait(false));
50
51 if (docsDict.Count == 0)
52 {
53 var scopeNote = AgentDiagnoseFilesCsScopeParser.IncludesGitDirtyWorktreeCs(_ladder.DiagnoseFilesCsScope)
54 ? "; no open .cs and git scope yielded none"
55 : _ladder.DiagnoseFilesIncludeWarmupCs
56 ? "; no open .cs and warmup scope yielded none"
57 : "";
58 return new(true, 0, 0, $"{VerifyRung.DiagnoseFiles} skipped (no .cs inputs{scopeNote})");
59 }
60
61 var errors = 0;
62 var warnings = 0;
63
64 foreach (var (path, content) in docsDict.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
65 {
66 cancellationToken.ThrowIfCancellationRequested();
67 IReadOnlyList<Diagnostic> raw;
68 try
69 {
70 raw = await Task.Run(
71 () => _language!.GetDiagnosticsForFile(path, content ?? "", cancellationToken),
72 cancellationToken).ConfigureAwait(false);
73 }
74 catch (OperationCanceledException)
75 {
76 throw;
77 }
78 catch
79 {
80 continue;
81 }
82
83 foreach (var d in raw)
84 {
85 if (d.Severity == DiagnosticSeverity.Error)
86 errors++;
87 else if (d.Severity == DiagnosticSeverity.Warning)
88 warnings++;
89 }
90 }
91
92 var green = errors == 0;
93 var detail = $"{VerifyRung.DiagnoseFiles}: {errors} errors, {warnings} warnings ({docsDict.Count} file(s))";
94 return new(green, errors, warnings, detail);
95 }
96
97 private async Task<IReadOnlyList<(string Path, string Content)>> CollectPathsAndContentAsync(
98 CancellationToken cancellationToken)
99 {
100 var resolver = _openCsDocuments!;
101 var list = resolver();
102 var merged = new List<(string Path, string Content)>(list ?? []);
103
104 if (AgentDiagnoseFilesCsScopeParser.IncludesGitDirtyWorktreeCs(_ladder.DiagnoseFilesCsScope)
105 && _gitRunner is not null
106 && _getWorkspaceRoot is not null)
107 {
108 var ws = _getWorkspaceRoot();
109 if (!string.IsNullOrWhiteSpace(ws) && Directory.Exists(ws))
110 await AppendGitDirtyCsFromDiskAsync(merged, ws, cancellationToken).ConfigureAwait(false);
111 }
112
113 if (_ladder.DiagnoseFilesIncludeWarmupCs && _getWarmupCsFilePaths is not null)
114 await AppendWarmupCsFromDiskAsync(merged, cancellationToken).ConfigureAwait(false);
115
116 return merged;
117 }
118
119 private async Task AppendGitDirtyCsFromDiskAsync(
120 List<(string Path, string Content)> merged,
121 string workspaceRootRaw,
122 CancellationToken cancellationToken)
123 {
124 var wsFull = Path.GetFullPath(workspaceRootRaw.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
125 var seen = new HashSet<string>(
126 merged.Select(t => Path.GetFullPath(t.Path)),
127 StringComparer.OrdinalIgnoreCase);
128
129 var unstaged = await GitNameOnlyAsync(wsFull, ["diff", "--name-only"], cancellationToken).ConfigureAwait(false);
130 var staged = await GitNameOnlyAsync(wsFull, ["diff", "--name-only", "--cached"], cancellationToken).ConfigureAwait(false);
131 var relCs = AgentDiagnoseFilesCsScopeParser.MergeGitNameOnlyOutputs(unstaged, staged);
132
133 var cap = Math.Max(1, _ladder.DiagnoseFilesGitDirtyMaxFiles);
134 var taken = 0;
135 foreach (var rel in relCs)
136 {
137 if (taken >= cap)
138 break;
139
140 if (!AgentDiagnoseFilesCsScopeParser.TryResolveWorkspaceCs(wsFull, rel, out var full))
141 continue;
142 if (!seen.Add(full))
143 continue;
144
145 string text;
146 try
147 {
148 text = await File.ReadAllTextAsync(full, cancellationToken).ConfigureAwait(false);
149 }
150 catch
151 {
152 continue;
153 }
154
155 merged.Add((full, text));
156 taken++;
157 }
158 }
159
160 private async Task AppendWarmupCsFromDiskAsync(
161 List<(string Path, string Content)> merged,
162 CancellationToken cancellationToken)
163 {
164 var paths = _getWarmupCsFilePaths!();
165 if (paths.Count == 0)
166 return;
167
168 var seen = new HashSet<string>(
169 merged.Select(t => Path.GetFullPath(t.Path)),
170 StringComparer.OrdinalIgnoreCase);
171
172 var cap = _ladder.DiagnoseFilesWarmupMaxFiles > 0
173 ? Math.Max(1, _ladder.DiagnoseFilesWarmupMaxFiles)
174 : Math.Max(1, paths.Count);
175
176 var taken = 0;
177 foreach (var path in paths)
178 {
179 if (taken >= cap)
180 break;
181
182 string full;
183 try
184 {
185 full = Path.GetFullPath(path.Trim());
186 }
187 catch
188 {
189 continue;
190 }
191
192 if (!seen.Add(full))
193 continue;
194
195 string text;
196 try
197 {
198 text = await File.ReadAllTextAsync(full, cancellationToken).ConfigureAwait(false);
199 }
200 catch
201 {
202 continue;
203 }
204
205 merged.Add((full, text));
206 taken++;
207 }
208 }
209
210 private async Task<string?> GitNameOnlyAsync(
211 string workingDirectory,
212 IReadOnlyList<string> gitArgsTail,
213 CancellationToken cancellationToken)
214 {
215 if (_gitRunner is null)
216 return null;
217
218 var args = new List<string> { "-c", "core.quotepath=false" };
219 args.AddRange(gitArgsTail);
220
221 var (success, _, output) =
222 await _gitRunner.RunAsync(args, workingDirectory, cancellationToken).ConfigureAwait(false);
223 return success ? output : null;
224 }
225
226 private static Dictionary<string, string> BuildDocumentMap(
227 IReadOnlyList<(string Path, string Content)> items)
228 {
229 var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
230 foreach (var (path, content) in items)
231 {
232 if (string.IsNullOrWhiteSpace(path))
233 continue;
234 dict[Path.GetFullPath(path.Trim())] = content ?? "";
235 }
236
237 return dict;
238 }
239}
240
View only · write via MCP/CIDE