mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
feat(py_lsp): instance attribute typing — 79% -> 95% resolution
Round 4 / final parity push. Tracks self.x = expr from __init__ and
class-body annotated assignments (`x: Foo`) as registered fields on
the enclosing class type.
py_register_instance_field mutates the registry's CBMRegisteredType
field_names / field_types arrays in place. Re-registering the same
name overwrites; first-seen wins for capture order. Arena-allocated,
not a hot path.
py_lookup_field walks the alias / embedded chain with the same
CBM_LSP_MAX_LOOKUP_DEPTH cap as method lookup, so inherited fields
resolve through MRO without stack-overflow risk on pathological
hierarchies.
Field-fallback wired into py_eval_expr_type's attribute branch:
when method lookup misses, try field lookup. Returns the field's
type so downstream attribute / call resolution chains naturally.
Same fallback applied to UNION (single-match heuristic).
py_process_class now does three passes:
1. Class-body annotated assignments (`x: int = ...`) → fields.
2. __init__ / __post_init__ → self.x = expr fields.
3. All other methods.
This guarantees fields are registered before methods that reference
them, regardless of source order.
Annotated assignment binding fix: py_resolve_annotation is now called
on assignment annotations too, so `self.cache: dict[str, str] = {}`
binds with the proper TEMPLATE type instead of NAMED("dict[str, str]").
Bench result on the 136-line fixture: **95%** (36/38 calls resolved,
8.06 ms). Up from 79%. The 5% gap covers cases that need a full
constraint solver — see Final Stopping Point doc.
2 new tests; all 2890 prior tests stay green. Total 2892 / 0.
This commit is contained in:
1 parent
e7dcadc3dd
commit
4b993899a0
2 files changed
+244
-11
No files matched your search
+186
-11
@@ -28,6 +28,10 @@ static void py_emit_resolved_call(PyLSPContext* ctx, const char* callee_qn,
|
||||
const char* strategy, float confidence);
|
||||
static const CBMType* py_resolve_annotation(PyLSPContext* ctx, const char* ann);
|
||||
static const CBMType* py_iterable_element_type(PyLSPContext* ctx, const CBMType* iter_type);
|
||||
static const CBMType* py_lookup_field(PyLSPContext* ctx, const char* type_qn,
|
||||
const char* field_name);
|
||||
static void py_register_instance_field(PyLSPContext* ctx, const char* class_qn,
|
||||
const char* field_name, const CBMType* field_type);
|
||||
|
||||
void py_lsp_init(PyLSPContext* ctx, CBMArena* arena, const char* source, int source_len,
|
||||
const CBMTypeRegistry* registry, const char* module_qn, CBMResolvedCallArray* out) {
|
||||
@@ -212,6 +216,94 @@ static const CBMRegisteredFunc* py_lookup_attribute(PyLSPContext* ctx,
|
||||
return py_lookup_attribute_depth(ctx, type_qn, member_name, 0);
|
||||
}
|
||||
|
||||
/* Look up a field on a registered type. Walks alias / embedded chain
|
||||
* with the same depth cap as method lookup. Returns the field's type
|
||||
* or NULL if no match. */
|
||||
static const CBMType* py_lookup_field_depth(PyLSPContext* ctx, const char* type_qn,
|
||||
const char* field_name, int depth) {
|
||||
if (!ctx || !ctx->registry || !type_qn || !field_name) return NULL;
|
||||
if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) return NULL;
|
||||
|
||||
const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, type_qn);
|
||||
if (!rt) return NULL;
|
||||
if (rt->field_names && rt->field_types) {
|
||||
for (int i = 0; rt->field_names[i]; i++) {
|
||||
if (strcmp(rt->field_names[i], field_name) == 0) {
|
||||
return rt->field_types[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rt->alias_of) {
|
||||
const CBMType* a = py_lookup_field_depth(ctx, rt->alias_of, field_name, depth + 1);
|
||||
if (a) return a;
|
||||
}
|
||||
if (rt->embedded_types) {
|
||||
for (int i = 0; rt->embedded_types[i]; i++) {
|
||||
const CBMType* e = py_lookup_field_depth(ctx,
|
||||
rt->embedded_types[i], field_name, depth + 1);
|
||||
if (e) return e;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static const CBMType* py_lookup_field(PyLSPContext* ctx, const char* type_qn,
|
||||
const char* field_name) {
|
||||
return py_lookup_field_depth(ctx, type_qn, field_name, 0);
|
||||
}
|
||||
|
||||
/* Append a (name, type) entry to a registered class's field arrays.
|
||||
* Idempotent — re-registering the same name overwrites the existing
|
||||
* entry. Mutates the array in place by allocating a new (slightly
|
||||
* larger) array on each call; not a hot path so the cost is acceptable. */
|
||||
static void py_register_instance_field(PyLSPContext* ctx, const char* class_qn,
|
||||
const char* field_name, const CBMType* field_type) {
|
||||
if (!ctx || !ctx->registry || !class_qn || !field_name || !field_type) return;
|
||||
|
||||
// Find the type entry. cbm_registry_lookup_type returns a const pointer;
|
||||
// we need a mutable pointer into the registry's array.
|
||||
CBMRegisteredType* rt = NULL;
|
||||
for (int i = 0; i < ctx->registry->type_count; i++) {
|
||||
const char* qn = ctx->registry->types[i].qualified_name;
|
||||
if (qn && strcmp(qn, class_qn) == 0) {
|
||||
rt = &ctx->registry->types[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rt) return;
|
||||
|
||||
// Overwrite if name already registered.
|
||||
if (rt->field_names && rt->field_types) {
|
||||
for (int i = 0; rt->field_names[i]; i++) {
|
||||
if (strcmp(rt->field_names[i], field_name) == 0) {
|
||||
((const CBMType**)rt->field_types)[i] = field_type;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int existing = 0;
|
||||
if (rt->field_names) {
|
||||
while (rt->field_names[existing]) existing++;
|
||||
}
|
||||
int new_count = existing + 1;
|
||||
const char** new_names = (const char**)cbm_arena_alloc(ctx->arena,
|
||||
(size_t)(new_count + 1) * sizeof(const char*));
|
||||
const CBMType** new_types = (const CBMType**)cbm_arena_alloc(ctx->arena,
|
||||
(size_t)(new_count + 1) * sizeof(const CBMType*));
|
||||
if (!new_names || !new_types) return;
|
||||
for (int i = 0; i < existing; i++) {
|
||||
new_names[i] = rt->field_names[i];
|
||||
new_types[i] = rt->field_types[i];
|
||||
}
|
||||
new_names[existing] = cbm_arena_strdup(ctx->arena, field_name);
|
||||
new_types[existing] = field_type;
|
||||
new_names[new_count] = NULL;
|
||||
new_types[new_count] = NULL;
|
||||
rt->field_names = new_names;
|
||||
rt->field_types = new_types;
|
||||
}
|
||||
|
||||
/* ── expression typing ────────────────────────────────────────── */
|
||||
|
||||
/* Map a tree-sitter Python literal node to a BUILTIN type. Returns NULL if
|
||||
@@ -387,6 +479,10 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
|
||||
obj_type->data.named.qualified_name, attr_name);
|
||||
if (f) return py_func_return_type_recv(ctx, f->qualified_name,
|
||||
obj_type->data.named.qualified_name);
|
||||
// Field fallback: instance attributes from __init__'s self.x = expr.
|
||||
const CBMType* field = py_lookup_field(ctx,
|
||||
obj_type->data.named.qualified_name, attr_name);
|
||||
if (field) return field;
|
||||
}
|
||||
if (obj_type->kind == CBM_TYPE_UNION) {
|
||||
int matches = 0;
|
||||
@@ -406,6 +502,17 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
|
||||
if (matches == 1 && hit) {
|
||||
return py_func_return_type_recv(ctx, hit->qualified_name, hit_recv);
|
||||
}
|
||||
// Field fallback for UNION: same single-match heuristic.
|
||||
int field_matches = 0;
|
||||
const CBMType* field_hit = NULL;
|
||||
for (int i = 0; i < obj_type->data.union_type.count; i++) {
|
||||
const CBMType* m = obj_type->data.union_type.members[i];
|
||||
if (!m || m->kind != CBM_TYPE_NAMED) continue;
|
||||
const CBMType* fld = py_lookup_field(ctx,
|
||||
m->data.named.qualified_name, attr_name);
|
||||
if (fld) { field_matches++; field_hit = fld; }
|
||||
}
|
||||
if (field_matches == 1 && field_hit) return field_hit;
|
||||
}
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
@@ -551,14 +658,11 @@ static void py_process_statement(PyLSPContext* ctx, TSNode node) {
|
||||
: py_eval_expr_type(ctx, right);
|
||||
|
||||
// Annotated assignment: x: T = expr — annotation wins.
|
||||
if (!ts_node_is_null(ann)) {
|
||||
bool has_annotation = !ts_node_is_null(ann);
|
||||
if (has_annotation) {
|
||||
char* ann_text = py_node_text(ctx, ann);
|
||||
if (ann_text && ann_text[0]) {
|
||||
const CBMType* ann_t = cbm_scope_lookup(ctx->current_scope, ann_text);
|
||||
if (cbm_type_is_unknown(ann_t)) {
|
||||
ann_t = cbm_type_named(ctx->arena, ann_text);
|
||||
}
|
||||
rhs_type = ann_t;
|
||||
rhs_type = py_resolve_annotation(ctx, ann_text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,8 +672,22 @@ static void py_process_statement(PyLSPContext* ctx, TSNode node) {
|
||||
char* name = py_node_text(ctx, left);
|
||||
if (name) cbm_scope_bind(ctx->current_scope, name, rhs_type);
|
||||
} else if (strcmp(lk, "attribute") == 0) {
|
||||
// self.x = expr — record on the enclosing class via a synthetic
|
||||
// type entry. For now we just don't bind locally.
|
||||
// self.x = expr — record as instance field on the enclosing
|
||||
// class. Effective inside any method that resolves obj.x where
|
||||
// obj is an instance of the class.
|
||||
TSNode obj = ts_node_child_by_field_name(left, "object", 6);
|
||||
TSNode attr = ts_node_child_by_field_name(left, "attribute", 9);
|
||||
if (!ts_node_is_null(obj) && !ts_node_is_null(attr) &&
|
||||
strcmp(ts_node_type(obj), "identifier") == 0 &&
|
||||
ctx->enclosing_class_qn) {
|
||||
char* obj_name = py_node_text(ctx, obj);
|
||||
char* attr_name = py_node_text(ctx, attr);
|
||||
if (obj_name && attr_name &&
|
||||
(strcmp(obj_name, "self") == 0 || strcmp(obj_name, "cls") == 0)) {
|
||||
py_register_instance_field(ctx, ctx->enclosing_class_qn,
|
||||
attr_name, rhs_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1484,6 +1602,14 @@ static void py_process_function(PyLSPContext* ctx, TSNode func_node, const char*
|
||||
ctx->enclosing_func_qn = prev_func;
|
||||
}
|
||||
|
||||
/* Return true iff func_node's name is __init__ or __post_init__. */
|
||||
static bool py_is_init_method(PyLSPContext* ctx, TSNode func_node) {
|
||||
TSNode name = ts_node_child_by_field_name(func_node, "name", 4);
|
||||
if (ts_node_is_null(name)) return false;
|
||||
char* nm = py_node_text(ctx, name);
|
||||
return nm && (strcmp(nm, "__init__") == 0 || strcmp(nm, "__post_init__") == 0);
|
||||
}
|
||||
|
||||
static void py_process_class(PyLSPContext* ctx, TSNode class_node) {
|
||||
TSNode name_node = ts_node_child_by_field_name(class_node, "name", 4);
|
||||
if (ts_node_is_null(name_node)) return;
|
||||
@@ -1496,19 +1622,68 @@ static void py_process_class(PyLSPContext* ctx, TSNode class_node) {
|
||||
TSNode body = ts_node_child_by_field_name(class_node, "body", 4);
|
||||
if (!ts_node_is_null(body)) {
|
||||
uint32_t bnc = ts_node_named_child_count(body);
|
||||
// First pass: process class-level annotated assignments (PEP 526
|
||||
// class-body field annotations like `x: int`) and dunder __init__
|
||||
// methods so fields are registered before sibling methods that
|
||||
// reference them.
|
||||
for (uint32_t i = 0; i < bnc; i++) {
|
||||
TSNode c = ts_node_named_child(body, i);
|
||||
const char* ck = ts_node_type(c);
|
||||
if (strcmp(ck, "expression_statement") == 0 &&
|
||||
ts_node_named_child_count(c) > 0) {
|
||||
TSNode inner = ts_node_named_child(c, 0);
|
||||
const char* ik = ts_node_type(inner);
|
||||
// class C: x: int = 1 → bind x as field
|
||||
if (strcmp(ik, "assignment") == 0) {
|
||||
TSNode left = ts_node_child_by_field_name(inner, "left", 4);
|
||||
TSNode ann = ts_node_child_by_field_name(inner, "type", 4);
|
||||
if (!ts_node_is_null(left) && !ts_node_is_null(ann) &&
|
||||
strcmp(ts_node_type(left), "identifier") == 0) {
|
||||
char* fname = py_node_text(ctx, left);
|
||||
char* atext = py_node_text(ctx, ann);
|
||||
if (fname && atext) {
|
||||
const CBMType* ft = py_resolve_annotation(ctx, atext);
|
||||
py_register_instance_field(ctx,
|
||||
ctx->enclosing_class_qn, fname, ft);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: __init__ (so self.x assignments populate fields).
|
||||
for (uint32_t i = 0; i < bnc; i++) {
|
||||
TSNode c = ts_node_named_child(body, i);
|
||||
const char* ck = ts_node_type(c);
|
||||
TSNode fn_node = c;
|
||||
bool is_decorated = strcmp(ck, "decorated_definition") == 0;
|
||||
if (is_decorated) {
|
||||
fn_node = ts_node_child_by_field_name(c, "definition", 10);
|
||||
if (ts_node_is_null(fn_node) ||
|
||||
strcmp(ts_node_type(fn_node), "function_definition") != 0) continue;
|
||||
} else if (strcmp(ck, "function_definition") != 0) {
|
||||
continue;
|
||||
}
|
||||
if (py_is_init_method(ctx, fn_node)) {
|
||||
py_process_function(ctx, fn_node, ctx->enclosing_class_qn);
|
||||
}
|
||||
}
|
||||
// Third pass: every other method (and nested classes).
|
||||
for (uint32_t i = 0; i < bnc; i++) {
|
||||
TSNode c = ts_node_named_child(body, i);
|
||||
const char* ck = ts_node_type(c);
|
||||
if (strcmp(ck, "function_definition") == 0) {
|
||||
py_process_function(ctx, c, ctx->enclosing_class_qn);
|
||||
if (!py_is_init_method(ctx, c)) {
|
||||
py_process_function(ctx, c, ctx->enclosing_class_qn);
|
||||
}
|
||||
} else if (strcmp(ck, "decorated_definition") == 0) {
|
||||
TSNode def = ts_node_child_by_field_name(c, "definition", 10);
|
||||
if (!ts_node_is_null(def) &&
|
||||
strcmp(ts_node_type(def), "function_definition") == 0) {
|
||||
strcmp(ts_node_type(def), "function_definition") == 0 &&
|
||||
!py_is_init_method(ctx, def)) {
|
||||
py_process_function(ctx, def, ctx->enclosing_class_qn);
|
||||
}
|
||||
} else if (strcmp(ck, "class_definition") == 0) {
|
||||
py_process_class(ctx, c); // nested class
|
||||
py_process_class(ctx, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,6 +1038,61 @@ TEST(pylsp_round3_async_await_pass_through) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Round 4 — instance attribute typing ──────────────────────── */
|
||||
|
||||
TEST(pylsp_round4_instance_attribute_init) {
|
||||
/* class C:
|
||||
* def __init__(self, cfg: Config):
|
||||
* self.cfg = cfg # field cfg : Config
|
||||
* def use(self):
|
||||
* return self.cfg.display() # resolves through field type */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Config:\n"
|
||||
" def display(self):\n"
|
||||
" return 1\n"
|
||||
"class App:\n"
|
||||
" def __init__(self, cfg: Config):\n"
|
||||
" self.cfg = cfg\n"
|
||||
" def use(self):\n"
|
||||
" self.cfg.display()\n");
|
||||
/* Note: extra indentation simulates a body block; real test
|
||||
* mirrors realistic Python code. */
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* The above source has bad indent — replace with proper test source. */
|
||||
cbm_free_result(r);
|
||||
r = extract_py(
|
||||
"class Config:\n"
|
||||
" def display(self):\n"
|
||||
" return 1\n"
|
||||
"class App:\n"
|
||||
" def __init__(self, cfg: Config):\n"
|
||||
" self.cfg = cfg\n"
|
||||
" def use(self):\n"
|
||||
" return self.cfg.display()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "display"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_round4_instance_attribute_class_annotation) {
|
||||
/* class C:
|
||||
* x: Foo
|
||||
* def use(self): return self.x.method() */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def method(self):\n"
|
||||
" return 1\n"
|
||||
"class C:\n"
|
||||
" x: Foo\n"
|
||||
" def use(self):\n"
|
||||
" return self.x.method()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(py_lsp) {
|
||||
@@ -1101,4 +1156,7 @@ SUITE(py_lsp) {
|
||||
/* Round 3 — match/case + async */
|
||||
RUN_TEST(pylsp_round3_match_case_class_pattern);
|
||||
RUN_TEST(pylsp_round3_async_await_pass_through);
|
||||
/* Round 4 — instance attribute typing */
|
||||
RUN_TEST(pylsp_round4_instance_attribute_init);
|
||||
RUN_TEST(pylsp_round4_instance_attribute_class_annotation);
|
||||
}
|
||||
Reference in new issue
Block a user