From 9ef37e89bee23bcbf9a2268d8df0d1659a28bed2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 28 Jun 2026 04:34:54 +0200 Subject: [PATCH] fix(py-lsp): resolve dict-dispatch calls funcs["a"](v) to the dispatch-table target A Python call through a dict dispatch table, funcs["a"](v), produced no CALLS edge at all: the call's function field is a subscript, which the textual callee extractor did not handle, so no textual call existed for the py-LSP's lsp_dict_dispatch resolution to attach to (callable=0, strategy absent). Three coordinated changes, the same reason-field pattern used for func_ptr and ctor refs: - extract_calls: for a Python call whose function field is a subscript with an identifier base and a literal string key, emit the base identifier ("funcs") as the textual callee so a callable-sourced CALLS edge exists. Gated to that exact shape so other subscript calls (arr[i]()) are unaffected. - py_lsp: the dict-dispatch resolution stashes the subscript base identifier in reason (the textual callee differs from the resolved target "foo"), via a new py_emit_resolved_call_reason; py_emit_resolved_call delegates with NULL. - lsp_resolve: admit lsp_dict_dispatch into the reason-matched join so the resolution binds to the textual call site. Signed-off-by: Martin Vogel --- internal/cbm/extract_calls.c | 19 +++++++++++++++++++ internal/cbm/lsp/py_lsp.c | 16 +++++++++++++--- src/pipeline/lsp_resolve.h | 3 ++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 0a4a2d93a..1887768cc 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -983,6 +983,25 @@ static char *extract_callee_lang_specific(CBMArena *a, TSNode node, const char * CBMLanguage lang) { const char *nk = ts_node_type(node); + /* Python dict-dispatch call `funcs["a"](v)`: the call's `function` field is a + * subscript whose base is the identifier holding the dispatch table. Emit the + * base identifier ("funcs") as the textual callee so a CALLS edge exists; the + * py-LSP resolves it to the real target and joins via `reason` (lsp_resolve.h, + * lsp_dict_dispatch). Gated to the literal-string-key shape the LSP handles so + * other subscript calls (arr[i]()) are unaffected. */ + if (lang == CBM_LANG_PYTHON && strcmp(nk, "call") == 0) { + TSNode fnf = ts_node_child_by_field_name(node, TS_FIELD("function")); + if (!ts_node_is_null(fnf) && strcmp(ts_node_type(fnf), "subscript") == 0) { + TSNode val = ts_node_child_by_field_name(fnf, TS_FIELD("value")); + TSNode idx = ts_node_child_by_field_name(fnf, TS_FIELD("subscript")); + if (!ts_node_is_null(val) && !ts_node_is_null(idx) && + strcmp(ts_node_type(val), "identifier") == 0 && + strcmp(ts_node_type(idx), "string") == 0) { + return cbm_node_text(a, val, source); + } + } + } + if (lang == CBM_LANG_JSONNET) { char *c = extract_jsonnet_callee(a, node, source, nk); return c ? c : extract_scripting_callee(a, node, source, lang, nk); diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index e94ae8f11..6741f76e8 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -324,8 +324,9 @@ static const char *py_lookup_dict_dispatch(PyLSPContext *ctx, const char *var, c return NULL; } -static void py_emit_resolved_call(PyLSPContext *ctx, const char *callee_qn, const char *strategy, - float confidence) { +static void py_emit_resolved_call_reason(PyLSPContext *ctx, const char *callee_qn, + const char *strategy, float confidence, + const char *reason) { if (!ctx || !ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn) return; // Dedupe by (caller, callee). Bounded-window scan: most duplicate @@ -354,9 +355,15 @@ static void py_emit_resolved_call(PyLSPContext *ctx, const char *callee_qn, cons rc.callee_qn = cbm_arena_strdup(ctx->arena, callee_qn); rc.strategy = strategy; rc.confidence = confidence; + rc.reason = reason ? cbm_arena_strdup(ctx->arena, reason) : NULL; cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); } +static void py_emit_resolved_call(PyLSPContext *ctx, const char *callee_qn, const char *strategy, + float confidence) { + py_emit_resolved_call_reason(ctx, callee_qn, strategy, confidence, NULL); +} + /* ── helpers: registry-driven attribute lookup with depth cap ──── */ static const CBMRegisteredFunc *py_lookup_attribute_depth(PyLSPContext *ctx, const char *type_qn, @@ -1664,7 +1671,10 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { if (var_name && k_text) { const char *tgt = py_lookup_dict_dispatch(ctx, var_name, k_text); if (tgt) { - py_emit_resolved_call(ctx, tgt, "lsp_dict_dispatch", 0.86f); + /* The textual callee of `funcs["a"](v)` is the subscript base + * identifier ("funcs"), not the resolved target ("foo"), so + * stash it in `reason` for the join (see lsp_resolve.h). */ + py_emit_resolved_call_reason(ctx, tgt, "lsp_dict_dispatch", 0.86f, var_name); return; } } diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 0e2790531..3975c005f 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -114,7 +114,8 @@ static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution( (strcmp(rc->strategy, "lsp_func_ptr") == 0 || strcmp(rc->strategy, "lsp_dll_resolve") == 0 || strcmp(rc->strategy, "lsp_method_ref_ctor") == 0 || - strcmp(rc->strategy, "lsp_method_ref_ctor_synth") == 0) && + strcmp(rc->strategy, "lsp_method_ref_ctor_synth") == 0 || + strcmp(rc->strategy, "lsp_dict_dispatch") == 0) && strcmp(cbm_lsp_bare_segment(rc->reason), call_short) == 0)) { continue; }