diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 082bca1d6..4d01d0c69 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -243,6 +243,11 @@ void cbm_typeassign_push(CBMTypeAssignArray *arr, CBMArena *a, CBMTypeAssign ta) arr->items[arr->count++] = ta; } +void cbm_fieldtype_push(CBMFieldTypeArray *arr, CBMArena *a, CBMFieldType ft) { + GROW_ARRAY(arr, a); + arr->items[arr->count++] = ft; +} + void cbm_stringref_push(CBMStringRefArray *arr, CBMArena *a, CBMStringRef sr) { GROW_ARRAY(arr, a); arr->items[arr->count++] = sr; diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index a33349f1a..870bfe9a5 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -409,6 +409,17 @@ typedef struct { CBMChannelDirection direction; } CBMChannel; +/* Python: one annotated instance field of a class -- `x: T` or `x: T = v` in + * the class body, `self.x: T = v` in __init__, or `self.x = p` where `p` is an + * annotated __init__ parameter. Not a graph node: it only carries the field's + * declared type to the cross-file LSP, so `obj.x.m()` on a class imported + * from another file can be typed (#1277). */ +typedef struct { + const char *class_qn; // QN of the owning class + const char *field_name; // attribute name + const char *type_text; // raw annotation text, resolved later per file +} CBMFieldType; + // Rust: impl Trait for Struct typedef struct { const char *trait_name; // trait name (raw text) @@ -526,6 +537,12 @@ typedef struct { int cap; } CBMChannelArray; +typedef struct { + CBMFieldType *items; + int count; + int cap; +} CBMFieldTypeArray; + // Full extraction result for one file. typedef struct CBMFileResult { CBMArena arena; // owns local memory; composites may also retain child arenas below @@ -544,6 +561,7 @@ typedef struct CBMFileResult { CBMStringRefArray string_refs; // URL/config string literals from AST CBMInfraBindingArray infra_bindings; // topic→URL pairs from IaC configs CBMChannelArray channels; // Socket.IO / EventEmitter pub/sub participation + CBMFieldTypeArray field_types; // Python: annotated instance fields (#1277) const char *module_qn; // module qualified name const char *namespace_name; // declared namespace/package (Java/Kotlin/C#/PHP), NULL if none @@ -900,6 +918,7 @@ void cbm_rw_push(CBMRWArray *arr, CBMArena *a, CBMReadWrite rw); void cbm_typerefs_push(CBMTypeRefArray *arr, CBMArena *a, CBMTypeRef tr); void cbm_envaccess_push(CBMEnvAccessArray *arr, CBMArena *a, CBMEnvAccess ea); void cbm_typeassign_push(CBMTypeAssignArray *arr, CBMArena *a, CBMTypeAssign ta); +void cbm_fieldtype_push(CBMFieldTypeArray *arr, CBMArena *a, CBMFieldType ft); void cbm_stringref_push(CBMStringRefArray *arr, CBMArena *a, CBMStringRef sr); void cbm_infrabinding_push(CBMInfraBindingArray *arr, CBMArena *a, CBMInfraBinding ib); void cbm_impltrait_push(CBMImplTraitArray *arr, CBMArena *a, CBMImplTrait it); diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index bb56e2870..de5b2e90d 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -4584,6 +4584,169 @@ static bool extract_sql_ddl_class_def(CBMExtractCtx *ctx, TSNode node, const cha return true; } +/* ── Python annotated instance fields (#1277) ───────────────────── + * Exported as result->field_types (never graph nodes) so the cross-file LSP + * can type `obj.x` when obj's class lives in another file. Only DECLARED + * types count: a class-body annotation, a `self.x: T = v` annotation in + * __init__, or `self.x = p` where p is an annotated __init__ parameter. An + * unannotated right-hand side is never guessed at. */ + +static void py_push_field_type(CBMExtractCtx *ctx, const char *class_qn, TSNode name_node, + const char *type_text) { + char *name = cbm_node_text(ctx->arena, name_node, ctx->source); + if (!name || !name[0] || !type_text || !type_text[0]) { + return; + } + CBMFieldType ft = {.class_qn = class_qn, .field_name = name, .type_text = type_text}; + cbm_fieldtype_push(&ctx->result->field_types, ctx->arena, ft); +} + +/* The assignment inside an expression_statement, or a null node. */ +static TSNode py_statement_assignment(TSNode stmt) { + TSNode null_node = {0}; + if (strcmp(ts_node_type(stmt), "expression_statement") != 0 || + ts_node_named_child_count(stmt) == 0) { + return null_node; + } + TSNode inner = ts_node_named_child(stmt, 0); + return strcmp(ts_node_type(inner), "assignment") == 0 ? inner : null_node; +} + +/* Annotation text of the __init__ parameter called `name`, or NULL. */ +static const char *py_init_param_annotation(CBMExtractCtx *ctx, TSNode params, const char *name) { + uint32_t n = ts_node_named_child_count(params); + for (uint32_t i = 0; i < n; i++) { + TSNode p = ts_node_named_child(params, i); + const char *pk = ts_node_type(p); + TSNode pname = {0}; + if (strcmp(pk, "typed_default_parameter") == 0) { + pname = ts_node_child_by_field_name(p, TS_FIELD("name")); + } else if (strcmp(pk, "typed_parameter") == 0 && ts_node_named_child_count(p) > 0) { + pname = ts_node_named_child(p, 0); + } else { + continue; + } + TSNode ptype = ts_node_child_by_field_name(p, TS_FIELD("type")); + if (ts_node_is_null(pname) || ts_node_is_null(ptype) || + strcmp(ts_node_type(pname), "identifier") != 0) { + continue; + } + char *pn = cbm_node_text(ctx->arena, pname, ctx->source); + if (pn && strcmp(pn, name) == 0) { + return cbm_node_text(ctx->arena, ptype, ctx->source); + } + } + return NULL; +} + +/* `self.x: T = v` / `self.x = p` inside __init__ (self = its first parameter). */ +static void py_init_field_assignment(CBMExtractCtx *ctx, const char *class_qn, TSNode assign, + TSNode params, const char *self_name) { + TSNode left = ts_node_child_by_field_name(assign, TS_FIELD("left")); + if (ts_node_is_null(left) || strcmp(ts_node_type(left), "attribute") != 0) { + return; + } + TSNode obj = ts_node_child_by_field_name(left, TS_FIELD("object")); + TSNode attr = ts_node_child_by_field_name(left, TS_FIELD("attribute")); + if (ts_node_is_null(obj) || ts_node_is_null(attr) || + strcmp(ts_node_type(obj), "identifier") != 0) { + return; + } + char *obj_name = cbm_node_text(ctx->arena, obj, ctx->source); + if (!obj_name || strcmp(obj_name, self_name) != 0) { + return; + } + TSNode ann = ts_node_child_by_field_name(assign, TS_FIELD("type")); + if (!ts_node_is_null(ann)) { + py_push_field_type(ctx, class_qn, attr, cbm_node_text(ctx->arena, ann, ctx->source)); + return; + } + TSNode right = ts_node_child_by_field_name(assign, TS_FIELD("right")); + if (ts_node_is_null(right) || strcmp(ts_node_type(right), "identifier") != 0) { + return; + } + char *rhs = cbm_node_text(ctx->arena, right, ctx->source); + if (rhs && strcmp(rhs, self_name) != 0) { + py_push_field_type(ctx, class_qn, attr, py_init_param_annotation(ctx, params, rhs)); + } +} + +/* Every `self.x` field assignment in one __init__ body, nested blocks + * included; nested functions, lambdas and classes have their own `self`. */ +static void py_extract_init_fields(CBMExtractCtx *ctx, const char *class_qn, TSNode fn) { + TSNode params = ts_node_child_by_field_name(fn, TS_FIELD("parameters")); + TSNode body = ts_node_child_by_field_name(fn, TS_FIELD("body")); + if (ts_node_is_null(params) || ts_node_is_null(body) || + ts_node_named_child_count(params) == 0) { + return; + } + TSNode self_node = ts_node_named_child(params, 0); + if (strcmp(ts_node_type(self_node), "identifier") != 0) { + return; + } + char *self_name = cbm_node_text(ctx->arena, self_node, ctx->source); + if (!self_name || !self_name[0]) { + return; + } + TSNodeStack stack; + ts_nstack_init(&stack, ctx, CBM_SZ_32); + ts_nstack_push(&stack, body); + while (stack.count > 0) { + TSNode n = ts_nstack_pop(&stack); + const char *k = ts_node_type(n); + if (strcmp(k, "function_definition") == 0 || strcmp(k, "class_definition") == 0 || + strcmp(k, "lambda") == 0) { + continue; + } + TSNode assign = py_statement_assignment(n); + if (!ts_node_is_null(assign)) { + py_init_field_assignment(ctx, class_qn, assign, params, self_name); + continue; + } + /* Reverse push keeps source order on pop. */ + for (uint32_t i = ts_node_named_child_count(n); i > 0; i--) { + ts_nstack_push(&stack, ts_node_named_child(n, i - 1)); + } + } +} + +static void extract_py_field_types(CBMExtractCtx *ctx, TSNode class_node, const char *class_qn) { + TSNode body = ts_node_child_by_field_name(class_node, TS_FIELD("body")); + if (ts_node_is_null(body)) { + return; + } + uint32_t count = ts_node_named_child_count(body); + /* Class-body annotations first: `x: T` / `x: T = v`. */ + for (uint32_t i = 0; i < count; i++) { + TSNode assign = py_statement_assignment(ts_node_named_child(body, i)); + if (ts_node_is_null(assign)) { + continue; + } + TSNode left = ts_node_child_by_field_name(assign, TS_FIELD("left")); + TSNode ann = ts_node_child_by_field_name(assign, TS_FIELD("type")); + if (!ts_node_is_null(left) && !ts_node_is_null(ann) && + strcmp(ts_node_type(left), "identifier") == 0) { + py_push_field_type(ctx, class_qn, left, cbm_node_text(ctx->arena, ann, ctx->source)); + } + } + /* Then __init__. */ + for (uint32_t i = 0; i < count; i++) { + TSNode fn = ts_node_named_child(body, i); + if (strcmp(ts_node_type(fn), "decorated_definition") == 0) { + fn = ts_node_child_by_field_name(fn, TS_FIELD("definition")); + } + if (ts_node_is_null(fn) || strcmp(ts_node_type(fn), "function_definition") != 0) { + continue; + } + TSNode fname = ts_node_child_by_field_name(fn, TS_FIELD("name")); + char *fn_name = + ts_node_is_null(fname) ? NULL : cbm_node_text(ctx->arena, fname, ctx->source); + if (fn_name && strcmp(fn_name, "__init__") == 0) { + py_extract_init_fields(ctx, class_qn, fn); + } + } +} + static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { CBMArena *a = ctx->arena; const char *kind = ts_node_type(node); @@ -4970,6 +5133,10 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec // Extract class-level variables (field declarations) extract_class_variables(ctx, node, class_qn, spec); + if (ctx->language == CBM_LANG_PYTHON) { + extract_py_field_types(ctx, node, class_qn); + } + // C# 12 primary-constructor parameters: declared on the class line // (`class Foo(IBar bar, IBaz baz) : Base { ... }`) and bound to implicit // captured fields accessible from any instance member. Tree-sitter c-sharp diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 2cf66ad01..70de5dce7 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -4965,6 +4965,47 @@ static const char **py_split_pipe(CBMArena *arena, const char *text) { return out; } +/* Split a class def's field_defs ("name:QN|name:QN", built by the Python + * field fold in pass_lsp_cross.c) into the parallel NULL-terminated arrays a + * CBMRegisteredType carries. The types are already project QNs, so each one + * becomes a NAMED type verbatim. Without this, `obj.field.method()` on a class + * from another file lost the field's type (#1277); same-file classes get their + * fields from the per-file walk instead. Pure string work: no registry lookup + * happens here, so it is safe inside the type-registration pass. */ +static void py_split_field_defs(CBMArena *arena, const char *field_defs, const char ***names_out, + const CBMType ***types_out) { + *names_out = NULL; + *types_out = NULL; + const char **pairs = py_split_pipe(arena, field_defs); + if (!pairs) + return; + int n = 0; + while (pairs[n]) + n++; + const char **names = + (const char **)cbm_arena_alloc(arena, (size_t)(n + 1) * sizeof(const char *)); + const CBMType **types = + (const CBMType **)cbm_arena_alloc(arena, (size_t)(n + 1) * sizeof(const CBMType *)); + if (!names || !types) + return; + int kept = 0; + for (int i = 0; i < n; i++) { + char *colon = strchr(pairs[i], ':'); + if (!colon || colon == pairs[i] || !colon[1]) + continue; + *colon = '\0'; /* pairs[i] is this call's own arena copy */ + names[kept] = pairs[i]; + types[kept] = cbm_type_named(arena, colon + 1); + kept++; + } + if (kept == 0) + return; + names[kept] = NULL; + types[kept] = NULL; + *names_out = names; + *types_out = types; +} + /* Build a registry from CBMLSPDef[] supplied by the caller — covers both * the source file's own defs and cross-file referenced defs. */ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRegistry *reg, @@ -4990,6 +5031,7 @@ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe if (d->method_names_str && d->method_names_str[0]) { rt.method_names = py_split_pipe(arena, d->method_names_str); } + py_split_field_defs(arena, d->field_defs, &rt.field_names, &rt.field_types); cbm_registry_add_type(reg, rt); } } diff --git a/internal/cbm/lsp/type_registry.c b/internal/cbm/lsp/type_registry.c index 4e2f02012..06b55c391 100644 --- a/internal/cbm/lsp/type_registry.c +++ b/internal/cbm/lsp/type_registry.c @@ -677,8 +677,28 @@ CBMRegisteredType *cbm_registry_type_for_update(CBMTypeRegistry *head, const cha for (const CBMTypeRegistry *r = head->fallback; r; r = r->fallback) { const CBMRegisteredType *base = lookup_type_self(r, qualified_name); if (base) { + CBMRegisteredType copy = *base; + /* Field refinements overwrite entries in this array. The type + * objects remain immutable, but the writable overlay must own + * the pointer array rather than mutate its shared fallback. */ + if (base->field_types) { + size_t count = 0; + /* Types are parallel to names; a named field may have no + * type, so an interior NULL does not terminate that array. */ + while (base->field_names ? base->field_names[count] != NULL + : base->field_types[count] != NULL) { + count++; + } + const CBMType **field_types = (const CBMType **)cbm_arena_alloc( + head->arena, (count + 1) * sizeof(*field_types)); + if (!field_types) { + return NULL; + } + memcpy(field_types, base->field_types, (count + 1) * sizeof(*field_types)); + copy.field_types = field_types; + } int before = head->type_count; - cbm_registry_add_type(head, *base); + cbm_registry_add_type(head, copy); if (head->type_count == before + 1) { return &head->types[before]; } diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index 8fedf8269..b0b1717e7 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -378,6 +378,12 @@ static void cr_walk(cr_ctx_t *c, CBMFileResult *r) { cr_str(c, &r->channels.items[i].transport); cr_str(c, &r->channels.items[i].enclosing_func_qn); } + cr_array(c, (void **)&r->field_types.items, r->field_types.count, sizeof(CBMFieldType)); + for (int i = 0; i < r->field_types.count && r->field_types.items; i++) { + cr_str(c, &r->field_types.items[i].class_qn); + cr_str(c, &r->field_types.items[i].field_name); + cr_str(c, &r->field_types.items[i].type_text); + } cr_str(c, &r->module_qn); cr_str(c, &r->namespace_name); cr_list(c, &r->exports); @@ -524,6 +530,7 @@ void cbm_result_compact(CBMFileResult *result) { tmp.string_refs.cap = tmp.string_refs.count; tmp.infra_bindings.cap = tmp.infra_bindings.count; tmp.channels.cap = tmp.channels.count; + tmp.field_types.cap = tmp.field_types.count; /* A composite kept its per-unit results only so shallow-copied strings * stayed valid; every string is now a copy of its own. */ diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index d484d18c2..8f5c1efe3 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -540,6 +540,132 @@ static void pxc_fold_go_struct_fields(CBMArena *arena, const CBMFileResult *resu } } +/* Python annotation text -> the one type name it declares, or NULL. Strips a + * string-literal forward reference and unwraps `Optional[X]`, `X | None` and + * `None | X`; anything else with brackets, commas or a real union (generics, + * Callable, Union[A, B]) names no single receiver type and is dropped. */ +static const char *pxc_py_annotation_type_name(CBMArena *arena, const char *text) { + if (!text) { + return NULL; + } + while (*text == ' ') { + text++; + } + size_t n = strlen(text); + while (n > 0 && text[n - 1] == ' ') { + n--; + } + if (n >= 2 && (text[0] == '"' || text[0] == '\'') && text[n - 1] == text[0]) { + text++; + n -= 2; + } + static const char *const optional_prefixes[] = {"Optional[", "typing.Optional["}; + for (size_t i = 0; i < sizeof(optional_prefixes) / sizeof(optional_prefixes[0]); i++) { + size_t plen = strlen(optional_prefixes[i]); + if (n > plen + 1 && strncmp(text, optional_prefixes[i], plen) == 0 && text[n - 1] == ']') { + text += plen; + n -= plen + 1; + break; + } + } + static const char none_tail[] = " | None"; + static const char none_head[] = "None | "; + const size_t none_len = sizeof(none_tail) - 1; + if (n > none_len && strncmp(text + n - none_len, none_tail, none_len) == 0) { + n -= none_len; + } else if (n > none_len && strncmp(text, none_head, none_len) == 0) { + text += none_len; + n -= none_len; + } + if (n == 0) { + return NULL; + } + for (size_t i = 0; i < n; i++) { + char c = text[i]; + bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '_' || c == '.'; + if (!ok) { + return NULL; + } + } + return cbm_arena_strndup(arena, text, n); +} + +/* Python: fold the file's annotated instance fields (result->field_types, + * #1277) into their class defs' field_defs as "name:QN|name:QN", the format + * py_register_lsp_defs reads. Each annotation resolves through the same + * registry + import map, and the same weak-strategy and type-like vetoes, as + * the file's base classes (pxc_resolve_base_qn): a field whose type is not a + * confidently-known project type is left out rather than guessed. Linear in + * the file: one hash of the file's class defs, one pass over its fields. */ +typedef struct { + int owner; /* index into defs[], or -1 */ + const char *type_qn; +} pxc_py_field_t; + +static void pxc_fold_py_field_types(CBMArena *arena, const CBMFileResult *result, CBMLSPDef *defs, + int start, int end, const cbm_registry_t *reg, + const char *module_qn, const char **imp_keys, + const char **imp_vals, int imp_count) { + int nf = result ? result->field_types.count : 0; + if (!arena || nf == 0 || !defs || start >= end || !reg) { + return; + } + CBMHashTable *by_qn = cbm_ht_create((uint32_t)(end - start)); + pxc_py_field_t *fields = (pxc_py_field_t *)cbm_arena_alloc(arena, (size_t)nf * sizeof(*fields)); + /* Per class def: exact buffer size, then the write cursor into it. */ + size_t *lens = (size_t *)cbm_arena_alloc(arena, (size_t)(end - start) * sizeof(size_t)); + size_t *used = (size_t *)cbm_arena_alloc(arena, (size_t)(end - start) * sizeof(size_t)); + if (!by_qn || !fields || !lens || !used) { + cbm_ht_free(by_qn); /* NULL-safe */ + return; + } + memset(lens, 0, (size_t)(end - start) * sizeof(size_t)); + memset(used, 0, (size_t)(end - start) * sizeof(size_t)); + for (int si = start; si < end; si++) { + if (defs[si].label && strcmp(defs[si].label, "Class") == 0 && defs[si].qualified_name) { + cbm_ht_set(by_qn, defs[si].qualified_name, &defs[si]); + } + } + for (int f = 0; f < nf; f++) { + const CBMFieldType *ft = &result->field_types.items[f]; + fields[f].owner = -1; + fields[f].type_qn = NULL; + CBMLSPDef *owner = ft->class_qn ? (CBMLSPDef *)cbm_ht_get(by_qn, ft->class_qn) : NULL; + const char *name = owner ? pxc_py_annotation_type_name(arena, ft->type_text) : NULL; + const char *qn = + name ? pxc_resolve_base_qn(reg, name, module_qn, imp_keys, imp_vals, imp_count) : NULL; + if (!qn || !ft->field_name || !ft->field_name[0]) { + continue; + } + fields[f].owner = (int)(owner - defs); + fields[f].type_qn = qn; + /* "name:QN" plus one byte for the '|' or the NUL after it. */ + lens[fields[f].owner - start] += strlen(ft->field_name) + 1 + strlen(qn) + 1; + } + cbm_ht_free(by_qn); + for (int si = start; si < end; si++) { + char *buf = lens[si - start] ? (char *)cbm_arena_alloc(arena, lens[si - start]) : NULL; + if (buf) { + buf[0] = '\0'; + } + defs[si].field_defs = buf; + } + for (int f = 0; f < nf; f++) { + char *buf = fields[f].owner >= 0 ? (char *)defs[fields[f].owner].field_defs : NULL; + if (!buf) { + continue; + } + int slot = fields[f].owner - start; + int wrote = + snprintf(buf + used[slot], lens[slot] - used[slot], "%s%s:%s", used[slot] ? "|" : "", + result->field_types.items[f].field_name, fields[f].type_qn); + if (wrote > 0) { + used[slot] += (size_t)wrote; + } + } +} + /* Carry one Rust type-level impl independently of any method definition. * `impl Trait for Type {}` is semantically meaningful even when the block is * empty (the trait may provide defaults), so attaching the relation only to @@ -645,6 +771,10 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *are idx++; } } + if (files[fi].language == CBM_LANG_PYTHON) { + pxc_fold_py_field_types(arena, fr, defs, file_start, idx, base_reg, def_modules[fi], + imp_keys, imp_vals, imp_count); + } cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count); /* NULL-safe */ if (files[fi].language == CBM_LANG_GO) { pxc_fold_go_struct_fields(arena, fr, defs, file_start, idx); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 8b13aa6d4..b5073a321 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1156,7 +1156,8 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c (size_t)r->string_refs.count * sizeof(CBMStringRef) + (size_t)r->impl_traits.count * sizeof(CBMImplTrait) + (size_t)r->infra_bindings.count * sizeof(CBMInfraBinding) + - (size_t)r->channels.count * sizeof(CBMChannel); + (size_t)r->channels.count * sizeof(CBMChannel) + + (size_t)r->field_types.count * sizeof(CBMFieldType); cap_other += (size_t)r->imports.cap * sizeof(CBMImport) + (size_t)r->resolved_calls.cap * sizeof(CBMResolvedCall) + (size_t)r->throws.cap * sizeof(CBMThrow) + @@ -1165,7 +1166,8 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c (size_t)r->string_refs.cap * sizeof(CBMStringRef) + (size_t)r->impl_traits.cap * sizeof(CBMImplTrait) + (size_t)r->infra_bindings.cap * sizeof(CBMInfraBinding) + - (size_t)r->channels.cap * sizeof(CBMChannel); + (size_t)r->channels.cap * sizeof(CBMChannel) + + (size_t)r->field_types.cap * sizeof(CBMFieldType); n_defs += (size_t)r->defs.count; n_calls += (size_t)r->calls.count; n_usages += (size_t)r->usages.count; diff --git a/tests/test_c_lsp.c b/tests/test_c_lsp.c index 933cacc36..d89091150 100644 --- a/tests/test_c_lsp.c +++ b/tests/test_c_lsp.c @@ -15350,6 +15350,10 @@ TEST(registry_overlay_chain_iterates_and_copies_on_write) { memset(&t, 0, sizeof(t)); t.qualified_name = "pkg.T"; t.short_name = "T"; + const char *field_names[] = {"untyped", "typed", NULL}; + const CBMType *field_types[] = {NULL, cbm_type_named(&arena, "pkg.Value"), NULL}; + t.field_names = field_names; + t.field_types = field_types; cbm_registry_add_type(&base, t); cbm_registry_finalize(&base); base.read_only = true; @@ -15414,6 +15418,10 @@ TEST(registry_overlay_chain_iterates_and_copies_on_write) { CBMRegisteredType *wt = cbm_registry_type_for_update(&overlay, "pkg.T"); ASSERT_NOT_NULL(wt); ASSERT_EQ(overlay.type_count, 1); + /* A missing type for an earlier named field must not truncate the copy. */ + bool field_slots_preserved = wt->field_types && wt->field_types != field_types && + wt->field_types[0] == NULL && + wt->field_types[1] == field_types[1] && wt->field_types[2] == NULL; cbm_registry_all_types_chain(&overlay, &ti); seen = 0; while (cbm_type_short_iter_next(&ti) >= 0) { @@ -15430,6 +15438,7 @@ TEST(registry_overlay_chain_iterates_and_copies_on_write) { cbm_arena_destroy(&scratch); cbm_arena_destroy(&arena); + ASSERT_TRUE(field_slots_preserved); PASS(); } @@ -15804,6 +15813,111 @@ TEST(seal_py_shared_registry_readonly_fields) { PASS(); } +/* #1277: the production resolver receives a writable overlay, so refining an + * existing field must detach its pointer array from the sealed fallback. */ +TEST(seal_py_overlay_existing_field_keeps_shared_type) { + CBMArena arena; + cbm_arena_init(&arena); + CBMLSPDef defs[4] = {0}; + defs[0].qualified_name = "test.mod.Foo"; + defs[0].short_name = "Foo"; + defs[0].label = "Class"; + defs[0].def_module_qn = "test.mod"; + defs[0].field_defs = "x:test.contracts.Contract"; + defs[1].qualified_name = "test.mod.Foo.m"; + defs[1].short_name = "m"; + defs[1].label = "Method"; + defs[1].receiver_type = "test.mod.Foo"; + defs[1].def_module_qn = "test.mod"; + defs[2].qualified_name = "test.contracts.Contract"; + defs[2].short_name = "Contract"; + defs[2].label = "Class"; + defs[2].def_module_qn = "test.contracts"; + defs[3].qualified_name = "test.contracts.Contract.process_batch"; + defs[3].short_name = "process_batch"; + defs[3].label = "Method"; + defs[3].receiver_type = "test.contracts.Contract"; + defs[3].def_module_qn = "test.contracts"; + for (int i = 0; i < 4; i++) { + defs[i].lang = CBM_LANG_PYTHON; + } + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 4); + ASSERT_NOT_NULL(reg); + ASSERT_TRUE(reg->read_only); + const CBMRegisteredType *base = cbm_registry_lookup_type(reg, "test.mod.Foo"); + ASSERT_NOT_NULL(base); + ASSERT_NOT_NULL(base->field_names); + ASSERT_NOT_NULL(base->field_types); + const char **names_before = base->field_names; + const CBMType **types_before = base->field_types; + const CBMType *type_before = base->field_types[0]; + ASSERT_NOT_NULL(type_before); + ASSERT_NULL(base->field_types[1]); + + CBMArena scratch; + cbm_arena_init(&scratch); + CBMTypeRegistry overlay; + cbm_registry_init(&overlay, &scratch); + overlay.fallback = reg; + const char *writer = "class Foo:\n" + " def m(self):\n" + " self.x = 1\n"; + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&scratch, writer, (int)strlen(writer), "test.mod", &overlay, + NULL, NULL, 0, NULL, &out, NULL); + + /* Observe while scratch is live, then release both arenas before any + * failure assertion so the regression also fails cleanly under LSan. */ + bool shared_names_unchanged = base->field_names == names_before; + bool shared_types_unchanged = base->field_types == types_before; + bool shared_element_unchanged = base->field_types[0] == type_before; + bool shared_terminator_unchanged = base->field_types[1] == NULL; + const CBMRegisteredType *refined = cbm_registry_lookup_type(&overlay, "test.mod.Foo"); + bool local_refined = refined && refined != base && refined->field_types && + refined->field_types != types_before && refined->field_types[0] && + refined->field_types[0] != type_before && + refined->field_types[0]->kind == CBM_TYPE_BUILTIN && + refined->field_types[1] == NULL; + cbm_arena_destroy(&scratch); + + /* Never read a corrupted shared pointer after scratch destruction. When + * intact, a later file must still see the original Contract field. */ + int hits = 0; + if (shared_names_unchanged && shared_types_unchanged && shared_element_unchanged && + shared_terminator_unchanged && local_refined) { + cbm_arena_init(&scratch); + cbm_registry_init(&overlay, &scratch); + overlay.fallback = reg; + const char *reader = "from mod import Foo\n\n" + "def run(foo: Foo):\n" + " foo.x.process_batch()\n"; + const char *import_names[] = {"Foo"}; + const char *import_qns[] = {"test.mod.Foo"}; + memset(&out, 0, sizeof(out)); + cbm_run_py_lsp_cross_with_registry(&scratch, reader, (int)strlen(reader), "test.reader", + &overlay, import_names, import_qns, 1, NULL, &out, NULL); + for (int i = 0; i < out.count; i++) { + const CBMResolvedCall *call = &out.items[i]; + if (call->caller_qn && call->callee_qn && + strcmp(call->caller_qn, "test.reader.run") == 0 && + strcmp(call->callee_qn, "test.contracts.Contract.process_batch") == 0 && + call->kind == CBM_RESOLVED_INVOCATION && + call->confidence >= CBM_LSP_CONFIDENCE_FLOOR) { + hits++; + } + } + cbm_arena_destroy(&scratch); + } + cbm_arena_destroy(&arena); + ASSERT_TRUE(shared_names_unchanged); + ASSERT_TRUE(shared_types_unchanged); + ASSERT_TRUE(shared_element_unchanged); + ASSERT_TRUE(shared_terminator_unchanged); + ASSERT_TRUE(local_refined); + ASSERT_EQ(hits, 1); + PASS(); +} + TEST(seal_cs_shared_registry_readonly) { CBMArena arena; cbm_arena_init(&arena); @@ -16402,6 +16516,7 @@ SUITE(c_lsp) { RUN_TEST(registry_overlay_chain_iterates_and_copies_on_write); RUN_TEST(clsp_method_return_refinement_is_copy_on_write); RUN_TEST(seal_py_shared_registry_readonly_fields); + RUN_TEST(seal_py_overlay_existing_field_keeps_shared_type); RUN_TEST(seal_cs_shared_registry_readonly); RUN_TEST(seal_ts_shared_registry_readonly); RUN_TEST(seal_go_shared_registry_readonly); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 0b6800cd1..4980426a9 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1182,6 +1182,62 @@ TEST(cpp_method_return_type_preserves_pointer_and_qualifier) { * ═══════════════════════════════════════════════════════════════════ */ /* --- Python --- */ +/* #1277: annotated Python instance fields reach result->field_types (the + * cross-file LSP's only source for them); nothing unannotated is guessed and + * no graph def is minted for a field. */ +static const char *py_field_type_of(const CBMFileResult *r, const char *cls_suffix, + const char *field) { + for (int i = 0; i < r->field_types.count; i++) { + const CBMFieldType *ft = &r->field_types.items[i]; + size_t cl = strlen(ft->class_qn); + size_t sl = strlen(cls_suffix); + if (cl >= sl && strcmp(ft->class_qn + cl - sl, cls_suffix) == 0 && + strcmp(ft->field_name, field) == 0) { + return ft->type_text; + } + } + return NULL; +} + +TEST(python_annotated_instance_fields_exported_issue1277) { + const char *src = "class Trainer(Base):\n" + " engine: Contract\n" + " limit: int = 3\n" + " plain = 5\n\n" + " def __init__(self, strategies: Contract, n, opt: 'Other' = None):\n" + " self.strategies = strategies\n" + " self.typed: Contract = strategies\n" + " self.count = n\n" + " self.made = make()\n" + " self.opt = opt\n" + " if n:\n" + " self.nested: Nested = n\n" + " def inner(other: Inner):\n" + " self.hidden = other\n\n" + " def later(self, x: Later):\n" + " self.late = x\n"; + CBMFileResult *r = extract(src, CBM_LANG_PYTHON, "t", "trainer.py"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(py_field_type_of(r, ".Trainer", "engine"), "Contract"); + ASSERT_STR_EQ(py_field_type_of(r, ".Trainer", "limit"), "int"); + ASSERT_STR_EQ(py_field_type_of(r, ".Trainer", "strategies"), "Contract"); + ASSERT_STR_EQ(py_field_type_of(r, ".Trainer", "typed"), "Contract"); + ASSERT_STR_EQ(py_field_type_of(r, ".Trainer", "opt"), "'Other'"); + ASSERT_STR_EQ(py_field_type_of(r, ".Trainer", "nested"), "Nested"); + /* Controls: no declared type, a nested function's binding, a non-__init__ method. */ + ASSERT_NULL(py_field_type_of(r, ".Trainer", "plain")); + ASSERT_NULL(py_field_type_of(r, ".Trainer", "count")); + ASSERT_NULL(py_field_type_of(r, ".Trainer", "made")); + ASSERT_NULL(py_field_type_of(r, ".Trainer", "hidden")); + ASSERT_NULL(py_field_type_of(r, ".Trainer", "late")); + ASSERT_EQ(r->field_types.count, 6); + for (int i = 0; i < r->defs.count; i++) { + ASSERT_FALSE(r->defs.items[i].label && strcmp(r->defs.items[i].label, "Field") == 0); + } + 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", @@ -8991,6 +9047,7 @@ SUITE(extraction) { /* Scripting */ RUN_TEST(python_function); RUN_TEST(python_class); + RUN_TEST(python_annotated_instance_fields_exported_issue1277); RUN_TEST(js_function); RUN_TEST(js_class); RUN_TEST(ts_function); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 179973db9..ec0fff931 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8518,6 +8518,145 @@ static void teardown_usages_repo(void) { g_usages_tmpdir[0] = '\0'; } +/* #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_lsp_calls(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count); + int hits = 0; + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK) { + size_t sl = strlen(src.qualified_name); + size_t tl = strlen(tgt.qualified_name); + size_t cs = strlen(caller_suffix); + 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")) { + hits++; + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) + cbm_store_free_edges(edges, edge_count); + return hits; +} + +static int check_python_crossfile_typed_field_calls_issue1277(bool parallel) { + const char *contracts = "class Contract:\n" + " def process_batch(self) -> None:\n" + " ...\n"; + const char *trainer = "from contracts import Contract\n\n" + "def make():\n" + " return None\n\n" + "class Trainer:\n" + " engine: Contract\n\n" + " def __init__(self, strategies: Contract) -> None:\n" + " self.strategies = strategies\n" + " self.typed: Contract = strategies\n" + " self.plain = make()\n"; + const char *loop = "from trainer import Trainer\n\n" + "def run(trainer: Trainer) -> None:\n" + " strategies = trainer.strategies\n" + " strategies.process_batch()\n\n" + "def run_typed(trainer: Trainer) -> None:\n" + " trainer.typed.process_batch()\n\n" + "def run_classlevel(trainer: Trainer) -> None:\n" + " trainer.engine.process_batch()\n\n" + "def run_untyped(trainer: Trainer) -> None:\n" + " trainer.plain.process_batch()\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) { + /* Three semantic files plus 50 inert files exceed the production + * threshold for the fused parallel pipeline. */ + for (int i = 0; i < 50; i++) { + snprintf(path, sizeof(path), "%s/field_pad_%02d.py", g_usages_tmpdir, i); + if (th_write_file(path, "field_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.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_alias = -1, via_typed = -1, via_class = -1, via_untyped = -1; + if (s) { + const char *project = cbm_pipeline_project_name(p); + via_alias = count_lsp_calls(s, project, "loop.run", "contracts.Contract.process_batch"); + via_typed = + count_lsp_calls(s, project, "loop.run_typed", "contracts.Contract.process_batch"); + via_class = + count_lsp_calls(s, project, "loop.run_classlevel", "contracts.Contract.process_batch"); + via_untyped = count_lsp_calls(s, project, "loop.run_untyped", "process_batch"); + 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_alias, 1); /* self.x = */ + ASSERT_EQ(via_typed, 1); /* self.x: T = ... */ + ASSERT_EQ(via_class, 1); /* class-level x: T */ + ASSERT_EQ(via_untyped, 0); /* control: no annotation, no guess */ + PASS(); +} + +TEST(python_crossfile_typed_field_calls_issue1277) { + return check_python_crossfile_typed_field_calls_issue1277(false); +} + +TEST(python_crossfile_typed_field_calls_issue1277_parallel) { + return check_python_crossfile_typed_field_calls_issue1277(true); +} + TEST(usages_creates_edges) { /* Port of TestPassUsagesCreatesEdges. * Go source with callback reference → USAGE edge. */ @@ -17130,6 +17269,8 @@ SUITE(pipeline) { RUN_TEST(implements_creates_override); RUN_TEST(implements_no_match); /* Usages pass (full pipeline integration) */ + RUN_TEST(python_crossfile_typed_field_calls_issue1277); + RUN_TEST(python_crossfile_typed_field_calls_issue1277_parallel); RUN_TEST(usages_creates_edges); RUN_TEST(usages_no_duplicate_calls); RUN_TEST(calls_edge_carries_call_site_line); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 3aaadee51..d65341900 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -580,6 +580,119 @@ TEST(pylsp_crossfile_method_dispatch) { PASS(); } +/* Issue #1277 (reproducer contributed by @Enferlain): a method call through a + * typed instance field of a class imported from ANOTHER file. The field type + * reaches the resolver only through CBMLSPDef.field_defs on the class def. */ +TEST(pylsp_crossfile_receiver_through_typed_field) { + const char *source = "from trainer import Trainer\n" + "def run(trainer: Trainer):\n" + " strategies = trainer.strategies\n" + " return strategies.process_batch()\n"; + + CBMLSPDef defs[3]; + memset(defs, 0, sizeof(defs)); + + defs[0].qualified_name = "contracts.Contract"; + defs[0].short_name = "Contract"; + defs[0].label = "Class"; + defs[0].def_module_qn = "contracts"; + + defs[1].qualified_name = "contracts.Contract.process_batch"; + defs[1].short_name = "process_batch"; + defs[1].label = "Method"; + defs[1].receiver_type = "contracts.Contract"; + defs[1].def_module_qn = "contracts"; + + defs[2].qualified_name = "trainer.Trainer"; + defs[2].short_name = "Trainer"; + defs[2].label = "Class"; + defs[2].def_module_qn = "trainer"; + defs[2].field_defs = "strategies:contracts.Contract"; + + const char *imp_names[] = {"Trainer"}; + const char *imp_qns[] = {"trainer.Trainer"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + + cbm_run_py_lsp_cross(&arena, source, (int)strlen(source), "test.loop", defs, 3, imp_names, + imp_qns, 1, NULL, &out, NULL); + + ASSERT_GTE(find_resolved_arr(&out, "run", "process_batch"), 0); + cbm_arena_destroy(&arena); + PASS(); +} + +/* #1277 on the production path: the sealed shared Tier-2 registry. Covers the + * direct chain, the local alias, a field inherited from a base class, and two + * controls: an undeclared field and a field whose type has no such method + * must stay unresolved (never guessed). */ +TEST(pylsp_crossfile_typed_field_shared_registry) { + CBMArena arena; + cbm_arena_init(&arena); + + CBMLSPDef defs[5]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "contracts.Contract"; + defs[0].short_name = "Contract"; + defs[0].label = "Class"; + defs[0].def_module_qn = "contracts"; + defs[1].qualified_name = "contracts.Contract.process_batch"; + defs[1].short_name = "process_batch"; + defs[1].label = "Method"; + defs[1].receiver_type = "contracts.Contract"; + defs[1].def_module_qn = "contracts"; + defs[2].qualified_name = "trainer.Trainer"; + defs[2].short_name = "Trainer"; + defs[2].label = "Class"; + defs[2].def_module_qn = "trainer"; + defs[2].field_defs = "strategies:contracts.Contract|name:builtins.str"; + defs[3].qualified_name = "trainer.SubTrainer"; + defs[3].short_name = "SubTrainer"; + defs[3].label = "Class"; + defs[3].def_module_qn = "trainer"; + defs[3].embedded_types = "trainer.Trainer"; + defs[4].qualified_name = "trainer.Other"; + defs[4].short_name = "Other"; + defs[4].label = "Class"; + defs[4].def_module_qn = "trainer"; + for (int i = 0; i < 5; i++) { + defs[i].lang = CBM_LANG_PYTHON; + } + + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 5); + ASSERT_NOT_NULL(reg); + ASSERT_TRUE(reg->read_only); + + const char *src = "from trainer import Trainer, SubTrainer, Other\n" + "def direct(t: Trainer):\n" + " return t.strategies.process_batch()\n" + "def alias(t: Trainer):\n" + " s = t.strategies\n" + " return s.process_batch()\n" + "def inherited(t: SubTrainer):\n" + " return t.strategies.process_batch()\n" + "def undeclared(t: Other):\n" + " return t.strategies.process_batch()\n" + "def wrong_type(t: Trainer):\n" + " return t.name.process_batch()\n"; + const char *imp_names[] = {"Trainer", "SubTrainer", "Other"}; + const char *imp_qns[] = {"trainer.Trainer", "trainer.SubTrainer", "trainer.Other"}; + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.loop", reg, imp_names, + imp_qns, 3, NULL, &out, NULL); + + ASSERT_GTE(find_resolved_arr(&out, "direct", "Contract.process_batch"), 0); + ASSERT_GTE(find_resolved_arr(&out, "alias", "Contract.process_batch"), 0); + ASSERT_GTE(find_resolved_arr(&out, "inherited", "Contract.process_batch"), 0); + ASSERT_EQ(find_resolved_arr(&out, "undeclared", "process_batch"), -1); + ASSERT_EQ(find_resolved_arr(&out, "wrong_type", "process_batch"), -1); + + cbm_arena_destroy(&arena); + PASS(); +} + /* Graph-quality guard for the Python seal fix (per-file field overlay). In the * FUSED path the shared Tier-2 registry is read_only, so `self.b = Bar()` can no * longer be recorded by mutating the shared registry; it is recorded in the per-file @@ -2193,6 +2306,8 @@ SUITE(py_lsp) { RUN_TEST(pylsp_pep695_generic_class); /* Phase 9 — cross-file + batch */ RUN_TEST(pylsp_crossfile_method_dispatch); + RUN_TEST(pylsp_crossfile_receiver_through_typed_field); + RUN_TEST(pylsp_crossfile_typed_field_shared_registry); RUN_TEST(pylsp_fused_self_attr_chain_via_overlay); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance);