From 54836346dea928fb1159a1c334ee98363ffd4ba6 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Tue, 16 Jun 2026 14:18:02 -0500 Subject: [PATCH 1/2] fix(resolver): disambiguate qualified cross-file calls by full namespace tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cbm_registry_resolve reduced a package/namespace-qualified callee (Foo::Bar::sub) to its bare simple name before the multi-candidate name lookup. When that bare name was defined in several packages, the bare-name scorer (best_by_import_distance) picked one winner deterministically and routed every caller to it — collapsing distinct packages' same-named subs onto a single definition and orphaning the rest. Impact is heaviest for Perl, which has no cross-file LSP and relies entirely on this generic resolver, but the defect applies to any "::"- or "."-qualified callee. Add qualified_suffix_match: when a callee is qualified and its bare name has multiple candidates, pick the sole candidate whose qualified tail matches at a segment boundary (normalizing "::" to "."). Returns the unique match with a new high-confidence "qualified_suffix" strategy; falls through to the existing bare-name scoring when zero or several candidates match. Language agnostic — bare callees contain no separator and are unaffected. Adds resolve_qualified_disambiguates_same_name covering per-package routing of a name defined in three packages, plus the bare-call (no-disambiguation) case. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Shane McCarron --- src/pipeline/registry.c | 67 +++++++++++++++++++++++++++++++++++++++++ tests/test_registry.c | 34 +++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 6533e9e2..edae525a 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -587,6 +587,63 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const return empty_result(); } +/* Confidence for a full qualified-tail match (Strategy 3.5). A package- or + * namespace-qualified callee that uniquely matches one candidate's full tail is + * as trustworthy as a same-module hit. */ +#define CONF_QUALIFIED_SUFFIX 0.90 + +/* When a callee is package/namespace-qualified (Foo::Bar::sub or Foo.Bar.sub), + * disambiguate among same-simple-name candidates by matching the FULL qualified + * tail against each candidate QN at a segment boundary. Returns the sole + * candidate whose QN equals or ends with ".", or NULL when zero + * or several candidates match (the caller then falls back to bare-name scoring). + * + * Fixes qualified cross-file calls collapsing onto one namespace when the bare + * symbol name is defined in several — e.g. Perl's Foo::Bar::run and Foo::Baz::run + * both reduce to "run", so the bare-name scorer would route every caller to a + * single winner. Language agnostic: callees with no separator return NULL and + * leave behavior unchanged. */ +static const char *qualified_suffix_match(const qn_array_t *arr, const char *callee_name) { + /* Normalize "::" → "." so the tail composes with dotted candidate QNs. */ + char dotted[CBM_SZ_512]; + size_t w = 0; + for (const char *s = callee_name; *s && w + SKIP_ONE < sizeof(dotted);) { + if (s[0] == ':' && s[1] == ':') { + dotted[w++] = '.'; + s += 2; + } else { + dotted[w++] = *s++; + } + } + dotted[w] = '\0'; + /* Must be qualified (contain a '.') — a bare name matches every candidate + * and carries no disambiguating signal. */ + if (!strchr(dotted, '.')) { + return NULL; + } + const char *match = NULL; + for (int i = 0; i < arr->count; i++) { + const char *qn = arr->items[i]; + size_t qlen = strlen(qn); + if (qlen < w) { + continue; + } + const char *tail = qn + (qlen - w); + if (strcmp(tail, dotted) != 0) { + continue; + } + /* Segment boundary: tail is the whole QN or is preceded by '.'. */ + if (tail != qn && tail[-1] != '.') { + continue; + } + if (match) { + return NULL; /* ambiguous — more than one qualified tail matches */ + } + match = qn; + } + return match; +} + /* Strategy 3+4: Name lookup + suffix match */ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_vals, @@ -600,6 +657,16 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char return empty_result(); /* unresolvably ambiguous — see REG_MAX_CANDIDATES */ } + /* Strategy 3.5: a qualified callee disambiguates among multiple same-name + * candidates by full qualified tail, before bare-name scoring collapses + * them onto a single winner. */ + if (arr->count > 1) { + const char *q = qualified_suffix_match(arr, callee_name); + if (q) { + return (cbm_resolution_t){q, "qualified_suffix", CONF_QUALIFIED_SUFFIX, REG_RESOLVED}; + } + } + /* Strategy 3: unique name */ if (arr->count == SKIP_ONE) { double conf = CONF_UNIQUE_NAME; diff --git a/tests/test_registry.c b/tests/test_registry.c index 2c2181a0..c0e213e5 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -264,6 +264,39 @@ TEST(resolve_same_module) { PASS(); } +/* A package/namespace-qualified callee whose bare name is defined in several + * places must resolve to the package named in the call — not collapse onto a + * single winner. Regression for qualified cross-file calls (e.g. Perl + * Foo::Bar::sub()) where the same sub name exists in multiple packages. */ +TEST(resolve_qualified_disambiguates_same_name) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "save", "proj.lib.App.Alpha.save", "Function"); + cbm_registry_add(r, "save", "proj.lib.App.Beta.save", "Function"); + cbm_registry_add(r, "save", "proj.lib.App.Gamma.save", "Function"); + + /* Each fully-qualified call routes to its own package. */ + cbm_resolution_t a = + cbm_registry_resolve(r, "App::Alpha::save", "proj.lib.App.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(a.qualified_name, "proj.lib.App.Alpha.save"); + ASSERT_STR_EQ(a.strategy, "qualified_suffix"); + + cbm_resolution_t b = + cbm_registry_resolve(r, "App::Beta::save", "proj.lib.App.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(b.qualified_name, "proj.lib.App.Beta.save"); + + cbm_resolution_t g = + cbm_registry_resolve(r, "App::Gamma::save", "proj.lib.App.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(g.qualified_name, "proj.lib.App.Gamma.save"); + + /* A bare call stays ambiguous (no qualifier → no disambiguation signal). */ + cbm_resolution_t bare = + cbm_registry_resolve(r, "save", "proj.lib.App.Caller", NULL, NULL, 0); + ASSERT_TRUE(!bare.strategy || strcmp(bare.strategy, "qualified_suffix") != 0); + + cbm_registry_free(r); + PASS(); +} + TEST(resolve_import_map) { cbm_registry_t *r = cbm_registry_new(); cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function"); @@ -656,6 +689,7 @@ SUITE(registry) { RUN_TEST(registry_no_duplicates); /* Resolution */ RUN_TEST(resolve_same_module); + RUN_TEST(resolve_qualified_disambiguates_same_name); RUN_TEST(resolve_import_map); RUN_TEST(resolve_import_map_bare_function); RUN_TEST(resolve_unique_name); From 37955602380ee9693d982bb5d3673c9dc7228bcd Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Tue, 16 Jun 2026 15:02:55 -0500 Subject: [PATCH 2/2] test(resolver): address QA round 1 (qualified-suffix edge coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden resolve_qualified_disambiguates_same_name and add a new test per the QA round 1 advisory notes, guarding the fall-through paths against silent regression: - dotted callee form (App.Beta.save) resolves via qualified_suffix - qualified callee matching no candidate tail falls through (not qualified_suffix) - resolve_qualified_ambiguous_tail_falls_through: two candidates sharing the same qualified tail are ambiguous → fall through to bare-name scoring Test-only; no behavior change. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Shane McCarron --- tests/test_registry.c | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_registry.c b/tests/test_registry.c index c0e213e5..a575b69e 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -288,6 +288,18 @@ TEST(resolve_qualified_disambiguates_same_name) { cbm_registry_resolve(r, "App::Gamma::save", "proj.lib.App.Caller", NULL, NULL, 0); ASSERT_STR_EQ(g.qualified_name, "proj.lib.App.Gamma.save"); + /* The dotted callee form (Go/Python/C#) disambiguates identically. */ + cbm_resolution_t dotted = + cbm_registry_resolve(r, "App.Beta.save", "proj.lib.App.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(dotted.qualified_name, "proj.lib.App.Beta.save"); + ASSERT_STR_EQ(dotted.strategy, "qualified_suffix"); + + /* A qualified callee whose tail matches NO candidate falls through to the + * existing bare-name scoring (never a qualified_suffix result). */ + cbm_resolution_t nomatch = + cbm_registry_resolve(r, "Other::Pkg::save", "proj.lib.App.Caller", NULL, NULL, 0); + ASSERT_TRUE(!nomatch.strategy || strcmp(nomatch.strategy, "qualified_suffix") != 0); + /* A bare call stays ambiguous (no qualifier → no disambiguation signal). */ cbm_resolution_t bare = cbm_registry_resolve(r, "save", "proj.lib.App.Caller", NULL, NULL, 0); @@ -297,6 +309,23 @@ TEST(resolve_qualified_disambiguates_same_name) { PASS(); } +/* When two candidates share the same qualified tail, a qualified callee is + * genuinely ambiguous and must fall through to bare-name scoring rather than + * pick arbitrarily under the high-confidence qualified_suffix strategy. */ +TEST(resolve_qualified_ambiguous_tail_falls_through) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "run", "proj.svcA.Foo.Bar.run", "Function"); + cbm_registry_add(r, "run", "proj.svcB.Foo.Bar.run", "Function"); + + /* "Foo::Bar::run" tail matches BOTH candidates → not unique → fall through. */ + cbm_resolution_t res = + cbm_registry_resolve(r, "Foo::Bar::run", "proj.svcA.Caller", NULL, NULL, 0); + ASSERT_TRUE(!res.strategy || strcmp(res.strategy, "qualified_suffix") != 0); + + cbm_registry_free(r); + PASS(); +} + TEST(resolve_import_map) { cbm_registry_t *r = cbm_registry_new(); cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function"); @@ -690,6 +719,7 @@ SUITE(registry) { /* Resolution */ RUN_TEST(resolve_same_module); RUN_TEST(resolve_qualified_disambiguates_same_name); + RUN_TEST(resolve_qualified_ambiguous_tail_falls_through); RUN_TEST(resolve_import_map); RUN_TEST(resolve_import_map_bare_function); RUN_TEST(resolve_unique_name);