| 1 | using CascadeIDE.Models; |
| 2 | using CascadeIDE.Services; |
| 3 | using Microsoft.CodeAnalysis; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Agent.Environment; |
| 6 | |
| 7 | public 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> |
| 19 | public 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 | |