From b974adf6ed9b82d8669d1a65b1757816ce0d12df Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Mon, 7 Sep 2026 10:19:48 -0400 Subject: [PATCH 1/2] fix: grow the ts_lsp AST walk stacks so wide files keep every declaration Three walks in internal/cbm/lsp/ts_lsp.c held at most 256 AST nodes in a fixed array and stopped pushing when it filled. Nothing reported the stop, so the passes answered as if they had seen the whole file. In rebuild_signatures_from_ast and convert_signature_type_params the seeding loop alone can fill all 256 slots, because it pushes every top-level child of the file. A file with more than 256 top-level declarations therefore skipped every declaration past that point: each one kept whatever signature the first extraction pass had guessed, and a generic function past the cap kept NAMED instead of TYPE_PARAM, so calls through it stopped resolving. In infer_return_type_from_body the same cap made a wide function body answer "unknown return type" for a return the walk had never reached. The repo already carries the module that solves this: internal/cbm/extract_node_stack.h, written for GitHub issue #199 to replace fixed TSNode stack[] arrays that silently drop subtrees. Nine extract files use it; ts_lsp.c was never converted. This converts it. The header gains ts_nstack_init_arena, which takes an arena directly, because the LSP passes hold a TSLSPContext rather than a CBMExtractCtx. ts_nstack_init keeps its context argument, which is what makes handing over the wrong arena a type error, and now delegates to the new function. Traversal order changes from last-child-first to first-child-first, because ts_nstack_push_children reverses on push. That also drops an O(N^2) ts_node_child(n, i) loop for one cursor pass. For infer_return_type_from_body the new order matches the comment the function already carried: it finds the first return, where the old code found the last. New test tslsp_stress_declarations_past_stack_cap makes the same claim as tslsp_generic_identity_inference, 300 declarations deeper in the file. Without this change it fails with "MISSING resolved call: caller~.go -> callee~use"; with it the ts_lsp suite is 304 passed, 0 failed. Signed-off-by: Joshua Richter --- internal/cbm/extract_node_stack.h | 18 ++++++-- internal/cbm/lsp/ts_lsp.c | 76 +++++++++++++++---------------- tests/test_ts_lsp.c | 30 ++++++++++++ 3 files changed, 79 insertions(+), 45 deletions(-) diff --git a/internal/cbm/extract_node_stack.h b/internal/cbm/extract_node_stack.h index 0013d53b1..443eb6e6a 100644 --- a/internal/cbm/extract_node_stack.h +++ b/internal/cbm/extract_node_stack.h @@ -34,6 +34,18 @@ typedef struct { CBMArena *scratch; } TSNodeStack; +/* Initialize a stack against an arena directly. The LSP passes hold a + * TSLSPContext, not a CBMExtractCtx, and every allocation in those files + * already comes from that one arena — see collect_children in ts_lsp.c. An + * extraction caller should still use ts_nstack_init below, whose context + * argument is what makes handing over the wrong arena a type error. */ +static inline void ts_nstack_init_arena(TSNodeStack *s, CBMArena *arena, int initial_cap) { + 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; +} + /* 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 @@ -41,11 +53,7 @@ typedef struct { * 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; + ts_nstack_init_arena(s, ctx->scratch ? ctx->scratch : ctx->arena, initial_cap); } /* Push a node onto the stack, growing 2x if needed. */ diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index fc8b8911f..2e7292416 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -27,6 +27,7 @@ */ #include "ts_lsp.h" +#include "extract_node_stack.h" #include #include #include @@ -4634,13 +4635,15 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body if (ts_node_is_null(body)) return cbm_type_unknown(); - // Iterative DFS using a small fixed-size stack to avoid C-stack blowup on big bodies. - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; - int top = 0; - stack[top++] = body; - while (top > 0) { - TSNode n = stack[--top]; + // Iterative DFS to avoid C-stack blowup on big bodies. The stack grows, so a + // body wider than any fixed cap is still walked in full: a fixed 256 slots + // used to stop pushing without a word, and this function then answered + // "unknown return type" for a body whose return it had never reached. + TSNodeStack stack; + ts_nstack_init_arena(&stack, ctx->arena, 256); + ts_nstack_push(&stack, body); + while (stack.count > 0) { + TSNode n = ts_nstack_pop(&stack); if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); @@ -4662,10 +4665,7 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body strcmp(k, "arrow_function") == 0 || strcmp(k, "method_definition") == 0) continue; - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + ts_nstack_push_children(&stack, n); } return cbm_type_unknown(); } @@ -4681,31 +4681,29 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR if (ts_node_is_null(root) || !reg || !ctx->module_qn) return; - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; - int top = 0; - uint32_t nc = ts_node_child_count(root); - for (uint32_t i = 0; i < nc && top < STACK_CAP; i++) - stack[top++] = ts_node_child(root, i); + /* The stack grows. A fixed 256 slots used to be filled by the seeding loop + * alone on a file with that many top-level children, and every declaration + * past it was then skipped without a word — it kept whatever signature + * extract_defs had guessed. */ + TSNodeStack stack; + ts_nstack_init_arena(&stack, ctx->arena, 256); + ts_nstack_push_children(&stack, root); - while (top > 0) { - TSNode n = stack[--top]; + while (stack.count > 0) { + TSNode n = ts_nstack_pop(&stack); if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); // Recurse into export_statement and class bodies. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + ts_nstack_push_children(&stack, n); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); - if (!ts_node_is_null(body) && top < STACK_CAP) - stack[top++] = body; + if (!ts_node_is_null(body)) + ts_nstack_push(&stack, body); continue; } @@ -4845,31 +4843,29 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp return; // Walk: function_declaration, class_declaration { method_definition }, plus exported. - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; - int top = 0; - uint32_t nc = ts_node_child_count(root); - for (uint32_t i = 0; i < nc && top < STACK_CAP; i++) - stack[top++] = ts_node_child(root, i); - - while (top > 0) { - TSNode n = stack[--top]; + /* The stack grows. A fixed 256 slots used to be filled by the seeding loop + * alone on a file with that many top-level children, and every declaration + * past it was then skipped without a word — it kept whatever signature + * extract_defs had guessed. */ + TSNodeStack stack; + ts_nstack_init_arena(&stack, ctx->arena, 256); + ts_nstack_push_children(&stack, root); + + while (stack.count > 0) { + TSNode n = ts_nstack_pop(&stack); if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); // Recurse into export_statement and class_body. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + ts_nstack_push_children(&stack, n); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); - if (!ts_node_is_null(body) && top < STACK_CAP) - stack[top++] = body; + if (!ts_node_is_null(body)) + ts_nstack_push(&stack, body); continue; } diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index 52c3bbacb..49230da43 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -2817,6 +2817,35 @@ TEST(tslsp_stress_many_classes) { PASS(); } +TEST(tslsp_stress_declarations_past_stack_cap) { + /* 300 padding functions, then the generic pair that needs the AST walk. + * The walk used to hold 256 nodes at most, so every top-level declaration + * after the 256th was dropped without a word and kept whatever signature + * the first extraction pass had guessed. */ + enum { BUF_CAP = 64 * 1024 }; + char *buf = (char *)malloc(BUF_CAP); + if (!buf) + PASS(); + char *p = buf; + char *end = buf + BUF_CAP; + for (int i = 0; i < 300; i++) { + p += snprintf(p, (size_t)(end - p), "function pad%d(): void {}\n", i); + } + p += snprintf(p, (size_t)(end - p), "function identity(x: T): T { return x; }\n"); + p += snprintf(p, (size_t)(end - p), "class Box { use(): void {} }\n"); + p += snprintf(p, (size_t)(end - p), + "function go() { const b = identity(new Box()); b.use(); }\n"); + *p = '\0'; + + CBMFileResult *r = extract_ts(buf); + free(buf); + ASSERT_NOT_NULL(r); + /* Same claim as tslsp_generic_identity_inference, 300 declarations deeper. */ + ASSERT_GTE(require_resolved(r, ".go", "use"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_stress_deep_inheritance) { /* Chain of 30 classes via extends, leaf method on root. */ char buf[16 * 1024]; @@ -4565,6 +4594,7 @@ SUITE(ts_lsp) { /* Stress tests */ RUN_TEST(tslsp_stress_many_classes); + RUN_TEST(tslsp_stress_declarations_past_stack_cap); RUN_TEST(tslsp_stress_deep_inheritance); RUN_TEST(tslsp_stress_long_method_chain); RUN_TEST(tslsp_stress_megafile_mixed); From ec56ea8f60f0058bd1cd0fffb38cc0153b99f895 Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Mon, 7 Sep 2026 12:28:01 -0400 Subject: [PATCH 2/2] test: cover the return-type walk past the old stack cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit converted three walks in ts_lsp.c but tested only two of them. infer_return_type_from_body was changed without a test of its own. tslsp_stress_return_past_stack_cap makes the same claim as the existing tslsp_return_inferred_local, with the return placed after 300 statements — more than the 256 slots the old fixed array held, so the walk used to stop before reaching it and answer "unknown return type". Without the fix the suite is 303 passed, 2 failed, the second failure being this test. With it, 305 passed. Signed-off-by: Joshua Richter --- tests/test_ts_lsp.c | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index 49230da43..ba0409e90 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -2846,6 +2846,35 @@ TEST(tslsp_stress_declarations_past_stack_cap) { PASS(); } +TEST(tslsp_stress_return_past_stack_cap) { + /* infer_return_type_from_body reads the return type out of a body that + * carries no written one. The return here sits after 300 statements, more + * than the old fixed cap held, so the walk used to stop before reaching it + * and answer "unknown return type". */ + enum { BUF_CAP = 64 * 1024 }; + char *buf = (char *)malloc(BUF_CAP); + if (!buf) + PASS(); + char *p = buf; + char *end = buf + BUF_CAP; + p += snprintf(p, (size_t)(end - p), "class Conn { ping(): void {} }\n"); + p += snprintf(p, (size_t)(end - p), "function makeConn() {\n"); + for (int i = 0; i < 300; i++) { + p += snprintf(p, (size_t)(end - p), " let a%d = 0;\n", i); + } + p += snprintf(p, (size_t)(end - p), " return new Conn();\n}\n"); + p += snprintf(p, (size_t)(end - p), "function go() { const c = makeConn(); c.ping(); }\n"); + *p = '\0'; + + CBMFileResult *r = extract_ts(buf); + free(buf); + ASSERT_NOT_NULL(r); + /* Same claim as tslsp_return_inferred_local, 300 statements deeper. */ + ASSERT_GTE(require_resolved(r, ".go", "ping"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_stress_deep_inheritance) { /* Chain of 30 classes via extends, leaf method on root. */ char buf[16 * 1024]; @@ -4595,6 +4624,7 @@ SUITE(ts_lsp) { /* Stress tests */ RUN_TEST(tslsp_stress_many_classes); RUN_TEST(tslsp_stress_declarations_past_stack_cap); + RUN_TEST(tslsp_stress_return_past_stack_cap); RUN_TEST(tslsp_stress_deep_inheritance); RUN_TEST(tslsp_stress_long_method_chain); RUN_TEST(tslsp_stress_megafile_mixed);