diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 9f2f430b5..c633ac7cf 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -825,6 +825,87 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal return match; } +/* A dotted callee whose FIRST segment starts upper-case names a type — URLSession, + * Calendar, JSONEncoder. That receiver chain is evidence the bare-name scorers + * throw away, and throwing it away binds Foundation's URLSession.shared.data to + * a project's own PickedFile.data: high confidence, and nothing in the graph + * shows it is wrong. Require instead that the candidate's own parent segment + * appears somewhere in the chain. Calendar.utcGregorian.startOfDayUTC resolving + * to AuthDTOs.Calendar.startOfDayUTC passes, because Calendar is in the chain. + * + * Only an upper-case first segment is guarded. A lower-case root names a value + * (vm.load, http.Get, os.path.join) whose declared type the chain does not + * show, so the chain proves nothing there and the call passes through + * unchanged. A callee with no separator passes through as well. + * + * Language agnostic by design: the registry holds no language, and every + * language that writes receiver chains gains the same protection. */ +static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn) { + /* Normalize "::" -> "." so the chain composes with dotted candidate QNs, + * the same way qualified_suffix_match does. */ + 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'; + + const char *last_dot = strrchr(dotted, '.'); + if (!last_dot) { + return true; /* bare name — no receiver chain to judge */ + } + if (dotted[0] < 'A' || dotted[0] > 'Z') { + return true; /* lower-case root names a value, not a type */ + } + /* A name written in capitals with underscores is a constant holding a + * value, not a type: ISO_4217_URL.lower is a string's own method. JSON and + * URL carry no underscore and stay guarded. */ + int has_underscore = 0; + int all_caps = 1; + for (const char *c = dotted; c < last_dot && *c != '.'; c++) { + if (*c == '_') { + has_underscore = 1; + } else if (*c >= 'a' && *c <= 'z') { + all_caps = 0; + break; + } + } + if (all_caps && has_underscore) { + return true; + } + + /* The candidate's parent segment: the one before its final name. */ + const char *cand_last = strrchr(candidate_qn, '.'); + if (!cand_last || cand_last == candidate_qn) { + return true; /* top-level candidate — no parent to look for */ + } + const char *parent = cand_last; + while (parent > candidate_qn && parent[-1] != '.') { + parent--; + } + size_t parent_len = (size_t)(cand_last - parent); + + /* Walk the chain — every segment before the final callee name. A trailing + * "()" is dropped so JSONEncoder().encode reads as JSONEncoder. */ + for (const char *seg = dotted; seg < last_dot;) { + const char *end = strchr(seg, '.'); + size_t len = (size_t)(end - seg); + if (len >= 2 && seg[len - 2] == '(' && seg[len - 1] == ')') { + len -= 2; /* an empty "()" — JSONEncoder().encode names JSONEncoder */ + } + if (len == parent_len && strncmp(seg, parent, parent_len) == 0) { + return true; + } + seg = end + SKIP_ONE; + } + return false; +} + /* 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, @@ -850,6 +931,9 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char /* Strategy 3: unique name */ if (arr->count == SKIP_ONE) { + if (!receiver_chain_admits(callee_name, arr->items[0])) { + return empty_result(); + } double conf = CONF_UNIQUE_NAME; if (import_vals && import_count > 0 && !is_import_reachable(arr->items[0], import_vals, import_count)) { @@ -864,6 +948,9 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char } const char *best = best_by_import_distance((const char **)arr->items, arr->count, module_qn); if (best) { + if (!receiver_chain_admits(callee_name, best)) { + return empty_result(); + } double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, arr->count); return (cbm_resolution_t){best, "suffix_match", conf, arr->count}; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 564792d2b..506bd29ed 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8787,6 +8787,78 @@ TEST(registry_confidence_suffix_match) { PASS(); } +/* Issue #1893: a call on a library type bound to a same-named project member. + * URLSession is Foundation's, not this project's, so PickedFile.data is the + * wrong target — and with one candidate it won the top name-only confidence. */ +TEST(registry_receiver_chain_refuses_library_unique_name_issue1893) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "data", "HomeboxUI.PickedFile.data", "Variable"); + + cbm_resolution_t r = + cbm_registry_resolve(reg, "URLSession.shared.data", "HomeboxUI.Net", NULL, NULL, 0); + ASSERT_NULL(r.qualified_name); + + cbm_registry_free(reg); + PASS(); +} + +/* The same refusal on the other name-only exit, where several candidates share + * the final name and import distance picks the winner. */ +TEST(registry_receiver_chain_refuses_library_suffix_match_issue1893) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "data", "HomeboxUI.PickedFile.data", "Variable"); + cbm_registry_add(reg, "data", "HomeboxUI.Payload.data", "Variable"); + + cbm_resolution_t r = + cbm_registry_resolve(reg, "URLSession.shared.data", "HomeboxUI.Net", NULL, NULL, 0); + ASSERT_NULL(r.qualified_name); + + cbm_registry_free(reg); + PASS(); +} + +/* The true positive the gate must not eat: the project extends Calendar itself, + * so Calendar really is in the receiver chain. */ +TEST(registry_receiver_chain_keeps_project_extension_issue1893) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "startOfDayUTC", "AuthDTOs.Calendar.startOfDayUTC", "Method"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "Calendar.utcGregorian.startOfDayUTC", + "HomeboxUI.Stats", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "AuthDTOs.Calendar.startOfDayUTC"); + ASSERT_STR_EQ(r.strategy, "unique_name"); + + cbm_registry_free(reg); + PASS(); +} + +/* A lower-case root names a value, whose type the chain does not show. The gate + * must not look at it, or every ordinary vm.load style call would be refused. */ +TEST(registry_receiver_chain_ignores_lowercase_root_issue1893) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "load", "HomeboxUI.EntityListViewModel.load", "Method"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "vm.load", "HomeboxUI.Views", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "HomeboxUI.EntityListViewModel.load"); + ASSERT_STR_EQ(r.strategy, "unique_name"); + + cbm_registry_free(reg); + PASS(); +} + +/* An unqualified callee has no chain at all and must pass through unchanged. */ +TEST(registry_receiver_chain_ignores_bare_name_issue1893) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "helper", "proj.pkg.helper", "Function"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "helper", "proj.other", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "proj.pkg.helper"); + ASSERT_STR_EQ(r.strategy, "unique_name"); + + cbm_registry_free(reg); + PASS(); +} + TEST(registry_fuzzy_confidence_single) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "Handler", "proj.svc.Handler", "Function"); @@ -12676,6 +12748,11 @@ SUITE(pipeline) { RUN_TEST(registry_confidence_same_module); RUN_TEST(registry_confidence_unique_name); RUN_TEST(registry_confidence_suffix_match); + RUN_TEST(registry_receiver_chain_refuses_library_unique_name_issue1893); + RUN_TEST(registry_receiver_chain_refuses_library_suffix_match_issue1893); + RUN_TEST(registry_receiver_chain_keeps_project_extension_issue1893); + RUN_TEST(registry_receiver_chain_ignores_lowercase_root_issue1893); + RUN_TEST(registry_receiver_chain_ignores_bare_name_issue1893); RUN_TEST(registry_fuzzy_confidence_single); RUN_TEST(registry_fuzzy_confidence_distance); RUN_TEST(registry_negative_import_rejects);