mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
96c3f41cf3
commit
519c765e1f
10 files changed
+639
-2
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user