diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 3ea5c48f5..f06104ee0 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -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 { diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 7e5dcecae..94d8b96b9 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -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; } @@ -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 diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index b25e9f592..c9744dda2 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -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 diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 1eeb55f83..d6cc648af 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -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 diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 4b1d15563..37f709a68 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -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 diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 5126bcbfe..790e34a60 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -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; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 73219f4da..c67e0a7aa 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4621,6 +4621,146 @@ TEST(extract_python_member_call_flags_is_method) { PASS(); } +/* Python bare-call local-binding flag (the bare-call counterpart of the + * receiver flag above). Pins BOTH directions: a callee shadowed by a parameter + * of an enclosing scope IS flagged so the resolver can suppress a weak + * short-name match, while an unshadowed callee — a genuine module-level + * function, an imported name, or a nested `def` — is NOT, so its true edge + * survives. Every parameter binding form the grammar produces is covered, since + * a form the extractor silently missed would leave that shape unguarded. */ +TEST(extract_python_bare_call_flags_locally_bound_callee) { + CBMFileResult *r = extract("from pkg import helper\n" + "\n" + "def outer(run, *rest, timeout=5, label: str = 'x', **opts):\n" + " def inner():\n" + " return run()\n" + " rest()\n" + " timeout()\n" + " label()\n" + " opts()\n" + " module_level()\n" + " helper()\n" + " return inner()\n" + "\n" + "def typed(cb: Callable):\n" + " return cb()\n" + "\n" + "apply_it = lambda fn: fn()\n", + CBM_LANG_PYTHON, "t", "x.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* callee name -> (expected flag, seen count) */ + struct { + const char *callee; + bool expect_bound; + int seen; + } cases[] = { + {"run", true, 0}, /* closure over an ENCLOSING function's parameter */ + {"rest", true, 0}, /* *args -> list_splat_pattern */ + {"timeout", true, 0}, /* default_parameter */ + {"label", true, 0}, /* typed_default_parameter (keyword-only) */ + {"opts", true, 0}, /* **kwargs -> dictionary_splat_pattern */ + {"cb", true, 0}, /* typed_parameter, no default */ + {"fn", true, 0}, /* lambda parameter */ + {"module_level", false, 0}, /* unbound: the true cross-file edge */ + {"helper", false, 0}, /* imported name, not a parameter */ + {"inner", false, 0}, /* nested def: a real target, keep the edge */ + }; + const int case_count = (int)(sizeof(cases) / sizeof(cases[0])); + + for (int i = 0; i < r->calls.count; i++) { + const char *cn = r->calls.items[i].callee_name; + if (!cn) { + continue; + } + for (int c = 0; c < case_count; c++) { + if (strcmp(cn, cases[c].callee) != 0) { + continue; + } + cases[c].seen++; + if (r->calls.items[i].callee_is_locally_bound != cases[c].expect_bound) { + printf(" bare-call flag mismatch for %s(): got %d, expected %d\n", cases[c].callee, + r->calls.items[i].callee_is_locally_bound ? 1 : 0, + cases[c].expect_bound ? 1 : 0); + } + ASSERT_EQ(r->calls.items[i].callee_is_locally_bound, cases[c].expect_bound); + } + } + /* Each shape must appear exactly once, so a missed extraction cannot let the + * loop above pass vacuously. */ + for (int c = 0; c < case_count; c++) { + if (cases[c].seen != 1) { + printf(" bare call %s() extracted %d times, expected 1\n", cases[c].callee, + cases[c].seen); + } + ASSERT_EQ(cases[c].seen, 1); + } + cbm_free_result(r); + PASS(); +} + +/* The scope walk behind the bare-call flag is BOUNDED (64 ancestors). An + * unbounded walk is QUADRATIC in a file's nesting depth -- every level of + * f(f(f(...))) is itself a bare call re-walking its own chain -- which hung + * stack_overflow_b's 30,000-deep fixture rather than merely slowing it. + * + * This pins the cap's CONTRACT deterministically rather than by wall clock (a + * timing assertion would be a lottery, not a gate): within the cap the shadowed + * callee is flagged; past it the guard FAILS OPEN and leaves the call alone, so + * the cap can only ever cost a suppression, never a true edge. Removing the cap + * flips the deep case to flagged and fails this test. */ +TEST(extract_python_bare_call_scope_walk_is_bounded) { + /* Shallow: return_statement / block / function_definition — 3 ancestors. */ + CBMFileResult *shallow = extract("def shallow(handler):\n" + " return handler()\n", + CBM_LANG_PYTHON, "t", "s.py"); + ASSERT_NOT_NULL(shallow); + ASSERT_FALSE(shallow->has_error); + int shallow_seen = 0; + for (int i = 0; i < shallow->calls.count; i++) { + const char *cn = shallow->calls.items[i].callee_name; + if (cn && strcmp(cn, "handler") == 0) { + shallow_seen++; + ASSERT_TRUE(shallow->calls.items[i].callee_is_locally_bound); + } + } + ASSERT_EQ(shallow_seen, 1); + cbm_free_result(shallow); + + /* Deep: 200 parenthesized_expression ancestors put the SAME call well past + * the cap, so the enclosing parameter is never reached and the call stays + * unflagged. */ + const int PARENS = 200; + size_t sz = (size_t)PARENS * 2 + 128; + char *src = malloc(sz); + ASSERT_NOT_NULL(src); + char *w = src; + w += snprintf(w, sz, "def deep(handler):\n return "); + memset(w, '(', (size_t)PARENS); + w += PARENS; + w += snprintf(w, sz - (size_t)(w - src), "handler()"); + memset(w, ')', (size_t)PARENS); + w += PARENS; + snprintf(w, sz - (size_t)(w - src), "\n"); + + CBMFileResult *deep = extract(src, CBM_LANG_PYTHON, "t", "d.py"); + ASSERT_NOT_NULL(deep); + ASSERT_FALSE(deep->has_error); + int deep_seen = 0; + for (int i = 0; i < deep->calls.count; i++) { + const char *cn = deep->calls.items[i].callee_name; + if (cn && strcmp(cn, "handler") == 0) { + deep_seen++; + ASSERT_FALSE(deep->calls.items[i].callee_is_locally_bound); + } + } + ASSERT_EQ(deep_seen, 1); + cbm_free_result(deep); + free(src); + 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 @@ -6300,6 +6440,8 @@ SUITE(extraction) { 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_python_bare_call_flags_locally_bound_callee); + RUN_TEST(extract_python_bare_call_scope_walk_is_bounded); 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); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index a45541e57..716b839a8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4836,6 +4836,60 @@ TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { PASS(); } +/* Python bare-call local-binding suppression, sequential path. The bare-call + * counterpart of the receiver guard above: `run` is a PARAMETER, so `run()` + * cannot be the module-level `run` and must not bind SatoriLive.run. + * + * The positive control is deliberately a CROSS-FILE bare call with no import, + * so it resolves by a weak short-name strategy — one this guard could have + * killed. Asserting a same-file (same_module) edge instead would prove nothing, + * because no guard in this codebase touches same_module for any input. + * Fewer than 50 files exercises pass_calls.c. */ +TEST(pipeline_python_bare_local_binding_suppresses_weak_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_bare_seq_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "live.py", + "class SatoriLive:\n" + " def run(self):\n" + " return 1\n"); + write_temp_file(tmp, "helpers.py", + "def compute_widget_total():\n" + " return 7\n"); + write_temp_file(tmp, "gate.py", + "def _run_with_heavy_slot(run):\n" + " return run()\n" + "\n" + "def uses_free_function():\n" + " return compute_widget_total()\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/py_bare.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* NEGATIVE: the callee is shadowed by a parameter. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "_run_with_heavy_slot", "run")); + /* POSITIVE: an unshadowed cross-file bare call survives. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "uses_free_function", "compute_widget_total")); + /* Tripwire: a run that emitted no edges at all would satisfy the negative + * assertion vacuously. */ + ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "CALLS"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Parallel Python regression for #1276. The field-type heuristic capitalizes * the receiver token and previously promoted accelerator.print() to * MockAccelerator.print at 0.85; ordinary suffix matching also selected one @@ -4933,6 +4987,78 @@ TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges) { PASS(); } +/* Parallel counterpart. >= 50 files forces pass_parallel.c, which is wired with + * the same gate: a guard wired on only one resolver produces an edge on the + * sequential path and not the parallel one, breaking MT determinism. #1386 + * wired both and tested only the sequential path, and the `parallel` suite is + * exactly what catches that. Same both-directions pin as the sequential test. */ +TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_bare_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "live.py", + "class SatoriLive:\n" + " def run(self):\n" + " return 1\n" + "\n" + "class BatchJob:\n" + " def execute(self):\n" + " return 2\n"); + write_temp_file(tmp, "helpers.py", + "def compute_widget_total():\n" + " return 7\n"); + write_temp_file(tmp, "gate.py", + "def _run_with_heavy_slot(run, execute):\n" + " run()\n" + " return execute()\n" + "\n" + "def uses_free_function():\n" + " return compute_widget_total()\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/py_bare_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* NEGATIVE: both callees are shadowed by parameters. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "_run_with_heavy_slot", "run")); + ASSERT_FALSE(cross_file_call_exists(s, project, "_run_with_heavy_slot", "execute")); + /* POSITIVE: the unshadowed cross-file bare call survives the parallel path. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "uses_free_function", "compute_widget_total")); + /* Tripwire against a vacuous pass. */ + ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "CALLS"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* Reproduce-first: pass_parallel's fused cross-LSP eligibility currently counts * only parser-backed calls/call references. A Python binary operator has no * parser CBMCall; its __add__ semantic record and carrier are created together @@ -12807,6 +12933,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); + RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); + RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); diff --git a/tests/test_registry.c b/tests/test_registry.c index ff81a50ee..2fd966948 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -853,6 +853,77 @@ TEST(dynamic_suppress_keeps_high_confidence_and_non_methods) { PASS(); } +TEST(local_binding_suppress_drops_weak_shadowed_bare_calls) { + /* A bare `run()` whose callee is a parameter of an enclosing scope cannot be + * the module-level `run`, so a weak short-name match fabricates the edge. */ + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "suffix_match")); + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "unique_name")); + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "field_type_hint")); + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "fuzzy")); + PASS(); +} + +TEST(local_binding_suppress_keeps_unshadowed_and_strong_strategies) { + /* THE RECALL PIN. A bare call to a genuine module-level function is NOT + * locally bound, so it is never suppressed — whatever the callee is spelled. + * This is the assertion a name-keyed guard (get/run/execute) would fail: it + * would drop these purely because of how the callee reads. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "suffix_match")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "unique_name")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "field_type_hint")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "fuzzy")); + /* Every receiver-/import-aware strategy is kept even when shadowed. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "same_module")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "import_map")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "import_map_suffix")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "qualified_suffix")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "callee_suffix")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "service_pattern")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "lsp_cross")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "lsp_py_method")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "lsp_direct")); + /* Languages outside the caller's gate are never affected. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(false, true, "suffix_match")); + /* No match (NULL/empty strategy) → nothing to suppress. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, NULL)); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "")); + PASS(); +} + +TEST(weak_call_guards_share_one_drop_list) { + /* The member guard and the local-binding guard must agree on what "weak" + * means. They share a single static predicate for exactly this reason; if + * someone re-inlines one of the lists and edits only that copy, the two + * guards start disagreeing and this test catches it at the contract level + * rather than in a corpus months later. */ + static const char *const strategies[] = {"suffix_match", + "unique_name", + "field_type_hint", + "fuzzy", + "same_module", + "import_map", + "import_map_suffix", + "qualified_suffix", + "callee_suffix", + "service_pattern", + "lsp_cross", + "lsp_ts_method", + "lsp_py_method", + "lsp_direct", + "", + NULL}; + for (int i = 0; strategies[i] != NULL; i++) { + bool member = cbm_suppress_weak_member_match(true, true, strategies[i]); + bool binding = cbm_suppress_weak_local_binding_call(true, true, strategies[i]); + if (member != binding) { + printf(" drop-list divergence on strategy \"%s\": member=%d binding=%d\n", + strategies[i], member, binding); + } + ASSERT_EQ(member, binding); + } + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* Method call THROUGH an imported symbol that is itself an indexed node @@ -947,4 +1018,7 @@ SUITE(registry) { RUN_TEST(cross_language_suffix_match_drops_py_vs_js); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); + RUN_TEST(local_binding_suppress_drops_weak_shadowed_bare_calls); + RUN_TEST(local_binding_suppress_keeps_unshadowed_and_strong_strategies); + RUN_TEST(weak_call_guards_share_one_drop_list); }