From 50ee111714baa9d3bfe9bdba3b81e27cd1bdfe38 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Fri, 28 Aug 2026 17:26:26 +0200 Subject: [PATCH 1/9] fix(pipeline): suppress weak short-name matches for Go selector calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go selector call x.foo() whose receiver the Go LSP cannot type falls through to the generic registry resolver, which binds it by bare short name to an arbitrary same-named project symbol. Stdlib calls are the worst case: f.Close() on an *os.File gets a CALLS edge to whatever project Close wins candidate ranking (measured on a real Go repo: confidence 0.11, 15 candidates; suffix_match + unique_name were 36% of all CALLS edges, and one 14-line stdlib-only function got 3 out of 3 false outbound edges). Extend the TS/JS receiver-aware guard (#592/#606) to Go: - extract_calls.c: flag Go call_expression with a selector_expression callee as is_method, mirroring the TS/JS member_expression flag. - registry.c: add cbm_go_suppress_weak_method_match. Unlike the TS/JS drop-list, field_type_hint is KEPT (Go struct fields carry declared types, so the hint is receiver-aware — lrp_go_s8_field_type_hint), and unique_name is dropped only when its confidence carries the import-unreachability penalty (the stdlib-hijack shape); an unpenalized lone candidate inside the caller's import closure never enters the field-type-hint upgrade and must survive. - pass_calls.c / pass_parallel.c: feed the Go gate next to the TS/JS one; the drop still defers to the emit path so service/route/HTTP edges stay main-identical. Reproduce-first: pipeline_go_receiver_suppresses_weak_method_edge is RED without the extractor flag (the f.Close -> project Close edge exists) and GREEN with it; typed same-package calls, bare local calls and import-qualified cross-package calls still resolve. The old extraction contract test used Go as the flag-exempt language — Python takes that role, and extract_go_selector_call_flags_is_method pins the new behavior. Signed-off-by: Ilya Brykau --- internal/cbm/extract_calls.c | 18 +++++++ src/pipeline/pass_calls.c | 12 ++++- src/pipeline/pass_parallel.c | 8 ++- src/pipeline/pipeline.h | 9 ++++ src/pipeline/registry.c | 27 +++++++++++ tests/test_extraction.c | 39 +++++++++++++-- tests/test_pipeline.c | 94 ++++++++++++++++++++++++++++++++++++ tests/test_registry.c | 42 ++++++++++++++++ 8 files changed, 242 insertions(+), 7 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 47250203d8..6d315faf04 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -3712,6 +3712,24 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML } } } + // Go receiver-aware guard (same direction as the TS/JS flag above). + // Flag a selector call x.foo(). The Go AST cannot separate a method + // call on a value from a package-qualified call — but every selector + // call the Go LSP or the import/qualified registry strategies CAN + // place never reaches the weak short-name guards, so the flag only + // bites on unresolvable receivers (`f.Close()` on an os.File, + // `sha256.New()` behind an unindexed import), where a project-wide + // short-name match fabricates an edge to an unrelated project + // symbol sharing the name. Bare calls (helper()) keep + // is_method=false and resolve same-module/import paths as before. + if (ctx->language == CBM_LANG_GO && + strcmp(ts_node_type(node), "call_expression") == 0) { + TSNode gofn = ts_node_child_by_field_name(node, TS_FIELD("function")); + if (!ts_node_is_null(gofn) && + strcmp(ts_node_type(gofn), "selector_expression") == 0) { + call.is_method = true; + } + } TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); /* tree-sitter-elixir attaches NO field name to a call's arguments diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index c9744dda29..2d96d440d1 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -623,7 +623,13 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * language gated on only one resolver produces an edge on the sequential * path and not the parallel one (or vice versa), breaking MT determinism. * ArkTS belongs to the JS/TS family here (#1842); dropping it would - * reintroduce the #592/#606 false-edge class for .ets files. */ + * reintroduce the #592/#606 false-edge class for .ets files. + * + * Go (#1906) rides the same deferred-drop plumbing through its OWN + * predicate: its drop-list differs (field_type_hint is receiver-aware for + * Go, and unique_name drops only when import-unreachability-penalized), so + * it composes via cbm_go_suppress_weak_method_match instead of widening + * the shared gate. Same lockstep rule: mirror pass_parallel.c. */ bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; @@ -636,7 +642,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, bool drop_plain_call = cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || cbm_suppress_weak_local_binding_call(suppress_weak_local_binding, - call->callee_is_locally_bound, res.strategy); + call->callee_is_locally_bound, res.strategy) || + cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy, + res.confidence); /* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g. * `requests.get("/api/orders/{id}")`) resolve to a QN containing the library diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index f5e4ab4d22..3e896c1f42 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2834,7 +2834,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * #606 direction. * * This language set MUST match the one in pass_calls.c exactly — see the - * note there. ArkTS belongs to the JS/TS family (#1842). */ + * note there. ArkTS belongs to the JS/TS family (#1842). Go (#1906) + * composes via its own predicate (different drop-list — see + * cbm_go_suppress_weak_method_match), mirrored in pass_calls.c. */ bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; @@ -2844,7 +2846,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB bool drop_plain_call = cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || cbm_suppress_weak_local_binding_call(suppress_weak_local_binding, - call->callee_is_locally_bound, res.strategy); + call->callee_is_locally_bound, res.strategy) || + cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy, + res.confidence); /* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the * service signal lives in the callee_name. The registry can mis-resolve diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 954686849d..764f8bd3c5 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -297,6 +297,15 @@ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *st bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound, const char *strategy); +/* Go analog of the TS/JS guard, same failure class: a selector call whose + * receiver the Go LSP could not type must not be bound by a receiver-blind + * short-name strategy. Drops suffix_match / fuzzy always, and unique_name only + * when its confidence is import-unreachability-penalized (the stdlib/vendor + * hijack shape). field_type_hint is deliberately NOT dropped for Go — struct + * fields carry declared types, so the hint is receiver-aware there. */ +bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, + double confidence); + /* #725: drop a suffix_match CALLS edge when the caller language and the * target file's language disagree. unique_name (candidates == 1) is #1572 * and is left alone; same_module / import_map / lsp_* are kept. JS/TS/TSX diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index fa926d3c54..6eabdf2274 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -501,6 +501,33 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b return weak_short_name_strategy(strategy); } +bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, + double confidence) { + if (!is_go || !is_method || !strategy || !strategy[0]) { + return false; + } + /* Go analog of the TS/JS guard above, same failure class: a selector call + * whose receiver the Go LSP could not type reaches the registry and a bare + * short-name strategy binds it to an arbitrary same-named project symbol + * (`f.Close()` on an os.File -> a project `Close`, suffix_match over 15 + * candidates). Unlike the TS/JS list, field_type_hint is KEPT: a Go struct + * field carries a declared type, so the parallel resolver's field-type + * hint is receiver-aware for Go (lrp_go_s8_field_type_hint), not a + * heuristic. */ + if (strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "fuzzy") == 0) { + return true; + } + /* unique_name is dropped only when PENALIZED: resolve_name_lookup scales + * CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate + * is not reachable through the caller's imports — the stdlib/vendor + * hijack shape (`io.Copy` -> a project `Copy`). An unpenalized + * unique_name target sits inside the caller's import closure (or the + * file has no imports, e.g. a same-package call) and must be kept — + * dropping it kills genuinely-typed lone-candidate calls that never + * enter the field-type-hint upgrade (candidate_count == 1). */ + return strcmp(strategy, "unique_name") == 0 && confidence < CONF_UNIQUE_NAME; +} + static bool js_ts_family(CBMLanguage lang) { return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 14d75bf8bd..4bdab079ef 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -5204,9 +5204,14 @@ TEST(extract_perl_method_call_flags_is_method) { /* Languages OUTSIDE the is_method flag set (only Perl and TS/JS/TSX set it) must * be unaffected: a Go method call never sets is_method. */ TEST(extract_flag_exempt_method_call_not_flagged_is_method) { - CBMFileResult *r = extract("package m\n" - "func run(o Obj) { o.Commit(); helper() }\n", - CBM_LANG_GO, "t", "x.go"); + /* Rust is flag-exempt: only Perl, Python, TS/JS and Go set is_method. + * Guards the blast radius of the receiver-aware flags for every other + * language. */ + CBMFileResult *r = extract("fn run(o: Obj) {\n" + " o.commit();\n" + " helper();\n" + "}\n", + CBM_LANG_RUST, "t", "x.rs"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); for (int i = 0; i < r->calls.count; i++) { @@ -5364,6 +5369,33 @@ TEST(extract_python_bare_call_flags_locally_bound_callee) { PASS(); } +TEST(extract_go_selector_call_flags_is_method) { + /* Go selector calls are flagged so the weak-match guard can fire when the + * Go LSP cannot type the receiver; bare calls stay unflagged. */ + CBMFileResult *r = extract("package m\n" + "func run(o Obj) { o.Commit(); helper() }\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + bool saw_selector = false; + bool saw_bare = false; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (c->callee_name && strstr(c->callee_name, "Commit") != NULL) { + ASSERT_TRUE(c->is_method); + saw_selector = true; + } + if (c->callee_name && strcmp(c->callee_name, "helper") == 0) { + ASSERT_FALSE(c->is_method); + saw_bare = true; + } + } + ASSERT_TRUE(saw_selector); + ASSERT_TRUE(saw_bare); + cbm_free_result(r); + PASS(); +} + /* The bare-call flag is DEPTH-INDEPENDENT, and the binding is UNWOUND when its * scope closes. * @@ -7508,6 +7540,7 @@ SUITE(extraction) { RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_python_bare_call_flags_locally_bound_callee); RUN_TEST(extract_python_bare_call_flag_is_depth_independent); + RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 565c727196..dfa5fea20a 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5467,6 +5467,99 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_receiver_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* go.mod makes project imports resolvable — real Go repos always have one, + * and import reachability (the unique_name penalty) depends on it. */ + write_temp_file(tmp, "go.mod", "module example.com/myapp\n\ngo 1.22\n"); + /* The lone project symbol named "Close" — a real method. */ + write_temp_file(tmp, "storage/storage.go", + "package storage\n" + "\n" + "type Storage struct{ open bool }\n" + "\n" + "func NewStorage() *Storage { return &Storage{open: true} }\n" + "\n" + "func (s *Storage) Close() {\n" + "\ts.open = false\n" + "}\n" + "\n" + "func Boot() {\n" + "\ts := NewStorage()\n" + "\ts.Close()\n" + "}\n"); + /* Cross-package control target: imported by hash.go, so the caller file + * has a non-empty import map (like any real Go file) and unreachable + * unique_name candidates get the import penalty. */ + write_temp_file(tmp, "util/util.go", + "package util\n" + "\n" + "func Tag() string { return \"t\" }\n"); + /* Stdlib receiver: `f.Close()` closes an *os.File, NOT the project method. + * The Go LSP cannot bind it to a project symbol → the registry would guess + * Close by short name (weak). This is the false edge to suppress — + * the exact shape that attached every file/rows/gzip Close in a real Go + * repo to one unrelated project method. */ + write_temp_file(tmp, "hash/hash.go", + "package hash\n" + "\n" + "import (\n" + "\t\"os\"\n" + "\n" + "\t\"example.com/myapp/util\"\n" + ")\n" + "\n" + "func FileLen(path string) int64 {\n" + "\tf, err := os.Open(path)\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\tdefer f.Close()\n" + "\tst, err := f.Stat()\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\treturn st.Size()\n" + "}\n" + "\n" + "func localHelper() int { return 1 }\n" + "\n" + "func CallsLocal() int { return localHelper() }\n" + "\n" + "func UsesUtil() string { return util.Tag() }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_recv.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* (1) The false edge is suppressed (reproduce-first: RED before the fix). */ + ASSERT_FALSE(cross_file_call_exists(s, project, "FileLen", "Close")); + /* (2) The same-package typed-receiver call survives (LSP / same_module — + * both outside the weak drop-list). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "Boot", "Close")); + /* (3) The bare local call survives (is_method stays false for bare calls). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "CallsLocal", "localHelper")); + /* (4) The import-qualified cross-package call survives (import-aware + * strategies are outside the drop-list). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Fixture for the #1928 cross-language reference-guard probes (sequential and * parallel twins). pad_files > 0 adds filler files to push the index over the * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per @@ -14594,6 +14687,7 @@ SUITE(pipeline) { #endif RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); RUN_TEST(pipeline_go_bare_ref_never_binds_field); diff --git a/tests/test_registry.c b/tests/test_registry.c index 247ca61adf..aaeb70abb4 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -973,6 +973,46 @@ TEST(weak_call_guards_share_one_drop_list) { PASS(); } +TEST(go_suppress_drops_weak_selector_matches) { + /* Go selector call with an untyped receiver, landed via a receiver-blind + * short-name strategy → drop (same failure class as #592/#606). + * suffix_match/fuzzy drop at any confidence; unique_name drops only when + * import-unreachability-penalized (CONF_UNIQUE_NAME 0.75 * 0.5 = 0.375 — + * the `io.Copy` -> project `Copy` stdlib-hijack shape). */ + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.9)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.11)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "fuzzy", 0.9)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.375)); + PASS(); +} + +TEST(go_suppress_keeps_typed_and_import_aware_matches) { + /* Unpenalized unique_name = lone candidate inside the caller's import + * closure (or an import-free file, e.g. same-package) — a genuinely-typed + * lone-candidate call never enters the field-type-hint upgrade, so it must + * survive (lrp_go_s8_field_type_hint). */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.75)); + /* field_type_hint is receiver-aware for Go — struct fields carry declared + * types (lrp_go_s8_field_type_hint) — so it stays, unlike the TS/JS list. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "field_type_hint", 0.85)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "same_module", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map", 0.95)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map_suffix", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "qualified_suffix", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "callee_suffix", 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "service_pattern", 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_strategy_cross_file", 0.92)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_direct", 0.95)); + /* A bare call (is_method=false) is a free-function call → never suppressed. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, false, "suffix_match", 0.11)); + /* Non-Go languages are never affected by this gate. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(false, true, "suffix_match", 0.11)); + /* No match (NULL/empty strategy) → nothing to suppress. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, NULL, 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "", 0.5)); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* Method call THROUGH an imported symbol that is itself an indexed node @@ -1072,4 +1112,6 @@ SUITE(registry) { RUN_TEST(local_binding_suppress_drops_weak_shadowed_bare_calls); RUN_TEST(local_binding_suppress_keeps_unshadowed_and_strong_strategies); RUN_TEST(weak_call_guards_share_one_drop_list); + RUN_TEST(go_suppress_drops_weak_selector_matches); + RUN_TEST(go_suppress_keeps_typed_and_import_aware_matches); } From da7619474d5ad27ff5073cf505b0b6cd5d0db36e Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sat, 29 Aug 2026 18:04:16 +0200 Subject: [PATCH 2/9] fix(extract): receiver-qualify Go method QNs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go method's QN was the flat package form (proj.pkg.method) — the receiver was ignored, so every same-name method in a package collided on one QN and the graph upsert kept exactly one node. Measured on a real Go repo: 20 Process/Name methods across 15 files kept 2 nodes; 9 different Task() methods fused into one chimera node carrying all nine bodies' call edges; 19 structs pointed DEFINES_METHOD at a single shared method node; a _test.go mock Close outranked the production Close in the dedupe tie-break. The upsert's own comment calls kind-disambiguated QNs 'the real cure'. Qualify the QN with the receiver type (proj.pkg.Recv.method), the same shape as Go interface members and the C++ out-of-line method path right below it in extract_func_def: - extract_defs.c: def.qualified_name = parent_class + name whenever the receiver type resolves; go_receiver_type_name becomes the shared cbm_go_receiver_type_name (exported via helpers.h) so both sides of the contract use one formula. - extract_unified.c (compute_func_qn): mirror branch for method_declaration, so method-body calls keep exact source attribution instead of degrading to File-node fallback (calls_find_source). - Consumers already agree: pxc_build_lsp_def passes the def QN and parent_class (receiver_type) verbatim into the Go LSP registries, and check_go_class_implements explicitly supports class-qualified method QNs (its path (b)). Side effect: resolve_same_module's exact module.name hash no longer matches concrete methods, which kills the conf-0.9 false edges where an interface-typed call bound to an unrelated same-package method. Fixes #1909 Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 14 +++++++-- internal/cbm/extract_unified.c | 19 +++++++++++++ internal/cbm/helpers.h | 6 ++++ internal/cbm/lsp/go_lsp.c | 25 ++++++++++------ tests/test_extraction.c | 50 ++++++++++++++++++++++++++++++-- tests/test_parallel.c | 5 ++-- tests/test_pipeline.c | 52 ++++++++++++++++++++++++++++++++++ 7 files changed, 156 insertions(+), 15 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b49147dc61..d2244c0341 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3521,7 +3521,7 @@ static void set_def_complexity(CBMDefinition *def, TSNode body, const CBMLangSpe * Walks to the parameter_declaration's `type` field, unwrapping pointer_type * and generic_type, and returns the type_identifier text (e.g. "OrderService"). * Returns NULL if no type_identifier is found. */ -static char *go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) { +char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) { uint32_t nc = ts_node_child_count(recv); for (uint32_t i = 0; i < nc; i++) { TSNode child = ts_node_child(recv, i); @@ -3733,12 +3733,22 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * (and downstream Go IMPLEMENTS/OVERRIDE) link the method to its owning * struct/type node. The parent QN must match the type's node QN, which * is computed the same way (cbm_fqn_compute on the type name). */ - char *recv_type = go_receiver_type_name(a, recv, ctx->source); + char *recv_type = cbm_go_receiver_type_name(a, recv, ctx->source); if (recv_type && recv_type[0]) { /* Must match the Go type node QN (directory-based module) so the * DEFINES_METHOD edge links the method to its owning type. */ def.parent_class = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, recv_type, ctx->language); + /* Receiver-qualify the method QN (proj.pkg.Recv.method) — same + * shape as the C++ out-of-line path below and Go interface + * members. With the flat proj.pkg.method QN every same-name + * method in a package collided in the graph upsert: one body + * survived and the twins' call edges accreted onto it. The + * call-scope side (compute_func_qn in extract_unified.c) mirrors + * this formula, and go_lsp consumers read the def QN and + * parent_class (receiver_type) verbatim, so resolution joins + * stay exact. */ + def.qualified_name = cbm_arena_sprintf(a, "%s.%s", def.parent_class, name); } } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index cd7f3bd28f..ee13cc8fd5 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1087,6 +1087,25 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Go method `func (s *Storage) Close() {...}`: the def extractor records + * this as Method "proj.pkg.Storage.Close" (receiver-qualified, mirroring + * the C++ out-of-line rule above). The call-scope QN must match — a bare + * "proj.pkg.Close" names a node that no longer exists, so every call + * inside the method body would fall back to File-node attribution + * (calls_find_source). Same ONE-formula contract as the def side: + * cbm_go_receiver_type_name + cbm_fqn_compute_source_lang. */ + if (ctx->language == CBM_LANG_GO && strcmp(ts_node_type(node), "method_declaration") == 0) { + TSNode recv = ts_node_child_by_field_name(node, TS_FIELD("receiver")); + if (!ts_node_is_null(recv)) { + char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source); + if (recv_type && recv_type[0]) { + const char *type_qn = cbm_fqn_compute_source_lang( + ctx->arena, ctx->project, ctx->rel_path, recv_type, ctx->language); + return cbm_arena_sprintf(ctx->arena, "%s.%s", type_qn, name); + } + } + } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def * extractor bakes it into the def QN. Compose it identically here — otherwise * an in-body call sources to a QN one or more segments short of the def, and diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 905de04a8d..08600b9b1a 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -119,6 +119,12 @@ TSNode cbm_resolve_func_name(TSNode node, CBMLanguage lang); // def extractor — drift dropped the class qualifier from in-body calls (#554/#621). char *cbm_cpp_out_of_line_parent_class(CBMArena *a, TSNode node, const char *source); +/* Go: resolve a method_declaration's receiver parameter_list down to the bare + * receiver type_identifier (unwrapping pointer_type / generic_type). Shared by + * def extraction (extract_defs.c) and call-scope attribution + * (extract_unified.c) so the receiver-qualified method QN has ONE formula. */ +char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source); + // Find a child node by kind string. TSNode cbm_find_child_by_kind(TSNode parent, const char *kind); diff --git a/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index 7fec20a9f5..7d9c526212 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -1861,15 +1861,24 @@ static void process_function(GoLSPContext* ctx, TSNode func_node) { char* func_name = lsp_node_text(ctx, name_node); if (!func_name || !func_name[0]) return; - // Enclosing-function QN must be the BARE package.Func form (no receiver - // type segment). The textual call events (extract_unified.c) source calls - // as package_qn.func_name — methods included — and the defs pass creates - // the graph Method node under the same QN, so any other form breaks the - // caller-QN join in cbm_pipeline_find_lsp_resolution and the LSP-resolved - // call silently falls back to the registry short-name resolver. The - // receiver type still reaches the registry via the def's parent_class / - // method->receiver_type; it just does not appear in the caller QN. + // Enclosing-function QN must be EXACTLY the QN the textual call events + // (extract_unified.c compute_func_qn) and the defs pass produce, or the + // caller-QN join in cbm_pipeline_find_lsp_resolution breaks and the + // LSP-resolved call silently falls back to the registry short-name + // resolver. Since #1909 receiver-qualified Go method QNs, that shared + // form is package_qn.ReceiverType.method for methods (the ONE-formula + // contract: cbm_go_receiver_type_name) and package_qn.func for functions. ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); + if (strcmp(ts_node_type(func_node), "method_declaration") == 0) { + TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8); + if (!ts_node_is_null(recv)) { + char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source); + if (recv_type && recv_type[0]) { + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s.%s", ctx->package_qn, + recv_type, func_name); + } + } + } // Push function scope CBMScope* saved_scope = ctx->current_scope; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 4bdab079ef..41e5a8c1c1 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4715,12 +4715,12 @@ TEST(extract_go_no_filename_in_module_qn) { ASSERT_NOT_NULL(conn); ASSERT_STR_EQ(conn->qualified_name, "proj.myapp.db.Conn"); - /* Go method nodes keep a FLAT QN (module + name) with a separate - * parent_class link to the receiver type — the QN must carry the + /* Go method nodes carry a receiver-qualified QN (module + receiver type + + * name) plus the parent_class link — the QN must carry the * directory-based module and NOT the `.conn.` filename segment. */ const CBMDefinition *query = find_def_by_name(r, "Query"); ASSERT_NOT_NULL(query); - ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Query"); + ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Conn.Query"); ASSERT_EQ(strstr(query->qualified_name, ".conn."), NULL); /* The method's parent_class must match the type node QN (for DEFINES_METHOD). */ ASSERT_NOT_NULL(query->parent_class); @@ -5501,6 +5501,49 @@ TEST(extract_python_bare_call_flag_is_depth_independent) { PASS(); } +TEST(extract_go_method_receiver_qualified_qn) { + /* A Go method QN carries the receiver type (proj.pkg.Recv.method), same + * shape as C++ out-of-line methods and Go interface members — so two + * same-name methods on different receivers no longer collide in the + * graph upsert. Free functions keep the flat package QN. */ + CBMFileResult *r = extract("package m\n" + "type Storage struct{}\n" + "type Cache struct{}\n" + "func (s *Storage) Close() {}\n" + "func (c Cache) Close() {}\n" + "func Shutdown() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + bool saw_storage = false; + bool saw_cache = false; + bool saw_free = false; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || !d->qualified_name) { + continue; + } + if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Storage.Close") == 0) { + ASSERT_STR_EQ(d->label, "Method"); + ASSERT_STR_EQ(d->parent_class, "t.Storage"); + saw_storage = true; + } + if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Cache.Close") == 0) { + ASSERT_STR_EQ(d->parent_class, "t.Cache"); + saw_cache = true; + } + if (strcmp(d->name, "Shutdown") == 0) { + ASSERT_STR_EQ(d->qualified_name, "t.Shutdown"); + saw_free = true; + } + } + ASSERT_TRUE(saw_storage); + ASSERT_TRUE(saw_cache); + ASSERT_TRUE(saw_free); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -7541,6 +7584,7 @@ SUITE(extraction) { RUN_TEST(extract_python_bare_call_flags_locally_bound_callee); RUN_TEST(extract_python_bare_call_flag_is_depth_independent); RUN_TEST(extract_go_selector_call_flags_is_method); + RUN_TEST(extract_go_method_receiver_qualified_qn); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 13477b67c4..6ae812528b 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3444,8 +3444,9 @@ TEST(parallel_go_cross_package_field_chain_resolves) { cbm_gbuf_t *gbuf = run_go_field_chain_sequential("go_field_fold", tmpdir, files, 2); ASSERT_NOT_NULL(gbuf); - const cbm_gbuf_edge_t *edge = - find_call_edge_to_target_fragment(gbuf, "handler.PlaceOrder", ".service.PlaceOrder"); + /* Since #1909, Go method QNs are receiver-qualified on both endpoints. */ + const cbm_gbuf_edge_t *edge = find_call_edge_to_target_fragment( + gbuf, "OrderHandler.PlaceOrder", ".service.OrderService.PlaceOrder"); const bool found = edge != NULL; const bool dispatch = edge && edge->properties_json && strstr(edge->properties_json, "\"strategy\":\"lsp_type_dispatch\""); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index dfa5fea20a..9cc757b96b 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5467,6 +5467,57 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_method_caller_keeps_lsp_join) { + /* #1909: receiver-qualifying method QNs moves the def/textual-call QN to + * package.Type.method — go_lsp's enclosing-function QN must move with it + * (the ONE-formula contract), or every LSP resolution sourced from inside + * a method body loses its caller join and the edge dies. The callee name + * collides across receivers so no short-name fallback can mask the loss. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_mcall_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "go.mod", "module example.com/fxmcall\n\ngo 1.22\n"); + write_temp_file(tmp, "svc/repo.go", + "package svc\n" + "\n" + "type Repo struct {\n" + "\tn int\n" + "}\n" + "\n" + "func (r *Repo) Install() int {\n" + "\treturn r.helper()\n" + "}\n"); + write_temp_file(tmp, "svc/helper.go", + "package svc\n" + "\n" + "func (r *Repo) helper() int {\n" + "\treturn r.n\n" + "}\n"); + write_temp_file(tmp, "other/other.go", + "package other\n" + "\n" + "type Decoy struct{}\n" + "\n" + "func (d *Decoy) helper() int { return 2 }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_mcall.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(cross_file_call_exists(s, project, "Install", "helper")); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + TEST(pipeline_go_receiver_suppresses_weak_method_edge) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); @@ -14687,6 +14738,7 @@ SUITE(pipeline) { #endif RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_method_caller_keeps_lsp_join); RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); From 7528ec06fe7da5ab703e7c802624fef88285ae37 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 22:33:37 +0200 Subject: [PATCH 3/9] test(pipeline): bind the Go selector guard on the parallel resolver Review follow-up for #1907 (asks from the 2026-09-28 review): - pipeline_go_receiver_parallel_suppresses_weak_method_edge: parallel twin of pipeline_go_receiver_suppresses_weak_method_edge. The guard is wired into pass_parallel.c as well as pass_calls.c, but the four-file fixture only runs the sequential resolver, so the parallel hunk was bound by no test. The fixture is shared (write_go_receiver_fixture) and padded with 52 Go files past MIN_FILES_FOR_PARALLEL, with CBM_WORKERS=4, in the shape of the Python parallel twin. - registry.c: cbm_go_suppress_weak_method_match builds on the shared weak_short_name_strategy list and narrows it in two places only (field_type_hint kept, unique_name only when penalized). Behaviour is unchanged; weak_call_guards_share_one_drop_list now binds the Go guard to the shared list too. - test_extraction.c: the flag-exempt comment names the current flag set (Perl, Python, TS/JS/TSX and Go) and Rust as the exempt language. Refs #1906 Signed-off-by: Martin Vogel --- src/pipeline/registry.c | 24 +++++++------ tests/test_extraction.c | 4 +-- tests/test_pipeline.c | 78 +++++++++++++++++++++++++++++++++++++---- tests/test_registry.c | 10 ++++++ 4 files changed, 97 insertions(+), 19 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index cbfa4ec5d1..bfc2e330eb 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -750,19 +750,23 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, double confidence) { - if (!is_go || !is_method || !strategy || !strategy[0]) { - return false; - } /* Go analog of the TS/JS guard above, same failure class: a selector call * whose receiver the Go LSP could not type reaches the registry and a bare * short-name strategy binds it to an arbitrary same-named project symbol * (`f.Close()` on an os.File -> a project `Close`, suffix_match over 15 - * candidates). Unlike the TS/JS list, field_type_hint is KEPT: a Go struct - * field carries a declared type, so the parallel resolver's field-type - * hint is receiver-aware for Go (lrp_go_s8_field_type_hint), not a - * heuristic. */ - if (strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "fuzzy") == 0) { - return true; + * candidates). Built on the shared drop-list so "weak" means one thing + * for every guard; Go narrows it in two places only. */ + if (!is_go || !is_method || !weak_short_name_strategy(strategy)) { + return false; + } + /* field_type_hint is KEPT for Go: a Go struct field carries a declared + * type, so the parallel resolver's field-type hint is receiver-aware for + * Go (lrp_go_s8_field_type_hint), not a heuristic. */ + if (strcmp(strategy, "field_type_hint") == 0) { + return false; + } + if (strcmp(strategy, "unique_name") != 0) { + return true; /* suffix_match, fuzzy */ } /* unique_name is dropped only when PENALIZED: resolve_name_lookup scales * CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate @@ -772,7 +776,7 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s * file has no imports, e.g. a same-package call) and must be kept — * dropping it kills genuinely-typed lone-candidate calls that never * enter the field-type-hint upgrade (candidate_count == 1). */ - return strcmp(strategy, "unique_name") == 0 && confidence < CONF_UNIQUE_NAME; + return confidence < CONF_UNIQUE_NAME; } /* Import-binding counterpart of the parameter guard above (#2127). An import diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 41a796ce85..13b03aa370 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -6886,8 +6886,8 @@ TEST(extract_perl_method_call_flags_is_method) { PASS(); } -/* Languages OUTSIDE the is_method flag set (only Perl and TS/JS/TSX set it) must - * be unaffected: a Go method call never sets is_method. */ +/* Languages OUTSIDE the is_method flag set (Perl, Python, TS/JS/TSX and Go set + * it) must be unaffected: a Rust method call never sets is_method. */ TEST(extract_flag_exempt_method_call_not_flagged_is_method) { /* Rust is flag-exempt: only Perl, Python, TS/JS and Go set is_method. * Guards the blast radius of the receiver-aware flags for every other diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4da054b144..d413aa9bb9 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8650,13 +8650,11 @@ TEST(pipeline_html_embedded_member_call_stays_unbound) { PASS(); } -TEST(pipeline_go_receiver_suppresses_weak_method_edge) { - char tmp[256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); - if (!cbm_mkdtemp(tmp)) { - FAIL("tmpdir"); - } - +/* Fixture for the #1906 Go selector-guard probes (sequential and parallel + * twins). pad_files > 0 adds filler Go files to push the index over + * MIN_FILES_FOR_PARALLEL, because the guard is wired into both resolvers + * (pass_calls.c and pass_parallel.c) and each needs its own probe. */ +static void write_go_receiver_fixture(const char *tmp, int pad_files) { /* go.mod makes project imports resolvable — real Go repos always have one, * and import reachability (the unique_name penalty) depends on it. */ write_temp_file(tmp, "go.mod", "module example.com/myapp\n\ngo 1.22\n"); @@ -8715,6 +8713,22 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { "func CallsLocal() int { return localHelper() }\n" "\n" "func UsesUtil() string { return util.Tag() }\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler/filler%d.go", i); + snprintf(body, sizeof(body), "package filler\n\nfunc Filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +TEST(pipeline_go_receiver_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_receiver_fixture(tmp, 0); char db_path[512]; snprintf(db_path, sizeof(db_path), "%s/go_recv.db", tmp); @@ -8743,6 +8757,55 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { PASS(); } +/* Parallel twin of the test above. The guard is wired into pass_parallel.c as + * well as pass_calls.c, but the four-file fixture only ever runs the + * sequential resolver, so the parallel hunk was bound by no test. Real + * repositories take the parallel path: >= MIN_FILES_FOR_PARALLEL (50) files + * plus CBM_WORKERS=4 force it, the shape of + * pipeline_python_receiver_parallel_suppresses_weak_method_edges. Same + * both-directions pin as the sequential test. */ +TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_receiver_fixture(tmp, 52); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_recv_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* NEGATIVE: the stdlib *os.File Close must not bind the project method. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "FileLen", "Close")); + /* POSITIVE: typed same-package, bare local and import-qualified calls + * survive the parallel resolver too. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "Boot", "Close")); + ASSERT_TRUE(cross_file_call_exists(s, project, "CallsLocal", "localHelper")); + ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* Fixture for the #1928 cross-language reference-guard probes (sequential and * parallel twins). pad_files > 0 adds filler files to push the index over the * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per @@ -23707,6 +23770,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_keeps_specific_unique_name_member_call); RUN_TEST(pipeline_html_embedded_member_call_stays_unbound); RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method); diff --git a/tests/test_registry.c b/tests/test_registry.c index e25ba369e6..a6fbd62011 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1673,6 +1673,16 @@ TEST(weak_call_guards_share_one_drop_list) { strategies[i], member, binding); } ASSERT_EQ(member, binding); + /* The Go selector guard builds on the same list and narrows it in + * exactly one place at a penalized confidence (0.75 * 0.5): it keeps + * field_type_hint. Any other drift from the shared list fails here. */ + bool go = cbm_go_suppress_weak_method_match(true, true, strategies[i], 0.375); + bool go_expected = member && strcmp(strategies[i], "field_type_hint") != 0; + if (go != go_expected) { + printf(" Go drop-list divergence on strategy \"%s\": go=%d expected=%d\n", + strategies[i], go, go_expected); + } + ASSERT_EQ(go, go_expected); } PASS(); } From 87cafb9ef8723b42e6348a9b2be864575a476699 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 22:40:55 +0200 Subject: [PATCH 4/9] fix(extract): bump the index format for receiver-qualified Go method QNs Review follow-up for #1913 (asks from the 2026-09-28 review), plus one finding from the review of the updated head: - store.h: CBM_INDEX_FORMAT_VERSION 3 -> 4. Every Go method QN changes (pkg.method -> pkg.Recv.method), so an index built before this change and refreshed incrementally would mix flat and receiver-qualified method nodes, with the old fused nodes keeping their stale edges. The bump routes such an index through one full rebuild. - pipeline_go_method_caller_keeps_lsp_join: the by-name assertion would also pass for a binding onto the decoy other.Decoy.helper. The test now pins the edge by QN suffix on both ends (.svc.Repo.Install -> .svc.Repo.helper) with an lsp_* strategy, and asserts no edge to the decoy. - extract_defs.c: the receiver-qualified def QN is Go-only. The templ grammar is Go-derived and reaches the same receiver branch, but its call-scope QN (compute_func_qn) stays flat, so a templ method would get a def QN no call names and its calls would degrade to the File node. extract_templ_method_qn_matches_call_scope pins def QN == call scope. - pass_semantic.c: the check_go_class_implements comment no longer describes Go method QNs as flat. Refs #1909 Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 9 ++++++-- src/pipeline/pass_semantic.c | 6 ++--- src/store/store.h | 6 +++-- tests/test_extraction.c | 43 ++++++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 42 +++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 9bf027d176..176d0a0b84 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6696,8 +6696,13 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * call-scope side (compute_func_qn in extract_unified.c) mirrors * this formula, and go_lsp consumers read the def QN and * parent_class (receiver_type) verbatim, so resolution joins - * stay exact. */ - def.qualified_name = cbm_arena_sprintf(a, "%s.%s", def.parent_class, name); + * stay exact. Go only: templ's Go-derived grammar reaches this + * branch too, but its call-scope QN (compute_func_qn) stays flat, + * and a def QN the calls do not share would orphan every call + * inside a templ method. */ + if (ctx->language == CBM_LANG_GO) { + def.qualified_name = cbm_arena_sprintf(a, "%s.%s", def.parent_class, name); + } } } diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 186b2dfdca..bc23e0d261 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -266,9 +266,9 @@ static int check_go_class_implements(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_nod } /* Resolve the struct's methods two ways and use whichever finds them: * (a) the struct's DEFINES_METHOD edges, matched by method NAME — the real - * pipeline path, where Go receiver methods carry a flat QN (e.g. - * "pkg.Area" rather than "pkg.Circle.Area"), so QN-string - * reconstruction would miss; and + * pipeline path. Go method QNs are receiver-qualified since #1913 + * ("pkg.Circle.Area"), so (b) matches them as well, but the edges + * stay the primary join; and * (b) the reconstructed QN "." — used by tests and any * extractor that does emit class-qualified method QNs without * DEFINES_METHOD edges from the class. */ diff --git a/src/store/store.h b/src/store/store.h index 803df81d87..0f291daf9c 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -32,8 +32,10 @@ typedef struct cbm_store_read_scope cbm_store_read_scope_t; * Dockerfile. keeps its suffix in its QNs. * 3 Image identity invents no registry: a reference without a host is * __image__, registry "unknown" -- no longer - * __image__docker.io/[library/]. */ -#define CBM_INDEX_FORMAT_VERSION 3 + * __image__docker.io/[library/]. + * 4 #1913: Go method QNs are receiver-qualified (pkg.Recv.method, generic + * receivers unwrapped); free functions stay pkg.func. */ +#define CBM_INDEX_FORMAT_VERSION 4 #define CBM_STORE_CANCELLED (-3) #define CBM_STORE_SCAN_LIMIT (-4) #define CBM_STORE_CALLBACK_ERR (-5) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8dfc5e6e19..65919f6c3d 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -7229,6 +7229,48 @@ TEST(extract_go_method_receiver_qualified_qn) { PASS(); } +/* Templ shares Go's method grammar (method_declaration with a receiver), but + * only Go method QNs are receiver-qualified (#1913); templ's call-scope QN + * stays flat. The method's def QN and the enclosing-function QN of a call + * inside it must agree, or the call has no source node and degrades to the + * File node. */ +TEST(extract_templ_method_qn_matches_call_scope) { + CBMFileResult *r = extract("package views\n" + "\n" + "type Page struct{}\n" + "\n" + "func (p Page) Title() string {\n" + "\treturn helper()\n" + "}\n" + "\n" + "func helper() string { return \"x\" }\n" + "\n" + "templ hello(name string) {\n" + "\t
{ name }
\n" + "}\n", + CBM_LANG_TEMPL, "t", "x.templ"); + ASSERT_NOT_NULL(r); + const char *title_qn = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->name && d->qualified_name && strcmp(d->name, "Title") == 0) { + title_qn = d->qualified_name; + } + } + const char *scope = NULL; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (c->callee_name && strcmp(c->callee_name, "helper") == 0) { + scope = c->enclosing_func_qn; + } + } + ASSERT_NOT_NULL(title_qn); + ASSERT_NOT_NULL(scope); + ASSERT_STR_EQ(scope, title_qn); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -12301,6 +12343,7 @@ SUITE(extraction) { RUN_TEST(extract_python_bare_call_flag_is_depth_independent); RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_go_method_receiver_qualified_qn); + RUN_TEST(extract_templ_method_qn_matches_call_scope); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 04925d6dcc..bb4cd174dd 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8650,6 +8650,41 @@ TEST(pipeline_html_embedded_member_call_stays_unbound) { PASS(); } +/* CALLS edges whose source and target QNs end with the given suffixes and + * whose serialized properties contain `props_fragment` (NULL: any edge). QN + * suffixes pin the exact node, not just a same-named one. */ +static int go_calls_between(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *props_fragment) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count); + size_t cs = strlen(caller_suffix); + size_t ts = strlen(callee_suffix); + int hits = 0; + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK && + src.qualified_name && tgt.qualified_name) { + size_t sl = strlen(src.qualified_name); + size_t tl = strlen(tgt.qualified_name); + if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && + strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0 && + (!props_fragment || + (edges[i].properties_json && strstr(edges[i].properties_json, props_fragment)))) { + hits++; + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + return hits; +} + TEST(pipeline_go_method_caller_keeps_lsp_join) { /* #1909: receiver-qualifying method QNs moves the def/textual-call QN to * package.Type.method — go_lsp's enclosing-function QN must move with it @@ -8695,6 +8730,13 @@ TEST(pipeline_go_method_caller_keeps_lsp_join) { cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); ASSERT_TRUE(cross_file_call_exists(s, project, "Install", "helper")); + /* Pinned to the receiver-qualified target in svc/helper.go and to an LSP + * strategy: a name-only binding, or one onto other.Decoy.helper, would + * still satisfy the by-name check above. */ + ASSERT_GTE(go_calls_between(s, project, ".svc.Repo.Install", ".svc.Repo.helper", + "\"strategy\":\"lsp_"), + 1); + ASSERT_EQ(go_calls_between(s, project, ".svc.Repo.Install", ".other.Decoy.helper", NULL), 0); cbm_store_close(s); cbm_pipeline_free(p); th_rmtree(tmp); From db8d547ae1acb444f69cf08a5f81d4e8ec5e83d9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 23:37:31 +0200 Subject: [PATCH 5/9] fix(extract): keep both Go method QN sides flat when the type QN is NULL cbm_fqn_compute_source_lang returns NULL only when the module QN cannot be built (arena exhaustion). The receiver-qualified branches then passed that NULL to "%s". Both sides now fall back to the flat QN together, so the def and its calls still agree. Allocation-failure path only; no change on any normal run. (Raised in our security review of the updated head.) Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 2 +- internal/cbm/extract_unified.c | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 176d0a0b84..1257945a5c 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6700,7 +6700,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * branch too, but its call-scope QN (compute_func_qn) stays flat, * and a def QN the calls do not share would orphan every call * inside a templ method. */ - if (ctx->language == CBM_LANG_GO) { + if (ctx->language == CBM_LANG_GO && def.parent_class) { def.qualified_name = cbm_arena_sprintf(a, "%s.%s", def.parent_class, name); } } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 009a911940..aac32e7862 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1109,7 +1109,11 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan if (recv_type && recv_type[0]) { const char *type_qn = cbm_fqn_compute_source_lang( ctx->arena, ctx->project, ctx->rel_path, recv_type, ctx->language); - return cbm_arena_sprintf(ctx->arena, "%s.%s", type_qn, name); + /* NULL only on allocation failure: fall through to the flat + * QN, as the def side does (extract_defs.c). */ + if (type_qn) { + return cbm_arena_sprintf(ctx->arena, "%s.%s", type_qn, name); + } } } } From e554a2ab58ca954c2e9f46ba3fe9d340d1bcf06d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 16:54:36 +0200 Subject: [PATCH 6/9] fix(go): no field-type hint for Go; it is a name guess, par-only The parallel resolver re-picks a multi-candidate registry match through try_field_type_hint. That helper capitalizes the receiver VARIABLE name and accepts any candidate whose QN contains it as a substring: `t.Run` on a *testing.T became `T` and bound a project `TBench.Run`. The sequential resolver has no such step. The Go selector guard treated field_type_hint as receiver-aware and kept it, so the same weak pick it drops in sequential mode survived in parallel mode under a new name. On caddy + prometheus, parallel and sequential CALLS diverged by 1,684 (this branch) and 5,560 (with receiver-qualified method QNs, which put type names into more QNs). At base the gap is 84. SCIP precision of Go field_type_hint is 0.016 at base. Go no longer takes the hint. The guard keeps field_type_hint on the shared weak list, and the fixture of both twins now pins the `t.Run` case. A typed field call (lrp_go_s8, `s.repo.Find()`) resolves through the Go LSP and is unaffected. Signed-off-by: Martin Vogel --- src/pipeline/pass_parallel.c | 10 +++++++++- src/pipeline/registry.c | 14 ++++++-------- tests/test_pipeline.c | 28 ++++++++++++++++++++++++++++ tests/test_registry.c | 15 ++++++++------- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 37bdc26d49..56a85e8a25 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3298,7 +3298,15 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } _rc_t0 = extract_now_ns(); - try_field_type_hint(rc, &res, call->callee_name, source_node->id); + /* Not for Go (F-G9): the hint matches the capitalized receiver + * VARIABLE name as a substring of a candidate QN (`t.Run` -> any + * `...T...Run`), with no type evidence. The sequential resolver has no + * such step, so for Go it produced parallel-only edges (par/seq CALLS + * diverged), and the Go selector guard could not see them as the weak + * pick they are. SCIP precision of Go field_type_hint: 0.016. */ + if (lang != CBM_LANG_GO) { + try_field_type_hint(rc, &res, call->callee_name, source_node->id); + } /* Cross-language veto — the same predicate as pass_calls.c, applied * AFTER the field-type hint so the hint may still re-pick a compatible * candidate first, and BEFORE the empty-resolution fallbacks below so a diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index bfc2e330eb..9b5165a272 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -755,18 +755,16 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s * short-name strategy binds it to an arbitrary same-named project symbol * (`f.Close()` on an os.File -> a project `Close`, suffix_match over 15 * candidates). Built on the shared drop-list so "weak" means one thing - * for every guard; Go narrows it in two places only. */ + * for every guard; Go narrows it in one place only (unique_name below). + * field_type_hint stays weak: it is a receiver-NAME substring match, not + * a declared-type lookup (the parallel resolver no longer produces it for + * Go; a typed field call such as lrp_go_s8's `s.repo.Find()` resolves + * through the Go LSP). */ if (!is_go || !is_method || !weak_short_name_strategy(strategy)) { return false; } - /* field_type_hint is KEPT for Go: a Go struct field carries a declared - * type, so the parallel resolver's field-type hint is receiver-aware for - * Go (lrp_go_s8_field_type_hint), not a heuristic. */ - if (strcmp(strategy, "field_type_hint") == 0) { - return false; - } if (strcmp(strategy, "unique_name") != 0) { - return true; /* suffix_match, fuzzy */ + return true; /* suffix_match, field_type_hint, fuzzy */ } /* unique_name is dropped only when PENALIZED: resolve_name_lookup scales * CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d413aa9bb9..6d04ad3275 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8713,6 +8713,30 @@ static void write_go_receiver_fixture(const char *tmp, int pad_files) { "func CallsLocal() int { return localHelper() }\n" "\n" "func UsesUtil() string { return util.Tag() }\n"); + /* F-G9: two project methods named Run, one under a directory whose QN + * carries an uppercase T. `t.Run` on a *testing.T must bind neither. The + * parallel resolver field-type hint matched the capitalized receiver + * variable ("T") as a QN substring and produced a par-only edge. */ + write_temp_file(tmp, "TBench/bench.go", + "package tbench\n" + "\n" + "type Bench struct{}\n" + "\n" + "func (b *Bench) Run(name string) bool { return name != \"\" }\n"); + write_temp_file(tmp, "worker/worker.go", + "package worker\n" + "\n" + "type Worker struct{}\n" + "\n" + "func (w *Worker) Run(name string) bool { return false }\n"); + write_temp_file(tmp, "hash/hash_test.go", + "package hash\n" + "\n" + "import \"testing\"\n" + "\n" + "func TestSubRun(t *testing.T) {\n" + "\tt.Run(\"a\", func(t *testing.T) {})\n" + "}\n"); for (int i = 0; i < pad_files; i++) { char name[64]; char body[128]; @@ -8750,6 +8774,8 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { /* (4) The import-qualified cross-package call survives (import-aware * strategies are outside the drop-list). */ ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + /* (5) F-G9: the stdlib *testing.T Run binds no project Run. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "TestSubRun", "Run")); cbm_store_close(s); cbm_pipeline_free(p); @@ -8793,6 +8819,8 @@ TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge) { ASSERT_TRUE(cross_file_call_exists(s, project, "Boot", "Close")); ASSERT_TRUE(cross_file_call_exists(s, project, "CallsLocal", "localHelper")); ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + /* F-G9: the parallel-only field-type hint must not bind `t.Run` either. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "TestSubRun", "Run")); cbm_store_close(s); cbm_pipeline_free(p); diff --git a/tests/test_registry.c b/tests/test_registry.c index a6fbd62011..90ab691706 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1673,11 +1673,12 @@ TEST(weak_call_guards_share_one_drop_list) { strategies[i], member, binding); } ASSERT_EQ(member, binding); - /* The Go selector guard builds on the same list and narrows it in - * exactly one place at a penalized confidence (0.75 * 0.5): it keeps - * field_type_hint. Any other drift from the shared list fails here. */ + /* The Go selector guard builds on the same list. At a penalized + * confidence (0.75 * 0.5) it agrees with it exactly; its one narrowing + * (an unpenalized unique_name) does not apply here. Any drift from + * the shared list fails here. */ bool go = cbm_go_suppress_weak_method_match(true, true, strategies[i], 0.375); - bool go_expected = member && strcmp(strategies[i], "field_type_hint") != 0; + bool go_expected = member; if (go != go_expected) { printf(" Go drop-list divergence on strategy \"%s\": go=%d expected=%d\n", strategies[i], go, go_expected); @@ -1697,6 +1698,9 @@ TEST(go_suppress_drops_weak_selector_matches) { ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.11)); ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "fuzzy", 0.9)); ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.375)); + /* field_type_hint is a receiver-NAME substring match, not type evidence + * (F-G9): weak for Go like the shared list. */ + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "field_type_hint", 0.85)); PASS(); } @@ -1706,9 +1710,6 @@ TEST(go_suppress_keeps_typed_and_import_aware_matches) { * lone-candidate call never enters the field-type-hint upgrade, so it must * survive (lrp_go_s8_field_type_hint). */ ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.75)); - /* field_type_hint is receiver-aware for Go — struct fields carry declared - * types (lrp_go_s8_field_type_hint) — so it stays, unlike the TS/JS list. */ - ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "field_type_hint", 0.85)); ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "same_module", 0.9)); ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map", 0.95)); ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map_suffix", 0.9)); From 033be76c703928a9ee2a8e74eec2ee26f2674c1f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 21:21:37 +0200 Subject: [PATCH 7/9] fix(doc-links): a Go method claims no flat doc-link node name #1913 makes Go method nodes receiver-qualified (..). The Go doc-link model still let every method claim the flat node .: - doclink_go.c go_scan_method pushed a claim record; - doc_links_go.c go_add_claim put it in the flat table. So a type, function or value that shares its name with a method in its directory became a graph_gap ("lost its node to the method"), although the graph now gives it that node. Base reports the same graph_gap for this shape, because there the method really did take the node. So this completes #1913's QN scheme for one of its consumers; it is not a regression #1913 causes. - A method record now carries w `-`: it claims no flat name. - The resolver no longer enters methods into the flat table. Methods are found through their receiver (the member table), which was already the first lookup. - The flat fallback in go_method_node is now unreachable. It stays, with a comment saying so. Corpus, before = this branch's tree, par x2, r1 == r2: - terraform: graph_gap rows 9 -> 0, doc-link MENTIONS 528 -> 536 (+8, -0). All 8 new edges were judged correct: e.g. [AbsProviderConfig] now lands on the type, not on the method RootProviderConfig.AbsProviderConfig. - vault, prometheus: unchanged. - prometheus incremental == full for MENTIONS. - The 3 -> 4 index-format bump already forces a full rebuild, so no stored scope blob mixes the two meanings. Signed-off-by: Martin Vogel --- internal/cbm/doclink_go.c | 16 +++++++++------- src/pipeline/doc_links_go.c | 23 +++++++++++++---------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/internal/cbm/doclink_go.c b/internal/cbm/doclink_go.c index 97dab618d1..aeb0c58582 100644 --- a/internal/cbm/doclink_go.c +++ b/internal/cbm/doclink_go.c @@ -43,10 +43,10 @@ * drops these lines and so carries no line number * at all. * G path go.mod: the module path - * w says who keeps the node. A Go method node has a FLAT qualified name - * (.), so a function, the methods of every receiver, a type and - * a value of one name all claim the node ., and within a file the - * declaration on the LAST line keeps it (cbm_gbuf_upsert_node). w is 1 for + * w says who keeps the node .. A function, a type and a value of + * one name all claim it, and within a file the declaration on the LAST line + * keeps it (cbm_gbuf_upsert_node). A method claims nothing: its node is + * receiver-qualified (.., #1913), and its w is `-`. w is 1 for * that declaration, 0 for the others of that name, x when two of them start * on that last line, and - for a value the extractor gives no node at all * (it gives one to the first name of a constant spec and of a parenthesis- @@ -1511,9 +1511,11 @@ static void go_scan_method(go_scan_t *s, TSNode m) { recv = "?"; recv_len = SKIP_ONE; } - go_push_claim(s, - cbm_arena_sprintf(s->tmp, "M\t%.*s\t%.*s", (int)recv_len, recv, (int)len, name), - name, len, m); + /* #1913: a Go method node is receiver-qualified (..), + * so a method claims no flat name: w is `-`, and the method is found + * through its receiver (the member table), never through .. */ + (void)go_push_rec( + s, cbm_arena_sprintf(s->tmp, "M\t%.*s\t%.*s\t-", (int)recv_len, recv, (int)len, name)); } /* The implied name of an embedded field: the type's own name, without diff --git a/src/pipeline/doc_links_go.c b/src/pipeline/doc_links_go.c index 047a7654e0..b43289c95e 100644 --- a/src/pipeline/doc_links_go.c +++ b/src/pipeline/doc_links_go.c @@ -32,10 +32,11 @@ * fields of its own struct declaration and the methods of its own interface * declaration: nothing promoted through embedding. * - * Binding. The graph keeps one node per qualified name, Go method nodes have - * a FLAT name (.), and is the directory, not the package - * clause: functions, methods of every receiver, types and values of one name, - * in every file of the directory, share one node, and the node is ONE of those + * Binding. The graph keeps one node per qualified name, and is the + * directory, not the package clause. Go method nodes are receiver-qualified + * (.., #1913) and are found through their receiver only. + * Functions, types and values of one name, in every file of the directory, + * share one node ., and the node is ONE of those * declarations (cbm_gbuf_upsert_node keeps the smallest file path, then the * last line). So a reference binds only when the node IS the entity it names: * the node's file is a file of the named package, the declaration that keeps @@ -59,7 +60,7 @@ * not), missing (a repository package that does not declare the name; an * import path inside a module without a package there), graph_gap (declared, * but the node is another declaration's or absent: block variables, later - * names of a declaration, embedded fields, methods that lost the flat name; + * names of a declaration, embedded fields; * also an undeclared name in a package with a parse error), test_only_target * (product code naming a declaration that only test files have), ambiguous * (several modules or packages fit; a name declared as two kinds; a name two @@ -1063,7 +1064,7 @@ static void go_record_decl(go_index_t *ix, go_file_t *file, char **f, int nf) { FIELD_FIELDS = 4, IM_FIELDS = 3 }; - enum { F_OWNER = 2, T_ALIAS = 3, T_OWNER = 4, V_OWNER = 2, M_OWNER = 3, FIELD_NODE = 3 }; + enum { F_OWNER = 2, T_ALIAS = 3, T_OWNER = 4, V_OWNER = 2, FIELD_NODE = 3 }; char kind = f[0][0]; if (nf < IM_FIELDS || !go_name_ok(f[SKIP_ONE])) { return; @@ -1078,10 +1079,12 @@ static void go_record_decl(go_index_t *ix, go_file_t *file, char **f, int nf) { go_add_sym(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL); go_add_claim(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL, f[V_OWNER]); } else if (kind == 'M' && nf == M_FIELDS && go_name_ok(f[PAIR_LEN])) { + /* #1913: a method is found through its receiver only; it claims no + * flat . (its record says w `-`), so a type, function or + * value of the same name keeps that node. */ if (strcmp(f[SKIP_ONE], "?") != 0) { go_add_member(ix, file, GO_MEM_METHOD, f[SKIP_ONE], f[PAIR_LEN], true); } - go_add_claim(ix, file, GO_KIND_METHOD, f[PAIR_LEN], f[SKIP_ONE], f[M_OWNER]); } else if (kind == 'f' && nf == FIELD_FIELDS && go_name_ok(f[PAIR_LEN])) { go_add_member(ix, file, GO_MEM_FIELD, f[SKIP_ONE], f[PAIR_LEN], f[FIELD_NODE][0] == '1'); } else if (kind == 'm' && nf == IM_FIELDS && go_name_ok(f[PAIR_LEN])) { @@ -1342,9 +1345,9 @@ static const cbm_gbuf_node_t *go_member_node(const go_ctx_t *c, const go_pkg_t * return (fm && (fm->node_kinds & kind)) ? n : NULL; } -/* The node of the method `recv.name`: its receiver-qualified node, else the - * flat node . when the method is the one that kept it (the last - * declaration of that name in the node's file). */ +/* The node of the method `recv.name`: its receiver-qualified node. The flat + * fallback below (the method that kept .) is unreachable since + * #1913: no method record claims a flat name any more. */ static const cbm_gbuf_node_t *go_method_node(const go_ctx_t *c, const go_pkg_t *pkg, go_str_t recv, go_str_t name) { const cbm_gbuf_node_t *qualified = go_member_node(c, pkg, GO_MEM_METHOD, recv, name); From 4c62b61845d4e9e402c4bf397311bf7b23ccb7b7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 21:21:37 +0200 Subject: [PATCH 8/9] test(doc_mentions_go): receiver-qualified Go method nodes (#1913) main's Go doc-link tests were written against flat Go method QNs, where `(A) Close` and `(B) Close` shared one node `alpha.Close`. That is the fused-node defect #1913 removes. The expectations move to the new scheme, and each changed assertion pins an edge onto, or a count of, the receiver-qualified node (production: the previous commit and #1913): - sources: RefM's source is p.pkg.S.M, not p.pkg.M. - forms: [Widget.Run] lands on alpha.Widget.Run, [beta.Thing.Do] on beta.Thing.Do. - plain_text, struct_fields: Run's doc is the source alpha.Widget.Run. - lookup_order: two alpha.Run sources and beta.Promoted named no node any more, so they held vacuously. They are now alpha.Widget.Run and beta.Embedded.Promoted. The alias links land on beta.Thing.Do and alpha.Deep10.End. - node_owner, the positive contract that replaces the fused case: - two Method nodes, alpha.A.Close and alpha.B.Close, and none named alpha.Close; - [A.Close] and [B.Close] are edges, not graph_gap rows; - each method's own [Target] is an edge from its own doc line (9 and 12), with no unresolved row; - the type Open keeps alpha.Open: [Open] is the type (no graph_gap), [B.Open] is the method, and UsesOpen has 2 targets. - scope_blob: - method records carry w `-`; - swapping two methods of one name no longer changes the blob; - a func/type pair swap still does (new case); - the k1-k3 records follow (a method neither keeps nor contests a name); - a new k4 keeps the "two on one line contest it" rule covered with a var and a const. - incremental_equals_full: - [b.T.M], [b.W.Name] and [b.V.Name] all resolve, and U.M's [Target] is an edge; - removing T.M breaks only [b.T.M]; - swapping V.Name and W.Name moves nothing and is now an ordinary closure repair instead of a forced full rebuild. - scope_delta_rules: - a method/function swap is LOCAL; - removing the method of such a pair is LOCAL with the name "Open" (a member left); - new func/type cases keep the GLOBAL keeper-change rules covered for swap and removal. - workers_agree: [p01.T3.Close] now resolves (exact edge, no row). The graph_gap reason stays covered by the named Shared assertion: one function declared by every file of a package, which is still a gap. Signed-off-by: Martin Vogel --- tests/test_doc_mentions_go.c | 192 ++++++++++++++++++++++------------- 1 file changed, 123 insertions(+), 69 deletions(-) diff --git a/tests/test_doc_mentions_go.c b/tests/test_doc_mentions_go.c index 78f1934e0b..0d4462f768 100644 --- a/tests/test_doc_mentions_go.c +++ b/tests/test_doc_mentions_go.c @@ -228,10 +228,10 @@ TEST(doc_mentions_go_sources) { const char *raw; const char *source; } want[] = { - {"RefF", "p.pkg.F"}, {"RefS", "p.pkg.S"}, {"RefField", "p.pkg.S.Field"}, - {"RefM", "p.pkg.M"}, /* Go method nodes have a flat name */ - {"RefI", "p.pkg.I"}, {"RefDo", "p.pkg.I.Do"}, {"RefC", "p.pkg.C"}, - {"RefK", "p.pkg.K"}, {"RefV", "p.pkg.V"}, {"RefA", "p.pkg.A"}, + {"RefF", "p.pkg.F"}, {"RefS", "p.pkg.S"}, {"RefField", "p.pkg.S.Field"}, + {"RefM", "p.pkg.S.M"}, /* #1913: Go method nodes are receiver-qualified */ + {"RefI", "p.pkg.I"}, {"RefDo", "p.pkg.I.Do"}, {"RefC", "p.pkg.C"}, + {"RefK", "p.pkg.K"}, {"RefV", "p.pkg.V"}, {"RefA", "p.pkg.A"}, }; for (size_t i = 0; i < GO_COUNT(want); i++) { const CBMDocLink *t = dm_find_token(r, want[i].raw); @@ -772,14 +772,15 @@ TEST(doc_mentions_go_scope_blob) { "T\tAlias\ta\tal.Widget\t1", "T\tSame\ta\tKind\t1", "T\tList\ts\t-\t1", - /* functions and methods; two methods named Run compete for one node - * and the later one keeps it */ + /* functions and methods; a method node is receiver-qualified + * (#1913), so a method claims no flat name and its w is `-`: the two + * methods named Run no longer compete */ "F\tNew\t1", "F\tMap\t1", - "M\tWidget\tRun\t0", - "M\tKind\tRun\t1", - "M\tList\tPush\t1", - "M\tlower\tExported\t1", + "M\tWidget\tRun\t-", + "M\tKind\tRun\t-", + "M\tList\tPush\t-", + "M\tlower\tExported\t-", /* own type parameters, by the declaration's first and last line */ "g\t36\t36\tT,U", "g\t42\t42\tA,B", @@ -814,15 +815,20 @@ TEST(doc_mentions_go_scope_blob) { char *portable = cbm_doclink_portable_scope(s); ASSERT_NOT_NULL(portable); ASSERT_NULL(strstr(portable, "\ng\t")); - ASSERT_TRUE(go_has_rec(portable, "M\tKind\tRun\t1")); + ASSERT_TRUE(go_has_rec(portable, "M\tKind\tRun\t-")); ASSERT(strncmp(portable, "go1\n", 4) == 0); cbm_free(CBM_MEM_CLASS_OTHER, portable); cbm_free_result(r); - /* moving declarations changes nothing unless a node changes its owner */ + /* moving declarations changes nothing unless a node changes its owner. + * Methods own no flat name (#1913), so swapping two methods of one name + * changes nothing either; two package-level declarations of one name + * still hand . over when swapped */ const char *a = "package p\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\nfunc F() {}\n"; const char *moved = "package p\n\nfunc F() {}\n\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\n"; const char *swapped = "package p\n\nfunc (b B) Run() {}\nfunc (a A) Run() {}\nfunc F() {}\n"; + const char *pair = "package p\n\nfunc Open() {}\ntype Open struct{}\n"; + const char *pair_swapped = "package p\n\ntype Open struct{}\nfunc Open() {}\n"; CBMFileResult *ra = go_extract(a, "p/x.go"); CBMFileResult *rm = go_extract(moved, "p/x.go"); CBMFileResult *rs = go_extract(swapped, "p/x.go"); @@ -830,16 +836,25 @@ TEST(doc_mentions_go_scope_blob) { ASSERT_NOT_NULL(rm); ASSERT_NOT_NULL(rs); ASSERT_STR_EQ(ra->doc_scope, rm->doc_scope); - ASSERT(strcmp(ra->doc_scope, rs->doc_scope) != 0); - ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tA\tRun\t1")); - ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tB\tRun\t0")); + ASSERT_STR_EQ(ra->doc_scope, rs->doc_scope); + ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tA\tRun\t-")); + ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tB\tRun\t-")); cbm_free_result(ra); cbm_free_result(rm); cbm_free_result(rs); - - /* the node . is one for a function, a type, a value and the - * methods of that name: the last declaration keeps it, whatever its kind, - * and two on one line contest it */ + CBMFileResult *rp = go_extract(pair, "p/x.go"); + CBMFileResult *rq = go_extract(pair_swapped, "p/x.go"); + ASSERT_NOT_NULL(rp); + ASSERT_NOT_NULL(rq); + ASSERT(strcmp(rp->doc_scope, rq->doc_scope) != 0); + ASSERT_TRUE(go_has_rec(rp->doc_scope, "T\tOpen\ts\t-\t1")); + ASSERT_TRUE(go_has_rec(rq->doc_scope, "F\tOpen\t1")); + cbm_free_result(rp); + cbm_free_result(rq); + + /* the node . is one for a function, a type and a value of + * that name: the last declaration keeps it, whatever its kind, and two on + * one line contest it. A method of that name claims nothing (#1913) */ CBMFileResult *k1 = go_extract("package p\n\nfunc Open() {}\nfunc (t T) Open() {}\n", "p/k.go"); CBMFileResult *k2 = go_extract("package p\n\nfunc (t T) Open() {}\nfunc Open() {}\n", "p/k.go"); CBMFileResult *k3 = go_extract("package p\n\ntype Open int\nvar Open = 1\n" @@ -848,14 +863,21 @@ TEST(doc_mentions_go_scope_blob) { ASSERT_NOT_NULL(k1); ASSERT_NOT_NULL(k2); ASSERT_NOT_NULL(k3); - ASSERT_TRUE(go_has_rec(k1->doc_scope, "F\tOpen\t0")); - ASSERT_TRUE(go_has_rec(k1->doc_scope, "M\tT\tOpen\t1")); + ASSERT_TRUE(go_has_rec(k1->doc_scope, "F\tOpen\t1")); + ASSERT_TRUE(go_has_rec(k1->doc_scope, "M\tT\tOpen\t-")); ASSERT_TRUE(go_has_rec(k2->doc_scope, "F\tOpen\t1")); - ASSERT_TRUE(go_has_rec(k2->doc_scope, "M\tT\tOpen\t0")); + ASSERT_TRUE(go_has_rec(k2->doc_scope, "M\tT\tOpen\t-")); ASSERT_TRUE(go_has_rec(k3->doc_scope, "T\tOpen\to\t-\t0")); ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\t0")); - ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\tx")); - ASSERT_TRUE(go_has_rec(k3->doc_scope, "M\tT\tOpen\tx")); + ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\t1")); + ASSERT_TRUE(go_has_rec(k3->doc_scope, "M\tT\tOpen\t-")); + /* two package-level declarations on one line contest the name */ + CBMFileResult *k4 = + go_extract("package p\n\ntype Open int\nvar Open = 1; const Open = 2\n", "p/k.go"); + ASSERT_NOT_NULL(k4); + ASSERT_TRUE(go_has_rec(k4->doc_scope, "T\tOpen\to\t-\t0")); + ASSERT_TRUE(go_has_rec(k4->doc_scope, "V\tOpen\tx")); + cbm_free_result(k4); cbm_free_result(k1); cbm_free_result(k2); cbm_free_result(k3); @@ -1030,7 +1052,7 @@ static const char GO_ALPHA_C[] = "package alpha\n" "// Target is a target; see [A.Close] and [B.Close].\n" "func Target() {}\n" "\n" - "// Open is a type that loses its node name to the method below.\n" + "// Open is a type; a method of B below has the same name.\n" "type Open struct{}\n" "\n" "// Open opens: a method named like the type.\n" @@ -1295,7 +1317,7 @@ TEST(doc_mentions_go_forms) { static const go_want_edge_t edges[] = { /* [Name], [Recv.Method], an interface's method, a constant, a variable */ {"alpha.Widget", "alpha.New", "unique"}, - {"alpha.Widget", "alpha.Run", "unique"}, + {"alpha.Widget", "alpha.Widget.Run", "unique"}, {"alpha.Widget", "alpha.Runner.Run", "unique"}, {"alpha.Widget", "alpha.Max", "unique"}, {"alpha.Widget", "alpha.Default", "unique"}, @@ -1311,7 +1333,7 @@ TEST(doc_mentions_go_forms) { {"alpha.New", "sub.deep.Deep", "exact"}, {"alpha.New", "nested.pkg.Far", "exact"}, /* [pkg.Recv.Name]: a method, a field, an interface method */ - {"alpha.New", "beta.Do", "exact"}, + {"alpha.New", "beta.Thing.Do", "exact"}, {"alpha.New", "beta.Thing.Field", "exact"}, {"alpha.New", "beta.Doer.Act", "exact"}, /* an identifier with letters beyond ASCII */ @@ -1395,7 +1417,7 @@ TEST(doc_mentions_go_plain_text) { ASSERT_EQ(go_bad_rows(db, rows, GO_COUNT(rows)), 0); /* and none of them is an edge: Run's doc holds rows, plain text and one * link, to the field Widget.Size */ - ASSERT_EQ(dm_mentions_from(db, "alpha.Run"), 1); + ASSERT_EQ(dm_mentions_from(db, "alpha.Widget.Run"), 1); ASSERT_EQ(dm_mentions_from(db, "alpha.Bare"), 0); static const go_want_edge_t edges[] = { {"alpha.Sibling", "alpha.Widget", "unique"}, @@ -1424,14 +1446,14 @@ TEST(doc_mentions_go_struct_fields) { static const go_want_edge_t edges[] = { /* the same package: a field named from a method's doc and from a * sibling field's doc */ - {"alpha.Run", "alpha.Widget.Size", "exact"}, + {"alpha.Widget.Run", "alpha.Widget.Size", "exact"}, {"alpha.Widget.Name", "alpha.Widget.Size", "exact"}, /* another package, by its import */ {"alpha.New", "beta.Thing.Field", "exact"}, {"gamma.ViaAlias", "alpha.Widget.Name", "exact"}, /* Depth is a field of beta.Embedded, which Widget embeds: it is no * field of Widget */ - {"alpha.Run", "beta.Embedded.Depth", ""}, + {"alpha.Widget.Run", "beta.Embedded.Depth", ""}, }; ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0); /* the promoted field exists as a node: the test above is not vacuous */ @@ -1545,7 +1567,7 @@ TEST(doc_mentions_go_lookup_order) { * file that imports "fmt", the repository's own in a file that imports * example.com/demo/fmt */ ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "fmt.Println"), "external"); - ASSERT_STR_EQ(go_tier(db, "alpha.Run", "fmt.Println"), ""); + ASSERT_STR_EQ(go_tier(db, "alpha.Widget.Run", "fmt.Println"), ""); ASSERT_STR_EQ(go_tier(db, "gamma.Local", "fmt.Println"), "exact"); /* 5. the longest module path decides: a directory inside the root module * that belongs to a nested module of another path is not reachable through @@ -1555,17 +1577,17 @@ TEST(doc_mentions_go_lookup_order) { ASSERT_STR_EQ(go_tier(db, "alpha.New", "sub.deep.Deep"), "exact"); /* 6. the bare name stops at the package: beta.Make is imported and still * no [Make] */ - ASSERT_STR_EQ(go_tier(db, "alpha.Run", "beta.Make"), ""); + ASSERT_STR_EQ(go_tier(db, "alpha.Widget.Run", "beta.Make"), ""); /* 7. members stop at the type: nothing promoted through embedding */ ASSERT_STR_EQ(go_reason(db, "gamma/k.go", "alpha.Widget.Promoted"), "missing"); - ASSERT_STR_EQ(go_tier(db, "gamma.ViaAlias", "beta.Promoted"), ""); + ASSERT_STR_EQ(go_tier(db, "gamma.ViaAlias", "beta.Embedded.Promoted"), ""); /* 8. an alias is followed for a member written with its package, to the * type that declares the member */ static const go_want_edge_t alias[] = { - {"gamma.ViaAlias", "beta.Do", "exact"}, + {"gamma.ViaAlias", "beta.Thing.Do", "exact"}, {"gamma.ViaAlias", "beta.Thing.Field", "exact"}, {"gamma.ViaAlias", "alpha.Alias", "exact"}, - {"gamma.ViaAlias", "alpha.End", "exact"}, /* seven aliases away */ + {"gamma.ViaAlias", "alpha.Deep10.End", "exact"}, /* seven aliases away */ {"gamma.ViaAlias", "alpha.Widget.Name", "exact"}, }; ASSERT_EQ(go_bad_edges(db, alias, GO_COUNT(alias)), 0); @@ -1638,28 +1660,42 @@ TEST(doc_mentions_go_node_owner) { char tmp[256]; char db[512]; ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); - /* Go method nodes have a flat name: A.Close and B.Close share - * alpha.Close, and the later declaration keeps it */ - ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE qualified_name LIKE '%.alpha.Close'"), + /* #1913: Go method nodes are receiver-qualified. A.Close and B.Close are + * two nodes (no shared alpha.Close), each a target of its own */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Method' AND " + "qualified_name LIKE '%.alpha.A.Close'"), 1); - ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.Close"), "unique"); - ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "A.Close"), "graph_gap"); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Method' AND " + "qualified_name LIKE '%.alpha.B.Close'"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE qualified_name LIKE '%.alpha.Close'"), + 0); + ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.A.Close"), "unique"); + ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.B.Close"), "unique"); + ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "A.Close"), ""); ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "B.Close"), ""); - /* the doc of the method that lost the name is not attributed to the one - * that kept it: B.Close's [Target] is the edge, A.Close's a row */ + /* each method's doc is attributed to its own node: both [Target]s are + * edges, from the line of that method's doc, and neither is a row */ char props[512]; int n = 0; - dm_edge(db, "alpha.Close", "alpha.Target", props, sizeof(props), &n); + dm_edge(db, "alpha.A.Close", "alpha.Target", props, sizeof(props), &n); + ASSERT_EQ(n, 1); + ASSERT_NOT_NULL(strstr(props, "\"line\":9,\"count\":1")); + dm_edge(db, "alpha.B.Close", "alpha.Target", props, sizeof(props), &n); ASSERT_EQ(n, 1); ASSERT_NOT_NULL(strstr(props, "\"line\":12,\"count\":1")); - ASSERT_EQ(dm_count(db, "SELECT line FROM doc_link_unresolved WHERE rel_path = 'alpha/c.go' " - "AND raw = 'Target'"), - 9); - /* a type that lost its name to a method of that name is no target, the - * method is */ - ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "Open"), "graph_gap"); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE rel_path = " + "'alpha/c.go' AND raw = 'Target'"), + 0); + /* a type and a method of the same name no longer share a node: [Open] + * is the type, [B.Open] the method */ + ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "Open"), ""); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label <> 'Method' AND " + "qualified_name LIKE '%.alpha.Open'"), + 1); ASSERT_STR_EQ(go_tier(db, "alpha.UsesOpen", "alpha.Open"), "unique"); - ASSERT_EQ(dm_mentions_from(db, "alpha.UsesOpen"), 1); + ASSERT_STR_EQ(go_tier(db, "alpha.UsesOpen", "alpha.B.Open"), "unique"); + ASSERT_EQ(dm_mentions_from(db, "alpha.UsesOpen"), 2); /* build variants are one entity: whichever file holds the node */ static const go_want_edge_t variants[] = { @@ -1836,15 +1872,16 @@ TEST(doc_mentions_go_incremental_equals_full) { ASSERT_EQ(dm_index(repo, inc_db, NULL), 0); /* the preconditions the steps below move away from */ static const go_want_edge_t before[] = { - {"a.Use", "b.Target", "exact"}, {"a.Use", "b.Gone", "exact"}, - {"a.Use", "b.M", "exact"}, /* T.M, in the first file of the directory, keeps b.M */ - {"a.Use", "b.Name", "exact"}, /* W.Name, the later declaration, keeps b.Name */ - {"a.Use", "a.Local", "unique"}, {"a.doc.go.__file__", "a.Local", "unique"}, + {"a.Use", "b.Target", "exact"}, {"a.Use", "b.Gone", "exact"}, + {"a.Use", "b.T.M", "exact"}, /* #1913: every method has its own node */ + {"a.Use", "b.W.Name", "exact"}, {"a.Use", "b.V.Name", "exact"}, + {"b.U.M", "b.Target", "unique"}, /* U.M is a source of its own */ + {"a.Use", "a.Local", "unique"}, {"a.doc.go.__file__", "a.Local", "unique"}, {"a.Other", "b.Target", ""}, /* other.go does not import b */ }; ASSERT_EQ(go_bad_edges(inc_db, before, GO_COUNT(before)), 0); - ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "graph_gap"); - ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "graph_gap"); /* U.M has no node */ + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), ""); + ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), ""); /* a doc-comment edit re-extracts the file alone */ go_w(repo, "a/a.go", GO_INC_A_DOC_EDIT); @@ -1880,23 +1917,27 @@ TEST(doc_mentions_go_incremental_equals_full) { 0); ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.Gone"), "missing"); - /* the method that kept a shared node name goes: the other file's method of - * that name takes the node over, and its doc becomes a source */ + /* a method goes: links to it break, and another receiver's method of + * the same name, in another file, is untouched */ go_w(repo, "b/b.go", GO_INC_B_NO_M); ASSERT_EQ( dm_step(repo, inc_db, full_db, "name keeper removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.T.M"), "missing"); - ASSERT_STR_EQ(go_tier(inc_db, "b.M", "b.Target"), "unique"); + ASSERT_STR_EQ(go_tier(inc_db, "b.U.M", "b.Target"), "unique"); ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), ""); - /* declaration order decides which declaration keeps a shared name: - * swapping two methods moves the node, for files with no edge into it too */ + /* declaration order no longer decides anything: swapping two methods of + * the same name keeps both nodes and both links */ go_w(repo, "b/d.go", GO_INC_D_SWAPPED); - ASSERT_EQ(dm_step(repo, inc_db, full_db, "methods swapped", CBM_INCREMENTAL_ROUTE_FORCED_FULL), - 0); - ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.W.Name"), "graph_gap"); + /* and the scope blob does not change (no method w moves), so the step + * is an ordinary closure repair, no longer a forced full rebuild */ + ASSERT_EQ( + dm_step(repo, inc_db, full_db, "methods swapped", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.W.Name"), ""); ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), ""); + ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.W.Name"), "exact"); + ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.V.Name"), "exact"); /* renaming a target */ go_w(repo, "b/b.go", GO_INC_B_RENAME); @@ -2006,10 +2047,20 @@ TEST(doc_mentions_go_scope_delta_rules) { GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M GO_DELTA_I GO_DELTA_C "var (\n\tV = 2\n)\n", CBM_DOCLINK_DELTA_GLOBAL, NULL}, - /* the declaration that keeps a shared node name changes */ - {"same-named declarations swapped", twins, - "package p\n\nfunc (T) Open() {}\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL}, - {"one of two same-named declarations removed", twins, "package p\n\nfunc Open() {}\n", + /* a method and a function of one name no longer share a node + * (#1913): swapping them changes no keeper */ + {"method and function swapped", twins, "package p\n\nfunc (T) Open() {}\nfunc Open() {}\n", + CBM_DOCLINK_DELTA_LOCAL, ""}, + /* the declaration that keeps a shared node name changes: two + * package-level declarations of one name still share . */ + {"same-named declarations swapped", "package p\n\nfunc Open() {}\ntype Open struct{}\n", + "package p\n\ntype Open struct{}\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* the method of a method/function pair removed: a member left, the + * function keeps its node either way */ + {"method of a same-named pair removed", twins, "package p\n\nfunc Open() {}\n", + CBM_DOCLINK_DELTA_LOCAL, "Open"}, + {"one of two same-named declarations removed", + "package p\n\nfunc Open() {}\ntype Open struct{}\n", "package p\n\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL}, /* other files follow `type X = q.Y` through this file's imports */ {"import changed under a qualified alias", alias, @@ -2091,7 +2142,10 @@ TEST(doc_mentions_go_workers_agree) { ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p01.T0"), "exact"); ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.Gone"), "missing"); ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "fmt.Println"), "external"); - ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.T3.Close"), "graph_gap"); + /* #1913: [p01.T3.Close] lands on its own method node now; the graph_gap + * reason stays covered by Shared, one name declared by every file */ + ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.T3.Close"), ""); + ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p01.T3.Close"), "exact"); ASSERT_STR_EQ(go_reason(par_db, "p00/f3.go", "Shared"), "graph_gap"); ASSERT_TRUE(dm_count(par_db, "SELECT COUNT(*) FROM edges WHERE type = 'MENTIONS'") > 100); dm_unlink_db(par_db); From aa4775f5669092fde88142c2011e82649fd8466c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 01:38:07 +0200 Subject: [PATCH 9/9] test(doc_links_pdf): receiver-qualified Go method nodes (#1913) Two PDF doc-link tests named a Go method by its flat QN. With #1913 a Go method's QN carries its receiver, so the lookups found no node. The resolver is unchanged: for a Go method, rel(parent_class).name now equals the QN, so the method binds through its QN, like a member of any other language. - pdf_links_pipeline: page 1 names `server.Handler.Serve`; the edge target pkg.server.Serve becomes pkg.server.Handler.Serve. Same assertions: pdf_qn syntax, unique tier. - pdf_qn_method_through_owner, page 2: `time.Provider.Now` binds pkg.time.Provider.Now (was pkg.time.Now), syntax pdf_qn. - pdf_qn_method_through_owner, page 1: `time.Now` must not bind the method. The target moves to pkg.time.Provider.Now too. Against the old flat QN, no node exists any more, so the zero count would have passed vacuously. The page 2 assertion proves that the node exists. The ownerless-QN rule in doc_links_pdf.c stays: a templ method keeps the flat package QN while it carries parent_class (extract_defs.c only receiver-qualifies CBM_LANG_GO). Its comment no longer says that Go methods have no receiver. Signed-off-by: Martin Vogel --- src/pipeline/doc_links_pdf.c | 7 ++++--- tests/test_doc_links_pdf.c | 15 +++++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/pipeline/doc_links_pdf.c b/src/pipeline/doc_links_pdf.c index a667fb6c71..1eaf057ae7 100644 --- a/src/pipeline/doc_links_pdf.c +++ b/src/pipeline/doc_links_pdf.c @@ -328,9 +328,10 @@ static void qn_candidate(const pdfr_index_t *x, const cbm_gbuf_node_t *n, const const char *rel = rel_qn(x, n->qualified_name); char alt[PDFR_KEY_CAP]; bool owned = owner_name(x, n, alt, sizeof(alt)); - /* A member whose QN leaves its owner out (a Go method's has no receiver) - * is named through the owner: `time.Now` is package time's function, - * never the method Now of a type that package time declares. */ + /* A member whose QN leaves its owner out (a templ method's keeps the flat + * package QN; a Go method's did before index format 4) is named through + * the owner: `time.Now` is package time's function, never the method Now + * of a type that package time declares. */ bool ownerless_qn = owned && !comp_suffix(rel, alt); bool via_rel = !ownerless_qn && comp_suffix(rel, s); bool via_alt = !via_rel && owned && strcmp(alt, rel) != 0 && comp_suffix(alt, s); diff --git a/tests/test_doc_links_pdf.c b/tests/test_doc_links_pdf.c index 1a78b72547..9c7bf90ff4 100644 --- a/tests/test_doc_links_pdf.c +++ b/tests/test_doc_links_pdf.c @@ -1087,8 +1087,9 @@ TEST(pdf_links_pipeline) { ASSERT_NOT_NULL(strstr(props, "\"line\":1")); /* qualified names (pdf_qn) passed a second held-out audit (97 of 99 * correct, Wilson 95 % low 0.929) once a member whose QN leaves its owner - * out binds only through the owner. A Go method through its receiver: */ - dm_edge(db, "docs.design.page_1", "pkg.server.Serve", props, sizeof(props), &n); + * out binds only through the owner. A Go method through its receiver + * (receiver-qualified QN since index format 4): */ + dm_edge(db, "docs.design.page_1", "pkg.server.Handler.Serve", props, sizeof(props), &n); ASSERT_EQ(n, 1); ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"pdf_qn\"")); ASSERT_NOT_NULL(strstr(props, "\"tier\":\"unique\"")); @@ -1142,10 +1143,12 @@ TEST(pdf_links_pipeline) { PASS(); } -/* A held-out finding of the PDF audit: a Go method's QN has no receiver, so +/* A held-out finding of the PDF audit: a Go method's QN had no receiver, so * `time.Now` (the standard library's function, in a spec) matched the method * Now of a type that the repository's own package time declares. A method is - * named through its owner: `time.Provider.Now` still binds it. */ + * named through its owner: `time.Provider.Now` still binds it. Since index + * format 4 the QN carries the receiver (pkg.time.Provider.Now), and both + * directions are checked against that node. */ TEST(pdf_qn_method_through_owner) { cbm_doclink_test_set_ships(CBM_DOCLINK_PDF_QN, true); char tmp[256]; @@ -1169,9 +1172,9 @@ TEST(pdf_qn_method_through_owner) { ASSERT_EQ(dm_index(repo, db, NULL), 0); char props[512]; int n; - dm_edge(db, "docs.spec.page_1", "pkg.time.Now", props, sizeof(props), &n); + dm_edge(db, "docs.spec.page_1", "pkg.time.Provider.Now", props, sizeof(props), &n); ASSERT_EQ(n, 0); - dm_edge(db, "docs.spec.page_2", "pkg.time.Now", props, sizeof(props), &n); + dm_edge(db, "docs.spec.page_2", "pkg.time.Provider.Now", props, sizeof(props), &n); ASSERT_EQ(n, 1); ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"pdf_qn\"")); th_cleanup(tmp);