From 95689b5c20101db7d92b69767bbea75e5bf0d4fc Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 17:59:58 +0200 Subject: [PATCH 01/14] fix(python): suppress bare calls shadowed by an enclosing parameter A Python `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 -- so resolving the call to a project Function/Method by a weak short-name strategy fabricates the edge by construction: def _run_with_heavy_slot(run): return run() # bound an unrelated SatoriLive.run The receiver-aware weak-member guard (#1276) cannot see this class at all: a bare call has no receiver, so is_method is false and the guard never fires. This is the bare-call counterpart of python_receiver_is_exempt. Keyed on the SCOPE FACT, not 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: nothing fails when the distribution shifts, the graph just quietly loses different edges. A parameter binding is decidable from this file's AST outright. Parameters only, deliberately. A parameter is in scope for the entire body regardless of position and Python forbids `global` on one, so no flow analysis is needed. Local assignments are flow- and binding-form-sensitive (`for`, `with as`, `except as`, `:=`, unpacking, plus global/nonlocal overrides); a partial body scan would suppress the wrong edges invisibly -- the same failure mode that rules out the name list. Enclosing scopes are walked to the file root so a closure over an outer parameter counts. Wired at both pass_calls.c and pass_parallel.c with an identical language gate: a guard on one resolver only diverges the sequential and parallel paths. The weak-strategy drop-list is now a single shared predicate used by both the member guard and this one, so they cannot disagree about what "weak" means; a unit test pins that agreement. Tests pin both directions. The pipeline positive control is a cross-file bare call with NO import, so it resolves by a weak strategy this guard could have killed -- asserting a same_module edge would prove nothing, since no guard touches same_module for any input. Verified by breaking the guard in both directions: under-suppressing fails the negatives and the extraction flag; over-suppressing fails the positives, so they are not vacuous. Disabling only the pass_parallel.c gate fails only the >=50-file test and nothing else. 805 passed / 0 failed across pipeline registry parallel extraction complexity lsp_resolution_probe. The defect class was identified by Joseph-MingEn in #1386, which proposed a name-keyed shape; the diagnosis is theirs. Signed-off-by: Martin Vogel Co-authored-by: Joseph-MingEn <125283161+Joseph-MingEn@users.noreply.github.com> --- internal/cbm/cbm.h | 4 ++ internal/cbm/extract_calls.c | 87 ++++++++++++++++++++++++ src/pipeline/pass_calls.c | 10 ++- src/pipeline/pass_parallel.c | 7 +- src/pipeline/pipeline.h | 12 ++++ src/pipeline/registry.c | 57 +++++++++++++--- tests/test_extraction.c | 80 ++++++++++++++++++++++ tests/test_pipeline.c | 128 +++++++++++++++++++++++++++++++++++ tests/test_registry.c | 63 +++++++++++++++++ 9 files changed, 436 insertions(+), 12 deletions(-) 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..c65725abb 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2800,6 +2800,86 @@ 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, TSNode call_node, + TSNode callee_ident) { + const char *callee_name = cbm_node_text(ctx->arena, callee_ident, ctx->source); + if (!callee_name || !callee_name[0]) { + return false; + } + for (TSNode scope = ts_node_parent(call_node); !ts_node_is_null(scope); + scope = ts_node_parent(scope)) { + 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) { + return true; + } + } + } + return false; +} + static bool is_objectscript_language(CBMLanguage language) { return language == CBM_LANG_OBJECTSCRIPT_UDL || language == CBM_LANG_OBJECTSCRIPT_ROUTINE; } @@ -3424,11 +3504,18 @@ 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, 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..7657835eb 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4621,6 +4621,85 @@ 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(); +} + /* 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 +6379,7 @@ 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_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..3747501ab 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -853,6 +853,66 @@ 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 +1007,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); } From 8bcfa2915fa02721c5b6d2a6c7082a26fef79d88 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 18:03:01 +0200 Subject: [PATCH 02/14] style: clang-format the new bare-call tests Changed-range clang-format (Homebrew LLVM 22.1.8, the build CI's lint-ci uses) flagged two alignment violations in the tests added by the previous commit. Whitespace only; 805 passed / 0 failed re-run after the reformat. Signed-off-by: Martin Vogel --- tests/test_extraction.c | 14 +++++++------- tests/test_registry.c | 21 ++++++++++++++++----- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7657835eb..0bfcaf552 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4656,13 +4656,13 @@ TEST(extract_python_bare_call_flags_locally_bound_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 */ + {"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 */ diff --git a/tests/test_registry.c b/tests/test_registry.c index 3747501ab..2fd966948 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -896,11 +896,22 @@ TEST(weak_call_guards_share_one_drop_list) { * 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}; + 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]); From 0b0d143c2d733e3313eef5b9d9745b0fb08cc2c0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 21:11:45 +0200 Subject: [PATCH 03/14] fix(python): walk bare-call scopes with the walk cursor, bounded #1912 hung stack_overflow_b on all seven CI legs: rc=124 at 900s with zero tests completed, on lsp_python_deep_nesting_no_crash. The bare-call scope walk used ts_node_parent(), which is NOT O(1) -- it restarts at the tree root and descends to find the parent (vendored ts_runtime/src/node.c). Each hop therefore costs O(depth), a parent chain is O(depth^2) per call, and since every level of f(f(f(...))) is itself a bare call, O(depth^3) across the file. The fixture nests 30,000 deep, so this is a hang rather than a slowdown. A hop-count cap alone is NOT sufficient, and this was measured rather than assumed: with a 64-hop cap the suite still timed out, because 64 x O(30,000) x 30,000 calls is still ~5.7e10. When the cost is per hop, bounding the number of hops cannot fix it -- the walk itself has to be cheap. Use the unified walk's own cursor. WalkState.current_cursor is already parked on the current node and ts_tree_cursor_goto_parent() IS O(1), because the cursor carries its path stack, so ascending costs nothing per hop. Same current_cursor idiom, including the ts_node_eq identity guard before trusting the shared cursor, as usage_current_field_name in extract_usages.c. This is the same lesson CBMWalkScope already records: carry walk state, never recompute it per node. The 64-hop cap is retained and now does real work, bounding the remaining O(depth) per call. Precedent for the shape: CBM_LSP_PERL_MAX_WALK_DEPTH and LEAN_MAX_PARENT_DEPTH in this file. Both the cap and a missing or mismatched cursor FAIL OPEN -- they can only ever cost a suppression, never a true edge, which is the safe direction for a guard whose justification is precision. lsp_python_deep_nesting_no_crash: 900s timeout, 0 completions -> PASS. stack_overflow_b overall: 6 passed in 46s. The regression test pins the cap's contract deterministically rather than by wall clock: within the cap a shadowed callee is flagged; past 64 ancestors the guard fails open and leaves it unflagged. Raising the cap flips the deep case and fails the test, verified. 826 passed / 0 failed across stack_overflow_a stack_overflow_b stack_overflow_c pipeline registry parallel extraction complexity lsp_resolution_probe. Signed-off-by: Martin Vogel Co-authored-by: Joseph-MingEn <125283161+Joseph-MingEn@users.noreply.github.com> --- internal/cbm/extract_calls.c | 55 ++++++++++++++++++++++++++++---- tests/test_extraction.c | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index c65725abb..94d8b96b9 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2853,14 +2853,54 @@ static const char *python_parameter_name(CBMExtractCtx *ctx, TSNode param) { * 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, TSNode call_node, +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; } - for (TSNode scope = ts_node_parent(call_node); !ts_node_is_null(scope); - scope = ts_node_parent(scope)) { + /* 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; @@ -2873,11 +2913,13 @@ static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, TSNode call_nod 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) { - return true; + bound = true; + break; } } } - return false; + ts_tree_cursor_delete(&up); + return bound; } static bool is_objectscript_language(CBMLanguage language) { @@ -3515,7 +3557,8 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML 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, node, fn); + 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/tests/test_extraction.c b/tests/test_extraction.c index 0bfcaf552..c67e0a7aa 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4700,6 +4700,67 @@ TEST(extract_python_bare_call_flags_locally_bound_callee) { 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 @@ -6380,6 +6441,7 @@ SUITE(extraction) { 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); From 97517a4652f376cb6bf096255e235edf456f86b1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 2 Sep 2026 10:45:37 +0200 Subject: [PATCH 04/14] fix(python): carry bare-call parameter bindings in the walk Replaces the cursor ascent from 0b0d143c. That commit fixed a real hang -- ts_node_parent() is O(depth), so a parent chain is O(depth^2) per call and O(depth^3) across a file where every level of f(f(f(...))) is itself a bare call, which hung stack_overflow_b's 30,000-deep fixture at 900s. But it fixed it by making each hop cheap rather than by removing the per-call walk, so the cost stayed O(depth) per call and needed a 64-ancestor cap to stay bounded. That cap FAILS OPEN. Past 64 ancestors the guard stopped suppressing, so deep-but-ordinary code silently kept the fabricated edges the guard exists to remove -- and nothing failed when it did. Carry the answer instead. The unified walk binds a def's or lambda's parameters when it opens that scope and unwinds them when it closes, so the guard is an O(1) map lookup. This is the lesson CBMWalkScope already records one struct away: carry walk state, never recompute it per node. - CBMParamSlot is a name -> active-count map, not a set. `def outer(run): def inner(run):` binds one name twice and leaving the inner scope must not unbind the outer. - Bindings are pushed at the end of push_boundary_scopes, after every scope push for that node, so they land in the frame just pushed -- covering function_definition (push_function_scope) and lambda (push_lexical_boundary) alike -- and pop_expired_scopes unwinds to the frame's entry height. - Allocation failure sets py_param_tracking_failed and the lookup answers false forever after: a lost suppression, never a lost edge. The repo's other binding table cannot serve this. CBMLexicalBinding is qsort-ed in cbm_finalize_lexical_usages AFTER the walk and its active_end stays 0 until then, so its binary search is invalid from handle_calls, which runs mid-walk and before handle_usages. extract_python_bare_call_scope_walk_is_bounded pinned the cap's fail-open contract, which no longer exists, and is replaced by extract_python_bare_call_flag_is_depth_independent. It keeps the shallow and 200-ancestor cases -- the deep one now asserts the call IS flagged, so reintroducing a cap fails it -- and adds an unwind case: `handler` as both a parameter and a module-level def, asserting the two shadowed calls are flagged and the sibling call after that scope closed is NOT. A binding outliving its frame would destroy that true edge, the one direction this guard must never fail in. It passed on the first run. extraction 327 passed; pipeline registry parallel complexity 407 passed; stack_overflow_b 6 passed in 38s (46s with the cursor ascent, 900s rc=124 before it), lsp_python_deep_nesting_no_crash PASS. Signed-off-by: Martin Vogel --- internal/cbm/extract_calls.c | 122 +++------------------ internal/cbm/extract_unified.c | 195 +++++++++++++++++++++++++++++++++ internal/cbm/extract_unified.h | 46 ++++++++ tests/test_extraction.c | 146 +++++++++++++++--------- 4 files changed, 353 insertions(+), 156 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index cb81fc543..b481fab6d 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2970,28 +2970,8 @@ static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) { * 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 + * 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 @@ -2999,92 +2979,25 @@ static const char *python_parameter_name(CBMExtractCtx *ctx, TSNode param) { * 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. + * "generic-looking" callee names -- Python forbids `global` on a parameter, and + * a parameter is in scope for the entire body regardless of position. * - * Enclosing scopes are walked to the file root so a closure over an outer - * parameter counts (`def outer(run): def inner(): return run()`). + * The answer is CARRIED BY THE WALK rather than recomputed here. The unified + * walk binds a def's or lambda's parameters when it opens that scope and + * unwinds them when it closes it, so this is an O(1) map lookup. Deciding it + * by ascending the tree instead -- with ts_node_parent() or with a copied walk + * cursor -- costs O(depth) per call, and since every level of f(f(f(...))) is + * itself a bare call, that is quadratic across the file: the 30,000-deep + * fixture in tests/test_stack_overflow.c turned it into a hang rather than a + * slowdown. An O(1) lookup needs no hop cap, so unlike a capped walk this can + * no longer fail open on deep-but-ordinary code. * - * 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, + * It still answers false if the walk's own tracking hit an allocation failure, + * which costs a suppression and never a true edge. */ +static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, WalkState *state, 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; + return cbm_walk_python_param_is_bound(state, callee_name); } static bool is_objectscript_language(CBMLanguage language) { @@ -3722,8 +3635,7 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML 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); + call.callee_is_locally_bound = python_callee_is_bound_parameter(ctx, state, fn); } } // TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index a5e314272..5c2905f9c 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -119,11 +119,196 @@ static uint32_t add_lexical_scope(WalkState *state, TSNode node, CBMLexicalScope return scope->id; } +/* ── #1912: Python parameter bindings held live by the walk ─────────────────── + * + * A bare Python call `run()` cannot honestly resolve to a project function by + * short name when `run` is bound as a parameter of an enclosing def or lambda: + * the parameter shadows any module-level `run` for the whole body, so the match + * is fabricated by construction. + * + * Deciding that per call by ASCENDING the tree is the trap this replaces. Both + * ts_node_parent() (O(depth) per hop) and a walk-cursor ascent (O(1) per hop, + * O(depth) per call) are per-call costs, and every level of f(f(f(...))) is + * itself a bare call, so the file-wide cost is quadratic or worse -- exactly + * what CBMWalkScope's comment records for the state it replaced. Scanning the + * frame stack has the same shape. + * + * So the walk carries the answer instead: a name -> active-count map, pushed + * when a Python function or lambda scope opens and unwound when it closes. + * Lookup is O(1), which is what removes the need for a hop cap. + * + * A COUNT, not a flag: `def outer(run): def inner(run):` binds one name twice, + * and leaving the inner scope must not unbind the outer one. + * + * Every failure path answers "not bound", which can only ever cost a + * suppression -- never a true edge. */ + +static uint32_t py_param_hash(const char *s) { + uint32_t h = 2166136261u; /* FNV-1a */ + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + h = (h ^ *p) * 16777619u; + } + return h ? h : 1u; /* 0 marks an empty slot */ +} + +static CBMParamSlot *py_param_slot(WalkState *state, const char *name, uint32_t hash) { + int mask = state->py_param_slot_capacity - 1; + int i = (int)(hash & (uint32_t)mask); + for (;;) { + CBMParamSlot *slot = &state->py_param_slots[i]; + if (slot->hash == 0) { + return slot; /* free slot -- caller decides whether to claim it */ + } + if (slot->hash == hash && strcmp(slot->name, name) == 0) { + return slot; + } + i = (i + 1) & mask; + } +} + +static bool py_param_grow(WalkState *state) { + int new_capacity = state->py_param_slot_capacity * PAIR_LEN; + if (new_capacity <= state->py_param_slot_capacity) { + return false; + } + CBMParamSlot *grown = + (CBMParamSlot *)cbm_arena_alloc(state->arena, (size_t)new_capacity * sizeof(*grown)); + if (!grown) { + return false; + } + memset(grown, 0, (size_t)new_capacity * sizeof(*grown)); + CBMParamSlot *old = state->py_param_slots; + int old_capacity = state->py_param_slot_capacity; + state->py_param_slots = grown; + state->py_param_slot_capacity = new_capacity; + for (int i = 0; i < old_capacity; i++) { + if (old[i].hash != 0) { + *py_param_slot(state, old[i].name, old[i].hash) = old[i]; + } + } + return true; +} + +static bool py_param_stack_reserve(WalkState *state) { + if (state->py_param_stack_count < state->py_param_stack_capacity) { + return true; + } + int new_capacity = state->py_param_stack_capacity * PAIR_LEN; + if (new_capacity <= state->py_param_stack_capacity) { + return false; + } + const char **grown = + (const char **)cbm_arena_alloc(state->arena, (size_t)new_capacity * sizeof(*grown)); + if (!grown) { + return false; + } + memcpy(grown, state->py_param_stack, (size_t)state->py_param_stack_count * sizeof(*grown)); + state->py_param_stack = grown; + state->py_param_stack_capacity = new_capacity; + return true; +} + +/* Bind one parameter name for the lifetime of the frame currently on top. */ +static void py_param_bind(WalkState *state, const char *name) { + if (state->py_param_tracking_failed || !name || !name[0]) { + return; + } + /* Grow before inserting: the probe below must always find a free slot, and + * a table above ~70% load degrades toward linear probing. */ + if ((state->py_param_slot_used + 1) * 10 >= state->py_param_slot_capacity * 7) { + if (!py_param_grow(state)) { + state->py_param_tracking_failed = true; + return; + } + } + if (!py_param_stack_reserve(state)) { + state->py_param_tracking_failed = true; + return; + } + uint32_t hash = py_param_hash(name); + CBMParamSlot *slot = py_param_slot(state, name, hash); + if (slot->hash == 0) { + slot->hash = hash; + slot->name = name; + slot->count = 0; + state->py_param_slot_used++; + } + slot->count++; + state->py_param_stack[state->py_param_stack_count++] = name; +} + +/* Unwind to a frame's entry height, undoing exactly what that frame bound. */ +static void py_param_unwind_to(WalkState *state, int base) { + if (state->py_param_tracking_failed) { + return; + } + while (state->py_param_stack_count > base) { + const char *name = state->py_param_stack[--state->py_param_stack_count]; + CBMParamSlot *slot = py_param_slot(state, name, py_param_hash(name)); + if (slot->hash != 0 && slot->count > 0) { + slot->count--; + } + } +} + +bool cbm_walk_python_param_is_bound(const WalkState *state, const char *name) { + if (!state || !name || !name[0] || state->py_param_tracking_failed || + state->py_param_slot_used == 0) { + return false; + } + const CBMParamSlot *slot = py_param_slot((WalkState *)state, name, py_param_hash(name)); + return slot->hash != 0 && slot->count > 0; +} + +/* The identifier a Python parameter node binds. Handles the bare, typed, + * defaulted, keyword-only, *args and **kwargs shapes. */ +static const char *py_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; +} + +/* Bind this node's parameters into the frame just pushed for it. Called after + * every push in push_boundary_scopes, so the top frame is this node's own and + * pop_expired_scopes unwinds them at exactly the right depth. */ +static void py_bind_scope_parameters(CBMExtractCtx *ctx, TSNode node, WalkState *state) { + if (ctx->language != CBM_LANG_PYTHON || state->scope_top == 0) { + return; + } + const char *kind = ts_node_type(node); + if (strcmp(kind, "function_definition") != 0 && strcmp(kind, "lambda") != 0) { + return; + } + TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return; + } + uint32_t count = ts_node_named_child_count(params); + for (uint32_t i = 0; i < count; i++) { + py_param_bind(state, py_parameter_name(ctx, ts_node_named_child(params, i))); + } +} + static bool push_scope(WalkState *state, uint8_t kind, uint32_t depth, const char *qn) { if (!ensure_scope_capacity(state)) { return false; } CBMWalkScope *f = &state->scopes[state->scope_top]; + f->prev_py_param_stack_count = state->py_param_stack_count; f->kind = kind; f->depth = depth; f->qn = qn; @@ -260,6 +445,7 @@ static void pop_expired_scopes(WalkState *state, uint32_t cur_depth) { state->inside_import = f->prev_inside_import; state->loop_depth = f->prev_loop_depth; state->branch_depth = f->prev_branch_depth; + py_param_unwind_to(state, f->prev_py_param_stack_count); } } @@ -2354,6 +2540,7 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS } push_lexical_boundary(node, state, depth); + py_bind_scope_parameters(ctx, node, state); push_call_scope(state, depth, invocation); if (is_actual_import_boundary(ctx, node, spec) && !is_export_of_declaration(node)) { push_scope(state, SCOPE_IMPORT, depth, NULL); @@ -2387,6 +2574,14 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { state.scope_capacity = MAX_SCOPES; state.lexical_scopes = state.inline_lexical_scopes; state.lexical_scope_capacity = INLINE_LEXICAL_SCOPES; + state.py_param_slots = state.inline_py_param_slots; + state.py_param_slot_capacity = INLINE_PY_PARAM_SLOTS; + state.py_param_slot_used = 0; + memset(state.inline_py_param_slots, 0, sizeof(state.inline_py_param_slots)); + state.py_param_stack = state.inline_py_param_stack; + state.py_param_stack_capacity = INLINE_PY_PARAM_STACK; + state.py_param_stack_count = 0; + state.py_param_tracking_failed = false; state.usage_start_index = ctx->result->usages.count; state.root_lexical_scope_id = add_lexical_scope(&state, ctx->root, CBM_LEXICAL_SCOPE_MODULE); /* Base walk-state tuple (previously established by the first diff --git a/internal/cbm/extract_unified.h b/internal/cbm/extract_unified.h index 820d2dc0a..f80237229 100644 --- a/internal/cbm/extract_unified.h +++ b/internal/cbm/extract_unified.h @@ -18,6 +18,8 @@ #define INLINE_LEXICAL_SCOPES 64 #define INLINE_LEXICAL_BINDINGS 64 #define INLINE_PYTHON_DIRECTIVES 16 +#define INLINE_PY_PARAM_SLOTS 64 +#define INLINE_PY_PARAM_STACK 64 // ObjectScript type map: variable name → class name (for instance_method_call // resolution). Stack-allocated, per-method scope. Overflow is silent (no crash). @@ -51,6 +53,15 @@ typedef struct { bool raw_call_emitted; } CBMInvocationDescriptor; +/* One name bound as a function/lambda parameter by a scope currently OPEN on + * the walk stack. A count, not a flag: `def outer(run): def inner(run):` binds + * the same name twice and the inner pop must not unbind the outer. */ +typedef struct { + const char *name; + uint32_t hash; + int count; +} CBMParamSlot; + typedef struct { const char *qn; uint32_t depth; @@ -73,6 +84,9 @@ typedef struct { bool prev_inside_import; int prev_loop_depth; int prev_branch_depth; + /* #1912: py_param_stack height on entry. Pop unwinds back to it, so a + * frame unbinds exactly the parameters it bound and nothing else. */ + int prev_py_param_stack_count; } CBMWalkScope; typedef enum { @@ -158,11 +172,43 @@ typedef struct { CBMPythonDirective inline_python_directives[INLINE_PYTHON_DIRECTIVES]; int python_directive_capacity; int python_directive_count; + /* #1912 -- Python bare-call shadowing. A name bound as a parameter by ANY + * enclosing function or lambda shadows every project function, so a bare + * `run()` under `def outer(run)` cannot honestly resolve by short name. + * + * Maintained as a live name->count map pushed and popped BY THE WALK, not + * recomputed per call. Walking ancestors (either ts_node_parent or a + * cursor) and scanning the frame stack are both O(depth) per call, and + * since every level of f(f(f(...))) is itself a bare call that is + * quadratic across the file -- the same trap CBMWalkScope records above. + * Lookup here is O(1), so no hop cap and no fail-open cap are needed. + * + * The other binding table (CBMLexicalBinding) cannot serve this: it is + * qsort-ed in cbm_finalize_lexical_usages AFTER the walk and its + * active_end stays 0 until then, so its binary search is invalid from + * handle_calls, which runs mid-walk and before handle_usages. */ + CBMParamSlot *py_param_slots; + CBMParamSlot inline_py_param_slots[INLINE_PY_PARAM_SLOTS]; + int py_param_slot_capacity; + int py_param_slot_used; + const char **py_param_stack; + const char *inline_py_param_stack[INLINE_PY_PARAM_STACK]; + int py_param_stack_capacity; + int py_param_stack_count; + /* Allocation failure: stop tracking and answer "not bound" forever after, + * which can only cost a suppression, never a true edge. */ + bool py_param_tracking_failed; + CBMLanguage language; os_type_map_t os_type_map; // ObjectScript variable → type mapping } WalkState; +/* #1912: is `name` bound as a parameter by a Python function or lambda scope + * currently open on the walk stack? O(1). Answers false on any failure, so a + * caller can only ever lose a suppression, never a true edge. */ +bool cbm_walk_python_param_is_bound(const WalkState *state, const char *name); + // Per-node handler prototypes. Each is called once per node during the // unified cursor walk, replacing the old recursive walk_* functions. CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 654bfe406..55000a395 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1225,19 +1225,18 @@ TEST(form_procedure) { /* --- Oracle PL/SQL --- */ TEST(plsql_package_and_call) { - const char *src = - "CREATE OR REPLACE PACKAGE BODY emp_pkg AS\n" - " FUNCTION hire(p_name VARCHAR2) RETURN NUMBER IS\n" - " v_sal NUMBER;\n" - " BEGIN\n" - " v_sal := util_pkg.calc_salary(p_name);\n" - " IF v_sal > 0 THEN\n" - " RETURN v_sal;\n" - " END IF;\n" - " RAISE no_data_found;\n" - " END;\n" - "END emp_pkg;\n" - "/\n"; + const char *src = "CREATE OR REPLACE PACKAGE BODY emp_pkg AS\n" + " FUNCTION hire(p_name VARCHAR2) RETURN NUMBER IS\n" + " v_sal NUMBER;\n" + " BEGIN\n" + " v_sal := util_pkg.calc_salary(p_name);\n" + " IF v_sal > 0 THEN\n" + " RETURN v_sal;\n" + " END IF;\n" + " RAISE no_data_found;\n" + " END;\n" + "END emp_pkg;\n" + "/\n"; CBMFileResult *r = extract(src, CBM_LANG_PLSQL, "t", "emp_pkg.pkb"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); @@ -2202,9 +2201,10 @@ TEST(dbt_source_and_two_arg_ref) { /* Both dbt builtins name the relation in their LAST string argument: * source('group','table') -> table, and the two-argument * ref('package','model') form -> model. */ - CBMFileResult *r = extract("SELECT * FROM {{ source('raw', 'customers') }}\n" - "UNION ALL SELECT * FROM {{ ref('analytics', 'legacy_customers') }}\n", - CBM_LANG_SQL, "t", "models/stg_customers.sql"); + CBMFileResult *r = + extract("SELECT * FROM {{ source('raw', 'customers') }}\n" + "UNION ALL SELECT * FROM {{ ref('analytics', 'legacy_customers') }}\n", + CBM_LANG_SQL, "t", "models/stg_customers.sql"); ASSERT_NOT_NULL(r); ASSERT(has_def(r, "Model", "stg_customers")); ASSERT(has_usage(r, "customers")); @@ -3276,8 +3276,8 @@ TEST(vue_embedded_structure_negative_controls_issue1410) { } TEST(vue_embedded_structure_host_controls_issue1410) { - CBMFileResult *plain = extract("function plainTs(): void { target(); }\n", CBM_LANG_TYPESCRIPT, - "t", "plain.ts"); + CBMFileResult *plain = + extract("function plainTs(): void { target(); }\n", CBM_LANG_TYPESCRIPT, "t", "plain.ts"); ASSERT_NOT_NULL(plain); ASSERT_FALSE(plain->has_error); ASSERT_EQ(count_defs_named(plain, "Function", "plainTs"), 1); @@ -3735,11 +3735,11 @@ TEST(extract_java_method_annotations_issue382) { /* ── ArkTS (HarmonyOS .ets) ─────────────────────────────────────── */ TEST(arkts_component_struct) { - CBMFileResult *r = extract( - "@Entry\n@Component\nstruct Index {\n @State message: string = 'Hello'\n\n" - " build() {\n Column() {\n Text(this.message).fontSize(20)\n }\n" - " .width('100%')\n }\n}\n", - CBM_LANG_ARKTS, "t", "Index.ets"); + CBMFileResult *r = + extract("@Entry\n@Component\nstruct Index {\n @State message: string = 'Hello'\n\n" + " build() {\n Column() {\n Text(this.message).fontSize(20)\n }\n" + " .width('100%')\n }\n}\n", + CBM_LANG_ARKTS, "t", "Index.ets"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT(has_def(r, "Struct", "Index")); @@ -3770,12 +3770,12 @@ TEST(arkts_exported_struct_decorators) { } TEST(arkts_member_decorators) { - CBMFileResult *r = extract( - "@Component\nstruct S {\n @State a: number = 0\n @Prop b: string\n" - " @Link c: boolean\n @Provide('k') d: string = ''\n @Consume('k') e: string\n" - " @StorageLink('s') f: number = 1\n @State @Watch('onW') g: boolean = false\n\n" - " build() {\n }\n}\n", - CBM_LANG_ARKTS, "t", "S.ets"); + CBMFileResult *r = + extract("@Component\nstruct S {\n @State a: number = 0\n @Prop b: string\n" + " @Link c: boolean\n @Provide('k') d: string = ''\n @Consume('k') e: string\n" + " @StorageLink('s') f: number = 1\n @State @Watch('onW') g: boolean = false\n\n" + " build() {\n }\n}\n", + CBM_LANG_ARKTS, "t", "S.ets"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT(decorators_contain(find_def_by_name(r, "a"), "State")); @@ -3815,11 +3815,11 @@ TEST(arkts_no_phantom_builtin_defs) { } TEST(arkts_builder_extend_styles) { - CBMFileResult *r = extract( - "@Builder\nfunction card(t: string) {\n Column() {\n Text(t)\n }\n}\n\n" - "@Extend(Text)\nfunction fancy(size: number) {\n .fontSize(size)\n}\n\n" - "@Styles\nfunction pressed() {\n .backgroundColor('#eee')\n}\n", - CBM_LANG_ARKTS, "t", "b.ets"); + CBMFileResult *r = + extract("@Builder\nfunction card(t: string) {\n Column() {\n Text(t)\n }\n}\n\n" + "@Extend(Text)\nfunction fancy(size: number) {\n .fontSize(size)\n}\n\n" + "@Styles\nfunction pressed() {\n .backgroundColor('#eee')\n}\n", + CBM_LANG_ARKTS, "t", "b.ets"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT(has_def(r, "Function", "card")); @@ -4903,17 +4903,22 @@ TEST(extract_python_bare_call_flags_locally_bound_callee) { 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. +/* The bare-call flag is DEPTH-INDEPENDENT, and the binding is UNWOUND when its + * scope closes. * - * 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) { + * Both properties come from the same design decision. The answer is carried by + * the unified walk -- parameters are bound when a def or lambda scope opens and + * unwound when it closes -- rather than recomputed per call by ascending the + * tree. An ascending walk is O(depth) per call, and every level of f(f(f(...))) + * is itself a bare call, so it is quadratic in a file's nesting depth; that hung + * stack_overflow_b's 30,000-deep fixture rather than merely slowing it. An + * earlier cut capped the ascent at 64 ancestors and FAILED OPEN past it, which + * silently stopped suppressing on deep-but-ordinary code. + * + * Pinned deterministically rather than by wall clock -- a timing assertion would + * be a lottery, not a gate. The depth case fails if a cap is reintroduced; the + * unwind case fails if a frame's bindings outlive its scope. */ +TEST(extract_python_bare_call_flag_is_depth_independent) { /* Shallow: return_statement / block / function_definition — 3 ancestors. */ CBMFileResult *shallow = extract("def shallow(handler):\n" " return handler()\n", @@ -4931,9 +4936,9 @@ TEST(extract_python_bare_call_scope_walk_is_bounded) { 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. */ + /* Deep: 200 parenthesized_expression ancestors separate the SAME call from + * its enclosing def. The parameter still shadows it, so it stays flagged -- + * depth changes nothing. This is the case a 64-ancestor cap got wrong. */ const int PARENS = 200; size_t sz = (size_t)PARENS * 2 + 128; char *src = malloc(sz); @@ -4955,12 +4960,51 @@ TEST(extract_python_bare_call_scope_walk_is_bounded) { 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_TRUE(deep->calls.items[i].callee_is_locally_bound); } } ASSERT_EQ(deep_seen, 1); cbm_free_result(deep); free(src); + + /* Unwind: `handler` is a parameter of shadowed() and a module-level function + * of the same name. The call INSIDE shadowed() is flagged; the call in + * sibling(), after that scope closed, must NOT be — it really does resolve + * to the module-level def. A binding that outlived its frame would flag it + * and destroy a true edge, which is the one direction this guard must never + * fail in. Nested same-name defs also pin the count: leaving the inner scope + * must not unbind the outer one. */ + CBMFileResult *unwound = extract("def handler():\n" + " return 1\n" + "\n" + "def shadowed(handler):\n" + " def inner(handler):\n" + " return handler()\n" + " return inner(handler) or handler()\n" + "\n" + "def sibling():\n" + " return handler()\n", + CBM_LANG_PYTHON, "t", "u.py"); + ASSERT_NOT_NULL(unwound); + ASSERT_FALSE(unwound->has_error); + int flagged = 0; + int unflagged = 0; + for (int i = 0; i < unwound->calls.count; i++) { + const char *cn = unwound->calls.items[i].callee_name; + if (!cn || strcmp(cn, "handler") != 0) { + continue; + } + if (unwound->calls.items[i].callee_is_locally_bound) { + flagged++; + } else { + unflagged++; + } + } + /* Two shadowed calls (inner body, and shadowed()'s own tail) and exactly one + * unshadowed call in sibling(). */ + ASSERT_EQ(flagged, 2); + ASSERT_EQ(unflagged, 1); + cbm_free_result(unwound); PASS(); } @@ -5372,8 +5416,8 @@ TEST(extract_c_test_dir_marks_is_test_issue1294) { * not be (#1294). */ TEST(extract_python_method_test_dir_marks_is_test_issue1294) { const char *src = "class Foo:\n" - " def helper(self):\n" - " pass\n"; + " def helper(self):\n" + " pass\n"; /* Python's LSP layer injects synthetic builtin stub Methods (str.upper, * dict.get, ...) into defs.items alongside real ones (py_builtins.c), so @@ -6644,7 +6688,7 @@ SUITE(extraction) { 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_python_bare_call_flag_is_depth_independent); 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); From 40f2722d6166c1f97fa1667c1e63e8c97e6f05f0 Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Wed, 2 Sep 2026 07:59:32 -0500 Subject: [PATCH 05/14] fix(extract): AST traversal stacks no longer outlive the file they walk Fixes #2010. Refs #1997. Indexing a 14k-file TypeScript repository holds 13.3 GB resident at post_extract on Linux aarch64, and about 3.1 GB of it is AST traversal scratch that no CBMFileResult points at. ts_nstack_init cut its stacks from ctx->arena, which is result->arena. In the parallel path that arena is stored into ec->result_cache[file_idx] (pass_parallel.c:963) and freed only after parallel_resolve and the infra passes (pipeline.c:1324), so a structure written for a one-file lifetime is in fact held for the whole index, once per file, concurrently. The header claimed otherwise and was wrong: "freed when the arena is destroyed at end of file extraction". The largest instance is channels. CHAN_STACK_CAP is 4096 and sizeof(TSNode) is 32, so scan_string_consts_js (extract_channels.c:104) and extract_channels_js (:376) take 128 KB each, per JavaScript or TypeScript file. Across 12,673 such files that is 3,168 MB, and it produced 23 Channel nodes out of 164,684. CBMExtractCtx gains a scratch arena, created and destroyed by the cbm_extract_file_ex call that builds the context. Nothing else changes about where anything lives: ctx->arena still owns every string a CBMFileResult points at, and ctx->scratch owns only traversal stacks, which nothing points at. Nothing a CBMFileResult points at moved. A context built without a scratch, and a file whose scratch arena fails to allocate, both fall back to ctx->arena, which is exactly the behaviour that shipped before this change, so no path can be handed a NULL arena and silently lose nodes. The signature change is what makes the mistake unrepresentable. ts_nstack_init takes the context rather than an arena, so handing it ctx->arena is an incompatible pointer type; push and push_children read the arena the stack recorded, so a stale argument is an arity error. Both fail under -Werror rather than silently retaining memory. The 55 init sites are NOT one uniform sed. 29 pass ctx->arena and 26 pass a local alias, and that alias is shared with result allocations: in parse_zig_imports (extract_imports.c:1093) the same `a` feeds cbm_node_text, strip_quotes, path_last and cbm_imports_push (extract_imports.c:1101, 1106, 1108, 1109). Rebinding it would have moved import names and module paths into memory that dies with the call while the result kept pointing at them. Each site was edited on its own and no alias was rebound. Re-derive the set with: grep -rn 'ts_nstack_init' internal/cbm/extract_*.c grep -rn 'ts_nstack_push' internal/cbm/extract_*.c parse_lisp_imports used its alias only for the stack, so the alias goes too. push_nested_class_nodes and push_class_body_children took an arena purely to reach the stack, so they take the context now; the one call site passes ctx. Two measurements, from two different builds, and they are not the same number. A macOS census build, which instruments every cbm_arena_alloc and sums the blocks behind every live CBMFileResult arena, reports the retained per-file arena set dropping from 11,226 MB mapped to 5,680 MB, and blocks from 48,045 to 34,186. Those are mapped bytes, not resident: that build logs mem.allocator.bound_populations_only, so untouched block tails never commit. The resident figures come from Linux aarch64, gcc 13.3, static build, where the build logs mem.allocator.owned classes=all and mapped equals resident. Five runs per side on the full 13,999-file tree: mem.collect post_extract rss_mb 13293 -> 6752 (-49.2%), mem.phase peak_mb 14840 -> 8565 (-42.3%). nodes 164684, parse_partial 280 and registry entries/defines/imports 787954/804999/100027 identical on all ten runs; edges 835869-835874 against 835867-835881, overlapping ranges, so run to run noise. Wall clock means 35651 ms against 30502 ms, but the ranges overlap and the host had other load, so the only supportable claim is no regression. ObjectScript Studio Export on a small fixture: nodes 18, edges 25, both sides. Scratch block size is 512 KB, chosen from measurement. arena_grow fires on one file in 12,000 at both 512 KB and 1 MB, and constantly at 256 KB, where the two channel stacks alone are exactly 262144 bytes. 512 KB therefore costs half the resident block per worker for the same growth behaviour, and stays under MI_LARGE_MAX_OBJ_SIZE in the vendored mimalloc, which is MI_LARGE_PAGE_SIZE/8, exactly 524288 bytes (types.h:426, with MI_ENABLE_LARGE_PAGES defaulting to 1 at :115 and not overridden by this build), so a 512 KB block is still bin-allocated from a 4 MiB large page. Growth is not free at that size for the same reason: arena_grow doubles to 1 MiB, above that bound and therefore a singleton OS allocation. One file in twelve thousand pays it, which is why it is accepted. The largest single file's scratch high water mark on this corpus is 33.5 MB, now transient rather than retained. Tests. Nothing in the suite asserted on result->arena size, block count or total_alloc after cbm_extract_file, which is why this went unnoticed for the life of extract_node_stack.h. traversal_stack_not_in_result_arena_issue2010 pins the byte budget: extracting "export const x = 1;" as TypeScript charged 365984 bytes to the result arena before and charges 87456 after, a difference of 278528, exactly the two 4096-entry channel walks plus the 512-entry ES import walk at 32 bytes per TSNode. Of the 87456 that remain, 7680 is the defs item array at GROW_ARRAY's starting capacity of 32 times sizeof(CBMDefinition) 240 and the other 79776 is everything else the extraction interns, none of it traversal scratch; the 128 KB bound is therefore above the new figure with room and a factor of four below the old one. extract_traversal_stacks_come_from_ctx_scratch_issue2010 in test_mem.c pins the lifetime, since a byte budget alone would also be satisfied by shrinking CHAN_STACK_CAP: it builds the extraction context directly over two arenas it owns, runs cbm_extract_channels, and asserts the scratch took at least the 262144 bytes of the two walks while the result arena did not. extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010 is not a scratch test. pp_ctx carries ctx->scratch so every context in the file is uniform, but nothing reads it there: pp_ctx reaches only cbm_extract_unified and cbm_run_c_lsp, and neither extract_unified.c nor anything under internal/cbm/lsp/ includes extract_node_stack.h, so no traversal stack is built on that path today. The test guards the macro-expansion path itself, which had no assertion on a call that exists only after expansion. ObjectScript composite extraction is already covered by pipeline_objectscript_export_preserves_calls_sequential_parallel and iris_export_xml_multi_class, so no new test is added there; a per-call arena is correct on that path by construction, since each call owns and destroys its own. Local: scripts/test.sh, 7558 passed, 0 failed, 8 skipped, 139 suites. Alternatives considered Thread-local scratch arena, reset per file, with a depth guard for nesting. Rejected on three counts. This codebase never uses thread-local storage for owned bulk memory; every existing _Thread_local here is a small cache, a guard or a log buffer, and every per-worker resource is a field in a context struct. It would have needed cbm_arena_reset, which has no production caller and a documented subtlety with grown block sizes (arena.c:213-217). And extraction threads are created per cbm_parallel_for call and joined (worker_pool.c:71,88) with no thread-exit hook in this project's own code, so the arena would have leaked once per thread per dispatch and turned the leak lane red. Save and restore mark and release on the arena. Rejected because a per-call arena already makes nesting correct by construction, and a mark type plus two functions would have to be added to both arena headers. Per-walk arenas, one per traversal rather than one per file. Rejected on cost: 55 sites would each need an explicit span, for the same bound. Right-sizing the initial capacities alone. CHAN_STACK_CAP at 256 rather than 4096 would cut those two sites from 3,168 MB to about 198 MB, which is real, but it tunes the constant while the lifetime defect remains at all 55 sites, and growth is not free: 256 doubling to 4096 abandons 122,880 bytes, and one file in this corpus took 33.5 MB of stacks. The cap and the scratch block size are coupled, which is a further reason to change one at a time. Worth doing separately. Routing to a scratch inside extract_node_stack.h while keeping the arena parameters. Rejected because a parameter that is ignored lies about the API, and the alias hazard above would stay a review obligation instead of a compile error. Freeing the per-file result arena earlier. Rejected: the results are read by parallel_resolve and the infra passes. That is the retention half #1925 addresses, and this change is independent of it. Signed-off-by: Daniel Ochoa --- internal/cbm/cbm.c | 52 ++++++- internal/cbm/cbm.h | 7 + internal/cbm/extract_channels.c | 60 ++++---- internal/cbm/extract_defs.c | 26 ++-- internal/cbm/extract_env_accesses.c | 6 +- internal/cbm/extract_imports.c | 217 ++++++++++++++-------------- internal/cbm/extract_node_stack.h | 41 ++++-- internal/cbm/extract_semantic.c | 12 +- internal/cbm/extract_type_assigns.c | 6 +- internal/cbm/extract_type_refs.c | 12 +- tests/test_extraction.c | 54 +++++++ tests/test_mem.c | 52 +++++++ 12 files changed, 359 insertions(+), 186 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index d7468af5a..9b4d16977 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -1,3 +1,7 @@ +/* Full declaration set for the same CBMArena, and it must precede cbm.h: + * internal/cbm/arena.h declares a subset and the two share the CBM_ARENA_H + * guard, so whichever is included first is the one this file sees. */ +#include "foundation/arena.h" // cbm_arena_init_sized #include "cbm.h" #include "arena.h" // CBMArena, cbm_arena_init/alloc/strdup/destroy #include "helpers.h" @@ -1162,11 +1166,27 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage return r; } -CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLanguage language, - const char *project, const char *rel_path, - int64_t timeout_micros, const char **extra_defines, - const char **include_paths, const CBMMacroTable *macro_table, - const CBMReturnTypeTable *return_type_table) { +/* Initial block for the per-file traversal scratch arena, chosen by measuring + * arena_grow on a 14k-file TypeScript tree: it fires on one file in 12,000 at + * both this size and at 1 MB, and on most files at 256 KB, where the two + * channel walks alone are exactly 262144 bytes. 512 KB therefore buys the same + * growth behaviour as 1 MB for half the resident block per worker. It is also + * exactly MI_LARGE_MAX_OBJ_SIZE in the vendored mimalloc + * (vendored/mimalloc/include/mimalloc/types.h:426, MI_LARGE_PAGE_SIZE/8 with + * MI_ENABLE_LARGE_PAGES defaulting to 1 at :115 and not overridden here), so + * the block is still bin-allocated from a large page. Growth is not free at + * this size for the same reason: arena_grow doubles to 1 MiB, which is above + * that bound and so a singleton OS allocation. One file in twelve thousand + * pays it, which is why the cost is accepted. */ +enum { CBM_EXTRACT_SCRATCH_BLOCK = CBM_SZ_512 * CBM_SZ_1K }; + +static CBMFileResult *extract_file_ex_body(const char *source, int source_len, CBMLanguage language, + const char *project, const char *rel_path, + int64_t timeout_micros, const char **extra_defines, + const char **include_paths, + const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table, + CBMArena *scratch) { // Allocate result on heap (arena inside for all string data) enum { SINGLE = 1 }; CBMFileResult *result = (CBMFileResult *)calloc(SINGLE, sizeof(CBMFileResult)); @@ -1277,6 +1297,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua // Build extraction context CBMExtractCtx ctx = { .arena = a, + .scratch = scratch, .result = result, .source = source, .source_len = source_len, @@ -1398,6 +1419,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua // Build context for expanded source — extract only calls via unified extractor CBMExtractCtx pp_ctx = { .arena = a, + .scratch = scratch, .result = result, .source = expanded, .source_len = expanded_len, @@ -1634,6 +1656,26 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua return result; } +/* Public entry. Owns the traversal scratch arena for the whole of one file's + * extraction: created here, handed to the body as ctx->scratch, destroyed on + * the way out. The body has seven early returns, so bracketing it in a wrapper + * is what keeps that to one create and one destroy. If the arena cannot be + * created, the body is handed NULL and the traversal stacks fall back to the + * result arena, which is what shipped before #1997. */ +CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLanguage language, + const char *project, const char *rel_path, + int64_t timeout_micros, const char **extra_defines, + const char **include_paths, const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table) { + CBMArena scratch; + cbm_arena_init_sized(&scratch, CBM_EXTRACT_SCRATCH_BLOCK); + CBMFileResult *result = extract_file_ex_body( + source, source_len, language, project, rel_path, timeout_micros, extra_defines, + include_paths, macro_table, return_type_table, scratch.nblocks > 0 ? &scratch : NULL); + cbm_arena_destroy(&scratch); + return result; +} + void cbm_free_result(CBMFileResult *result) { if (!result) { return; diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 440d80fce..b4f09a90f 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -574,6 +574,13 @@ typedef struct { typedef struct { CBMArena *arena; + /* Scratch for AST traversal, owned by the cbm_extract_file_ex call that + * built this context and destroyed when it returns. Nothing a + * CBMFileResult points at may be allocated here: `arena` is the result's + * own, and it outlives extraction by the whole pipeline (#1997). NULL in a + * context built without one, in which case the stacks fall back to + * `arena`. */ + CBMArena *scratch; CBMFileResult *result; const char *source; int source_len; diff --git a/internal/cbm/extract_channels.c b/internal/cbm/extract_channels.c index c12d4ebeb..493133a13 100644 --- a/internal/cbm/extract_channels.c +++ b/internal/cbm/extract_channels.c @@ -101,8 +101,8 @@ static const char *literal_from_first_child(CBMExtractCtx *ctx, TSNode node) { * const table per file is sufficient for the common Socket.IO pattern). */ static void scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0 && tbl->count < CHAN_CONST_CAP) { TSNode node = ts_nstack_pop(&stack); @@ -128,15 +128,15 @@ static void scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) { } } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } /* Python constant resolution: NAME = "value" (assignment node). */ static void scan_string_consts_python(CBMExtractCtx *ctx, chan_const_table_t *tbl) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0 && tbl->count < CHAN_CONST_CAP) { TSNode node = ts_nstack_pop(&stack); @@ -163,7 +163,7 @@ static void scan_string_consts_python(CBMExtractCtx *ctx, chan_const_table_t *tb uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -373,15 +373,15 @@ static void extract_channels_js(CBMExtractCtx *ctx) { /* Second pass: walk the tree looking for call_expression nodes. */ TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "call_expression") == 0) { js_process_call(ctx, node, &consts); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -549,8 +549,8 @@ static void extract_channels_python(CBMExtractCtx *ctx) { scan_string_consts_python(ctx, &consts); TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -562,7 +562,7 @@ static void extract_channels_python(CBMExtractCtx *ctx) { } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -627,8 +627,8 @@ static void go_process_call(CBMExtractCtx *ctx, TSNode call) { static void extract_channels_go(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -637,7 +637,7 @@ static void extract_channels_go(CBMExtractCtx *ctx) { } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -724,8 +724,8 @@ static void java_process_annotation(CBMExtractCtx *ctx, TSNode annotation) { static void extract_channels_java(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -737,7 +737,7 @@ static void extract_channels_java(CBMExtractCtx *ctx) { } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -793,8 +793,8 @@ static void csharp_process_call(CBMExtractCtx *ctx, TSNode call) { static void extract_channels_csharp(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -803,7 +803,7 @@ static void extract_channels_csharp(CBMExtractCtx *ctx) { } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -856,8 +856,8 @@ static void ruby_process_call(CBMExtractCtx *ctx, TSNode call) { static void extract_channels_ruby(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -866,7 +866,7 @@ static void extract_channels_ruby(CBMExtractCtx *ctx) { } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -958,8 +958,8 @@ static void elixir_process_function_def(CBMExtractCtx *ctx, TSNode func_def) { static void extract_channels_elixir(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -971,7 +971,7 @@ static void extract_channels_elixir(CBMExtractCtx *ctx) { } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -1034,8 +1034,8 @@ static void rust_process_call(CBMExtractCtx *ctx, TSNode call) { static void extract_channels_rust(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CHAN_STACK_CAP); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -1044,7 +1044,7 @@ static void extract_channels_rust(CBMExtractCtx *ctx) { } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index f9854b03e..e6656067b 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5054,8 +5054,8 @@ static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur) { static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { (void)spec; TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_64); - ts_nstack_push(&stack, ctx->arena, node); + ts_nstack_init(&stack, ctx, CBM_SZ_64); + ts_nstack_push(&stack, node); while (stack.count > 0) { TSNode cur = ts_nstack_pop(&stack); @@ -5083,7 +5083,7 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp for (int di = (int)dbc - SKIP_CHAR; di >= 0; di--) { TSNode dchild = ts_node_child(do_block, (uint32_t)di); if (!ts_node_is_null(dchild) && strcmp(ts_node_type(dchild), "call") == 0) { - ts_nstack_push(&stack, ctx->arena, dchild); + ts_nstack_push(&stack, dchild); } } } @@ -6021,8 +6021,8 @@ static void extract_var_names(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec // Used by YAML, TOML, INI, JSON. static void walk_variables_iter(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_256); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, CBM_SZ_256); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -6046,7 +6046,7 @@ static void walk_variables_iter(CBMExtractCtx *ctx, TSNode root, const CBMLangSp strcmp(ck, "section") == 0 || strcmp(ck, "object") == 0 || strcmp(ck, "array") == 0 || strcmp(ck, "pair") == 0 || strcmp(ck, "element") == 0 || strcmp(ck, "content") == 0) { - ts_nstack_push(&stack, ctx->arena, child); + ts_nstack_push(&stack, child); } } } @@ -6749,10 +6749,10 @@ static void wd_push_children_reverse(wd_stack_t *s, TSNode node, const char *enc // Push nested class nodes from a class body container onto the defs stack. // Iteratively walks into wrapper nodes (field_declaration, template_declaration). static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_stack_t *s, - const char *enclosing_qn, CBMArena *arena) { + const char *enclosing_qn, const CBMExtractCtx *ctx) { TSNodeStack nc_stack; - ts_nstack_init(&nc_stack, arena, NESTED_CLASS_STACK_CAP); - ts_nstack_push(&nc_stack, arena, body); + ts_nstack_init(&nc_stack, ctx, NESTED_CLASS_STACK_CAP); + ts_nstack_push(&nc_stack, body); while (nc_stack.count > 0) { TSNode cur = ts_nstack_pop(&nc_stack); @@ -6767,7 +6767,7 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_sta const char *ck = ts_node_type(child); if (strcmp(ck, "field_declaration") == 0 || strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0) { - ts_nstack_push(&nc_stack, arena, child); + ts_nstack_push(&nc_stack, child); } } } @@ -6883,7 +6883,7 @@ static void extract_typescript_namespace_def(CBMExtractCtx *ctx, TSNode node, // Push nested class children from a class body container onto the walk stack. static void push_class_body_children(TSNode node, const CBMLangSpec *spec, wd_stack_t *s, - const char *new_enclosing, CBMArena *arena) { + const char *new_enclosing, const CBMExtractCtx *ctx) { /* Use the same language-aware body selection as method extraction. The old * independent spelling list omitted valid containers such as Scala's * `template_body` and Solidity's contract body. Methods were extracted @@ -6899,7 +6899,7 @@ static void push_class_body_children(TSNode node, const CBMLangSpec *spec, wd_st body = find_class_member_body(node, spec->language); } if (!ts_node_is_null(body)) { - push_nested_class_nodes(body, spec, s, new_enclosing, arena); + push_nested_class_nodes(body, spec, s, new_enclosing, ctx); return; } @@ -7423,7 +7423,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, if (cbm_kind_in_set(node, spec->class_node_types)) { extract_class_def(ctx, node, spec); const char *new_enclosing = compute_class_qn(ctx, node, frame.enclosing_class_qn); - push_class_body_children(node, spec, &s, new_enclosing, ctx->arena); + push_class_body_children(node, spec, &s, new_enclosing, ctx); continue; } diff --git a/internal/cbm/extract_env_accesses.c b/internal/cbm/extract_env_accesses.c index ab0fc8f96..902d13d53 100644 --- a/internal/cbm/extract_env_accesses.c +++ b/internal/cbm/extract_env_accesses.c @@ -140,8 +140,8 @@ static bool is_env_var_name(const char *s) { // Iterative env access walker — explicit stack static void walk_env_accesses(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, 4096); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, 4096); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -163,7 +163,7 @@ static void walk_env_accesses(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec continue; // don't push children (avoid double-counting) } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 8696e4b30..c31e2bbfd 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -506,8 +506,8 @@ static bool process_commonjs_require(CBMExtractCtx *ctx, TSNode call) { static void walk_es_imports(CBMExtractCtx *ctx, TSNode root) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -540,7 +540,7 @@ static void walk_es_imports(CBMExtractCtx *ctx, TSNode root) { } if (push_children) { - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } } @@ -1093,8 +1093,8 @@ static void parse_haskell_imports(CBMExtractCtx *ctx) { static void parse_zig_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "builtin_function") == 0) { @@ -1111,7 +1111,7 @@ static void parse_zig_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1163,8 +1163,8 @@ static void process_wolfram_needs(CBMExtractCtx *ctx, TSNode node) { static void walk_wolfram_imports(CBMExtractCtx *ctx, TSNode root) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -1176,7 +1176,7 @@ static void walk_wolfram_imports(CBMExtractCtx *ctx, TSNode root) { process_wolfram_needs(ctx, node); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -1534,8 +1534,8 @@ static void capture_namespace_decl(CBMExtractCtx *ctx) { static void parse_hare_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "use_statement") == 0) { @@ -1554,7 +1554,7 @@ static void parse_hare_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1566,8 +1566,8 @@ static void parse_hare_imports(CBMExtractCtx *ctx) { static void parse_pascal_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "declUses") == 0) { @@ -1585,7 +1585,7 @@ static void parse_pascal_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1598,8 +1598,8 @@ static void parse_pascal_imports(CBMExtractCtx *ctx) { static void parse_powershell_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "command") == 0) { @@ -1609,8 +1609,8 @@ static void parse_powershell_imports(CBMExtractCtx *ctx) { /* Find the last generic_token anywhere under the command — that * is the module path / namespace / assembly being imported. */ TSNodeStack inner; - ts_nstack_init(&inner, a, CBM_SZ_512); - ts_nstack_push(&inner, a, node); + ts_nstack_init(&inner, ctx, CBM_SZ_512); + ts_nstack_push(&inner, node); const char *last_tok = NULL; uint32_t last_start = 0; while (inner.count > 0) { @@ -1624,7 +1624,7 @@ static void parse_powershell_imports(CBMExtractCtx *ctx) { last_start = sb; } } - ts_nstack_push_children(&inner, a, c); + ts_nstack_push_children(&inner, c); } if (last_tok && last_tok[0]) { CBMImport imp = {.local_name = path_last(a, last_tok), @@ -1634,7 +1634,7 @@ static void parse_powershell_imports(CBMExtractCtx *ctx) { } continue; /* commands don't nest imports further */ } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1821,17 +1821,16 @@ static void lisp_process_list(CBMExtractCtx *ctx, TSNode node) { * just root children. Stack-based (not recursive) to avoid deep-nesting stack * overflow, matching the other walkers in this file. */ static void parse_lisp_imports(CBMExtractCtx *ctx) { - CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *nt = ts_node_type(node); if (strcmp(nt, "list") == 0 || strcmp(nt, "list_lit") == 0) { lisp_process_list(ctx, node); } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1841,8 +1840,8 @@ static void parse_lisp_imports(CBMExtractCtx *ctx) { static void parse_starlark_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "call") == 0) { @@ -1870,7 +1869,7 @@ static void parse_starlark_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1881,8 +1880,8 @@ static void parse_starlark_imports(CBMExtractCtx *ctx) { static void parse_tcl_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "command") == 0) { @@ -1904,7 +1903,7 @@ static void parse_tcl_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1915,8 +1914,8 @@ static void parse_tcl_imports(CBMExtractCtx *ctx) { static void parse_teal_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "function_call") == 0) { @@ -1940,7 +1939,7 @@ static void parse_teal_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1950,8 +1949,8 @@ static void parse_teal_imports(CBMExtractCtx *ctx) { static void parse_zsh_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "command") == 0) { @@ -1971,7 +1970,7 @@ static void parse_zsh_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2058,15 +2057,15 @@ static void parse_html_imports(CBMExtractCtx *ctx) { parse_embedded_imports(ctx); TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "start_tag") == 0 || strcmp(ts_node_type(node), "self_closing_tag") == 0) { html_extract_tag_src(ctx, node); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -2125,8 +2124,8 @@ static void push_string_descendant_import(CBMExtractCtx *ctx, TSNode node) { static void parse_cmake_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "normal_command") == 0) { @@ -2144,15 +2143,15 @@ static void parse_cmake_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } // --- BitBake imports: require/include path --- static void parse_bitbake_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2160,7 +2159,7 @@ static void parse_bitbake_imports(CBMExtractCtx *ctx) { strcmp(k, "inherit_directive") == 0) { push_string_descendant_import(ctx, node); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -2172,8 +2171,8 @@ static void parse_bitbake_imports(CBMExtractCtx *ctx) { static void parse_meson_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2196,35 +2195,35 @@ static void parse_meson_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } // --- Kconfig imports: source "path" --- static void parse_kconfig_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (ts_node_is_named(node) && strcmp(ts_node_type(node), "source") == 0) { push_string_descendant_import(ctx, node); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } // --- GN imports: import("//path") --- static void parse_gn_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "import_statement") == 0) { push_string_descendant_import(ctx, node); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -2248,8 +2247,8 @@ static void parse_just_imports(CBMExtractCtx *ctx) { static void parse_nix_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "apply_expression") == 0) { @@ -2268,15 +2267,15 @@ static void parse_nix_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } // --- Jsonnet imports: import 'path' / importstr 'path' --- static void parse_jsonnet_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2284,15 +2283,15 @@ static void parse_jsonnet_imports(CBMExtractCtx *ctx) { strcmp(k, "importbin") == 0)) { push_string_descendant_import(ctx, node); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } // --- Pkl imports: amends/extends/import "path" --- static void parse_pkl_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2300,7 +2299,7 @@ static void parse_pkl_imports(CBMExtractCtx *ctx) { strcmp(k, "importExpr") == 0) { push_string_descendant_import(ctx, node); } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -2308,8 +2307,8 @@ static void parse_pkl_imports(CBMExtractCtx *ctx) { static void parse_nickel_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); /* The grammar emits an anonymous `import` token followed by a @@ -2330,7 +2329,7 @@ static void parse_nickel_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2354,8 +2353,8 @@ static void parse_thrift_imports(CBMExtractCtx *ctx) { // --- Cap'n Proto imports: using X = import "path" --- static void parse_capnp_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2364,7 +2363,7 @@ static void parse_capnp_imports(CBMExtractCtx *ctx) { push_string_descendant_import(ctx, node); continue; /* don't double-emit from nested import_path */ } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -2372,8 +2371,8 @@ static void parse_capnp_imports(CBMExtractCtx *ctx) { static void parse_dlang_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "import_declaration") == 0) { @@ -2387,15 +2386,15 @@ static void parse_dlang_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } // --- TableGen imports: include "path.td" --- static void parse_tablegen_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2404,22 +2403,22 @@ static void parse_tablegen_imports(CBMExtractCtx *ctx) { push_string_descendant_import(ctx, node); continue; } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } // --- Crystal imports: require "./path" --- static void parse_crystal_imports(CBMExtractCtx *ctx) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (ts_node_is_named(node) && strcmp(ts_node_type(node), "require") == 0) { push_string_descendant_import(ctx, node); continue; } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } @@ -2427,8 +2426,8 @@ static void parse_crystal_imports(CBMExtractCtx *ctx) { static void parse_fsharp_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2444,7 +2443,7 @@ static void parse_fsharp_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2452,8 +2451,8 @@ static void parse_fsharp_imports(CBMExtractCtx *ctx) { static void parse_ada_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "with_clause") == 0) { @@ -2472,7 +2471,7 @@ static void parse_ada_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2483,8 +2482,8 @@ static void parse_ada_imports(CBMExtractCtx *ctx) { static void parse_elm_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "import_clause") == 0) { @@ -2506,7 +2505,7 @@ static void parse_elm_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2517,8 +2516,8 @@ static void parse_elm_imports(CBMExtractCtx *ctx) { static void parse_move_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "use_declaration") == 0) { @@ -2535,7 +2534,7 @@ static void parse_move_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2569,8 +2568,8 @@ static char *smali_demangle_descriptor(CBMArena *a, const char *desc) { static void parse_smali_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2585,7 +2584,7 @@ static void parse_smali_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2595,8 +2594,8 @@ static void parse_smali_imports(CBMExtractCtx *ctx) { static void parse_tlaplus_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *k = ts_node_type(node); @@ -2615,7 +2614,7 @@ static void parse_tlaplus_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2627,8 +2626,8 @@ static void parse_tlaplus_imports(CBMExtractCtx *ctx) { static void parse_vhdl_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "use_clause") == 0) { @@ -2646,7 +2645,7 @@ static void parse_vhdl_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2656,8 +2655,8 @@ static void parse_vhdl_imports(CBMExtractCtx *ctx) { static void parse_wit_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "use_item") == 0) { @@ -2675,7 +2674,7 @@ static void parse_wit_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2686,8 +2685,8 @@ static void parse_wit_imports(CBMExtractCtx *ctx) { static void parse_smithy_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "use_statement") == 0) { @@ -2709,7 +2708,7 @@ static void parse_smithy_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2719,8 +2718,8 @@ static void parse_smithy_imports(CBMExtractCtx *ctx) { static void parse_hyprlang_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; - ts_nstack_init(&stack, a, CBM_SZ_512); - ts_nstack_push(&stack, a, ctx->root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, ctx->root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (strcmp(ts_node_type(node), "source") == 0) { @@ -2737,7 +2736,7 @@ static void parse_hyprlang_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } diff --git a/internal/cbm/extract_node_stack.h b/internal/cbm/extract_node_stack.h index d112ccc2f..0013d53b1 100644 --- a/internal/cbm/extract_node_stack.h +++ b/internal/cbm/extract_node_stack.h @@ -4,14 +4,22 @@ * Replaces fixed-size TSNode stack[] arrays that silently drop AST subtrees * when the stack overflows (GitHub issue #199). * - * Uses the arena allocator for zero-fragmentation growth: old blocks are - * abandoned (freed when the arena is destroyed at end of file extraction). - * Initial capacity matches the previous fixed caps so small files allocate - * no extra memory. + * Traversal stacks are scratch: nothing in a CBMFileResult ever points into + * one. They are therefore cut from ctx->scratch, which the enclosing + * cbm_extract_file_ex call owns and destroys on the way out, and never from + * ctx->arena, which the pipeline holds for every file until the whole result + * cache is freed (#1997). + * + * Growth abandons the old buffer in that scratch arena, which is free because + * the arena dies with the file. The initial capacities below are unchanged, but + * they are no longer what a small file costs: cbm_extract_file_ex creates the + * scratch arena's first block for every call, including calls that build no + * stack at all, and reclaims it on the way out. */ #ifndef CBM_EXTRACT_NODE_STACK_H #define CBM_EXTRACT_NODE_STACK_H +#include "cbm.h" /* CBMExtractCtx: a stack draws from ctx->scratch */ #include "arena.h" #include "tree_sitter/api.h" #include /* memcpy */ @@ -20,20 +28,31 @@ typedef struct { TSNode *items; int count; int cap; + /* The arena every allocation for this stack comes from, recorded once by + * ts_nstack_init so push() cannot be handed a different one. It is + * ctx->scratch, or ctx->arena as the fallback when the context has none. */ + CBMArena *scratch; } TSNodeStack; -/* Initialize a stack with the given initial capacity, arena-allocated. */ -static inline void ts_nstack_init(TSNodeStack *s, CBMArena *arena, int initial_cap) { +/* Initialize a stack with the given initial capacity, allocated from the + * context's traversal scratch. Taking the context rather than an arena is + * deliberate: it makes handing over ctx->arena, or a local alias of it, a type + * error rather than a retention bug nobody notices. A context built without a + * scratch falls back to ctx->arena, which is the behaviour that shipped before + * #1997, so no caller ever gets a NULL arena and silently loses nodes. */ +static inline void ts_nstack_init(TSNodeStack *s, const CBMExtractCtx *ctx, int initial_cap) { + CBMArena *arena = ctx->scratch ? ctx->scratch : ctx->arena; + s->scratch = arena; s->items = (TSNode *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(TSNode)); s->count = 0; s->cap = s->items ? initial_cap : 0; } /* Push a node onto the stack, growing 2x if needed. */ -static inline void ts_nstack_push(TSNodeStack *s, CBMArena *arena, TSNode node) { +static inline void ts_nstack_push(TSNodeStack *s, TSNode node) { if (s->count >= s->cap) { int new_cap = s->cap ? s->cap * 2 : 512; - TSNode *new_items = (TSNode *)cbm_arena_alloc(arena, (size_t)new_cap * sizeof(TSNode)); + TSNode *new_items = (TSNode *)cbm_arena_alloc(s->scratch, (size_t)new_cap * sizeof(TSNode)); if (!new_items) return; /* OOM: best-effort, stop growing */ if (s->items && s->count > 0) { @@ -54,7 +73,7 @@ static inline TSNode ts_nstack_pop(TSNodeStack *s) { /* * Push all children of `node` so they POP in forward (source) order — a drop-in * replacement for the common idiom: - * for (int i = (int)count - 1; i >= 0; i--) ts_nstack_push(s, a, ts_node_child(node, i)); + * for (int i = (int)count - 1; i >= 0; i--) ts_nstack_push(s, ts_node_child(node, i)); * * That idiom calls ts_node_child(node, i) once per index, and ts_node_child is * O(i) in tree-sitter (it walks the child iterator from the first child each @@ -63,12 +82,12 @@ static inline TSNode ts_nstack_pop(TSNodeStack *s) { * files). This helper enumerates children in a single O(N) cursor pass, then * reverses the just-pushed segment so pop order is identical to the old idiom. */ -static inline void ts_nstack_push_children(TSNodeStack *s, CBMArena *arena, TSNode node) { +static inline void ts_nstack_push_children(TSNodeStack *s, TSNode node) { int base = s->count; TSTreeCursor cursor = ts_tree_cursor_new(node); if (ts_tree_cursor_goto_first_child(&cursor)) { do { - ts_nstack_push(s, arena, ts_tree_cursor_current_node(&cursor)); + ts_nstack_push(s, ts_tree_cursor_current_node(&cursor)); } while (ts_tree_cursor_goto_next_sibling(&cursor)); } ts_tree_cursor_delete(&cursor); diff --git a/internal/cbm/extract_semantic.c b/internal/cbm/extract_semantic.c index 9379f7171..a4ab6f223 100644 --- a/internal/cbm/extract_semantic.c +++ b/internal/cbm/extract_semantic.c @@ -125,14 +125,14 @@ static void process_throw_node(CBMExtractCtx *ctx, TSNode node, const CBMLangSpe // Iterative throw walker static void walk_throws(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); process_throw_node(ctx, node, spec); uint32_t count = ts_node_child_count(node); for (int i = (int)count - LAST_IDX; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -263,8 +263,8 @@ static void try_emit_assignment_write(CBMExtractCtx *ctx, TSNode node, const cha static void walk_readwrites(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (cbm_kind_in_set(node, spec->assignment_node_types)) { @@ -272,7 +272,7 @@ static void walk_readwrites(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec * } uint32_t count = ts_node_child_count(node); for (int i = (int)count - LAST_IDX; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } diff --git a/internal/cbm/extract_type_assigns.c b/internal/cbm/extract_type_assigns.c index 7830a8b18..7ac30ee1d 100644 --- a/internal/cbm/extract_type_assigns.c +++ b/internal/cbm/extract_type_assigns.c @@ -186,12 +186,12 @@ static void process_type_assign_node(CBMExtractCtx *ctx, TSNode node, const CBML // Walk AST for assignment patterns where RHS is a constructor call. static void walk_type_assigns(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, 4096); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, 4096); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); process_type_assign_node(ctx, node, spec, cbm_enclosing_func_qn_cached(ctx, node)); - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } diff --git a/internal/cbm/extract_type_refs.c b/internal/cbm/extract_type_refs.c index 8f78101f4..9bfdb0e49 100644 --- a/internal/cbm/extract_type_refs.c +++ b/internal/cbm/extract_type_refs.c @@ -245,14 +245,14 @@ static void process_body_type_ref(CBMExtractCtx *ctx, TSNode node, const char *f // Walk function body for type references (casts, type assertions, local var types, generics). static void walk_body_type_refs(CBMExtractCtx *ctx, TSNode root, const char *func_qn) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, 4096); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, 4096); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); process_body_type_ref(ctx, node, func_qn); uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } @@ -289,8 +289,8 @@ static void walk_type_refs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *s } TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, 4096); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, 4096); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -301,7 +301,7 @@ static void walk_type_refs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *s } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 447e7c104..28f7feb48 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -7,6 +7,7 @@ */ #include "test_framework.h" #include "cbm.h" +#include "foundation/constants.h" /* CBM_SZ_* */ #include "../src/foundation/compat.h" /* cbm_clock_gettime (wide-flat scaling guard) */ #include "../src/foundation/compat_fs.h" #include @@ -145,6 +146,57 @@ TEST(extract_ts_factory_object_methods_issue341) { PASS(); } +/* #2010, split out of #1997: AST traversal stacks were allocated from + * result->arena, which the parallel pass holds for every file until the whole + * result cache is freed, so a one-file scratch structure was retained for the + * length of the index. cbm_extract_channels runs for every file and dispatches + * TypeScript to extract_channels_js, whose two walks take a 4096-entry TSNode + * stack each, and the ES import walk takes a 512-entry one: + * 2 * 4096 * 32 + 512 * 32 = 278528 bytes charged to the arena of a one-line + * file. + * + * The bound is derived, not tuned. Measured on this source, total_alloc was + * 365984 before the scratch arena and is 87456 after, exactly that difference. + * Of the 87456 that remain, 7680 is the defs item array at GROW_ARRAY's + * starting capacity of 32 times sizeof(CBMDefinition) 240, and the other 79776 + * is everything else this file's extraction interns; none of it is traversal + * scratch. So the bound sits above 87456 with room and a factor of four below + * 365984. + * + * It is a byte budget, not a proof of lifetime; that is + * extract_traversal_stacks_come_from_ctx_scratch_issue2010 in test_mem.c. */ +TEST(traversal_stack_not_in_result_arena_issue2010) { + CBMFileResult *r = extract("export const x = 1;\n", CBM_LANG_TYPESCRIPT, "t", "a.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_TRUE(has_def_any(r, "x")); + /* Read the field rather than cbm_arena_total(): this file sees + * internal/cbm/arena.h, which declares a subset of the API. test_mem.c + * includes foundation/arena.h ahead of cbm.h and can call the accessor. */ + ASSERT_LT(r->arena.total_alloc, (size_t)CBM_SZ_128 * CBM_SZ_1K); + cbm_free_result(r); + PASS(); +} + +/* Not a scratch test. The C and C++ preprocessed second pass builds its own + * extraction context (pp_ctx in cbm_extract_file_ex), and that context carries + * ctx->scratch so every context in the file is uniform, but nothing reads it + * there: pp_ctx reaches only cbm_extract_unified and cbm_run_c_lsp, and neither + * extract_unified.c nor anything under internal/cbm/lsp/ includes + * extract_node_stack.h, so no traversal stack is built on that path today. + * This guards the macro-expansion path itself, which had no assertion on a call + * that exists only after expansion. */ +TEST(extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010) { + CBMFileResult *r = extract("void target(void) {}\n" + "#define INVOKE() target()\n" + "void caller(void) { INVOKE(); }\n", + CBM_LANG_C, "t", "macro_call.c"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(has_call(r, "target")); + cbm_free_result(r); + PASS(); +} + /* --- C/C++ preprocessor macros become Macro nodes (#375) --- */ TEST(extract_c_macros_issue375) { CBMFileResult *r = extract("#define SIMPLE_MACRO 1\n" @@ -5371,6 +5423,8 @@ SUITE(extraction) { RUN_TEST(extract_r_box_use_imports_issue218); RUN_TEST(extract_r_dollar_call_issue219); RUN_TEST(extract_ts_factory_object_methods_issue341); + RUN_TEST(traversal_stack_not_in_result_arena_issue2010); + RUN_TEST(extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010); RUN_TEST(extract_c_macros_issue375); RUN_TEST(extract_cpp_macros_issue375); RUN_TEST(extract_cpp_functionlike_macro_type_arg_no_false_parse_partial_issue1071); diff --git a/tests/test_mem.c b/tests/test_mem.c index 31c5a686f..e42eabb3d 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -14,6 +14,8 @@ #include "graph_buffer/graph_buffer.h" #include "discover/discover.h" #include "cbm.h" +#include "lang_specs.h" /* cbm_ts_language */ +#include "foundation/constants.h" /* CBM_SZ_* */ #include #include @@ -1277,6 +1279,53 @@ TEST(mem_map_attributes_a_known_allocation) { PASS(); } +/* #2010, the lifetime half. traversal_stack_not_in_result_arena in + * test_extraction.c pins the byte budget of the result arena, but a smaller + * CHAN_STACK_CAP would satisfy that too. This pins where the bytes actually + * went: the scratch arena takes the two 128 KB channel walks and the result + * arena does not. Builds the extraction context directly, since a completed + * cbm_extract_file_ex has already destroyed its scratch. */ +TEST(extract_traversal_stacks_come_from_ctx_scratch_issue2010) { + enum { CHANNEL_WALK_BYTES = 2 * 4096 * (int)sizeof(TSNode) }; + const char *src = "export const x = 1;\n"; + const TSLanguage *ts_lang = cbm_ts_language(CBM_LANG_TYPESCRIPT); + ASSERT_NOT_NULL((void *)ts_lang); + + TSParser *parser = ts_parser_new(); + ASSERT_NOT_NULL(parser); + ts_parser_set_language(parser, ts_lang); + TSTree *tree = ts_parser_parse_string(parser, NULL, src, (uint32_t)strlen(src)); + ASSERT_NOT_NULL(tree); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMArena scratch; + cbm_arena_init_sized(&scratch, (size_t)CBM_SZ_512 * CBM_SZ_1K); + + CBMExtractCtx ctx = { + .arena = &result.arena, + .scratch = &scratch, + .result = &result, + .source = src, + .source_len = (int)strlen(src), + .language = CBM_LANG_TYPESCRIPT, + .project = "t", + .rel_path = "a.ts", + .root = ts_tree_root_node(tree), + }; + cbm_extract_channels(&ctx); + + ASSERT_GTE(cbm_arena_total(&scratch), (size_t)CHANNEL_WALK_BYTES); + ASSERT_LT(cbm_arena_total(&result.arena), (size_t)CBM_SZ_64 * CBM_SZ_1K); + + cbm_arena_destroy(&scratch); + cbm_arena_destroy(&result.arena); + ts_tree_delete(tree); + ts_parser_delete(parser); + PASS(); +} + SUITE(mem) { /* mem API */ RUN_TEST(mem_arena_eager_commit_follows_platform_commit_cost); @@ -1340,4 +1389,7 @@ SUITE(mem) { RUN_TEST(parallel_extract_without_source_retention); RUN_TEST(parallel_extract_tiny_source_retention_budget); RUN_TEST(parallel_extract_with_slab); + + /* extraction scratch arena (#2010) */ + RUN_TEST(extract_traversal_stacks_come_from_ctx_scratch_issue2010); } From 572725e61a00b9deda7407971dc9290477170515 Mon Sep 17 00:00:00 2001 From: Rares Popa <2606875+rarepops@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:00:57 +0200 Subject: [PATCH 06/14] fix(extraction): treat trailing blanks at EOF as an absent newline, not a miss A file whose final line lacks its newline leaves the grammar's mandatory terminator MISSING at EOF. #1610 suppressed that phantom parse_partial on the grounds that the node is ZERO-WIDTH: the parser consumed no source for it, so by construction nothing was dropped. That suppression tested `end == source_len` exactly. Trailing blanks are extras owned by no node, so `ENTRYPOINT ["a"] ` + EOF parks the zero-width terminator one byte short of source_len and the check missed it. This is why neither a trailing blank nor an absent final newline flagged on its own -- only the pair did, exactly as the reporter's byte-exact control matrix showed. Treat "at EOF" as EOF modulo a trailing blank run. Every blank except newline qualifies, deliberately not a hand-picked subset: a form feed is no more content than a space, and #1610 exists precisely because whether a file got flagged used to hinge on such incidentals. Newline stays excluded because a terminated final line produces no MISSING terminator at all. The rule remains ZERO-WIDTH ONLY. A width-bearing MISSING/ERROR at EOF is a genuine loss and is still flagged (a Makefile's unterminated final recipe really does vanish), as is any failure earlier in the file; both are pinned by guard tests alongside the existing #1610 guards. Verified end to end: the issue's own `cli index_repository` reproduction goes from parse_partial_count 1 to 0 with node count unchanged, so the phantom flag is gone without anything dropping out of the graph. Fixes #1746 Signed-off-by: Rares Popa <2606875+rarepops@users.noreply.github.com> --- internal/cbm/cbm.c | 12 ++++-- tests/test_parse_coverage.c | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index da6e720a2..cac199d2b 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -817,9 +817,13 @@ static void cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) { * untouched. * * #1746: the Dockerfile grammar places that zero-width missing newline before - * trailing horizontal whitespace rather than at raw EOF. Preserve the broad - * exact-EOF rule above; only extend it past spaces/tabs when the missing token - * is specifically a newline. */ + * trailing whitespace rather than at raw EOF. Preserve the broad exact-EOF + * rule above; only extend it past blanks when the missing token is specifically + * a newline. */ +static bool cbm_is_blank_not_newline(char c) { + return c == ' ' || c == '\t' || c == '\v' || c == '\f' || c == '\r'; +} + static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_len) { if (!ts_node_is_missing(n) || source_len < 0) { return false; @@ -836,7 +840,7 @@ static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_ return false; } for (uint32_t i = end; i < (uint32_t)source_len; i++) { - if (source[i] != ' ' && source[i] != '\t') { + if (!cbm_is_blank_not_newline(source[i])) { return false; } } diff --git a/tests/test_parse_coverage.c b/tests/test_parse_coverage.c index ba741531d..e044418fb 100644 --- a/tests/test_parse_coverage.c +++ b/tests/test_parse_coverage.c @@ -470,6 +470,77 @@ TEST(perl_malformed_source_remains_partial_issue1838) { PASS(); } +/* ── #1746: trailing blanks before EOF are still just an absent newline ─────── + * + * #1610 suppressed the zero-width MISSING terminator but tested for it with + * `end == source_len`. Trailing blanks are extras owned by no node, so + * `ENTRYPOINT ["a"] ` + EOF parks it at [29,29) while source_len is 30. + * + * The reporter's byte-exact controls pin the trigger to the PAIR: `] ` + EOF + * flags, `] ` + newline is clean, `]` + EOF is clean. */ +TEST(dockerfile_trailing_blank_at_eof_not_flagged_issue1746) { + const char *cases[] = { + "FROM scratch\nENTRYPOINT [\"a\"] ", /* space + EOF — the report */ + "FROM scratch\nENTRYPOINT [\"a\"]\t", /* tab + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"]\v", /* vertical tab + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"]\f", /* form feed + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"] \t ", /* run of blanks + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"] \r", /* CRLF file truncated to CR */ + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + CBMFileResult *r = do_extract(cases[i], CBM_LANG_DOCKERFILE, "Dockerfile"); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + if (flagged) { + fprintf(stderr, " case %zu flagged: ranges=%s\n", i, + r->error_ranges ? r->error_ranges : "(none)"); + } + cbm_free_result(r); + if (flagged) { + FAIL("trailing blanks must not turn an absent final newline into parse_partial"); + } + } + PASS(); +} + +/* GUARD: widening the tail must not swallow a genuine mid-file failure just + * because the file happens to end in blanks. */ +TEST(real_error_before_eof_still_flagged_with_trailing_blank_issue1746) { + size_t n = strlen(C_IFDEF_SPLIT); + char *buf = (char *)malloc(n + 1); + ASSERT_NOT_NULL(buf); + memcpy(buf, C_IFDEF_SPLIT, n + 1); + buf[n - 1] = ' '; /* final newline becomes a blank */ + + CBMFileResult *r = do_extract(buf, CBM_LANG_C, "split.c"); + free(buf); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + bool has_ranges = r->error_ranges != NULL; + cbm_free_result(r); + if (!flagged) { + FAIL("a real mid-file parse failure must still be reported when the file ends in blanks"); + } + if (!has_ranges) { + FAIL("a reported failure must still name its line range"); + } + PASS(); +} + +/* GUARD: a WIDTH-BEARING loss at EOF stays honest with a blank tail too — the + * Makefile recipe really is dropped, and only zero-width nodes are excused. */ +TEST(width_bearing_error_at_eof_still_flagged_with_trailing_blank_issue1746) { + const char *src = "all:\n\techo hi "; + CBMFileResult *r = do_extract(src, CBM_LANG_MAKEFILE, "Makefile"); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + cbm_free_result(r); + if (!flagged) { + FAIL("a width-bearing parse failure at EOF must still be reported when the file ends in blanks"); + } + PASS(); +} + SUITE(parse_coverage) { RUN_TEST(c_ifdef_split_brace_sets_parse_incomplete); RUN_TEST(c_ifdef_split_brace_neighbors_still_extracted); @@ -491,4 +562,7 @@ SUITE(parse_coverage) { RUN_TEST(width_bearing_error_at_eof_still_flagged_issue1610); RUN_TEST(perl_format_followed_by_named_sub_is_complete_issue1838); RUN_TEST(perl_malformed_source_remains_partial_issue1838); + RUN_TEST(dockerfile_trailing_blank_at_eof_not_flagged_issue1746); + RUN_TEST(real_error_before_eof_still_flagged_with_trailing_blank_issue1746); + RUN_TEST(width_bearing_error_at_eof_still_flagged_with_trailing_blank_issue1746); } From e0785b353af1af3812d5d14cad55c839efc5749d Mon Sep 17 00:00:00 2001 From: Rares Popa <2606875+rarepops@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:46:29 +0200 Subject: [PATCH 07/14] feat(ui): show serving binary version Expose the compiled server version through the existing UI config endpoint and render it beside the product name. Keep older or unavailable config responses compatible by hiding the label, with focused frontend and native endpoint coverage. Fixes #1820 Signed-off-by: Rares Popa <2606875+rarepops@users.noreply.github.com> --- graph-ui/src/App.test.tsx | 59 +++++++++++++++++++++++++++++++++++++++ graph-ui/src/App.tsx | 24 ++++++++++++++++ src/ui/http_server.c | 10 +++++-- tests/test_httpd.c | 21 ++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 graph-ui/src/App.test.tsx diff --git a/graph-ui/src/App.test.tsx b/graph-ui/src/App.test.tsx new file mode 100644 index 000000000..f6437c40d --- /dev/null +++ b/graph-ui/src/App.test.tsx @@ -0,0 +1,59 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { messages } from "./lib/i18n"; + +vi.mock("./components/GraphTab", () => ({ GraphTab: () => null })); +vi.mock("./components/StatsTab", () => ({ StatsTab: () => null })); +vi.mock("./components/ControlTab", () => ({ ControlTab: () => null })); +vi.mock("./lib/i18n", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useUiMessages: () => messages.en }; +}); + +describe("App", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); + }); + + it("shows the serving binary version", async () => { + vi.stubGlobal("fetch", vi.fn(async () => + new Response(JSON.stringify({ lang: "en", version: "0.10.8" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + )); + + render(); + + expect(await screen.findByText("v0.10.8")).toBeVisible(); + }); + + it("hides the version when the config has no string version", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ lang: "en", version: 108 }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config")); + expect(screen.queryByTitle("Server version")).not.toBeInTheDocument(); + }); + + it("hides the version when the config request fails", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("offline"); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config")); + expect(screen.queryByTitle("Server version")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 18392667c..a0d84b0b6 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -33,8 +33,24 @@ function routeUrl(tab: TabId, project: string | null): string { export function App() { const t = useUiMessages(); const [route, setRoute] = useState(readRoute); + const [version, setVersion] = useState(null); const { tab: activeTab, project: selectedProject } = route; + useEffect(() => { + let cancelled = false; + void fetch("/api/ui-config") + .then((response) => (response.ok ? response.json() : null)) + .then((config) => { + if (!cancelled && typeof config?.version === "string" && config.version) { + setVersion(config.version); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + /* Normalize the URL on first load so it always carries the current route. */ useEffect(() => { const initial = readRoute(); @@ -73,6 +89,14 @@ export function App() { Codebase Memory + {version && ( + + {version.startsWith("v") ? version : `v${version}`} + + )} {/* Tabs inline in header */} diff --git a/src/ui/http_server.c b/src/ui/http_server.c index b187438a5..77a600b11 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -66,6 +66,10 @@ /* ── Constants ────────────────────────────────────────────────── */ +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif + /* Max JSON-RPC request body size (1 MB) — transport enforces the same cap. */ #define MAX_BODY_SIZE CBM_HTTP_MAX_BODY @@ -143,8 +147,10 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) { * audit forbids hardcoded external URLs in graph-ui source (external * targets must come from an auditable backend response, same pattern as * the /api/repo-info deep-links). */ - cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\",\"upstream_issues_url\":\"%s\"}", - lang_buf, "https://github.com/DeusData/codebase-memory-mcp/issues/new"); + cbm_http_replyf(c, 200, g_cors_json, + "{\"lang\":\"%s\",\"version\":\"%s\",\"upstream_issues_url\":\"%s\"}", + lang_buf, CBM_VERSION, + "https://github.com/DeusData/codebase-memory-mcp/issues/new"); } /* ── Server state ─────────────────────────────────────────────── */ diff --git a/tests/test_httpd.c b/tests/test_httpd.c index eacf92634..3ac680197 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -33,6 +33,9 @@ #include #include #include +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif #ifndef _WIN32 #include #endif @@ -1573,6 +1576,23 @@ TEST(ui_server_ui_config_detects_zh_accept_language) { PASS(); } +TEST(ui_server_ui_config_includes_serving_version_issue1820) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + + char resp[4096]; + int n = th_http(cbm_http_server_port(ts.srv), "GET /api/ui-config HTTP/1.1\r\n\r\n", resp, + sizeof(resp)); + ASSERT_TRUE(n > 0); + ASSERT_EQ(th_status(resp), 200); + char expected_version[128]; + snprintf(expected_version, sizeof(expected_version), "\"version\":\"%s\"", CBM_VERSION); + ASSERT_NOT_NULL(strstr(resp, expected_version)); + + th_server_stop(&ts); + PASS(); +} + TEST(ui_server_ui_config_prefers_config_lang) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX"); @@ -2397,6 +2417,7 @@ SUITE(httpd) { RUN_TEST(ui_server_delete_project_invalid_name_keeps_watch); RUN_TEST(ui_server_delete_project_unlink_failure_keeps_watch); RUN_TEST(ui_server_ui_config_detects_zh_accept_language); + RUN_TEST(ui_server_ui_config_includes_serving_version_issue1820); RUN_TEST(ui_server_ui_config_prefers_config_lang); RUN_TEST(ui_server_slow_request_hits_deadline); RUN_TEST(ui_server_access_log_redacts_query); From c4e902849f8062e37a0f0ddaed1c6cc042780cb3 Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Fri, 28 Aug 2026 21:46:32 -0400 Subject: [PATCH 08/14] fix(extraction): reach Swift call arguments through call_suffix handle_calls reads a call's arguments through one tree-sitter field lookup, ts_node_child_by_field_name(node, "arguments"). The vendored Swift grammar declares no "arguments" field at all -- its own ts_field_names[] table has zero occurrences, against one in Go and two in TypeScript. Swift models a call as a target expression plus a call_suffix, and the arguments hang off the suffix as value_arguments. So args was null for every Swift call ever parsed, first_string_arg was never populated, and no Swift HTTP call could raise an HTTP_CALLS edge or a Route node. Alamofire, Moya and URLSession have been in the service-pattern table the whole time and match the callee text correctly; the URL simply never arrived. Three changes, all in extract_calls.c: - swift_call_args() reaches the argument list through call_suffix, in the same shape as the existing objectscript_call_args() fallback and used from the same place. A trailing closure has no value_arguments, so it returns a null node and that call behaves as before. - extract_url_or_topic_arg() unwraps Swift's per-argument value_argument node, stepping past a leading value_argument_label. Without it, dataTask(with: "/api/v1/widgets") yields the label "with" rather than the path. PHP and C# already had the same unwrap for their own "argument" node. - is_string_like() gains line_string_literal, which is what Swift calls an ordinary "..." literal. The list already held raw_string_literal, so only Swift's common case was missing. This is the layer underneath #1892 rather than the whole of it. A literal URL argument now arrives; a literal nested inside a constructor, as in URLSession.shared.data(from: URL(string: "...")!), still does not, because extract_positional_url reads a literal, a template string, a concatenation or a named constant and that shape is none of them. Reproduce-first: all three tests fail without the fix, two on a null first_string_arg and one on HTTP_CALLS being 0. Fixes #1892 Signed-off-by: Joshua Richter --- internal/cbm/extract_calls.c | 32 ++++++++++++++++++++- tests/test_extraction.c | 37 ++++++++++++++++++++++++ tests/test_pipeline.c | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index c190e36b7..f61b34be4 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -61,7 +61,8 @@ static const char *lookup_url_builder(const CBMExtractCtx *ctx, const char *name static int is_string_like(const char *kind) { return (strcmp(kind, "string") == 0 || strcmp(kind, "string_literal") == 0 || strcmp(kind, "interpreted_string_literal") == 0 || - strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0); + strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0 || + strcmp(kind, "line_string_literal") == 0); } /* Strip surrounding quotes from a string, return arena-allocated copy */ @@ -2286,6 +2287,18 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) { arg = ts_node_named_child(arg, 0); } + /* Swift wraps each argument in a value_argument that may lead with its + * label, so `data(from: url)` would otherwise yield the label `from` + * rather than the value. Step past a leading value_argument_label. */ + if (strcmp(ts_node_type(arg), "value_argument") == 0 && + ts_node_named_child_count(arg) > 0) { + TSNode val = ts_node_named_child(arg, 0); + if (strcmp(ts_node_type(val), "value_argument_label") == 0 && + ts_node_named_child_count(arg) > 1) { + val = ts_node_named_child(arg, 1); + } + arg = val; + } const char *ak = ts_node_type(arg); if (strcmp(ak, "keyword_argument") == 0 || strcmp(ak, "pair") == 0) { @@ -3045,6 +3058,19 @@ static TSNode objectscript_call_args(TSNode node) { : cbm_find_child_by_kind(macro_function, "method_args"); } +/* Swift models a call as a target expression plus a call_suffix, and its grammar + * declares no "arguments" field at all, so the generic field lookup finds + * nothing for every Swift call. Reach the argument list through the suffix + * instead. A trailing closure has a call_suffix with no value_arguments, which + * returns a null node and leaves the call without a string argument, as before. */ +static TSNode swift_call_args(TSNode node) { + TSNode suffix = cbm_find_child_by_kind(node, "call_suffix"); + if (ts_node_is_null(suffix)) { + return (TSNode){0}; + } + return cbm_find_child_by_kind(suffix, "value_arguments"); +} + static bool node_has_token(TSNode node, const char *token) { uint32_t count = ts_node_child_count(node); for (uint32_t i = 0; i < count; i++) { @@ -3634,6 +3660,10 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML if (ts_node_is_null(args) && is_objectscript_language(ctx->language)) { args = objectscript_call_args(node); } + // Swift has no "arguments" field either; its args hang off call_suffix. + if (ts_node_is_null(args) && ctx->language == CBM_LANG_SWIFT) { + args = swift_call_args(node); + } if (!ts_node_is_null(args)) { call.first_string_arg = extract_url_or_topic_arg(ctx, args); /* #952: routes registered inside Laravel `prefix()->group()` diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5f3c9dd80..0af1a467c 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4027,6 +4027,41 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee) return NULL; } +/* #1892: the Swift grammar declares no "arguments" field, so the generic field + * lookup read nothing and every Swift call lost its arguments. Without the URL + * the service-pattern table cannot raise an HTTP_CALLS edge or a Route node, + * even though Alamofire/Moya/URLSession are already listed in it. */ +TEST(swift_call_string_arg_issue1892) { + CBMFileResult *r = + extract("func listWidgets() { AF.request(\"https://example.com/api/v1/widgets\") }\n", + CBM_LANG_SWIFT, "t", "Client.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "AF.request"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "https://example.com/api/v1/widgets"); + cbm_free_result(r); + PASS(); +} + +/* Swift labels its arguments, and each one sits in a value_argument node that + * leads with the label. Reading the first child alone would return `with` + * rather than the path. */ +TEST(swift_labeled_call_string_arg_issue1892) { + CBMFileResult *r = + extract("func fetch() { URLSession.shared.dataTask(with: \"/api/v1/widgets/1\") }\n", + CBM_LANG_SWIFT, "t", "Fetch.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "URLSession.shared.dataTask"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/api/v1/widgets/1"); + cbm_free_result(r); + PASS(); +} + /* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped * literal, consumed as client(buildPath(id)). The builder's URL is recorded in * the per-file constant map and resolved at the call site, for both return @@ -6782,6 +6817,8 @@ SUITE(extraction) { RUN_TEST(swift_constructor_call); RUN_TEST(swift_chained_call); RUN_TEST(swift_force_unwrap_scanner_shift); + RUN_TEST(swift_call_string_arg_issue1892); + RUN_TEST(swift_labeled_call_string_arg_issue1892); RUN_TEST(objc_interface); RUN_TEST(objc_implementation); RUN_TEST(dart_top_level_function); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2af0366f8..91b516677 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5480,6 +5480,61 @@ TEST(pipeline_native_fetch_classified_as_http_calls) { PASS(); } +/* #1892: Swift produced no Route node and no HTTP_CALLS edge, because the + * Swift grammar has no "arguments" field and the generic lookup therefore read + * no call arguments at all. Alamofire/URLSession were already in the service + * pattern table; the URL simply never reached it. This is the Swift twin of + * the TypeScript fetch case above. */ +TEST(pipeline_swift_http_call_makes_route_issue1892) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* URLSession, not Alamofire's `AF` shorthand: the service pattern table + * matches the library name in the callee text, and "AF.request" contains + * no such name. */ + write_temp_file(tmp, "Sources/Client.swift", + "import Foundation\n" + "final class Client {\n" + " func listWidgets() {\n" + " URLSession.shared.dataTask(with: \"/api/v1/widgets\")\n" + " }\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/swifthttp.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); + + ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1); + + /* The edge carries the URL, so pass_route_nodes can mint the Route the + * cross-repo matcher joins a server route against. */ + cbm_node_t *routes = NULL; + int route_count = 0; + cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count); + int widget_routes = 0; + for (int i = 0; i < route_count; i++) { + if (routes[i].qualified_name && strstr(routes[i].qualified_name, "/api/v1/widgets")) { + widget_routes++; + } + } + cbm_store_free_nodes(routes, route_count); + ASSERT_GTE(widget_routes, 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Native `fetch()` (#856), parallel path (>= 50 files -> pass_parallel.c's * resolve_file_calls). Mirrors pipeline_native_fetch_classified_as_http_calls * but forces the parallel resolver, since the empty-resolution fallback is a @@ -13145,6 +13200,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); + RUN_TEST(pipeline_swift_http_call_makes_route_issue1892); RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); /* Git history pass */ From ad04339030bbfea4e77139bcfa9a87356c1e0e5b Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Sun, 30 Aug 2026 14:43:43 -0400 Subject: [PATCH 09/14] test(cli): stop the registry plan check depending on the developer's machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test built a fixture with its own HOME and PATH, then asserted that the whole install plan contained no path with "/plugins/" in it. Agent detection does not stay inside HOME and PATH: cbm_find_cli also looks in /usr/local/bin and, on macOS, /opt/homebrew/bin. A developer with OpenCode installed there has it detected inside the fixture, and OpenCode ships a real plugin file, so the plan legitimately carried a "/plugins/" path and the assertion failed. On a machine without OpenCode the same assertion passed, which is why CI never saw it. Name the two directories the check actually meant — the fixture's Qoder and Pi directories, neither of which has a plugin directory, so a planned path under one would be invented. The check now tests the same thing regardless of what the developer has installed. No production behaviour changed. Searching well-known install directories is deliberate, so that install finds an agent whose command is not on the current PATH. Fixes the failure at tests/test_cli.c:6859. Local cli suite: 293 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Joshua Richter --- tests/test_cli.c | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.c b/tests/test_cli.c index b2b543d71..927437802 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -6561,8 +6561,19 @@ TEST(cli_agent_client_registry_routes_plan_install_and_uninstall) { char *plan = cbm_build_install_plan_json(tmpdir, binary_path); yyjson_doc *plan_doc = plan ? yyjson_read(plan, strlen(plan), 0) : NULL; yyjson_val *plan_root = plan_doc ? yyjson_doc_get_root(plan_doc) : NULL; + /* Neither of these agents has a plugin directory, so a planned path under + * one would be invented. Name the two directories rather than searching the + * whole plan for "/plugins/": OpenCode does ship a real plugin file, and + * agent detection finds a command in /usr/local/bin or /opt/homebrew/bin + * whatever HOME and PATH say, so a blanket search passes or fails according + * to what the developer happens to have installed. */ + char qoder_plugins[700]; + char pi_plugins[700]; + snprintf(qoder_plugins, sizeof(qoder_plugins), "%s/plugins/", qoder_dir); + snprintf(pi_plugins, sizeof(pi_plugins), "%s/plugins/", pi_dir); bool plan_ok = - plan && !strstr(plan, "/plugins/") && !strstr(plan, "plugin_files") && + plan && !strstr(plan, qoder_plugins) && !strstr(plan, pi_plugins) && + !strstr(plan, "plugin_files") && test_json_string_array_contains(plan_root, "config_files_planned", qoder_settings) && test_json_string_array_contains(plan_root, "config_files_planned", amazon_config) && test_json_string_array_contains(plan_root, "config_files_planned", roo_config) && From 93d2ee59eaec4999ca666a8be214de8d9f2f2bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=96=87=E7=91=84?= Date: Fri, 28 Aug 2026 18:55:48 +0800 Subject: [PATCH 10/14] fix(mcp): non-mutating query-only store resolve; annotate the ten read-only tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_graph, query_graph, trace_path, get_code_snippet, get_graph_schema, get_architecture, search_code, index_status, check_index_coverage and detect_changes were annotated readOnlyHint=false / destructiveHint=true because resolve_store()'s corrupt-store recovery quarantines database files - a mutation. That conservatism locked every plan-mode client out of the read-only analysis surface (#1100 calls the same tools "eleven read-only analysis tools"), since spec-compliant plan gates refuse tools that are not declared read-only. This closes the gap the annotation comment pointed at. The query-only resolve now classifies a failed integrity check without mutating: no mutation lease, no quarantine. A confirmed-corrupt database is reported (a new STORE_RECOVERY_CORRUPT status; the tool reply names the corruption and points at index_repository instead of a misleading "project not found") and left in place for a write-side open to quarantine and rebuild, exactly as before. Transient verdicts stay retryable. Write-side opens (index_repository, manage_adr updates) keep the existing blocking recovery, quarantine, and generation re-check semantics unchanged. The ten query tools' annotations flip to readOnlyHint=true / destructiveHint=false, making them callable under plan mode in Claude Code, Codex, ZCode, and every other client that gates on the hints. Tests: the annotations expectation table is flipped; the query branch of the guard-balance and guard-denial tests now assert zero leases taken and byte-identical files; the ghost-db test (#704) expects the corruption message; the four write-side recovery tests (generation re-check after lease wait, unique backup naming, publish failure, published-WAL completeness) are driven through manage_adr update - the write path where that recovery now lives. Signed-off-by: 周文瑄 --- src/mcp/mcp.c | 119 ++++++++++++++++++++++++++++++++++++----------- tests/test_mcp.c | 92 ++++++++++++++++++++---------------- 2 files changed, 145 insertions(+), 66 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index af2092a82..ec4871e31 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -754,23 +754,30 @@ typedef struct { } tool_annotation_def_t; /* Tool annotations are deliberately explicit. All tools operate on the local - * repository/index domain, so none cross an open-world trust boundary. */ + * repository/index domain, so none cross an open-world trust boundary. + * + * The ten pure query tools resolve their store through resolve_store(), whose + * query-only path is strictly non-mutating: a corrupt database is reported and + * left in place, never quarantined or rebuilt — quarantine/rebuild is reserved + * for write-side opens (index_repository, manage_adr writes). That is what + * makes readOnlyHint=true honest for them and lets plan-mode clients expose + * them (the "read-only analysis tools" surface described in #1100). */ static const tool_annotation_def_t TOOL_ANNOTATIONS[] = { {"index_repository", false, false, true, false}, - {"search_graph", false, true, true, false}, - {"query_graph", false, true, true, false}, - {"trace_path", false, true, true, false}, - {"get_code_snippet", false, true, true, false}, + {"search_graph", true, false, true, false}, + {"query_graph", true, false, true, false}, + {"trace_path", true, false, true, false}, + {"get_code_snippet", true, false, true, false}, {"get_file_outline", false, true, true, false}, - {"get_graph_schema", false, true, true, false}, + {"get_graph_schema", true, false, true, false}, {"compare_graphs", true, false, true, false}, - {"get_architecture", false, true, true, false}, - {"search_code", false, true, true, false}, + {"get_architecture", true, false, true, false}, + {"search_code", true, false, true, false}, {"list_projects", true, false, true, false}, {"delete_project", false, true, true, false}, - {"index_status", false, true, true, false}, - {"check_index_coverage", false, true, true, false}, - {"detect_changes", false, true, true, false}, + {"index_status", true, false, true, false}, + {"check_index_coverage", true, false, true, false}, + {"detect_changes", true, false, true, false}, {"manage_adr", false, true, false, false}, {"ingest_traces", false, false, false, false}, }; @@ -1624,6 +1631,12 @@ struct cbm_mcp_server { bool owns_store; /* true if we opened the store */ char *current_project; /* which project store is open for (heap) */ time_t store_last_used; /* last time resolve_store was called for a named project */ + /* Set by a query-only resolve that hit a confirmed-corrupt database and + * left it in place (no quarantine, no rebuild). Error builders read this + * so the tool reply names the corruption instead of a misleading + * "project not found". */ + bool readonly_resolve_hit_corrupt; + char readonly_corrupt_project[CBM_SZ_1K]; /* Session + auto-index state */ char session_root[CBM_SZ_1K]; /* detected project root path */ @@ -2217,14 +2230,21 @@ typedef enum { STORE_RECOVERY_NONE, STORE_RECOVERY_BUSY, STORE_RECOVERY_TRY_GUARD_UNAVAILABLE, + /* Query-only resolve: a confirmed-corrupt database was reported and left + * in place. Not an error state of the recovery machinery — the caller is + * expected to answer with the corruption, not retry. */ + STORE_RECOVERY_CORRUPT, } store_recovery_status_t; static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *project, bool mutation_already_held, bool nonblocking_recovery, - store_recovery_status_t *recovery_status) { + store_recovery_status_t *recovery_status, + bool allow_autorecovery) { if (recovery_status) { *recovery_status = STORE_RECOVERY_NONE; } + srv->readonly_resolve_hit_corrupt = false; + srv->readonly_corrupt_project[0] = '\0'; if (!project) { return NULL; /* project is required — no implicit fallback */ } @@ -2254,6 +2274,38 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr project_db_path(project, path, sizeof(path)); srv->store = path[0] ? cbm_store_open_path_query(path) : NULL; if (srv->store) { + /* Query-only resolve: classify a failed integrity check without any + * mutation — no lease, no quarantine. A corrupt database is reported + * and left in place for a write-side open (index_repository) to + * quarantine and rebuild; that is what keeps the ten read-only + * tools' readOnlyHint=true honest. A transient failure (lock, IO, or + * the shallow check's own prepare-error-as-corrupt blind spot) is + * answered as busy and retried by the next resolve. */ + if (!allow_autorecovery && !cbm_store_check_integrity(srv->store)) { + cbm_store_close(srv->store); + srv->store = NULL; + srv->store = cbm_store_open_path_query(path); + cbm_integrity_verdict_t verdict = + srv->store ? cbm_store_check_integrity_verdict(srv->store) + : CBM_INTEGRITY_TRANSIENT; + if (srv->store) { + cbm_store_close(srv->store); + srv->store = NULL; + } + if (verdict == CBM_INTEGRITY_CORRUPT) { + srv->readonly_resolve_hit_corrupt = true; + snprintf(srv->readonly_corrupt_project, + sizeof(srv->readonly_corrupt_project), "%s", project); + cbm_log_warn("store.corrupt_readonly", "project", project, "path", path, + "action", "left in place for a write-side rebuild"); + if (recovery_status) { + *recovery_status = STORE_RECOVERY_CORRUPT; + } + } else if (recovery_status) { + *recovery_status = STORE_RECOVERY_BUSY; + } + return NULL; + } /* Check DB integrity — back up (never silently delete) a corrupt DB */ if (!cbm_store_check_integrity(srv->store)) { cbm_store_close(srv->store); @@ -2358,7 +2410,8 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr } static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { - return resolve_store_internal(srv, project, false, false, NULL); + /* Query-only callers (every read tool): strictly non-mutating resolve. */ + return resolve_store_internal(srv, project, false, false, NULL, false); } /* Forward decl — definition lives below alongside list_projects. */ @@ -2489,16 +2542,28 @@ static char *build_no_store_error(const char *project) { : build_missing_project_error(); } +/* Same contract as build_no_store_error, but when the query-only resolve + * confirmed a corrupt database and left it in place, name that instead of a + * misleading "project not found". */ +static char *build_no_store_error_checked(cbm_mcp_server_t *srv, const char *project) { + if (srv->readonly_resolve_hit_corrupt && project && + strcmp(project, srv->readonly_corrupt_project) == 0) { + return build_project_list_error( + "project store is corrupt (left untouched); run index_repository to rebuild it"); + } + return build_no_store_error(project); +} + /* Bail with the right error when no store is available. */ -#define REQUIRE_STORE(store, project) \ - do { \ - if (!(store)) { \ - char *_err = build_no_store_error(project); \ - char *_res = cbm_mcp_text_result(_err, true); \ - free(_err); \ - free(project); \ - return _res; \ - } \ +#define REQUIRE_STORE(store, project) \ + do { \ + if (!(store)) { \ + char *_err = build_no_store_error_checked(srv, project); \ + char *_res = cbm_mcp_text_result(_err, true); \ + free(_err); \ + free(project); \ + return _res; \ + } \ } while (0) static bool project_has_adr(cbm_store_t *store, const char *project, const char *root_path) { @@ -11627,7 +11692,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { char *root_path = get_project_root(srv, project); if (!root_path) { - char *err = build_no_store_error(project); + char *err = build_no_store_error_checked(srv, project); char *res = cbm_mcp_text_result(err, true); free(err); free(project); @@ -12396,8 +12461,8 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { * backend the UI /api/adr endpoints use — so writes via the MCP tool and * the UI are visible to each other (#256). */ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE; - cbm_store_t *resolved = - resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status); + cbm_store_t *resolved = resolve_store_internal(srv, project, mutation_held, !write_request, + &recovery_status, true); if (!resolved) { char *res = NULL; if (recovery_status == STORE_RECOVERY_BUSY) { @@ -12406,7 +12471,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { res = cbm_mcp_text_result("project recovery requires a nonblocking mutation guard", true); } else { - char *err = build_no_store_error(project); + char *err = build_no_store_error_checked(srv, project); res = cbm_mcp_text_result(err, true); free(err); } @@ -12458,7 +12523,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { invalidate_cached_store(srv); resolved = NULL; store = NULL; - resolved = resolve_store_internal(srv, project, true, false, NULL); + resolved = resolve_store_internal(srv, project, true, false, NULL, true); } if (resolved) { store = open_adr_store_for_write(srv, resolved, &owned_rw); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 8db63b1ed..e7959bdee 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -934,23 +934,27 @@ TEST(mcp_tools_have_behavior_annotations) { bool open_world; } expected[] = { {"index_repository", false, false, true, false}, - /* These query tools can reach resolve_store(), whose corrupt-store - * recovery quarantines/removes database files. Keep the annotations - * conservative until query resolution is strictly non-mutating. */ - {"search_graph", false, true, true, false}, - {"query_graph", false, true, true, false}, - {"trace_path", false, true, true, false}, - {"get_code_snippet", false, true, true, false}, + /* The ten query tools resolve their store through the strictly + * non-mutating query-only path: a corrupt database is reported and + * left in place, never quarantined or rebuilt. Quarantine/rebuild is + * a write-side job (index_repository, manage_adr writes), so the + * read-only annotations are honest and plan-mode clients can expose + * these tools. get_file_outline arrived after this split and keeps + * its upstream conservative annotation. */ + {"search_graph", true, false, true, false}, + {"query_graph", true, false, true, false}, + {"trace_path", true, false, true, false}, + {"get_code_snippet", true, false, true, false}, {"get_file_outline", false, true, true, false}, - {"get_graph_schema", false, true, true, false}, + {"get_graph_schema", true, false, true, false}, {"compare_graphs", true, false, true, false}, - {"get_architecture", false, true, true, false}, - {"search_code", false, true, true, false}, + {"get_architecture", true, false, true, false}, + {"search_code", true, false, true, false}, {"list_projects", true, false, true, false}, {"delete_project", false, true, true, false}, - {"index_status", false, true, true, false}, - {"check_index_coverage", false, true, true, false}, - {"detect_changes", false, true, true, false}, + {"index_status", true, false, true, false}, + {"check_index_coverage", true, false, true, false}, + {"detect_changes", true, false, true, false}, {"manage_adr", false, true, false, false}, {"ingest_traces", false, false, false, false}, }; @@ -7553,9 +7557,11 @@ TEST(tool_cross_repo_honors_source_name_override) { } /* Corrupt-store quarantine renames/unlinks the project DB and sidecars, so it - * is a mutation even when resolve_store() was reached by a query tool. Generic - * queries use a blocking guard for that recovery, while manage_adr reads must - * use one nonblocking acquisition and never nest a blocking lease. */ + * is a mutation — and exactly the mutation a query-only resolve must never + * perform. A query tool that meets a corrupt store reports the corruption and + * leaves every file in place, taking no mutation lease; the quarantine path + * belongs to write-side opens, where manage_adr reads must use one nonblocking + * acquisition and never nest a blocking lease. */ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-guard-XXXXXX", cbm_tmpdir()); @@ -7581,6 +7587,7 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { char *resp = cbm_mcp_handle_tool(query_srv, "search_graph", "{\"project\":\"guard-corrupt-project\",\"name_pattern\":\".*\"}"); + bool query_reports_corruption = resp && strstr(resp, "corrupt") != NULL; free(resp); cbm_mcp_server_free(query_srv); char query_backup_path[CBM_SZ_1K]; @@ -7617,13 +7624,14 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { free(saved_cache_copy); cbm_rmdir(cache); - ASSERT_TRUE(query_quarantined); - ASSERT_EQ(query_probe.begin_count, 1); - ASSERT_EQ(query_probe.end_count, 1); - ASSERT_STR_EQ(query_probe.begin_projects[0], project); - ASSERT_STR_EQ(query_probe.end_projects[0], project); - ASSERT_TRUE(query_probe.db_exists_at_begin); - ASSERT_FALSE(query_probe.db_exists_at_end); + /* A query-only resolve never repairs: no lease is taken, the corrupt DB + * and its sidecars stay in place, and the reply names the corruption + * instead of "project not found". */ + ASSERT_FALSE(query_quarantined); + ASSERT_TRUE(cbm_file_exists(db_path)); + ASSERT_TRUE(query_reports_corruption); + ASSERT_EQ(query_probe.begin_count, 0); + ASSERT_EQ(query_probe.end_count, 0); ASSERT_TRUE(adr_quarantined); ASSERT_EQ(adr_probe.begin_count, 0); ASSERT_EQ(adr_probe.try_begin_count, 1); @@ -7636,8 +7644,9 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { } /* Integrity is checked before the lease is requested, but quarantine itself - * must fail closed when that lease is denied. In particular, a rejected query - * may not remove either a recoverable DB generation or its committed WAL. */ + * must fail closed when that lease is denied. A query-only resolve never + * reaches that point at all: it asks for no lease, touches no file, and + * leaves both a recoverable DB generation and its committed WAL untouched. */ TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-denied-XXXXXX", cbm_tmpdir()); @@ -7695,9 +7704,9 @@ TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { free(saved_cache_copy); cbm_rmdir(cache); - ASSERT_EQ(begin_count, 1); + ASSERT_EQ(begin_count, 0); ASSERT_EQ(end_count, 0); - ASSERT_TRUE(guarded_project); + ASSERT_FALSE(guarded_project); ASSERT_TRUE(db_unchanged); ASSERT_TRUE(wal_unchanged); ASSERT_EQ(backup_count, 0); @@ -7801,10 +7810,11 @@ TEST(tool_manage_adr_corrupt_store_missing_try_guard_reports_configuration) { PASS(); } -/* Another session may publish a good generation while this query waits for - * the mutation lease. Cleanup must re-open and re-check the path after lease - * acquisition; quarantining based on the stale pre-wait handle loses the new - * generation and returns a false "not indexed" result. */ +/* A write-side open must trust only the generation that is current after its + * mutation lease is held: another session may have published a good + * generation while this request waited for the lease. Quarantining based on + * a stale pre-wait handle loses the new generation and returns a false + * "not indexed" result. */ TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-recheck-XXXXXX", cbm_tmpdir()); @@ -7832,7 +7842,8 @@ TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { cbm_mcp_server_set_project_mutation_guard(srv, mcp_replacing_mutation_guard_begin, mcp_replacing_mutation_guard_end, &replacement); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", "{\"project\":\"guard-corrupt-recheck\",\"name_pattern\":\".*\"}"); + srv, "manage_adr", + "{\"project\":\"guard-corrupt-recheck\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending replacement.\"}"); bool response_used_replacement = resp && !response_contains_json_fragment(resp, "\"isError\":true"); free(resp); @@ -7908,7 +7919,8 @@ TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", "{\"project\":\"guard-corrupt-unique\",\"name_pattern\":\".*\"}"); + srv, "manage_adr", + "{\"project\":\"guard-corrupt-unique\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending quarantine.\"}"); free(resp); cbm_mcp_server_free(srv); @@ -7981,9 +7993,9 @@ TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) { mcp_mutation_guard_probe_end, &guard); mcp_quarantine_hook_probe_t hook = {.deny_step = "before_snapshot_publish"}; cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); - char *resp = - cbm_mcp_handle_tool(srv, "search_graph", - "{\"project\":\"guard-corrupt-publish-fail\",\"name_pattern\":\".*\"}"); + char *resp = cbm_mcp_handle_tool( + srv, "manage_adr", + "{\"project\":\"guard-corrupt-publish-fail\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending publish.\"}"); bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); @@ -8056,8 +8068,8 @@ TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { mcp_quarantine_hook_probe_t hook = {.deny_step = "after_snapshot_publish"}; cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", - "{\"project\":\"guard-corrupt-after-publish\",\"name_pattern\":\".*\"}"); + srv, "manage_adr", + "{\"project\":\"guard-corrupt-after-publish\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending publish.\"}"); bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); @@ -11071,12 +11083,14 @@ TEST(tool_resolve_store_by_internal_name_issue704) { free(q_alpha); /* ── D: the 0-byte ghost is NOT resolvable ─────────────────────── */ + /* A query-only resolve reports the corrupt generation and leaves it in + * place — the reply names the corruption instead of "not found". */ char *q_ghost = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," "\"params\":{\"name\":\"search_graph\",\"arguments\":{" "\"project\":\"ghost704\",\"name_pattern\":\".*\",\"limit\":5}}}"); ASSERT_NOT_NULL(q_ghost); - ASSERT_NOT_NULL(strstr(q_ghost, "not found")); + ASSERT_NOT_NULL(strstr(q_ghost, "corrupt")); free(q_ghost); /* ── E: addressing the drifted db by its FILENAME stays not-found ── */ From 2d8721a60360df5804dfcce37640a2ca5fa0eac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=96=87=E7=91=84?= Date: Fri, 28 Aug 2026 20:26:08 +0800 Subject: [PATCH 11/14] style(mcp): clang-format the query-only resolve and error builder changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI lint gate flagged 18 clang-format violations in the lines the previous commit touched (the read-only verdict branch inside resolve_store_internal, the REQUIRE_STORE macro, and the two call sites that switched to build_no_store_error_checked). No semantic change: whitespace and line wrapping only; the mcp suite still passes 202/0/7. Signed-off-by: 周文瑄 --- src/mcp/mcp.c | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index ec4871e31..aefc810cd 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -2285,19 +2285,19 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr cbm_store_close(srv->store); srv->store = NULL; srv->store = cbm_store_open_path_query(path); - cbm_integrity_verdict_t verdict = - srv->store ? cbm_store_check_integrity_verdict(srv->store) - : CBM_INTEGRITY_TRANSIENT; + cbm_integrity_verdict_t verdict = srv->store + ? cbm_store_check_integrity_verdict(srv->store) + : CBM_INTEGRITY_TRANSIENT; if (srv->store) { cbm_store_close(srv->store); srv->store = NULL; } if (verdict == CBM_INTEGRITY_CORRUPT) { srv->readonly_resolve_hit_corrupt = true; - snprintf(srv->readonly_corrupt_project, - sizeof(srv->readonly_corrupt_project), "%s", project); - cbm_log_warn("store.corrupt_readonly", "project", project, "path", path, - "action", "left in place for a write-side rebuild"); + snprintf(srv->readonly_corrupt_project, sizeof(srv->readonly_corrupt_project), "%s", + project); + cbm_log_warn("store.corrupt_readonly", "project", project, "path", path, "action", + "left in place for a write-side rebuild"); if (recovery_status) { *recovery_status = STORE_RECOVERY_CORRUPT; } @@ -2555,15 +2555,15 @@ static char *build_no_store_error_checked(cbm_mcp_server_t *srv, const char *pro } /* Bail with the right error when no store is available. */ -#define REQUIRE_STORE(store, project) \ - do { \ - if (!(store)) { \ - char *_err = build_no_store_error_checked(srv, project); \ - char *_res = cbm_mcp_text_result(_err, true); \ - free(_err); \ - free(project); \ - return _res; \ - } \ +#define REQUIRE_STORE(store, project) \ + do { \ + if (!(store)) { \ + char *_err = build_no_store_error_checked(srv, project); \ + char *_res = cbm_mcp_text_result(_err, true); \ + free(_err); \ + free(project); \ + return _res; \ + } \ } while (0) static bool project_has_adr(cbm_store_t *store, const char *project, const char *root_path) { @@ -12034,7 +12034,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { /* module rollup: a quotient view of the blast radius */ if (impact.visited_count > 0) { cbm_sb_append(&sb, "impacted_modules: (rows: module count)\n"); - char (*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); + char(*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); int *mcnt = malloc(DETECT_MODCAP * sizeof(int)); if (mods && mcnt) { int overflow = 0; @@ -12097,7 +12097,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_val(doc, root_obj, "impacted", imp); /* Model parity with the tree encoding: the complete module rollup. */ if (impact.visited_count > 0) { - char (*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); + char(*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); int *mcnt = malloc(DETECT_MODCAP * sizeof(int)); if (mods && mcnt) { int overflow = 0; @@ -12461,8 +12461,8 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { * backend the UI /api/adr endpoints use — so writes via the MCP tool and * the UI are visible to each other (#256). */ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE; - cbm_store_t *resolved = resolve_store_internal(srv, project, mutation_held, !write_request, - &recovery_status, true); + cbm_store_t *resolved = + resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true); if (!resolved) { char *res = NULL; if (recovery_status == STORE_RECOVERY_BUSY) { From 240dc8156ba3ce9be92e2142e8506fef727710b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=96=87=E7=91=84?= Date: Fri, 28 Aug 2026 20:46:03 +0800 Subject: [PATCH 12/14] style(mcp): keep the upstream pointer-to-array declaration spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local clang-format 15 that produced the previous commit removes the space in 'char (*mods)[CBM_SZ_128]' (pointer-to-array declaration), while the CI formatter keeps it; 15 also rewrote those two pre-existing detect_changes lines even though they were untouched by this PR. Restore the upstream spelling so the CI format gate passes and the PR diff no longer touches lines it has no reason to touch. Signed-off-by: 周文瑄 --- src/mcp/mcp.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index aefc810cd..4eeebf45a 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -12034,7 +12034,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { /* module rollup: a quotient view of the blast radius */ if (impact.visited_count > 0) { cbm_sb_append(&sb, "impacted_modules: (rows: module count)\n"); - char(*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); + char (*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); int *mcnt = malloc(DETECT_MODCAP * sizeof(int)); if (mods && mcnt) { int overflow = 0; @@ -12097,7 +12097,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_val(doc, root_obj, "impacted", imp); /* Model parity with the tree encoding: the complete module rollup. */ if (impact.visited_count > 0) { - char(*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); + char (*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); int *mcnt = malloc(DETECT_MODCAP * sizeof(int)); if (mods && mcnt) { int overflow = 0; From adb49b3a28826e6eef871b1777b63668199c0463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=96=87=E7=91=84?= Date: Fri, 28 Aug 2026 23:25:47 +0800 Subject: [PATCH 13/14] test(mcp): snapshot the query-branch DB state before suite teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's tsan and lsan-macos runs failed the mutation-guard suite on ASSERT(cbm_file_exists(db_path)) at the guard-balance test, while the behavior suite passed everywhere. The assertion ran after the test's own teardown: cleanup_project_db() unlinks the same db path before the assertion block, and the manage_adr branch above replants and quarantines that very path, so the late file check observed the test's cleanup, not the query-only resolve it was meant to pin. Whether it tripped depended on unlink/visibility timing, which is why it surfaced only under some schedulers and never in the single-suite local runs. Capture cbm_file_exists() into query_db_left_in_place right after the query branch completes, before anything replants or removes the file, and assert on that snapshot. Signed-off-by: 周文瑄 --- tests/test_mcp.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp.c b/tests/test_mcp.c index e7959bdee..dc7716b14 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7595,6 +7595,11 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { mcp_find_corrupt_backups(cache, project, query_backup_path, sizeof(query_backup_path)); bool query_quarantined = !cbm_file_exists(db_path) && query_backup_count == 1 && query_backup_path[0] != '\0'; + /* Snapshot the on-disk state HERE: the manage_adr branch below replants + * and then quarantines this same path, and the suite's cleanup unlinks + * it before the assertions run — a later cbm_file_exists() would observe + * that teardown, not the query-only resolve this check pins. */ + bool query_db_left_in_place = cbm_file_exists(db_path); /* Replant the same deterministic corruption to exercise manage_adr's * already-held lease independently from the query server above. */ @@ -7628,7 +7633,7 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { * and its sidecars stay in place, and the reply names the corruption * instead of "project not found". */ ASSERT_FALSE(query_quarantined); - ASSERT_TRUE(cbm_file_exists(db_path)); + ASSERT_TRUE(query_db_left_in_place); ASSERT_TRUE(query_reports_corruption); ASSERT_EQ(query_probe.begin_count, 0); ASSERT_EQ(query_probe.end_count, 0); From 0ac290e2e243148b9e84bb682cf1101fe38bc6e3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 3 Sep 2026 15:48:59 +0200 Subject: [PATCH 14/14] fix(cli): drop the POSIX probe-counter store that Clang 23 rejects `g_mcp_command_path_probe_counter` is only ever read inside `cbm_json_mcp_probe_command_path`, which is compiled for Windows alone. On POSIX test builds the setter stored a pointer nothing read, and Clang 23 (Homebrew LLVM 23.1.0, which `brew install llvm` now pours on the macOS LSan leg) flags that as `-Wunused-but-set-global`; with `-Werror` the whole test-runner build dies at `src/cli/cli.c:1768:21`. Keep the counter where a probe can happen (Windows) and make the POSIX setter an explicit no-op with the reason spelled out. The three tests that install a counter keep asserting it stays at zero on every platform, which on POSIX is exactly what "no classifier" means. Verified in the failing arena: `origin/main` `src/cli/cli.c` fails under clang 23.1.1 with the CI diagnostic; with this change it compiles clean, as do all 105 built `src` TUs and all 155 test + extraction TUs under the same flags. `cli` suite: 292 passed with Homebrew LLVM 22. Signed-off-by: Martin Vogel --- src/cli/cli.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cli/cli.c b/src/cli/cli.c index 1e1c71f15..7badf1724 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1765,14 +1765,25 @@ static bool cbm_json_mcp_command_path_probe_safe(const char *command) { #endif #ifdef CBM_CLI_ENABLE_TEST_API +#ifdef _WIN32 static CBM_TLS int *g_mcp_command_path_probe_counter = NULL; +#endif bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows) { return cbm_json_mcp_command_path_probe_safe_for_platform(command, windows); } void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter) { +#ifdef _WIN32 g_mcp_command_path_probe_counter = counter; +#else + /* The command-path classifier (cbm_json_mcp_probe_command_path) is compiled + * for Windows only, so on POSIX there is no probe to count. The tests still + * install a counter on every platform and assert it stays at zero; a pointer + * stored here but never read is what Clang 23 rejects under -Werror + * (-Wunused-but-set-global). */ + (void)counter; +#endif } #endif