From 2910e2842f912be2dbb3a8c7fbcd939881cc6616 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 12:39:53 +0200 Subject: [PATCH] feat(lsp): resolve cross-file base classes for the Python/TS cross-LSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 (#592/#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 #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 --- src/pipeline/pass_lsp_cross.c | 149 +++++++++++++++++++++++-- src/pipeline/pass_lsp_cross.h | 17 ++- src/pipeline/pipeline.c | 2 +- src/pipeline/pipeline_incremental.c | 6 +- tests/test_lsp_resolution_probe.c | 162 +++++++++++++++++++++++----- tests/test_parallel.c | 4 +- 6 files changed, 295 insertions(+), 45 deletions(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 684554455..ab92301e8 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -150,6 +150,114 @@ static const char *pxc_join_pipe(CBMArena *arena, const char *const *items) { return buf; } +/* ── Cross-file base-class QN resolution ────────────────────────── + * + * CBMDefinition.base_classes carries the SOURCE SPELLING of each base + * ("Base", "django.db.Model", "React.Component"): 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 had no member to find and fell through to the weak textual + * cascade (where the #592/#606 guard correctly kills it). + * + * Resolve each base name ONCE per definition here, from 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, not a short-name guess, so the CALLS + * edge it enables is a supported fact that the weak-member guard keeps. + * + * Cost: O(defs) hash lookups + ONE import map per file. No per-call-site + * hierarchy walk, no registry scan, no per-file registry rebuild. + */ +static bool pxc_lang_resolves_base_qns(CBMLanguage lang) { + switch (lang) { + case CBM_LANG_PYTHON: + case CBM_LANG_JAVASCRIPT: + case CBM_LANG_TYPESCRIPT: + case CBM_LANG_TSX: + return true; + default: + /* Go / JVM / C# / C++ / Rust registrars qualify their own embedded + * types already (struct embedding, parent_class chains, impl-trait + * provenance). Re-resolving here would fight those paths, so they + * keep the raw join. */ + return false; + } +} + +/* True for the registry strategies that are pure short-name guesses. + * EXPLICIT drop-list, mirroring cbm_tsjs_suppress_weak_method_match: a + * base class bound by "some project type happens to share this name" is + * exactly the fabricated relation #606 removed, and inheritance + * multiplies it — every inherited member of the wrong base would become + * a callable target. Import-, module- and suffix-aware strategies are + * kept; anything unresolved simply retains its source spelling and the + * behaviour that predates this resolution. */ +static bool pxc_base_strategy_is_weak(const char *strategy) { + if (!strategy || !strategy[0]) { + return true; + } + return strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "unique_name") == 0 || + strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0; +} + +/* Resolve one base-class spelling to a project QN. Mirrors + * pass_semantic.c::resolve_as_class — same registry, same type-like veto — + * then additionally rejects weak short-name strategies (see above). + * Returns NULL when the base is not a confidently-known project type; + * stdlib and third-party bases land here and keep their raw spelling. */ +static const char *pxc_resolve_base_qn(const cbm_registry_t *reg, const char *raw, + const char *module_qn, const char **imp_keys, + const char **imp_vals, int imp_count) { + if (!reg || !raw || !raw[0]) { + return NULL; + } + cbm_resolution_t res = cbm_registry_resolve(reg, raw, module_qn, imp_keys, imp_vals, imp_count); + if (!res.qualified_name || !res.qualified_name[0]) { + return NULL; + } + if (pxc_base_strategy_is_weak(res.strategy)) { + return NULL; + } + if (!cbm_label_is_type_like(cbm_registry_label_of(reg, res.qualified_name))) { + return NULL; + } + return res.qualified_name; +} + +/* pxc_join_pipe over base_classes, substituting each resolved QN for its + * source spelling. Unresolved entries pass through verbatim so a base the + * registry does not know keeps working exactly as before. */ +static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, + const cbm_registry_t *reg, const char *module_qn, + const char **imp_keys, const char **imp_vals, int imp_count) { + if (!bases || !bases[0]) { + return NULL; + } + int count = 0; + while (bases[count]) { + count++; + } + const char **resolved = + (const char **)cbm_arena_alloc(arena, (size_t)(count + 1) * sizeof(const char *)); + if (!resolved) { + return pxc_join_pipe(arena, bases); + } + for (int i = 0; i < count; i++) { + const char *qn = + pxc_resolve_base_qn(reg, bases[i], module_qn, imp_keys, imp_vals, imp_count); + resolved[i] = qn ? qn : bases[i]; + } + resolved[count] = NULL; + return pxc_join_pipe(arena, resolved); +} + static bool pxc_is_jvm_lang(CBMLanguage lang); static const char *pxc_last_component(const char *qn) { @@ -262,7 +370,9 @@ static const char *pxc_qn_leaf(const char *name) { * to skip (unsupported label or missing required field). dst gets borrowed * pointers into src and into `arena` for synthesised composites. */ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const char *module_qn, - const char *namespace_name, CBMLanguage lang, CBMLSPDef *dst) { + const char *namespace_name, CBMLanguage lang, CBMLSPDef *dst, + const cbm_registry_t *reg, const char **imp_keys, + const char **imp_vals, int imp_count) { const char *label = pxc_map_label(src->label); if (!label || !src->qualified_name || !src->name) return -1; @@ -283,7 +393,13 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch * for multi-return languages (Go); single-return languages just see one * piece, which is what's already stored. */ dst->return_types = src->return_type; - dst->embedded_types = pxc_join_pipe(arena, src->base_classes); + /* Languages whose cross registrars read embedded_types as QNs get their + * bases resolved against the project registry; everyone else keeps the + * raw source spelling their own registrar already knows how to handle. */ + dst->embedded_types = (reg && pxc_lang_resolves_base_qns(lang)) + ? pxc_join_base_qns(arena, src->base_classes, reg, module_qn, + imp_keys, imp_vals, imp_count) + : pxc_join_pipe(arena, src->base_classes); dst->signature_param_types = src->signature_param_types; dst->signature_param_count = src->signature_param_count; dst->lang = lang; @@ -323,9 +439,10 @@ static int pxc_build_rust_impl_relation(CBMArena *arena, const CBMImplTrait *imp /* Collect a project-wide CBMLSPDef[] from all cached results. Returns a * malloc'd array (caller frees) of length *out_count. String fields are * borrowed from cache[i]->arena and from def_modules[i] (also borrowed). */ -CBMLSPDef *cbm_pxc_collect_all_defs(CBMFileResult **cache, const cbm_file_info_t *files, - int file_count, const char *project_name, char **def_modules, - int *out_count, int *out_def_starts) { +CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMFileResult **cache, + const cbm_file_info_t *files, int file_count, + const char *project_name, char **def_modules, int *out_count, + int *out_def_starts) { int total = 0; for (int i = 0; i < file_count; i++) { if (cache[i]) { @@ -369,12 +486,30 @@ CBMLSPDef *cbm_pxc_collect_all_defs(CBMFileResult **cache, const cbm_file_info_t cache[fi]->namespace_name = namespace_name; } } + /* One import map per FILE (not per def, and not per base name): the + * cross-file base-class resolution below needs the same local-name → + * import-QN view pass_semantic uses. Built only for the languages + * that consume resolved base QNs, and only when a caller supplied the + * pipeline context (the surface-probe path passes NULL and keeps the + * raw spelling). */ + const cbm_registry_t *base_reg = NULL; + const char **imp_keys = NULL; + const char **imp_vals = NULL; + int imp_count = 0; + if (ctx && ctx->registry && pxc_lang_resolves_base_qns(files[fi].language)) { + base_reg = ctx->registry; + cbm_pxc_build_import_map(ctx->gbuf, project_name, files[fi].rel_path, + files[fi].language, cache[fi], &imp_keys, &imp_vals, + &imp_count); + } for (int di = 0; di < cache[fi]->defs.count; di++) { if (pxc_build_lsp_def(&cache[fi]->arena, &cache[fi]->defs.items[di], def_modules[fi], - namespace_name, files[fi].language, &defs[idx]) == 0) { + namespace_name, files[fi].language, &defs[idx], base_reg, + imp_keys, imp_vals, imp_count) == 0) { idx++; } } + cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count); /* NULL-safe */ if (files[fi].language == CBM_LANG_RUST) { for (int ii = 0; ii < cache[fi]->impl_traits.count; ii++) { 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 * int def_count = 0; int *def_starts = (int *)calloc((size_t)file_count + 1, sizeof(int)); - CBMLSPDef *all_defs = cbm_pxc_collect_all_defs(cache, files, file_count, ctx->project_name, + CBMLSPDef *all_defs = cbm_pxc_collect_all_defs(ctx, cache, files, file_count, ctx->project_name, def_modules, &def_count, def_starts); /* Same seam as the parallel driver: serialize per-file surfaces while the * result cache is alive. Failure only degrades to a full rebuild on the diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index 7630d8c72..705b804cc 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -54,10 +54,19 @@ bool cbm_pxc_has_cross_lsp(CBMLanguage lang); * receives per-file prefix offsets: file i's defs occupy * [out_def_starts[i], out_def_starts[i+1]) — the LSP-surface serializer * needs the per-file slices, which the flat array does not otherwise - * record. */ -CBMLSPDef *cbm_pxc_collect_all_defs(CBMFileResult **cache, const cbm_file_info_t *files, - int file_count, const char *project_name, char **def_modules, - int *out_count, int *out_def_starts); + * record. + * + * `ctx` (nullable) enables cross-file base-class resolution: for the + * languages whose cross registrars read embedded_types as qualified names + * (Python, JS/TS/TSX), every CBMDefinition.base_classes spelling is resolved + * to a project QN through ctx->registry plus the file's import map — the same + * inputs pass_semantic uses to draw its INHERITS edge, so the LSP's + * inheritance view and the graph's cannot diverge. Pass NULL to keep the raw + * source spelling (surface-probe paths that build no registry). */ +CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMFileResult **cache, + const cbm_file_info_t *files, int file_count, + const char *project_name, char **def_modules, int *out_count, + int *out_def_starts); /* Detect TS dialect flags from a relative path. */ void cbm_pxc_ts_modes(CBMLanguage lang, const char *rel_path, bool *out_js, bool *out_jsx, diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index cc9190f52..938437549 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1253,7 +1253,7 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, def_modules = (char **)calloc((size_t)file_count, sizeof(char *)); def_starts = (int *)calloc((size_t)file_count + 1, sizeof(int)); all_defs = def_modules - ? cbm_pxc_collect_all_defs(cache, files, file_count, ctx->project_name, + ? cbm_pxc_collect_all_defs(ctx, cache, files, file_count, ctx->project_name, def_modules, &def_count, def_starts) : NULL; } diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 4ff89aa42..bf71a1f84 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1285,7 +1285,7 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed int fresh_count = 0; CBMLSPDef *fresh_defs = def_modules && def_starts - ? cbm_pxc_collect_all_defs(cache, changed_files, ci, ctx->project_name, + ? cbm_pxc_collect_all_defs(ctx, cache, changed_files, ci, ctx->project_name, def_modules, &fresh_count, def_starts) : NULL; if ((fresh_defs || fresh_count == 0) && def_starts && @@ -1588,8 +1588,8 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, int def_count = 0; CBMLSPDef *defs = NULL; if (def_modules && def_starts) { - defs = cbm_pxc_collect_all_defs(cache, probe_files, probe_count, project, def_modules, - &def_count, def_starts); + defs = cbm_pxc_collect_all_defs(NULL, cache, probe_files, probe_count, project, + def_modules, &def_count, def_starts); rc = cbm_lsp_surface_build_rows(project, cache, probe_files, probe_count, defs, def_starts, out_rows, out_count); } else { diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 7386710f9..91844f67e 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -222,6 +222,36 @@ static void lrp_diag(cbm_store_t *store, const char *project, const char *scenar fprintf(stderr, " [LRP] %s edges=[%s]\n", scenario, line[0] ? line : "(none)"); } +/* Count CALLS edges whose resolution strategy starts with `prefix`. + * + * The S6 inheritance scenarios need more than "an edge exists": a fixture + * small enough to have exactly one same-named method can be satisfied by a + * weak short-name guess that happens to be right, which is precisely the + * class of edge the receiver-aware guard (#592/#606) removes. Asserting an + * `lsp_*` strategy is what distinguishes inheritance RESOLUTION from a lucky + * coincidence. Returns -1 when the edges cannot be read at all. */ +static int lrp_count_calls_with_strategy(cbm_store_t *store, const char *project, + const char *prefix) { + if (!store) { + return -1; + } + cbm_edge_t *edges = NULL; + int n = 0; + if (cbm_store_find_edges_by_type(store, project, "CALLS", &edges, &n) != CBM_STORE_OK) { + return -1; + } + char needle[64]; + snprintf(needle, sizeof(needle), "\"strategy\":\"%s", prefix); + int hits = 0; + for (int i = 0; i < n; i++) { + if (edges[i].properties_json && strstr(edges[i].properties_json, needle)) { + hits++; + } + } + cbm_store_free_edges(edges, n); + return hits; +} + /* Index + assert CALLS >= floor; on failure emit diagnostics. * expect_green=true: ASSERT_TRUE (green guard). * expect_green=false: still asserts CALLS>=1 (the correct outcome), so RED @@ -952,21 +982,28 @@ TEST(lrp_python_s6_inherited_method) { {"child.py", "from .base import Base\n\n\nclass Child(Base):\n" " def extra(self):\n return 'extra'\n\n\n" "def run(c):\n return c.describe()\n"}}; - /* RED: c.describe() needs py_lsp_cross to walk `class Child(Base)`. Exactly - * the TS/S6 situation above, and resolved the same way (#1276). - * Until the receiver-aware guard landed, this scenario passed via a - * unique_name registry fallback — MEASURED on main before the guard: - * strategy=unique_name, cands=1, conf=0.7500. "describe" is the only symbol - * of that name in this 2-file fixture, so a weak short-name guess happened - * to be right. In a real repo the same guess binds an arbitrary same-named - * method (the false edges #1276 targets: accelerator.print() -> - * MockAccelerator.print), so the guard now suppresses weak member-call - * matches whose receiver is unresolved — here `c`, a bare parameter. - * c.describe() is the ONLY call in the fixture, so a correctly-suppressed - * run yields exactly zero CALLS. + /* MEASURED on main before the guard: strategy=unique_name, cands=1, + * conf=0.7500. "describe" is the only symbol of that name in this 2-file + * fixture, so a weak short-name guess happened to be right. In a real repo + * the same guess binds an arbitrary same-named method (the false edges + * #1276 targets: accelerator.print() -> MockAccelerator.print), so the + * Python weak-member guard suppresses member calls whose receiver is + * unresolved -- here `c`, a bare parameter. c.describe() is the ONLY call + * in the fixture, so a correctly-suppressed run yields exactly zero CALLS. + * + * This is NOT an inheritance case, despite the name, and it has no + * flip-back condition: `c` is un-annotated, so no receiver type exists to + * inherit THROUGH. Resolving cross-file base classes cannot recover this + * edge, because there is nothing to say `c` is a Child. Cross-file + * inheritance resolution is covered by S6b below, which supplies the + * receiver type this fixture deliberately withholds. + * + * The base_classes extraction bug this comment used to cite is FIXED -- + * extraction_inheritance is green for Python, and base_classes holds clean + * names with parens, kwargs and generic subscripts already stripped. + * * Tripwire: assert the store opened AND calls == 0 exactly, so an infra/DB - * failure cannot pass vacuously; flip to ASSERT calls >= 1 once py_lsp_cross - * resolves inheritance (lsp_py_*, a strategy the guard keeps). */ + * failure cannot pass vacuously. */ LRP_Proj lp; cbm_store_t *store = lrp_index(&lp, f, 2); ASSERT_NOT_NULL(store); @@ -976,6 +1013,61 @@ TEST(lrp_python_s6_inherited_method) { PASS(); } +/* S6b — Python inherited method call through a receiver whose type IS known. + * + * S6 above is deliberately kept as-is, but it cannot demonstrate inheritance + * resolution: its `def run(c)` parameter carries no annotation, so nothing in + * the program says `c` is a Child. Whatever binds c.describe() there is a + * guess about a name, not a fact about a type — which is why it resolves via + * unique_name and why the Python weak-member guard (#1324) is expected to take + * it to zero. + * + * This row supplies the receiver type three ways — annotated parameter, `self` + * inside the subclass, and a constructor result — so `Base.describe` is + * reachable ONLY by crossing the file boundary that `class Child(Base)` + * declares. Before cbm_pxc_collect_all_defs resolved base spellings to project + * QNs, py_lsp_cross received "Base" and looked it up as a qualified name, found + * nothing (Base lives in base.py), and all three fell through to unique_name at + * confidence 0.75. Asserting the lsp_* strategy is what separates the two + * outcomes: a short-name guess and a resolved inherited member produce the same + * edge COUNT here, and only the strategy tells them apart. */ +TEST(lrp_python_s6b_inherited_method_typed_receiver) { + static const LRP_File annotated[] = { + {"base.py", "class Base:\n def describe(self):\n return 'base'\n"}, + {"child.py", "from .base import Base\n\n\nclass Child(Base):\n" + " def extra(self):\n return 'extra'\n\n\n" + "def run(c: Child):\n return c.describe()\n"}}; + static const LRP_File self_attr[] = { + {"base.py", "class Base:\n def describe(self):\n return 'base'\n"}, + {"child.py", "from .base import Base\n\n\nclass Child(Base):\n" + " def go(self):\n return self.describe()\n"}}; + static const LRP_File constructed[] = { + {"base.py", "class Base:\n def describe(self):\n return 'base'\n"}, + {"child.py", "from .base import Base\n\n\nclass Child(Base):\n pass\n\n\n" + "def run():\n return Child().describe()\n"}}; + const LRP_File *shapes[3] = {annotated, self_attr, constructed}; + const char *names[3] = {"annotated_param", "self_attr", "constructor"}; + + for (int k = 0; k < 3; k++) { + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, shapes[k], 2); + ASSERT_NOT_NULL(store); + int inherits = cbm_store_count_edges_by_type(store, lp.project, "INHERITS"); + int lsp_calls = lrp_count_calls_with_strategy(store, lp.project, "lsp_"); + if (inherits < 1 || lsp_calls < 1) { + fprintf(stderr, " [LRP] python/S6b/%s FAIL inherits=%d lsp_calls=%d\n", names[k], + inherits, lsp_calls); + lrp_diag(store, lp.project, names[k]); + } + lrp_cleanup(&lp, store); + /* INHERITS proves the cross-file base really was resolved; the lsp_ + * strategy proves the CALL rode that relation instead of guessing. */ + ASSERT_TRUE(inherits >= 1); + ASSERT_TRUE(lsp_calls >= 1); + } + PASS(); +} + /* S7 — Python type-annotated call (PEP 484 type hint). */ TEST(lrp_python_s7_annotated_call) { static const LRP_File f[] = { @@ -1116,25 +1208,38 @@ TEST(lrp_ts_s6_inherited_method) { "import { Base } from './base';\n\n" "export class Child extends Base {\n extra(): string { return 'child'; }\n}\n\n" "export function run(c: Child): string { return c.describe(); }\n"}}; - /* RED: c.describe() needs ts_lsp_cross to walk `Child extends Base`. The - * INHERITS edge IS extracted (the probe diagnostic shows INHERITS=1); the - * gap is in ts_lsp_cross's cross-file inheritance RESOLUTION, not extraction. - * Until the receiver-aware guard (#592/#606) landed, this scenario passed via - * a unique_name registry fallback — "describe" is unique in this 2-file - * fixture, so a weak short-name guess happened to be right. In a real repo the - * same guess binds an arbitrary same-named method (the false edges #606 - * targets), so the guard now suppresses weak member-call matches with an - * unresolved receiver. c.describe() is the ONLY call in the fixture, so a - * correctly-suppressed run yields exactly zero CALLS. - * Tripwire: assert store opened AND calls == 0 exactly (an infra/DB failure - * must not vacuously pass); flip to ASSERT calls >= 1 once ts_lsp_cross - * resolves inheritance (lsp_ts_*, a strategy the guard keeps). */ + /* GREEN: c.describe() resolves through `Child extends Base` across files. + * + * This scenario was RED for as long as ts_lsp_cross received the base class + * as its SOURCE SPELLING ("Base") while ts_lookup_member fed embedded_types + * straight into cbm_registry_lookup_type as a QN — so a base declared in + * ANOTHER file never matched, the receiver stayed unresolved, and the only + * call in the fixture died in the weak textual cascade. Its predecessor + * "pass" came from a unique_name registry fallback ("describe" is unique + * here, so a weak short-name guess happened to be right); the receiver-aware + * guard (#592/#606) correctly suppressed that, which is why the tripwire + * asserted exactly zero. + * + * cbm_pxc_collect_all_defs now resolves each base spelling to a project QN + * through the import map + project registry — the same inputs pass_semantic + * uses for its INHERITS edge — so `Child extends Base` is a cross-file fact + * the TS LSP can walk. Asserting the lsp_ts_* strategy is the whole point: + * it proves the edge came from inheritance RESOLUTION and not from another + * lucky short-name guess. Keep BOTH assertions — calls>=1 alone would go + * green again for the wrong reason if the guard ever regressed. */ LRP_Proj lp; cbm_store_t *store = lrp_index(&lp, f, 2); ASSERT_NOT_NULL(store); int calls = cbm_store_count_edges_by_type(store, lp.project, "CALLS"); + int lsp_calls = lrp_count_calls_with_strategy(store, lp.project, "lsp_ts"); + if (calls < 1 || lsp_calls < 1) { + fprintf(stderr, " [LRP] ts/S6/inherited_method FAIL calls=%d lsp_ts_calls=%d\n", calls, + lsp_calls); + lrp_diag(store, lp.project, "ts/S6/inherited_method"); + } lrp_cleanup(&lp, store); - ASSERT_EQ(calls, 0); + ASSERT_TRUE(calls >= 1); + ASSERT_TRUE(lsp_calls >= 1); PASS(); } @@ -1800,6 +1905,7 @@ SUITE(lsp_resolution_probe) { RUN_TEST(lrp_python_s4_class_method); RUN_TEST(lrp_python_s5_chained); RUN_TEST(lrp_python_s6_inherited_method); + RUN_TEST(lrp_python_s6b_inherited_method_typed_receiver); RUN_TEST(lrp_python_s7_annotated_call); RUN_TEST(lrp_python_s8_field_type_hint); RUN_TEST(lrp_python_crossfile_dunder_carrier); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 2b94130e5..181c1f7ca 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -324,8 +324,8 @@ static cbm_gbuf_t *run_parallel_with_extract_opts_and_mutator( char **def_modules = (char **)calloc((size_t)file_count, sizeof(char *)); int def_count = 0; CBMLSPDef *all_defs = - def_modules ? cbm_pxc_collect_all_defs(result_cache, files, file_count, ctx.project_name, - def_modules, &def_count, NULL) + def_modules ? cbm_pxc_collect_all_defs(&ctx, result_cache, files, file_count, + ctx.project_name, def_modules, &def_count, NULL) : NULL; CBMModuleDefIndex *module_def_index = all_defs ? cbm_pxc_build_module_def_index(all_defs, def_count) : NULL;