diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index a1a7b7c72..23b871dbc 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -402,6 +402,31 @@ void cbm_imports_push(CBMImportArray *arr, CBMArena *a, CBMImport imp) { arr->items[arr->count++] = imp; } +bool cbm_imports_local_span_binds_runtime_name(const CBMImportArray *imports, const char *name, + size_t len) { + if (!imports || !name) { + return true; + } + bool seen = false; + for (int i = 0; i < imports->count; i++) { + const CBMImport *imp = &imports->items[i]; + if (!imp->local_name || strncmp(imp->local_name, name, len) != 0 || + imp->local_name[len] != '\0') { + continue; + } + if (cbm_import_binds_runtime_name(imp)) { + return true; + } + seen = true; + } + return !seen; +} + +bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const char *local_name) { + return !local_name || + cbm_imports_local_span_binds_runtime_name(imports, local_name, strlen(local_name)); +} + void cbm_usages_push(CBMUsageArray *arr, CBMArena *a, CBMUsage usage) { GROW_ARRAY(arr, a); arr->items[arr->count++] = usage; diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 86586dd15..504bda4fc 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -398,9 +398,20 @@ typedef struct { const char *member; // ES named import: the exported name the local binds (`get` in // `import { get as navigate } from "axios"`); NULL otherwise bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) + bool type_checking_only; // Python: inside a module-level `if TYPE_CHECKING:` block -- + // binds the name for annotations, never at runtime (#1277) CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind } CBMImport; +/* True when an import binds its local name at runtime. A Python import under + * `if TYPE_CHECKING:` exists for annotations only: a call through that name + * cannot happen, so no CALLS import map may bind it (an import_map edge + * through it would be fabricated by construction). THE one rule every CALLS + * import-map builder applies. */ +static inline bool cbm_import_binds_runtime_name(const CBMImport *imp) { + return imp && !imp->type_checking_only; +} + /* Content-only ordered Python namespace facts; no resolved target identities. */ #define CBM_PY_NAMESPACE_FACTS_VERSION 1u @@ -1306,6 +1317,14 @@ void cbm_py_namespace_test_copy_fail_after(int successful_allocations); void cbm_defs_push(CBMDefArray *arr, CBMArena *a, CBMDefinition def); void cbm_calls_push(CBMCallArray *arr, CBMArena *a, CBMCall call); void cbm_imports_push(CBMImportArray *arr, CBMArena *a, CBMImport imp); +/* True unless every import of `imports` that binds `local_name` is + * annotation-only (cbm_import_binds_runtime_name), so also true when no import + * names it: the rule for the builders that key IMPORTS edges by local name. */ +bool cbm_imports_local_binds_runtime_name(const CBMImportArray *imports, const char *local_name); +/* The same for the `len` bytes at `name` (not NUL-terminated): lets a caller + * decide before it copies the name. */ +bool cbm_imports_local_span_binds_runtime_name(const CBMImportArray *imports, const char *name, + size_t len); void cbm_usages_push(CBMUsageArray *arr, CBMArena *a, CBMUsage usage); void cbm_throws_push(CBMThrowArray *arr, CBMArena *a, CBMThrow thr); void cbm_rw_push(CBMRWArray *arr, CBMArena *a, CBMReadWrite rw); diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 6491a677f..6cd935c50 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -11493,9 +11493,12 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_sta wd_push(s, child, enclosing_qn); } else { const char *ck = ts_node_type(child); - /* A nested class inside #if/#else is still nested here. */ + /* A nested class inside #if/#else is still nested here, and so is + * a decorated one (Python `@dataclass class Meta:` in a class + * body): the decorated_definition wraps the class_definition. */ if (strcmp(ck, "field_declaration") == 0 || strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0 || + (ctx->language == CBM_LANG_PYTHON && strcmp(ck, "decorated_definition") == 0) || cbm_kind_in_set(child, cbm_conditional_block_types(spec->language))) { ts_nstack_push(&nc_stack, child); } diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 576bdb8f1..2ab97c94f 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -123,6 +123,19 @@ static const char *python_import_root(CBMArena *a, const char *path) { return dot ? cbm_arena_strndup(a, path, (size_t)(dot - path)) : path; } +/* Spell `from import ` as one dotted path. A package-relative + * module (`from . import x`, `from .. import x`) is nothing but dots, which + * already end in the separator: joining with another '.' would add a level and + * name the PARENT package ("..x"), so the import resolved one package too high + * and fell to a same-named definition anywhere in the project. */ +static const char *python_join_from_import(CBMArena *a, const char *module, const char *name) { + size_t len = strlen(module); + if (len > 0 && module[len - SKIP_ONE] == '.') { + return cbm_arena_sprintf(a, "%s%s", module, name); + } + return cbm_arena_sprintf(a, "%s.%s", module, name); +} + // --- Go imports --- // import_declaration -> import_spec_list -> import_spec -> (name, path) @@ -209,7 +222,7 @@ static void emit_py_aliased_import(CBMExtractCtx *ctx, TSNode child, const char } const char *local = !ts_node_is_null(alias_node) ? cbm_node_text(a, alias_node, ctx->source) : path_last(a, name); - const char *full = mod_prefix ? cbm_arena_sprintf(a, "%s.%s", mod_prefix, name) : name; + const char *full = mod_prefix ? python_join_from_import(a, mod_prefix, name) : name; CBMImport imp = {.local_name = local, .module_path = full}; cbm_imports_push(&ctx->result->imports, a, imp); } @@ -267,7 +280,7 @@ static void emit_py_import_from_name(CBMExtractCtx *ctx, TSNode child, const cha CBMArena *a = ctx->arena; char *name = cbm_node_text(a, child, ctx->source); if (name && name[0]) { - const char *full = mod_path ? cbm_arena_sprintf(a, "%s.%s", mod_path, name) : name; + const char *full = mod_path ? python_join_from_import(a, mod_path, name) : name; CBMImport imp = {.local_name = name, .module_path = full}; cbm_imports_push(&ctx->result->imports, a, imp); } @@ -321,6 +334,139 @@ static void process_py_import_from(CBMExtractCtx *ctx, TSNode node) { } } +/* `TYPE_CHECKING` or `.TYPE_CHECKING` (typing.TYPE_CHECKING, t.TYPE_CHECKING). */ +static bool py_is_type_checking_condition(CBMExtractCtx *ctx, TSNode cond) { + if (ts_node_is_null(cond)) { + return false; + } + TSNode name = cond; + if (strcmp(ts_node_type(cond), "attribute") == 0) { + name = ts_node_child_by_field_name(cond, TS_FIELD("attribute")); + } + if (ts_node_is_null(name) || strcmp(ts_node_type(name), "identifier") != 0) { + return false; + } + const char *text = cbm_node_text(ctx->arena, name, ctx->source); + return text && strcmp(text, "TYPE_CHECKING") == 0; +} + +/* One import statement; `type_only` flags every import it records. */ +static void process_py_import_node(CBMExtractCtx *ctx, TSNode node, bool type_only) { + const char *kind = ts_node_type(node); + int before = ctx->result->imports.count; + if (strcmp(kind, "import_statement") == 0) { + process_py_import_stmt(ctx, node); + } else if (strcmp(kind, "import_from_statement") == 0 || + strcmp(kind, "future_import_statement") == 0) { + // `from __future__ import annotations` is a distinct node type in + // tree-sitter-python but has the same shape (module + name list). + process_py_import_from(ctx, node); + } + for (int i = before; type_only && i < ctx->result->imports.count; i++) { + ctx->result->imports.items[i].type_checking_only = true; + } +} + +static bool py_is_import_node(TSNode node) { + const char *kind = ts_node_type(node); + return strcmp(kind, "import_statement") == 0 || strcmp(kind, "import_from_statement") == 0 || + strcmp(kind, "future_import_statement") == 0; +} + +static bool py_import_is_wildcard(TSNode import_node) { + uint32_t n = ts_node_child_count(import_node); + for (uint32_t i = 0; i < n; i++) { + if (strcmp(ts_node_type(ts_node_child(import_node, i)), "wildcard_import") == 0) { + return true; + } + } + return false; +} + +/* Records, unflagged, every import statement anywhere under `root` (the + * runtime alternative of a TYPE_CHECKING block usually nests them in + * try/except ImportError). An iterative pre-order walk: adversarially deep + * nesting must not exhaust the stack. Returns true when one of them is a star + * import. */ +static bool py_record_subtree_imports(CBMExtractCtx *ctx, TSNode root) { + bool star = false; + TSTreeCursor cursor = ts_tree_cursor_new(root); + for (;;) { + TSNode node = ts_tree_cursor_current_node(&cursor); + bool is_import = py_is_import_node(node); + if (is_import) { + process_py_import_node(ctx, node, false); + star = star || py_import_is_wildcard(node); + } + if (!is_import && ts_tree_cursor_goto_first_child(&cursor)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cursor)) { + if (!ts_tree_cursor_goto_parent(&cursor)) { + ts_tree_cursor_delete(&cursor); + return star; + } + } + } +} + +/* Drops the imports [first, mark) whose local name an import at [mark, count) + * binds as well (every one when `star`), then the imports [mark, count). */ +static void py_drop_rebound_imports(CBMImportArray *imports, int first, int mark, bool star) { + int keep = first; + for (int i = first; i < mark; i++) { + const CBMImport *imp = &imports->items[i]; + bool rebound = star; + for (int j = mark; !rebound && j < imports->count; j++) { + rebound = imp->local_name && imports->items[j].local_name && + strcmp(imp->local_name, imports->items[j].local_name) == 0; + } + if (!rebound) { + imports->items[keep++] = *imp; + } + } + imports->count = keep; +} + +/* A module-level `if TYPE_CHECKING:` holds imports a type checker sees and + * the interpreter never runs (#1277: an annotated field's type is usually + * imported exactly there, to avoid an import cycle). Record the imports of + * its consequence block, flagged type_checking_only. Its elif/else branches + * are runtime alternatives and stay unrecorded, as every other compound + * statement's imports do; a name they bind again (the compat shape: the same + * name imported in `else:` under try/except ImportError) is bound at runtime, + * so its TYPE_CHECKING import is no annotation-only import and is dropped as + * well, and a star import there may bind any name. */ +static void process_py_type_checking_block(CBMExtractCtx *ctx, TSNode if_node) { + if (!py_is_type_checking_condition( + ctx, ts_node_child_by_field_name(if_node, TS_FIELD("condition")))) { + return; + } + TSNode body = ts_node_child_by_field_name(if_node, TS_FIELD("consequence")); + if (ts_node_is_null(body)) { + return; + } + int first = ctx->result->imports.count; + TSTreeCursor cursor = ts_tree_cursor_new(body); + if (ts_tree_cursor_goto_first_child(&cursor)) { + do { + process_py_import_node(ctx, ts_tree_cursor_current_node(&cursor), true); + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); + int mark = ctx->result->imports.count; + bool star = false; + uint32_t n = ts_node_child_count(if_node); + for (uint32_t i = 0; i < n; i++) { + TSNode alt = ts_node_child(if_node, i); + const char *kind = ts_node_type(alt); + if (strcmp(kind, "elif_clause") == 0 || strcmp(kind, "else_clause") == 0) { + star = py_record_subtree_imports(ctx, alt) || star; + } + } + py_drop_rebound_imports(&ctx->result->imports, first, mark, star); +} + static void parse_python_imports(CBMExtractCtx *ctx) { TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); if (!ts_tree_cursor_goto_first_child(&cursor)) { @@ -329,15 +475,10 @@ static void parse_python_imports(CBMExtractCtx *ctx) { } do { TSNode node = ts_tree_cursor_current_node(&cursor); - const char *kind = ts_node_type(node); - - if (strcmp(kind, "import_statement") == 0) { - process_py_import_stmt(ctx, node); - } else if (strcmp(kind, "import_from_statement") == 0 || - strcmp(kind, "future_import_statement") == 0) { - // `from __future__ import annotations` is a distinct node type in - // tree-sitter-python but has the same shape (module + name list). - process_py_import_from(ctx, node); + if (strcmp(ts_node_type(node), "if_statement") == 0) { + process_py_type_checking_block(ctx, node); + } else { + process_py_import_node(ctx, node, false); } } while (ts_tree_cursor_goto_next_sibling(&cursor)); ts_tree_cursor_delete(&cursor); diff --git a/internal/cbm/lsp/lsp_work.h b/internal/cbm/lsp/lsp_work.h index fa286293d..4f7b2bf34 100644 --- a/internal/cbm/lsp/lsp_work.h +++ b/internal/cbm/lsp/lsp_work.h @@ -17,8 +17,16 @@ extern _Thread_local uint64_t cbm_lsp_work_steps; #define CBM_LSP_WORK(n) (cbm_lsp_work_steps += (uint64_t)(n)) /* Read and reset the calling thread's counter. */ uint64_t cbm_lsp_work_take(void); +/* Test-only work ceiling (0 = none). A regression test that reverts a bound + * sets it so the reverted, unbounded loop stops deterministically and the test + * reports RED instead of hanging the runner. Seam builds only: a product build + * has no ceiling, so it can never decide graph content. */ +extern _Thread_local uint64_t cbm_lsp_work_ceiling; +#define CBM_LSP_WORK_CEILING_HIT() \ + (cbm_lsp_work_ceiling != 0 && cbm_lsp_work_steps > cbm_lsp_work_ceiling) #else #define CBM_LSP_WORK(n) ((void)0) +#define CBM_LSP_WORK_CEILING_HIT() (0) #endif #endif /* CBM_LSP_WORK_H */ diff --git a/internal/cbm/lsp/php_lsp.c b/internal/cbm/lsp/php_lsp.c index f08aa3908..571b1493b 100644 --- a/internal/cbm/lsp/php_lsp.c +++ b/internal/cbm/lsp/php_lsp.c @@ -4372,6 +4372,17 @@ void cbm_run_php_lsp(CBMArena *arena, CBMFileResult *result, const char *source, } } + /* Index the registry (F12). Unindexed, every lookup below was a linear + * scan over the stdlib plus every definition of the file: O(lookups x + * definitions), quadratic in the size of one file. First-registered + * answers keep the result of that scan (the first registration of a + * duplicate QN wins), so the output does not change. Index memory goes to + * a scratch arena that dies with this call, as in cbm_run_php_lsp_cross. */ + reg.index_first_registered = true; + CBMArena idx_arena; + cbm_arena_init(&idx_arena); + cbm_registry_finalize_into(®, &idx_arena); + /* Phase C: run the resolver. */ PHPLSPContext ctx; php_lsp_init(&ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); @@ -4401,6 +4412,11 @@ void cbm_run_php_lsp(CBMArena *arena, CBMFileResult *result, const char *source, ctx.use_count = 0; } + /* Trait flattening registered methods after the index was built; lookups + * would tail-scan them. Re-index once, first-registered as above, so the + * answers equal those of the indexed part followed by the tail scan. */ + cbm_registry_finalize_into(®, &idx_arena); + php_lsp_process_file(&ctx, root); if (ctx.debug) { @@ -4414,6 +4430,7 @@ void cbm_run_php_lsp(CBMArena *arena, CBMFileResult *result, const char *source, rc->confidence); } } + cbm_arena_destroy(&idx_arena); } /* ── Cross-file + batch ───────────────────────────────────────── */ diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 18384d82e..01a0905e4 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -1224,9 +1224,231 @@ static const CBMRegisteredFunc *py_lookup_attribute_depth(PyLSPContext *ctx, con return NULL; } +/* ── The C3 method resolution order ──────────────────────────────── */ + +/* Longest linearization followed. A longer one (or more bases than this) keeps + * the depth-first walk above. */ +#define PY_C3_MAX 64 + +enum { PY_MRO_CACHE_INITIAL_CAP = 64 }; /* power of two (index masking) */ +enum { PY_MRO_CACHE_MAX_CAP = 1 << 20 }; /* hard stop for adversarial files */ + +static uint32_t py_mro_hash(const char *qn) { + uint32_t h = 2166136261u; /* FNV-1a */ + for (const unsigned char *p = (const unsigned char *)qn; *p; p++) { + h = (h ^ *p) * 16777619u; + } + return h; +} + +static const CBMPyMroCacheEntry *py_mro_cache_find(const PyLSPContext *ctx, const char *qn, + uint32_t h) { + if (!ctx->mro_cache || ctx->mro_cache_cap == 0) + return NULL; + uint32_t mask = (uint32_t)ctx->mro_cache_cap - 1; + uint32_t idx = h & mask; + for (int probed = 0; probed < ctx->mro_cache_cap; probed++) { + const CBMPyMroCacheEntry *e = &ctx->mro_cache[idx]; + if (!e->qn) + return NULL; + if (e->hash == h && strcmp(e->qn, qn) == 0) + return e; + idx = (idx + 1) & mask; + } + return NULL; +} + +/* Doubles the table (arena allocation: the old table is freed with the + * per-file arena). */ +static bool py_mro_cache_grow(PyLSPContext *ctx) { + if (ctx->mro_cache_cap >= PY_MRO_CACHE_MAX_CAP) + return false; + int new_cap = ctx->mro_cache_cap ? ctx->mro_cache_cap * 2 : PY_MRO_CACHE_INITIAL_CAP; + CBMPyMroCacheEntry *entries = (CBMPyMroCacheEntry *)cbm_arena_alloc( + ctx->arena, (size_t)new_cap * sizeof(CBMPyMroCacheEntry)); + if (!entries) + return false; + memset(entries, 0, (size_t)new_cap * sizeof(CBMPyMroCacheEntry)); + uint32_t mask = (uint32_t)new_cap - 1; + for (int i = 0; i < ctx->mro_cache_cap; i++) { + const CBMPyMroCacheEntry *e = &ctx->mro_cache[i]; + if (!e->qn) + continue; + uint32_t idx = e->hash & mask; + while (entries[idx].qn) + idx = (idx + 1) & mask; + entries[idx] = *e; + } + ctx->mro_cache = entries; + ctx->mro_cache_cap = new_cap; + return true; +} + +/* Records the order of `qn` (len -1: none). A table that cannot grow only + * costs a recomputation later. */ +static void py_mro_cache_put(PyLSPContext *ctx, const char *qn, uint32_t h, const char **mro, + int len) { + if ((ctx->mro_cache_count + 1) * 4 > ctx->mro_cache_cap * 3 && !py_mro_cache_grow(ctx)) + return; + const char *key = cbm_arena_strdup(ctx->arena, qn); + if (!key) + return; + uint32_t mask = (uint32_t)ctx->mro_cache_cap - 1; + uint32_t idx = h & mask; + while (ctx->mro_cache[idx].qn) + idx = (idx + 1) & mask; + ctx->mro_cache[idx].qn = key; + ctx->mro_cache[idx].hash = h; + ctx->mro_cache[idx].len = len; + ctx->mro_cache[idx].mro = mro; + ctx->mro_cache_count++; +} + +static bool py_c3_in_tail(const char *qn, const char *const *seq, int head, int len) { + for (int k = head + 1; k < len; k++) { + if (strcmp(seq[k], qn) == 0) + return true; + } + return false; +} + +/* The C3 head to take next: the first sequence head that is in no sequence's + * tail. NULL when every head is blocked (Python rejects such a class). */ +static const char *py_c3_pick(const char **const *seqs, const int *lens, const int *heads, + int nseq) { + for (int s = 0; s < nseq; s++) { + if (heads[s] >= lens[s]) + continue; + const char *cand = seqs[s][heads[s]]; + bool blocked = false; + for (int t = 0; t < nseq && !blocked; t++) { + blocked = heads[t] < lens[t] && py_c3_in_tail(cand, seqs[t], heads[t], lens[t]); + } + if (!blocked) + return cand; + } + return NULL; +} + +/* The C3 merge of `nseq` sequences into mro[1..]: returns the order's length, + * or -1 when there is none or it is longer than PY_C3_MAX. */ +static int py_c3_merge(const char **const *seqs, const int *lens, int *heads, int nseq, + const char **mro) { + int n = 1; + for (;;) { + bool pending = false; + for (int s = 0; s < nseq && !pending; s++) { + pending = heads[s] < lens[s]; + } + if (!pending) + return n; + const char *pick = py_c3_pick(seqs, lens, heads, nseq); + if (!pick || n >= PY_C3_MAX) + return -1; + mro[n++] = pick; + for (int s = 0; s < nseq; s++) { + if (heads[s] < lens[s] && strcmp(seqs[s][heads[s]], pick) == 0) + heads[s]++; + } + } +} + +/* Python's method resolution order of `type_qn`: the class, then the C3 merge + * of its bases' linearizations and of the base list. A common base therefore + * follows every class deriving from it. Computed once per class and file + * (ctx->mro_cache). Sets *out to an arena array and returns its length, or -1 + * when there is no C3 order, the hierarchy is deeper than + * CBM_LSP_MAX_LOOKUP_DEPTH, or the order is longer than PY_C3_MAX. A base the + * registry does not know (external, unresolved) linearizes to itself. Only an + * order computed from the top (depth 0) is cached as missing: below it, the + * depth cap may have cut a hierarchy that is fine on its own. */ +static int py_c3_linearize(PyLSPContext *ctx, const char *type_qn, const char ***out, int depth) { + uint32_t h = py_mro_hash(type_qn); + const CBMPyMroCacheEntry *hit = py_mro_cache_find(ctx, type_qn, h); + if (hit) { + *out = hit->mro; + return hit->len; + } + int n = -1; + const char **mro = NULL; + const CBMRegisteredType *rt = + depth <= CBM_LSP_MAX_LOOKUP_DEPTH ? cbm_registry_lookup_type(ctx->registry, type_qn) : NULL; + const char **bases = rt ? rt->embedded_types : NULL; + int nb = 0; + while (bases && bases[nb]) + nb++; + if (depth > CBM_LSP_MAX_LOOKUP_DEPTH || nb >= PY_C3_MAX) { + n = -1; + } else if (rt && rt->alias_of) { + n = py_c3_linearize(ctx, rt->alias_of, &mro, depth + 1); + } else { + const char ***seqs = + (const char ***)cbm_arena_alloc(ctx->arena, (size_t)(nb + 1) * sizeof(const char **)); + int *lens = (int *)cbm_arena_alloc(ctx->arena, (size_t)(nb + 1) * 2 * sizeof(int)); + mro = (const char **)cbm_arena_alloc(ctx->arena, PY_C3_MAX * sizeof(const char *)); + bool ok = seqs && lens && mro; + int *heads = lens ? lens + nb + 1 : NULL; + for (int b = 0; ok && b < nb; b++) { + lens[b] = py_c3_linearize(ctx, bases[b], &seqs[b], depth + 1); + heads[b] = 0; + ok = lens[b] >= 0; + } + if (ok) { + seqs[nb] = bases; + lens[nb] = nb; + heads[nb] = 0; + mro[0] = cbm_arena_strdup(ctx->arena, type_qn); + n = mro[0] ? py_c3_merge((const char **const *)seqs, lens, heads, nb + 1, mro) : -1; + } + } + if (n >= 0 || depth == 0) + py_mro_cache_put(ctx, type_qn, h, n >= 0 ? mro : NULL, n); + *out = n >= 0 ? mro : NULL; + return n; +} + +/* Attribute lookup along the class's C3 order: the class itself, then each + * class of its order that declares the member. A hierarchy without a C3 order + * keeps the depth-first walk. */ static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const char *type_qn, const char *member_name) { - return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); + if (!ctx || !type_qn || !member_name) + return NULL; + const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, type_qn, member_name); + if (f) + return f; + const char **mro = NULL; + int n = py_c3_linearize(ctx, type_qn, &mro, 0); + if (n < 0) + return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); + /* mro[0] is the class itself, already asked, or an alias's target. */ + for (int i = strcmp(mro[0], type_qn) == 0 ? 1 : 0; i < n; i++) { + f = cbm_registry_lookup_method(ctx->registry, mro[i], member_name); + if (f) + return f; + } + return NULL; +} + +/* The method super() reaches from inside `class_qn`: the first class AFTER it in + * its C3 order that declares `member` itself. *have_mro is false when the + * hierarchy has no C3 order; the caller then keeps its base-by-base walk. */ +static const CBMRegisteredFunc *py_lookup_super_attribute(PyLSPContext *ctx, const char *class_qn, + const char *member, bool *have_mro) { + *have_mro = false; + if (!ctx || !class_qn || !member) + return NULL; + const char **mro = NULL; + int n = py_c3_linearize(ctx, class_qn, &mro, 0); + if (n < 0) + return NULL; + *have_mro = true; + for (int i = 1; i < n; i++) { + const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, mro[i], member); + if (f) + return f; + } + return NULL; } /* Per-file field overlay: look up a (class_qn, field_name) recorded during resolve @@ -2964,6 +3186,22 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { const CBMRegisteredType *enclosing = cbm_registry_lookup_type(ctx->registry, ctx->enclosing_class_qn); if (enclosing && enclosing->embedded_types) { + // The proxy walks the enclosing class's C3 order, in which a + // diamond's shared base follows every class deriving from it: + // RadarrCalendarEntity(RadarrEntity, CalendarEntity) reaches + // CalendarEntity before Entity, which the first base reaches + // through its own ancestry. The base-by-base walk below stays for + // a hierarchy without a C3 order and for an undeclared __init__. + bool have_mro = false; + const CBMRegisteredFunc *sf = py_lookup_super_attribute( + ctx, ctx->enclosing_class_qn, attr_name, &have_mro); + if (sf) { + bool is_init = strcmp(attr_name, "__init__") == 0; + py_emit_resolved_call(ctx, sf->qualified_name, + is_init ? "lsp_super_init" : "lsp_super", + is_init ? 0.90f : 0.88f, call_node); + return; + } for (int i = 0; enclosing->embedded_types[i]; i++) { // super().__init__() is a constructor delegation: // lsp_super_init is the MORE SPECIFIC, more accurate @@ -4278,6 +4516,11 @@ static const CBMType *py_resolve_annotation(PyLSPContext *ctx, const char *ann) const CBMType *t = cbm_scope_lookup(ctx->current_scope, ann); if (!cbm_type_is_unknown(t)) return t; + if (ctx->annotation_scope) { + t = cbm_scope_lookup(ctx->annotation_scope, ann); + if (!cbm_type_is_unknown(t)) + return t; + } if (ctx->module_qn) { const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, ann); const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, qn); @@ -5360,6 +5603,27 @@ static void py_replay_import_statement(PyLSPContext *ctx, TSNode stmt, unsigned } } +/* The consequence block of a module-level `if TYPE_CHECKING:` / + * `if .TYPE_CHECKING:` statement, else a null node. Mirrors the + * extraction's py_is_type_checking_condition (extract_imports.c). */ +static TSNode py_type_checking_body(PyLSPContext *ctx, TSNode stmt) { + TSNode none = {0}; + if (strcmp(ts_node_type(stmt), "if_statement") != 0) + return none; + TSNode cond = ts_node_child_by_field_name(stmt, "condition", 9); + if (ts_node_is_null(cond)) + return none; + TSNode name = cond; + if (strcmp(ts_node_type(cond), "attribute") == 0) + name = ts_node_child_by_field_name(cond, "attribute", 9); + if (ts_node_is_null(name) || strcmp(ts_node_type(name), "identifier") != 0) + return none; + char *text = py_node_text(ctx, name); + if (!text || strcmp(text, "TYPE_CHECKING") != 0) + return none; + return ts_node_child_by_field_name(stmt, "consequence", 11); +} + static bool py_expression_is_annotation_only_assignment(TSNode statement) { if (ts_node_named_child_count(statement) != 1) return false; @@ -5444,6 +5708,29 @@ void py_lsp_process_file(PyLSPContext *ctx, TSNode root) { * Invalidate only syntactic binding targets at the control-flow * join; a later unconditional statement may restore exact proof. */ py_invalidate_possible_bindings(ctx, c, 0); + /* `if TYPE_CHECKING:` holds the imports annotations are written + * against (#1277). Bind them into the annotation-only frame: an + * annotation resolves through them, a runtime call never does (the + * interpreter never runs the block). Extraction recorded them + * flagged type_checking_only, so they reach this walk through the + * LSP import map only, never a CALLS import map. */ + TSNode tc_body = py_type_checking_body(ctx, c); + if (!ts_node_is_null(tc_body)) { + if (!ctx->annotation_scope) + ctx->annotation_scope = cbm_scope_push(ctx->arena, NULL); + CBMScope *runtime_scope = ctx->current_scope; + PyKids tk = py_kids(ctx->arena, tc_body); + for (uint32_t t = 0; ctx->annotation_scope && t < tk.n && !ctx->eval_failure; t++) { + TSNode stmt = py_kid(&tk, t); + const char *sk = ts_node_type(stmt); + if (strcmp(sk, "import_statement") == 0 || + strcmp(sk, "import_from_statement") == 0) { + ctx->current_scope = ctx->annotation_scope; + py_replay_import_statement(ctx, stmt, consumed_imports); + ctx->current_scope = runtime_scope; + } + } + } } } // Pass 2: top-level calls (rare) and nested definitions. diff --git a/internal/cbm/lsp/py_lsp.h b/internal/cbm/lsp/py_lsp.h index fa5e03e06..87eebe2ea 100644 --- a/internal/cbm/lsp/py_lsp.h +++ b/internal/cbm/lsp/py_lsp.h @@ -38,6 +38,16 @@ typedef struct { const CBMType *result; // full-fidelity result, never NULL in a live entry } CBMPyTypeCacheEntry; +// A class's C3 method resolution order (py_c3_linearize), computed once per +// class and file. The registry is complete before the walk starts, so an entry +// never goes stale. +typedef struct { + const char *qn; // class QN; NULL marks an empty slot + uint32_t hash; // hash of qn + int len; // entries in mro; -1 = the class has no C3 order + const char **mro; // arena-allocated, mro[0] is the class (or its alias target) +} CBMPyMroCacheEntry; + // PyLSPContext holds state for one Python file's type evaluation. typedef struct { CBMArena *arena; @@ -49,6 +59,10 @@ typedef struct { * cbm_registry_*_for_update, never into anything behind ->fallback. */ CBMTypeRegistry *registry_head; CBMScope *current_scope; + // Names a module-level `if TYPE_CHECKING:` imports (#1277): consulted by + // annotation resolution only, after the ordinary scope chain misses, so a + // runtime call through such a name never resolves. NULL when none. + CBMScope *annotation_scope; // Import map: local_name -> module_qn (arena-allocated, NULL-terminated). // Built from CBMFileResult.imports. @@ -109,6 +123,11 @@ typedef struct { int eval_depth; // evaluator recursion depth (PY_LSP_MAX_EVAL_DEPTH) uint32_t eval_truncations; // depth-cap cutoff count — gates memo inserts CBMLSPStatus eval_failure; // sticky: explicit failure makes this walk incomplete + // C3 orders of the classes whose attributes this file looks up: one + // linearization per class, open addressing, arena-allocated. + CBMPyMroCacheEntry *mro_cache; + int mro_cache_count; // occupied slots (kept < 75% of cap) + int mro_cache_cap; // power-of-two capacity // Pending links of the left-associative chains being evaluated // iteratively (operator, receiver, subscript, conditional and // parenthesis chains): one stack shared by nested evaluations. diff --git a/internal/cbm/lsp/scope.c b/internal/cbm/lsp/scope.c index 0b3c6505c..2fcfb8aa9 100644 --- a/internal/cbm/lsp/scope.c +++ b/internal/cbm/lsp/scope.c @@ -5,6 +5,7 @@ #ifdef CBM_ENABLE_TEST_SEAMS _Thread_local uint64_t cbm_lsp_work_steps = 0; +_Thread_local uint64_t cbm_lsp_work_ceiling = 0; uint64_t cbm_lsp_work_take(void) { uint64_t v = cbm_lsp_work_steps; cbm_lsp_work_steps = 0; diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index dfbb8f7fb..fbbdabbe7 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -95,21 +95,24 @@ static const char *itoa_log(int val) { /* Build per-file import map from cached extraction result or graph buffer edges. * Returns parallel arrays of (local_name, module_qn) pairs. Caller frees. */ -/* Parse "local_name":"value" from JSON properties string. Returns strdup'd key or NULL. */ -static char *extract_local_name_from_json(const char *props_json) { +/* The "local_name":"value" of a JSON properties string, as a span of it; + * false when absent or empty. */ +static bool local_name_span_from_json(const char *props_json, const char **out, size_t *len) { if (!props_json) { - return NULL; + return false; } const char *start = strstr(props_json, "\"local_name\":\""); if (!start) { - return NULL; + return false; } start += strlen("\"local_name\":\""); const char *end = strchr(start, '"'); if (!end || end <= start) { - return NULL; + return false; } - return cbm_strndup(start, end - start); + *out = start; + *len = (size_t)(end - start); + return true; } /* The graph node whose QN is cbm_pipeline_fqn_compute(project, rel_path, name), @@ -141,7 +144,8 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, for (int i = 0; i < result->imports.count; i++) { const CBMImport *imp = &result->imports.items[i]; - if (!imp->local_name || !imp->local_name[0] || !imp->module_path) { + if (!imp->local_name || !imp->local_name[0] || !imp->module_path || + !cbm_import_binds_runtime_name(imp)) { continue; } const cbm_gbuf_node_t *target = @@ -192,7 +196,16 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, if (!target) { continue; } - char *key = extract_local_name_from_json(e->properties_json); + const char *name = NULL; + size_t name_len = 0; + /* An annotation-only import (#1277) binds no CALLS name: skipped before + * the key is copied. */ + if (!local_name_span_from_json(e->properties_json, &name, &name_len) || + !cbm_imports_local_span_binds_runtime_name(result ? &result->imports : NULL, name, + name_len)) { + continue; + } + char *key = cbm_strndup(name, name_len); if (key) { keys[count] = key; vals[count] = target->qualified_name; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 6d99ff794..be72d3d5b 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -305,10 +305,16 @@ static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const c * 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 pxc_base_scope_t *bs, const char *raw, - const char *module_qn) { + const char *module_qn, const char *class_qn) { if (!bs || !bs->reg || !raw || !raw[0]) { return NULL; } + /* A base of a class nested in a class body: an earlier sibling first. */ + const char *sibling = + cbm_registry_resolve_python_class_body_base(bs->reg, bs->gbuf, class_qn, raw, bs->lang); + if (sibling) { + return sibling; + } const char *dotted = pxc_python_dotted_base_qn(bs, raw); if (dotted) { return dotted; @@ -338,7 +344,8 @@ static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *r * 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 pxc_base_scope_t *bs, const char *module_qn) { + const pxc_base_scope_t *bs, const char *module_qn, + const char *class_qn) { if (!bases || !bases[0]) { return NULL; } @@ -352,7 +359,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(bs, bases[i], module_qn); + const char *qn = pxc_resolve_base_qn(bs, bases[i], module_qn, class_qn); resolved[i] = qn ? qn : bases[i]; } resolved[count] = NULL; @@ -520,9 +527,10 @@ 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 = (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->embedded_types = + (bs && bs->reg && pxc_lang_resolves_base_qns(lang)) + ? pxc_join_base_qns(arena, src->base_classes, bs, module_qn, src->qualified_name) + : pxc_join_pipe(arena, src->base_classes); dst->signature_param_types = NULL; dst->signature_param_count = 0; if (src->signature_param_types && src->signature_param_count > 0) { @@ -727,7 +735,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(bs, name, module_qn) : NULL; + const char *qn = name ? pxc_resolve_base_qn(bs, name, module_qn, NULL) : NULL; if (!qn || !ft->field_name || !ft->field_name[0]) { continue; } @@ -876,9 +884,11 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *are if (ctx && ctx->registry && pxc_lang_resolves_base_qns(files[fi].language)) { 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, &bs.imp_keys, &bs.imp_vals, - &bs.imp_count); + /* Bases and field annotations resolve through annotation-only + * imports too: `if TYPE_CHECKING:` exists for them (#1277). */ + cbm_pxc_build_import_map_ex(ctx->gbuf, project_name, files[fi].rel_path, + files[fi].language, fr, true, &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, @@ -1068,10 +1078,26 @@ static char *pxc_python_import_from_metadata(const cbm_gbuf_t *gbuf, const char /* Build per-file import map (local_name -> semantic import QN) from gbuf * IMPORTS edges. Both pipeline drivers call this implementation. Returns 0 - * with *out_count = 0 when the file has no IMPORTS edges. */ + * with *out_count = 0 when the file has no IMPORTS edges. The CALLS map. */ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, const char *rel_path, CBMLanguage lang, const CBMFileResult *result, const char ***out_keys, const char ***out_vals, int *out_count) { + return cbm_pxc_build_import_map_ex(gbuf, project_name, rel_path, lang, result, false, out_keys, + out_vals, out_count); +} + +bool cbm_pxc_result_has_type_only_import(const CBMFileResult *result) { + for (int i = 0; result && i < result->imports.count; i++) { + if (!cbm_import_binds_runtime_name(&result->imports.items[i])) + return true; + } + return false; +} + +int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name, + const char *rel_path, CBMLanguage lang, const CBMFileResult *result, + bool include_type_only, const char ***out_keys, + const char ***out_vals, int *out_count) { *out_keys = NULL; *out_vals = NULL; *out_count = 0; @@ -1129,7 +1155,10 @@ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, c continue; memcpy(local, start, n); local[n] = '\0'; - char *value = pxc_import_value_qn(lang, result, local, target); + /* An annotation-only import (#1277) binds no CALLS name: it gets no value. */ + bool binds = include_type_only || + cbm_imports_local_binds_runtime_name(result ? &result->imports : NULL, local); + char *value = binds ? pxc_import_value_qn(lang, result, local, target) : NULL; if (!value) { free(local); continue; @@ -1146,6 +1175,7 @@ int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, c for (int i = 0; i < metadata_count; i++) { const CBMImport *imp = &result->imports.items[i]; if (!imp->local_name || !imp->local_name[0] || !imp->module_path || + (!include_type_only && !cbm_import_binds_runtime_name(imp)) || pxc_import_map_has_local(keys, count, imp->local_name)) { continue; } @@ -2212,8 +2242,10 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - cbm_pxc_build_import_map(ctx->gbuf, ctx->project_name, files[i].rel_path, lang, cache[i], - &imp_keys, &imp_vals, &imp_count); + /* The LSP map: annotation-only imports bind for annotations (#1277). + * MUST match the LSP map of pass_parallel.c. */ + cbm_pxc_build_import_map_ex(ctx->gbuf, ctx->project_name, files[i].rel_path, lang, cache[i], + true, &imp_keys, &imp_vals, &imp_count); /* Journal around the resolve: a hang here must be attributed to THIS * file, not to a stale extraction marker (the innocent-quarantine diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index 05e1545ca..bcbe30425 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -83,6 +83,17 @@ void cbm_pxc_ts_modes(CBMLanguage lang, const char *rel_path, bool *out_js, bool int cbm_pxc_build_import_map(const cbm_gbuf_t *gbuf, const char *project_name, const char *rel_path, CBMLanguage lang, const CBMFileResult *result, const char ***out_keys, const char ***out_vals, int *out_count); +/* As cbm_pxc_build_import_map (which is include_type_only = false, the CALLS + * map), but with include_type_only also binding annotation-only imports + * (Python `if TYPE_CHECKING:`), for the consumers that resolve annotations: + * the Python LSP and the cross-file base/field-type resolution (#1277). */ +int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name, + const char *rel_path, CBMLanguage lang, const CBMFileResult *result, + bool include_type_only, const char ***out_keys, + const char ***out_vals, int *out_count); +/* True when the file records an annotation-only import (the LSP map then + * differs from the CALLS map and must be built separately). */ +bool cbm_pxc_result_has_type_only_import(const CBMFileResult *result); void cbm_pxc_free_import_map(const char **keys, const char **vals, int count); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 298b6b8d2..450b78a54 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3910,13 +3910,17 @@ static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (cbm_pipeline_php_vendor_bound(php_vendor, def->base_classes[b])) { continue; } - const char *bqn = - resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, fs->lang); - /* 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; + /* Same order as the sequential semantic pass: an earlier sibling of + * the class body first (no external-base gate), then the registry. */ + const char *bqn = cbm_registry_resolve_python_class_body_base( + rc->registry, rc->main_gbuf, def->qualified_name, def->base_classes[b], fs->lang); + if (!bqn) { + bqn = resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, fs->lang); + 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); if (bn && node->id != bn->id) { @@ -4370,10 +4374,27 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { * this file, not to a stale extraction marker. */ cbm_index_mark_start(rel); bool had_error = result->has_error; + /* The LSP map also binds annotation-only imports (#1277); + * only a file that records one needs its own map, every + * other file passes the CALLS map unchanged. MUST match the + * sequential dispatch in pass_lsp_cross.c. */ + const char **lsp_keys = NULL; + const char **lsp_vals = NULL; + int lsp_count = 0; + bool own_lsp_map = cbm_pxc_result_has_type_only_import(result); + if (own_lsp_map) { + cbm_pxc_build_import_map_ex(rc->main_gbuf, rc->project_name, rel, lang, result, + true, &lsp_keys, &lsp_vals, &lsp_count); + } cbm_pxc_dispatch_file_with_php_bindings( lang, result, lsp_source, lsp_source_len, rel, def_module, rc->cross_registries, - rc->module_def_index, rc->all_defs, rc->def_count, imp_keys, imp_vals, - imp_count, pp_rust_shared_registry_get, rc, &php_vendor); + rc->module_def_index, rc->all_defs, rc->def_count, + own_lsp_map ? lsp_keys : imp_keys, own_lsp_map ? lsp_vals : imp_vals, + own_lsp_map ? lsp_count : imp_count, pp_rust_shared_registry_get, rc, + &php_vendor); + if (own_lsp_map) { + cbm_pxc_free_import_map(lsp_keys, lsp_vals, lsp_count); + } cbm_index_mark_done(rel); if (!had_error && result->has_error && result->error_msg && (strcmp(result->error_msg, CBM_C_LSP_MEMO_ERROR) == 0 || diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 186b2dfdc..0c009e737 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -643,12 +643,18 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * if (cbm_pipeline_php_vendor_bound(php_vendor, def->base_classes[b])) { continue; } - const char *base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, - imp_keys, imp_vals, imp_count, lang); - 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`) */ + /* An earlier sibling of the class body shadows every module or + * imported name, so it needs no external-base gate. */ + const char *base_qn = cbm_registry_resolve_python_class_body_base( + ctx->registry, ctx->gbuf, def->qualified_name, def->base_classes[b], lang); + if (!base_qn) { + base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, imp_keys, + imp_vals, imp_count, lang); + 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) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 0965e0398..5e5cd9506 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -270,6 +270,14 @@ cbm_resolution_t cbm_registry_resolve_call_scoped(const cbm_registry_t *r, const const char **import_map_vals, int import_map_count, CBMLanguage lang, const char *scope_qn); +/* Python: the bare base `base` of the class `class_qn` nested in a class body + * binds the sibling class of that body (`.`) when the + * sibling is defined earlier in the same file (Python evaluates the bases in + * the class body). Exact lookups only; the registry-owned sibling QN, or NULL + * to resolve the base as before. Every base-class resolver asks this first. */ +const char *cbm_registry_resolve_python_class_body_base(const cbm_registry_t *r, + const cbm_gbuf_t *gb, const char *class_qn, + const char *base, CBMLanguage lang); #ifdef CBM_ENABLE_TEST_SEAMS void cbm_pxc_test_import_probe_fail_after(int successful_allocations); #endif diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 5dda868a4..3269b9676 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1383,6 +1383,47 @@ const char *cbm_registry_value_target(const cbm_registry_t *r, const char *qn) { return owned ? owned : qn; } +/* Python evaluates a class statement's bases in the namespace of the body + * that encloses it. For a class nested in a class, a bare base naming a class + * defined EARLIER in that same class body is that sibling + * (`class IntegrityError(DatabaseError)` in sqlalchemy's asyncpg adapter), + * whatever same-named classes the project has elsewhere. A sibling defined + * later is not bound yet when the base is evaluated. Exact lookups only. */ +const char *cbm_registry_resolve_python_class_body_base(const cbm_registry_t *r, + const cbm_gbuf_t *gb, const char *class_qn, + const char *base, CBMLanguage lang) { + if (lang != CBM_LANG_PYTHON || !r || !gb || !class_qn || !base || !base[0] || + strchr(base, '.')) { + return NULL; + } + const char *dot = strrchr(class_qn, '.'); + if (!dot || dot == class_qn) { + return NULL; + } + char sibling[CBM_SZ_1K]; + int enclosing_len = (int)(dot - class_qn); + int n = snprintf(sibling, sizeof(sibling), "%.*s", enclosing_len, class_qn); + if (n < 0 || (size_t)n >= sizeof(sibling) || + !cbm_label_is_type_like(cbm_registry_label_of(r, sibling))) { + return NULL; /* not nested in a class body (module level, or a def) */ + } + n = snprintf(sibling, sizeof(sibling), "%.*s.%s", enclosing_len, class_qn, base); + if (n < 0 || (size_t)n >= sizeof(sibling) || strcmp(sibling, class_qn) == 0) { + return NULL; + } + const char *owned = cbm_ht_get_key(r->exact, sibling); + if (!owned || !cbm_label_is_type_like(cbm_registry_label_of(r, owned))) { + return NULL; + } + const cbm_gbuf_node_t *cls = cbm_gbuf_find_by_qn(gb, class_qn); + const cbm_gbuf_node_t *sib = cbm_gbuf_find_by_qn(gb, owned); + if (!cls || !sib || !cls->file_path || !sib->file_path || + strcmp(cls->file_path, sib->file_path) != 0 || sib->start_line >= cls->start_line) { + return NULL; + } + return owned; +} + int cbm_registry_find_by_name(const cbm_registry_t *r, const char *name, const char ***out, int *count) { if (!r || !out || !count) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8c37e8c5d..3b5ad5785 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1506,6 +1506,82 @@ TEST(python_annotated_instance_fields_exported_issue1277) { PASS(); } +/* #1277: the import an annotated field's type comes from usually sits under + * `if TYPE_CHECKING:`. It is recorded, flagged annotation-only; a runtime + * import is not flagged; an else branch (a runtime alternative) and an + * ordinary `if` stay unrecorded as before. */ +static const CBMImport *py_find_import(const CBMFileResult *r, const char *local) { + for (int i = 0; i < r->imports.count; i++) { + if (r->imports.items[i].local_name && strcmp(r->imports.items[i].local_name, local) == 0) + return &r->imports.items[i]; + } + return NULL; +} + +TEST(python_type_checking_imports_flagged_issue1277) { + const char *src = "from typing import TYPE_CHECKING\n" + "import typing\n" + "if TYPE_CHECKING:\n" + " from trainer import Trainer\n" + " import contracts\n" + "else:\n" + " from fallback import Runtime\n" + "if typing.TYPE_CHECKING:\n" + " from engine import Engine as Eng\n" + "if FLAG:\n" + " from other import Conditional\n"; + CBMFileResult *r = extract(src, CBM_LANG_PYTHON, "t", "loop.py"); + ASSERT_NOT_NULL(r); + const CBMImport *tc = py_find_import(r, "TYPE_CHECKING"); + const CBMImport *trainer = py_find_import(r, "Trainer"); + const CBMImport *contracts = py_find_import(r, "contracts"); + const CBMImport *eng = py_find_import(r, "Eng"); + ASSERT_NOT_NULL(tc); + ASSERT_FALSE(tc->type_checking_only); + ASSERT_NOT_NULL(trainer); + ASSERT_TRUE(trainer->type_checking_only); + ASSERT_NOT_NULL(contracts); + ASSERT_TRUE(contracts->type_checking_only); + ASSERT_NOT_NULL(eng); + ASSERT_TRUE(eng->type_checking_only); + ASSERT_NULL(py_find_import(r, "Runtime")); + ASSERT_NULL(py_find_import(r, "Conditional")); + cbm_free_result(r); + PASS(); +} + +/* A name the alternative of an `if TYPE_CHECKING:` binds again (the airflow + * compat shape, usually inside try/except ImportError) is bound at runtime, so + * its TYPE_CHECKING import is not annotation-only: it stays unrecorded, as the + * runtime import itself does. A star import in an alternative may bind any + * name, so it covers every name of the block. */ +TEST(python_type_checking_rebound_in_else_unrecorded_issue1277) { + const char *src = "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " from airflow.sdk.execution_time.secrets_masker import mask_secret\n" + " from airflow.sdk.types import OnlyTyped\n" + "else:\n" + " try:\n" + " from airflow.sdk.log import mask_secret\n" + " except ImportError:\n" + " mask_secret = None\n" + "if TYPE_CHECKING:\n" + " from compat.types import Starred\n" + "elif SOMETHING:\n" + " from compat import *\n"; + CBMFileResult *r = extract(src, CBM_LANG_PYTHON, "t", "hook.py"); + ASSERT_NOT_NULL(r); + const CBMImport *typed = py_find_import(r, "OnlyTyped"); + ASSERT_NOT_NULL(typed); + ASSERT_TRUE(typed->type_checking_only); /* only the type checker binds it */ + ASSERT_NULL(py_find_import(r, "mask_secret")); /* the else branch binds it at runtime */ + ASSERT_NULL(py_find_import(r, "Starred")); /* the elif star import may bind it */ + ASSERT_NULL(py_find_import(r, "compat")); /* alternatives stay unrecorded */ + ASSERT_NOT_NULL(py_find_import(r, "TYPE_CHECKING")); + cbm_free_result(r); + PASS(); +} + TEST(python_function) { CBMFileResult *r = extract( "def greet(name):\n return f\"Hello {name}\"\n\ndef main():\n greet(\"World\")\n", @@ -3790,6 +3866,48 @@ TEST(python_imports) { PASS(); } +/* The module_path recorded for a local name, or NULL. */ +static const char *import_path_of(CBMFileResult *r, const char *local) { + for (int i = 0; i < r->imports.count; i++) { + if (r->imports.items[i].local_name && strcmp(r->imports.items[i].local_name, local) == 0) + return r->imports.items[i].module_path; + } + return NULL; +} + +/* `from . import x` names the importing file's OWN package: the path is ".x", + * as `from .m import f` is ".m.f". Joining the all-dots module with another '.' + * recorded "..x" (the parent package), so the import resolved one package too + * high and fell to an arbitrary same-named definition (home-assistant: 7,175 + * calls bound to another integration's function). */ +TEST(python_package_relative_import_paths) { + CBMFileResult *r = extract("from . import async_register\n" + "from .. import shared\n" + "from . import local_fn as lf\n" + "from .helpers import helper_fn\n" + "from pkg.db import get_row\n", + CBM_LANG_PYTHON, "t", "comp/web/sub/mod.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *own = import_path_of(r, "async_register"); + const char *parent = import_path_of(r, "shared"); + const char *aliased = import_path_of(r, "lf"); + const char *member = import_path_of(r, "helper_fn"); + const char *absolute = import_path_of(r, "get_row"); + ASSERT_NOT_NULL(own); + ASSERT_NOT_NULL(parent); + ASSERT_NOT_NULL(aliased); + ASSERT_NOT_NULL(member); + ASSERT_NOT_NULL(absolute); + ASSERT_STR_EQ(own, ".async_register"); + ASSERT_STR_EQ(parent, "..shared"); + ASSERT_STR_EQ(aliased, ".local_fn"); + ASSERT_STR_EQ(member, ".helpers.helper_fn"); /* control: unchanged */ + ASSERT_STR_EQ(absolute, "pkg.db.get_row"); /* control: unchanged */ + cbm_free_result(r); + PASS(); +} + TEST(js_imports) { CBMFileResult *r = extract("import React from 'react';\nimport { useState } from " "'react';\nconst fs = require('fs');\n", @@ -12683,6 +12801,8 @@ SUITE(extraction) { RUN_TEST(python_function); RUN_TEST(python_class); RUN_TEST(python_annotated_instance_fields_exported_issue1277); + RUN_TEST(python_type_checking_imports_flagged_issue1277); + RUN_TEST(python_type_checking_rebound_in_else_unrecorded_issue1277); RUN_TEST(js_function); RUN_TEST(js_class); RUN_TEST(ts_function); @@ -12847,6 +12967,7 @@ SUITE(extraction) { RUN_TEST(python_iris_invokeClassMethod); RUN_TEST(go_calls); RUN_TEST(python_imports); + RUN_TEST(python_package_relative_import_paths); RUN_TEST(js_imports); RUN_TEST(go_imports); RUN_TEST(go_cgo_pseudo_import_dropped); diff --git a/tests/test_php_lsp.c b/tests/test_php_lsp.c index 354d96c75..344bedf39 100644 --- a/tests/test_php_lsp.c +++ b/tests/test_php_lsp.c @@ -19,6 +19,9 @@ #include "cbm.h" #include "../src/pipeline/lsp_resolve.h" #include "lsp/php_lsp.h" +#include "lsp/lsp_work.h" +#include +#include #include /* ── Helpers ───────────────────────────────────────────────────── */ @@ -625,6 +628,79 @@ TEST(phplsp_class_aliased_trait_name_collision_terminates) { PASS(); } +/* ── 23e. #765 / #1265: trait flattening work is counted, bounded, linear ── + * + * The guards above prove only that extraction returns; a regression would hang + * the runner instead of failing it. These tests count the LSP work + * (CBM_LSP_WORK, which flatten_trait_into_class adds to once per registry + * function it visits) under a test-only ceiling (lsp_work.h): if a flatten + * bound is reverted, the runaway loop stops at the ceiling and the test is RED + * deterministically. No clock is read (O9). */ +enum { PHPLSP_FLATTEN_CEILING = 50000000 }; + +static uint64_t phplsp_counted_work(const char *src, int *ceiling_hit, CBMFileResult **out) { + (void)cbm_lsp_work_take(); + cbm_lsp_work_ceiling = PHPLSP_FLATTEN_CEILING; + CBMFileResult *r = extract_php(src); + uint64_t work = cbm_lsp_work_take(); + cbm_lsp_work_ceiling = 0; + *ceiling_hit = work > PHPLSP_FLATTEN_CEILING; + if (out) { + *out = r; + } else if (r) { + cbm_free_result(r); + } + return work; +} + +/* F12: the single-file PHP LSP (cbm_run_php_lsp) never indexed its registry, + * so every type and method lookup was a linear scan over the stdlib plus every + * definition of the file. Each class here looks up the previous class and its + * method once: twice the classes must cost about twice the counted work; the + * linear scans drift toward four. */ +static char *phplsp_chain_classes_source(int n) { + size_t sz = (size_t)n * 96 + 256; + char *src = malloc(sz); + if (!src) { + return NULL; + } + size_t pos = (size_t)snprintf(src, sz, + "m(); } }\n", + i, i - 1); + } + return src; +} + +TEST(phplsp_single_file_lookup_work_is_linear_f12) { + enum { N1 = 1000, N2 = 2000 }; + char *s1 = phplsp_chain_classes_source(N1); + char *s2 = phplsp_chain_classes_source(N2); + ASSERT_NOT_NULL(s1); + ASSERT_NOT_NULL(s2); + int hit1 = 0; + int hit2 = 0; + CBMFileResult *r2 = NULL; + uint64_t w1 = phplsp_counted_work(s1, &hit1, NULL); + uint64_t w2 = phplsp_counted_work(s2, &hit2, &r2); + free(s1); + free(s2); + printf(" chained classes %d -> %d: work %llu -> %llu (ratio %.2f)\n", N1, N2, + (unsigned long long)w1, (unsigned long long)w2, w1 ? (double)w2 / (double)w1 : 0.0); + ASSERT_NOT_NULL(r2); + ASSERT_FALSE(hit1); + ASSERT_FALSE(hit2); + ASSERT(w1 > 0); + /* The last class's call still resolves to the previous class's method. */ + ASSERT(find_resolved(r2, "C1999.m", "C1998.m") >= 0); + cbm_free_result(r2); + ASSERT(w2 * 10 <= w1 * 25); /* ratio <= 2.5 */ + PASS(); +} + /* ── 24. Match expression result type ──────────────────────────── */ TEST(phplsp_match_result_type) { @@ -5671,6 +5747,7 @@ SUITE(php_lsp) { RUN_TEST(phplsp_trait_self_use_terminates); RUN_TEST(phplsp_trait_aliased_import_name_collision_terminates); RUN_TEST(phplsp_class_aliased_trait_name_collision_terminates); + RUN_TEST(phplsp_single_file_lookup_work_is_linear_f12); RUN_TEST(phplsp_match_result_type); RUN_TEST(phplsp_ternary_result_type); RUN_TEST(phplsp_method_chain_depth_three); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9a756f688..777eccd4b 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10618,6 +10618,258 @@ TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge) { PASS(); } +/* CALLS edges from a caller to a callee, both matched by QN suffix (start each + * suffix with '.' to anchor it at a segment boundary), any strategy. *guessed + * is set when a matching edge came from a name guess rather than an import + * binding or the LSP. */ +static int py_calls_between(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, bool *guessed) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count); + int hits = 0; + size_t cs = strlen(caller_suffix); + size_t ts = strlen(callee_suffix); + 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); + if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && + strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0) { + hits++; + const char *pj = edges[i].properties_json ? edges[i].properties_json : ""; + if (guessed && !strstr(pj, "\"import_map\"") && !strstr(pj, "\"lsp")) { + *guessed = true; + } + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) + cbm_store_free_edges(edges, edge_count); + return hits; +} + +/* Pin CBM_WORKERS=4 for a padded (> 50 files) parallel run; restore after. */ +typedef struct { + char *saved; + bool had; + int rc; +} py_workers_pin_t; + +static py_workers_pin_t py_pin_workers(bool parallel) { + py_workers_pin_t pin = {0}; + if (!parallel) { + return pin; + } + const char *old = getenv("CBM_WORKERS"); + pin.had = old != NULL; + pin.saved = old ? strdup(old) : NULL; + pin.rc = (old && !pin.saved) ? -1 : cbm_setenv("CBM_WORKERS", "4", 1); + return pin; +} + +static int py_unpin_workers(bool parallel, py_workers_pin_t *pin) { + if (!parallel) { + return 0; + } + int rc = pin->had ? cbm_setenv("CBM_WORKERS", pin->saved, 1) : cbm_unsetenv("CBM_WORKERS"); + free(pin->saved); + pin->saved = NULL; + return rc; +} + +static void py_write_padding(const char *tmp, const char *stem, bool parallel) { + for (int i = 0; parallel && i < 52; i++) { + char name[64]; + char body[96]; + snprintf(name, sizeof(name), "%s%d.py", stem, i); + snprintf(body, sizeof(body), "def %s%d():\n return %d\n", stem, i, i); + write_temp_file(tmp, name, body); + } +} + +/* `from . import x`, `from .. import x` and `from . import x as y` name the + * importing file's own (or parent) package. The extractor recorded them one + * package too high ("..x"), so the import fell to pass_pkgmap's symbol-name + * guess -- the lexicographically first same-named project definition -- and + * every call became a confident edge into comp.aaa, through the import map and + * the Python LSP's alias binding alike (home-assistant: 7,175 call pairs). + * Both resolvers: <= 50 files sequential, padded > 50 parallel. */ +static int check_python_package_relative_imports(bool parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_relpkg_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "comp/__init__.py", ""); + write_temp_file(tmp, "comp/aaa/__init__.py", + "def async_register(hass):\n" + " return 1\n\n" + "def local_fn():\n" + " return 2\n"); + write_temp_file(tmp, "comp/web/__init__.py", + "def async_register(hass):\n" + " return 3\n"); + write_temp_file(tmp, "comp/web/trigger.py", + "from . import async_register\n\n" + "def attach(hass):\n" + " return async_register(hass)\n"); + write_temp_file(tmp, "comp/web/sub/__init__.py", + "def local_fn():\n" + " return 4\n"); + write_temp_file(tmp, "comp/web/sub/mod.py", + "from .. import async_register\n" + "from . import local_fn as lf\n\n" + "def deep(hass):\n" + " async_register(hass)\n" + " return lf()\n"); + py_write_padding(tmp, "relpad", parallel); + + py_workers_pin_t pin = py_pin_workers(parallel); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/relpkg.db", tmp); + cbm_pipeline_t *p = pin.rc == 0 ? cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL) : NULL; + int run_rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = run_rc == 0 ? cbm_store_open_path(db_path) : NULL; + bool opened = s != NULL; + bool guessed = false; + int own = -1, own_decoy = -1, parent = -1, parent_decoy = -1, alias = -1, alias_decoy = -1; + if (s) { + const char *project = cbm_pipeline_project_name(p); + own = py_calls_between(s, project, ".comp.web.trigger.attach", ".comp.web.async_register", + &guessed); + own_decoy = py_calls_between(s, project, ".comp.web.trigger.attach", + ".comp.aaa.async_register", NULL); + parent = py_calls_between(s, project, ".comp.web.sub.mod.deep", ".comp.web.async_register", + &guessed); + parent_decoy = py_calls_between(s, project, ".comp.web.sub.mod.deep", + ".comp.aaa.async_register", NULL); + alias = py_calls_between(s, project, ".comp.web.sub.mod.deep", ".comp.web.sub.local_fn", + &guessed); + alias_decoy = + py_calls_between(s, project, ".comp.web.sub.mod.deep", ".comp.aaa.local_fn", NULL); + cbm_store_close(s); + } + cbm_pipeline_free(p); + int unpin_rc = py_unpin_workers(parallel, &pin); + th_rmtree(tmp); + + ASSERT_EQ(pin.rc, 0); + ASSERT_EQ(unpin_rc, 0); + ASSERT_EQ(run_rc, 0); + ASSERT_TRUE(opened); + ASSERT_EQ(own, 1); /* from . import x: the file's own package */ + ASSERT_EQ(own_decoy, 0); /* never the lexicographically first namesake */ + ASSERT_EQ(parent, 1); /* from .. import x: the parent package */ + ASSERT_EQ(parent_decoy, 0); + ASSERT_EQ(alias, 1); /* from . import x as y */ + ASSERT_EQ(alias_decoy, 0); + ASSERT_FALSE(guessed); /* bound by the import, not by a name guess */ + PASS(); +} + +TEST(pipeline_python_package_relative_imports) { + return check_python_package_relative_imports(false); +} + +TEST(pipeline_python_package_relative_imports_parallel) { + return check_python_package_relative_imports(true); +} + +/* #1237: a plain `from pkg.db import get_row_or_none` and bare calls resolve to + * the imported module-level def (function and method callers, plus the + * same-file caller), never to a same-named def elsewhere that a name guess + * would rank first (legacy.compat), and a re-index that only ADDS an unrelated + * file (the reporter's watcher re-index after writing benchmark scripts into the + * indexed tree) keeps exactly the same CALLS. Main already resolved this shape + * when the guard landed: it pins the shape and has no RED on revert. */ +static int check_python_from_import_bare_call_issue1237(bool parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_1237_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "pkg/__init__.py", ""); + write_temp_file(tmp, "pkg/db.py", + "def get_row_or_none(model, **filters):\n" + " return None\n\n" + "def other_helper():\n" + " return get_row_or_none(1, id=2)\n"); + write_temp_file(tmp, "pkg/views.py", + "from pkg.db import get_row_or_none\n\n" + "def show(pk):\n" + " return get_row_or_none(object, id=pk)\n\n" + "class Detail:\n" + " def get(self, pk):\n" + " return get_row_or_none(object, pk=pk)\n"); + write_temp_file(tmp, "legacy/__init__.py", ""); + write_temp_file(tmp, "legacy/compat.py", + "def get_row_or_none(model):\n" + " return model\n"); + py_write_padding(tmp, "rowpad", parallel); + + py_workers_pin_t pin = py_pin_workers(parallel); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/rows.db", tmp); + int run_rc[2] = {-1, -1}; + int show[2] = {-1, -1}; + int method[2] = {-1, -1}; + int same[2] = {-1, -1}; + int decoy[2] = {-1, -1}; + int calls[2] = {-1, -1}; + bool guessed = false; + for (int run = 0; run < 2 && pin.rc == 0; run++) { + if (run == 1) { + /* Second run on the same db: routes to the incremental re-index. */ + write_temp_file(tmp, "bench_rows.py", "def bench():\n return 0\n"); + } + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + run_rc[run] = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = run_rc[run] == 0 ? cbm_store_open_path(db_path) : NULL; + if (s) { + const char *project = cbm_pipeline_project_name(p); + const char *target = ".pkg.db.get_row_or_none"; + const char *namesake = ".legacy.compat.get_row_or_none"; + show[run] = py_calls_between(s, project, ".pkg.views.show", target, &guessed); + method[run] = py_calls_between(s, project, ".pkg.views.Detail.get", target, &guessed); + same[run] = py_calls_between(s, project, ".pkg.db.other_helper", target, &guessed); + decoy[run] = py_calls_between(s, project, ".pkg.views.show", namesake, NULL) + + py_calls_between(s, project, ".pkg.views.Detail.get", namesake, NULL); + calls[run] = cbm_store_count_edges_by_type(s, project, "CALLS"); + cbm_store_close(s); + } + cbm_pipeline_free(p); + } + int unpin_rc = py_unpin_workers(parallel, &pin); + th_rmtree(tmp); + + ASSERT_EQ(pin.rc, 0); + ASSERT_EQ(unpin_rc, 0); + for (int run = 0; run < 2; run++) { + ASSERT_EQ(run_rc[run], 0); + ASSERT_EQ(show[run], 1); /* cross-file bare call through a plain from-import */ + ASSERT_EQ(method[run], 1); /* the same from a method */ + ASSERT_EQ(same[run], 1); /* same-file bare call */ + ASSERT_EQ(decoy[run], 0); /* never the namesake a name guess would pick */ + } + ASSERT_EQ(calls[1], calls[0]); /* adding a call-free file moves no CALLS edge */ + ASSERT_FALSE(guessed); + PASS(); +} + +TEST(pipeline_python_from_import_bare_call_issue1237) { + return check_python_from_import_bare_call_issue1237(false); +} + +TEST(pipeline_python_from_import_bare_call_issue1237_parallel) { + return check_python_from_import_bare_call_issue1237(true); +} + /* Reproduce-first: pass_parallel's fused cross-LSP eligibility currently counts * only parser-backed calls/call references. A Python binary operator has no * parser CBMCall; its __add__ semantic record and carrier are created together @@ -13754,8 +14006,17 @@ TEST(pipeline_python_init_import_probe_failure_falls_back) { /* #1277: typed instance fields of a Python class imported from another file. * Counts CALLS edges caller -> callee (QN suffixes) whose properties name an * LSP strategy. */ +static int count_calls_with(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *fragment); + static int count_lsp_calls(cbm_store_t *s, const char *project, const char *caller_suffix, const char *callee_suffix) { + return count_calls_with(s, project, caller_suffix, callee_suffix, "lsp"); +} + +/* As count_lsp_calls, with any properties fragment (NULL = every CALLS edge). */ +static int count_calls_with(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *fragment) { cbm_edge_t *edges = NULL; int edge_count = 0; cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count); @@ -13771,7 +14032,8 @@ static int count_lsp_calls(cbm_store_t *s, const char *project, const char *call size_t ts = strlen(callee_suffix); if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0 && - edges[i].properties_json && strstr(edges[i].properties_json, "lsp")) { + (!fragment || + (edges[i].properties_json && strstr(edges[i].properties_json, fragment)))) { hits++; } } @@ -13890,6 +14152,110 @@ TEST(python_crossfile_typed_field_calls_issue1277_parallel) { return check_python_crossfile_typed_field_calls_issue1277(true); } +/* #1277, second half: the reporter's chain with both annotation imports + * under `if TYPE_CHECKING:` (the usual shape when the import would cycle). + * trainer.strategies is typed through a TYPE_CHECKING import of Contract, and + * run()'s parameter through a typing.TYPE_CHECKING import of Trainer; the call + * resolves by the LSP exactly as with runtime imports. A runtime call through + * a TYPE_CHECKING-only name cannot happen: it gets no CALLS edge at all. */ +static int check_python_type_checking_typed_field_issue1277(bool parallel) { + const char *contracts = "class Contract:\n" + " def process_batch(self) -> None:\n" + " ...\n"; + const char *trainer = "from __future__ import annotations\n" + "from typing import TYPE_CHECKING\n\n" + "if TYPE_CHECKING:\n" + " from contracts import Contract\n\n" + "class Trainer:\n" + " def __init__(self, strategies: Contract) -> None:\n" + " self.strategies = strategies\n"; + const char *loop = "from __future__ import annotations\n" + "import typing\n\n" + "if typing.TYPE_CHECKING:\n" + " from trainer import Trainer\n\n" + "def run(trainer: Trainer) -> None:\n" + " trainer.strategies.process_batch()\n\n" + "def bad():\n" + " return Trainer()\n"; + if (setup_usages_repo("contracts.py", contracts, "trainer.py", trainer) != 0) { + FAIL("failed to create temp dir"); + } + char path[512]; + snprintf(path, sizeof(path), "%s/loop.py", g_usages_tmpdir); + if (th_write_file(path, loop) != 0) { + teardown_usages_repo(); + FAIL("failed to write loop.py"); + } + if (parallel) { + for (int i = 0; i < 50; i++) { + snprintf(path, sizeof(path), "%s/tc_pad_%02d.py", g_usages_tmpdir, i); + if (th_write_file(path, "tc_padding = 0\n") != 0) { + teardown_usages_repo(); + FAIL("failed to write parallel-selection fixture"); + } + } + } + + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + if ((old_workers && !saved_workers) || (old_single && !saved_single)) { + free(saved_workers); + free(saved_single); + teardown_usages_repo(); + FAIL("failed to save pipeline environment"); + } + int pin_workers_rc = parallel ? cbm_setenv("CBM_WORKERS", "4", 1) : 0; + int pin_single_rc = parallel ? cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") : 0; + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test_1277_tc.db", g_usages_tmpdir); + cbm_pipeline_t *p = pin_workers_rc == 0 && pin_single_rc == 0 + ? cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL) + : NULL; + int run_rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = run_rc == 0 ? cbm_store_open_path(db_path) : NULL; + bool store_opened = s != NULL; + int via_field = -1, runtime_import = -1, runtime_lsp = -1; + if (s) { + const char *project = cbm_pipeline_project_name(p); + via_field = count_lsp_calls(s, project, "loop.run", "contracts.Contract.process_batch"); + runtime_import = count_calls_with(s, project, "loop.bad", "trainer.Trainer", + "\"strategy\":\"import_map"); + runtime_lsp = count_lsp_calls(s, project, "loop.bad", "trainer.Trainer"); + cbm_store_close(s); + } + + cbm_pipeline_free(p); + int restore_workers_rc = + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + int restore_single_rc = saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + teardown_usages_repo(); + + ASSERT_EQ(pin_workers_rc, 0); + ASSERT_EQ(pin_single_rc, 0); + ASSERT_EQ(restore_workers_rc, 0); + ASSERT_EQ(restore_single_rc, 0); + ASSERT_EQ(run_rc, 0); + ASSERT_TRUE(store_opened); + ASSERT_EQ(via_field, 1); /* annotation through TYPE_CHECKING imports */ + ASSERT_EQ(runtime_import, 0); /* control: no CALLS import map binds the name */ + ASSERT_EQ(runtime_lsp, 0); /* control: the LSP binds it for annotations only */ + PASS(); +} + +TEST(python_type_checking_typed_field_issue1277) { + return check_python_type_checking_typed_field_issue1277(false); +} + +TEST(python_type_checking_typed_field_issue1277_parallel) { + return check_python_type_checking_typed_field_issue1277(true); +} + /* A base class spelled through a root-binding import (`import pkg.mod.nodes` * binds `pkg`; `class X(pkg.mod.nodes.Context)`) is the same class as the * from-imported spelling. The Python LSP's inheritance view never resolved the @@ -15211,6 +15577,200 @@ TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548) { return pyn_run_shadowing(52); } +/* ── super() follows the C3 method resolution order ──────────────── */ + +/* The home-assistant radarr shape: RadarrCalendarEntity(RadarrEntity, + * CalendarEntity), where RadarrEntity reaches the common base Entity through + * CoordinatorEntity and CalendarEntity overrides Entity's method. Python's C3 + * order is [RadarrCalendarEntity, RadarrEntity, CoordinatorEntity, + * CalendarEntity, Entity]: a common base comes after every class that + * derives from it, so super() reaches CalendarEntity, not Entity through the + * first base's ancestry. */ +static const char pym_base_py[] = "class Entity:\n" + " def __init__(self):\n" + " self.ready = False\n" + "\n" + " def write_state(self):\n" + " return 0\n" + "\n" + "class CoordinatorEntity(Entity):\n" + " pass\n" + "\n" + "class CalendarEntity(Entity):\n" + " def __init__(self):\n" + " self.events = []\n" + "\n" + " def write_state(self):\n" + " return 1\n"; + +static const char pym_radarr_py[] = "from base import CoordinatorEntity, CalendarEntity\n" + "\n" + "class RadarrEntity(CoordinatorEntity):\n" + " pass\n" + "\n" + "class RadarrCalendarEntity(RadarrEntity, CalendarEntity):\n" + " def __init__(self):\n" + " super().__init__()\n" + "\n" + " def write_state(self):\n" + " return super().write_state()\n" + "\n" + "class PlainCalendar(CalendarEntity):\n" + " def write_state(self):\n" + " return super().write_state()\n"; + +static int pym_run_super_c3(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_superc3_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "base.py", pym_base_py); + write_temp_file(tmp, "radarr.py", pym_radarr_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/superc3.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *pr = p ? cbm_pipeline_project_name(p) : NULL; + const char *rce = "radarr.RadarrCalendarEntity"; + char src[256]; + snprintf(src, sizeof(src), "%s.write_state", rce); + bool m_c3 = s && pyn_edge(s, pr, src, "base.CalendarEntity.write_state", "CALLS"); + bool m_dfs = s && pyn_edge(s, pr, src, "base.Entity.write_state", "CALLS"); + snprintf(src, sizeof(src), "%s.__init__", rce); + bool i_c3 = s && pyn_edge(s, pr, src, "base.CalendarEntity.__init__", "CALLS"); + bool i_dfs = s && pyn_edge(s, pr, src, "base.Entity.__init__", "CALLS"); + bool plain = s && pyn_edge(s, pr, "radarr.PlainCalendar.write_state", + "base.CalendarEntity.write_state", "CALLS"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(m_c3); /* super().write_state(): CalendarEntity comes before Entity */ + ASSERT_FALSE(m_dfs); /* ... never Entity through RadarrEntity's ancestry */ + ASSERT_TRUE(i_c3); /* super().__init__(): the same order */ + ASSERT_FALSE(i_dfs); + ASSERT_TRUE(plain); /* control: single inheritance */ + PASS(); +} + +TEST(pipeline_python_super_follows_c3_mro) { + return pym_run_super_c3(0); +} + +TEST(pipeline_python_super_follows_c3_mro_parallel) { + return pym_run_super_c3(52); +} + +/* ── attribute lookup follows the C3 method resolution order ─────── */ + +/* The same diamond as the super() test, seen from an ordinary attribute: + * `self.write_state()` in RadarrCalendarEntity and `e.write_state()` on a + * RadarrCalendarEntity both look the method up along the class's C3 order, + * which reaches CalendarEntity before the shared base Entity. */ +static const char pya_radarr_py[] = "from base import CoordinatorEntity, CalendarEntity\n" + "\n" + "class RadarrEntity(CoordinatorEntity):\n" + " pass\n" + "\n" + "class RadarrCalendarEntity(RadarrEntity, CalendarEntity):\n" + " def refresh(self):\n" + " return self.write_state()\n" + "\n" + "class PlainCalendar(CalendarEntity):\n" + " def refresh(self):\n" + " return self.write_state()\n" + "\n" + "def publish(e: RadarrCalendarEntity):\n" + " return e.write_state()\n"; + +static int pya_run_attr_c3(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_attrc3_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "base.py", pym_base_py); + write_temp_file(tmp, "radarr.py", pya_radarr_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/attrc3.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *pr = p ? cbm_pipeline_project_name(p) : NULL; + const char *c3 = "base.CalendarEntity.write_state"; + const char *dfs = "base.Entity.write_state"; + const char *self_src = "radarr.RadarrCalendarEntity.refresh"; + bool self_c3 = s && pyn_edge(s, pr, self_src, c3, "CALLS"); + bool self_dfs = s && pyn_edge(s, pr, self_src, dfs, "CALLS"); + bool typed_c3 = s && pyn_edge(s, pr, "radarr.publish", c3, "CALLS"); + bool typed_dfs = s && pyn_edge(s, pr, "radarr.publish", dfs, "CALLS"); + bool plain = s && pyn_edge(s, pr, "radarr.PlainCalendar.refresh", c3, "CALLS"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(self_c3); /* self.write_state(): CalendarEntity before Entity */ + ASSERT_FALSE(self_dfs); /* ... never Entity through RadarrEntity's ancestry */ + ASSERT_TRUE(typed_c3); /* e.write_state() on a RadarrCalendarEntity */ + ASSERT_FALSE(typed_dfs); + ASSERT_TRUE(plain); /* control: single inheritance */ + PASS(); +} + +TEST(pipeline_python_attribute_follows_c3_mro) { + return pya_run_attr_c3(0); +} + +TEST(pipeline_python_attribute_follows_c3_mro_parallel) { + return pya_run_attr_c3(52); +} + /* L7 review of #1548: a package __init__.py function can share a submodule's QN * (poetry: layouts/__init__.py `def layout` is the QN prefix of every symbol in * layouts/layout.py). The function-local detection is bounded at the symbol's @@ -15308,6 +15868,164 @@ TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548) { return pyn_run_init_collision(52); } +/* ── F15: a nested class's bare base resolves in its class body first ── */ + +/* The sqlalchemy asyncpg shape: an adapter class re-declares the DBAPI + * exception hierarchy as nested classes, each based on an EARLIER sibling + * spelled bare (`class IntegrityError(DatabaseError)`). Python evaluates a + * class statement's bases in the enclosing class body, so the sibling wins + * over the same-named module classes of exc.py, which the adapter imports + * as a module (so a name guess prefers them, as on sqlalchemy once its + * relative `from ... import exc` resolves). A sibling defined LATER in the + * body is not bound yet: `Early(Later)` names the module-level Later. */ +static const char pyb_exc_py[] = "class DBAPIError(Exception):\n" + " pass\n" + "\n" + "class DatabaseError(DBAPIError):\n" + " pass\n" + "\n" + "class IntegrityError(DatabaseError):\n" + " pass\n"; + +static const char pyb_adapter_py[] = "import exc\n" + "from exc import DBAPIError\n" + "\n" + "class Later(Exception):\n" + " pass\n" + "\n" + "class AsyncAdapt_dbapi:\n" + " class Error(DBAPIError):\n" + " pass\n" + "\n" + " class DatabaseError(Error):\n" + " pass\n" + "\n" + " class IntegrityError(DatabaseError):\n" + " pass\n" + "\n" + " class NotNullViolationError(IntegrityError):\n" + " pass\n" + "\n" + " class Early(Later):\n" + " pass\n" + "\n" + " class Later(Exception):\n" + " pass\n"; + +/* A second adapter with the same nested names, as sqlalchemy has one per + * driver: a name-only guess has several same-named candidates to pick. */ +static const char pyb_adapter2_py[] = "class AsyncAdapt_other_dbapi:\n" + " class Error(Exception):\n" + " pass\n" + "\n" + " class DatabaseError(Error):\n" + " pass\n" + "\n" + " class IntegrityError(DatabaseError):\n" + " pass\n"; + +static int pyb_run_class_body_base(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_cbbase_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "exc.py", pyb_exc_py); + write_temp_file(tmp, "adapter.py", pyb_adapter_py); + write_temp_file(tmp, "adapter2.py", pyb_adapter2_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/cbbase.db", tmp); + int rc[2] = {-1, -1}; + bool db_sib[2] = {false, false}; + bool db_mod[2] = {true, true}; + bool ie_sib[2] = {false, false}; + bool ie_mod[2] = {true, true}; + bool nn_sib[2] = {false, false}; + bool err_imp[2] = {false, false}; + bool early_mod[2] = {false, false}; + bool early_later_sib[2] = {true, true}; + bool other_sib[2] = {false, false}; + for (int run = 0; run < 2; run++) { + if (run == 1) { + char path[512]; + snprintf(path, sizeof(path), "%s/adapter.py", tmp); + th_append_file(path, "\n# edited: the second run re-indexes incrementally\n"); + } + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + rc[run] = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc[run] == 0 ? cbm_store_open_path(db_path) : NULL; + if (s) { + const char *pr = cbm_pipeline_project_name(p); + const char *cls = "adapter.AsyncAdapt_dbapi"; + char a[256]; + char b[256]; + snprintf(a, sizeof(a), "%s.DatabaseError", cls); + snprintf(b, sizeof(b), "%s.Error", cls); + db_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + db_mod[run] = pyn_edge(s, pr, a, "adapter2.AsyncAdapt_other_dbapi.Error", "INHERITS"); + snprintf(a, sizeof(a), "%s.IntegrityError", cls); + snprintf(b, sizeof(b), "%s.DatabaseError", cls); + ie_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + ie_mod[run] = pyn_edge(s, pr, a, "exc.DatabaseError", "INHERITS"); + snprintf(a, sizeof(a), "%s.NotNullViolationError", cls); + snprintf(b, sizeof(b), "%s.IntegrityError", cls); + nn_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + snprintf(a, sizeof(a), "%s.Error", cls); + err_imp[run] = pyn_edge(s, pr, a, "exc.DBAPIError", "INHERITS"); + snprintf(a, sizeof(a), "%s.Early", cls); + snprintf(b, sizeof(b), "%s.Later", cls); + early_mod[run] = pyn_edge(s, pr, a, "adapter.Later", "INHERITS"); + early_later_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + other_sib[run] = pyn_edge(s, pr, "adapter2.AsyncAdapt_other_dbapi.IntegrityError", + "adapter2.AsyncAdapt_other_dbapi.DatabaseError", "INHERITS"); + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int run = 0; run < 2; run++) { + ASSERT_EQ(rc[run], 0); + ASSERT_TRUE(db_sib[run]); /* DatabaseError(Error): the earlier sibling */ + ASSERT_FALSE(db_mod[run]); /* ... not the other adapter's Error */ + ASSERT_TRUE(ie_sib[run]); /* IntegrityError(DatabaseError): the sibling */ + ASSERT_FALSE(ie_mod[run]); /* ... not exc.DatabaseError */ + ASSERT_TRUE(nn_sib[run]); /* a chain of siblings */ + ASSERT_TRUE(err_imp[run]); /* control: a non-sibling base keeps the import */ + ASSERT_TRUE(early_mod[run]); /* a LATER sibling is not bound yet: module Later */ + ASSERT_FALSE(early_later_sib[run]); /* ... never the later sibling */ + ASSERT_TRUE(other_sib[run]); /* the second adapter binds its own sibling */ + } + PASS(); +} + +TEST(pipeline_python_class_body_sibling_base) { + return pyb_run_class_body_base(0); +} + +TEST(pipeline_python_class_body_sibling_base_parallel) { + return pyb_run_class_body_base(52); +} + /* #1548 keeps TESTS for calls made through a test's own nested helper: the * call now starts at the extracted helper def (not at the test FILE node, as * before #1548), so TESTS is attributed to the enclosing test function. A @@ -15403,6 +16121,94 @@ TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548) { return pyn_run_nested_helper_tests(52); } +/* A decorated class nested in a class body (`@dataclass class Meta:` inside a + * class) is a nested class like an undecorated one: it gets its Class node and + * its methods their Method nodes. push_nested_class_nodes looked through + * field/template/declaration and conditional wrappers but not through Python's + * decorated_definition, so the decorated class and everything in it was never + * extracted. Both resolvers. */ +static const char pyn_decorated_nested_py[] = "def deco(cls):\n" + " return cls\n" + "\n" + "class Outer:\n" + " @deco\n" + " class Decorated:\n" + " def run(self):\n" + " return 1\n" + "\n" + " class Plain:\n" + " def go(self):\n" + " return 2\n"; + +static bool pyn_node_exists(cbm_store_t *s, const char *project, const char *rel_qn, + const char *label) { + char qn[512]; + pyn_qn(qn, sizeof(qn), project, rel_qn); + cbm_node_t n = {0}; + bool found = cbm_store_find_node_by_qn(s, project, qn, &n) == CBM_STORE_OK && n.label && + strcmp(n.label, label) == 0; + cbm_node_free_fields(&n); + return found; +} + +static int pyn_run_decorated_nested(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_decnest_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "sd.py", pyn_decorated_nested_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/decnest.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *project = p ? cbm_pipeline_project_name(p) : NULL; + bool dec_class = s && pyn_node_exists(s, project, "sd.Outer.Decorated", "Class"); + bool dec_method = s && pyn_node_exists(s, project, "sd.Outer.Decorated.run", "Method"); + bool plain_class = s && pyn_node_exists(s, project, "sd.Outer.Plain", "Class"); + bool plain_method = s && pyn_node_exists(s, project, "sd.Outer.Plain.go", "Method"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(dec_class); /* the decorated nested class is extracted */ + ASSERT_TRUE(dec_method); /* ... and its method */ + ASSERT_TRUE(plain_class); /* control: undecorated nested class */ + ASSERT_TRUE(plain_method); /* control */ + PASS(); +} + +TEST(pipeline_python_decorated_nested_class_extracted) { + return pyn_run_decorated_nested(0); +} + +TEST(pipeline_python_decorated_nested_class_extracted_parallel) { + return pyn_run_decorated_nested(52); +} + TEST(pipeline_python_project) { /* Port of TestPipelinePythonProject */ const char *files[] = {"main.py", "utils.py"}; @@ -24853,6 +25659,10 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); + RUN_TEST(pipeline_python_package_relative_imports); + RUN_TEST(pipeline_python_package_relative_imports_parallel); + RUN_TEST(pipeline_python_from_import_bare_call_issue1237); + RUN_TEST(pipeline_python_from_import_bare_call_issue1237_parallel); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); @@ -24888,6 +25698,8 @@ SUITE(pipeline) { 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_type_checking_typed_field_issue1277); + RUN_TEST(python_type_checking_typed_field_issue1277_parallel); RUN_TEST(python_dotted_base_inheritance); RUN_TEST(python_dotted_base_inheritance_parallel); RUN_TEST(python_typed_field_external_import_stays_unresolved); @@ -24914,10 +25726,18 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); + RUN_TEST(pipeline_python_super_follows_c3_mro); + RUN_TEST(pipeline_python_super_follows_c3_mro_parallel); + RUN_TEST(pipeline_python_attribute_follows_c3_mro); + RUN_TEST(pipeline_python_attribute_follows_c3_mro_parallel); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); + RUN_TEST(pipeline_python_class_body_sibling_base); + RUN_TEST(pipeline_python_class_body_sibling_base_parallel); RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548); + RUN_TEST(pipeline_python_decorated_nested_class_extracted); + RUN_TEST(pipeline_python_decorated_nested_class_extracted_parallel); RUN_TEST(pipeline_header_include_target_is_independent_of_registration_order); RUN_TEST(pipeline_imports_multi_symbol_edges); RUN_TEST(pipeline_go_cross_package_call);