fix(python): retain typed fields across file boundaries

Carry declared instance-field types through extraction and result
compaction into the shared Python type registry so chained calls through
imported classes resolve their receiver types.

Refs #1277

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-03 01:37:27 +02:00
1 parent 96c3f41cf3
commit 519c765e1f
10 files changed
+639 -2

No files matched your search

+5
View File
@@ -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;
+19
View File
@@ -405,6 +405,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)
@@ -518,6 +529,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
@@ -536,6 +553,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
@@ -892,6 +910,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);
+167
View File
@@ -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
+42
View File
@@ -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);
}
}
+7
View File
@@ -377,6 +377,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);
@@ -523,6 +529,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. */
+130
View File
@@ -529,6 +529,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
@@ -634,6 +760,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);
+4 -2
View File
@@ -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;
+57
View File
@@ -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);
+93
View File
@@ -8166,6 +8166,98 @@ 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;
}
TEST(python_crossfile_typed_field_calls_issue1277) {
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);
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs(loop, f);
fclose(f);
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/test_1277.db", g_usages_tmpdir);
cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
const char *project = cbm_pipeline_project_name(p);
int via_alias = count_lsp_calls(s, project, "loop.run", "contracts.Contract.process_batch");
int via_typed =
count_lsp_calls(s, project, "loop.run_typed", "contracts.Contract.process_batch");
int via_class =
count_lsp_calls(s, project, "loop.run_classlevel", "contracts.Contract.process_batch");
int via_untyped = count_lsp_calls(s, project, "loop.run_untyped", "process_batch");
cbm_store_close(s);
cbm_pipeline_free(p);
teardown_usages_repo();
ASSERT_EQ(via_alias, 1); /* self.x = <annotated __init__ parameter> */
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(usages_creates_edges) {
/* Port of TestPassUsagesCreatesEdges.
* Go source with callback reference → USAGE edge. */
@@ -16378,6 +16470,7 @@ 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(usages_creates_edges);
RUN_TEST(usages_no_duplicate_calls);
RUN_TEST(calls_edge_carries_call_site_line);
+115
View File
@@ -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);