Skip to content

Commit cc3e263

Browse files
authored
Merge pull request #1912 from DeusData/feat/python-bare-local-binding
fix(python): suppress bare calls shadowed by an enclosing parameter
2 parents db2f104 + 97517a4 commit cc3e263

11 files changed

Lines changed: 785 additions & 48 deletions

File tree

‎internal/cbm/cbm.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ typedef struct {
269269
// pass_lsp_cross.c. Default false.
270270
bool requires_lsp_resolution; // synthetic semantic candidate (for example an implicit
271271
// C++ operator). Never fall back to textual resolution.
272+
bool callee_is_locally_bound; // bare call foo() whose callee identifier is bound as a
273+
// parameter of an enclosing function, so it cannot be the
274+
// module-level foo. Python only today. Read by the
275+
// weak-local-binding guard. Default false.
272276
} CBMCall;
273277

274278
typedef struct {

‎internal/cbm/extract_calls.c‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2973,6 +2973,41 @@ static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) {
29732973
return false;
29742974
}
29752975

2976+
/* Name bound by one Python parameter node, or NULL when the shape binds none.
2977+
* Covers every binding form a `parameters` / `lambda_parameters` list produces:
2978+
* a bare `identifier`, the `name` field of default/typed parameters, and the
2979+
* identifier under a `*args` / `**kwargs` splat. A shape with no identifier (the
2980+
* bare `*` keyword separator) yields NULL and simply matches nothing. */
2981+
/* True when the callee of a BARE Python call `foo()` is bound as a parameter of
2982+
* an enclosing function or lambda -- the bare-call counterpart of
2983+
* python_receiver_is_exempt above.
2984+
*
2985+
* A parameter binding shadows any module-level `foo` for the whole body, so
2986+
* resolving such a call to a project Function/Method by short name alone
2987+
* fabricates the edge BY CONSTRUCTION: `def _run_with_heavy_slot(run): run()`
2988+
* must not bind an unrelated `SatoriLive.run`. Unlike a receiver type this is
2989+
* decidable from the AST outright, with no flow analysis and no list of
2990+
* "generic-looking" callee names -- Python forbids `global` on a parameter, and
2991+
* a parameter is in scope for the entire body regardless of position.
2992+
*
2993+
* The answer is CARRIED BY THE WALK rather than recomputed here. The unified
2994+
* walk binds a def's or lambda's parameters when it opens that scope and
2995+
* unwinds them when it closes it, so this is an O(1) map lookup. Deciding it
2996+
* by ascending the tree instead -- with ts_node_parent() or with a copied walk
2997+
* cursor -- costs O(depth) per call, and since every level of f(f(f(...))) is
2998+
* itself a bare call, that is quadratic across the file: the 30,000-deep
2999+
* fixture in tests/test_stack_overflow.c turned it into a hang rather than a
3000+
* slowdown. An O(1) lookup needs no hop cap, so unlike a capped walk this can
3001+
* no longer fail open on deep-but-ordinary code.
3002+
*
3003+
* It still answers false if the walk's own tracking hit an allocation failure,
3004+
* which costs a suppression and never a true edge. */
3005+
static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, WalkState *state,
3006+
TSNode callee_ident) {
3007+
const char *callee_name = cbm_node_text(ctx->arena, callee_ident, ctx->source);
3008+
return cbm_walk_python_param_is_bound(state, callee_name);
3009+
}
3010+
29763011
static bool is_objectscript_language(CBMLanguage language) {
29773012
return language == CBM_LANG_OBJECTSCRIPT_UDL || language == CBM_LANG_OBJECTSCRIPT_ROUTINE;
29783013
}
@@ -3597,11 +3632,18 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
35973632
// (`accelerator.print()` must not bind MockAccelerator.print).
35983633
// Imported receivers stay unflagged: module.function() is Python's
35993634
// canonical cross-file call and the import map resolves it.
3635+
// A BARE Python call foo() whose callee is bound as a parameter of an
3636+
// enclosing scope cannot be the module-level foo, so short-name
3637+
// resolution would fabricate the edge (`def f(run): run()` must not
3638+
// bind SatoriLive.run). Distinct from is_method: there is no receiver
3639+
// here, so the weak-member guard cannot see this class at all.
36003640
if (ctx->language == CBM_LANG_PYTHON && strcmp(ts_node_type(node), "call") == 0) {
36013641
TSNode fn = ts_node_child_by_field_name(node, TS_FIELD("function"));
36023642
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "attribute") == 0) {
36033643
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
36043644
call.is_method = !python_receiver_is_exempt(ctx, obj);
3645+
} else if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
3646+
call.callee_is_locally_bound = python_callee_is_bound_parameter(ctx, state, fn);
36053647
}
36063648
}
36073649
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent

‎internal/cbm/extract_unified.c‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,196 @@ static uint32_t add_lexical_scope(WalkState *state, TSNode node, CBMLexicalScope
119119
return scope->id;
120120
}
121121

122+
/* ── #1912: Python parameter bindings held live by the walk ───────────────────
123+
*
124+
* A bare Python call `run()` cannot honestly resolve to a project function by
125+
* short name when `run` is bound as a parameter of an enclosing def or lambda:
126+
* the parameter shadows any module-level `run` for the whole body, so the match
127+
* is fabricated by construction.
128+
*
129+
* Deciding that per call by ASCENDING the tree is the trap this replaces. Both
130+
* ts_node_parent() (O(depth) per hop) and a walk-cursor ascent (O(1) per hop,
131+
* O(depth) per call) are per-call costs, and every level of f(f(f(...))) is
132+
* itself a bare call, so the file-wide cost is quadratic or worse -- exactly
133+
* what CBMWalkScope's comment records for the state it replaced. Scanning the
134+
* frame stack has the same shape.
135+
*
136+
* So the walk carries the answer instead: a name -> active-count map, pushed
137+
* when a Python function or lambda scope opens and unwound when it closes.
138+
* Lookup is O(1), which is what removes the need for a hop cap.
139+
*
140+
* A COUNT, not a flag: `def outer(run): def inner(run):` binds one name twice,
141+
* and leaving the inner scope must not unbind the outer one.
142+
*
143+
* Every failure path answers "not bound", which can only ever cost a
144+
* suppression -- never a true edge. */
145+
146+
static uint32_t py_param_hash(const char *s) {
147+
uint32_t h = 2166136261u; /* FNV-1a */
148+
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
149+
h = (h ^ *p) * 16777619u;
150+
}
151+
return h ? h : 1u; /* 0 marks an empty slot */
152+
}
153+
154+
static CBMParamSlot *py_param_slot(WalkState *state, const char *name, uint32_t hash) {
155+
int mask = state->py_param_slot_capacity - 1;
156+
int i = (int)(hash & (uint32_t)mask);
157+
for (;;) {
158+
CBMParamSlot *slot = &state->py_param_slots[i];
159+
if (slot->hash == 0) {
160+
return slot; /* free slot -- caller decides whether to claim it */
161+
}
162+
if (slot->hash == hash && strcmp(slot->name, name) == 0) {
163+
return slot;
164+
}
165+
i = (i + 1) & mask;
166+
}
167+
}
168+
169+
static bool py_param_grow(WalkState *state) {
170+
int new_capacity = state->py_param_slot_capacity * PAIR_LEN;
171+
if (new_capacity <= state->py_param_slot_capacity) {
172+
return false;
173+
}
174+
CBMParamSlot *grown =
175+
(CBMParamSlot *)cbm_arena_alloc(state->arena, (size_t)new_capacity * sizeof(*grown));
176+
if (!grown) {
177+
return false;
178+
}
179+
memset(grown, 0, (size_t)new_capacity * sizeof(*grown));
180+
CBMParamSlot *old = state->py_param_slots;
181+
int old_capacity = state->py_param_slot_capacity;
182+
state->py_param_slots = grown;
183+
state->py_param_slot_capacity = new_capacity;
184+
for (int i = 0; i < old_capacity; i++) {
185+
if (old[i].hash != 0) {
186+
*py_param_slot(state, old[i].name, old[i].hash) = old[i];
187+
}
188+
}
189+
return true;
190+
}
191+
192+
static bool py_param_stack_reserve(WalkState *state) {
193+
if (state->py_param_stack_count < state->py_param_stack_capacity) {
194+
return true;
195+
}
196+
int new_capacity = state->py_param_stack_capacity * PAIR_LEN;
197+
if (new_capacity <= state->py_param_stack_capacity) {
198+
return false;
199+
}
200+
const char **grown =
201+
(const char **)cbm_arena_alloc(state->arena, (size_t)new_capacity * sizeof(*grown));
202+
if (!grown) {
203+
return false;
204+
}
205+
memcpy(grown, state->py_param_stack, (size_t)state->py_param_stack_count * sizeof(*grown));
206+
state->py_param_stack = grown;
207+
state->py_param_stack_capacity = new_capacity;
208+
return true;
209+
}
210+
211+
/* Bind one parameter name for the lifetime of the frame currently on top. */
212+
static void py_param_bind(WalkState *state, const char *name) {
213+
if (state->py_param_tracking_failed || !name || !name[0]) {
214+
return;
215+
}
216+
/* Grow before inserting: the probe below must always find a free slot, and
217+
* a table above ~70% load degrades toward linear probing. */
218+
if ((state->py_param_slot_used + 1) * 10 >= state->py_param_slot_capacity * 7) {
219+
if (!py_param_grow(state)) {
220+
state->py_param_tracking_failed = true;
221+
return;
222+
}
223+
}
224+
if (!py_param_stack_reserve(state)) {
225+
state->py_param_tracking_failed = true;
226+
return;
227+
}
228+
uint32_t hash = py_param_hash(name);
229+
CBMParamSlot *slot = py_param_slot(state, name, hash);
230+
if (slot->hash == 0) {
231+
slot->hash = hash;
232+
slot->name = name;
233+
slot->count = 0;
234+
state->py_param_slot_used++;
235+
}
236+
slot->count++;
237+
state->py_param_stack[state->py_param_stack_count++] = name;
238+
}
239+
240+
/* Unwind to a frame's entry height, undoing exactly what that frame bound. */
241+
static void py_param_unwind_to(WalkState *state, int base) {
242+
if (state->py_param_tracking_failed) {
243+
return;
244+
}
245+
while (state->py_param_stack_count > base) {
246+
const char *name = state->py_param_stack[--state->py_param_stack_count];
247+
CBMParamSlot *slot = py_param_slot(state, name, py_param_hash(name));
248+
if (slot->hash != 0 && slot->count > 0) {
249+
slot->count--;
250+
}
251+
}
252+
}
253+
254+
bool cbm_walk_python_param_is_bound(const WalkState *state, const char *name) {
255+
if (!state || !name || !name[0] || state->py_param_tracking_failed ||
256+
state->py_param_slot_used == 0) {
257+
return false;
258+
}
259+
const CBMParamSlot *slot = py_param_slot((WalkState *)state, name, py_param_hash(name));
260+
return slot->hash != 0 && slot->count > 0;
261+
}
262+
263+
/* The identifier a Python parameter node binds. Handles the bare, typed,
264+
* defaulted, keyword-only, *args and **kwargs shapes. */
265+
static const char *py_parameter_name(CBMExtractCtx *ctx, TSNode param) {
266+
if (ts_node_is_null(param)) {
267+
return NULL;
268+
}
269+
if (strcmp(ts_node_type(param), "identifier") == 0) {
270+
return cbm_node_text(ctx->arena, param, ctx->source);
271+
}
272+
TSNode name = ts_node_child_by_field_name(param, TS_FIELD("name"));
273+
if (!ts_node_is_null(name) && strcmp(ts_node_type(name), "identifier") == 0) {
274+
return cbm_node_text(ctx->arena, name, ctx->source);
275+
}
276+
/* `*args` / `**kwargs`, and any typed shape without a `name` field: the
277+
* bound identifier is the first named child. */
278+
TSNode first = ts_node_named_child(param, 0);
279+
if (!ts_node_is_null(first) && strcmp(ts_node_type(first), "identifier") == 0) {
280+
return cbm_node_text(ctx->arena, first, ctx->source);
281+
}
282+
return NULL;
283+
}
284+
285+
/* Bind this node's parameters into the frame just pushed for it. Called after
286+
* every push in push_boundary_scopes, so the top frame is this node's own and
287+
* pop_expired_scopes unwinds them at exactly the right depth. */
288+
static void py_bind_scope_parameters(CBMExtractCtx *ctx, TSNode node, WalkState *state) {
289+
if (ctx->language != CBM_LANG_PYTHON || state->scope_top == 0) {
290+
return;
291+
}
292+
const char *kind = ts_node_type(node);
293+
if (strcmp(kind, "function_definition") != 0 && strcmp(kind, "lambda") != 0) {
294+
return;
295+
}
296+
TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters"));
297+
if (ts_node_is_null(params)) {
298+
return;
299+
}
300+
uint32_t count = ts_node_named_child_count(params);
301+
for (uint32_t i = 0; i < count; i++) {
302+
py_param_bind(state, py_parameter_name(ctx, ts_node_named_child(params, i)));
303+
}
304+
}
305+
122306
static bool push_scope(WalkState *state, uint8_t kind, uint32_t depth, const char *qn) {
123307
if (!ensure_scope_capacity(state)) {
124308
return false;
125309
}
126310
CBMWalkScope *f = &state->scopes[state->scope_top];
311+
f->prev_py_param_stack_count = state->py_param_stack_count;
127312
f->kind = kind;
128313
f->depth = depth;
129314
f->qn = qn;
@@ -260,6 +445,7 @@ static void pop_expired_scopes(WalkState *state, uint32_t cur_depth) {
260445
state->inside_import = f->prev_inside_import;
261446
state->loop_depth = f->prev_loop_depth;
262447
state->branch_depth = f->prev_branch_depth;
448+
py_param_unwind_to(state, f->prev_py_param_stack_count);
263449
}
264450
}
265451

@@ -2354,6 +2540,7 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS
23542540
}
23552541

23562542
push_lexical_boundary(node, state, depth);
2543+
py_bind_scope_parameters(ctx, node, state);
23572544
push_call_scope(state, depth, invocation);
23582545
if (is_actual_import_boundary(ctx, node, spec) && !is_export_of_declaration(node)) {
23592546
push_scope(state, SCOPE_IMPORT, depth, NULL);
@@ -2387,6 +2574,14 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
23872574
state.scope_capacity = MAX_SCOPES;
23882575
state.lexical_scopes = state.inline_lexical_scopes;
23892576
state.lexical_scope_capacity = INLINE_LEXICAL_SCOPES;
2577+
state.py_param_slots = state.inline_py_param_slots;
2578+
state.py_param_slot_capacity = INLINE_PY_PARAM_SLOTS;
2579+
state.py_param_slot_used = 0;
2580+
memset(state.inline_py_param_slots, 0, sizeof(state.inline_py_param_slots));
2581+
state.py_param_stack = state.inline_py_param_stack;
2582+
state.py_param_stack_capacity = INLINE_PY_PARAM_STACK;
2583+
state.py_param_stack_count = 0;
2584+
state.py_param_tracking_failed = false;
23902585
state.usage_start_index = ctx->result->usages.count;
23912586
state.root_lexical_scope_id = add_lexical_scope(&state, ctx->root, CBM_LEXICAL_SCOPE_MODULE);
23922587
/* Base walk-state tuple (previously established by the first

‎internal/cbm/extract_unified.h‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
#define INLINE_LEXICAL_SCOPES 64
1919
#define INLINE_LEXICAL_BINDINGS 64
2020
#define INLINE_PYTHON_DIRECTIVES 16
21+
#define INLINE_PY_PARAM_SLOTS 64
22+
#define INLINE_PY_PARAM_STACK 64
2123

2224
// ObjectScript type map: variable name → class name (for instance_method_call
2325
// resolution). Stack-allocated, per-method scope. Overflow is silent (no crash).
@@ -51,6 +53,15 @@ typedef struct {
5153
bool raw_call_emitted;
5254
} CBMInvocationDescriptor;
5355

56+
/* One name bound as a function/lambda parameter by a scope currently OPEN on
57+
* the walk stack. A count, not a flag: `def outer(run): def inner(run):` binds
58+
* the same name twice and the inner pop must not unbind the outer. */
59+
typedef struct {
60+
const char *name;
61+
uint32_t hash;
62+
int count;
63+
} CBMParamSlot;
64+
5465
typedef struct {
5566
const char *qn;
5667
uint32_t depth;
@@ -73,6 +84,9 @@ typedef struct {
7384
bool prev_inside_import;
7485
int prev_loop_depth;
7586
int prev_branch_depth;
87+
/* #1912: py_param_stack height on entry. Pop unwinds back to it, so a
88+
* frame unbinds exactly the parameters it bound and nothing else. */
89+
int prev_py_param_stack_count;
7690
} CBMWalkScope;
7791

7892
typedef enum {
@@ -158,11 +172,43 @@ typedef struct {
158172
CBMPythonDirective inline_python_directives[INLINE_PYTHON_DIRECTIVES];
159173
int python_directive_capacity;
160174
int python_directive_count;
175+
/* #1912 -- Python bare-call shadowing. A name bound as a parameter by ANY
176+
* enclosing function or lambda shadows every project function, so a bare
177+
* `run()` under `def outer(run)` cannot honestly resolve by short name.
178+
*
179+
* Maintained as a live name->count map pushed and popped BY THE WALK, not
180+
* recomputed per call. Walking ancestors (either ts_node_parent or a
181+
* cursor) and scanning the frame stack are both O(depth) per call, and
182+
* since every level of f(f(f(...))) is itself a bare call that is
183+
* quadratic across the file -- the same trap CBMWalkScope records above.
184+
* Lookup here is O(1), so no hop cap and no fail-open cap are needed.
185+
*
186+
* The other binding table (CBMLexicalBinding) cannot serve this: it is
187+
* qsort-ed in cbm_finalize_lexical_usages AFTER the walk and its
188+
* active_end stays 0 until then, so its binary search is invalid from
189+
* handle_calls, which runs mid-walk and before handle_usages. */
190+
CBMParamSlot *py_param_slots;
191+
CBMParamSlot inline_py_param_slots[INLINE_PY_PARAM_SLOTS];
192+
int py_param_slot_capacity;
193+
int py_param_slot_used;
194+
const char **py_param_stack;
195+
const char *inline_py_param_stack[INLINE_PY_PARAM_STACK];
196+
int py_param_stack_capacity;
197+
int py_param_stack_count;
198+
/* Allocation failure: stop tracking and answer "not bound" forever after,
199+
* which can only cost a suppression, never a true edge. */
200+
bool py_param_tracking_failed;
201+
161202
CBMLanguage language;
162203

163204
os_type_map_t os_type_map; // ObjectScript variable → type mapping
164205
} WalkState;
165206

207+
/* #1912: is `name` bound as a parameter by a Python function or lambda scope
208+
* currently open on the walk stack? O(1). Answers false on any failure, so a
209+
* caller can only ever lose a suppression, never a true edge. */
210+
bool cbm_walk_python_param_is_bound(const WalkState *state, const char *name);
211+
166212
// Per-node handler prototypes. Each is called once per node during the
167213
// unified cursor walk, replacing the old recursive walk_* functions.
168214
CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec,

‎src/pipeline/pass_calls.c‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,8 +627,16 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
627627
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
628628
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
629629
lang == CBM_LANG_ARKTS;
630+
/* Bare-call local-binding suppression. A member call has a receiver the
631+
* guard above can reason about; a bare `run()` has none, so that guard
632+
* cannot see this class at all. Python-only today because the extraction
633+
* flag is set only for Python — this gate MUST match pass_parallel.c's
634+
* exactly, for the same divergence reason noted above. */
635+
bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON;
630636
bool drop_plain_call =
631-
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);
637+
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) ||
638+
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
639+
call->callee_is_locally_bound, res.strategy);
632640

633641
/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
634642
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library

0 commit comments

Comments
 (0)