fix(python): resolve iris.cls("Pkg.X").Method() to the named class

In IRIS embedded Python, iris.cls("Pkg.X").Method(...) names its target
class in the receiver's string literal. There was no receiver path for it
next to the classMethod* string dispatch, so after the weak-member-call
suppression in b6a22843 these calls produced no CALLS edge at all.

When a Python call's receiver is iris.cls() with a plain quoted literal as
its first argument, also emit "<class-literal>.<method>" as a callee, the
same way classMethodValue already does. Non-literal class arguments
(variables, f-strings) name nothing and get no class-qualified callee.

Also add invokeClassMethod to the Python dispatch suffixes. It takes the
same (class_name, method_name, *args) arguments as classMethodValue.

Tests: the #1260 repro as a pipeline test (two classes sharing Run, a
nonexistent class, an unrelated receiver, classMethodValue and
invokeClassMethod), plus extraction tests for the synthesized callee,
non-literal and f-string arguments, and invokeClassMethod.

Fixes #1260

Signed-off-by: Thomas Dyar <tdyar@intersystems.com>
This commit is contained in:
Thomas Dyar committed 2026-09-26 04:21:17 -04:00
1 parent 64c23fab6c
commit 8f803f7262
3 files changed
+222 -2

No files matched your search

+57 -2
View File
@@ -79,8 +79,9 @@ static const char *strip_quotes(CBMArena *a, const char *text) {
// Callee suffixes for IRIS Python interop string-dispatch. Kept at file scope
// (not inside the function) to satisfy cppcheck variableScope.
static const char *s_py_dispatch_suffixes[] = {".classMethodValue", ".classMethodVoid",
".classMethodBoolean", ".classMethodObject", NULL};
static const char *s_py_dispatch_suffixes[] = {".classMethodValue", ".classMethodVoid",
".classMethodBoolean", ".classMethodObject",
".invokeClassMethod", NULL};
// Per-language callee-suffix dispatch table — returns a NULL-terminated list of
// method-name suffixes whose calls should be resolved by extracting class+method
@@ -2039,6 +2040,49 @@ static const char *extract_nth_string_arg(CBMExtractCtx *ctx, TSNode args, uint3
return NULL;
}
// IRIS embedded Python names the target class in the receiver:
// iris.cls("Pkg.Class").Method(...) calls ClassMethod Pkg.Class.Method.
// Returns "Pkg.Class.Method", or NULL unless the receiver is iris.cls() with
// a string-literal first argument. A non-literal class names nothing, so it
// gets no class-qualified callee rather than a guessed one.
static const char *python_iris_cls_callee(CBMExtractCtx *ctx, TSNode call_node) {
TSNode fn = ts_node_child_by_field_name(call_node, TS_FIELD("function"));
if (ts_node_is_null(fn) || strcmp(ts_node_type(fn), "attribute") != 0) {
return NULL;
}
TSNode recv = ts_node_child_by_field_name(fn, TS_FIELD("object"));
TSNode meth = ts_node_child_by_field_name(fn, TS_FIELD("attribute"));
if (ts_node_is_null(recv) || ts_node_is_null(meth) || strcmp(ts_node_type(recv), "call") != 0) {
return NULL;
}
TSNode recv_fn = ts_node_child_by_field_name(recv, TS_FIELD("function"));
TSNode recv_args = ts_node_child_by_field_name(recv, TS_FIELD("arguments"));
if (ts_node_is_null(recv_fn) || ts_node_is_null(recv_args) ||
ts_node_named_child_count(recv_args) == 0) {
return NULL;
}
const char *recv_text = cbm_node_text(ctx->arena, recv_fn, ctx->source);
if (!recv_text || strcmp(recv_text, "iris.cls") != 0) {
return NULL;
}
TSNode cls_arg = ts_node_named_child(recv_args, 0);
if (!is_string_like(ts_node_type(cls_arg))) {
return NULL;
}
// Plain quoted literals only: an f-string or other prefixed string is a
// "string" node too, but its text is not a class name.
char *cls_text = cbm_node_text(ctx->arena, cls_arg, ctx->source);
if (!cls_text || (cls_text[0] != '"' && cls_text[0] != '\'')) {
return NULL;
}
const char *cls = strip_and_validate_string_arg(ctx->arena, cls_text);
const char *mth = cbm_node_text(ctx->arena, meth, ctx->source);
if (!cls || !cls[0] || !mth || !mth[0]) {
return NULL;
}
return cbm_arena_sprintf(ctx->arena, "%s.%s", cls, mth);
}
// --- Unified handler: called once per node by the cursor walk ---
// Process a keyword argument (keyword_argument or pair node).
@@ -3911,6 +3955,17 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
}
}
}
if (ctx->language == CBM_LANG_PYTHON) {
const char *iris_callee = python_iris_cls_callee(ctx, node);
if (iris_callee) {
CBMCall xcall = {0};
xcall.callee_name = iris_callee;
xcall.enclosing_func_qn = call.enclosing_func_qn;
xcall.site_start_byte = call.site_start_byte;
xcall.site_end_byte = call.site_end_byte;
cbm_calls_push(&ctx->result->calls, ctx->arena, xcall);
}
}
}
}
+63
View File
@@ -3265,6 +3265,67 @@ TEST(python_iris_classMethodValue) {
PASS();
}
/* #1260: count calls with an exact callee whose enclosing function qn ends
* with ".<func>". has_call() is a substring match, which cannot tell
* "Pkg.A.Run" emitted from want_a apart from the same callee in want_b. */
static int count_calls_in_func(CBMFileResult *r, const char *callee, const char *func) {
int n = 0;
size_t flen = strlen(func);
for (int i = 0; i < r->calls.count; i++) {
const CBMCall *c = &r->calls.items[i];
if (!c->callee_name || strcmp(c->callee_name, callee) != 0 || !c->enclosing_func_qn) {
continue;
}
size_t qlen = strlen(c->enclosing_func_qn);
if (qlen > flen && c->enclosing_func_qn[qlen - flen - 1] == '.' &&
strcmp(c->enclosing_func_qn + qlen - flen, func) == 0) {
n++;
}
}
return n;
}
TEST(python_iris_cls_receiver_is_class_aware) {
CBMFileResult *r = extract("import iris\n"
"def want_a():\n"
" return iris.cls(\"Pkg.A\").Run(\"x\")\n"
"def want_b():\n"
" return iris.cls('Pkg.B').Run('x')\n"
"def dynamic(name):\n"
" return iris.cls(name).Run('x')\n"
"def fstring(n):\n"
" return iris.cls(f'Pkg.{n}').Run('x')\n"
"def unrelated_receiver():\n"
" return some_random_thing.Run('x')\n",
CBM_LANG_PYTHON, "t", "caller.py");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(count_calls_in_func(r, "Pkg.A.Run", "want_a"), 1);
ASSERT_EQ(count_calls_in_func(r, "Pkg.B.Run", "want_b"), 1);
ASSERT_EQ(count_calls_in_func(r, "Pkg.A.Run", "want_b"), 0);
/* A non-literal class argument names nothing: no class-qualified callee. */
ASSERT_EQ(count_calls_in_func(r, "name.Run", "dynamic"), 0);
/* An f-string is a Python "string" node but not a class name. */
ASSERT_EQ(count_calls_in_func(r, "f'Pkg.{n}'.Run", "fstring"), 0);
ASSERT_EQ(count_calls_in_func(r, "Pkg.{n}.Run", "fstring"), 0);
ASSERT_EQ(count_calls_in_func(r, "Pkg.A.Run", "unrelated_receiver"), 0);
ASSERT_EQ(count_calls_in_func(r, "Pkg.B.Run", "unrelated_receiver"), 0);
cbm_free_result(r);
PASS();
}
TEST(python_iris_invokeClassMethod) {
CBMFileResult *r =
extract("def call(db):\n"
" return db.invokeClassMethod('MyApp.Service', 'SomeClassMethod', 1)\n",
CBM_LANG_PYTHON, "t", "svc.py");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(count_calls_in_func(r, "MyApp.Service.SomeClassMethod", "call"), 1);
cbm_free_result(r);
PASS();
}
TEST(go_calls) {
CBMFileResult *r =
extract("package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"hello\") }\n",
@@ -8535,6 +8596,8 @@ SUITE(extraction) {
/* Cross-cutting */
RUN_TEST(python_calls);
RUN_TEST(python_iris_classMethodValue);
RUN_TEST(python_iris_cls_receiver_is_class_aware);
RUN_TEST(python_iris_invokeClassMethod);
RUN_TEST(go_calls);
RUN_TEST(python_imports);
RUN_TEST(js_imports);
+102
View File
@@ -14674,6 +14674,107 @@ TEST(pipeline_ensemble_routing_method_scoping) {
PASS();
}
/* #1260: CALLS edges from the function named `func` whose target qn ends
* with ".<target_suffix>" (segment-anchored). A NULL suffix counts every
* CALLS edge to a node named "Run". */
static int iris_cls_calls(cbm_store_t *s, const char *project, const char *func,
const char *target_suffix) {
cbm_node_t *fn = NULL;
int fn_count = 0;
cbm_store_find_nodes_by_name(s, project, func, &fn, &fn_count);
int n = 0;
for (int fi = 0; fi < fn_count; fi++) {
cbm_edge_t *edges = NULL;
int ec = 0;
cbm_store_find_edges_by_source_type(s, fn[fi].id, "CALLS", &edges, &ec);
for (int ei = 0; ei < ec; ei++) {
cbm_node_t tgt = {0};
if (cbm_store_find_node_by_id(s, edges[ei].target_id, &tgt) != 0) {
continue;
}
if (!target_suffix) {
if (tgt.name && strcmp(tgt.name, "Run") == 0) {
n++;
}
} else if (tgt.qualified_name) {
size_t ql = strlen(tgt.qualified_name);
size_t sl = strlen(target_suffix);
if (ql > sl && tgt.qualified_name[ql - sl - 1] == '.' &&
strcmp(tgt.qualified_name + ql - sl, target_suffix) == 0) {
n++;
}
}
cbm_node_free_fields(&tgt);
}
cbm_store_free_edges(edges, ec);
}
cbm_store_free_nodes(fn, fn_count);
return n;
}
TEST(pipeline_python_iris_cls_class_aware_calls) {
/* The #1260 repro: two ObjectScript classes share a method name. Python
* callers must reach the class named in iris.cls("<literal>"), and a call
* that names no indexed class must not borrow an edge from one that does. */
char *tmp = th_mktempdir("cbm_iris_cls");
ASSERT_NOT_NULL(tmp);
const char *run_body = "{\n"
"ClassMethod Run(x As %String) As %String\n"
"{\n"
" Quit x\n"
"}\n"
"}\n";
char cls[256];
snprintf(cls, sizeof(cls), "Class Pkg.A Extends %%RegisteredObject\n%s", run_body);
write_temp_file(tmp, "A.cls", cls);
snprintf(cls, sizeof(cls), "Class Pkg.B Extends %%RegisteredObject\n%s", run_body);
write_temp_file(tmp, "B.cls", cls);
write_temp_file(tmp, "caller.py",
"import iris\n"
"\n"
"def want_a():\n"
" return iris.cls(\"Pkg.A\").Run(\"x\")\n"
"\n"
"def want_b():\n"
" return iris.cls(\"Pkg.B\").Run(\"x\")\n"
"\n"
"def want_nonexistent():\n"
" return iris.cls(\"Pkg.DoesNotExist\").Run(\"x\")\n"
"\n"
"def unrelated_receiver():\n"
" return some_random_thing.Run(\"x\")\n"
"\n"
"def via_classmethodvalue(db):\n"
" return db.classMethodValue(\"Pkg.B\", \"Run\", \"x\")\n"
"\n"
"def via_invoke(db):\n"
" return db.invokeClassMethod(\"Pkg.A\", \"Run\", \"x\")\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/iris_cls.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, 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);
ASSERT_EQ(iris_cls_calls(s, project, "want_a", "Pkg.A.Run"), 1);
ASSERT_EQ(iris_cls_calls(s, project, "want_a", NULL), 1);
ASSERT_EQ(iris_cls_calls(s, project, "want_b", "Pkg.B.Run"), 1);
ASSERT_EQ(iris_cls_calls(s, project, "want_b", NULL), 1);
ASSERT_EQ(iris_cls_calls(s, project, "want_nonexistent", NULL), 0);
ASSERT_EQ(iris_cls_calls(s, project, "unrelated_receiver", NULL), 0);
ASSERT_EQ(iris_cls_calls(s, project, "via_classmethodvalue", "Pkg.B.Run"), 1);
ASSERT_EQ(iris_cls_calls(s, project, "via_invoke", "Pkg.A.Run"), 1);
ASSERT_EQ(iris_cls_calls(s, project, "via_invoke", NULL), 1);
cbm_store_close(s);
cbm_pipeline_free(p);
th_rmtree(tmp);
PASS();
}
/* #518/#519 item-7 regression: the DELTA merge is the warm path most users
* hit. It used to write nodes_fts with a hand-rolled four-column INSERT of
* its own; with a fifth `body` column that literal leaves prose NULL for every
@@ -15428,6 +15529,7 @@ SUITE(pipeline) {
/* Ensemble routing pass */
RUN_TEST(pipeline_ensemble_routing_edges);
RUN_TEST(pipeline_ensemble_routing_method_scoping);
RUN_TEST(pipeline_python_iris_cls_class_aware_calls);
RUN_TEST(pipeline_ensemble_routing_attr_does_not_leak_across_items);
RUN_TEST(pipeline_ensemble_routing_settings_targets);
RUN_TEST(pipeline_ensemble_routing_unterminated_item_is_safe);