From 6b1cc878abbf532b6761feda444aa9f630386dd2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 18:38:13 +0200 Subject: [PATCH] fix(pipeline): resolve overrides through nearest declaring ancestors Preserve all equally near targets and guard Python inheritance against external import collisions. Refs #1278. Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 80 ++++---- src/pipeline/pass_parallel.c | 25 ++- src/pipeline/pass_pkgmap.c | 26 +++ src/pipeline/pass_semantic.c | 253 +++++++++++++++++++++---- src/pipeline/pipeline_internal.h | 11 ++ tests/test_edge_imports.c | 169 +++++++++++++++++ tests/test_pipeline.c | 314 +++++++++++++++++++++++++++++++ tests/test_registry.c | 37 ++++ 8 files changed, 844 insertions(+), 71 deletions(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 66751a682..5fb4bfd95 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -220,25 +220,47 @@ static bool pxc_base_strategy_is_weak(const char *strategy) { strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0; } +/* One file's base-resolution scope: the registry + import map pass_semantic + * uses, plus the facts the external-base gate reads. NULL (the surface-probe + * path) keeps every base at its raw spelling. */ +typedef struct { + const cbm_registry_t *reg; + const char **imp_keys; + const char **imp_vals; + int imp_count; + const CBMImportArray *imports; + const cbm_gbuf_t *gbuf; + const char *project_name; + const char *rel; + CBMLanguage lang; +} pxc_base_scope_t; + /* Resolve one base-class spelling to a project QN. Mirrors * pass_semantic.c::resolve_as_class — same registry, same type-like veto — * then additionally rejects weak short-name strategies (see above). * Returns NULL when the base is not a confidently-known project type; * stdlib and third-party bases land here and keep their raw spelling. */ -static const char *pxc_resolve_base_qn(const cbm_registry_t *reg, const char *raw, - const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count) { - if (!reg || !raw || !raw[0]) { +static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *raw, + const char *module_qn) { + if (!bs || !bs->reg || !raw || !raw[0]) { return NULL; } - cbm_resolution_t res = cbm_registry_resolve(reg, raw, module_qn, imp_keys, imp_vals, imp_count); + cbm_resolution_t res = + cbm_registry_resolve(bs->reg, raw, module_qn, bs->imp_keys, bs->imp_vals, bs->imp_count); if (!res.qualified_name || !res.qualified_name[0]) { return NULL; } if (pxc_base_strategy_is_weak(res.strategy)) { return NULL; } - if (!cbm_label_is_type_like(cbm_registry_label_of(reg, res.qualified_name))) { + if (!cbm_label_is_type_like(cbm_registry_label_of(bs->reg, res.qualified_name))) { + return NULL; + } + /* `unittest.TestCase` under an external `import unittest` survives the + * weak-strategy veto through the same-module suffix fallback; the shared + * external-base gate keeps it at its source spelling. */ + if (cbm_python_external_base_contradicts(bs->lang, bs->imports, raw, res.qualified_name, + bs->gbuf, bs->project_name, bs->rel)) { return NULL; } return res.qualified_name; @@ -248,8 +270,7 @@ static const char *pxc_resolve_base_qn(const cbm_registry_t *reg, const char *ra * source spelling. Unresolved entries pass through verbatim so a base the * registry does not know keeps working exactly as before. */ static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, - const cbm_registry_t *reg, const char *module_qn, - const char **imp_keys, const char **imp_vals, int imp_count) { + const pxc_base_scope_t *bs, const char *module_qn) { if (!bases || !bases[0]) { return NULL; } @@ -263,8 +284,7 @@ static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, return pxc_join_pipe(arena, bases); } for (int i = 0; i < count; i++) { - const char *qn = - pxc_resolve_base_qn(reg, bases[i], module_qn, imp_keys, imp_vals, imp_count); + const char *qn = pxc_resolve_base_qn(bs, bases[i], module_qn); resolved[i] = qn ? qn : bases[i]; } resolved[count] = NULL; @@ -390,8 +410,7 @@ static const char *pxc_qn_leaf(const char *name) { * pointers into src and into `arena` for synthesised composites. */ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const char *module_qn, const char *namespace_name, CBMLanguage lang, CBMLSPDef *dst, - const cbm_registry_t *reg, const char **imp_keys, - const char **imp_vals, int imp_count) { + const pxc_base_scope_t *bs) { const char *label = pxc_map_label(arena, src->label); if (!label || !src->qualified_name || !src->name) return -1; @@ -417,9 +436,8 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch /* Languages whose cross registrars read embedded_types as QNs get their * bases resolved against the project registry; everyone else keeps the * raw source spelling their own registrar already knows how to handle. */ - dst->embedded_types = (reg && pxc_lang_resolves_base_qns(lang)) - ? pxc_join_base_qns(arena, src->base_classes, reg, module_qn, - imp_keys, imp_vals, imp_count) + dst->embedded_types = (bs && bs->reg && pxc_lang_resolves_base_qns(lang)) + ? pxc_join_base_qns(arena, src->base_classes, bs, module_qn) : pxc_join_pipe(arena, src->base_classes); dst->signature_param_types = NULL; dst->signature_param_count = 0; @@ -593,11 +611,10 @@ typedef struct { } pxc_py_field_t; static void pxc_fold_py_field_types(CBMArena *arena, const CBMFileResult *result, CBMLSPDef *defs, - int start, int end, const cbm_registry_t *reg, - const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count) { + int start, int end, const pxc_base_scope_t *bs, + const char *module_qn) { int nf = result ? result->field_types.count : 0; - if (!arena || nf == 0 || !defs || start >= end || !reg) { + if (!arena || nf == 0 || !defs || start >= end || !bs || !bs->reg) { return; } CBMHashTable *by_qn = cbm_ht_create((uint32_t)(end - start)); @@ -622,8 +639,7 @@ static void pxc_fold_py_field_types(CBMArena *arena, const CBMFileResult *result fields[f].type_qn = NULL; CBMLSPDef *owner = ft->class_qn ? (CBMLSPDef *)cbm_ht_get(by_qn, ft->class_qn) : NULL; const char *name = owner ? pxc_py_annotation_type_name(arena, ft->type_text) : NULL; - const char *qn = - name ? pxc_resolve_base_qn(reg, name, module_qn, imp_keys, imp_vals, imp_count) : NULL; + const char *qn = name ? pxc_resolve_base_qn(bs, name, module_qn) : NULL; if (!qn || !ft->field_name || !ft->field_name[0]) { continue; } @@ -744,27 +760,27 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *are * that consume resolved base QNs, and only when a caller supplied the * pipeline context (the surface-probe path passes NULL and keeps the * raw spelling). */ - const cbm_registry_t *base_reg = NULL; - const char **imp_keys = NULL; - const char **imp_vals = NULL; - int imp_count = 0; + pxc_base_scope_t bs = {.imports = &fr->imports, + .project_name = project_name, + .rel = files[fi].rel_path, + .lang = files[fi].language}; if (ctx && ctx->registry && pxc_lang_resolves_base_qns(files[fi].language)) { - base_reg = ctx->registry; + bs.reg = ctx->registry; + bs.gbuf = ctx->gbuf; cbm_pxc_build_import_map(ctx->gbuf, project_name, files[fi].rel_path, - files[fi].language, fr, &imp_keys, &imp_vals, &imp_count); + files[fi].language, fr, &bs.imp_keys, &bs.imp_vals, + &bs.imp_count); } for (int di = 0; di < fr->defs.count; di++) { if (pxc_build_lsp_def(arena, &fr->defs.items[di], def_modules[fi], namespace_name, - files[fi].language, &defs[idx], base_reg, imp_keys, imp_vals, - imp_count) == 0) { + files[fi].language, &defs[idx], bs.reg ? &bs : NULL) == 0) { idx++; } } if (files[fi].language == CBM_LANG_PYTHON) { - pxc_fold_py_field_types(arena, fr, defs, file_start, idx, base_reg, def_modules[fi], - imp_keys, imp_vals, imp_count); + pxc_fold_py_field_types(arena, fr, defs, file_start, idx, &bs, def_modules[fi]); } - cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count); /* NULL-safe */ + cbm_pxc_free_import_map(bs.imp_keys, bs.imp_vals, bs.imp_count); /* NULL-safe */ if (files[fi].language == CBM_LANG_GO) { pxc_fold_go_struct_fields(arena, fr, defs, file_start, idx); } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a91acaf83..c5cedf445 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3357,16 +3357,27 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi } } +/* The per-file facts the base-class gate needs beyond the import map. */ +typedef struct { + const CBMImportArray *imports; + const char *rel; + CBMLanguage lang; +} pp_file_scope_t; + /* Resolve base_classes → INHERITS edges for one definition. */ static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws, const CBMDefinition *def, const cbm_gbuf_node_t *node, - const char *mq, const char **ik, const char **iv, int ic) { + const pp_file_scope_t *fs, const char *mq, const char **ik, + const char **iv, int ic) { if (!def->base_classes) { return; } for (int b = 0; def->base_classes[b]; b++) { const char *bqn = resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic); - if (!bqn) { + /* Same external-base gate as the sequential semantic pass. */ + if (!bqn || + cbm_python_external_base_contradicts(fs->lang, fs->imports, def->base_classes[b], bqn, + rc->main_gbuf, rc->project_name, fs->rel)) { continue; } const cbm_gbuf_node_t *bn = cbm_gbuf_find_by_qn(rc->main_gbuf, bqn); @@ -3453,8 +3464,10 @@ static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws /* Resolve INHERITS + DECORATES + IMPLEMENTS for one file. */ static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws, - CBMFileResult *result, const char *module_qn, - const char **imp_keys, const char **imp_vals, int imp_count) { + CBMFileResult *result, const char *rel, CBMLanguage lang, + const char *module_qn, const char **imp_keys, + const char **imp_vals, int imp_count) { + const pp_file_scope_t fs = {&result->imports, rel, lang}; for (int d = 0; d < result->defs.count; d++) { CBMDefinition *def = &result->defs.items[d]; if (!def->qualified_name) { @@ -3464,7 +3477,7 @@ static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (!node) { continue; } - resolve_def_inherits(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count); + resolve_def_inherits(rc, ws, def, node, &fs, module_qn, imp_keys, imp_vals, imp_count); resolve_def_decorators(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count); } for (int t = 0; t < result->impl_traits.count; t++) { @@ -3813,7 +3826,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { /* ── INHERITS + DECORATES + IMPLEMENTS ──────────────────── */ _ph_t0 = extract_now_ns(); - resolve_file_semantic(rc, ws, result, module_qn, imp_keys, imp_vals, imp_count); + resolve_file_semantic(rc, ws, result, rel, lang, module_qn, imp_keys, imp_vals, imp_count); atomic_fetch_add_explicit(&rc->time_ns_semantic, extract_now_ns() - _ph_t0, memory_order_relaxed); diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index dc5e46db0..152f111d0 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -1676,6 +1676,11 @@ bool cbm_python_import_binding_contradicts(const CBMImportArray *imports, const /* `import a.b` binds the ROOT package `a`: the callee already * spells its own full dotted path (`a.b.f()`). */ snprintf(full, sizeof(full), "%s", callee_name); + } else if (!as && !path_leaf_seg && full[0] != '.' && strcmp(full, imp->local_name) != 0) { + /* `import m as y` (the extractor stores module_path `m`, local + * `y`; a from-import always spells a dotted path): `y` IS module + * `m`, so `y.T` spells `m.T`. */ + snprintf(full, sizeof(full), "%s%s", imp->module_path, callee_name + root_len); } /* Otherwise (`from m import x [as y]`) only the import's own module * chain is evidence: members reached THROUGH x (`x.objects.create`) @@ -1691,6 +1696,27 @@ bool cbm_python_import_binding_contradicts(const CBMImportArray *imports, const return bound; } +/* Base-class twin of the #2127 call gate. `class SimpleTestCase( + * unittest.TestCase)` under `import unittest` names the STDLIB base; the + * registry's same-module suffix fallback (`unittest.TestCase` -> module's + * `TestCase`) or a short-name strategy bound it to a same-named PROJECT class + * (django: an inheritance cycle SimpleTestCase -> TestCase -> + * TransactionTestCase -> SimpleTestCase, plus fabricated OVERRIDEs). An + * external import binding (never materialized as an IMPORTS edge) whose + * module chain the target does not spell can never be that target, whatever + * the registry strategy. Exact metadata import maps remain valid when the + * target spells the imported module chain. Every base resolver (sequential + * + parallel semantic, cross-LSP pxc) calls this ONE + * gate so the venues cannot diverge. Python only. */ +bool cbm_python_external_base_contradicts(CBMLanguage lang, const CBMImportArray *imports, + const char *base_spelling, const char *base_qn, + const cbm_gbuf_t *gbuf, const char *project_name, + const char *rel_path) { + return lang == CBM_LANG_PYTHON && + cbm_python_import_binding_contradicts(imports, base_spelling, base_qn, gbuf, + project_name, rel_path); +} + /* #2127: whether a Python import's module lives in this project. Relative * imports always do; otherwise the module part (the path minus the imported * name) must resolve to a graph node. Strategy 1 has already failed on the diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 48dab0083..128576cf3 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -611,6 +611,7 @@ static void resolve_decorator(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *no } static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, + const cbm_file_info_t *fi, const CBMFileResult *result, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, int *inherits_count, int *decorates_count) { @@ -625,8 +626,10 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * for (int b = 0; def->base_classes[b]; b++) { const char *base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, imp_keys, imp_vals, imp_count); - if (!base_qn) { - continue; + if (!base_qn || cbm_python_external_base_contradicts( + fi->language, &result->imports, def->base_classes[b], base_qn, + ctx->gbuf, ctx->project_name, fi->rel_path)) { + continue; /* unresolved, or an external base (`unittest.TestCase`) */ } const cbm_gbuf_node_t *base_node = cbm_gbuf_find_by_qn(ctx->gbuf, base_qn); if (base_node && node->id != base_node->id) { @@ -736,8 +739,8 @@ int cbm_pipeline_pass_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *f /* ── INHERITS + DECORATES from definitions ──────────────── */ for (int d = 0; d < result->defs.count; d++) { - sem_process_def_edges(ctx, &result->defs.items[d], module_qn, imp_keys, imp_vals, - imp_count, &inherits_count, &decorates_count); + sem_process_def_edges(ctx, &result->defs.items[d], &files[i], result, module_qn, + imp_keys, imp_vals, imp_count, &inherits_count, &decorates_count); } /* ── IMPLEMENTS from impl_traits (Rust) ─────────────────── */ @@ -770,31 +773,111 @@ const char *cbm_semantic_base_edge_type(const cbm_gbuf_node_t *base_node) { : "INHERITS"; } -/* Create OVERRIDE edges from one class's methods to same-named methods of one - * explicit base (interface or superclass). */ -static int override_match_methods(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *cls, - const cbm_gbuf_node_t *base) { - const cbm_gbuf_edge_t **cls_dm = NULL; - int cls_dm_count = 0; - cbm_gbuf_find_edges_by_source_type(ctx->gbuf, cls->id, "DEFINES_METHOD", &cls_dm, - &cls_dm_count); - if (cls_dm_count == 0) { - return 0; +/* ── Explicit OVERRIDE: nearest declaring ancestor (#1278) ────────── + * + * A method overrides the same-named method of its NEAREST ancestor that + * declares one, not only of its direct base: `Leaf(Intermediate(Processor))` + * with an Intermediate that redeclares nothing still binds + * Leaf.process -> Processor.process. + * + * The walk is level-synchronous over explicit IMPLEMENTS/INHERITS edges: + * level 1 is every direct base, level k+1 every not-yet-visited base of + * level k. A method is bound to every declaring ancestor on the FIRST level + * that has one and is not looked up further, so the edge set does not depend + * on edge or node order (a diamond's two equally near declarations both + * bind; its shared root binds once, via insert-edge dedup). Direct bases are + * level 1, so every edge the direct-base-only matcher made is still made. + * A visited set makes every ancestor enter the walk at most once: cycles and + * self-loops in a malformed hierarchy terminate, and the level count can + * never exceed the node count (the check below is only that invariant). */ + +typedef struct { + int64_t *ids; + int count; + int cap; +} ovr_id_list_t; + +static bool ovr_ids_push(ovr_id_list_t *list, int64_t id) { + if (list->count == list->cap) { + int cap = list->cap ? list->cap * 2 : 16; + int64_t *grown = + cbm_realloc(CBM_MEM_CLASS_SEMANTIC, list->ids, (size_t)cap * sizeof(int64_t)); + if (!grown) { + return false; + } + list->ids = grown; + list->cap = cap; + } + list->ids[list->count++] = id; + return true; +} + +static int ovr_cmp_id(const void *a, const void *b) { + int64_t x = *(const int64_t *)a; + int64_t y = *(const int64_t *)b; + return (x > y) - (x < y); +} + +/* Sort + unique in place. */ +static void ovr_ids_normalize(ovr_id_list_t *list) { + if (list->count < 2) { + return; + } + qsort(list->ids, (size_t)list->count, sizeof(int64_t), ovr_cmp_id); + int w = 1; + for (int r = 1; r < list->count; r++) { + if (list->ids[r] != list->ids[w - 1]) { + list->ids[w++] = list->ids[r]; + } + } + list->count = w; +} + +static bool ovr_ids_has(const ovr_id_list_t *sorted, int64_t id) { + return sorted->count > 0 && + bsearch(&id, sorted->ids, (size_t)sorted->count, sizeof(int64_t), ovr_cmp_id) != NULL; +} + +static const char *const ovr_base_edge_types[] = {"IMPLEMENTS", "INHERITS"}; +enum { OVR_BASE_EDGE_TYPE_COUNT = 2 }; + +/* Append every explicit base of `node` to `out`. */ +static bool ovr_push_bases(const cbm_gbuf_t *gb, int64_t node, ovr_id_list_t *out) { + for (int t = 0; t < OVR_BASE_EDGE_TYPE_COUNT; t++) { + const cbm_gbuf_edge_t **edges = NULL; + int n = 0; + cbm_gbuf_find_edges_by_source_type(gb, node, ovr_base_edge_types[t], &edges, &n); + for (int e = 0; e < n; e++) { + if (!ovr_ids_push(out, edges[e]->target_id)) { + return false; + } + } } + return true; +} + +/* Bind each still-unbound method of the class to the same-named methods of + * one ancestor. Marks nothing: a level's bindings are committed by the caller + * after the whole level ran, so equally near declarations all bind. */ +static int ovr_match_ancestor(cbm_gbuf_t *gb, const cbm_gbuf_edge_t **cls_dm, int cls_dm_count, + const bool *bound, bool *bound_now, int64_t ancestor) { const cbm_gbuf_edge_t **base_dm = NULL; int base_dm_count = 0; - cbm_gbuf_find_edges_by_source_type(ctx->gbuf, base->id, "DEFINES_METHOD", &base_dm, - &base_dm_count); + cbm_gbuf_find_edges_by_source_type(gb, ancestor, "DEFINES_METHOD", &base_dm, &base_dm_count); int created = 0; for (int c = 0; c < cls_dm_count; c++) { - const cbm_gbuf_node_t *cm = cbm_gbuf_find_by_id(ctx->gbuf, cls_dm[c]->target_id); + if (bound[c]) { + continue; + } + const cbm_gbuf_node_t *cm = cbm_gbuf_find_by_id(gb, cls_dm[c]->target_id); if (!cm || !cm->name) { continue; } for (int b = 0; b < base_dm_count; b++) { - const cbm_gbuf_node_t *bm = cbm_gbuf_find_by_id(ctx->gbuf, base_dm[b]->target_id); + const cbm_gbuf_node_t *bm = cbm_gbuf_find_by_id(gb, base_dm[b]->target_id); if (bm && bm->name && cm->id != bm->id && strcmp(cm->name, bm->name) == 0) { - cbm_gbuf_insert_edge(ctx->gbuf, cm->id, bm->id, "OVERRIDE", "{}"); + cbm_gbuf_insert_edge(gb, cm->id, bm->id, "OVERRIDE", "{}"); + bound_now[c] = true; created++; break; } @@ -803,29 +886,133 @@ static int override_match_methods(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t return created; } +typedef struct { + ovr_id_list_t visited; /* sorted */ + ovr_id_list_t level; + ovr_id_list_t next; + bool *bound; + bool *bound_now; + int flags_cap; +} ovr_walk_t; + +static void ovr_walk_free(ovr_walk_t *w) { + cbm_free(CBM_MEM_CLASS_SEMANTIC, w->visited.ids); + cbm_free(CBM_MEM_CLASS_SEMANTIC, w->level.ids); + cbm_free(CBM_MEM_CLASS_SEMANTIC, w->next.ids); + cbm_free(CBM_MEM_CLASS_SEMANTIC, w->bound); + cbm_free(CBM_MEM_CLASS_SEMANTIC, w->bound_now); +} + +static bool ovr_walk_reserve_flags(ovr_walk_t *w, int n) { + if (n <= w->flags_cap) { + return true; + } + bool *b = cbm_realloc(CBM_MEM_CLASS_SEMANTIC, w->bound, (size_t)n * sizeof(bool)); + if (!b) { + return false; + } + w->bound = b; + bool *bn = cbm_realloc(CBM_MEM_CLASS_SEMANTIC, w->bound_now, (size_t)n * sizeof(bool)); + if (!bn) { + return false; + } + w->bound_now = bn; + w->flags_cap = n; + return true; +} + +/* Drop from `next` every id already visited, then add the rest to visited. */ +static bool ovr_walk_advance(ovr_walk_t *w) { + ovr_ids_normalize(&w->next); + w->level.count = 0; + for (int i = 0; i < w->next.count; i++) { + int64_t id = w->next.ids[i]; + if (!ovr_ids_has(&w->visited, id) && !ovr_ids_push(&w->level, id)) { + return false; + } + } + for (int i = 0; i < w->level.count; i++) { + if (!ovr_ids_push(&w->visited, w->level.ids[i])) { + return false; + } + } + ovr_ids_normalize(&w->visited); + w->next.count = 0; + return true; +} + +/* All OVERRIDE edges of one class: walk its ancestors level by level until + * every method is bound or the ancestors run out. */ +static int ovr_class_overrides(cbm_gbuf_t *gb, int64_t cls_id, ovr_walk_t *w) { + const cbm_gbuf_edge_t **cls_dm = NULL; + int cls_dm_count = 0; + cbm_gbuf_find_edges_by_source_type(gb, cls_id, "DEFINES_METHOD", &cls_dm, &cls_dm_count); + if (cls_dm_count == 0 || !ovr_walk_reserve_flags(w, cls_dm_count)) { + return 0; + } + memset(w->bound, 0, (size_t)cls_dm_count * sizeof(bool)); + memset(w->bound_now, 0, (size_t)cls_dm_count * sizeof(bool)); + w->visited.count = 0; + w->next.count = 0; + if (!ovr_ids_push(&w->visited, cls_id) || !ovr_push_bases(gb, cls_id, &w->next) || + !ovr_walk_advance(w)) { + return 0; + } + int created = 0; + int unbound = cls_dm_count; + const int max_levels = cbm_gbuf_node_count(gb); /* invariant, never reached */ + for (int depth = 0; w->level.count > 0 && unbound > 0 && depth < max_levels; depth++) { + for (int i = 0; i < w->level.count; i++) { + created += ovr_match_ancestor(gb, cls_dm, cls_dm_count, w->bound, w->bound_now, + w->level.ids[i]); + if (!ovr_push_bases(gb, w->level.ids[i], &w->next)) { + return created; + } + } + for (int c = 0; c < cls_dm_count; c++) { + if (w->bound_now[c] && !w->bound[c]) { + w->bound[c] = true; + unbound--; + } + } + if (!ovr_walk_advance(w)) { + return created; + } + } + return created; +} + int cbm_pipeline_override_explicit(cbm_pipeline_ctx_t *ctx) { if (!ctx || !ctx->gbuf) { return 0; } - int created = 0; - static const char *base_edge_types[] = {"IMPLEMENTS", "INHERITS"}; - for (size_t t = 0; t < sizeof(base_edge_types) / sizeof(base_edge_types[0]); t++) { + /* Every class with at least one explicit base, once, in id order. */ + ovr_id_list_t classes = {0}; + for (int t = 0; t < OVR_BASE_EDGE_TYPE_COUNT; t++) { const cbm_gbuf_edge_t **edges = NULL; int edge_count = 0; - cbm_gbuf_find_edges_by_type(ctx->gbuf, base_edge_types[t], &edges, &edge_count); + cbm_gbuf_find_edges_by_type(ctx->gbuf, ovr_base_edge_types[t], &edges, &edge_count); for (int e = 0; e < edge_count; e++) { - const cbm_gbuf_node_t *cls = cbm_gbuf_find_by_id(ctx->gbuf, edges[e]->source_id); - const cbm_gbuf_node_t *base = cbm_gbuf_find_by_id(ctx->gbuf, edges[e]->target_id); - if (!cls || !base) { - continue; - } - /* Go's implicit satisfaction already emits OVERRIDE with interface - * semantics; running both would double-cover .go sources. */ - if (cls->file_path && fp_ends_with(cls->file_path, ".go")) { - continue; + if (!ovr_ids_push(&classes, edges[e]->source_id)) { + cbm_free(CBM_MEM_CLASS_SEMANTIC, classes.ids); + return 0; } - created += override_match_methods(ctx, cls, base); } } + ovr_ids_normalize(&classes); + + int created = 0; + ovr_walk_t walk = {0}; + for (int i = 0; i < classes.count; i++) { + const cbm_gbuf_node_t *cls = cbm_gbuf_find_by_id(ctx->gbuf, classes.ids[i]); + /* Go's implicit satisfaction already emits OVERRIDE with interface + * semantics; running both would double-cover .go sources. */ + if (!cls || (cls->file_path && fp_ends_with(cls->file_path, ".go"))) { + continue; + } + created += ovr_class_overrides(ctx->gbuf, cls->id, &walk); + } + ovr_walk_free(&walk); + cbm_free(CBM_MEM_CLASS_SEMANTIC, classes.ids); return created; } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 7f266eae9..03182d833 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -317,6 +317,17 @@ bool cbm_python_import_binding_contradicts(const CBMImportArray *imports, const const char *resolved_qn, const cbm_gbuf_t *gbuf, const char *project_name, const char *rel_path); +/* Base-class twin: true when a Python base spelling (`unittest.TestCase`, + * `TestCase` under `from unittest import TestCase`, `ut.TestCase` under + * `import unittest as ut`) is bound by an EXTERNAL import whose chain + * contradicts `base_qn` -- the INHERITS / embedded-type resolution must then + * leave the base unresolved. False for every other language. Shared by + * pass_semantic.c, pass_parallel.c and pass_lsp_cross.c. */ +bool cbm_python_external_base_contradicts(CBMLanguage lang, const CBMImportArray *imports, + const char *base_spelling, const char *base_qn, + const cbm_gbuf_t *gbuf, const char *project_name, + const char *rel_path); + /* Check if a file path is worth tracking for git history analysis. */ bool cbm_is_trackable_file(const char *path); diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 097cebafe..99a3e0035 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -752,6 +752,174 @@ TEST(ei_py_external_import_never_binds_project_symbol) { PASS(); } +/* Base-class helper: INHERITS edges out of the (single) Class named `src` + * (return value), and how many land on a node whose QN ends with + * `want_qn_suffix` (*to_want); -1 when the class is missing. */ +static int ei_inherits_out(cbm_store_t *store, const char *project, const char *src, + const char *want_qn_suffix, int *to_want) { + *to_want = 0; + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_name(store, project, src, &nodes, &count) != CBM_STORE_OK) { + return -1; + } + int64_t id = 0; + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, "Class") == 0) { + id = nodes[i].id; + } + } + cbm_store_free_nodes(nodes, count); + if (id == 0) { + return -1; + } + cbm_edge_t *edges = NULL; + int n = 0; + if (cbm_store_find_edges_by_source_type(store, id, "INHERITS", &edges, &n) != CBM_STORE_OK) { + return -1; + } + size_t wl = strlen(want_qn_suffix); + for (int i = 0; i < n; i++) { + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) == CBM_STORE_OK && + tgt.qualified_name) { + size_t ql = strlen(tgt.qualified_name); + if (ql >= wl && strcmp(tgt.qualified_name + ql - wl, want_qn_suffix) == 0) { + (*to_want)++; + } + } + cbm_node_free_fields(&tgt); + } + cbm_store_free_edges(edges, n); + return n; +} + +/* Count one exact method-to-method OVERRIDE in this fixture's testcases.py. + * Missing endpoints or a failed edge query are fixture failures, not zero. */ +static int ei_testcases_override(cbm_store_t *store, const char *project, const char *source, + const char *target) { + char source_qn[512]; + char target_qn[512]; + snprintf(source_qn, sizeof(source_qn), "%s.pkg.test.testcases.%s", project, source); + snprintf(target_qn, sizeof(target_qn), "%s.pkg.test.testcases.%s", project, target); + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + int found_src = cbm_store_find_node_by_qn(store, project, source_qn, &src); + int found_tgt = cbm_store_find_node_by_qn(store, project, target_qn, &tgt); + int matches = -1; + if (found_src == CBM_STORE_OK && found_tgt == CBM_STORE_OK) { + cbm_edge_t *edges = NULL; + int count = 0; + if (cbm_store_find_edges_by_source_type(store, src.id, "OVERRIDE", &edges, &count) == + CBM_STORE_OK) { + matches = 0; + for (int i = 0; i < count; i++) { + matches += edges[i].target_id == tgt.id; + } + } + cbm_store_free_edges(edges, count); + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + return matches; +} + +/* The django shape (django/test/testcases.py): `class SimpleTestCase( + * unittest.TestCase)` names the STDLIB base through `import unittest`, an + * external module. The registry fell through to a short-name strategy and + * bound it to the project's own `TestCase` -- which itself descends from + * SimpleTestCase, so the graph grew an inheritance CYCLE + * (SimpleTestCase -> TestCase -> TransactionTestCase -> SimpleTestCase) and a + * fabricated OVERRIDE (SimpleTestCase.setUp -> TestCase.setUp). Same family + * as #2127: a name bound by an external import can never denote a same-named + * project symbol. Controls: the project bases (same-module, relative + * `from .testcases import TestCase`, dotted `pkg.test.models.Base` through a + * project `import`) keep their edge. `pad` > MIN_FILES_FOR_PARALLEL(50) runs + * the parallel resolver; 0 the sequential one. */ +static int ei_py_external_base_case(int pad) { + enum { EI_BASE_FIXED = 7, EI_BASE_MAX = EI_BASE_FIXED + 64 }; + static char names[EI_BASE_MAX][32]; + EILangFile f[EI_BASE_MAX]; + int n = 0; + f[n++] = (EILangFile){"pkg/__init__.py", ""}; + f[n++] = (EILangFile){"pkg/test/__init__.py", ""}; + f[n++] = (EILangFile){"pkg/test/testcases.py", "import unittest\n\n\n" + "class SimpleTestCase(unittest.TestCase):\n" + " def setUp(self):\n pass\n\n\n" + "class TransactionTestCase(SimpleTestCase):\n" + " pass\n\n\n" + "class TestCase(TransactionTestCase):\n" + " def setUp(self):\n pass\n"}; + f[n++] = (EILangFile){"pkg/test/models.py", "class Base:\n pass\n"}; + f[n++] = (EILangFile){"pkg/test/suite.py", "import pkg.test.models\n" + "from .testcases import TestCase\n\n\n" + "class RelativeUser(TestCase):\n pass\n\n\n" + "class DottedLocal(pkg.test.models.Base):\n" + " pass\n"}; + f[n++] = (EILangFile){"pkg/test/stdbare.py", "from unittest import TestCase\n\n\n" + "class BareStd(TestCase):\n pass\n"}; + f[n++] = (EILangFile){"pkg/test/stdalias.py", "import unittest as ut\n\n\n" + "class AliasStd(ut.TestCase):\n pass\n"}; + for (int i = 0; i < pad && n < EI_BASE_MAX; i++) { + snprintf(names[n], sizeof(names[n]), "pad/mod_%02d.py", i); + f[n] = (EILangFile){names[n], "def filler():\n return 0\n"}; + n++; + } + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, n); + int w = 0; + int simple = store ? ei_inherits_out(store, lp.project, "SimpleTestCase", ".TestCase", &w) : -1; + int bare = store ? ei_inherits_out(store, lp.project, "BareStd", ".TestCase", &w) : -1; + int alias = store ? ei_inherits_out(store, lp.project, "AliasStd", ".TestCase", &w) : -1; + int w_tx = 0; + int w_tc = 0; + int w_rel = 0; + int w_dot = 0; + int tx = store ? ei_inherits_out(store, lp.project, "TransactionTestCase", + "testcases.SimpleTestCase", &w_tx) + : -1; + int tc = store ? ei_inherits_out(store, lp.project, "TestCase", "testcases.TransactionTestCase", + &w_tc) + : -1; + int rel = store + ? ei_inherits_out(store, lp.project, "RelativeUser", "testcases.TestCase", &w_rel) + : -1; + int dot = store ? ei_inherits_out(store, lp.project, "DottedLocal", "models.Base", &w_dot) : -1; + int overrides = store ? cbm_store_count_edges_by_type(store, lp.project, "OVERRIDE") : -1; + /* #1278: TransactionTestCase declares no setUp. The nearest declaring + * project ancestor of TestCase.setUp is SimpleTestCase.setUp. The missing + * external INHERITS edge must still forbid the reverse override. */ + int forward = store ? ei_testcases_override(store, lp.project, "TestCase.setUp", + "SimpleTestCase.setUp") + : -1; + int reverse = store ? ei_testcases_override(store, lp.project, "SimpleTestCase.setUp", + "TestCase.setUp") + : -1; + int ok = simple == 0 && bare == 0 && alias == 0 && tx == 1 && w_tx == 1 && tc == 1 && + w_tc == 1 && rel == 1 && w_rel == 1 && dot == 1 && w_dot == 1 && overrides == 1 && + forward == 1 && reverse == 0; + if (!ok) { + fprintf(stderr, + " [py-ext-base pad=%d] external bases SimpleTestCase=%d BareStd=%d AliasStd=%d " + "(want 0/0/0); project bases Transaction=%d/%d TestCase=%d/%d Relative=%d/%d " + "Dotted=%d/%d (want 1/1 each); OVERRIDE=%d (want 1); " + "forward=%d reverse=%d (want 1/0)\n", + pad, simple, bare, alias, tx, w_tx, tc, w_tc, rel, w_rel, dot, w_dot, overrides, + forward, reverse); + } + ei_cleanup(&lp, store); + return ok; +} + +TEST(ei_py_external_base_never_inherits_project_class) { + /* Both legs run before asserting so a failure diagnoses both drivers. */ + int sequential_ok = ei_py_external_base_case(0); + int parallel_ok = ei_py_external_base_case(60); + ASSERT_TRUE(sequential_ok); + ASSERT_TRUE(parallel_ok); + PASS(); +} + /* C++: header include should resolve to the header file node, not the same-stem * source node. Also exercises angle-bracket include resolution. */ TEST(ei_cpp_header_include_targets_header_file) { @@ -1279,6 +1447,7 @@ SUITE(edge_imports) { RUN_TEST(ei_go_two_consumers_same_package); RUN_TEST(ei_go_import_never_binds_symbol); RUN_TEST(ei_py_external_import_never_binds_project_symbol); + RUN_TEST(ei_py_external_base_never_inherits_project_class); RUN_TEST(ei_cpp_header_include_targets_header_file); /* ── RED REPRODUCTIONS — Rust (expected to FAIL until pipeline fixed) ── */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9e26829e2..fb47d3a48 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8305,6 +8305,316 @@ TEST(python_crossfile_typed_field_calls_issue1277_parallel) { return check_python_crossfile_typed_field_calls_issue1277(true); } +/* Bind the external-import veto specifically to #1277's typed-field fold. + * No base classes or CALLS edges participate: a name-only call fallback + * cannot turn this metadata assertion into a false positive. The unguarded + * registry deliberately has a same-module TestCase candidate in both cases. */ +static int python_typed_field_scope_case(bool external) { + char source[512]; + snprintf(source, sizeof(source), + "import unittest\n\n" + "class TestCase:\n pass\n\n" + "class Holder:\n field: %s\n", + external ? "unittest.TestCase" : "TestCase"); + CBMFileResult *result = cbm_extract_file(source, (int)strlen(source), CBM_LANG_PYTHON, + "proj", "models.py", 0, NULL, NULL); + cbm_registry_t *reg = cbm_registry_new(); + cbm_gbuf_t *gbuf = cbm_gbuf_new("proj", "/unused"); + CBMArena arena; + cbm_arena_init(&arena); + CBMLSPDef *defs = NULL; + char *modules[1] = {NULL}; + int ok = 0; + if (result && reg && gbuf && result->field_types.count == 1) { + for (int i = 0; i < result->defs.count; i++) { + const CBMDefinition *def = &result->defs.items[i]; + cbm_registry_add(reg, def->name, def->qualified_name, def->label); + } + const char **keys = NULL; + const char **vals = NULL; + int import_count = 0; + cbm_pxc_build_import_map(gbuf, "proj", "models.py", CBM_LANG_PYTHON, result, &keys, + &vals, &import_count); + cbm_resolution_t raw = cbm_registry_resolve( + reg, external ? "unittest.TestCase" : "TestCase", "proj.models", keys, vals, + import_count); + bool candidate = raw.qualified_name && raw.strategy && + strcmp(raw.qualified_name, "proj.models.TestCase") == 0 && + strcmp(raw.strategy, "same_module") == 0; + cbm_pxc_free_import_map(keys, vals, import_count); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", .gbuf = gbuf, .registry = reg}; + cbm_file_info_t files[1] = {{.rel_path = "models.py", .language = CBM_LANG_PYTHON}}; + CBMFileResult *cache[1] = {result}; + int count = 0; + defs = cbm_pxc_collect_all_defs(&ctx, &arena, cache, files, 1, "proj", modules, &count, + NULL); + int holders = 0; + bool field_ok = false; + for (int i = 0; defs && i < count; i++) { + if (defs[i].qualified_name && + strcmp(defs[i].qualified_name, "proj.models.Holder") == 0) { + holders++; + field_ok = external ? !defs[i].field_defs + : defs[i].field_defs && + strcmp(defs[i].field_defs, + "field:proj.models.TestCase") == 0; + } + } + ok = candidate && holders == 1 && field_ok; + if (!ok) { + fprintf(stderr, " [py-field-scope external=%d] candidate=%d holders=%d field_ok=%d\n", + external, candidate, holders, field_ok); + } + } + free(defs); + free(modules[0]); + cbm_arena_destroy(&arena); + cbm_gbuf_free(gbuf); + cbm_registry_free(reg); + cbm_free_result(result); + return ok; +} + +TEST(python_typed_field_external_import_stays_unresolved) { + ASSERT_TRUE(python_typed_field_scope_case(true)); + PASS(); +} + +TEST(python_typed_field_project_class_resolves) { + ASSERT_TRUE(python_typed_field_scope_case(false)); + PASS(); +} + +/* ── Explicit OVERRIDE through non-redeclaring ancestors (#1278) ── */ + +static int64_t ovr_class(cbm_gbuf_t *gb, const char *name) { + char qn[128]; + snprintf(qn, sizeof(qn), "m.%s", name); + return cbm_gbuf_upsert_node(gb, "Class", name, qn, "m.py", 1, 50, "{}"); +} + +static int64_t ovr_method(cbm_gbuf_t *gb, int64_t cls, const char *cls_name, const char *name) { + char qn[128]; + snprintf(qn, sizeof(qn), "m.%s.%s", cls_name, name); + int64_t id = cbm_gbuf_upsert_node(gb, "Method", name, qn, "m.py", 2, 3, "{}"); + cbm_gbuf_insert_edge(gb, cls, id, "DEFINES_METHOD", "{}"); + return id; +} + +/* OVERRIDE edges leaving `method`; *only_target receives the single target (or 0). */ +static int ovr_out(cbm_gbuf_t *gb, int64_t method, int64_t *only_target) { + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_source_type(gb, method, "OVERRIDE", &edges, &count); + *only_target = count == 1 ? edges[0]->target_id : 0; + return count; +} + +/* Exactly two different targets, each once, independent of edge order. */ +static bool ovr_two_targets(cbm_gbuf_t *gb, int64_t method, int64_t first, int64_t second) { + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_source_type(gb, method, "OVERRIDE", &edges, &count); + int first_count = 0; + int second_count = 0; + for (int i = 0; i < count; i++) { + first_count += edges[i]->target_id == first; + second_count += edges[i]->target_id == second; + } + return first != second && count == 2 && first_count == 1 && second_count == 1; +} + +TEST(override_explicit_walks_to_nearest_declaring_ancestor) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + /* Contract: Processor.process */ + int64_t processor = ovr_class(gb, "Processor"); + int64_t proc_process = ovr_method(gb, processor, "Processor", "process"); + + /* Direct control: DirectProcessor(Processor) */ + int64_t direct = ovr_class(gb, "DirectProcessor"); + int64_t direct_process = ovr_method(gb, direct, "DirectProcessor", "process"); + cbm_gbuf_insert_edge(gb, direct, processor, "INHERITS", "{}"); + + /* The #1278 shape: LeafProcessor(IntermediateProcessor(Processor)), the + * intermediate declares nothing. `extra` exists on no ancestor. */ + int64_t mid = ovr_class(gb, "IntermediateProcessor"); + int64_t leaf = ovr_class(gb, "LeafProcessor"); + int64_t leaf_process = ovr_method(gb, leaf, "LeafProcessor", "process"); + int64_t leaf_extra = ovr_method(gb, leaf, "LeafProcessor", "extra"); + cbm_gbuf_insert_edge(gb, mid, processor, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, leaf, mid, "INHERITS", "{}"); + + /* Nearest wins: Deep(Redeclaring(Processor)), Redeclaring declares process. */ + int64_t redecl = ovr_class(gb, "Redeclaring"); + int64_t redecl_process = ovr_method(gb, redecl, "Redeclaring", "process"); + int64_t deep = ovr_class(gb, "Deep"); + int64_t deep_process = ovr_method(gb, deep, "Deep", "process"); + cbm_gbuf_insert_edge(gb, redecl, processor, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, deep, redecl, "INHERITS", "{}"); + + /* Diamond: Both(Left, Right), Left and Right each inherit Processor + * without redeclaring -> exactly one edge, no duplicate. */ + int64_t left = ovr_class(gb, "Left"); + int64_t right = ovr_class(gb, "Right"); + int64_t both = ovr_class(gb, "Both"); + int64_t both_process = ovr_method(gb, both, "Both", "process"); + cbm_gbuf_insert_edge(gb, left, processor, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, right, processor, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, both, left, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, both, right, "INHERITS", "{}"); + + /* Equal nearest depth: two declaring ancestors behind empty direct + * parents. Both declarations bind, while the deeper Processor does not. + * Both explicit graph relationships share the nearest declaring depth. */ + int64_t tie_left = ovr_class(gb, "TieLeft"); + int64_t tie_right = ovr_class(gb, "TieRight"); + int64_t tie_left_process = ovr_method(gb, tie_left, "TieLeft", "process"); + int64_t tie_right_process = ovr_method(gb, tie_right, "TieRight", "process"); + int64_t tie_left_mid = ovr_class(gb, "TieLeftMid"); + int64_t tie_right_mid = ovr_class(gb, "TieRightMid"); + int64_t tied = ovr_class(gb, "Tied"); + int64_t tied_process = ovr_method(gb, tied, "Tied", "process"); + cbm_gbuf_insert_edge(gb, tie_left, processor, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, tie_right, processor, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, tie_left_mid, tie_left, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, tie_right_mid, tie_right, "IMPLEMENTS", "{}"); + cbm_gbuf_insert_edge(gb, tied, tie_right_mid, "IMPLEMENTS", "{}"); + cbm_gbuf_insert_edge(gb, tied, tie_left_mid, "INHERITS", "{}"); + + /* The separate Go implicit-satisfaction pass owns .go classes. */ + int64_t go_type = + cbm_gbuf_upsert_node(gb, "Struct", "GoType", "m.GoType", "m.go", 1, 5, "{}"); + int64_t go_process = ovr_method(gb, go_type, "GoType", "process"); + cbm_gbuf_insert_edge(gb, go_type, processor, "IMPLEMENTS", "{}"); + + /* Malformed hierarchy: CycA <-> CycB plus a self-loop, CycLeaf(CycA). + * The walk must terminate and bind nothing. */ + int64_t cyc_a = ovr_class(gb, "CycA"); + int64_t cyc_b = ovr_class(gb, "CycB"); + int64_t cyc_leaf = ovr_class(gb, "CycLeaf"); + int64_t cyc_process = ovr_method(gb, cyc_leaf, "CycLeaf", "process"); + cbm_gbuf_insert_edge(gb, cyc_a, cyc_b, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, cyc_b, cyc_a, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, cyc_a, cyc_a, "INHERITS", "{}"); + cbm_gbuf_insert_edge(gb, cyc_leaf, cyc_a, "INHERITS", "{}"); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", + .repo_path = "/tmp/test", + .gbuf = gb, + .cancelled = &cancelled, + }; + (void)cbm_pipeline_override_explicit(&ctx); + + int64_t target = 0; + bool direct_ok = ovr_out(gb, direct_process, &target) == 1 && target == proc_process; + bool indirect_ok = ovr_out(gb, leaf_process, &target) == 1 && target == proc_process; + bool extra_absent = ovr_out(gb, leaf_extra, &target) == 0; + bool nearest_ok = ovr_out(gb, deep_process, &target) == 1 && target == redecl_process; + bool redecl_ok = ovr_out(gb, redecl_process, &target) == 1 && target == proc_process; + bool diamond_ok = ovr_out(gb, both_process, &target) == 1 && target == proc_process; + bool ties_ok = ovr_two_targets(gb, tied_process, tie_left_process, tie_right_process); + bool go_absent = ovr_out(gb, go_process, &target) == 0; + bool cycle_absent = ovr_out(gb, cyc_process, &target) == 0; + int first_count = cbm_gbuf_edge_count_by_type(gb, "OVERRIDE"); + + /* Idempotent: a second run adds no edges. */ + (void)cbm_pipeline_override_explicit(&ctx); + int second_count = cbm_gbuf_edge_count_by_type(gb, "OVERRIDE"); + bool second_ties_ok = ovr_two_targets(gb, tied_process, tie_left_process, tie_right_process); + + cbm_gbuf_free(gb); + ASSERT_TRUE(direct_ok); + ASSERT_TRUE(indirect_ok); + ASSERT_TRUE(extra_absent); + ASSERT_TRUE(nearest_ok); + ASSERT_TRUE(redecl_ok); + ASSERT_TRUE(diamond_ok); + ASSERT_TRUE(ties_ok); + ASSERT_TRUE(go_absent); + ASSERT_TRUE(cycle_absent); + ASSERT_EQ(first_count, 9); + ASSERT_EQ(second_count, 9); + ASSERT_TRUE(second_ties_ok); + PASS(); +} + +/* OVERRIDE edges from a method whose QN ends with src_suffix to one whose QN + * ends with tgt_suffix, in the stored graph. */ +static int count_override_edges(cbm_store_t *s, const char *project, const char *src_suffix, + const char *tgt_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, "OVERRIDE", &edges, &edge_count); + 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) { + size_t sl = strlen(src.qualified_name); + size_t tl = strlen(tgt.qualified_name); + size_t ss = strlen(src_suffix); + size_t ts = strlen(tgt_suffix); + if (sl >= ss && tl >= ts && strcmp(src.qualified_name + sl - ss, src_suffix) == 0 && + strcmp(tgt.qualified_name + tl - ts, tgt_suffix) == 0) { + hits++; + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) + cbm_store_free_edges(edges, edge_count); + return hits; +} + +TEST(override_python_through_intermediate_class) { + /* The #1278 reproduction, end to end through the full pipeline. */ + const char *py = "from abc import ABC, abstractmethod\n\n" + "class Processor(ABC):\n" + " @abstractmethod\n" + " def process(self) -> str:\n" + " raise NotImplementedError\n\n" + "class IntermediateProcessor(Processor):\n" + " pass\n\n" + "class LeafProcessor(IntermediateProcessor):\n" + " def process(self) -> str:\n" + " return \"leaf\"\n\n" + "class DirectProcessor(Processor):\n" + " def process(self) -> str:\n" + " return \"direct\"\n\n" + "def run(processor: Processor) -> str:\n" + " return processor.process()\n"; + if (setup_usages_repo("procs.py", py, NULL, NULL) != 0) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test_override.db", g_usages_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + int direct = count_override_edges(s, project, "DirectProcessor.process", ".Processor.process"); + int leaf = count_override_edges(s, project, "LeafProcessor.process", ".Processor.process"); + int all = count_override_edges(s, project, "", ""); + + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_usages_repo(); + + ASSERT_EQ(direct, 1); /* control */ + ASSERT_EQ(leaf, 1); /* #1278 */ + ASSERT_EQ(all, 2); /* nothing else, no duplicates */ + PASS(); +} + TEST(usages_creates_edges) { /* Port of TestPassUsagesCreatesEdges. * Go source with callback reference → USAGE edge. */ @@ -16517,8 +16827,12 @@ SUITE(pipeline) { RUN_TEST(implements_creates_override); RUN_TEST(implements_no_match); /* Usages pass (full pipeline integration) */ + RUN_TEST(override_explicit_walks_to_nearest_declaring_ancestor); + RUN_TEST(override_python_through_intermediate_class); RUN_TEST(python_crossfile_typed_field_calls_issue1277); RUN_TEST(python_crossfile_typed_field_calls_issue1277_parallel); + RUN_TEST(python_typed_field_external_import_stays_unresolved); + RUN_TEST(python_typed_field_project_class_resolves); RUN_TEST(usages_creates_edges); RUN_TEST(usages_no_duplicate_calls); RUN_TEST(calls_edge_carries_call_site_line); diff --git a/tests/test_registry.c b/tests/test_registry.c index 278e97d9b..fd9b547ee 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1230,6 +1230,42 @@ TEST(python_import_binding_contradicts_only_foreign_chains) { PASS(); } +/* Base-class twin: the django shapes. Every spelling of the stdlib base must + * contradict the same-named project class; project bases never do, and the + * gate is Python-only. */ +TEST(python_external_base_contradicts_django_shapes) { + CBMImport items[] = { + {.local_name = "unittest", .module_path = "unittest"}, + {.local_name = "ut", .module_path = "unittest"}, + {.local_name = "TestCase", .module_path = "unittest.TestCase"}, + {.local_name = "pkg", .module_path = "pkg.test.models"}, + }; + CBMImportArray imps = {.items = items, .count = 4, .cap = 4}; + const char *project_tc = "proj.django.test.testcases.TestCase"; + ASSERT_TRUE(cbm_python_external_base_contradicts(CBM_LANG_PYTHON, &imps, "unittest.TestCase", + project_tc, NULL, NULL, NULL)); + ASSERT_TRUE(cbm_python_external_base_contradicts(CBM_LANG_PYTHON, &imps, "TestCase", project_tc, + NULL, NULL, NULL)); + /* `import unittest as ut`: `ut.TestCase` spells `unittest.TestCase`. */ + ASSERT_TRUE(cbm_python_external_base_contradicts(CBM_LANG_PYTHON, &imps, "ut.TestCase", + project_tc, NULL, NULL, NULL)); + /* A target that does spell the chain (vendored/src-layout unittest). */ + ASSERT_FALSE(cbm_python_external_base_contradicts( + CBM_LANG_PYTHON, &imps, "ut.TestCase", "proj.src.unittest.TestCase", NULL, NULL, NULL)); + /* Dotted project base through `import pkg.test.models`. */ + ASSERT_FALSE( + cbm_python_external_base_contradicts(CBM_LANG_PYTHON, &imps, "pkg.test.models.Base", + "proj.pkg.test.models.Base", NULL, NULL, NULL)); + /* Not import-bound: a same-module base is never judged. */ + ASSERT_FALSE(cbm_python_external_base_contradicts(CBM_LANG_PYTHON, &imps, "SimpleTestCase", + "proj.django.test.testcases.SimpleTestCase", + NULL, NULL, NULL)); + /* Python only. */ + ASSERT_FALSE(cbm_python_external_base_contradicts(CBM_LANG_JAVA, &imps, "unittest.TestCase", + project_tc, NULL, NULL, NULL)); + PASS(); +} + TEST(weak_call_guards_share_one_drop_list) { /* The member guard and the local-binding guard must agree on what "weak" * means. They share a single static predicate for exactly this reason; if @@ -1371,5 +1407,6 @@ SUITE(registry) { RUN_TEST(local_binding_suppress_keeps_unshadowed_and_strong_strategies); RUN_TEST(import_binding_suppress_drops_only_weak_contradicted_calls); RUN_TEST(python_import_binding_contradicts_only_foreign_chains); + RUN_TEST(python_external_base_contradicts_django_shapes); RUN_TEST(weak_call_guards_share_one_drop_list); }