@ctxo/lang-csharp 0.7.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +800 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/tools/ctxo-roslyn/Program.cs +637 -0
- package/tools/ctxo-roslyn/ctxo-roslyn.csproj +14 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
using System.Text.Json;
|
|
2
|
+
using System.Text.Json.Serialization;
|
|
3
|
+
using Microsoft.Build.Locator;
|
|
4
|
+
using Microsoft.CodeAnalysis;
|
|
5
|
+
using Microsoft.CodeAnalysis.CSharp;
|
|
6
|
+
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
7
|
+
using Microsoft.CodeAnalysis.FindSymbols;
|
|
8
|
+
using Microsoft.CodeAnalysis.MSBuild;
|
|
9
|
+
using Microsoft.CodeAnalysis.Operations;
|
|
10
|
+
|
|
11
|
+
// ── Entry Point ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
MSBuildLocator.RegisterDefaults();
|
|
14
|
+
|
|
15
|
+
var jsonOptions = new JsonSerializerOptions
|
|
16
|
+
{
|
|
17
|
+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
18
|
+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
19
|
+
WriteIndented = false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray();
|
|
23
|
+
var keepAlive = cliArgs.Contains("--keep-alive");
|
|
24
|
+
var solutionPath = cliArgs.FirstOrDefault(a => !a.StartsWith("--"));
|
|
25
|
+
|
|
26
|
+
if (string.IsNullOrEmpty(solutionPath))
|
|
27
|
+
{
|
|
28
|
+
WriteError("Usage: dotnet run -- <solution.sln> [--keep-alive]");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!File.Exists(solutionPath))
|
|
33
|
+
{
|
|
34
|
+
WriteError($"Solution not found: {solutionPath}");
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!;
|
|
39
|
+
|
|
40
|
+
// Load solution
|
|
41
|
+
WriteProgress("Loading solution...");
|
|
42
|
+
using var workspace = MSBuildWorkspace.Create();
|
|
43
|
+
workspace.WorkspaceFailed += (_, e) =>
|
|
44
|
+
{
|
|
45
|
+
if (e.Diagnostic.Kind == WorkspaceDiagnosticKind.Failure)
|
|
46
|
+
WriteStderr($"Workspace: {e.Diagnostic.Message}");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
var solution = await workspace.OpenSolutionAsync(solutionPath);
|
|
50
|
+
var projectCount = solution.Projects.Count();
|
|
51
|
+
var fileCount = solution.Projects.SelectMany(p => p.Documents).Count(d => IsUserCsFile(d.FilePath));
|
|
52
|
+
|
|
53
|
+
WriteProgress($"Solution loaded: {projectCount} projects, {fileCount} .cs files");
|
|
54
|
+
|
|
55
|
+
// Build compilations
|
|
56
|
+
var compilations = new Dictionary<ProjectId, Compilation>();
|
|
57
|
+
foreach (var project in solution.Projects)
|
|
58
|
+
{
|
|
59
|
+
var compilation = await project.GetCompilationAsync();
|
|
60
|
+
if (compilation != null)
|
|
61
|
+
compilations[project.Id] = compilation;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
WriteProgress($"Compilations ready: {compilations.Count} projects");
|
|
65
|
+
|
|
66
|
+
if (keepAlive)
|
|
67
|
+
{
|
|
68
|
+
await RunKeepAlive(solution, compilations);
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
{
|
|
72
|
+
await RunBatchIndex(solution, compilations);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return 0;
|
|
76
|
+
|
|
77
|
+
// ── Batch Mode ───────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async Task RunBatchIndex(Solution sol, Dictionary<ProjectId, Compilation> comps)
|
|
80
|
+
{
|
|
81
|
+
var totalFiles = 0;
|
|
82
|
+
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
83
|
+
|
|
84
|
+
foreach (var project in sol.Projects)
|
|
85
|
+
{
|
|
86
|
+
if (!comps.TryGetValue(project.Id, out var compilation)) continue;
|
|
87
|
+
|
|
88
|
+
foreach (var document in project.Documents)
|
|
89
|
+
{
|
|
90
|
+
// BUG 2 FIX: Skip generated/obj/bin files
|
|
91
|
+
if (!IsUserCsFile(document.FilePath)) continue;
|
|
92
|
+
|
|
93
|
+
var relativePath = Path.GetRelativePath(solutionDir, document.FilePath!).Replace('\\', '/');
|
|
94
|
+
var result = await AnalyzeDocument(document, compilation, sol, relativePath);
|
|
95
|
+
WriteLine(JsonSerializer.Serialize(result, jsonOptions));
|
|
96
|
+
totalFiles++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Project dependency graph
|
|
101
|
+
var projectGraph = BuildProjectGraph(sol);
|
|
102
|
+
WriteLine(JsonSerializer.Serialize(projectGraph, jsonOptions));
|
|
103
|
+
|
|
104
|
+
// Done
|
|
105
|
+
sw.Stop();
|
|
106
|
+
var done = new { type = "done", totalFiles, elapsed = $"{sw.Elapsed.TotalSeconds:F1}s" };
|
|
107
|
+
WriteLine(JsonSerializer.Serialize(done, jsonOptions));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Keep-Alive Mode ──────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
async Task RunKeepAlive(Solution sol, Dictionary<ProjectId, Compilation> comps)
|
|
113
|
+
{
|
|
114
|
+
var ready = new { type = "ready", projectCount, fileCount };
|
|
115
|
+
WriteLine(JsonSerializer.Serialize(ready, jsonOptions));
|
|
116
|
+
|
|
117
|
+
string? line;
|
|
118
|
+
while ((line = Console.ReadLine()) != null)
|
|
119
|
+
{
|
|
120
|
+
try
|
|
121
|
+
{
|
|
122
|
+
var request = JsonSerializer.Deserialize<KeepAliveRequest>(line, jsonOptions);
|
|
123
|
+
if (request?.File == null) continue;
|
|
124
|
+
|
|
125
|
+
var fullPath = Path.GetFullPath(Path.Combine(solutionDir, request.File));
|
|
126
|
+
|
|
127
|
+
// Find the document
|
|
128
|
+
var doc = sol.Projects
|
|
129
|
+
.SelectMany(p => p.Documents)
|
|
130
|
+
.FirstOrDefault(d => d.FilePath != null &&
|
|
131
|
+
string.Equals(Path.GetFullPath(d.FilePath), fullPath, StringComparison.OrdinalIgnoreCase));
|
|
132
|
+
|
|
133
|
+
if (doc == null)
|
|
134
|
+
{
|
|
135
|
+
WriteError($"File not found in solution: {request.File}");
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Incremental update if file content changed
|
|
140
|
+
if (File.Exists(fullPath))
|
|
141
|
+
{
|
|
142
|
+
var newText = Microsoft.CodeAnalysis.Text.SourceText.From(File.ReadAllText(fullPath));
|
|
143
|
+
sol = sol.WithDocumentText(doc.Id, newText);
|
|
144
|
+
|
|
145
|
+
// Recompile affected project
|
|
146
|
+
var project = sol.GetProject(doc.Project.Id);
|
|
147
|
+
if (project != null)
|
|
148
|
+
{
|
|
149
|
+
var newComp = await project.GetCompilationAsync();
|
|
150
|
+
if (newComp != null)
|
|
151
|
+
comps[project.Id] = newComp;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
doc = sol.GetDocument(doc.Id)!;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!comps.TryGetValue(doc.Project.Id, out var compilation)) continue;
|
|
158
|
+
|
|
159
|
+
var relativePath = Path.GetRelativePath(solutionDir, doc.FilePath!).Replace('\\', '/');
|
|
160
|
+
var result = await AnalyzeDocument(doc, compilation, sol, relativePath);
|
|
161
|
+
WriteLine(JsonSerializer.Serialize(result, jsonOptions));
|
|
162
|
+
}
|
|
163
|
+
catch (Exception ex)
|
|
164
|
+
{
|
|
165
|
+
WriteError($"Keep-alive error: {ex.Message}");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Document Analysis ────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
async Task<FileResult> AnalyzeDocument(Document document, Compilation compilation, Solution sol, string relativePath)
|
|
173
|
+
{
|
|
174
|
+
var semanticModel = await document.GetSemanticModelAsync();
|
|
175
|
+
var syntaxRoot = await document.GetSyntaxRootAsync();
|
|
176
|
+
|
|
177
|
+
if (semanticModel == null || syntaxRoot == null)
|
|
178
|
+
return new FileResult { Type = "file", File = relativePath };
|
|
179
|
+
|
|
180
|
+
var symbols = ExtractSymbols(syntaxRoot, semanticModel, relativePath);
|
|
181
|
+
var edges = ExtractEdges(syntaxRoot, semanticModel, compilation, relativePath);
|
|
182
|
+
var complexity = ExtractComplexity(syntaxRoot, semanticModel, relativePath);
|
|
183
|
+
|
|
184
|
+
return new FileResult
|
|
185
|
+
{
|
|
186
|
+
Type = "file",
|
|
187
|
+
File = relativePath,
|
|
188
|
+
Symbols = symbols,
|
|
189
|
+
Edges = edges,
|
|
190
|
+
Complexity = complexity,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Symbol Extraction ────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
List<CtxoSymbol> ExtractSymbols(SyntaxNode root, SemanticModel model, string filePath)
|
|
197
|
+
{
|
|
198
|
+
var symbols = new List<CtxoSymbol>();
|
|
199
|
+
|
|
200
|
+
foreach (var node in root.DescendantNodes())
|
|
201
|
+
{
|
|
202
|
+
ISymbol? symbol = node switch
|
|
203
|
+
{
|
|
204
|
+
ClassDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
205
|
+
StructDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
206
|
+
RecordDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
207
|
+
InterfaceDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
208
|
+
EnumDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
209
|
+
DelegateDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
210
|
+
MethodDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
211
|
+
ConstructorDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
212
|
+
PropertyDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
213
|
+
FieldDeclarationSyntax field => field.Declaration.Variables.FirstOrDefault() is { } v
|
|
214
|
+
? model.GetDeclaredSymbol(v) : null,
|
|
215
|
+
EventDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
216
|
+
EnumMemberDeclarationSyntax => model.GetDeclaredSymbol(node),
|
|
217
|
+
_ => null,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (symbol == null) continue;
|
|
221
|
+
|
|
222
|
+
var kind = MapSymbolKind(symbol);
|
|
223
|
+
if (kind == null) continue;
|
|
224
|
+
|
|
225
|
+
// BUG 1 FIX: Use syntax node span for full body range, not just declaration line
|
|
226
|
+
var nodeSpan = node.GetLocation().GetLineSpan();
|
|
227
|
+
|
|
228
|
+
// BUG 3 FIX: Use type name for constructors instead of .ctor
|
|
229
|
+
var qualifiedName = GetQualifiedName(symbol);
|
|
230
|
+
|
|
231
|
+
symbols.Add(new CtxoSymbol
|
|
232
|
+
{
|
|
233
|
+
SymbolId = $"{filePath}::{qualifiedName}::{kind}",
|
|
234
|
+
Name = qualifiedName,
|
|
235
|
+
Kind = kind,
|
|
236
|
+
StartLine = nodeSpan.StartLinePosition.Line,
|
|
237
|
+
EndLine = nodeSpan.EndLinePosition.Line,
|
|
238
|
+
StartOffset = node.Span.Start,
|
|
239
|
+
EndOffset = node.Span.End,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return symbols;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Edge Extraction (IOperation-based) ───────────────────────────────
|
|
247
|
+
|
|
248
|
+
List<CtxoEdge> ExtractEdges(
|
|
249
|
+
SyntaxNode root, SemanticModel model, Compilation compilation, string filePath)
|
|
250
|
+
{
|
|
251
|
+
var edges = new List<CtxoEdge>();
|
|
252
|
+
var seen = new HashSet<string>();
|
|
253
|
+
|
|
254
|
+
// 1. Inheritance edges: extends + implements
|
|
255
|
+
foreach (var typeDecl in root.DescendantNodes().OfType<TypeDeclarationSyntax>())
|
|
256
|
+
{
|
|
257
|
+
if (model.GetDeclaredSymbol(typeDecl) is not INamedTypeSymbol typeSymbol) continue;
|
|
258
|
+
var fromId = $"{filePath}::{GetQualifiedName(typeSymbol)}::{MapSymbolKind(typeSymbol)}";
|
|
259
|
+
|
|
260
|
+
// BaseType -> extends
|
|
261
|
+
if (typeSymbol.BaseType != null &&
|
|
262
|
+
typeSymbol.BaseType.SpecialType != SpecialType.System_Object &&
|
|
263
|
+
typeSymbol.BaseType.SpecialType != SpecialType.System_ValueType)
|
|
264
|
+
{
|
|
265
|
+
var toId = SymbolToId(typeSymbol.BaseType);
|
|
266
|
+
if (toId != null)
|
|
267
|
+
AddEdge(edges, seen, fromId, toId, "extends");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Interfaces -> implements
|
|
271
|
+
foreach (var iface in typeSymbol.Interfaces)
|
|
272
|
+
{
|
|
273
|
+
var toId = SymbolToId(iface);
|
|
274
|
+
if (toId != null)
|
|
275
|
+
AddEdge(edges, seen, fromId, toId, "implements");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// BUG 5 FIX: Resolve using directives to actual type references, not just namespaces
|
|
280
|
+
// Walk all identifier nodes and resolve cross-file type usages as imports edges
|
|
281
|
+
var fileTypes = new HashSet<string>();
|
|
282
|
+
foreach (var typeDecl in root.DescendantNodes().OfType<TypeDeclarationSyntax>())
|
|
283
|
+
{
|
|
284
|
+
if (model.GetDeclaredSymbol(typeDecl) is INamedTypeSymbol ts)
|
|
285
|
+
fileTypes.Add(GetQualifiedName(ts));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
var firstTypeId = root.DescendantNodes().OfType<TypeDeclarationSyntax>().FirstOrDefault() is { } ftd
|
|
289
|
+
&& model.GetDeclaredSymbol(ftd) is INamedTypeSymbol ft
|
|
290
|
+
? $"{filePath}::{GetQualifiedName(ft)}::{MapSymbolKind(ft)}"
|
|
291
|
+
: null;
|
|
292
|
+
|
|
293
|
+
if (firstTypeId != null)
|
|
294
|
+
{
|
|
295
|
+
// Find all type references in signatures (base lists already covered above)
|
|
296
|
+
foreach (var typeRef in root.DescendantNodes().OfType<IdentifierNameSyntax>())
|
|
297
|
+
{
|
|
298
|
+
var refSymbol = model.GetSymbolInfo(typeRef).Symbol;
|
|
299
|
+
if (refSymbol is INamedTypeSymbol referencedType &&
|
|
300
|
+
referencedType.Locations.FirstOrDefault()?.IsInSource == true)
|
|
301
|
+
{
|
|
302
|
+
var refName = GetQualifiedName(referencedType);
|
|
303
|
+
if (!fileTypes.Contains(refName)) // cross-file reference
|
|
304
|
+
{
|
|
305
|
+
var toId = SymbolToId(referencedType);
|
|
306
|
+
if (toId != null)
|
|
307
|
+
AddEdge(edges, seen, firstTypeId, toId, "imports");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 3. Method body analysis: calls + uses (via IOperation tree)
|
|
314
|
+
foreach (var methodDecl in root.DescendantNodes().OfType<BaseMethodDeclarationSyntax>())
|
|
315
|
+
{
|
|
316
|
+
if (model.GetDeclaredSymbol(methodDecl) is not IMethodSymbol methodSymbol) continue;
|
|
317
|
+
var fromId = $"{filePath}::{GetQualifiedName(methodSymbol)}::{MapSymbolKind(methodSymbol)}";
|
|
318
|
+
|
|
319
|
+
var body = (SyntaxNode?)methodDecl switch
|
|
320
|
+
{
|
|
321
|
+
MethodDeclarationSyntax m => (SyntaxNode?)m.Body ?? m.ExpressionBody,
|
|
322
|
+
ConstructorDeclarationSyntax c => (SyntaxNode?)c.Body ?? c.ExpressionBody,
|
|
323
|
+
_ => null,
|
|
324
|
+
};
|
|
325
|
+
if (body == null) continue;
|
|
326
|
+
|
|
327
|
+
var operation = model.GetOperation(body);
|
|
328
|
+
if (operation == null) continue;
|
|
329
|
+
|
|
330
|
+
foreach (var op in EnumerateOperations(operation))
|
|
331
|
+
{
|
|
332
|
+
switch (op)
|
|
333
|
+
{
|
|
334
|
+
case IInvocationOperation invocation:
|
|
335
|
+
{
|
|
336
|
+
var target = invocation.TargetMethod.OriginalDefinition;
|
|
337
|
+
var toId = SymbolToId(target);
|
|
338
|
+
if (toId != null)
|
|
339
|
+
AddEdge(edges, seen, fromId, toId, "calls");
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case IObjectCreationOperation creation:
|
|
343
|
+
{
|
|
344
|
+
if (creation.Type is INamedTypeSymbol createdType)
|
|
345
|
+
{
|
|
346
|
+
var toId = SymbolToId(createdType);
|
|
347
|
+
if (toId != null)
|
|
348
|
+
AddEdge(edges, seen, fromId, toId, "uses");
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case IPropertyReferenceOperation propRef:
|
|
353
|
+
{
|
|
354
|
+
if (!SymbolEqualityComparer.Default.Equals(propRef.Property.ContainingType, methodSymbol.ContainingType))
|
|
355
|
+
{
|
|
356
|
+
var toId = SymbolToId(propRef.Property);
|
|
357
|
+
if (toId != null)
|
|
358
|
+
AddEdge(edges, seen, fromId, toId, "uses");
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case IFieldReferenceOperation fieldRef:
|
|
363
|
+
{
|
|
364
|
+
if (!SymbolEqualityComparer.Default.Equals(fieldRef.Field.ContainingType, methodSymbol.ContainingType))
|
|
365
|
+
{
|
|
366
|
+
var toId = SymbolToId(fieldRef.Field);
|
|
367
|
+
if (toId != null)
|
|
368
|
+
AddEdge(edges, seen, fromId, toId, "uses");
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return edges;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Complexity ───────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
// BUG 4 FIX: Process all method-like declarations (methods + constructors + accessors)
|
|
382
|
+
List<CtxoComplexity> ExtractComplexity(SyntaxNode root, SemanticModel model, string filePath)
|
|
383
|
+
{
|
|
384
|
+
var metrics = new List<CtxoComplexity>();
|
|
385
|
+
|
|
386
|
+
foreach (var methodDecl in root.DescendantNodes().OfType<BaseMethodDeclarationSyntax>())
|
|
387
|
+
{
|
|
388
|
+
if (model.GetDeclaredSymbol(methodDecl) is not IMethodSymbol methodSymbol) continue;
|
|
389
|
+
|
|
390
|
+
var body = (SyntaxNode?)methodDecl switch
|
|
391
|
+
{
|
|
392
|
+
MethodDeclarationSyntax m => (SyntaxNode?)m.Body ?? m.ExpressionBody,
|
|
393
|
+
ConstructorDeclarationSyntax c => (SyntaxNode?)c.Body ?? c.ExpressionBody,
|
|
394
|
+
_ => null,
|
|
395
|
+
};
|
|
396
|
+
if (body == null) continue;
|
|
397
|
+
|
|
398
|
+
int cyclomatic = 1;
|
|
399
|
+
int cognitive = 0;
|
|
400
|
+
|
|
401
|
+
void Walk(SyntaxNode node, int depth)
|
|
402
|
+
{
|
|
403
|
+
bool increments = node is IfStatementSyntax
|
|
404
|
+
or ConditionalExpressionSyntax
|
|
405
|
+
or SwitchStatementSyntax or SwitchExpressionSyntax
|
|
406
|
+
or ForStatementSyntax or ForEachStatementSyntax
|
|
407
|
+
or WhileStatementSyntax or DoStatementSyntax
|
|
408
|
+
or CatchClauseSyntax;
|
|
409
|
+
|
|
410
|
+
// BUG 8 FIX: Count else clauses in cognitive complexity
|
|
411
|
+
bool isElse = node is ElseClauseSyntax;
|
|
412
|
+
|
|
413
|
+
bool isBoolOp = node is BinaryExpressionSyntax bin &&
|
|
414
|
+
(bin.IsKind(SyntaxKind.LogicalAndExpression) ||
|
|
415
|
+
bin.IsKind(SyntaxKind.LogicalOrExpression) ||
|
|
416
|
+
bin.IsKind(SyntaxKind.CoalesceExpression));
|
|
417
|
+
|
|
418
|
+
if (increments)
|
|
419
|
+
{
|
|
420
|
+
cyclomatic++;
|
|
421
|
+
cognitive += 1 + depth;
|
|
422
|
+
}
|
|
423
|
+
if (isElse)
|
|
424
|
+
{
|
|
425
|
+
cognitive += 1 + depth; // else adds cognitive complexity with nesting penalty
|
|
426
|
+
}
|
|
427
|
+
if (isBoolOp)
|
|
428
|
+
{
|
|
429
|
+
cyclomatic++;
|
|
430
|
+
cognitive++;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
bool nests = node is IfStatementSyntax or SwitchStatementSyntax
|
|
434
|
+
or ForStatementSyntax or ForEachStatementSyntax
|
|
435
|
+
or WhileStatementSyntax or DoStatementSyntax
|
|
436
|
+
or CatchClauseSyntax or LambdaExpressionSyntax;
|
|
437
|
+
|
|
438
|
+
if (nests) depth++;
|
|
439
|
+
|
|
440
|
+
foreach (var child in node.ChildNodes())
|
|
441
|
+
Walk(child, depth);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
Walk(body, 0);
|
|
445
|
+
|
|
446
|
+
var qualifiedName = GetQualifiedName(methodSymbol);
|
|
447
|
+
metrics.Add(new CtxoComplexity
|
|
448
|
+
{
|
|
449
|
+
SymbolId = $"{filePath}::{qualifiedName}::method",
|
|
450
|
+
Cyclomatic = cyclomatic,
|
|
451
|
+
Cognitive = cognitive,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return metrics;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── Project Dependency Graph ─────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
object BuildProjectGraph(Solution sol)
|
|
461
|
+
{
|
|
462
|
+
var depGraph = sol.GetProjectDependencyGraph();
|
|
463
|
+
var projects = new List<object>();
|
|
464
|
+
var graphEdges = new List<object>();
|
|
465
|
+
|
|
466
|
+
foreach (var projectId in depGraph.GetTopologicallySortedProjects())
|
|
467
|
+
{
|
|
468
|
+
var project = sol.GetProject(projectId);
|
|
469
|
+
if (project == null) continue;
|
|
470
|
+
|
|
471
|
+
projects.Add(new { name = project.Name, path = project.FilePath });
|
|
472
|
+
|
|
473
|
+
foreach (var depId in depGraph.GetProjectsThatThisProjectDirectlyDependsOn(projectId))
|
|
474
|
+
{
|
|
475
|
+
var dep = sol.GetProject(depId);
|
|
476
|
+
if (dep != null)
|
|
477
|
+
graphEdges.Add(new { from = project.Name, to = dep.Name, kind = "projectReference" });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return new { type = "projectGraph", projects, edges = graphEdges };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
// BUG 2 FIX: Filter out generated/build artifact files
|
|
487
|
+
bool IsUserCsFile(string? filePath)
|
|
488
|
+
{
|
|
489
|
+
if (filePath == null || !filePath.EndsWith(".cs")) return false;
|
|
490
|
+
var normalized = filePath.Replace('\\', '/');
|
|
491
|
+
return !normalized.Contains("/obj/") && !normalized.Contains("/bin/");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
IEnumerable<IOperation> EnumerateOperations(IOperation root)
|
|
495
|
+
{
|
|
496
|
+
var stack = new Stack<IOperation>();
|
|
497
|
+
stack.Push(root);
|
|
498
|
+
while (stack.Count > 0)
|
|
499
|
+
{
|
|
500
|
+
var current = stack.Pop();
|
|
501
|
+
yield return current;
|
|
502
|
+
foreach (var child in current.ChildOperations.Reverse())
|
|
503
|
+
stack.Push(child);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
string? MapSymbolKind(ISymbol symbol) => symbol switch
|
|
508
|
+
{
|
|
509
|
+
INamedTypeSymbol t => t.TypeKind switch
|
|
510
|
+
{
|
|
511
|
+
TypeKind.Class => "class",
|
|
512
|
+
TypeKind.Struct => "class",
|
|
513
|
+
TypeKind.Interface => "interface",
|
|
514
|
+
TypeKind.Enum => "type",
|
|
515
|
+
TypeKind.Delegate => "type",
|
|
516
|
+
_ => null,
|
|
517
|
+
},
|
|
518
|
+
IMethodSymbol => "method",
|
|
519
|
+
IPropertySymbol => "variable",
|
|
520
|
+
IFieldSymbol => "variable",
|
|
521
|
+
IEventSymbol => "variable",
|
|
522
|
+
_ => null,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// BUG 3 FIX: Use containing type name for constructors
|
|
526
|
+
string GetQualifiedName(ISymbol symbol)
|
|
527
|
+
{
|
|
528
|
+
var parts = new List<string>();
|
|
529
|
+
var current = symbol;
|
|
530
|
+
|
|
531
|
+
// For constructors, replace .ctor with the type name (e.g., BaseSyncJob.BaseSyncJob)
|
|
532
|
+
if (current is IMethodSymbol ms && ms.MethodKind == MethodKind.Constructor && ms.ContainingType != null)
|
|
533
|
+
{
|
|
534
|
+
parts.Add(ms.ContainingType.Name);
|
|
535
|
+
current = current.ContainingSymbol; // skip to containing type
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
while (current != null && current is not INamespaceSymbol { IsGlobalNamespace: true })
|
|
539
|
+
{
|
|
540
|
+
if (current is INamespaceSymbol ns)
|
|
541
|
+
{
|
|
542
|
+
if (!ns.IsGlobalNamespace)
|
|
543
|
+
parts.Add(ns.Name);
|
|
544
|
+
}
|
|
545
|
+
else
|
|
546
|
+
{
|
|
547
|
+
parts.Add(current.Name);
|
|
548
|
+
}
|
|
549
|
+
current = current.ContainingSymbol;
|
|
550
|
+
}
|
|
551
|
+
parts.Reverse();
|
|
552
|
+
return string.Join(".", parts);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// BUG 6 FIX: Consistent SymbolToId for all symbol types
|
|
556
|
+
string? SymbolToId(ISymbol symbol)
|
|
557
|
+
{
|
|
558
|
+
// Only resolve symbols that are in source (not metadata/framework)
|
|
559
|
+
var original = symbol.OriginalDefinition;
|
|
560
|
+
var location = original.Locations.FirstOrDefault();
|
|
561
|
+
if (location == null || !location.IsInSource) return null;
|
|
562
|
+
|
|
563
|
+
var sourceFilePath = location.SourceTree?.FilePath;
|
|
564
|
+
if (sourceFilePath == null) return null;
|
|
565
|
+
|
|
566
|
+
var relativePath = Path.GetRelativePath(solutionDir, sourceFilePath).Replace('\\', '/');
|
|
567
|
+
var kind = MapSymbolKind(original);
|
|
568
|
+
if (kind == null) return null;
|
|
569
|
+
|
|
570
|
+
// BUG 3 FIX: Guard against empty/invalid qualified names (e.g., implicit record base types, compiler-generated types like <Program>$)
|
|
571
|
+
var name = GetQualifiedName(original);
|
|
572
|
+
if (string.IsNullOrWhiteSpace(name)) return null;
|
|
573
|
+
if (name.Contains('<') || name.Contains('>')) return null; // compiler-generated
|
|
574
|
+
|
|
575
|
+
return $"{relativePath}::{name}::{kind}";
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
void AddEdge(List<CtxoEdge> edges, HashSet<string> seen, string from, string to, string kind)
|
|
579
|
+
{
|
|
580
|
+
if (from == to) return; // skip self-references
|
|
581
|
+
var key = $"{from}|{to}|{kind}";
|
|
582
|
+
if (!seen.Add(key)) return; // deduplicate
|
|
583
|
+
edges.Add(new CtxoEdge { From = from, To = to, Kind = kind });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
void WriteProgress(string message) =>
|
|
587
|
+
WriteLine(JsonSerializer.Serialize(new { type = "progress", message }, jsonOptions));
|
|
588
|
+
|
|
589
|
+
void WriteError(string message) =>
|
|
590
|
+
WriteStderr($"[ctxo-roslyn] {message}");
|
|
591
|
+
|
|
592
|
+
void WriteStderr(string message) =>
|
|
593
|
+
Console.Error.WriteLine(message);
|
|
594
|
+
|
|
595
|
+
void WriteLine(string json) =>
|
|
596
|
+
Console.WriteLine(json);
|
|
597
|
+
|
|
598
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
record FileResult
|
|
601
|
+
{
|
|
602
|
+
[JsonPropertyName("type")] public string Type { get; init; } = "file";
|
|
603
|
+
[JsonPropertyName("file")] public string File { get; init; } = "";
|
|
604
|
+
[JsonPropertyName("symbols")] public List<CtxoSymbol>? Symbols { get; init; }
|
|
605
|
+
[JsonPropertyName("edges")] public List<CtxoEdge>? Edges { get; init; }
|
|
606
|
+
[JsonPropertyName("complexity")] public List<CtxoComplexity>? Complexity { get; init; }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
record CtxoSymbol
|
|
610
|
+
{
|
|
611
|
+
[JsonPropertyName("symbolId")] public string SymbolId { get; init; } = "";
|
|
612
|
+
[JsonPropertyName("name")] public string Name { get; init; } = "";
|
|
613
|
+
[JsonPropertyName("kind")] public string Kind { get; init; } = "";
|
|
614
|
+
[JsonPropertyName("startLine")] public int StartLine { get; init; }
|
|
615
|
+
[JsonPropertyName("endLine")] public int EndLine { get; init; }
|
|
616
|
+
[JsonPropertyName("startOffset")] public int StartOffset { get; init; }
|
|
617
|
+
[JsonPropertyName("endOffset")] public int EndOffset { get; init; }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
record CtxoEdge
|
|
621
|
+
{
|
|
622
|
+
[JsonPropertyName("from")] public string From { get; init; } = "";
|
|
623
|
+
[JsonPropertyName("to")] public string To { get; init; } = "";
|
|
624
|
+
[JsonPropertyName("kind")] public string Kind { get; init; } = "";
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
record CtxoComplexity
|
|
628
|
+
{
|
|
629
|
+
[JsonPropertyName("symbolId")] public string SymbolId { get; init; } = "";
|
|
630
|
+
[JsonPropertyName("cyclomatic")] public int Cyclomatic { get; init; }
|
|
631
|
+
[JsonPropertyName("cognitive")] public int Cognitive { get; init; }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
record KeepAliveRequest
|
|
635
|
+
{
|
|
636
|
+
[JsonPropertyName("file")] public string? File { get; init; }
|
|
637
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<OutputType>Exe</OutputType>
|
|
4
|
+
<TargetFramework>net8.0</TargetFramework>
|
|
5
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
6
|
+
<Nullable>enable</Nullable>
|
|
7
|
+
<RootNamespace>CtxoRoslyn</RootNamespace>
|
|
8
|
+
</PropertyGroup>
|
|
9
|
+
<ItemGroup>
|
|
10
|
+
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.12.0" />
|
|
11
|
+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
|
|
12
|
+
<PackageReference Include="Microsoft.Build.Locator" Version="1.7.8" />
|
|
13
|
+
</ItemGroup>
|
|
14
|
+
</Project>
|