@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.
@@ -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>