Files
zzet--gortex/internal/parser/languages/csharp_test.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

841 lines
26 KiB
Go

package languages
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// Regression: a bodyless interface-method signature must mint a
// <file>::<Iface>.<name> KindMethod node (marked iface_member) so its id
// resolves and dispatch calls bind to it — parity with PHP's extractMethod.
func TestCSharpExtractor_BodylessInterfaceMethodNode(t *testing.T) {
src := []byte(`namespace App {
public interface ISink {
void Write(string msg);
string Flush();
}
public class FileSink : ISink {
public void Write(string msg) {}
public string Flush() { return ""; }
}
}`)
e := NewCSharpExtractor()
result, err := e.Extract("Sink.cs", src)
require.NoError(t, err)
byID := map[string]*graph.Node{}
for _, n := range result.Nodes {
byID[n.ID] = n
}
m := byID["Sink.cs::ISink.Write"]
require.NotNil(t, m, "bodyless interface method must mint a node")
assert.Equal(t, graph.KindMethod, m.Kind)
assert.Equal(t, "ISink", m.Meta["receiver"])
assert.Equal(t, true, m.Meta["iface_member"])
require.NotNil(t, byID["Sink.cs::ISink.Flush"], "every bodyless interface method mints a node")
}
func TestCSharpExtractor_ClassWithMethods(t *testing.T) {
src := []byte(`public class UserService {
public User FindById(string id) {
return null;
}
public void Save(User user) {
_db.Execute(user);
}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("UserService.cs", src)
require.NoError(t, err)
types := nodesOfKind(result.Nodes, graph.KindType)
require.Len(t, types, 1)
assert.Equal(t, "UserService", types[0].Name)
methods := nodesOfKind(result.Nodes, graph.KindMethod)
require.Len(t, methods, 2)
assert.Equal(t, "FindById", methods[0].Name)
assert.Equal(t, "Save", methods[1].Name)
memberEdges := edgesOfKind(result.Edges, graph.EdgeMemberOf)
require.Len(t, memberEdges, 2)
for _, e := range memberEdges {
assert.Equal(t, "UserService.cs::UserService", e.To)
}
}
func TestCSharpExtractor_Interface(t *testing.T) {
src := []byte(`public interface IUserService {
User FindById(string id);
void Save(User user);
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("IUserService.cs", src)
require.NoError(t, err)
ifaces := nodesOfKind(result.Nodes, graph.KindInterface)
require.Len(t, ifaces, 1)
assert.Equal(t, "IUserService", ifaces[0].Name)
require.NotNil(t, ifaces[0].Meta)
methods, ok := ifaces[0].Meta["methods"].([]string)
require.True(t, ok)
assert.Equal(t, []string{"FindById", "Save"}, methods)
}
// TestCSharpExtractor_InterfaceMembers verifies that interface member
// declarations — overloaded methods, a property, and a C# 8 default (bodied)
// method — each get their own <file>::<Iface>.<member> node marked
// iface_member, while the interface node still carries its method-name list.
func TestCSharpExtractor_InterfaceMembers(t *testing.T) {
src := []byte(`public interface ITruncator {
string Truncate(string value);
string Truncate(string value, int length);
int Count { get; }
string Describe() => "default";
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("ITruncator.cs", src)
require.NoError(t, err)
// First Truncate overload keeps the bare id; the second gets the
// _L<line> suffix (line 3 in the source above).
t1 := nodeByID(result.Nodes, "ITruncator.cs::ITruncator.Truncate")
require.NotNil(t, t1, "first Truncate overload node")
assert.Equal(t, graph.KindMethod, t1.Kind)
assert.Equal(t, "ITruncator", t1.Meta["receiver"])
assert.Equal(t, VisibilityPublic, t1.Meta["visibility"])
assert.Equal(t, true, t1.Meta["iface_member"])
t2 := nodeByID(result.Nodes, "ITruncator.cs::ITruncator.Truncate_L3")
require.NotNil(t, t2, "overloaded Truncate gets the _L<line> id")
assert.Equal(t, true, t2.Meta["iface_member"])
// Property member — a KindField node marked property + iface_member.
count := nodeByID(result.Nodes, "ITruncator.cs::ITruncator.Count")
require.NotNil(t, count, "interface property node")
assert.Equal(t, graph.KindField, count.Kind)
assert.Equal(t, "property", count.Meta["kind"])
assert.Equal(t, true, count.Meta["iface_member"])
// C# 8 default (bodied) method — must NOT leak as a bare function and
// is still marked an interface member.
describe := nodeByID(result.Nodes, "ITruncator.cs::ITruncator.Describe")
require.NotNil(t, describe, "default interface method node")
assert.Equal(t, graph.KindMethod, describe.Kind)
assert.Equal(t, true, describe.Meta["iface_member"])
assert.Nil(t, nodeByID(result.Nodes, "ITruncator.cs::Describe"),
"default method must not leak as a bare function")
// Each member is a MemberOf the interface type node.
memberOf := 0
for _, ed := range result.Edges {
if ed.Kind == graph.EdgeMemberOf && ed.To == "ITruncator.cs::ITruncator" {
memberOf++
}
}
assert.Equal(t, 4, memberOf, "2 method overloads + property + default method")
// Backward compat: the interface node still lists its method names.
iface := nodeByID(result.Nodes, "ITruncator.cs::ITruncator")
require.NotNil(t, iface)
names, _ := iface.Meta["methods"].([]string)
assert.Contains(t, names, "Truncate")
assert.Contains(t, names, "Describe")
}
// TestCSharpExtractor_ExtensionMethod verifies extension methods (a static
// method whose first parameter carries the `this` modifier) are stamped with
// extension=true + this_param_type, keep their <StaticClass>.<name> id, and a
// plain static method is not misflagged.
func TestCSharpExtractor_ExtensionMethod(t *testing.T) {
src := []byte(`public static class Exts {
public static string Dehumanize(this string value) { return value; }
public static int AddTo(this int x, int y) { return x + y; }
public static string Plain(string value) { return value; }
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("Exts.cs", src)
require.NoError(t, err)
deh := nodeByID(result.Nodes, "Exts.cs::Exts.Dehumanize")
require.NotNil(t, deh, "extension method id stays <StaticClass>.<name>")
assert.Equal(t, true, deh.Meta["extension"])
assert.Equal(t, "string", deh.Meta["this_param_type"])
assert.Equal(t, true, deh.Meta["static"])
add := nodeByID(result.Nodes, "Exts.cs::Exts.AddTo")
require.NotNil(t, add)
assert.Equal(t, true, add.Meta["extension"])
assert.Equal(t, "int", add.Meta["this_param_type"])
// A plain static method (no `this`) must not be flagged an extension.
plain := nodeByID(result.Nodes, "Exts.cs::Exts.Plain")
require.NotNil(t, plain)
_, isExt := plain.Meta["extension"]
assert.False(t, isExt, "plain static method must not be an extension")
}
// csharpSymbolNames returns the set of non-file node names in a result.
func csharpSymbolNames(res *parser.ExtractionResult) map[string]bool {
names := map[string]bool{}
for _, n := range res.Nodes {
if n != nil && n.Kind != graph.KindFile {
names[n.Name] = true
}
}
return names
}
// TestCSharpConditionalRecoversSymbolsViaAdaptiveReparse is a B1 named test:
// when a conditional directive desynchronises the native parse (here a #if that
// brackets a stray brace plus a whole namespace), the adaptive re-parse falls
// back to the directive-blanked source and recovers the symbols the native
// parse dropped — without the caller doing anything.
func TestCSharpConditionalRecoversSymbolsViaAdaptiveReparse(t *testing.T) {
src := []byte("public class C {\n" +
" public void A() {}\n" +
"#if NET\n" +
"}\n" +
"namespace Extra {\n" +
" public class D { public void B() {} }\n" +
"}\n" +
"#endif\n")
res, err := NewCSharpExtractor().Extract("R.cs", src)
require.NoError(t, err)
names := csharpSymbolNames(res)
// The native parse keeps only C and A; the blanked re-parse additionally
// recovers the #if-guarded namespace's class D and its method B.
assert.True(t, names["D"], "class D should be recovered by the adaptive re-parse; got %v", names)
assert.True(t, names["B"], "method B should be recovered by the adaptive re-parse; got %v", names)
assert.True(t, names["A"], "method A must still be present; got %v", names)
}
// TestCSharpConditionalKeepsAllBranchMethods proves the native tree-sitter
// handling — which already extracts methods from every #if/#elif/#else branch —
// is preserved (the adaptive path only ever adds symbols, never drops them).
func TestCSharpConditionalKeepsAllBranchMethods(t *testing.T) {
src := []byte("public class C {\n" +
"#if A\n" +
" public void M_A() {}\n" +
"#elif B\n" +
" public void M_B() {}\n" +
"#else\n" +
" public void M_C() {}\n" +
"#endif\n" +
" public void Always() {}\n" +
"}\n")
res, err := NewCSharpExtractor().Extract("R.cs", src)
require.NoError(t, err)
names := csharpSymbolNames(res)
for _, want := range []string{"M_A", "M_B", "M_C", "Always"} {
assert.True(t, names[want], "%s should be extracted from its branch; got %v", want, names)
}
}
func TestCSharpExtractor_UsingImports(t *testing.T) {
src := []byte(`using System;
using System.Collections.Generic;
public class App {}
`)
e := NewCSharpExtractor()
result, err := e.Extract("App.cs", src)
require.NoError(t, err)
imports := edgesOfKind(result.Edges, graph.EdgeImports)
require.Len(t, imports, 2)
}
func TestCSharpExtractor_Namespace(t *testing.T) {
src := []byte(`namespace MyApp.Services
{
public class Foo {}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("Foo.cs", src)
require.NoError(t, err)
pkgs := nodesOfKind(result.Nodes, graph.KindPackage)
require.Len(t, pkgs, 1)
assert.Equal(t, "MyApp.Services", pkgs[0].Name)
}
func TestCSharpExtractor_StructAndEnum(t *testing.T) {
src := []byte(`public enum Status {
Active,
Inactive
}
public struct Point {
public int X;
public int Y;
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("Types.cs", src)
require.NoError(t, err)
types := nodesOfKind(result.Nodes, graph.KindType)
require.Len(t, types, 2)
names := []string{types[0].Name, types[1].Name}
assert.Contains(t, names, "Status")
assert.Contains(t, names, "Point")
// Struct fields should be extracted.
fields := nodesOfKind(result.Nodes, graph.KindField)
assert.Len(t, fields, 2)
// Enum members are navigable nodes with their own MemberOf edges.
members := nodesOfKind(result.Nodes, graph.KindEnumMember)
assert.Len(t, members, 2, "Active + Inactive")
pointMembers, statusMembers := 0, 0
for _, e := range edgesOfKind(result.Edges, graph.EdgeMemberOf) {
switch e.To {
case "Types.cs::Point":
pointMembers++
case "Types.cs::Status":
statusMembers++
}
}
assert.Equal(t, 2, pointMembers, "struct fields belong to Point")
assert.Equal(t, 2, statusMembers, "enum members belong to Status")
}
func TestCSharpExtractor_Constructor(t *testing.T) {
src := []byte(`public class UserService {
private readonly Database _db;
public UserService(Database db) {
_db = db;
}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("UserService.cs", src)
require.NoError(t, err)
methods := nodesOfKind(result.Nodes, graph.KindMethod)
require.Len(t, methods, 1)
assert.Equal(t, "UserService.<init>", methods[0].Name)
memberEdges := edgesOfKind(result.Edges, graph.EdgeMemberOf)
// Constructor + field = 2 MemberOf edges
require.GreaterOrEqual(t, len(memberEdges), 1)
found := false
for _, e := range memberEdges {
if e.From == "UserService.cs::UserService.<init>" {
assert.Equal(t, "UserService.cs::UserService", e.To)
found = true
}
}
assert.True(t, found, "constructor should have MemberOf edge to class")
}
func TestCSharpExtractor_FullSample(t *testing.T) {
src := []byte(`using System;
using System.Collections.Generic;
namespace MyApp.Services
{
public interface IUserService
{
User FindById(string id);
void Save(User user);
}
public class UserService : IUserService
{
private readonly Database _db;
public UserService(Database db)
{
_db = db;
}
public User FindById(string id)
{
return _db.Query(id);
}
public void Save(User user)
{
_db.Execute(user);
}
}
public enum Status
{
Active,
Inactive
}
public struct Point
{
public int X;
public int Y;
}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("Services.cs", src)
require.NoError(t, err)
// 1 namespace
pkgs := nodesOfKind(result.Nodes, graph.KindPackage)
assert.Len(t, pkgs, 1)
// 1 interface
ifaces := nodesOfKind(result.Nodes, graph.KindInterface)
assert.Len(t, ifaces, 1)
// 3 types: UserService, Status, Point
types := nodesOfKind(result.Nodes, graph.KindType)
assert.Len(t, types, 3)
// 5 methods: the 2 interface members (IUserService.FindById / .Save)
// plus the concrete UserService constructor + FindById + Save.
methods := nodesOfKind(result.Nodes, graph.KindMethod)
assert.Len(t, methods, 5)
// 2 imports
imports := edgesOfKind(result.Edges, graph.EdgeImports)
assert.Len(t, imports, 2)
// Call edges (Query, Execute)
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
assert.GreaterOrEqual(t, len(calls), 2)
}
func TestCSharpExtractor_TypeEnv_ExplicitType(t *testing.T) {
src := []byte(`public class UserService {
public void Save() {}
}
public class App {
public void Main() {
UserService svc = new UserService();
svc.Save();
}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("app.cs", src)
require.NoError(t, err)
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
var saveCall *graph.Edge
for _, c := range calls {
if strings.HasSuffix(c.To, "Save") {
saveCall = c
break
}
}
require.NotNil(t, saveCall, "expected a call edge to Save")
require.NotNil(t, saveCall.Meta, "expected Meta on Save call edge")
assert.Equal(t, "UserService", saveCall.Meta["receiver_type"])
}
func TestCSharpExtractor_TypeEnv_NewExpression(t *testing.T) {
src := []byte(`public class Client {
public void Connect() {}
}
public class App {
public void Main() {
var client = new Client();
client.Connect();
}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("app.cs", src)
require.NoError(t, err)
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
var connectCall *graph.Edge
for _, c := range calls {
if strings.HasSuffix(c.To, "Connect") {
connectCall = c
break
}
}
require.NotNil(t, connectCall)
require.NotNil(t, connectCall.Meta)
assert.Equal(t, "Client", connectCall.Meta["receiver_type"])
}
func TestCSharpExtractor_TypeEnv_Unknown(t *testing.T) {
src := []byte(`public class App {
public object GetService() { return null; }
public void Main() {
var svc = GetService();
svc.Process();
}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("app.cs", src)
require.NoError(t, err)
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
var processCall *graph.Edge
for _, c := range calls {
if strings.HasSuffix(c.To, "Process") {
processCall = c
break
}
}
require.NotNil(t, processCall)
assert.NotContains(t, processCall.Meta, "receiver_type", "unknown type should not produce a receiver_type hint")
}
func TestCSharpExtractor_TypeEnv_Chain(t *testing.T) {
src := []byte(`public class Order {
public int Id;
}
public class UserService {
public Order GetOrder() { return new Order(); }
}
public class App {
public void Main() {
UserService svc = new UserService();
svc.GetOrder().ToString();
}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("app.cs", src)
require.NoError(t, err)
// Verify return_type is set on GetOrder method.
var getOrderNode *graph.Node
for _, n := range result.Nodes {
if n.Name == "GetOrder" {
getOrderNode = n
break
}
}
require.NotNil(t, getOrderNode, "expected a node for GetOrder")
assert.Equal(t, "Order", getOrderNode.Meta["return_type"])
// Verify chain resolution: svc.GetOrder() should resolve to Order.
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
var toStringCall *graph.Edge
for _, c := range calls {
if strings.HasSuffix(c.To, "ToString") {
toStringCall = c
break
}
}
require.NotNil(t, toStringCall, "expected a call edge to ToString")
require.NotNil(t, toStringCall.Meta, "expected Meta on ToString call edge")
assert.Equal(t, "Order", toStringCall.Meta["receiver_type"])
}
func TestCSharpExtractor_DocAndVisibility(t *testing.T) {
src := []byte(`namespace X
{
/// <summary>
/// Greeter wraps the greeting.
/// </summary>
public class Greeter
{
/// <summary>Says hi.</summary>
public void Hello() {}
private void Secret() {}
}
class Internal {}
}
`)
e := NewCSharpExtractor()
result, err := e.Extract("Greeter.cs", src)
require.NoError(t, err)
byID := map[string]*graph.Node{}
for _, n := range result.Nodes {
byID[n.ID] = n
}
greeter := byID["Greeter.cs::Greeter"]
require.NotNil(t, greeter)
if greeter.Meta["visibility"] != "public" {
t.Fatalf("Greeter.vis = %q", greeter.Meta["visibility"])
}
if greeter.Meta["doc"] != "Greeter wraps the greeting." {
t.Fatalf("Greeter.doc = %q", greeter.Meta["doc"])
}
hello := byID["Greeter.cs::Greeter.Hello"]
require.NotNil(t, hello)
if hello.Meta["visibility"] != "public" {
t.Fatalf("Hello.vis = %q", hello.Meta["visibility"])
}
if hello.Meta["doc"] != "Says hi." {
t.Fatalf("Hello.doc = %q", hello.Meta["doc"])
}
secret := byID["Greeter.cs::Greeter.Secret"]
require.NotNil(t, secret)
if secret.Meta["visibility"] != "private" {
t.Fatalf("Secret.vis = %q", secret.Meta["visibility"])
}
internalT := byID["Greeter.cs::Internal"]
require.NotNil(t, internalT)
if internalT.Meta["visibility"] != "internal" {
t.Fatalf("Internal.vis = %q", internalT.Meta["visibility"])
}
}
// edgeTargetNames returns the bare target names of every edge of the
// given kind whose From matches the given source ID. The C# base-list
// heuristic emits unresolved targets (`unresolved::Name`), so the
// prefix is stripped for readable assertions.
func edgeTargetNames(edges []*graph.Edge, from string, kind graph.EdgeKind) []string {
var out []string
for _, e := range edges {
if e.Kind != kind || e.From != from {
continue
}
out = append(out, strings.TrimPrefix(e.To, "unresolved::"))
}
return out
}
func TestCSharpExtractor_BaseListDiscrimination(t *testing.T) {
e := NewCSharpExtractor()
t.Run("class with base class and interface", func(t *testing.T) {
src := []byte(`class Foo : BaseClass, IService {}`)
result, err := e.Extract("Foo.cs", src)
require.NoError(t, err)
extends := edgeTargetNames(result.Edges, "Foo.cs::Foo", graph.EdgeExtends)
implements := edgeTargetNames(result.Edges, "Foo.cs::Foo", graph.EdgeImplements)
assert.Equal(t, []string{"BaseClass"}, extends)
assert.Equal(t, []string{"IService"}, implements)
// Heuristic edges ride at the inferred tier, not resolved.
for _, ed := range result.Edges {
if ed.Kind == graph.EdgeExtends || ed.Kind == graph.EdgeImplements {
assert.Equal(t, graph.OriginASTInferred, ed.Origin)
}
}
})
t.Run("base resolved via local interface prescan", func(t *testing.T) {
// IThing breaks the I-prefix convention (it does, but we also
// confirm a same-file interface is honoured even by name): the
// prescan must classify Bar's base as an interface.
src := []byte(`interface IThing {}
class Bar : IThing {}`)
result, err := e.Extract("Bar.cs", src)
require.NoError(t, err)
assert.Empty(t, edgeTargetNames(result.Edges, "Bar.cs::Bar", graph.EdgeExtends))
assert.Equal(t, []string{"IThing"},
edgeTargetNames(result.Edges, "Bar.cs::Bar", graph.EdgeImplements))
})
t.Run("prescan wins over name shape", func(t *testing.T) {
// Widget does not look like an interface (no I-prefix) but is
// declared as one in this file — the prescan must win, so it is
// implemented, not extended.
src := []byte(`interface Widget {}
class Panel : Widget {}`)
result, err := e.Extract("Panel.cs", src)
require.NoError(t, err)
assert.Empty(t, edgeTargetNames(result.Edges, "Panel.cs::Panel", graph.EdgeExtends))
assert.Equal(t, []string{"Widget"},
edgeTargetNames(result.Edges, "Panel.cs::Panel", graph.EdgeImplements))
})
t.Run("struct implements only, never extends", func(t *testing.T) {
src := []byte(`struct S : IComparable {}`)
result, err := e.Extract("S.cs", src)
require.NoError(t, err)
assert.Empty(t, edgeTargetNames(result.Edges, "S.cs::S", graph.EdgeExtends))
assert.Equal(t, []string{"IComparable"},
edgeTargetNames(result.Edges, "S.cs::S", graph.EdgeImplements))
})
t.Run("generic interface strips type arguments", func(t *testing.T) {
src := []byte(`class L : IList<int> {}`)
result, err := e.Extract("L.cs", src)
require.NoError(t, err)
assert.Empty(t, edgeTargetNames(result.Edges, "L.cs::L", graph.EdgeExtends))
assert.Equal(t, []string{"IList"},
edgeTargetNames(result.Edges, "L.cs::L", graph.EdgeImplements))
})
t.Run("generic base class extends with stripped name", func(t *testing.T) {
src := []byte(`class C : Base<int>, IList<int> {}`)
result, err := e.Extract("C.cs", src)
require.NoError(t, err)
assert.Equal(t, []string{"Base"},
edgeTargetNames(result.Edges, "C.cs::C", graph.EdgeExtends))
assert.Equal(t, []string{"IList"},
edgeTargetNames(result.Edges, "C.cs::C", graph.EdgeImplements))
})
t.Run("qualified base name reduced to simple name", func(t *testing.T) {
src := []byte(`class Outer : System.Object, ICloneable {}`)
result, err := e.Extract("Outer.cs", src)
require.NoError(t, err)
assert.Equal(t, []string{"Object"},
edgeTargetNames(result.Edges, "Outer.cs::Outer", graph.EdgeExtends))
assert.Equal(t, []string{"ICloneable"},
edgeTargetNames(result.Edges, "Outer.cs::Outer", graph.EdgeImplements))
})
t.Run("record extends base and implements interface", func(t *testing.T) {
src := []byte(`record Rec(int X) : Base(X), IThing {}`)
result, err := e.Extract("Rec.cs", src)
require.NoError(t, err)
// The primary_constructor_base_type Base(X) is always a base class.
assert.Equal(t, []string{"Base"},
edgeTargetNames(result.Edges, "Rec.cs::Rec", graph.EdgeExtends))
assert.Equal(t, []string{"IThing"},
edgeTargetNames(result.Edges, "Rec.cs::Rec", graph.EdgeImplements))
})
t.Run("record struct implements only", func(t *testing.T) {
src := []byte(`record struct RS : IFoo {}`)
result, err := e.Extract("RS.cs", src)
require.NoError(t, err)
assert.Empty(t, edgeTargetNames(result.Edges, "RS.cs::RS", graph.EdgeExtends))
assert.Equal(t, []string{"IFoo"},
edgeTargetNames(result.Edges, "RS.cs::RS", graph.EdgeImplements))
})
}
// TestCSharpEnumMembersConstsAndFlags is the C8 test: enum members extract as
// navigable nodes (with values), `const` fields classify as constants,
// async/static/readonly/value-type flags are stamped, and types/methods carry
// their namespace scope.
func TestCSharpEnumMembersConstsAndFlags(t *testing.T) {
src := []byte("namespace App.Core {\n" +
" public enum Color { Red, Green = 5, Blue }\n" +
" public struct Point { public int X; }\n" +
" public class C {\n" +
" public const int MAX = 10;\n" +
" private static readonly int Y = 1;\n" +
" public async Task<int> FetchAsync() { return 1; }\n" +
" public static void Helper() {}\n" +
" }\n}\n")
res, err := NewCSharpExtractor().Extract("a.cs", src)
require.NoError(t, err)
byName := map[string]*graph.Node{}
for _, n := range res.Nodes {
byName[n.Name] = n
}
// Enum members.
for _, m := range []string{"Red", "Green", "Blue"} {
require.NotNil(t, byName[m], "enum member %s should be a node", m)
assert.Equal(t, graph.KindEnumMember, byName[m].Kind)
}
assert.Equal(t, "5", byName["Green"].Meta["value"], "explicit enum value")
// const → constant; flags.
require.NotNil(t, byName["MAX"])
assert.Equal(t, graph.KindConstant, byName["MAX"].Kind, "const field classifies as a constant")
assert.Equal(t, true, byName["Y"].Meta["static"])
assert.Equal(t, true, byName["Y"].Meta["readonly"])
assert.Equal(t, true, byName["FetchAsync"].Meta["async"])
assert.Equal(t, true, byName["Helper"].Meta["static"])
// value type + namespace scope.
assert.Equal(t, true, byName["Point"].Meta["value_type"], "struct is a value type")
assert.Equal(t, "App.Core", byName["C"].Meta["scope_ns"])
assert.Equal(t, "App.Core", byName["FetchAsync"].Meta["scope_ns"])
}
func TestCSharpTypeFlavor(t *testing.T) {
src := []byte(`namespace App;
class Service {}
struct Vec { public int X; }
interface IStore {}
enum Color { Red }
record Rec(int X);
record struct RVec(int X);
`)
res, err := NewCSharpExtractor().Extract("flavor.cs", src)
require.NoError(t, err)
byName := map[string]*graph.Node{}
for _, n := range res.Nodes {
byName[n.Name] = n
}
require.NotNil(t, byName["Service"])
assert.Equal(t, "class", byName["Service"].Meta["type_flavor"])
require.NotNil(t, byName["Vec"])
assert.Equal(t, "struct", byName["Vec"].Meta["type_flavor"])
// Dual-write: the legacy value_type marker stays beside type_flavor.
assert.Equal(t, true, byName["Vec"].Meta["value_type"])
require.NotNil(t, byName["IStore"])
assert.Equal(t, graph.KindInterface, byName["IStore"].Kind)
assert.Equal(t, "interface", byName["IStore"].Meta["type_flavor"])
require.NotNil(t, byName["Color"])
assert.Equal(t, "enum", byName["Color"].Meta["type_flavor"])
require.NotNil(t, byName["Rec"])
assert.Equal(t, "record", byName["Rec"].Meta["type_flavor"])
require.NotNil(t, byName["RVec"])
assert.Equal(t, "record", byName["RVec"].Meta["type_flavor"])
}
func TestCSharpAnonymousTypeFlavor(t *testing.T) {
src := []byte(`namespace App;
class Host {
void Wire() {
var p = new { Name = "x", Age = 5 };
System.Console.WriteLine(p.Name);
}
}
`)
res, err := NewCSharpExtractor().Extract("Host.cs", src)
require.NoError(t, err)
anon, _ := anonTypeAndExtends(t, res)
// Dual-write: the legacy anonymous marker stays beside type_flavor.
assert.Equal(t, true, anon.Meta["anonymous"])
assert.Equal(t, "anonymous_class", anon.Meta["type_flavor"])
}