Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/cbm/cbm.h
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,9 @@ typedef struct {
int loop_depth; // enclosing loop nesting at the call site
int branch_depth; // enclosing branch nesting at the call site
int start_line; // 1-based source line of the call (for def range-match)
bool is_method; // method/member call with a non-self receiver. Perl:
// arrow/method call ($obj->m). TS/JS/TSX: member call
bool is_method; // method/member call with an unresolved receiver. Perl:
// arrow/method call ($obj->m). Python: x.foo() where x is
// not self/cls/super or an imported name. TS/JS/TSX:
// x.foo() whose receiver is not this/super. Default false.
} CBMCall;

Expand Down
56 changes: 56 additions & 0 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -2145,6 +2145,49 @@ static char *resolve_objectscript_instance_call(CBMArena *a, TSNode node, const
return NULL;
}

/* True when a Python attribute call receiver is safe from the weak-member
* guard. Direct self/cls and super() receivers retain class-local semantics.
* An imported identifier — including the root of an attribute chain — must
* remain eligible for import-map resolution (`helper.compute()`). */
static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) {
if (ts_node_is_null(receiver)) {
return false;
}

if (strcmp(ts_node_type(receiver), "call") == 0) {
TSNode fn = ts_node_child_by_field_name(receiver, TS_FIELD("function"));
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
char *name = cbm_node_text(ctx->arena, fn, ctx->source);
return name && strcmp(name, "super") == 0;
}
return false;
}

bool direct_identifier = strcmp(ts_node_type(receiver), "identifier") == 0;
TSNode root = receiver;
while (!ts_node_is_null(root) && strcmp(ts_node_type(root), "attribute") == 0) {
root = ts_node_child_by_field_name(root, TS_FIELD("object"));
}
if (ts_node_is_null(root) || strcmp(ts_node_type(root), "identifier") != 0) {
return false;
}

char *name = cbm_node_text(ctx->arena, root, ctx->source);
if (!name) {
return false;
}
if (direct_identifier && (strcmp(name, "self") == 0 || strcmp(name, "cls") == 0)) {
return true;
}
for (int i = 0; i < ctx->result->imports.count; i++) {
const char *local_name = ctx->result->imports.items[i].local_name;
if (local_name && strcmp(local_name, name) == 0) {
return true;
}
}
return false;
}

void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, WalkState *state) {
if (!spec->call_node_types || !spec->call_node_types[0]) {
return;
Expand Down Expand Up @@ -2221,6 +2264,19 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk
strcmp(ts_node_type(node), "method_call_expression") == 0) {
call.is_method = true;
}
// Python receiver-aware guard (#1276). Imported receivers remain
// ordinary calls: module.function() is Python's canonical
// cross-file call shape and the import map can resolve it. Unknown
// object receivers are flagged so weak short-name matching cannot
// fabricate an edge. self/cls/super() keep class-local semantics.
if (ctx->language == CBM_LANG_PYTHON && strcmp(ts_node_type(node), "call") == 0) {
TSNode fn = ts_node_child_by_field_name(node, TS_FIELD("function"));
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "attribute") == 0) {
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
bool receiver_is_exempt = python_receiver_is_exempt(ctx, obj);
call.is_method = !receiver_is_exempt;
}
}
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent
// as the Perl flag above). Flag a member call x.foo() whose receiver
// is NOT `this`/`super`. When the TS-LSP cannot resolve the receiver
Expand Down
15 changes: 8 additions & 7 deletions src/pipeline/pass_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -571,8 +571,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
return 0;
}

/* TS/JS/TSX weak-method suppression (#592/#606). A member call x.foo() only
* reaches the registry when the TS-LSP could not resolve the receiver type
/* Dynamic-language weak-method suppression (#592/#606/#1276). A member call
* x.foo() only reaches the registry when the language-specific resolver
* could not resolve the receiver type
* (the LSP block above already returned for type-resolved calls, including
* the "resolved but target out of gbuf" fall-through). Binding such a call
* by a weak short-name strategy fabricates an edge (`re.test()` -> a project
Expand All @@ -581,10 +582,10 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
* defer to emit_classified_edge and suppress ONLY the plain-CALLS
* fall-through, so every service edge stays main-identical. res.strategy may
* be lsp_* here; the helper's explicit drop-list leaves lsp_* untouched. */
bool is_tsjs =
lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
bool tsjs_drop_plain_call =
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
bool drop_plain_call =
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);

/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library
Expand All @@ -611,7 +612,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
return 0;
}
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals,
imp_count, tsjs_drop_plain_call);
imp_count, drop_plain_call);
return SKIP_ONE;
}

Expand Down
23 changes: 12 additions & 11 deletions src/pipeline/pass_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -2321,20 +2321,21 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
continue;
}

/* TS/JS/TSX weak-method suppression (#592/#606). The receiver-aware guard
* must NOT drop this call here: doing so would also skip the #523
* callee-name service bypass below, emit_service_edge's route/gRPC/config
* branches, and its unconditional detect_url_in_args (which classifies
* verb-suffix HTTP clients like api.patch('/x')). Instead, defer to the
* emit path and suppress ONLY the plain-CALLS fall-through
/* Dynamic-language weak-method suppression (#592/#606/#1276). The
* receiver-aware guard must NOT drop this call here: doing so would
* also skip the #523 callee-name service bypass below,
* emit_service_edge's route/gRPC/config branches, and its unconditional
* detect_url_in_args (which classifies verb-suffix HTTP clients like
* api.patch('/x')). Instead, defer to the emit path and suppress ONLY
* the plain-CALLS fall-through
* (emit_normal_calls_edge), so every service edge stays main-identical by
* construction. res.strategy may carry an lsp_* value here (LSP-resolved
* calls keep res through this point); the helper's EXPLICIT drop-list
* leaves lsp_ts_method / lsp_cross untouched. See #606 direction. */
bool is_tsjs =
lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
bool tsjs_drop_plain_call =
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
bool drop_plain_call =
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);

/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the
* service signal lives in the callee_name. The registry can mis-resolve
Expand Down Expand Up @@ -2423,7 +2424,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
_rc_t0 = extract_now_ns();
emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn,
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
tsjs_drop_plain_call);
drop_plain_call);
atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0,
memory_order_relaxed);
ws->calls_resolved++;
Expand Down
11 changes: 6 additions & 5 deletions src/pipeline/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,14 @@ bool cbm_perl_is_builtin(const char *name);
bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *callee_name,
const char *strategy);

/* Decide whether a resolved TS/JS/TSX member-call edge is weak-strategy noise to
* drop (#592/#606): true only for TS/JS, only for a member call with a
* non-this/super receiver (is_method), and only when the match used a weak
* short-name strategy (suffix_match / unique_name / field_type_hint / fuzzy).
/* Decide whether a resolved dynamic-language member-call edge is weak-strategy
* noise to drop (#592/#606/#1276): true only when the caller enables the guard,
* only for a member call with a non-self receiver (is_method), and only when
* the match used a weak short-name strategy (suffix_match / unique_name /
* field_type_hint / fuzzy).
* Explicit drop-list keeps every lsp_* / import / same-module / qualified match.
* Pure; unit-tested in test_registry.c. */
bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy);
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy);

/* Get the label of a qualified name, or NULL if not found. */
const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn);
Expand Down
27 changes: 14 additions & 13 deletions src/pipeline/registry.c
Original file line number Diff line number Diff line change
Expand Up @@ -428,19 +428,20 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
return true; /* weak short-name match (suffix_match / unique_name / …) → drop */
}

/* TS/JS analogue of the Perl guard above (#592/#606 direction; precedent #477).
* A member call `x.foo()` reaches the weak textual cascade ONLY when the TS-LSP
* could not resolve the receiver type — type-resolved calls win via lsp_*
* strategies before the registry runs. Binding such a call to a project symbol
* by a weak short-name strategy fabricates a CALLS edge (`re.test()` ->
* SalesforceRestClient.test, `date.toISOString()` -> any project toISOString).
* Drop ONLY the weak strategies; keep import/same-module/qualified-tail matches
* and every lsp_* strategy. Uses an EXPLICIT drop-list (not keep-list +
* default-drop) because the parallel resolver runs lsp_* strategies through the
* same guard variable — a default-drop would silently kill lsp_ts_method. Pure
* + side-effect-free so the contract is unit-testable without a full pipeline. */
bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy) {
if (!is_tsjs || !is_method || !strategy || !strategy[0]) {
/* Dynamic-language analogue of the Perl guard above (#592/#606/#1276;
* precedent #477). A member call `x.foo()` reaches the weak textual cascade
* only when the language-specific resolver could not identify the receiver
* type — type-resolved calls win via lsp_* strategies before the registry runs.
* Binding such a call to a project symbol by a weak short-name strategy
* fabricates a CALLS edge (`re.test()` -> SalesforceRestClient.test,
* `accelerator.print()` -> MockAccelerator.print). Drop only the weak
* strategies; keep import/same-module/qualified-tail matches and every lsp_*
* strategy. Uses an explicit drop-list (not a keep-list plus default-drop)
* because the parallel resolver runs lsp_* strategies through the same guard
* variable — a default-drop would silently kill lsp_ts_method. Pure and
* side-effect-free so the contract is unit-testable without a full pipeline. */
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy) {
if (!enabled || !is_method || !strategy || !strategy[0]) {
return false;
}
/* Weak short-name strategies that actually reach the call-resolution guards:
Expand Down
63 changes: 63 additions & 0 deletions tests/test_extraction.c
Original file line number Diff line number Diff line change
Expand Up @@ -3753,6 +3753,68 @@ TEST(extract_flag_exempt_method_call_not_flagged_is_method) {
PASS();
}

/* Python attribute calls on unknown receivers are flagged so they cannot bind
* to unrelated project methods by name. Direct self/cls/super() calls retain
* class-local semantics, and imported receivers remain import-resolvable. */
TEST(extract_python_member_call_flags_is_method) {
CBMFileResult *r = extract("from pkg import helper\n"
"import tools as toolkit\n"
"\n"
"class C(Base):\n"
" def run(self, external):\n"
" external.commit()\n"
" self.client.send()\n"
" self.helper()\n"
" super ( ).render()\n"
" helper.compute()\n"
" toolkit.format()\n"
" helper()\n",
CBM_LANG_PYTHON, "t", "x.py");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
int external = 0;
int nested = 0;
int self_call = 0;
int super_call = 0;
int imported_module = 0;
int imported_alias = 0;
int bare = 0;
for (int i = 0; i < r->calls.count; i++) {
const char *cn = r->calls.items[i].callee_name;
if (strcmp(cn, "external.commit") == 0) {
external++;
ASSERT_TRUE(r->calls.items[i].is_method);
} else if (strcmp(cn, "self.client.send") == 0) {
nested++;
ASSERT_TRUE(r->calls.items[i].is_method);
} else if (strcmp(cn, "self.helper") == 0) {
self_call++;
ASSERT_FALSE(r->calls.items[i].is_method);
} else if (strstr(cn, "render") != NULL) {
super_call++;
ASSERT_FALSE(r->calls.items[i].is_method);
} else if (strcmp(cn, "helper.compute") == 0) {
imported_module++;
ASSERT_FALSE(r->calls.items[i].is_method);
} else if (strcmp(cn, "toolkit.format") == 0) {
imported_alias++;
ASSERT_FALSE(r->calls.items[i].is_method);
} else if (strcmp(cn, "helper") == 0) {
bare++;
ASSERT_FALSE(r->calls.items[i].is_method);
}
}
ASSERT_EQ(external, 1);
ASSERT_EQ(nested, 1);
ASSERT_EQ(self_call, 1);
ASSERT_EQ(super_call, 1);
ASSERT_EQ(imported_module, 1);
ASSERT_EQ(imported_alias, 1);
ASSERT_EQ(bare, 1);
cbm_free_result(r);
PASS();
}

/* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above).
* A member call x.foo() with a non-this/super receiver is flagged is_method so
* the resolver can suppress a weak short-name match (`re.test()` must not bind a
Expand Down Expand Up @@ -5040,6 +5102,7 @@ SUITE(extraction) {
RUN_TEST(extract_perl_builtin_call_is_function_not_method);
RUN_TEST(extract_perl_method_call_flags_is_method);
RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method);
RUN_TEST(extract_python_member_call_flags_is_method);
RUN_TEST(extract_ts_member_call_flags_is_method);
RUN_TEST(extract_ts_this_super_receiver_not_flagged);
RUN_TEST(extract_js_member_call_flags_is_method);
Expand Down
Loading
Loading