@@ -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+
122306static 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
0 commit comments