Skip to content

Commit b6a2284

Browse files
DeusDataEnferlain
andcommitted
fix(pipeline): suppress weak Python member calls (distilled from DeusData#1324)
Python attribute calls were not classified as member calls, so weak short-name strategies (suffix_match / unique_name / field_type_hint) could bind `obj.method()` to an arbitrary same-named project symbol — `accelerator.print()` resolving to MockAccelerator.print. Flag a Python attribute call is_method when its receiver is unresolved, and extend the existing TS/JS weak-member guard to Python. Receivers that ARE known stay exempt: self/cls, super(), and any identifier bound by one of the file's imports (including the root of an attribute chain), so Python's canonical `module.function()` cross-file call still resolves. The guard helper is renamed cbm_tsjs_suppress_weak_method_match -> cbm_suppress_weak_member_match. Its drop-list is unchanged, and the per-language gate stays at the two call sites, which enumerate the same language set in pass_calls.c and pass_parallel.c so the sequential and parallel resolvers cannot diverge. ArkTS remains in that set. python/S6/inherited_method in the resolution probe now asserts calls == 0, recording the same trade TypeScript took when the receiver-aware guard landed: the edge it used to produce came from a unique_name registry fallback (measured on main: strategy=unique_name, cands=1, conf=0.7500) where a weak short-name guess happened to be right in a 2-file fixture. It flips back to >= 1 once py_lsp_cross resolves inheritance, matching the flip-back note already carried on the TypeScript S6 case. Fixes DeusData#1276 Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com> Co-authored-by: Enferlain <15861396+Enferlain@users.noreply.github.com>
1 parent d8f8f45 commit b6a2284

10 files changed

Lines changed: 430 additions & 80 deletions

File tree

‎internal/cbm/cbm.h‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,13 @@ typedef struct {
259259
uint32_t site_start_byte; // exact AST occurrence span; end > start when present
260260
uint32_t site_end_byte; // exclusive byte offset in the source file
261261
CBMSourceOrigin source_origin; // raw source or C-family preprocessed buffer
262-
bool is_method; // method/member call with a non-self receiver. Perl:
262+
bool is_method; // method/member call with an UNRESOLVED receiver. Perl:
263263
// arrow/method call ($obj->m). TS/JS/TSX: member call
264-
// x.foo() whose receiver is not this/super. Default false.
264+
// x.foo() whose receiver is not this/super. Python:
265+
// x.foo() where x is not self/cls/super() and is not
266+
// rooted in an imported name. Read by the weak-member
267+
// guard and by the pxc synthetic-carrier dedup key in
268+
// pass_lsp_cross.c. Default false.
265269
bool requires_lsp_resolution; // synthetic semantic candidate (for example an implicit
266270
// C++ operator). Never fall back to textual resolution.
267271
} CBMCall;

‎internal/cbm/extract_calls.c‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2740,6 +2740,66 @@ static char *resolve_objectscript_instance_call(CBMArena *a, TSNode node, const
27402740
return NULL;
27412741
}
27422742

2743+
/* True when a Python attribute-call receiver is EXEMPT from the weak-member
2744+
* guard (#1276). Three exemptions, each because the receiver is in fact known:
2745+
* - `self.x()` / `cls.x()` — a direct self/cls receiver keeps class-local
2746+
* semantics; the enclosing class is the right namespace for a weak match.
2747+
* - `super().x()` — same, via the base class.
2748+
* - `helper.compute()` — an identifier bound by one of THIS file's
2749+
* imports, including the root of an attribute chain (`pkg.sub.fn()`).
2750+
* module.function() is Python's canonical cross-file call shape and the
2751+
* import map resolves it; flagging it would kill the true edge.
2752+
* Everything else — a parameter, a local, an attribute of self — has no
2753+
* statically-known type here, so the call must not bind by short name alone.
2754+
* Note `self.client.send()` is NOT exempt: the receiver is `self.client`, an
2755+
* attribute of unknown type, not `self` itself. */
2756+
static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) {
2757+
if (ts_node_is_null(receiver)) {
2758+
return false;
2759+
}
2760+
2761+
/* super().m() — the receiver is a call node whose function is `super`. */
2762+
if (strcmp(ts_node_type(receiver), "call") == 0) {
2763+
TSNode fn = ts_node_child_by_field_name(receiver, TS_FIELD("function"));
2764+
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
2765+
char *name = cbm_node_text(ctx->arena, fn, ctx->source);
2766+
return name && strcmp(name, "super") == 0;
2767+
}
2768+
return false;
2769+
}
2770+
2771+
/* Walk an attribute chain down to its root identifier: for `pkg.sub.fn()`
2772+
* the receiver is `pkg.sub`, whose root is `pkg` — the name an import binds. */
2773+
bool direct_identifier = strcmp(ts_node_type(receiver), "identifier") == 0;
2774+
TSNode root = receiver;
2775+
while (!ts_node_is_null(root) && strcmp(ts_node_type(root), "attribute") == 0) {
2776+
root = ts_node_child_by_field_name(root, TS_FIELD("object"));
2777+
}
2778+
if (ts_node_is_null(root) || strcmp(ts_node_type(root), "identifier") != 0) {
2779+
return false;
2780+
}
2781+
2782+
char *name = cbm_node_text(ctx->arena, root, ctx->source);
2783+
if (!name) {
2784+
return false;
2785+
}
2786+
/* self/cls only as a DIRECT receiver: `self.m()` is class-local, but
2787+
* `self.client.m()` has receiver `self.client` of unknown type. */
2788+
if (direct_identifier && (strcmp(name, "self") == 0 || strcmp(name, "cls") == 0)) {
2789+
return true;
2790+
}
2791+
/* Import-bound root, incl. aliases (`import tools as toolkit` binds
2792+
* local_name "toolkit"). Per-file scan: imports.count is a file-local
2793+
* number (tens), never the corpus, so this stays O(file), not O(corpus). */
2794+
for (int i = 0; i < ctx->result->imports.count; i++) {
2795+
const char *local_name = ctx->result->imports.items[i].local_name;
2796+
if (local_name && strcmp(local_name, name) == 0) {
2797+
return true;
2798+
}
2799+
}
2800+
return false;
2801+
}
2802+
27432803
static bool is_objectscript_language(CBMLanguage language) {
27442804
return language == CBM_LANG_OBJECTSCRIPT_UDL || language == CBM_LANG_OBJECTSCRIPT_ROUTINE;
27452805
}
@@ -3357,6 +3417,20 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
33573417
strcmp(ts_node_type(node), "method_call_expression") == 0) {
33583418
call.is_method = true;
33593419
}
3420+
// Python receiver-aware guard (#1276; same intent as the Perl and
3421+
// TS/JS flags). Flag an attribute call x.foo() whose receiver is not
3422+
// self/cls/super() and is not rooted in an imported name, so the
3423+
// call-resolution pass can suppress weak short-name matches for it
3424+
// (`accelerator.print()` must not bind MockAccelerator.print).
3425+
// Imported receivers stay unflagged: module.function() is Python's
3426+
// canonical cross-file call and the import map resolves it.
3427+
if (ctx->language == CBM_LANG_PYTHON && strcmp(ts_node_type(node), "call") == 0) {
3428+
TSNode fn = ts_node_child_by_field_name(node, TS_FIELD("function"));
3429+
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "attribute") == 0) {
3430+
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
3431+
call.is_method = !python_receiver_is_exempt(ctx, obj);
3432+
}
3433+
}
33603434
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent
33613435
// as the Perl flag above). Flag a member call x.foo() whose receiver
33623436
// is NOT `this`/`super`. When the TS-LSP cannot resolve the receiver

‎src/pipeline/pass_calls.c‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -606,20 +606,29 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
606606
return 0;
607607
}
608608

609-
/* TS/JS/TSX weak-method suppression (#592/#606). A member call x.foo() only
610-
* reaches the registry when the TS-LSP could not resolve the receiver type
611-
* (the LSP block above already returned for type-resolved calls, including
612-
* the "resolved but target out of gbuf" fall-through). Binding such a call
613-
* by a weak short-name strategy fabricates an edge (`re.test()` -> a project
614-
* `test`). Rather than drop it here — which would also skip the service
615-
* bypasses below and emit_classified_edge's route/HTTP/CONFIG branches —
616-
* defer to emit_classified_edge and suppress ONLY the plain-CALLS
617-
* fall-through, so every service edge stays main-identical. res.strategy may
618-
* be lsp_* here; the helper's explicit drop-list leaves lsp_* untouched. */
619-
bool is_tsjs = lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT ||
620-
lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS;
621-
bool tsjs_drop_plain_call =
622-
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);
609+
/* Dynamic-language weak-member suppression (#592/#606/#1276). A member call
610+
* x.foo() only reaches the registry when the language's LSP could not
611+
* resolve the receiver type (the LSP block above already returned for
612+
* type-resolved calls, including the "resolved but target out of gbuf"
613+
* fall-through). Binding such a call by a weak short-name strategy
614+
* fabricates an edge (`re.test()` -> a project `test`,
615+
* `accelerator.print()` -> MockAccelerator.print). Rather than drop it here
616+
* — which would also skip the service bypasses below and
617+
* emit_classified_edge's route/HTTP/CONFIG branches — defer to
618+
* emit_classified_edge and suppress ONLY the plain-CALLS fall-through, so
619+
* every service edge stays main-identical. res.strategy may be lsp_* here;
620+
* the helper's explicit drop-list leaves lsp_* untouched.
621+
*
622+
* This language set MUST match the one in pass_parallel.c exactly — a
623+
* language gated on only one resolver produces an edge on the sequential
624+
* path and not the parallel one (or vice versa), breaking MT determinism.
625+
* ArkTS belongs to the JS/TS family here (#1842); dropping it would
626+
* reintroduce the #592/#606 false-edge class for .ets files. */
627+
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
628+
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
629+
lang == CBM_LANG_ARKTS;
630+
bool drop_plain_call =
631+
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);
623632

624633
/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
625634
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library
@@ -652,7 +661,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
652661
return 0;
653662
}
654663
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals,
655-
imp_count, tsjs_drop_plain_call);
664+
imp_count, drop_plain_call);
656665
return SKIP_ONE;
657666
}
658667

‎src/pipeline/pass_parallel.c‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2465,20 +2465,25 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
24652465
continue;
24662466
}
24672467

2468-
/* TS/JS/TSX weak-method suppression (#592/#606). The receiver-aware guard
2469-
* must NOT drop this call here: doing so would also skip the #523
2470-
* callee-name service bypass below, emit_service_edge's route/gRPC/config
2471-
* branches, and its unconditional detect_url_in_args (which classifies
2472-
* verb-suffix HTTP clients like api.patch('/x')). Instead, defer to the
2473-
* emit path and suppress ONLY the plain-CALLS fall-through
2474-
* (emit_normal_calls_edge), so every service edge stays main-identical by
2475-
* construction. res.strategy may carry an lsp_* value here (LSP-resolved
2476-
* calls keep res through this point); the helper's EXPLICIT drop-list
2477-
* leaves lsp_ts_method / lsp_cross untouched. See #606 direction. */
2478-
bool is_tsjs = lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT ||
2479-
lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS;
2480-
bool tsjs_drop_plain_call =
2481-
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);
2468+
/* Dynamic-language weak-member suppression (#592/#606/#1276). The
2469+
* receiver-aware guard must NOT drop this call here: doing so would also
2470+
* skip the #523 callee-name service bypass below, emit_service_edge's
2471+
* route/gRPC/config branches, and its unconditional detect_url_in_args
2472+
* (which classifies verb-suffix HTTP clients like api.patch('/x')).
2473+
* Instead, defer to the emit path and suppress ONLY the plain-CALLS
2474+
* fall-through (emit_normal_calls_edge), so every service edge stays
2475+
* main-identical by construction. res.strategy may carry an lsp_* value
2476+
* here (LSP-resolved calls keep res through this point); the helper's
2477+
* EXPLICIT drop-list leaves lsp_ts_method / lsp_cross untouched. See
2478+
* #606 direction.
2479+
*
2480+
* This language set MUST match the one in pass_calls.c exactly — see the
2481+
* note there. ArkTS belongs to the JS/TS family (#1842). */
2482+
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
2483+
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
2484+
lang == CBM_LANG_ARKTS;
2485+
bool drop_plain_call =
2486+
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);
24822487

24832488
/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the
24842489
* service signal lives in the callee_name. The registry can mis-resolve
@@ -2573,7 +2578,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
25732578
_rc_t0 = extract_now_ns();
25742579
emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn,
25752580
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
2576-
tsjs_drop_plain_call);
2581+
drop_plain_call);
25772582
atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0,
25782583
memory_order_relaxed);
25792584
ws->calls_resolved++;

‎src/pipeline/pipeline.h‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,16 @@ bool cbm_perl_is_builtin(const char *name);
269269
bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *callee_name,
270270
const char *strategy);
271271

272-
/* Decide whether a resolved TS/JS/TSX member-call edge is weak-strategy noise to
273-
* drop (#592/#606): true only for TS/JS, only for a member call with a
274-
* non-this/super receiver (is_method), and only when the match used a weak
275-
* short-name strategy (suffix_match / unique_name / field_type_hint / fuzzy).
272+
/* Decide whether a resolved member-call edge is weak-strategy noise to drop
273+
* (#592/#606/#1276): true only when the CALLER's per-language gate says the
274+
* guard applies (`enabled`), only for a member call with an unresolved receiver
275+
* (is_method), and only when the match used a weak short-name strategy
276+
* (suffix_match / unique_name / field_type_hint / fuzzy).
276277
* Explicit drop-list keeps every lsp_* / import / same-module / qualified match.
278+
* The language set lives at the call sites (pass_calls.c / pass_parallel.c) and
279+
* must be identical in both, or the sequential and parallel resolvers diverge.
277280
* Pure; unit-tested in test_registry.c. */
278-
bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy);
281+
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy);
279282

280283
/* #725: drop a suffix_match CALLS edge when the caller language and the
281284
* target file's language disagree. unique_name (candidates == 1) is #1572

‎src/pipeline/registry.c‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -429,19 +429,27 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
429429
return true; /* weak short-name match (suffix_match / unique_name / …) → drop */
430430
}
431431

432-
/* TS/JS analogue of the Perl guard above (#592/#606 direction; precedent #477).
433-
* A member call `x.foo()` reaches the weak textual cascade ONLY when the TS-LSP
434-
* could not resolve the receiver type — type-resolved calls win via lsp_*
435-
* strategies before the registry runs. Binding such a call to a project symbol
436-
* by a weak short-name strategy fabricates a CALLS edge (`re.test()` ->
437-
* SalesforceRestClient.test, `date.toISOString()` -> any project toISOString).
438-
* Drop ONLY the weak strategies; keep import/same-module/qualified-tail matches
439-
* and every lsp_* strategy. Uses an EXPLICIT drop-list (not keep-list +
440-
* default-drop) because the parallel resolver runs lsp_* strategies through the
441-
* same guard variable — a default-drop would silently kill lsp_ts_method. Pure
442-
* + side-effect-free so the contract is unit-testable without a full pipeline. */
443-
bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy) {
444-
if (!is_tsjs || !is_method || !strategy || !strategy[0]) {
432+
/* Dynamic-language analogue of the Perl guard above (#592/#606/#1276
433+
* direction; precedent #477). A member call `x.foo()` reaches the weak textual
434+
* cascade ONLY when the language's LSP could not resolve the receiver type —
435+
* type-resolved calls win via lsp_* strategies before the registry runs.
436+
* Binding such a call to a project symbol by a weak short-name strategy
437+
* fabricates a CALLS edge (`re.test()` -> SalesforceRestClient.test,
438+
* `accelerator.print()` -> MockAccelerator.print). Drop ONLY the weak
439+
* strategies; keep import/same-module/qualified-tail matches and every lsp_*
440+
* strategy. Uses an EXPLICIT drop-list (not keep-list + default-drop) because
441+
* the parallel resolver runs lsp_* strategies through the same guard variable —
442+
* a default-drop would silently kill lsp_ts_method. Pure + side-effect-free so
443+
* the contract is unit-testable without a full pipeline.
444+
*
445+
* `enabled` is the CALLER's per-language gate, deliberately kept OUT of this
446+
* helper: the guard applies only to the language set each call site enumerates
447+
* (today Python plus the JS/TS family including ArkTS). Widening it is a
448+
* per-language decision made at the call sites in pass_calls.c and
449+
* pass_parallel.c, which MUST stay in lockstep — a gate added to only one of
450+
* them diverges the sequential and parallel resolvers. */
451+
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy) {
452+
if (!enabled || !is_method || !strategy || !strategy[0]) {
445453
return false;
446454
}
447455
/* Weak short-name strategies that actually reach the call-resolution guards:

0 commit comments

Comments
 (0)