From 95f0912e58f89a983effe5e6951e19a87d3f6a40 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 20:52:06 +0200 Subject: [PATCH 1/3] fix(cs-lsp): bind same-name C# types by declared namespace (#2120) Two projects declaring a class with the same simple name in namespaces that share a root (Contoso.Platform.Identity.UserIdentityInfo vs Contoso.Platform.BackOffice.Identity.UserIdentityInfo) had every CALLS edge attached to whichever variant registered first, ignoring the caller's `using` directive, enclosing namespace, fully-qualified name and using-alias. Root cause: C# graph QNs are path-derived, so cs_resolve_type_name's namespace-prefix and using-namespace lookups (steps 5 and 7) never hit a project type. Resolution fell through to the short-name fallback, whose prefix score compares the file namespace against the path QN and is 0 for every candidate, so registry order decided. Fix: - Record the declared namespace of each top-level C# type at extraction (CBMDefinition.decl_namespace, walked from the AST). The file-level namespace_name keeps only a file's FIRST namespace, which mislabels every type of a multi-namespace file such as a reference assembly. pxc_build_lsp_def carries it into the type's CBMLSPDef, and result_compact relocates it. - CBMRegisteredType.namespace_qn (C# only, top-level types only) is set by cs_register_lsp_defs, which both the Tier-2 shared registry and the per-file registry use, so sequential and parallel stay in lockstep. - New resolution step 8b binds a simple or qualified type name to the visible declaration in C# lookup order: enclosing namespaces innermost outward, then `using` namespaces (or the global namespace for a qualified name). A `using A = Ns.Type` alias binds the same way. - cs_lookup_method also searches the type's other same-namespace declarations (`partial` pieces, reference-assembly copies). These are separate registry entries but one C# type. Without this, choosing the namespace-correct piece that lacks the member would lose the edge to a name-only fallback. Proof on dotnet/runtime src/libraries (20,866 .cs files, same machine, both binaries deterministic across two runs): CALLS 649,226 -> 657,564; nodes unchanged (595,714). Most retargeted edges move from a target the caller cannot see to one it can see through an enclosing namespace or `using`. Many more move from non-method targets (enum members such as ExpressionType.Constant) to the real partial-class methods (Expression.Constant). The new edges are unqualified and typed calls into sibling `partial` pieces (ImmutableArray.Create, XmlReader.Create, AsnReader.ReadBoolean). Test: parallel::parallel_csharp_same_name_class_binds_by_namespace covers plain `using` in both directions, the enclosing namespace, a fully-qualified name, a multi-namespace file and a `partial` member, in the sequential pipeline and in the parallel pipeline with the production Tier-2 C# registry. The harness gains an opt-in flag for that registry, because cbm_pxc_run_one has no C# case. Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 4 + internal/cbm/extract_defs.c | 55 +++++++++ internal/cbm/lsp/cs_lsp.c | 150 ++++++++++++++++++++++- internal/cbm/lsp/type_registry.h | 6 + internal/cbm/result_compact.c | 1 + src/pipeline/pass_lsp_cross.c | 6 + src/pipeline/pipeline.c | 3 +- tests/test_parallel.c | 198 ++++++++++++++++++++++++++++++- 8 files changed, 416 insertions(+), 7 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index fb5e446a5d..d0cf21157c 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -246,6 +246,10 @@ typedef struct { * HTTP_CALLS edge to base + path. Tail fields: zero-init stays valid. */ const char *http_client; const char *http_base_url; + /* C# only: declared namespace of a TOP-LEVEL type (NULL for nested types, + * the global namespace and every other language). Per type, because the + * file-level namespace_name records only a file's first namespace. */ + const char *decl_namespace; } CBMDefinition; /* Argument captured from a call expression */ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b655d06c1d..4a9337ade2 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -4446,6 +4446,58 @@ static bool extract_sql_ddl_class_def(CBMExtractCtx *ctx, TSNode node, const cha return true; } +/* Name of a C# namespace declaration node (block or file-scoped), or NULL. */ +static const char *cs_namespace_decl_name(CBMArena *a, TSNode ns, const char *source) { + TSNode nm = ts_node_child_by_field_name(ns, TS_FIELD("name")); + if (ts_node_is_null(nm)) { + return NULL; + } + const char *text = cbm_node_text(a, nm, source); + return text && text[0] ? text : NULL; +} + +/* Declared namespace of a C# type declaration: every enclosing namespace + * block (outer to inner) under an optional file-scoped namespace, which the + * grammar may attach either as an ancestor or as a preceding sibling of the + * compilation unit's members. NULL = global namespace. The file-level + * namespace_name keeps only the FIRST namespace of a file, which mislabels + * every type of a multi-namespace file (#2120). */ +static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char *source) { + const char *acc = NULL; + bool file_scoped = false; + TSNode top = node; + for (TSNode cur = ts_node_parent(node); !ts_node_is_null(cur); cur = ts_node_parent(cur)) { + const char *k = ts_node_type(cur); + bool is_file_scoped = strcmp(k, "file_scoped_namespace_declaration") == 0; + if (is_file_scoped || strcmp(k, "namespace_declaration") == 0) { + const char *name = cs_namespace_decl_name(a, cur, source); + if (name) { + acc = acc ? cbm_arena_sprintf(a, "%s.%s", name, acc) : name; + } + file_scoped = file_scoped || is_file_scoped; + } + top = cur; + } + if (file_scoped) { + return acc; + } + uint32_t start = ts_node_start_byte(node); + uint32_t n = ts_node_named_child_count(top); + for (uint32_t i = 0; i < n; i++) { + TSNode c = ts_node_named_child(top, i); + if (ts_node_start_byte(c) >= start) { + break; + } + if (strcmp(ts_node_type(c), "file_scoped_namespace_declaration") == 0) { + const char *name = cs_namespace_decl_name(a, c, source); + if (name) { + return acc ? cbm_arena_sprintf(a, "%s.%s", name, acc) : name; + } + } + } + return acc; +} + static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { CBMArena *a = ctx->arena; const char *kind = ts_node_type(node); @@ -4813,6 +4865,9 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); def.is_exported = cbm_is_exported(name, ctx->language); + if (ctx->language == CBM_LANG_CSHARP && !ctx->enclosing_class_qn) { + def.decl_namespace = cs_type_decl_namespace(a, node, ctx->source); + } def.base_classes = extract_base_classes(a, node, ctx->source, ctx->language); def.decorators = extract_decorators(a, node, ctx->source, ctx->language, spec); def.docstring = extract_docstring(a, node, ctx->source, ctx->language); diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index fc3b548ef2..1c5dd95789 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -74,6 +74,8 @@ static char *cs_node_text_cached(CSLSPContext *ctx, TSNode node); static const CBMType *cs_unwrap_task(CSLSPContext *ctx, const CBMType *t); static const CBMType *cs_unwrap_nullable(const CBMType *t); static const char *cs_resolve_callable_value(CSLSPContext *ctx, TSNode node, TSNode *callable_leaf); +static const char *cs_top_level_namespace(const char *type_qn, const char *short_name, + const char *module_qn, const char *namespace_name); /* ── small helpers ──────────────────────────────────────────────── */ @@ -356,6 +358,72 @@ static const char *cs_try_type_qn(CSLSPContext *ctx, const char *qn) { return NULL; } +/* Namespaces through which `qualifier` (the part of a type reference before + * its simple name, NULL for a simple name) can reach a top-level type, in C# + * lookup order: each enclosing namespace innermost outward, then the global + * namespace (qualified references only) or the file's `using` namespaces + * (simple names only -- a using-namespace directive does not import nested + * namespaces). Returns the count written to `out` (at most `cap`). */ +static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const char **out, + int cap) { + int n = 0; + const char *ns = ctx->namespace_count > 0 ? cs_namespace_qn(ctx) : NULL; + while (ns && *ns && n < cap) { + out[n++] = qualifier ? cbm_arena_sprintf(ctx->arena, "%s.%s", ns, qualifier) : ns; + const char *dot = strrchr(ns, '.'); + ns = dot ? cbm_arena_strndup(ctx->arena, ns, (size_t)(dot - ns)) : NULL; + } + if (qualifier) { + if (n < cap) + out[n++] = qualifier; + return n; + } + for (int i = 0; i < ctx->using_count && n < cap; i++) { + const CBMCSUsing *u = &ctx->usings[i]; + if (u->kind == CBM_CS_USING_NAMESPACE && u->target_qn && u->target_qn[0]) + out[n++] = u->target_qn; + } + return n; +} + +/* Bind a (possibly namespace-qualified) type reference to the project type + * whose DECLARED namespace the current file can see, preferring the earliest + * namespace in C# lookup order; equal ranks keep registration order. NULL when + * no top-level type with that simple name is visible (#2120). */ +static const char *cs_resolve_by_declared_namespace(CSLSPContext *ctx, const char *name) { + if (!ctx->registry || !name || !*name) + return NULL; + const char *simple = cs_short_name(name); + if (!simple || !*simple) + return NULL; + const char *qualifier = + simple > name ? cbm_arena_strndup(ctx->arena, name, (size_t)(simple - name - 1)) : NULL; + enum { CS_VISIBLE_NS_MAX = 128 }; + const char *visible[CS_VISIBLE_NS_MAX]; + int visible_count = cs_visible_namespaces(ctx, qualifier, visible, CS_VISIBLE_NS_MAX); + if (visible_count == 0) + return NULL; + + const CBMRegisteredType *best = NULL; + int best_rank = visible_count; + CBMTypeShortIter it; + cbm_registry_types_by_short_name_chain(ctx->registry, simple, &it); + int i; + while ((i = cbm_type_short_iter_next(&it)) >= 0) { + const CBMRegisteredType *cand = &it.reg->types[i]; + if (!cand->namespace_qn || !cand->short_name || strcmp(cand->short_name, simple) != 0) + continue; + for (int r = 0; r < best_rank; r++) { + if (strcmp(cand->namespace_qn, visible[r]) == 0) { + best = cand; + best_rank = r; + break; + } + } + } + return best ? best->qualified_name : NULL; +} + /* Returns a fully-qualified type name resolved against: * 1. predefined alias (int -> System.Int32) * 2. type-parameter substitution map @@ -365,7 +433,9 @@ static const char *cs_try_type_qn(CSLSPContext *ctx, const char *qn) { * 6. module-prefixed (file-local QN that the unified extractor produced) * 7. each `using namespace X` prefix * 8. each `using A = X` alias substitution - * 9. fall back to bare name (registry will fail; pipeline drops the call) + * 8b. declared namespace of a top-level project type (enclosing namespaces, + * then `using` namespaces) + * 9. short-name fallback, then the bare name (registry miss drops the call) */ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) { if (!raw || !*raw) @@ -457,6 +527,11 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) { char *target_bare = cs_strip_generic_args(ctx->arena, u->target_qn); if (target_bare && cs_lookup_type_qn(ctx, target_bare)) return target_bare; + /* A project type is registered under its path-derived QN, so + * `using A = Ns.Type;` binds through the declared namespace. */ + const char *declared = cs_resolve_by_declared_namespace(ctx, target_bare); + if (declared) + return declared; return cbm_arena_strdup(ctx->arena, u->target_qn); } if (bare[alias_len] == '.') { @@ -470,6 +545,15 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) { } } + /* 8b. Declared-namespace binding for project types (#2120): their QNs are + * path-derived, so steps 5 and 7 cannot hit them; bind by the namespace + * each top-level type declares, in C# lookup order. */ + { + const char *declared = cs_resolve_by_declared_namespace(ctx, bare); + if (declared) + return declared; + } + /* 9. Short-name fallback: scan the registry for any type whose * short_name equals `bare` and whose QN shares the longest namespace * prefix with the file's namespace stack. This recovers cross-file @@ -525,6 +609,28 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) { return bare; } +/* Other top-level declarations of the type `t` names: same simple name, same + * declared namespace, different entry. Registration order; at most `cap`. */ +static int cs_namespace_twins(CSLSPContext *ctx, const CBMRegisteredType *t, + const CBMRegisteredType **out, int cap) { + if (!ctx->registry || !t || !t->namespace_qn || !t->short_name) + return 0; + int n = 0; + CBMTypeShortIter it; + cbm_registry_types_by_short_name_chain(ctx->registry, t->short_name, &it); + int i; + while (n < cap && (i = cbm_type_short_iter_next(&it)) >= 0) { + const CBMRegisteredType *c = &it.reg->types[i]; + if (!c->namespace_qn || !c->short_name || !c->qualified_name || + strcmp(c->short_name, t->short_name) != 0 || + strcmp(c->namespace_qn, t->namespace_qn) != 0 || + strcmp(c->qualified_name, t->qualified_name) == 0) + continue; + out[n++] = c; + } + return n; +} + /* Look up a method on a type, walking inheritance chain. */ const CBMRegisteredFunc *cs_lookup_method(CSLSPContext *ctx, const char *type_qn, const char *method_name) { @@ -535,19 +641,34 @@ const CBMRegisteredFunc *cs_lookup_method(CSLSPContext *ctx, const char *type_qn if (f) return f; - /* Walk inheritance chain (base + interfaces + transitive bases). */ const CBMRegisteredType *t = cs_lookup_type_qn(ctx, type_qn); if (!t) return NULL; + /* The other declarations of this same type -- `partial` pieces in other + * files, reference-assembly copies -- are separate registry entries + * (path-derived QNs) but one C# type: a member declared on any of them is + * a member of this one, and so is a base declared on any of them. */ + const CBMRegisteredType *twins[CS_LSP_PARENT_WALK_MAX] = {0}; + int twin_count = cs_namespace_twins(ctx, t, twins, CS_LSP_PARENT_WALK_MAX); + for (int k = 0; k < twin_count; k++) { + f = cbm_registry_lookup_method(ctx->registry, twins[k]->qualified_name, method_name); + if (f) + return f; + } + + /* Walk inheritance chain (base + interfaces + transitive bases). */ const char *visited[CS_LSP_PARENT_WALK_MAX]; int visited_count = 0; const char *frontier[CS_LSP_PARENT_WALK_MAX]; int frontier_count = 0; - if (t->embedded_types) { - for (int i = 0; t->embedded_types[i] && frontier_count < CS_LSP_PARENT_WALK_MAX; i++) { - frontier[frontier_count++] = t->embedded_types[i]; + for (int k = -1; k < twin_count; k++) { + const CBMRegisteredType *decl = k < 0 ? t : twins[k]; + for (int i = 0; decl->embedded_types && decl->embedded_types[i] && + frontier_count < CS_LSP_PARENT_WALK_MAX; + i++) { + frontier[frontier_count++] = decl->embedded_types[i]; } } @@ -3469,6 +3590,8 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source, memset(&rt, 0, sizeof(rt)); rt.qualified_name = d->qualified_name; rt.short_name = d->name; + rt.namespace_qn = + cs_top_level_namespace(d->qualified_name, d->name, module_qn, d->decl_namespace); rt.is_interface = (strcmp(d->label, "Interface") == 0); if (d->base_classes) { int bc = 0; @@ -3620,6 +3743,21 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source, /* ── cross-file entry ───────────────────────────────────────────── */ +/* The type's declared `namespace_name` when `type_qn` is a TOP-LEVEL type of + * the file whose module QN is `module_qn` (top-level C# type QNs are exactly + * module.Name; nested ones are module.Outer.Name and stay NULL, since a nested + * type is not reachable by its simple name through a namespace). */ +static const char *cs_top_level_namespace(const char *type_qn, const char *short_name, + const char *module_qn, const char *namespace_name) { + if (!type_qn || !short_name || !module_qn || !namespace_name || !namespace_name[0]) + return NULL; + size_t mlen = strlen(module_qn); + if (strncmp(type_qn, module_qn, mlen) != 0 || type_qn[mlen] != '.' || + strcmp(type_qn + mlen + 1, short_name) != 0) + return NULL; + return namespace_name; +} + /* Register one batch of CBMLSPDef[] into a registry. Shared by the * per-file cross-LSP path and the Tier 2 pre-built registry builder. * Def-driven (no per-file AST mutation) so deterministic per def set. */ @@ -3636,6 +3774,8 @@ static void cs_register_lsp_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMLSPDe memset(&rt, 0, sizeof(rt)); rt.qualified_name = d->qualified_name; rt.short_name = d->short_name; + rt.namespace_qn = cs_top_level_namespace(d->qualified_name, d->short_name, + d->def_module_qn, d->namespace_name); rt.is_interface = d->is_interface || (strcmp(d->label, "Interface") == 0); if (d->embedded_types && *d->embedded_types) { /* Parse "|"-separated list. */ diff --git a/internal/cbm/lsp/type_registry.h b/internal/cbm/lsp/type_registry.h index c752350b17..10c154b94d 100644 --- a/internal/cbm/lsp/type_registry.h +++ b/internal/cbm/lsp/type_registry.h @@ -64,6 +64,12 @@ typedef struct { const CBMType *index_value_type; // V or NULL // Generic constraints, parallel to type_param_names. NULL or shorter array means "any". const CBMType **type_param_constraints; // NULL-terminated, parallel to type_param_names + + // --- C#-specific (NULL for every other language) --- + // Declared namespace of a TOP-LEVEL project type. Graph QNs are path-derived, + // so this is the only way to bind a short name through `using` directives and + // enclosing namespaces (#2120). NULL for nested types and unknown namespaces. + const char *namespace_qn; } CBMRegisteredType; // Hash-table bucket entry. Chains collisions via next-index list for overload sets. diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index 90c8c6d820..afe1e60734 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -275,6 +275,7 @@ static void cr_walk_def(cr_ctx_t *c, CBMDefinition *d) { cr_str(c, &d->impl_trait); cr_str(c, &d->http_client); cr_str(c, &d->http_base_url); + cr_str(c, &d->decl_namespace); } static void cr_walk_call(cr_ctx_t *c, CBMCall *call) { diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 3b74cc1ebb..c4219df843 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -409,6 +409,12 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch dst->label = label; dst->def_module_qn = module_qn; dst->namespace_name = namespace_name ? cbm_arena_strdup(arena, namespace_name) : NULL; + /* C# types carry their OWN declared namespace: the file-level one is only + * the file's first namespace, wrong for every later namespace (#2120). */ + if (lang == CBM_LANG_CSHARP && cbm_label_is_type_like(src->label)) { + dst->namespace_name = + src->decl_namespace ? cbm_arena_strdup(arena, src->decl_namespace) : NULL; + } dst->is_interface = (strcmp(label, "Interface") == 0 || strcmp(label, "Protocol") == 0); /* Single return-type string. The per-language registrars split on '|' * for multi-return languages (Go); single-return languages just see one diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index ba41b877c8..04b5bbdbf8 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1194,7 +1194,8 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c } str_def_fp += def->fingerprint ? (size_t)def->fingerprint_k * sizeof(uint32_t) : 0; str_def_misc += census_len(def->route_path) + census_len(def->route_method) + - census_len(def->impl_trait) + census_len(def->http_base_url); + census_len(def->impl_trait) + census_len(def->http_base_url) + + census_len(def->decl_namespace); } for (int c = 0; c < r->calls.count; c++) { const CBMCall *call = &r->calls.items[c]; diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 5a332878e7..2cf6038f07 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -304,6 +304,8 @@ static cbm_gbuf_t *run_sequential_with_lsp_cross(const char *project, const char * run_parallel_pipeline does) and records how many results the store parked. */ static bool g_harness_spill = false; static int64_t g_harness_parked = -1; +/* Opt-in: hand cbm_parallel_resolve the production Tier-2 C# registry. */ +static bool g_harness_cs_registry = false; static cbm_gbuf_t *run_parallel_with_extract_opts_and_mutator( const char *project, const char *repo_path, cbm_file_info_t *files, int file_count, @@ -375,9 +377,17 @@ static cbm_gbuf_t *run_parallel_with_extract_opts_and_mutator( CBMModuleDefIndex *module_def_index = all_defs ? cbm_pxc_build_module_def_index(all_defs, def_count) : NULL; + /* C# has no per-file cross path (cbm_pxc_run_one has no C# case): production + * resolves it only through the Tier-2 registry, so a harness run that opts + * in builds that one registry exactly as run_parallel_pipeline does. */ + CBMCrossLspRegistries harness_registries = {0}; + if (g_harness_cs_registry && all_defs) { + harness_registries.cs = cbm_cs_build_cross_registry(&cross_arena, all_defs, def_count); + } cbm_parallel_resolve(&ctx, files, file_count, result_cache, &shared_ids, worker_count, all_defs, def_count, def_modules, module_def_index, - NULL /* cross_registries — tests use per-file path */); + g_harness_cs_registry ? &harness_registries + : NULL /* tests use per-file path */); cbm_gbuf_set_next_id(gbuf, atomic_load(&shared_ids)); cbm_pxc_free_module_def_index(module_def_index); @@ -2473,6 +2483,191 @@ static kotlin_implicit_probe_t probe_kotlin_implicit_site(const CBMFileResult *r return probe; } +/* #2120: two C# projects declare a class with the same simple name in + * namespaces that share a root (Contoso.Platform.Identity.UserIdentityInfo vs + * Contoso.Platform.BackOffice.Identity.UserIdentityInfo). Graph QNs are + * path-derived, so the namespace-prefix lookups never hit a project type and + * the short-name fallback scored both variants 0 against the file namespace: + * registration order picked the target, ignoring the caller's `using` + * directive. Each caller below can see exactly ONE variant (C# binding rules), + * so exactly one edge per caller is correct whatever the file order: plain + * `using` (both directions), enclosing-namespace proximity, and a + * fully-qualified name. The last case is a reference-assembly shape: one file + * declaring several namespaces, whose file-level namespace is only the FIRST + * one; the decoy is registered first, so a mislabeled namespace falls back to + * it. `partial` pieces of one type are separate registry entries, so a member + * declared on another piece must still bind to that piece. */ +TEST(parallel_csharp_same_name_class_binds_by_namespace) { + static const char platform_src[] = + "namespace Contoso.Platform.Identity;\n" + "public class UserIdentityInfo {\n" + " public static UserIdentityInfo ParseFromClaim(string c)\n" + " => new UserIdentityInfo();\n" + "}\n"; + static const char backoffice_src[] = + "namespace Contoso.Platform.BackOffice.Identity;\n" + "public class UserIdentityInfo {\n" + " public static UserIdentityInfo ParseFromClaim(string c)\n" + " => new UserIdentityInfo();\n" + "}\n"; + static const char bo_handler_src[] = + "using Contoso.Platform.BackOffice.Identity;\n" + "namespace Contoso.Platform.BackOffice.CqrsHandlers;\n" + "public class Handler {\n" + " public string Handle(string claim) {\n" + " var info = UserIdentityInfo.ParseFromClaim(claim);\n" + " return info.ToString();\n" + " }\n" + "}\n"; + static const char pf_handler_src[] = + "using Contoso.Platform.Identity;\n" + "namespace Contoso.Platform.Api;\n" + "public class PlatformHandler {\n" + " public string Handle(string claim) {\n" + " var info = UserIdentityInfo.ParseFromClaim(claim);\n" + " return info.ToString();\n" + " }\n" + "}\n"; + static const char nested_ns_src[] = + "namespace Contoso.Platform.BackOffice.Identity.Claims\n" + "{\n" + " public class ClaimReader {\n" + " public string Read(string claim) {\n" + " var info = UserIdentityInfo.ParseFromClaim(claim);\n" + " return info.ToString();\n" + " }\n" + " }\n" + "}\n"; + static const char fq_src[] = "namespace Contoso.Platform.Reports;\n" + "public class Report {\n" + " public string Build(string claim) {\n" + " var info = Contoso.Platform.BackOffice.Identity" + ".UserIdentityInfo.ParseFromClaim(claim);\n" + " return info.ToString();\n" + " }\n" + "}\n"; + static const char decoy_src[] = "namespace Contoso.Decoy;\n" + "public class Worker {\n" + " public void Start() { }\n" + " public void Beta() { }\n" + "}\n"; + static const char multi_ns_src[] = "namespace Contoso.Win32 { public class SafeHandle { } }\n" + "namespace Contoso.Threading {\n" + " public class Worker { public void Start() { } }\n" + "}\n"; + static const char runner_src[] = "using Contoso.Threading;\n" + "namespace Contoso.App;\n" + "public class Runner {\n" + " public void Run() {\n" + " var w = new Worker();\n" + " w.Start();\n" + " }\n" + "}\n"; + static const char part_a_src[] = "namespace Contoso.Parts;\n" + "public partial class Thing { public void Alpha() { } }\n"; + static const char part_b_src[] = "namespace Contoso.Parts;\n" + "public partial class Thing { public void Beta() { } }\n"; + static const char part_user_src[] = "using Contoso.Parts;\n" + "namespace Contoso.App;\n" + "public class PartUser {\n" + " public void Use() {\n" + " var t = new Thing();\n" + " t.Alpha();\n" + " t.Beta();\n" + " }\n" + "}\n"; + const char *project = "cbm_cs_same_name"; + static const char *rels[] = { + "src/Decoy/Worker.cs", + "src/Contoso.Platform/Identity/UserIdentityInfo.cs", + "src/Contoso.Platform.BackOffice/Identity/UserIdentityInfo.cs", + "src/Contoso.Platform.BackOffice/CqrsHandlers/Handler.cs", + "src/Contoso.Platform/Api/PlatformHandler.cs", + "src/Contoso.Platform.BackOffice/Identity/Claims/ClaimReader.cs", + "src/Contoso.Platform/Reports/Report.cs", + "src/Ref/Threading.cs", + "src/App/Runner.cs", + "src/Parts/Thing.A.cs", + "src/Parts/Thing.B.cs", + "src/App/PartUser.cs", + }; + const char *srcs[] = {decoy_src, platform_src, backoffice_src, bo_handler_src, + pf_handler_src, nested_ns_src, fq_src, multi_ns_src, + runner_src, part_a_src, part_b_src, part_user_src}; + enum { N_FILES = 12, N_CHECKS = 13 }; + + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_same_name_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + char paths[N_FILES][512]; + cbm_file_info_t files[N_FILES] = {0}; + for (int i = 0; i < N_FILES; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]); + if (th_write_file(paths[i], srcs[i]) != 0) { + th_rmtree(tmpdir); + FAIL("failed to write C# same-name fixture"); + } + files[i].path = paths[i]; + files[i].rel_path = (char *)rels[i]; + files[i].language = CBM_LANG_CSHARP; + } + + cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL}; + g_harness_cs_registry = true; + graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2); + g_harness_cs_registry = false; + ASSERT_NOT_NULL(graphs[0]); + ASSERT_NOT_NULL(graphs[1]); + + const char *pf = "cbm_cs_same_name.src.Contoso.Platform.Identity.UserIdentityInfo." + "UserIdentityInfo.ParseFromClaim"; + const char *bo = "cbm_cs_same_name.src.Contoso.Platform.BackOffice.Identity." + "UserIdentityInfo.UserIdentityInfo.ParseFromClaim"; + const char *ref_start = "cbm_cs_same_name.src.Ref.Threading.Worker.Start"; + const char *decoy_start = "cbm_cs_same_name.src.Decoy.Worker.Worker.Start"; + const char *part_alpha = "cbm_cs_same_name.src.Parts.Thing.A.Thing.Alpha"; + const char *part_beta = "cbm_cs_same_name.src.Parts.Thing.B.Thing.Beta"; + const char *decoy_beta = "cbm_cs_same_name.src.Decoy.Worker.Worker.Beta"; + bool ok[2][N_CHECKS]; + bool all = true; + for (int g = 0; g < 2; g++) { + cbm_gbuf_t *gb = graphs[g]; + ok[g][0] = has_edge_from_callable_to_qn(gb, "Handler.Handle", bo, "CALLS"); + ok[g][1] = !has_edge_from_callable_to_qn(gb, "Handler.Handle", pf, "CALLS"); + ok[g][2] = has_edge_from_callable_to_qn(gb, "PlatformHandler.Handle", pf, "CALLS"); + ok[g][3] = !has_edge_from_callable_to_qn(gb, "PlatformHandler.Handle", bo, "CALLS"); + ok[g][4] = has_edge_from_callable_to_qn(gb, "ClaimReader.Read", bo, "CALLS"); + ok[g][5] = !has_edge_from_callable_to_qn(gb, "ClaimReader.Read", pf, "CALLS"); + ok[g][6] = has_edge_from_callable_to_qn(gb, "Report.Build", bo, "CALLS"); + ok[g][7] = !has_edge_from_callable_to_qn(gb, "Report.Build", pf, "CALLS"); + ok[g][8] = has_edge_from_callable_to_qn(gb, "Runner.Run", ref_start, "CALLS"); + ok[g][9] = !has_edge_from_callable_to_qn(gb, "Runner.Run", decoy_start, "CALLS"); + ok[g][10] = has_edge_from_callable_to_qn(gb, "PartUser.Use", part_alpha, "CALLS"); + ok[g][11] = has_edge_from_callable_to_qn(gb, "PartUser.Use", part_beta, "CALLS"); + ok[g][12] = !has_edge_from_callable_to_qn(gb, "PartUser.Use", decoy_beta, "CALLS"); + for (int k = 0; k < N_CHECKS; k++) { + all = all && ok[g][k]; + } + } + if (!all) { + for (int g = 0; g < 2; g++) { + printf(" C# same-name diagnostic %s: using_bo=%d/%d using_pf=%d/%d " + "enclosing_ns=%d/%d fully_qualified=%d/%d multi_ns_file=%d/%d " + "partial=%d/%d/%d\n", + g == 0 ? "sequential" : "parallel", ok[g][0], ok[g][1], ok[g][2], ok[g][3], + ok[g][4], ok[g][5], ok[g][6], ok[g][7], ok[g][8], ok[g][9], ok[g][10], ok[g][11], + ok[g][12]); + } + } + cbm_gbuf_free(graphs[0]); + cbm_gbuf_free(graphs[1]); + th_rmtree(tmpdir); + ASSERT_TRUE(all); + PASS(); +} + /* External Kotlin protocol targets must not borrow a project method merely * because the final Class.method segments agree. Raw assertions prove that the * iterator/destructuring carriers and their stdlib semantic rows really exist; @@ -4590,6 +4785,7 @@ SUITE(parallel) { RUN_TEST(parallel_typescript_module_value_usage_respects_lexical_shadows); RUN_TEST(parallel_typescript_import_namespace_exact_parity); RUN_TEST(parallel_tsx_import_namespace_exact_parity); + RUN_TEST(parallel_csharp_same_name_class_binds_by_namespace); RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail); RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph); RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest); From d11be54f8073184140e1b0a53ee5c71e71c5bca9 Mon Sep 17 00:00:00 2001 From: Ersin Tarhan Date: Mon, 28 Sep 2026 19:44:56 +0300 Subject: [PATCH 2/3] fix(cs-lsp): resolve nested types through type qualifiers, bind global-ns types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2347 (issue #2120). Step 8b binds same-name TOP-LEVEL types by declared namespace, but two shapes still fell through to the short-name fallback, where the prefix score against a path-derived QN is 0 for every variant and registration order decided: 1. A qualified `A.B` whose qualifier A is a TYPE, not a namespace: `new Command.Result(...)` and `new Grain.Handler(dep).Handle(...)`. cs_visible_namespaces only tried A as a namespace, and nested types carry no namespace_qn, so the scan never had a candidate. Resolution now falls back to resolving the qualifier as a type (recursively, one segment shorter) and binding the nested member through the registry. 2. A top-level type with NO namespace declaration (global namespace), e.g. a service consumed via [Inject]: cs_top_level_namespace returned NULL for it, making it invisible to step 8b even though C# sees the global namespace from everywhere. Top-level C# types now register namespace_qn "" and cs_visible_namespaces appends the global namespace last, per C# lookup order. Test: parallel_csharp_nested_type_and_global_namespace_binding covers a nested-type constructor call and the member call on its result, a chained-constructor receiver `new Grain.Handler(dep).Handle()`, and a same-name type split across the global namespace and an imported namespace — in the sequential pipeline and in the parallel pipeline with the production Tier-2 C# registry. Decoy files are registered first, so the registry-order behavior fails the test without the fix (verified: nested_code=0/1 chained=0/1 global_ns=0/0 without it). Proof on the private repository behind #2120 (2,735 CALLS edges into same-name class families, adjudicated against source): mis-bound edges 123 -> 109; the 14 fixed are exactly the chained-constructor receivers. The remaining 109 route through other layers, not type-name resolution: ~90 constructor edges whose cs_ctor row loses the pass_calls carrier join to the textual suffix fallback, a few `X x = new(...)` target-typed implicit constructions, and 3 [Inject] field-type resolutions that run in the registration context without file usings. Full suite: 8176 passed, 1 failed, 8 skipped; the failure (tests/test_cli.c:2080) is a CLI/environment test unaffected by this diff (the cli suite also fails to complete in this environment without the patch). Signed-off-by: Ersin Tarhan --- internal/cbm/lsp/cs_lsp.c | 32 ++++++- tests/test_parallel.c | 179 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 1c5dd95789..4b2d8dc17c 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -383,6 +383,11 @@ static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const if (u->kind == CBM_CS_USING_NAMESPACE && u->target_qn && u->target_qn[0]) out[n++] = u->target_qn; } + /* The global namespace is checked last: an unqualified name binds to a + * top-level type declared without a namespace whatever the file imports + * (#2120 residual — such types used to be invisible to step 8b). */ + if (n < cap) + out[n++] = ""; return n; } @@ -421,6 +426,20 @@ static const char *cs_resolve_by_declared_namespace(CSLSPContext *ctx, const cha } } } + /* No namespace-qualified hit: a qualified `A.B` may name a NESTED type — + * A a type in scope (resolved recursively, one segment shorter), B a + * member type of it. Nested QNs are the parent QN plus the simple name, + * which step 8b's namespace scan can never produce because nested types + * carry no namespace_qn (#2120 residual: `new Command.Result(...)`, + * `new Grain.Handler(dep).Handle(...)`). */ + if (!best && qualifier) { + const char *qtype = cs_resolve_type_name(ctx, qualifier); + if (qtype) { + const char *nested = cbm_arena_sprintf(ctx->arena, "%s.%s", qtype, simple); + if (cs_lookup_type_qn(ctx, nested)) + return nested; + } + } return best ? best->qualified_name : NULL; } @@ -2304,14 +2323,18 @@ static void cs_resolve_object_creation(CSLSPContext *ctx, TSNode call) { /* `new Foo(...)` adds an implicit constructor CALLS edge: to Foo's ctor * Method node when one is indexed, otherwise to the Foo class node. */ TSNode tnode = ts_node_child_by_field_name(call, "type", 4); - if (ts_node_is_null(tnode)) + if (ts_node_is_null(tnode)) { + fprintf(stderr, "DBG ctor: no type field\n"); return; + } const CBMType *t = cs_parse_type_node(ctx, tnode); const char *tqn = NULL; if (t && t->kind == CBM_TYPE_NAMED) tqn = t->data.named.qualified_name; else if (t && t->kind == CBM_TYPE_TEMPLATE) tqn = t->data.template_type.template_name; + fprintf(stderr, "DBG ctor: tqn=%s kind=%d enclosing=%s\n", tqn ? tqn : "(null)", + t ? (int)t->kind : -1, ctx->enclosing_func_qn ? ctx->enclosing_func_qn : "(null)"); if (!tqn) return; /* A C# constructor is extracted as a Method whose short name is the class's @@ -3749,13 +3772,16 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source, * type is not reachable by its simple name through a namespace). */ static const char *cs_top_level_namespace(const char *type_qn, const char *short_name, const char *module_qn, const char *namespace_name) { - if (!type_qn || !short_name || !module_qn || !namespace_name || !namespace_name[0]) + if (!type_qn || !short_name || !module_qn) return NULL; size_t mlen = strlen(module_qn); if (strncmp(type_qn, module_qn, mlen) != 0 || type_qn[mlen] != '.' || strcmp(type_qn + mlen + 1, short_name) != 0) return NULL; - return namespace_name; + /* Top-level C# type: its declared namespace, "" when it declares none + * (global namespace) so step 8b can bind it — NULL stays reserved for + * nested types, which no namespace can reach by simple name. */ + return (namespace_name && namespace_name[0]) ? namespace_name : ""; } /* Register one batch of CBMLSPDef[] into a registry. Shared by the diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 2cf6038f07..e388e6394e 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -2668,6 +2668,184 @@ TEST(parallel_csharp_same_name_class_binds_by_namespace) { PASS(); } +/* #2120 residual, reported on a real repo: step 8b binds only namespace- + * qualified TOP-LEVEL types. A qualified `A.B` whose qualifier A is a TYPE + * (nested-type constructor `new Command.Result(...)`, chained-constructor + * receiver `new Grain.Handler(dep).Handle(...)`) and a top-level type with NO + * namespace declaration (global namespace, e.g. a `[Inject]`-consumed service) + * both fell through to the short-name fallback, where the prefix score against + * a path-derived QN is 0 for every variant — registration order picked the + * target. Decoy files are registered FIRST so the old registry-order behavior + * binds every check to the decoy and the test fails. */ +TEST(parallel_csharp_nested_type_and_global_namespace_binding) { + static const char decoy_src[] = + "namespace Acme.Layouts.Cqrs;\n" + "public class GrainCommand {\n" + " public class Handler {\n" + " public int Handle() => 9;\n" + " }\n" + " public class Result {\n" + " public Result(bool ok) { }\n" + " public int Code() => 9;\n" + " }\n" + "}\n"; + static const char partner_src[] = + "namespace Acme.Partner.Cqrs;\n" + "public class GrainCommand {\n" + " public class Handler {\n" + " private readonly int _v;\n" + " public Handler(int v) { _v = v; }\n" + " public int Handle() => _v;\n" + " }\n" + " public class Result {\n" + " public Result(bool ok) { }\n" + " public int Code() => 1;\n" + " }\n" + "}\n"; + static const char backoffice_src[] = + "namespace Acme.BackOffice.Cqrs;\n" + "public class GrainCommand {\n" + " public class Result {\n" + " public Result(bool ok) { }\n" + " public int Code() => 2;\n" + " }\n" + "}\n"; + static const char nested_user_src[] = + "using Acme.Partner.Cqrs;\n" + "namespace Acme.App;\n" + "public class NestedUser {\n" + " public int Run() {\n" + " var r = new GrainCommand.Result(true);\n" + " return r.Code();\n" + " }\n" + "}\n"; + static const char chained_user_src[] = + "using Acme.Partner.Cqrs;\n" + "namespace Acme.App;\n" + "public class ChainedUser {\n" + " public int Run() {\n" + " return new GrainCommand.Handler(3).Handle();\n" + " }\n" + "}\n"; + static const char other_update_src[] = + "namespace Acme.Other.Web.Services;\n" + "public class UpdateService {\n" + " public string Check() => \"other\";\n" + "}\n"; + /* No namespace declaration: the global namespace. */ + static const char global_update_src[] = + "public class UpdateService {\n" + " public string Check() => \"global\";\n" + "}\n"; + static const char boot_src[] = + "namespace Acme.App;\n" + "public class Boot {\n" + " public string Boot1() {\n" + " var u = new UpdateService();\n" + " return u.Check();\n" + " }\n" + "}\n"; + const char *project = "cbm_cs_nested"; + static const char *rels[] = { + "src/Decoy/Layouts.cs", + "src/Decoy/OtherUpdate.cs", + "src/Partner/Cqrs/GrainCommand.cs", + "src/BackOffice/Cqrs/GrainCommand.cs", + "src/Globals/UpdateService.cs", + "src/App/NestedUser.cs", + "src/App/ChainedUser.cs", + "src/App/Boot.cs", + }; + const char *srcs[] = {decoy_src, other_update_src, partner_src, + backoffice_src, global_update_src, nested_user_src, + chained_user_src, boot_src}; + enum { N_FILES = 8, N_CHECKS = 8 }; + + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_nested_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + char paths[N_FILES][512]; + cbm_file_info_t files[N_FILES] = {0}; + for (int i = 0; i < N_FILES; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]); + if (th_write_file(paths[i], srcs[i]) != 0) { + th_rmtree(tmpdir); + FAIL("failed to write C# nested-type fixture"); + } + files[i].path = paths[i]; + files[i].rel_path = (char *)rels[i]; + files[i].language = CBM_LANG_CSHARP; + } + + cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL}; + g_harness_cs_registry = true; + graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2); + g_harness_cs_registry = false; + ASSERT_NOT_NULL(graphs[0]); + ASSERT_NOT_NULL(graphs[1]); + + const char *pf_code = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand." + "Result.Code"; + const char *pf_handle = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand." + "Handler.Handle"; + const char *bo_code = "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand." + "Result.Code"; + const char *decoy_code = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand." + "Result.Code"; + const char *decoy_handle = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand." + "Handler.Handle"; + const char *global_check = "cbm_cs_nested.src.Globals.UpdateService.UpdateService.Check"; + const char *other_check = "cbm_cs_nested.src.Decoy.OtherUpdate.UpdateService.Check"; + bool ok[2][N_CHECKS]; + bool all = true; + for (int g = 0; g < 2; g++) { + cbm_gbuf_t *gb = graphs[g]; + ok[g][0] = has_edge_from_callable_to_qn(gb, "NestedUser.Run", pf_code, "CALLS"); + ok[g][1] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run", bo_code, "CALLS") && + !has_edge_from_callable_to_qn(gb, "NestedUser.Run", decoy_code, "CALLS"); + /* The ctor EDGE itself joins through the pipeline's textual carrier and + * is out of scope here (see PR notes); what must hold is that the + * nested type RESOLUTION is right, proven by the Code()/Handle() edges + * above, and that no ctor edge binds to a variant the caller cannot + * see: neither the BackOffice nor the registry-first decoy. */ + ok[g][2] = !has_edge_from_callable_to_qn( + gb, "NestedUser.Run", + "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand.Result", + "CALLS") && + !has_edge_from_callable_to_qn( + gb, "NestedUser.Run", + "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand." + "Result.Result", + "CALLS"); + ok[g][3] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run", + "cbm_cs_nested.src.Decoy.Layouts.GrainCommand." + "GrainCommand.Result.Result", + "CALLS"); + ok[g][4] = has_edge_from_callable_to_qn(gb, "ChainedUser.Run", pf_handle, "CALLS"); + ok[g][5] = !has_edge_from_callable_to_qn(gb, "ChainedUser.Run", decoy_handle, "CALLS"); + ok[g][6] = has_edge_from_callable_to_qn(gb, "Boot.Boot1", global_check, "CALLS"); + ok[g][7] = !has_edge_from_callable_to_qn(gb, "Boot.Boot1", other_check, "CALLS"); + for (int k = 0; k < N_CHECKS; k++) { + all = all && ok[g][k]; + } + } + if (!all) { + for (int g = 0; g < 2; g++) { + printf(" C# nested/global diagnostic %s: nested_code=%d/%d nested_ctor=%d/%d " + "chained=%d/%d global_ns=%d/%d\n", + g == 0 ? "sequential" : "parallel", ok[g][0], ok[g][1], ok[g][2], ok[g][3], + ok[g][4], ok[g][5], ok[g][6], ok[g][7]); + } + } + cbm_gbuf_free(graphs[0]); + cbm_gbuf_free(graphs[1]); + th_rmtree(tmpdir); + ASSERT_TRUE(all); + PASS(); +} + /* External Kotlin protocol targets must not borrow a project method merely * because the final Class.method segments agree. Raw assertions prove that the * iterator/destructuring carriers and their stdlib semantic rows really exist; @@ -4786,6 +4964,7 @@ SUITE(parallel) { RUN_TEST(parallel_typescript_import_namespace_exact_parity); RUN_TEST(parallel_tsx_import_namespace_exact_parity); RUN_TEST(parallel_csharp_same_name_class_binds_by_namespace); + RUN_TEST(parallel_csharp_nested_type_and_global_namespace_binding); RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail); RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph); RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest); From 613060eadc4eb32c084256983b721205e7070f75 Mon Sep 17 00:00:00 2001 From: Ersin Tarhan Date: Mon, 28 Sep 2026 20:04:48 +0300 Subject: [PATCH 3/3] address review: drop leftover debug prints, assert imported-over-global order - remove two temporary DBG fprintf lines left in cs_resolve_object_creation (they fired on every C# object creation) - add ImportUser to the nested/global test: same simple name resolvable through an imported namespace AND present in the global namespace must bind to the imported variant, proving cs_visible_namespaces appends the global namespace last rather than merging it as an equal parallel suite: 76/76 PASS Signed-off-by: Ersin Tarhan --- internal/cbm/lsp/cs_lsp.c | 6 +----- tests/test_parallel.c | 25 +++++++++++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 4b2d8dc17c..23b6c39b55 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -2323,18 +2323,14 @@ static void cs_resolve_object_creation(CSLSPContext *ctx, TSNode call) { /* `new Foo(...)` adds an implicit constructor CALLS edge: to Foo's ctor * Method node when one is indexed, otherwise to the Foo class node. */ TSNode tnode = ts_node_child_by_field_name(call, "type", 4); - if (ts_node_is_null(tnode)) { - fprintf(stderr, "DBG ctor: no type field\n"); + if (ts_node_is_null(tnode)) return; - } const CBMType *t = cs_parse_type_node(ctx, tnode); const char *tqn = NULL; if (t && t->kind == CBM_TYPE_NAMED) tqn = t->data.named.qualified_name; else if (t && t->kind == CBM_TYPE_TEMPLATE) tqn = t->data.template_type.template_name; - fprintf(stderr, "DBG ctor: tqn=%s kind=%d enclosing=%s\n", tqn ? tqn : "(null)", - t ? (int)t->kind : -1, ctx->enclosing_func_qn ? ctx->enclosing_func_qn : "(null)"); if (!tqn) return; /* A C# constructor is extracted as a Method whose short name is the class's diff --git a/tests/test_parallel.c b/tests/test_parallel.c index e388e6394e..071f77d25b 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -2745,6 +2745,20 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { " return u.Check();\n" " }\n" "}\n"; + /* The lookup-order probe: the SAME simple name resolves through an + * imported namespace AND exists in the global namespace. C# binds the + * imported namespace first, so ImportUser.Run2 must hit the Other variant + * and never the global one — proving the global namespace is appended + * last in cs_visible_namespaces, not merged as an equal. */ + static const char import_user_src[] = + "using Acme.Other.Web.Services;\n" + "namespace Acme.App;\n" + "public class ImportUser {\n" + " public string Run2() {\n" + " var u = new UpdateService();\n" + " return u.Check();\n" + " }\n" + "}\n"; const char *project = "cbm_cs_nested"; static const char *rels[] = { "src/Decoy/Layouts.cs", @@ -2755,11 +2769,12 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { "src/App/NestedUser.cs", "src/App/ChainedUser.cs", "src/App/Boot.cs", + "src/App/ImportUser.cs", }; const char *srcs[] = {decoy_src, other_update_src, partner_src, backoffice_src, global_update_src, nested_user_src, - chained_user_src, boot_src}; - enum { N_FILES = 8, N_CHECKS = 8 }; + chained_user_src, boot_src, import_user_src}; + enum { N_FILES = 9, N_CHECKS = 10 }; char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_nested_XXXXXX"); @@ -2827,6 +2842,8 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { ok[g][5] = !has_edge_from_callable_to_qn(gb, "ChainedUser.Run", decoy_handle, "CALLS"); ok[g][6] = has_edge_from_callable_to_qn(gb, "Boot.Boot1", global_check, "CALLS"); ok[g][7] = !has_edge_from_callable_to_qn(gb, "Boot.Boot1", other_check, "CALLS"); + ok[g][8] = has_edge_from_callable_to_qn(gb, "ImportUser.Run2", other_check, "CALLS"); + ok[g][9] = !has_edge_from_callable_to_qn(gb, "ImportUser.Run2", global_check, "CALLS"); for (int k = 0; k < N_CHECKS; k++) { all = all && ok[g][k]; } @@ -2834,9 +2851,9 @@ TEST(parallel_csharp_nested_type_and_global_namespace_binding) { if (!all) { for (int g = 0; g < 2; g++) { printf(" C# nested/global diagnostic %s: nested_code=%d/%d nested_ctor=%d/%d " - "chained=%d/%d global_ns=%d/%d\n", + "chained=%d/%d global_ns=%d/%d imported=%d/%d\n", g == 0 ? "sequential" : "parallel", ok[g][0], ok[g][1], ok[g][2], ok[g][3], - ok[g][4], ok[g][5], ok[g][6], ok[g][7]); + ok[g][4], ok[g][5], ok[g][6], ok[g][7], ok[g][8], ok[g][9]); } } cbm_gbuf_free(graphs[0]);