fix(registry): reject unreachable qualified fallbacks

When a dotted or namespace-qualified callee has import evidence but no candidate is import-reachable, stop before weak same-simple-name fallback.

This prevents external calls such as email.message.Message or receiver calls such as response.get from resolving to unrelated project symbols while preserving import_map, import_map_suffix, qualified_suffix, and bare-name fallback behavior.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-05 02:59:22 -04:00
parent 11aad8657f
commit cc3794deaa
2 changed files with 49 additions and 4 deletions
+13 -4
View File
@@ -104,6 +104,10 @@ static const char *simple_name(const char *qn) {
return seg;
}
static bool callee_has_qualified_separator(const char *callee_name) {
return callee_name && (strchr(callee_name, '.') != NULL || strstr(callee_name, "::") != NULL);
}
/* Extract everything before the last dot. Returns heap-allocated string. */
/* Check if a qualified name looks like a test/mock path. */
@@ -613,7 +617,8 @@ static cbm_resolution_t resolve_same_module(const cbm_registry_t *r, const char
/* Strategy 4: multiple candidates with import filtering. */
static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const char *module_qn,
const char **import_vals, int import_count) {
const char **import_vals, int import_count,
bool callee_qualified) {
const char *filtered[CBM_SZ_256];
int fcount = 0;
for (int i = 0; i < arr->count && fcount < CBM_SZ_256; i++) {
@@ -632,7 +637,10 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const
return (cbm_resolution_t){best, "suffix_match", conf, fcount};
}
}
/* No import-reachable — use all candidates with penalty */
if (callee_qualified) {
return empty_result();
}
/* No import-reachable for a bare callee — use all candidates with penalty. */
const char *best = best_by_import_distance((const char **)arr->items, arr->count, module_qn);
if (best) {
double conf = candidate_count_penalty(CONF_SUFFIX_MATCH * REG_HALF_PENALTY, arr->count);
@@ -672,7 +680,7 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal
dotted[w] = '\0';
/* Must be qualified (contain a '.') — a bare name matches every candidate
* and carries no disambiguating signal. */
if (!strchr(dotted, '.')) {
if (!callee_has_qualified_separator(dotted)) {
return NULL;
}
const char *match = NULL;
@@ -733,7 +741,8 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char
/* Strategy 4: multiple candidates */
if (import_vals && import_count > 0) {
return resolve_multi_with_imports(arr, module_qn, import_vals, import_count);
return resolve_multi_with_imports(arr, module_qn, import_vals, import_count,
callee_has_qualified_separator(callee_name));
}
const char *best = best_by_import_distance((const char **)arr->items, arr->count, module_qn);
if (best) {
+36
View File
@@ -326,6 +326,40 @@ TEST(resolve_qualified_ambiguous_tail_falls_through) {
PASS();
}
TEST(resolve_qualified_imported_external_rejects_unreachable_suffix) {
cbm_registry_t *r = cbm_registry_new();
cbm_registry_add(r, "Message", "proj.docs.additional.Message", "Class");
cbm_registry_add(r, "Message", "proj.models.Message", "Class");
cbm_registry_add(r, "Message", "proj.other.Message", "Class");
const char *keys[] = {"email.message"};
const char *vals[] = {"email.message"};
cbm_resolution_t res =
cbm_registry_resolve(r, "email.message.Message", "proj.fastapi.routing", keys, vals, 1);
ASSERT_TRUE(!res.qualified_name || res.qualified_name[0] == '\0');
ASSERT_TRUE(!res.strategy || res.strategy[0] == '\0');
cbm_registry_free(r);
PASS();
}
TEST(resolve_dotted_receiver_rejects_unreachable_suffix_when_imports_exist) {
cbm_registry_t *r = cbm_registry_new();
cbm_registry_add(r, "get", "proj.fastapi.routing.APIRouter.get", "Method");
cbm_registry_add(r, "get", "proj.datastructures.Headers.get", "Method");
cbm_registry_add(r, "get", "proj.other.Mapping.get", "Method");
const char *keys[] = {"Request"};
const char *vals[] = {"starlette.requests.Request"};
cbm_resolution_t res =
cbm_registry_resolve(r, "response.get", "proj.fastapi.routing", keys, vals, 1);
ASSERT_TRUE(!res.qualified_name || res.qualified_name[0] == '\0');
ASSERT_TRUE(!res.strategy || res.strategy[0] == '\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");
@@ -797,6 +831,8 @@ SUITE(registry) {
RUN_TEST(resolve_same_module);
RUN_TEST(resolve_qualified_disambiguates_same_name);
RUN_TEST(resolve_qualified_ambiguous_tail_falls_through);
RUN_TEST(resolve_qualified_imported_external_rejects_unreachable_suffix);
RUN_TEST(resolve_dotted_receiver_rejects_unreachable_suffix_when_imports_exist);
RUN_TEST(resolve_import_map);
RUN_TEST(resolve_import_map_bare_function);
RUN_TEST(resolve_unique_name);