Skip to content

Commit 2910e28

Browse files
committed
feat(lsp): resolve cross-file base classes for the Python/TS cross-LSP
`CBMDefinition.base_classes` carries the SOURCE SPELLING of each base ("Base", "django.db.Model"): extraction strips keywords and generic arguments, but it cannot know where the name is declared. The Python and TS cross-file registrars, however, consume `CBMLSPDef.embedded_types` as fully-qualified names — py_lookup_attribute and ts_lookup_member feed each entry straight into cbm_registry_lookup_type. An unqualified spelling therefore matched nothing declared in ANOTHER file. `class Child(Base)` in child.py never saw Base in base.py, so a call to an inherited method through a typed receiver found no member and fell through to the weak textual cascade, where the receiver-aware guard (DeusData#592/DeusData#606) correctly kills it. The member-lookup walk over embedded_types was already there in both languages; only the names it walked were unusable across files. cbm_pxc_collect_all_defs now resolves each base spelling to a project QN using exactly the inputs pass_semantic uses to draw its INHERITS edge: the project registry, the declaring module, and the file's import map. Two properties follow. The LSP's inheritance view is the same relation the graph records, so the two cannot diverge. And the binding is import- or same-module-backed rather than a short-name guess, so the CALLS edge it enables is a supported fact that the weak-member guard keeps. Weak registry strategies (suffix_match / unique_name / field_type_hint / fuzzy) are rejected for bases via an explicit drop-list: a base bound because some project type happens to share its name is precisely the fabricated relation DeusData#606 removed, and inheritance multiplies it — every inherited member of the wrong base would become a callable target. An unresolved base keeps its raw spelling and the behaviour that predates this change, so stdlib and third-party bases are unaffected. Scoped to the languages whose registrars read embedded_types as QNs (Python, JS/TS/TSX). Go, JVM, C#, C++ and Rust already qualify their own embedded types and keep the raw join. Cost is O(defs) hash lookups plus ONE import map per file: no per-call- site hierarchy walk, no registry scan, no per-file registry rebuild. The complexity guard stays linear (nodes 1.93, edges 2.07, per-file defs 2.00 against a files x corpus coupling of ~4). Tests: - ts/S6 flips to its own documented condition: was `calls == 0` as a tripwire recording the gap, now asserts calls >= 1 AND an lsp_ts_* strategy. Measured: strategy=lsp_ts_method, confidence 0.95. - python/S6b is new and covers what python/S6 structurally cannot: S6's `def run(c)` parameter is un-annotated, so no receiver type exists to inherit through and any binding there is a guess about a name (it resolves via unique_name at 0.75). S6b supplies the receiver three ways — annotated parameter, `self` in the subclass, constructor result — and asserts INHERITS >= 1 plus an lsp_* strategy. Revert-check: all three fall back to unique_name at 0.75 without this change, lsp_method at 0.90 with it. - Both rows assert the STRATEGY, not just the edge count: a short-name guess and a resolved inherited member produce the same count in a two-file fixture, and only the strategy tells them apart. - Corrects python/S6's stale comment citing a base_classes extraction bug; extraction_inheritance is green for Python and TS, and base_classes now holds clean names. macOS full suite 7690 passed / 0 failed / 8 skipped (140 suites); make lint-ci clean. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 5d96377 commit 2910e28

6 files changed

Lines changed: 295 additions & 45 deletions

File tree

‎src/pipeline/pass_lsp_cross.c‎

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,114 @@ static const char *pxc_join_pipe(CBMArena *arena, const char *const *items) {
150150
return buf;
151151
}
152152

153+
/* ── Cross-file base-class QN resolution ──────────────────────────
154+
*
155+
* CBMDefinition.base_classes carries the SOURCE SPELLING of each base
156+
* ("Base", "django.db.Model", "React.Component"): extraction strips
157+
* keywords and generic arguments, but it cannot know WHERE the name is
158+
* declared. The Python and TS cross-file registrars, however, consume
159+
* CBMLSPDef.embedded_types as fully-qualified names — py_lookup_attribute
160+
* and ts_lookup_member feed each entry straight into
161+
* cbm_registry_lookup_type. An unqualified spelling therefore matched
162+
* nothing declared in ANOTHER file: `class Child(Base)` in child.py never
163+
* saw Base in base.py, so a call to an inherited method through a typed
164+
* receiver had no member to find and fell through to the weak textual
165+
* cascade (where the #592/#606 guard correctly kills it).
166+
*
167+
* Resolve each base name ONCE per definition here, from exactly the
168+
* inputs pass_semantic uses to draw its INHERITS edge: the project
169+
* registry, the declaring module, and the file's import map. Two
170+
* properties follow. The LSP's inheritance view is the same relation the
171+
* graph records, so the two cannot diverge. And the binding is
172+
* import- or same-module-backed, not a short-name guess, so the CALLS
173+
* edge it enables is a supported fact that the weak-member guard keeps.
174+
*
175+
* Cost: O(defs) hash lookups + ONE import map per file. No per-call-site
176+
* hierarchy walk, no registry scan, no per-file registry rebuild.
177+
*/
178+
static bool pxc_lang_resolves_base_qns(CBMLanguage lang) {
179+
switch (lang) {
180+
case CBM_LANG_PYTHON:
181+
case CBM_LANG_JAVASCRIPT:
182+
case CBM_LANG_TYPESCRIPT:
183+
case CBM_LANG_TSX:
184+
return true;
185+
default:
186+
/* Go / JVM / C# / C++ / Rust registrars qualify their own embedded
187+
* types already (struct embedding, parent_class chains, impl-trait
188+
* provenance). Re-resolving here would fight those paths, so they
189+
* keep the raw join. */
190+
return false;
191+
}
192+
}
193+
194+
/* True for the registry strategies that are pure short-name guesses.
195+
* EXPLICIT drop-list, mirroring cbm_tsjs_suppress_weak_method_match: a
196+
* base class bound by "some project type happens to share this name" is
197+
* exactly the fabricated relation #606 removed, and inheritance
198+
* multiplies it — every inherited member of the wrong base would become
199+
* a callable target. Import-, module- and suffix-aware strategies are
200+
* kept; anything unresolved simply retains its source spelling and the
201+
* behaviour that predates this resolution. */
202+
static bool pxc_base_strategy_is_weak(const char *strategy) {
203+
if (!strategy || !strategy[0]) {
204+
return true;
205+
}
206+
return strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "unique_name") == 0 ||
207+
strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0;
208+
}
209+
210+
/* Resolve one base-class spelling to a project QN. Mirrors
211+
* pass_semantic.c::resolve_as_class — same registry, same type-like veto —
212+
* then additionally rejects weak short-name strategies (see above).
213+
* Returns NULL when the base is not a confidently-known project type;
214+
* stdlib and third-party bases land here and keep their raw spelling. */
215+
static const char *pxc_resolve_base_qn(const cbm_registry_t *reg, const char *raw,
216+
const char *module_qn, const char **imp_keys,
217+
const char **imp_vals, int imp_count) {
218+
if (!reg || !raw || !raw[0]) {
219+
return NULL;
220+
}
221+
cbm_resolution_t res = cbm_registry_resolve(reg, raw, module_qn, imp_keys, imp_vals, imp_count);
222+
if (!res.qualified_name || !res.qualified_name[0]) {
223+
return NULL;
224+
}
225+
if (pxc_base_strategy_is_weak(res.strategy)) {
226+
return NULL;
227+
}
228+
if (!cbm_label_is_type_like(cbm_registry_label_of(reg, res.qualified_name))) {
229+
return NULL;
230+
}
231+
return res.qualified_name;
232+
}
233+
234+
/* pxc_join_pipe over base_classes, substituting each resolved QN for its
235+
* source spelling. Unresolved entries pass through verbatim so a base the
236+
* registry does not know keeps working exactly as before. */
237+
static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases,
238+
const cbm_registry_t *reg, const char *module_qn,
239+
const char **imp_keys, const char **imp_vals, int imp_count) {
240+
if (!bases || !bases[0]) {
241+
return NULL;
242+
}
243+
int count = 0;
244+
while (bases[count]) {
245+
count++;
246+
}
247+
const char **resolved =
248+
(const char **)cbm_arena_alloc(arena, (size_t)(count + 1) * sizeof(const char *));
249+
if (!resolved) {
250+
return pxc_join_pipe(arena, bases);
251+
}
252+
for (int i = 0; i < count; i++) {
253+
const char *qn =
254+
pxc_resolve_base_qn(reg, bases[i], module_qn, imp_keys, imp_vals, imp_count);
255+
resolved[i] = qn ? qn : bases[i];
256+
}
257+
resolved[count] = NULL;
258+
return pxc_join_pipe(arena, resolved);
259+
}
260+
153261
static bool pxc_is_jvm_lang(CBMLanguage lang);
154262

155263
static const char *pxc_last_component(const char *qn) {
@@ -262,7 +370,9 @@ static const char *pxc_qn_leaf(const char *name) {
262370
* to skip (unsupported label or missing required field). dst gets borrowed
263371
* pointers into src and into `arena` for synthesised composites. */
264372
static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const char *module_qn,
265-
const char *namespace_name, CBMLanguage lang, CBMLSPDef *dst) {
373+
const char *namespace_name, CBMLanguage lang, CBMLSPDef *dst,
374+
const cbm_registry_t *reg, const char **imp_keys,
375+
const char **imp_vals, int imp_count) {
266376
const char *label = pxc_map_label(src->label);
267377
if (!label || !src->qualified_name || !src->name)
268378
return -1;
@@ -283,7 +393,13 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch
283393
* for multi-return languages (Go); single-return languages just see one
284394
* piece, which is what's already stored. */
285395
dst->return_types = src->return_type;
286-
dst->embedded_types = pxc_join_pipe(arena, src->base_classes);
396+
/* Languages whose cross registrars read embedded_types as QNs get their
397+
* bases resolved against the project registry; everyone else keeps the
398+
* raw source spelling their own registrar already knows how to handle. */
399+
dst->embedded_types = (reg && pxc_lang_resolves_base_qns(lang))
400+
? pxc_join_base_qns(arena, src->base_classes, reg, module_qn,
401+
imp_keys, imp_vals, imp_count)
402+
: pxc_join_pipe(arena, src->base_classes);
287403
dst->signature_param_types = src->signature_param_types;
288404
dst->signature_param_count = src->signature_param_count;
289405
dst->lang = lang;
@@ -323,9 +439,10 @@ static int pxc_build_rust_impl_relation(CBMArena *arena, const CBMImplTrait *imp
323439
/* Collect a project-wide CBMLSPDef[] from all cached results. Returns a
324440
* malloc'd array (caller frees) of length *out_count. String fields are
325441
* borrowed from cache[i]->arena and from def_modules[i] (also borrowed). */
326-
CBMLSPDef *cbm_pxc_collect_all_defs(CBMFileResult **cache, const cbm_file_info_t *files,
327-
int file_count, const char *project_name, char **def_modules,
328-
int *out_count, int *out_def_starts) {
442+
CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMFileResult **cache,
443+
const cbm_file_info_t *files, int file_count,
444+
const char *project_name, char **def_modules, int *out_count,
445+
int *out_def_starts) {
329446
int total = 0;
330447
for (int i = 0; i < file_count; i++) {
331448
if (cache[i]) {
@@ -369,12 +486,30 @@ CBMLSPDef *cbm_pxc_collect_all_defs(CBMFileResult **cache, const cbm_file_info_t
369486
cache[fi]->namespace_name = namespace_name;
370487
}
371488
}
489+
/* One import map per FILE (not per def, and not per base name): the
490+
* cross-file base-class resolution below needs the same local-name →
491+
* import-QN view pass_semantic uses. Built only for the languages
492+
* that consume resolved base QNs, and only when a caller supplied the
493+
* pipeline context (the surface-probe path passes NULL and keeps the
494+
* raw spelling). */
495+
const cbm_registry_t *base_reg = NULL;
496+
const char **imp_keys = NULL;
497+
const char **imp_vals = NULL;
498+
int imp_count = 0;
499+
if (ctx && ctx->registry && pxc_lang_resolves_base_qns(files[fi].language)) {
500+
base_reg = ctx->registry;
501+
cbm_pxc_build_import_map(ctx->gbuf, project_name, files[fi].rel_path,
502+
files[fi].language, cache[fi], &imp_keys, &imp_vals,
503+
&imp_count);
504+
}
372505
for (int di = 0; di < cache[fi]->defs.count; di++) {
373506
if (pxc_build_lsp_def(&cache[fi]->arena, &cache[fi]->defs.items[di], def_modules[fi],
374-
namespace_name, files[fi].language, &defs[idx]) == 0) {
507+
namespace_name, files[fi].language, &defs[idx], base_reg,
508+
imp_keys, imp_vals, imp_count) == 0) {
375509
idx++;
376510
}
377511
}
512+
cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count); /* NULL-safe */
378513
if (files[fi].language == CBM_LANG_RUST) {
379514
for (int ii = 0; ii < cache[fi]->impl_traits.count; ii++) {
380515
if (pxc_build_rust_impl_relation(
@@ -1284,7 +1419,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *
12841419

12851420
int def_count = 0;
12861421
int *def_starts = (int *)calloc((size_t)file_count + 1, sizeof(int));
1287-
CBMLSPDef *all_defs = cbm_pxc_collect_all_defs(cache, files, file_count, ctx->project_name,
1422+
CBMLSPDef *all_defs = cbm_pxc_collect_all_defs(ctx, cache, files, file_count, ctx->project_name,
12881423
def_modules, &def_count, def_starts);
12891424
/* Same seam as the parallel driver: serialize per-file surfaces while the
12901425
* result cache is alive. Failure only degrades to a full rebuild on the

‎src/pipeline/pass_lsp_cross.h‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,19 @@ bool cbm_pxc_has_cross_lsp(CBMLanguage lang);
5454
* receives per-file prefix offsets: file i's defs occupy
5555
* [out_def_starts[i], out_def_starts[i+1]) — the LSP-surface serializer
5656
* needs the per-file slices, which the flat array does not otherwise
57-
* record. */
58-
CBMLSPDef *cbm_pxc_collect_all_defs(CBMFileResult **cache, const cbm_file_info_t *files,
59-
int file_count, const char *project_name, char **def_modules,
60-
int *out_count, int *out_def_starts);
57+
* record.
58+
*
59+
* `ctx` (nullable) enables cross-file base-class resolution: for the
60+
* languages whose cross registrars read embedded_types as qualified names
61+
* (Python, JS/TS/TSX), every CBMDefinition.base_classes spelling is resolved
62+
* to a project QN through ctx->registry plus the file's import map — the same
63+
* inputs pass_semantic uses to draw its INHERITS edge, so the LSP's
64+
* inheritance view and the graph's cannot diverge. Pass NULL to keep the raw
65+
* source spelling (surface-probe paths that build no registry). */
66+
CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMFileResult **cache,
67+
const cbm_file_info_t *files, int file_count,
68+
const char *project_name, char **def_modules, int *out_count,
69+
int *out_def_starts);
6170

6271
/* Detect TS dialect flags from a relative path. */
6372
void cbm_pxc_ts_modes(CBMLanguage lang, const char *rel_path, bool *out_js, bool *out_jsx,

‎src/pipeline/pipeline.c‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1253,7 +1253,7 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx,
12531253
def_modules = (char **)calloc((size_t)file_count, sizeof(char *));
12541254
def_starts = (int *)calloc((size_t)file_count + 1, sizeof(int));
12551255
all_defs = def_modules
1256-
? cbm_pxc_collect_all_defs(cache, files, file_count, ctx->project_name,
1256+
? cbm_pxc_collect_all_defs(ctx, cache, files, file_count, ctx->project_name,
12571257
def_modules, &def_count, def_starts)
12581258
: NULL;
12591259
}

‎src/pipeline/pipeline_incremental.c‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,7 +1285,7 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed
12851285
int fresh_count = 0;
12861286
CBMLSPDef *fresh_defs =
12871287
def_modules && def_starts
1288-
? cbm_pxc_collect_all_defs(cache, changed_files, ci, ctx->project_name,
1288+
? cbm_pxc_collect_all_defs(ctx, cache, changed_files, ci, ctx->project_name,
12891289
def_modules, &fresh_count, def_starts)
12901290
: NULL;
12911291
if ((fresh_defs || fresh_count == 0) && def_starts &&
@@ -1588,8 +1588,8 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project,
15881588
int def_count = 0;
15891589
CBMLSPDef *defs = NULL;
15901590
if (def_modules && def_starts) {
1591-
defs = cbm_pxc_collect_all_defs(cache, probe_files, probe_count, project, def_modules,
1592-
&def_count, def_starts);
1591+
defs = cbm_pxc_collect_all_defs(NULL, cache, probe_files, probe_count, project,
1592+
def_modules, &def_count, def_starts);
15931593
rc = cbm_lsp_surface_build_rows(project, cache, probe_files, probe_count, defs,
15941594
def_starts, out_rows, out_count);
15951595
} else {

0 commit comments

Comments
 (0)