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
4 changes: 4 additions & 0 deletions internal/cbm/cbm.h
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@ typedef struct {
// pass_lsp_cross.c. Default false.
bool requires_lsp_resolution; // synthetic semantic candidate (for example an implicit
// C++ operator). Never fall back to textual resolution.
bool callee_is_locally_bound; // bare call foo() whose callee identifier is bound as a
// parameter of an enclosing function, so it cannot be the
// module-level foo. Python only today. Read by the
// weak-local-binding guard. Default false.
} CBMCall;

typedef struct {
Expand Down
130 changes: 130 additions & 0 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -2800,6 +2800,128 @@ static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) {
return false;
}

/* Name bound by one Python parameter node, or NULL when the shape binds none.
* Covers every binding form a `parameters` / `lambda_parameters` list produces:
* a bare `identifier`, the `name` field of default/typed parameters, and the
* identifier under a `*args` / `**kwargs` splat. A shape with no identifier (the
* bare `*` keyword separator) yields NULL and simply matches nothing. */
static const char *python_parameter_name(CBMExtractCtx *ctx, TSNode param) {
if (ts_node_is_null(param)) {
return NULL;
}
if (strcmp(ts_node_type(param), "identifier") == 0) {
return cbm_node_text(ctx->arena, param, ctx->source);
}
TSNode name = ts_node_child_by_field_name(param, TS_FIELD("name"));
if (!ts_node_is_null(name) && strcmp(ts_node_type(name), "identifier") == 0) {
return cbm_node_text(ctx->arena, name, ctx->source);
}
/* `*args` / `**kwargs`, and any typed shape without a `name` field: the
* bound identifier is the first named child. */
TSNode first = ts_node_named_child(param, 0);
if (!ts_node_is_null(first) && strcmp(ts_node_type(first), "identifier") == 0) {
return cbm_node_text(ctx->arena, first, ctx->source);
}
return NULL;
}

/* True when the callee of a BARE Python call `foo()` is bound as a parameter of
* an enclosing function or lambda — the bare-call counterpart of
* python_receiver_is_exempt above.
*
* A parameter binding shadows any module-level `foo` for the whole body, so
* resolving such a call to a project Function/Method by short name alone
* fabricates the edge BY CONSTRUCTION: `def _run_with_heavy_slot(run): run()`
* must not bind an unrelated `SatoriLive.run`. Unlike a receiver type this is
* decidable from the AST outright, with no flow analysis and no list of
* "generic-looking" callee names — Python forbids `global` on a parameter, and a
* parameter is in scope for the entire body regardless of position, so there is
* no ordering subtlety to get wrong.
*
* Enclosing scopes are walked to the file root so a closure over an outer
* parameter counts (`def outer(run): def inner(): return run()`).
*
* LOCAL ASSIGNMENTS are deliberately NOT covered. They are flow- and
* binding-form-sensitive (`for`, `with ... as`, `except ... as`, `:=`,
* unpacking, plus `global`/`nonlocal` overrides), so a partial body scan would
* suppress the wrong edges invisibly — the same failure mode that rules out a
* hardcoded name list. Parameters alone already cover the Callable-parameter
* shape that motivated this guard. Cost is O(enclosing depth x params) per bare
* call, never the corpus.
*
* Known and accepted: `def inner(): global run; return run()` nested in a
* function whose parameter is `run` is still flagged. Detecting it needs exactly
* the body scan this helper avoids, and it costs one edge in a shape that
* essentially does not occur. */
static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, WalkState *state, TSNode call_node,
TSNode callee_ident) {
/* Ancestors are walked with the UNIFIED WALK'S OWN CURSOR, not ts_node_parent().
*
* ts_node_parent() is not O(1): it restarts at the tree root and descends to
* find the parent (vendored ts_runtime/src/node.c), so it costs O(depth) per
* hop. A parent-chain walk is therefore O(depth^2) per call, and since every
* level of f(f(f(...))) is ITSELF a bare call, O(depth^3) across the file --
* tests/test_stack_overflow.c nests 30,000 deep, which is a hang, not a
* slowdown. Capping the hop COUNT does not fix that, because the cost is per
* hop; the walk itself has to be cheap.
*
* ts_tree_cursor_goto_parent() IS O(1) -- the cursor carries its path stack --
* so copying the walk cursor and ascending costs nothing per hop. Same reason
* CBMWalkScope keeps a frame stack instead of recomputing enclosing state, and
* same current_cursor idiom (including the identity guard) as
* usage_current_field_name in extract_usages.c.
*
* The hop cap then bounds the remaining O(depth) per call. 64 rather than
* Lean's 20 above: that walk looks for an IMMEDIATE declaration boundary,
* while this one crosses whatever statement/expression nesting separates a
* call from its enclosing def, so it needs headroom before it can bite on
* ordinary code.
*
* Both the cap and a missing/mismatched cursor FAIL OPEN -- return false, do
* NOT suppress. A guard whose whole justification is precision must never
* destroy an edge it did not actually prove was fabricated, so either can
* only ever cost a suppression, never a true edge. Pinned in
* test_extraction.c. */
enum { PY_MAX_SCOPE_WALK_DEPTH = 64 };

const char *callee_name = cbm_node_text(ctx->arena, callee_ident, ctx->source);
if (!callee_name || !callee_name[0]) {
return false;
}
/* Only trust the shared cursor when it is actually parked on this call. */
if (!state || !state->current_cursor ||
!ts_node_eq(ts_tree_cursor_current_node(state->current_cursor), call_node)) {
return false;
}

TSTreeCursor up = ts_tree_cursor_copy(state->current_cursor);
bool bound = false;
for (int walked = 0; walked < PY_MAX_SCOPE_WALK_DEPTH && !bound; walked++) {
if (!ts_tree_cursor_goto_parent(&up)) {
break;
}
TSNode scope = ts_tree_cursor_current_node(&up);
const char *kind = ts_node_type(scope);
if (strcmp(kind, "function_definition") != 0 && strcmp(kind, "lambda") != 0) {
continue;
}
TSNode params = ts_node_child_by_field_name(scope, TS_FIELD("parameters"));
if (ts_node_is_null(params)) {
continue;
}
uint32_t count = ts_node_named_child_count(params);
for (uint32_t i = 0; i < count; i++) {
const char *pname = python_parameter_name(ctx, ts_node_named_child(params, i));
if (pname && strcmp(pname, callee_name) == 0) {
bound = true;
break;
}
}
}
ts_tree_cursor_delete(&up);
return bound;
}

static bool is_objectscript_language(CBMLanguage language) {
return language == CBM_LANG_OBJECTSCRIPT_UDL || language == CBM_LANG_OBJECTSCRIPT_ROUTINE;
}
Expand Down Expand Up @@ -3424,11 +3546,19 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
// (`accelerator.print()` must not bind MockAccelerator.print).
// Imported receivers stay unflagged: module.function() is Python's
// canonical cross-file call and the import map resolves it.
// A BARE Python call foo() whose callee is bound as a parameter of an
// enclosing scope cannot be the module-level foo, so short-name
// resolution would fabricate the edge (`def f(run): run()` must not
// bind SatoriLive.run). Distinct from is_method: there is no receiver
// here, so the weak-member guard cannot see this class at all.
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"));
call.is_method = !python_receiver_is_exempt(ctx, obj);
} else if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
call.callee_is_locally_bound =
python_callee_is_bound_parameter(ctx, state, node, fn);
}
}
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent
Expand Down
10 changes: 9 additions & 1 deletion src/pipeline/pass_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -627,8 +627,16 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS;
/* Bare-call local-binding suppression. A member call has a receiver the
* guard above can reason about; a bare `run()` has none, so that guard
* cannot see this class at all. Python-only today because the extraction
* flag is set only for Python — this gate MUST match pass_parallel.c's
* exactly, for the same divergence reason noted above. */
bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON;
bool drop_plain_call =
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, 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 Down
7 changes: 6 additions & 1 deletion src/pipeline/pass_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -2482,8 +2482,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS;
/* Bare-call local-binding suppression — see the note in pass_calls.c.
* This gate MUST stay identical to the one there. */
bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON;
bool drop_plain_call =
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, 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
12 changes: 12 additions & 0 deletions src/pipeline/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,18 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
* Pure; unit-tested in test_registry.c. */
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy);

/* Bare-call counterpart of the guard above. True when a resolved BARE call edge
* binds a callee that is shadowed by an enclosing parameter, and the match came
* from a weak short-name strategy — so the edge is fabricated by construction
* (`def f(run): run()` must not bind an unrelated `SatoriLive.run`). Shares the
* member guard's drop-list, so lsp_* / import / same-module matches are kept.
* Deliberately keyed on the SCOPE FACT, not on the callee's spelling. The
* language set lives at the call sites (pass_calls.c / pass_parallel.c) and must
* be identical in both, or the sequential and parallel resolvers diverge.
* Pure; unit-tested in test_registry.c. */
bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound,
const char *strategy);

/* #725: drop a suffix_match CALLS edge when the caller language and the
* target file's language disagree. unique_name (candidates == 1) is #1572
* and is left alone; same_module / import_map / lsp_* are kept. JS/TS/TSX
Expand Down
57 changes: 47 additions & 10 deletions src/pipeline/registry.c
Original file line number Diff line number Diff line change
Expand Up @@ -448,22 +448,59 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
* per-language decision made at the call sites in pass_calls.c and
* pass_parallel.c, which MUST stay in lockstep — a gate added to only one of
* them diverges the sequential and parallel resolvers. */
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy) {
if (!enabled || !is_method || !strategy || !strategy[0]) {
/* The weak short-name strategies that actually reach the call-resolution
* guards: the registry's suffix_match / unique_name and the parallel
* field_type_hint. "fuzzy" is listed as defensive insurance only —
* cbm_registry_fuzzy_resolve is not wired into the sequential/parallel resolvers
* today, so it never reaches these helpers, but naming it keeps a future wiring
* from silently reintroducing the noise. Everything else — same_module /
* import_map / import_map_suffix / qualified_suffix / callee_suffix /
* service_pattern / lsp_* — is a receiver- or import-aware match and is KEPT.
*
* Shared by BOTH weak-call guards below so the drop-list exists exactly once: a
* list that drifted between the member guard and the local-binding guard would
* make the two disagree about what "weak" means. */
static bool weak_short_name_strategy(const char *strategy) {
if (!strategy || !strategy[0]) {
return false;
}
/* Weak short-name strategies that actually reach the call-resolution guards:
* the registry's suffix_match / unique_name and the parallel field_type_hint.
* "fuzzy" is listed as defensive insurance only — cbm_registry_fuzzy_resolve
* is not wired into the sequential/parallel resolvers today, so it never
* reaches this helper, but naming it keeps a future wiring from silently
* reintroducing the noise. Everything else — same_module / import_map /
* import_map_suffix / qualified_suffix / callee_suffix / service_pattern /
* lsp_* — is a receiver- or import-aware match and is KEPT. */
return strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "unique_name") == 0 ||
strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0;
}

bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy) {
if (!enabled || !is_method) {
return false;
}
return weak_short_name_strategy(strategy);
}

/* Bare-call counterpart of the member guard above. A Python call `foo()` whose
* callee identifier is bound as a parameter of an enclosing scope cannot be the
* module-level `foo`: the parameter shadows it for the whole body. Binding such
* a call to a project Function/Method by a weak short-name strategy fabricates
* the edge by construction (`def _run_with_heavy_slot(run): run()` ->
* SatoriLive.run).
*
* This is deliberately NOT keyed on the callee's spelling. A list of
* "generic-looking" names (get / run / execute) asserts that certain spellings
* are usually noise, which is a claim about corpus fashion rather than about
* what the resolver knew — and it ages invisibly, because nothing fails when the
* distribution shifts, the graph just quietly loses different edges. A parameter
* binding is a fact about THIS file's scope, decidable outright.
*
* `enabled` is the caller's per-language gate, kept out of the helper for the
* same reason as the member guard: the two call sites in pass_calls.c and
* pass_parallel.c MUST enumerate the identical language set, or the sequential
* and parallel resolvers diverge. Pure; unit-tested in test_registry.c. */
bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound,
const char *strategy) {
if (!enabled || !callee_is_locally_bound) {
return false;
}
return weak_short_name_strategy(strategy);
}

static bool js_ts_family(CBMLanguage lang) {
return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS;
Expand Down
Loading
Loading