From 95689b5c20101db7d92b69767bbea75e5bf0d4fc Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 17:59:58 +0200 Subject: [PATCH 1/7] 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 3ea5c48f5b..f06104ee05 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 7e5dcecae7..c65725abb4 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 b25e9f592b..c9744dda29 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 1eeb55f837..d6cc648af5 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 4b1d15563d..37f709a681 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 5126bcbfee..790e34a60b 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 73219f4da5..7657835eb3 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 a45541e57d..716b839a89 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 ff81a50eed..3747501abf 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 2/7] 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 7657835eb3..0bfcaf5522 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 3747501abf..2fd9669487 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 3/7] 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 c65725abb4..94d8b96b9d 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 0bfcaf5522..c67e0a7aa6 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 4/7] 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 cb81fc543b..b481fab6d6 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. - * - * Enclosing scopes are walked to the file root so a closure over an outer - * parameter counts (`def outer(run): def inner(): return run()`). + * "generic-looking" callee names -- Python forbids `global` on a parameter, and + * a parameter is in scope for the entire body regardless of position. * - * 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. + * 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. * - * 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 a5e3142727..5c2905f9ce 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 820d2dc0af..f80237229d 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 654bfe4064..55000a3950 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 5/7] 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 d7468af5a0..9b4d169771 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 440d80fcec..b4f09a90fc 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 c12d4ebeb4..493133a13c 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 f9854b03ef..e6656067b6 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 ab0fc8f964..902d13d53e 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 8696e4b308..c31e2bbfd4 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 d112ccc2f1..0013d53b1e 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 9379f7171f..a4ab6f2234 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 7830a8b188..7ac30ee1d9 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 8f78101f44..9bfdb0e49d 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 447e7c104c..28f7feb48e 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 31c5a686f2..e42eabb3d3 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 6/7] 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 da6e720a2f..cac199d2b2 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 ba741531d6..e044418fb3 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 7/7] 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 0000000000..f6437c40de --- /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 18392667cc..a0d84b0b67 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 b187438a5e..77a600b11b 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 eacf92634e..3ac680197f 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);