diff --git a/graph-ui/src/App.test.tsx b/graph-ui/src/App.test.tsx new file mode 100644 index 000000000..f6437c40d --- /dev/null +++ b/graph-ui/src/App.test.tsx @@ -0,0 +1,59 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { messages } from "./lib/i18n"; + +vi.mock("./components/GraphTab", () => ({ GraphTab: () => null })); +vi.mock("./components/StatsTab", () => ({ StatsTab: () => null })); +vi.mock("./components/ControlTab", () => ({ ControlTab: () => null })); +vi.mock("./lib/i18n", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useUiMessages: () => messages.en }; +}); + +describe("App", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); + }); + + it("shows the serving binary version", async () => { + vi.stubGlobal("fetch", vi.fn(async () => + new Response(JSON.stringify({ lang: "en", version: "0.10.8" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + )); + + render(); + + expect(await screen.findByText("v0.10.8")).toBeVisible(); + }); + + it("hides the version when the config has no string version", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ lang: "en", version: 108 }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config")); + expect(screen.queryByTitle("Server version")).not.toBeInTheDocument(); + }); + + it("hides the version when the config request fails", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("offline"); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config")); + expect(screen.queryByTitle("Server version")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 18392667c..a0d84b0b6 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -33,8 +33,24 @@ function routeUrl(tab: TabId, project: string | null): string { export function App() { const t = useUiMessages(); const [route, setRoute] = useState(readRoute); + const [version, setVersion] = useState(null); const { tab: activeTab, project: selectedProject } = route; + useEffect(() => { + let cancelled = false; + void fetch("/api/ui-config") + .then((response) => (response.ok ? response.json() : null)) + .then((config) => { + if (!cancelled && typeof config?.version === "string" && config.version) { + setVersion(config.version); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + /* Normalize the URL on first load so it always carries the current route. */ useEffect(() => { const initial = readRoute(); @@ -73,6 +89,14 @@ export function App() { Codebase Memory + {version && ( + + {version.startsWith("v") ? version : `v${version}`} + + )} {/* Tabs inline in header */} diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index da6e720a2..97f00ca2a 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" @@ -817,9 +821,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 +844,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; } } @@ -1186,11 +1194,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)); @@ -1301,6 +1325,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, @@ -1429,6 +1454,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, @@ -1665,6 +1691,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 e9d307c57..f5636738d 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -269,6 +269,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 { @@ -589,6 +593,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_calls.c b/internal/cbm/extract_calls.c index c190e36b7..05d789350 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -61,7 +61,8 @@ static const char *lookup_url_builder(const CBMExtractCtx *ctx, const char *name static int is_string_like(const char *kind) { return (strcmp(kind, "string") == 0 || strcmp(kind, "string_literal") == 0 || strcmp(kind, "interpreted_string_literal") == 0 || - strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0); + strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0 || + strcmp(kind, "line_string_literal") == 0); } /* Strip surrounding quotes from a string, return arena-allocated copy */ @@ -2286,6 +2287,18 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) { arg = ts_node_named_child(arg, 0); } + /* Swift wraps each argument in a value_argument that may lead with its + * label, so `data(from: url)` would otherwise yield the label `from` + * rather than the value. Step past a leading value_argument_label. */ + if (strcmp(ts_node_type(arg), "value_argument") == 0 && + ts_node_named_child_count(arg) > 0) { + TSNode val = ts_node_named_child(arg, 0); + if (strcmp(ts_node_type(val), "value_argument_label") == 0 && + ts_node_named_child_count(arg) > 1) { + val = ts_node_named_child(arg, 1); + } + arg = val; + } const char *ak = ts_node_type(arg); if (strcmp(ak, "keyword_argument") == 0 || strcmp(ak, "pair") == 0) { @@ -2973,6 +2986,41 @@ 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. */ +/* 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. + * + * 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. + * + * 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) { + const char *callee_name = cbm_node_text(ctx->arena, callee_ident, ctx->source); + return cbm_walk_python_param_is_bound(state, callee_name); +} + static bool is_objectscript_language(CBMLanguage language) { return language == CBM_LANG_OBJECTSCRIPT_UDL || language == CBM_LANG_OBJECTSCRIPT_ROUTINE; } @@ -3045,6 +3093,19 @@ static TSNode objectscript_call_args(TSNode node) { : cbm_find_child_by_kind(macro_function, "method_args"); } +/* Swift models a call as a target expression plus a call_suffix, and its grammar + * declares no "arguments" field at all, so the generic field lookup finds + * nothing for every Swift call. Reach the argument list through the suffix + * instead. A trailing closure has a call_suffix with no value_arguments, which + * returns a null node and leaves the call without a string argument, as before. */ +static TSNode swift_call_args(TSNode node) { + TSNode suffix = cbm_find_child_by_kind(node, "call_suffix"); + if (ts_node_is_null(suffix)) { + return (TSNode){0}; + } + return cbm_find_child_by_kind(suffix, "value_arguments"); +} + static bool node_has_token(TSNode node, const char *token) { uint32_t count = ts_node_child_count(node); for (uint32_t i = 0; i < count; i++) { @@ -3597,11 +3658,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, state, fn); } } // TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent @@ -3634,6 +3702,10 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML if (ts_node_is_null(args) && is_objectscript_language(ctx->language)) { args = objectscript_call_args(node); } + // Swift has no "arguments" field either; its args hang off call_suffix. + if (ts_node_is_null(args) && ctx->language == CBM_LANG_SWIFT) { + args = swift_call_args(node); + } if (!ts_node_is_null(args)) { call.first_string_arg = extract_url_or_topic_arg(ctx, args); /* #952: routes registered inside Laravel `prefix()->group()` diff --git a/internal/cbm/extract_channels.c b/internal/cbm/extract_channels.c index 9cedbd45d..a8916c28a 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 e29493e8d..6e6cd0c60 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5316,8 +5316,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); @@ -5345,7 +5345,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); } } } @@ -6285,8 +6285,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); @@ -6310,7 +6310,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); } } } @@ -7144,10 +7144,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); @@ -7162,7 +7162,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); } } } @@ -7278,7 +7278,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 @@ -7294,7 +7294,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; } @@ -7880,7 +7880,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, if (cbm_kind_in_set(node, spec->class_node_types)) { extract_class_def(ctx, node, spec); const char *new_enclosing = compute_class_qn(ctx, node, frame.enclosing_class_qn); - push_class_body_children(node, spec, &s, new_enclosing, ctx->arena); + push_class_body_children(node, spec, &s, new_enclosing, ctx); continue; } diff --git a/internal/cbm/extract_env_accesses.c b/internal/cbm/extract_env_accesses.c index ab0fc8f96..902d13d53 100644 --- a/internal/cbm/extract_env_accesses.c +++ b/internal/cbm/extract_env_accesses.c @@ -140,8 +140,8 @@ static bool is_env_var_name(const char *s) { // Iterative env access walker — explicit stack static void walk_env_accesses(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, 4096); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, 4096); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); @@ -163,7 +163,7 @@ static void walk_env_accesses(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec continue; // don't push children (avoid double-counting) } - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 9de2fd007..b3d705a9f 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); } } @@ -1666,8 +1666,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) { @@ -1686,7 +1686,7 @@ static void parse_hare_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1698,8 +1698,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) { @@ -1717,7 +1717,7 @@ static void parse_pascal_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -1730,8 +1730,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) { @@ -1741,8 +1741,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) { @@ -1756,7 +1756,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), @@ -1766,7 +1766,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); } } @@ -1993,17 +1993,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); } } @@ -2013,8 +2012,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) { @@ -2042,7 +2041,7 @@ static void parse_starlark_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2053,8 +2052,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) { @@ -2076,7 +2075,7 @@ static void parse_tcl_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2087,8 +2086,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) { @@ -2112,7 +2111,7 @@ static void parse_teal_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2122,8 +2121,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) { @@ -2143,7 +2142,7 @@ static void parse_zsh_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2230,15 +2229,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); } } @@ -2297,8 +2296,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) { @@ -2316,15 +2315,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); @@ -2332,7 +2331,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); } } @@ -2344,8 +2343,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); @@ -2368,35 +2367,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); } } @@ -2420,8 +2419,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) { @@ -2440,15 +2439,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); @@ -2456,15 +2455,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); @@ -2472,7 +2471,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); } } @@ -2480,8 +2479,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 @@ -2502,7 +2501,7 @@ static void parse_nickel_imports(CBMExtractCtx *ctx) { } } } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2526,8 +2525,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); @@ -2536,7 +2535,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); } } @@ -2544,8 +2543,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) { @@ -2559,15 +2558,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); @@ -2576,22 +2575,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); } } @@ -2599,8 +2598,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); @@ -2616,7 +2615,7 @@ static void parse_fsharp_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2624,8 +2623,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) { @@ -2644,7 +2643,7 @@ static void parse_ada_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2655,8 +2654,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) { @@ -2678,7 +2677,7 @@ static void parse_elm_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2689,8 +2688,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) { @@ -2707,7 +2706,7 @@ static void parse_move_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2741,8 +2740,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); @@ -2757,7 +2756,7 @@ static void parse_smali_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2767,8 +2766,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); @@ -2787,7 +2786,7 @@ static void parse_tlaplus_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2799,8 +2798,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) { @@ -2818,7 +2817,7 @@ static void parse_vhdl_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2828,8 +2827,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) { @@ -2847,7 +2846,7 @@ static void parse_wit_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2858,8 +2857,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) { @@ -2881,7 +2880,7 @@ static void parse_smithy_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } @@ -2891,8 +2890,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) { @@ -2909,7 +2908,7 @@ static void parse_hyprlang_imports(CBMExtractCtx *ctx) { } continue; } - ts_nstack_push_children(&stack, a, node); + ts_nstack_push_children(&stack, node); } } diff --git a/internal/cbm/extract_node_stack.h b/internal/cbm/extract_node_stack.h index d112ccc2f..0013d53b1 100644 --- a/internal/cbm/extract_node_stack.h +++ b/internal/cbm/extract_node_stack.h @@ -4,14 +4,22 @@ * Replaces fixed-size TSNode stack[] arrays that silently drop AST subtrees * when the stack overflows (GitHub issue #199). * - * Uses the arena allocator for zero-fragmentation growth: old blocks are - * abandoned (freed when the arena is destroyed at end of file extraction). - * Initial capacity matches the previous fixed caps so small files allocate - * no extra memory. + * Traversal stacks are scratch: nothing in a CBMFileResult ever points into + * one. They are therefore cut from ctx->scratch, which the enclosing + * cbm_extract_file_ex call owns and destroys on the way out, and never from + * ctx->arena, which the pipeline holds for every file until the whole result + * cache is freed (#1997). + * + * Growth abandons the old buffer in that scratch arena, which is free because + * the arena dies with the file. The initial capacities below are unchanged, but + * they are no longer what a small file costs: cbm_extract_file_ex creates the + * scratch arena's first block for every call, including calls that build no + * stack at all, and reclaims it on the way out. */ #ifndef CBM_EXTRACT_NODE_STACK_H #define CBM_EXTRACT_NODE_STACK_H +#include "cbm.h" /* CBMExtractCtx: a stack draws from ctx->scratch */ #include "arena.h" #include "tree_sitter/api.h" #include /* memcpy */ @@ -20,20 +28,31 @@ typedef struct { TSNode *items; int count; int cap; + /* The arena every allocation for this stack comes from, recorded once by + * ts_nstack_init so push() cannot be handed a different one. It is + * ctx->scratch, or ctx->arena as the fallback when the context has none. */ + CBMArena *scratch; } TSNodeStack; -/* Initialize a stack with the given initial capacity, arena-allocated. */ -static inline void ts_nstack_init(TSNodeStack *s, CBMArena *arena, int initial_cap) { +/* Initialize a stack with the given initial capacity, allocated from the + * context's traversal scratch. Taking the context rather than an arena is + * deliberate: it makes handing over ctx->arena, or a local alias of it, a type + * error rather than a retention bug nobody notices. A context built without a + * scratch falls back to ctx->arena, which is the behaviour that shipped before + * #1997, so no caller ever gets a NULL arena and silently loses nodes. */ +static inline void ts_nstack_init(TSNodeStack *s, const CBMExtractCtx *ctx, int initial_cap) { + CBMArena *arena = ctx->scratch ? ctx->scratch : ctx->arena; + s->scratch = arena; s->items = (TSNode *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(TSNode)); s->count = 0; s->cap = s->items ? initial_cap : 0; } /* Push a node onto the stack, growing 2x if needed. */ -static inline void ts_nstack_push(TSNodeStack *s, CBMArena *arena, TSNode node) { +static inline void ts_nstack_push(TSNodeStack *s, TSNode node) { if (s->count >= s->cap) { int new_cap = s->cap ? s->cap * 2 : 512; - TSNode *new_items = (TSNode *)cbm_arena_alloc(arena, (size_t)new_cap * sizeof(TSNode)); + TSNode *new_items = (TSNode *)cbm_arena_alloc(s->scratch, (size_t)new_cap * sizeof(TSNode)); if (!new_items) return; /* OOM: best-effort, stop growing */ if (s->items && s->count > 0) { @@ -54,7 +73,7 @@ static inline TSNode ts_nstack_pop(TSNodeStack *s) { /* * Push all children of `node` so they POP in forward (source) order — a drop-in * replacement for the common idiom: - * for (int i = (int)count - 1; i >= 0; i--) ts_nstack_push(s, a, ts_node_child(node, i)); + * for (int i = (int)count - 1; i >= 0; i--) ts_nstack_push(s, ts_node_child(node, i)); * * That idiom calls ts_node_child(node, i) once per index, and ts_node_child is * O(i) in tree-sitter (it walks the child iterator from the first child each @@ -63,12 +82,12 @@ static inline TSNode ts_nstack_pop(TSNodeStack *s) { * files). This helper enumerates children in a single O(N) cursor pass, then * reverses the just-pushed segment so pop order is identical to the old idiom. */ -static inline void ts_nstack_push_children(TSNodeStack *s, CBMArena *arena, TSNode node) { +static inline void ts_nstack_push_children(TSNodeStack *s, TSNode node) { int base = s->count; TSTreeCursor cursor = ts_tree_cursor_new(node); if (ts_tree_cursor_goto_first_child(&cursor)) { do { - ts_nstack_push(s, arena, ts_tree_cursor_current_node(&cursor)); + ts_nstack_push(s, ts_tree_cursor_current_node(&cursor)); } while (ts_tree_cursor_goto_next_sibling(&cursor)); } ts_tree_cursor_delete(&cursor); diff --git a/internal/cbm/extract_semantic.c b/internal/cbm/extract_semantic.c index 36709d2dd..63fa31e80 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)); } } } @@ -271,8 +271,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)) { @@ -280,7 +280,7 @@ static void walk_readwrites(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec * } uint32_t count = ts_node_child_count(node); for (int i = (int)count - LAST_IDX; i >= 0; i--) { - ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); + ts_nstack_push(&stack, ts_node_child(node, (uint32_t)i)); } } } diff --git a/internal/cbm/extract_type_assigns.c b/internal/cbm/extract_type_assigns.c index 7830a8b18..7ac30ee1d 100644 --- a/internal/cbm/extract_type_assigns.c +++ b/internal/cbm/extract_type_assigns.c @@ -186,12 +186,12 @@ static void process_type_assign_node(CBMExtractCtx *ctx, TSNode node, const CBML // Walk AST for assignment patterns where RHS is a constructor call. static void walk_type_assigns(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, 4096); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, 4096); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); process_type_assign_node(ctx, node, spec, cbm_enclosing_func_qn_cached(ctx, node)); - ts_nstack_push_children(&stack, ctx->arena, node); + ts_nstack_push_children(&stack, node); } } diff --git a/internal/cbm/extract_type_refs.c b/internal/cbm/extract_type_refs.c index 2b4a9023d..738334179 100644 --- a/internal/cbm/extract_type_refs.c +++ b/internal/cbm/extract_type_refs.c @@ -246,14 +246,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)); } } } @@ -290,8 +290,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); @@ -302,7 +302,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/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index a5e314272..5c2905f9c 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -119,11 +119,196 @@ static uint32_t add_lexical_scope(WalkState *state, TSNode node, CBMLexicalScope return scope->id; } +/* ── #1912: Python parameter bindings held live by the walk ─────────────────── + * + * A bare Python call `run()` cannot honestly resolve to a project function by + * short name when `run` is bound as a parameter of an enclosing def or lambda: + * the parameter shadows any module-level `run` for the whole body, so the match + * is fabricated by construction. + * + * Deciding that per call by ASCENDING the tree is the trap this replaces. Both + * ts_node_parent() (O(depth) per hop) and a walk-cursor ascent (O(1) per hop, + * O(depth) per call) are per-call costs, and every level of f(f(f(...))) is + * itself a bare call, so the file-wide cost is quadratic or worse -- exactly + * what CBMWalkScope's comment records for the state it replaced. Scanning the + * frame stack has the same shape. + * + * So the walk carries the answer instead: a name -> active-count map, pushed + * when a Python function or lambda scope opens and unwound when it closes. + * Lookup is O(1), which is what removes the need for a hop cap. + * + * A COUNT, not a flag: `def outer(run): def inner(run):` binds one name twice, + * and leaving the inner scope must not unbind the outer one. + * + * Every failure path answers "not bound", which can only ever cost a + * suppression -- never a true edge. */ + +static uint32_t py_param_hash(const char *s) { + uint32_t h = 2166136261u; /* FNV-1a */ + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + h = (h ^ *p) * 16777619u; + } + return h ? h : 1u; /* 0 marks an empty slot */ +} + +static CBMParamSlot *py_param_slot(WalkState *state, const char *name, uint32_t hash) { + int mask = state->py_param_slot_capacity - 1; + int i = (int)(hash & (uint32_t)mask); + for (;;) { + CBMParamSlot *slot = &state->py_param_slots[i]; + if (slot->hash == 0) { + return slot; /* free slot -- caller decides whether to claim it */ + } + if (slot->hash == hash && strcmp(slot->name, name) == 0) { + return slot; + } + i = (i + 1) & mask; + } +} + +static bool py_param_grow(WalkState *state) { + int new_capacity = state->py_param_slot_capacity * PAIR_LEN; + if (new_capacity <= state->py_param_slot_capacity) { + return false; + } + CBMParamSlot *grown = + (CBMParamSlot *)cbm_arena_alloc(state->arena, (size_t)new_capacity * sizeof(*grown)); + if (!grown) { + return false; + } + memset(grown, 0, (size_t)new_capacity * sizeof(*grown)); + CBMParamSlot *old = state->py_param_slots; + int old_capacity = state->py_param_slot_capacity; + state->py_param_slots = grown; + state->py_param_slot_capacity = new_capacity; + for (int i = 0; i < old_capacity; i++) { + if (old[i].hash != 0) { + *py_param_slot(state, old[i].name, old[i].hash) = old[i]; + } + } + return true; +} + +static bool py_param_stack_reserve(WalkState *state) { + if (state->py_param_stack_count < state->py_param_stack_capacity) { + return true; + } + int new_capacity = state->py_param_stack_capacity * PAIR_LEN; + if (new_capacity <= state->py_param_stack_capacity) { + return false; + } + const char **grown = + (const char **)cbm_arena_alloc(state->arena, (size_t)new_capacity * sizeof(*grown)); + if (!grown) { + return false; + } + memcpy(grown, state->py_param_stack, (size_t)state->py_param_stack_count * sizeof(*grown)); + state->py_param_stack = grown; + state->py_param_stack_capacity = new_capacity; + return true; +} + +/* Bind one parameter name for the lifetime of the frame currently on top. */ +static void py_param_bind(WalkState *state, const char *name) { + if (state->py_param_tracking_failed || !name || !name[0]) { + return; + } + /* Grow before inserting: the probe below must always find a free slot, and + * a table above ~70% load degrades toward linear probing. */ + if ((state->py_param_slot_used + 1) * 10 >= state->py_param_slot_capacity * 7) { + if (!py_param_grow(state)) { + state->py_param_tracking_failed = true; + return; + } + } + if (!py_param_stack_reserve(state)) { + state->py_param_tracking_failed = true; + return; + } + uint32_t hash = py_param_hash(name); + CBMParamSlot *slot = py_param_slot(state, name, hash); + if (slot->hash == 0) { + slot->hash = hash; + slot->name = name; + slot->count = 0; + state->py_param_slot_used++; + } + slot->count++; + state->py_param_stack[state->py_param_stack_count++] = name; +} + +/* Unwind to a frame's entry height, undoing exactly what that frame bound. */ +static void py_param_unwind_to(WalkState *state, int base) { + if (state->py_param_tracking_failed) { + return; + } + while (state->py_param_stack_count > base) { + const char *name = state->py_param_stack[--state->py_param_stack_count]; + CBMParamSlot *slot = py_param_slot(state, name, py_param_hash(name)); + if (slot->hash != 0 && slot->count > 0) { + slot->count--; + } + } +} + +bool cbm_walk_python_param_is_bound(const WalkState *state, const char *name) { + if (!state || !name || !name[0] || state->py_param_tracking_failed || + state->py_param_slot_used == 0) { + return false; + } + const CBMParamSlot *slot = py_param_slot((WalkState *)state, name, py_param_hash(name)); + return slot->hash != 0 && slot->count > 0; +} + +/* The identifier a Python parameter node binds. Handles the bare, typed, + * defaulted, keyword-only, *args and **kwargs shapes. */ +static const char *py_parameter_name(CBMExtractCtx *ctx, TSNode param) { + if (ts_node_is_null(param)) { + return NULL; + } + if (strcmp(ts_node_type(param), "identifier") == 0) { + return cbm_node_text(ctx->arena, param, ctx->source); + } + TSNode name = ts_node_child_by_field_name(param, TS_FIELD("name")); + if (!ts_node_is_null(name) && strcmp(ts_node_type(name), "identifier") == 0) { + return cbm_node_text(ctx->arena, name, ctx->source); + } + /* `*args` / `**kwargs`, and any typed shape without a `name` field: the + * bound identifier is the first named child. */ + TSNode first = ts_node_named_child(param, 0); + if (!ts_node_is_null(first) && strcmp(ts_node_type(first), "identifier") == 0) { + return cbm_node_text(ctx->arena, first, ctx->source); + } + return NULL; +} + +/* Bind this node's parameters into the frame just pushed for it. Called after + * every push in push_boundary_scopes, so the top frame is this node's own and + * pop_expired_scopes unwinds them at exactly the right depth. */ +static void py_bind_scope_parameters(CBMExtractCtx *ctx, TSNode node, WalkState *state) { + if (ctx->language != CBM_LANG_PYTHON || state->scope_top == 0) { + return; + } + const char *kind = ts_node_type(node); + if (strcmp(kind, "function_definition") != 0 && strcmp(kind, "lambda") != 0) { + return; + } + TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return; + } + uint32_t count = ts_node_named_child_count(params); + for (uint32_t i = 0; i < count; i++) { + py_param_bind(state, py_parameter_name(ctx, ts_node_named_child(params, i))); + } +} + static bool push_scope(WalkState *state, uint8_t kind, uint32_t depth, const char *qn) { if (!ensure_scope_capacity(state)) { return false; } CBMWalkScope *f = &state->scopes[state->scope_top]; + f->prev_py_param_stack_count = state->py_param_stack_count; f->kind = kind; f->depth = depth; f->qn = qn; @@ -260,6 +445,7 @@ static void pop_expired_scopes(WalkState *state, uint32_t cur_depth) { state->inside_import = f->prev_inside_import; state->loop_depth = f->prev_loop_depth; state->branch_depth = f->prev_branch_depth; + py_param_unwind_to(state, f->prev_py_param_stack_count); } } @@ -2354,6 +2540,7 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS } push_lexical_boundary(node, state, depth); + py_bind_scope_parameters(ctx, node, state); push_call_scope(state, depth, invocation); if (is_actual_import_boundary(ctx, node, spec) && !is_export_of_declaration(node)) { push_scope(state, SCOPE_IMPORT, depth, NULL); @@ -2387,6 +2574,14 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { state.scope_capacity = MAX_SCOPES; state.lexical_scopes = state.inline_lexical_scopes; state.lexical_scope_capacity = INLINE_LEXICAL_SCOPES; + state.py_param_slots = state.inline_py_param_slots; + state.py_param_slot_capacity = INLINE_PY_PARAM_SLOTS; + state.py_param_slot_used = 0; + memset(state.inline_py_param_slots, 0, sizeof(state.inline_py_param_slots)); + state.py_param_stack = state.inline_py_param_stack; + state.py_param_stack_capacity = INLINE_PY_PARAM_STACK; + state.py_param_stack_count = 0; + state.py_param_tracking_failed = false; state.usage_start_index = ctx->result->usages.count; state.root_lexical_scope_id = add_lexical_scope(&state, ctx->root, CBM_LEXICAL_SCOPE_MODULE); /* Base walk-state tuple (previously established by the first diff --git a/internal/cbm/extract_unified.h b/internal/cbm/extract_unified.h index 820d2dc0a..f80237229 100644 --- a/internal/cbm/extract_unified.h +++ b/internal/cbm/extract_unified.h @@ -18,6 +18,8 @@ #define INLINE_LEXICAL_SCOPES 64 #define INLINE_LEXICAL_BINDINGS 64 #define INLINE_PYTHON_DIRECTIVES 16 +#define INLINE_PY_PARAM_SLOTS 64 +#define INLINE_PY_PARAM_STACK 64 // ObjectScript type map: variable name → class name (for instance_method_call // resolution). Stack-allocated, per-method scope. Overflow is silent (no crash). @@ -51,6 +53,15 @@ typedef struct { bool raw_call_emitted; } CBMInvocationDescriptor; +/* One name bound as a function/lambda parameter by a scope currently OPEN on + * the walk stack. A count, not a flag: `def outer(run): def inner(run):` binds + * the same name twice and the inner pop must not unbind the outer. */ +typedef struct { + const char *name; + uint32_t hash; + int count; +} CBMParamSlot; + typedef struct { const char *qn; uint32_t depth; @@ -73,6 +84,9 @@ typedef struct { bool prev_inside_import; int prev_loop_depth; int prev_branch_depth; + /* #1912: py_param_stack height on entry. Pop unwinds back to it, so a + * frame unbinds exactly the parameters it bound and nothing else. */ + int prev_py_param_stack_count; } CBMWalkScope; typedef enum { @@ -158,11 +172,43 @@ typedef struct { CBMPythonDirective inline_python_directives[INLINE_PYTHON_DIRECTIVES]; int python_directive_capacity; int python_directive_count; + /* #1912 -- Python bare-call shadowing. A name bound as a parameter by ANY + * enclosing function or lambda shadows every project function, so a bare + * `run()` under `def outer(run)` cannot honestly resolve by short name. + * + * Maintained as a live name->count map pushed and popped BY THE WALK, not + * recomputed per call. Walking ancestors (either ts_node_parent or a + * cursor) and scanning the frame stack are both O(depth) per call, and + * since every level of f(f(f(...))) is itself a bare call that is + * quadratic across the file -- the same trap CBMWalkScope records above. + * Lookup here is O(1), so no hop cap and no fail-open cap are needed. + * + * The other binding table (CBMLexicalBinding) cannot serve this: it is + * qsort-ed in cbm_finalize_lexical_usages AFTER the walk and its + * active_end stays 0 until then, so its binary search is invalid from + * handle_calls, which runs mid-walk and before handle_usages. */ + CBMParamSlot *py_param_slots; + CBMParamSlot inline_py_param_slots[INLINE_PY_PARAM_SLOTS]; + int py_param_slot_capacity; + int py_param_slot_used; + const char **py_param_stack; + const char *inline_py_param_stack[INLINE_PY_PARAM_STACK]; + int py_param_stack_capacity; + int py_param_stack_count; + /* Allocation failure: stop tracking and answer "not bound" forever after, + * which can only cost a suppression, never a true edge. */ + bool py_param_tracking_failed; + CBMLanguage language; os_type_map_t os_type_map; // ObjectScript variable → type mapping } WalkState; +/* #1912: is `name` bound as a parameter by a Python function or lambda scope + * currently open on the walk stack? O(1). Answers false on any failure, so a + * caller can only ever lose a suppression, never a true edge. */ +bool cbm_walk_python_param_is_bound(const WalkState *state, const char *name); + // Per-node handler prototypes. Each is called once per node during the // unified cursor walk, replacing the old recursive walk_* functions. CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, diff --git a/src/cli/cli.c b/src/cli/cli.c index 1e1c71f15..7badf1724 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1765,14 +1765,25 @@ static bool cbm_json_mcp_command_path_probe_safe(const char *command) { #endif #ifdef CBM_CLI_ENABLE_TEST_API +#ifdef _WIN32 static CBM_TLS int *g_mcp_command_path_probe_counter = NULL; +#endif bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows) { return cbm_json_mcp_command_path_probe_safe_for_platform(command, windows); } void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter) { +#ifdef _WIN32 g_mcp_command_path_probe_counter = counter; +#else + /* The command-path classifier (cbm_json_mcp_probe_command_path) is compiled + * for Windows only, so on POSIX there is no probe to count. The tests still + * install a counter on every platform and assert it stays at zero; a pointer + * stored here but never read is what Clang 23 rejects under -Werror + * (-Wunused-but-set-global). */ + (void)counter; +#endif } #endif diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 9d2e2e161..50adb6d4e 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -754,23 +754,30 @@ typedef struct { } tool_annotation_def_t; /* Tool annotations are deliberately explicit. All tools operate on the local - * repository/index domain, so none cross an open-world trust boundary. */ + * repository/index domain, so none cross an open-world trust boundary. + * + * The ten pure query tools resolve their store through resolve_store(), whose + * query-only path is strictly non-mutating: a corrupt database is reported and + * left in place, never quarantined or rebuilt — quarantine/rebuild is reserved + * for write-side opens (index_repository, manage_adr writes). That is what + * makes readOnlyHint=true honest for them and lets plan-mode clients expose + * them (the "read-only analysis tools" surface described in #1100). */ static const tool_annotation_def_t TOOL_ANNOTATIONS[] = { {"index_repository", false, false, true, false}, - {"search_graph", false, true, true, false}, - {"query_graph", false, true, true, false}, - {"trace_path", false, true, true, false}, - {"get_code_snippet", false, true, true, false}, + {"search_graph", true, false, true, false}, + {"query_graph", true, false, true, false}, + {"trace_path", true, false, true, false}, + {"get_code_snippet", true, false, true, false}, {"get_file_outline", false, true, true, false}, - {"get_graph_schema", false, true, true, false}, + {"get_graph_schema", true, false, true, false}, {"compare_graphs", true, false, true, false}, - {"get_architecture", false, true, true, false}, - {"search_code", false, true, true, false}, + {"get_architecture", true, false, true, false}, + {"search_code", true, false, true, false}, {"list_projects", true, false, true, false}, {"delete_project", false, true, true, false}, - {"index_status", false, true, true, false}, - {"check_index_coverage", false, true, true, false}, - {"detect_changes", false, true, true, false}, + {"index_status", true, false, true, false}, + {"check_index_coverage", true, false, true, false}, + {"detect_changes", true, false, true, false}, {"manage_adr", false, true, false, false}, {"ingest_traces", false, false, false, false}, }; @@ -1624,6 +1631,12 @@ struct cbm_mcp_server { bool owns_store; /* true if we opened the store */ char *current_project; /* which project store is open for (heap) */ time_t store_last_used; /* last time resolve_store was called for a named project */ + /* Set by a query-only resolve that hit a confirmed-corrupt database and + * left it in place (no quarantine, no rebuild). Error builders read this + * so the tool reply names the corruption instead of a misleading + * "project not found". */ + bool readonly_resolve_hit_corrupt; + char readonly_corrupt_project[CBM_SZ_1K]; /* Session + auto-index state */ char session_root[CBM_SZ_1K]; /* detected project root path */ @@ -2217,14 +2230,21 @@ typedef enum { STORE_RECOVERY_NONE, STORE_RECOVERY_BUSY, STORE_RECOVERY_TRY_GUARD_UNAVAILABLE, + /* Query-only resolve: a confirmed-corrupt database was reported and left + * in place. Not an error state of the recovery machinery — the caller is + * expected to answer with the corruption, not retry. */ + STORE_RECOVERY_CORRUPT, } store_recovery_status_t; static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *project, bool mutation_already_held, bool nonblocking_recovery, - store_recovery_status_t *recovery_status) { + store_recovery_status_t *recovery_status, + bool allow_autorecovery) { if (recovery_status) { *recovery_status = STORE_RECOVERY_NONE; } + srv->readonly_resolve_hit_corrupt = false; + srv->readonly_corrupt_project[0] = '\0'; if (!project) { return NULL; /* project is required — no implicit fallback */ } @@ -2254,6 +2274,38 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr project_db_path(project, path, sizeof(path)); srv->store = path[0] ? cbm_store_open_path_query(path) : NULL; if (srv->store) { + /* Query-only resolve: classify a failed integrity check without any + * mutation — no lease, no quarantine. A corrupt database is reported + * and left in place for a write-side open (index_repository) to + * quarantine and rebuild; that is what keeps the ten read-only + * tools' readOnlyHint=true honest. A transient failure (lock, IO, or + * the shallow check's own prepare-error-as-corrupt blind spot) is + * answered as busy and retried by the next resolve. */ + if (!allow_autorecovery && !cbm_store_check_integrity(srv->store)) { + cbm_store_close(srv->store); + srv->store = NULL; + srv->store = cbm_store_open_path_query(path); + cbm_integrity_verdict_t verdict = srv->store + ? cbm_store_check_integrity_verdict(srv->store) + : CBM_INTEGRITY_TRANSIENT; + if (srv->store) { + cbm_store_close(srv->store); + srv->store = NULL; + } + if (verdict == CBM_INTEGRITY_CORRUPT) { + srv->readonly_resolve_hit_corrupt = true; + snprintf(srv->readonly_corrupt_project, sizeof(srv->readonly_corrupt_project), "%s", + project); + cbm_log_warn("store.corrupt_readonly", "project", project, "path", path, "action", + "left in place for a write-side rebuild"); + if (recovery_status) { + *recovery_status = STORE_RECOVERY_CORRUPT; + } + } else if (recovery_status) { + *recovery_status = STORE_RECOVERY_BUSY; + } + return NULL; + } /* Check DB integrity — back up (never silently delete) a corrupt DB */ if (!cbm_store_check_integrity(srv->store)) { cbm_store_close(srv->store); @@ -2358,7 +2410,8 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr } static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { - return resolve_store_internal(srv, project, false, false, NULL); + /* Query-only callers (every read tool): strictly non-mutating resolve. */ + return resolve_store_internal(srv, project, false, false, NULL, false); } /* Forward decl — definition lives below alongside list_projects. */ @@ -2489,16 +2542,28 @@ static char *build_no_store_error(const char *project) { : build_missing_project_error(); } +/* Same contract as build_no_store_error, but when the query-only resolve + * confirmed a corrupt database and left it in place, name that instead of a + * misleading "project not found". */ +static char *build_no_store_error_checked(cbm_mcp_server_t *srv, const char *project) { + if (srv->readonly_resolve_hit_corrupt && project && + strcmp(project, srv->readonly_corrupt_project) == 0) { + return build_project_list_error( + "project store is corrupt (left untouched); run index_repository to rebuild it"); + } + return build_no_store_error(project); +} + /* Bail with the right error when no store is available. */ -#define REQUIRE_STORE(store, project) \ - do { \ - if (!(store)) { \ - char *_err = build_no_store_error(project); \ - char *_res = cbm_mcp_text_result(_err, true); \ - free(_err); \ - free(project); \ - return _res; \ - } \ +#define REQUIRE_STORE(store, project) \ + do { \ + if (!(store)) { \ + char *_err = build_no_store_error_checked(srv, project); \ + char *_res = cbm_mcp_text_result(_err, true); \ + free(_err); \ + free(project); \ + return _res; \ + } \ } while (0) static bool project_has_adr(cbm_store_t *store, const char *project, const char *root_path) { @@ -11648,7 +11713,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { char *root_path = get_project_root(srv, project); if (!root_path) { - char *err = build_no_store_error(project); + char *err = build_no_store_error_checked(srv, project); char *res = cbm_mcp_text_result(err, true); free(err); free(project); @@ -12418,7 +12483,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { * the UI are visible to each other (#256). */ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE; cbm_store_t *resolved = - resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status); + resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true); if (!resolved) { char *res = NULL; if (recovery_status == STORE_RECOVERY_BUSY) { @@ -12427,7 +12492,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { res = cbm_mcp_text_result("project recovery requires a nonblocking mutation guard", true); } else { - char *err = build_no_store_error(project); + char *err = build_no_store_error_checked(srv, project); res = cbm_mcp_text_result(err, true); free(err); } @@ -12479,7 +12544,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { invalidate_cached_store(srv); resolved = NULL; store = NULL; - resolved = resolve_store_internal(srv, project, true, false, NULL); + resolved = resolve_store_internal(srv, project, true, false, NULL, true); } if (resolved) { store = open_adr_store_for_write(srv, resolved, &owned_rw); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index b25e9f592..c9744dda2 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -627,8 +627,16 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; + /* Bare-call local-binding suppression. A member call has a receiver the + * guard above can reason about; a bare `run()` has none, so that guard + * cannot see this class at all. Python-only today because the extraction + * flag is set only for Python — this gate MUST match pass_parallel.c's + * exactly, for the same divergence reason noted above. */ + bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON; bool drop_plain_call = - cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy); + cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || + cbm_suppress_weak_local_binding_call(suppress_weak_local_binding, + call->callee_is_locally_bound, res.strategy); /* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g. * `requests.get("/api/orders/{id}")`) resolve to a QN containing the library diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 77a64c408..cdeafe9d8 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2492,8 +2492,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 109da9781..dfe81f726 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 b29eba494..97c843355 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/src/ui/http_server.c b/src/ui/http_server.c index 8ed6d4297..b806b10c6 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -70,6 +70,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 @@ -147,8 +151,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_cli.c b/tests/test_cli.c index fe22858b8..4b98d8a92 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -6638,8 +6638,19 @@ TEST(cli_agent_client_registry_routes_plan_install_and_uninstall) { char *plan = cbm_build_install_plan_json(tmpdir, binary_path); yyjson_doc *plan_doc = plan ? yyjson_read(plan, strlen(plan), 0) : NULL; yyjson_val *plan_root = plan_doc ? yyjson_doc_get_root(plan_doc) : NULL; + /* Neither of these agents has a plugin directory, so a planned path under + * one would be invented. Name the two directories rather than searching the + * whole plan for "/plugins/": OpenCode does ship a real plugin file, and + * agent detection finds a command in /usr/local/bin or /opt/homebrew/bin + * whatever HOME and PATH say, so a blanket search passes or fails according + * to what the developer happens to have installed. */ + char qoder_plugins[700]; + char pi_plugins[700]; + snprintf(qoder_plugins, sizeof(qoder_plugins), "%s/plugins/", qoder_dir); + snprintf(pi_plugins, sizeof(pi_plugins), "%s/plugins/", pi_dir); bool plan_ok = - plan && !strstr(plan, "/plugins/") && !strstr(plan, "plugin_files") && + plan && !strstr(plan, qoder_plugins) && !strstr(plan, pi_plugins) && + !strstr(plan, "plugin_files") && test_json_string_array_contains(plan_root, "config_files_planned", qoder_settings) && test_json_string_array_contains(plan_root, "config_files_planned", amazon_config) && test_json_string_array_contains(plan_root, "config_files_planned", roo_config) && diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5f3c9dd80..7ea702c3a 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 @@ -166,6 +167,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" @@ -1225,19 +1277,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); @@ -2220,9 +2271,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")); @@ -3294,8 +3346,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); @@ -3753,11 +3805,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")); @@ -3788,12 +3840,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")); @@ -3833,11 +3885,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")); @@ -4027,6 +4079,41 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee) return NULL; } +/* #1892: the Swift grammar declares no "arguments" field, so the generic field + * lookup read nothing and every Swift call lost its arguments. Without the URL + * the service-pattern table cannot raise an HTTP_CALLS edge or a Route node, + * even though Alamofire/Moya/URLSession are already listed in it. */ +TEST(swift_call_string_arg_issue1892) { + CBMFileResult *r = + extract("func listWidgets() { AF.request(\"https://example.com/api/v1/widgets\") }\n", + CBM_LANG_SWIFT, "t", "Client.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "AF.request"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "https://example.com/api/v1/widgets"); + cbm_free_result(r); + PASS(); +} + +/* Swift labels its arguments, and each one sits in a value_argument node that + * leads with the label. Reading the first child alone would return `with` + * rather than the path. */ +TEST(swift_labeled_call_string_arg_issue1892) { + CBMFileResult *r = + extract("func fetch() { URLSession.shared.dataTask(with: \"/api/v1/widgets/1\") }\n", + CBM_LANG_SWIFT, "t", "Fetch.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "URLSession.shared.dataTask"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/api/v1/widgets/1"); + cbm_free_result(r); + PASS(); +} + /* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped * literal, consumed as client(buildPath(id)). The builder's URL is recorded in * the per-file constant map and resolved at the call site, for both return @@ -4954,6 +5041,190 @@ TEST(extract_python_member_call_flags_is_method) { PASS(); } +/* Python bare-call local-binding flag (the bare-call counterpart of the + * receiver flag above). Pins BOTH directions: a callee shadowed by a parameter + * of an enclosing scope IS flagged so the resolver can suppress a weak + * short-name match, while an unshadowed callee — a genuine module-level + * function, an imported name, or a nested `def` — is NOT, so its true edge + * survives. Every parameter binding form the grammar produces is covered, since + * a form the extractor silently missed would leave that shape unguarded. */ +TEST(extract_python_bare_call_flags_locally_bound_callee) { + CBMFileResult *r = extract("from pkg import helper\n" + "\n" + "def outer(run, *rest, timeout=5, label: str = 'x', **opts):\n" + " def inner():\n" + " return run()\n" + " rest()\n" + " timeout()\n" + " label()\n" + " opts()\n" + " module_level()\n" + " helper()\n" + " return inner()\n" + "\n" + "def typed(cb: Callable):\n" + " return cb()\n" + "\n" + "apply_it = lambda fn: fn()\n", + CBM_LANG_PYTHON, "t", "x.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* callee name -> (expected flag, seen count) */ + struct { + const char *callee; + bool expect_bound; + int seen; + } cases[] = { + {"run", true, 0}, /* closure over an ENCLOSING function's parameter */ + {"rest", true, 0}, /* *args -> list_splat_pattern */ + {"timeout", true, 0}, /* default_parameter */ + {"label", true, 0}, /* typed_default_parameter (keyword-only) */ + {"opts", true, 0}, /* **kwargs -> dictionary_splat_pattern */ + {"cb", true, 0}, /* typed_parameter, no default */ + {"fn", true, 0}, /* lambda parameter */ + {"module_level", false, 0}, /* unbound: the true cross-file edge */ + {"helper", false, 0}, /* imported name, not a parameter */ + {"inner", false, 0}, /* nested def: a real target, keep the edge */ + }; + const int case_count = (int)(sizeof(cases) / sizeof(cases[0])); + + for (int i = 0; i < r->calls.count; i++) { + const char *cn = r->calls.items[i].callee_name; + if (!cn) { + continue; + } + for (int c = 0; c < case_count; c++) { + if (strcmp(cn, cases[c].callee) != 0) { + continue; + } + cases[c].seen++; + if (r->calls.items[i].callee_is_locally_bound != cases[c].expect_bound) { + printf(" bare-call flag mismatch for %s(): got %d, expected %d\n", cases[c].callee, + r->calls.items[i].callee_is_locally_bound ? 1 : 0, + cases[c].expect_bound ? 1 : 0); + } + ASSERT_EQ(r->calls.items[i].callee_is_locally_bound, cases[c].expect_bound); + } + } + /* Each shape must appear exactly once, so a missed extraction cannot let the + * loop above pass vacuously. */ + for (int c = 0; c < case_count; c++) { + if (cases[c].seen != 1) { + printf(" bare call %s() extracted %d times, expected 1\n", cases[c].callee, + cases[c].seen); + } + ASSERT_EQ(cases[c].seen, 1); + } + cbm_free_result(r); + PASS(); +} + +/* The bare-call flag is DEPTH-INDEPENDENT, and the binding is UNWOUND when its + * scope closes. + * + * 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", + 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 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); + 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_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(); +} + /* 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 @@ -5362,8 +5633,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 @@ -6623,6 +6894,8 @@ SUITE(extraction) { RUN_TEST(extract_perl_method_call_flags_is_method); RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method); RUN_TEST(extract_python_member_call_flags_is_method); + RUN_TEST(extract_python_bare_call_flags_locally_bound_callee); + RUN_TEST(extract_python_bare_call_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); @@ -6665,6 +6938,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); @@ -6782,6 +7057,8 @@ SUITE(extraction) { RUN_TEST(swift_constructor_call); RUN_TEST(swift_chained_call); RUN_TEST(swift_force_unwrap_scanner_shift); + RUN_TEST(swift_call_string_arg_issue1892); + RUN_TEST(swift_labeled_call_string_arg_issue1892); RUN_TEST(objc_interface); RUN_TEST(objc_implementation); RUN_TEST(dart_top_level_function); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index eacf92634..3ac680197 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -33,6 +33,9 @@ #include #include #include +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif #ifndef _WIN32 #include #endif @@ -1573,6 +1576,23 @@ TEST(ui_server_ui_config_detects_zh_accept_language) { PASS(); } +TEST(ui_server_ui_config_includes_serving_version_issue1820) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + + char resp[4096]; + int n = th_http(cbm_http_server_port(ts.srv), "GET /api/ui-config HTTP/1.1\r\n\r\n", resp, + sizeof(resp)); + ASSERT_TRUE(n > 0); + ASSERT_EQ(th_status(resp), 200); + char expected_version[128]; + snprintf(expected_version, sizeof(expected_version), "\"version\":\"%s\"", CBM_VERSION); + ASSERT_NOT_NULL(strstr(resp, expected_version)); + + th_server_stop(&ts); + PASS(); +} + TEST(ui_server_ui_config_prefers_config_lang) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX"); @@ -2397,6 +2417,7 @@ SUITE(httpd) { RUN_TEST(ui_server_delete_project_invalid_name_keeps_watch); RUN_TEST(ui_server_delete_project_unlink_failure_keeps_watch); RUN_TEST(ui_server_ui_config_detects_zh_accept_language); + RUN_TEST(ui_server_ui_config_includes_serving_version_issue1820); RUN_TEST(ui_server_ui_config_prefers_config_lang); RUN_TEST(ui_server_slow_request_hits_deadline); RUN_TEST(ui_server_access_log_redacts_query); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 8db63b1ed..dc7716b14 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -934,23 +934,27 @@ TEST(mcp_tools_have_behavior_annotations) { bool open_world; } expected[] = { {"index_repository", false, false, true, false}, - /* These query tools can reach resolve_store(), whose corrupt-store - * recovery quarantines/removes database files. Keep the annotations - * conservative until query resolution is strictly non-mutating. */ - {"search_graph", false, true, true, false}, - {"query_graph", false, true, true, false}, - {"trace_path", false, true, true, false}, - {"get_code_snippet", false, true, true, false}, + /* The ten query tools resolve their store through the strictly + * non-mutating query-only path: a corrupt database is reported and + * left in place, never quarantined or rebuilt. Quarantine/rebuild is + * a write-side job (index_repository, manage_adr writes), so the + * read-only annotations are honest and plan-mode clients can expose + * these tools. get_file_outline arrived after this split and keeps + * its upstream conservative annotation. */ + {"search_graph", true, false, true, false}, + {"query_graph", true, false, true, false}, + {"trace_path", true, false, true, false}, + {"get_code_snippet", true, false, true, false}, {"get_file_outline", false, true, true, false}, - {"get_graph_schema", false, true, true, false}, + {"get_graph_schema", true, false, true, false}, {"compare_graphs", true, false, true, false}, - {"get_architecture", false, true, true, false}, - {"search_code", false, true, true, false}, + {"get_architecture", true, false, true, false}, + {"search_code", true, false, true, false}, {"list_projects", true, false, true, false}, {"delete_project", false, true, true, false}, - {"index_status", false, true, true, false}, - {"check_index_coverage", false, true, true, false}, - {"detect_changes", false, true, true, false}, + {"index_status", true, false, true, false}, + {"check_index_coverage", true, false, true, false}, + {"detect_changes", true, false, true, false}, {"manage_adr", false, true, false, false}, {"ingest_traces", false, false, false, false}, }; @@ -7553,9 +7557,11 @@ TEST(tool_cross_repo_honors_source_name_override) { } /* Corrupt-store quarantine renames/unlinks the project DB and sidecars, so it - * is a mutation even when resolve_store() was reached by a query tool. Generic - * queries use a blocking guard for that recovery, while manage_adr reads must - * use one nonblocking acquisition and never nest a blocking lease. */ + * is a mutation — and exactly the mutation a query-only resolve must never + * perform. A query tool that meets a corrupt store reports the corruption and + * leaves every file in place, taking no mutation lease; the quarantine path + * belongs to write-side opens, where manage_adr reads must use one nonblocking + * acquisition and never nest a blocking lease. */ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-guard-XXXXXX", cbm_tmpdir()); @@ -7581,6 +7587,7 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { char *resp = cbm_mcp_handle_tool(query_srv, "search_graph", "{\"project\":\"guard-corrupt-project\",\"name_pattern\":\".*\"}"); + bool query_reports_corruption = resp && strstr(resp, "corrupt") != NULL; free(resp); cbm_mcp_server_free(query_srv); char query_backup_path[CBM_SZ_1K]; @@ -7588,6 +7595,11 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { mcp_find_corrupt_backups(cache, project, query_backup_path, sizeof(query_backup_path)); bool query_quarantined = !cbm_file_exists(db_path) && query_backup_count == 1 && query_backup_path[0] != '\0'; + /* Snapshot the on-disk state HERE: the manage_adr branch below replants + * and then quarantines this same path, and the suite's cleanup unlinks + * it before the assertions run — a later cbm_file_exists() would observe + * that teardown, not the query-only resolve this check pins. */ + bool query_db_left_in_place = cbm_file_exists(db_path); /* Replant the same deterministic corruption to exercise manage_adr's * already-held lease independently from the query server above. */ @@ -7617,13 +7629,14 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { free(saved_cache_copy); cbm_rmdir(cache); - ASSERT_TRUE(query_quarantined); - ASSERT_EQ(query_probe.begin_count, 1); - ASSERT_EQ(query_probe.end_count, 1); - ASSERT_STR_EQ(query_probe.begin_projects[0], project); - ASSERT_STR_EQ(query_probe.end_projects[0], project); - ASSERT_TRUE(query_probe.db_exists_at_begin); - ASSERT_FALSE(query_probe.db_exists_at_end); + /* A query-only resolve never repairs: no lease is taken, the corrupt DB + * and its sidecars stay in place, and the reply names the corruption + * instead of "project not found". */ + ASSERT_FALSE(query_quarantined); + ASSERT_TRUE(query_db_left_in_place); + ASSERT_TRUE(query_reports_corruption); + ASSERT_EQ(query_probe.begin_count, 0); + ASSERT_EQ(query_probe.end_count, 0); ASSERT_TRUE(adr_quarantined); ASSERT_EQ(adr_probe.begin_count, 0); ASSERT_EQ(adr_probe.try_begin_count, 1); @@ -7636,8 +7649,9 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { } /* Integrity is checked before the lease is requested, but quarantine itself - * must fail closed when that lease is denied. In particular, a rejected query - * may not remove either a recoverable DB generation or its committed WAL. */ + * must fail closed when that lease is denied. A query-only resolve never + * reaches that point at all: it asks for no lease, touches no file, and + * leaves both a recoverable DB generation and its committed WAL untouched. */ TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-denied-XXXXXX", cbm_tmpdir()); @@ -7695,9 +7709,9 @@ TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { free(saved_cache_copy); cbm_rmdir(cache); - ASSERT_EQ(begin_count, 1); + ASSERT_EQ(begin_count, 0); ASSERT_EQ(end_count, 0); - ASSERT_TRUE(guarded_project); + ASSERT_FALSE(guarded_project); ASSERT_TRUE(db_unchanged); ASSERT_TRUE(wal_unchanged); ASSERT_EQ(backup_count, 0); @@ -7801,10 +7815,11 @@ TEST(tool_manage_adr_corrupt_store_missing_try_guard_reports_configuration) { PASS(); } -/* Another session may publish a good generation while this query waits for - * the mutation lease. Cleanup must re-open and re-check the path after lease - * acquisition; quarantining based on the stale pre-wait handle loses the new - * generation and returns a false "not indexed" result. */ +/* A write-side open must trust only the generation that is current after its + * mutation lease is held: another session may have published a good + * generation while this request waited for the lease. Quarantining based on + * a stale pre-wait handle loses the new generation and returns a false + * "not indexed" result. */ TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-recheck-XXXXXX", cbm_tmpdir()); @@ -7832,7 +7847,8 @@ TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { cbm_mcp_server_set_project_mutation_guard(srv, mcp_replacing_mutation_guard_begin, mcp_replacing_mutation_guard_end, &replacement); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", "{\"project\":\"guard-corrupt-recheck\",\"name_pattern\":\".*\"}"); + srv, "manage_adr", + "{\"project\":\"guard-corrupt-recheck\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending replacement.\"}"); bool response_used_replacement = resp && !response_contains_json_fragment(resp, "\"isError\":true"); free(resp); @@ -7908,7 +7924,8 @@ TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", "{\"project\":\"guard-corrupt-unique\",\"name_pattern\":\".*\"}"); + srv, "manage_adr", + "{\"project\":\"guard-corrupt-unique\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending quarantine.\"}"); free(resp); cbm_mcp_server_free(srv); @@ -7981,9 +7998,9 @@ TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) { mcp_mutation_guard_probe_end, &guard); mcp_quarantine_hook_probe_t hook = {.deny_step = "before_snapshot_publish"}; cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); - char *resp = - cbm_mcp_handle_tool(srv, "search_graph", - "{\"project\":\"guard-corrupt-publish-fail\",\"name_pattern\":\".*\"}"); + char *resp = cbm_mcp_handle_tool( + srv, "manage_adr", + "{\"project\":\"guard-corrupt-publish-fail\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending publish.\"}"); bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); @@ -8056,8 +8073,8 @@ TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { mcp_quarantine_hook_probe_t hook = {.deny_step = "after_snapshot_publish"}; cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", - "{\"project\":\"guard-corrupt-after-publish\",\"name_pattern\":\".*\"}"); + srv, "manage_adr", + "{\"project\":\"guard-corrupt-after-publish\",\"mode\":\"update\",\"content\":\"# ADR\\n\\nPending publish.\"}"); bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); @@ -11071,12 +11088,14 @@ TEST(tool_resolve_store_by_internal_name_issue704) { free(q_alpha); /* ── D: the 0-byte ghost is NOT resolvable ─────────────────────── */ + /* A query-only resolve reports the corrupt generation and leaves it in + * place — the reply names the corruption instead of "not found". */ char *q_ghost = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," "\"params\":{\"name\":\"search_graph\",\"arguments\":{" "\"project\":\"ghost704\",\"name_pattern\":\".*\",\"limit\":5}}}"); ASSERT_NOT_NULL(q_ghost); - ASSERT_NOT_NULL(strstr(q_ghost, "not found")); + ASSERT_NOT_NULL(strstr(q_ghost, "corrupt")); free(q_ghost); /* ── E: addressing the drifted db by its FILENAME stays not-found ── */ diff --git a/tests/test_mem.c b/tests/test_mem.c index 1ded83dad..7db92b735 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 @@ -1287,6 +1289,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); @@ -1350,4 +1399,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); } diff --git a/tests/test_parse_coverage.c b/tests/test_parse_coverage.c index ba741531d..e044418fb 100644 --- a/tests/test_parse_coverage.c +++ b/tests/test_parse_coverage.c @@ -470,6 +470,77 @@ TEST(perl_malformed_source_remains_partial_issue1838) { PASS(); } +/* ── #1746: trailing blanks before EOF are still just an absent newline ─────── + * + * #1610 suppressed the zero-width MISSING terminator but tested for it with + * `end == source_len`. Trailing blanks are extras owned by no node, so + * `ENTRYPOINT ["a"] ` + EOF parks it at [29,29) while source_len is 30. + * + * The reporter's byte-exact controls pin the trigger to the PAIR: `] ` + EOF + * flags, `] ` + newline is clean, `]` + EOF is clean. */ +TEST(dockerfile_trailing_blank_at_eof_not_flagged_issue1746) { + const char *cases[] = { + "FROM scratch\nENTRYPOINT [\"a\"] ", /* space + EOF — the report */ + "FROM scratch\nENTRYPOINT [\"a\"]\t", /* tab + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"]\v", /* vertical tab + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"]\f", /* form feed + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"] \t ", /* run of blanks + EOF */ + "FROM scratch\nENTRYPOINT [\"a\"] \r", /* CRLF file truncated to CR */ + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + CBMFileResult *r = do_extract(cases[i], CBM_LANG_DOCKERFILE, "Dockerfile"); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + if (flagged) { + fprintf(stderr, " case %zu flagged: ranges=%s\n", i, + r->error_ranges ? r->error_ranges : "(none)"); + } + cbm_free_result(r); + if (flagged) { + FAIL("trailing blanks must not turn an absent final newline into parse_partial"); + } + } + PASS(); +} + +/* GUARD: widening the tail must not swallow a genuine mid-file failure just + * because the file happens to end in blanks. */ +TEST(real_error_before_eof_still_flagged_with_trailing_blank_issue1746) { + size_t n = strlen(C_IFDEF_SPLIT); + char *buf = (char *)malloc(n + 1); + ASSERT_NOT_NULL(buf); + memcpy(buf, C_IFDEF_SPLIT, n + 1); + buf[n - 1] = ' '; /* final newline becomes a blank */ + + CBMFileResult *r = do_extract(buf, CBM_LANG_C, "split.c"); + free(buf); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + bool has_ranges = r->error_ranges != NULL; + cbm_free_result(r); + if (!flagged) { + FAIL("a real mid-file parse failure must still be reported when the file ends in blanks"); + } + if (!has_ranges) { + FAIL("a reported failure must still name its line range"); + } + PASS(); +} + +/* GUARD: a WIDTH-BEARING loss at EOF stays honest with a blank tail too — the + * Makefile recipe really is dropped, and only zero-width nodes are excused. */ +TEST(width_bearing_error_at_eof_still_flagged_with_trailing_blank_issue1746) { + const char *src = "all:\n\techo hi "; + CBMFileResult *r = do_extract(src, CBM_LANG_MAKEFILE, "Makefile"); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + cbm_free_result(r); + if (!flagged) { + FAIL("a width-bearing parse failure at EOF must still be reported when the file ends in blanks"); + } + PASS(); +} + SUITE(parse_coverage) { RUN_TEST(c_ifdef_split_brace_sets_parse_incomplete); RUN_TEST(c_ifdef_split_brace_neighbors_still_extracted); @@ -491,4 +562,7 @@ SUITE(parse_coverage) { RUN_TEST(width_bearing_error_at_eof_still_flagged_issue1610); RUN_TEST(perl_format_followed_by_named_sub_is_complete_issue1838); RUN_TEST(perl_malformed_source_remains_partial_issue1838); + RUN_TEST(dockerfile_trailing_blank_at_eof_not_flagged_issue1746); + RUN_TEST(real_error_before_eof_still_flagged_with_trailing_blank_issue1746); + RUN_TEST(width_bearing_error_at_eof_still_flagged_with_trailing_blank_issue1746); } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2af0366f8..c5d3a9539 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5102,6 +5102,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 @@ -5199,6 +5253,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 @@ -5480,6 +5606,61 @@ TEST(pipeline_native_fetch_classified_as_http_calls) { PASS(); } +/* #1892: Swift produced no Route node and no HTTP_CALLS edge, because the + * Swift grammar has no "arguments" field and the generic lookup therefore read + * no call arguments at all. Alamofire/URLSession were already in the service + * pattern table; the URL simply never reached it. This is the Swift twin of + * the TypeScript fetch case above. */ +TEST(pipeline_swift_http_call_makes_route_issue1892) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* URLSession, not Alamofire's `AF` shorthand: the service pattern table + * matches the library name in the callee text, and "AF.request" contains + * no such name. */ + write_temp_file(tmp, "Sources/Client.swift", + "import Foundation\n" + "final class Client {\n" + " func listWidgets() {\n" + " URLSession.shared.dataTask(with: \"/api/v1/widgets\")\n" + " }\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/swifthttp.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1); + + /* The edge carries the URL, so pass_route_nodes can mint the Route the + * cross-repo matcher joins a server route against. */ + cbm_node_t *routes = NULL; + int route_count = 0; + cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count); + int widget_routes = 0; + for (int i = 0; i < route_count; i++) { + if (routes[i].qualified_name && strstr(routes[i].qualified_name, "/api/v1/widgets")) { + widget_routes++; + } + } + cbm_store_free_nodes(routes, route_count); + ASSERT_GTE(widget_routes, 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Native `fetch()` (#856), parallel path (>= 50 files -> pass_parallel.c's * resolve_file_calls). Mirrors pipeline_native_fetch_classified_as_http_calls * but forces the parallel resolver, since the empty-resolution fallback is a @@ -13141,10 +13322,13 @@ SUITE(pipeline) { RUN_TEST(pipeline_go_bare_ref_never_binds_field_parallel); 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_arg_url_rejects_non_http_slash_arguments); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); + RUN_TEST(pipeline_swift_http_call_makes_route_issue1892); RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); /* Git history pass */ diff --git a/tests/test_registry.c b/tests/test_registry.c index 0f3f3861a..247ca61ad 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -902,6 +902,77 @@ TEST(dynamic_suppress_keeps_high_confidence_and_non_methods) { PASS(); } +TEST(local_binding_suppress_drops_weak_shadowed_bare_calls) { + /* A bare `run()` whose callee is a parameter of an enclosing scope cannot be + * the module-level `run`, so a weak short-name match fabricates the edge. */ + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "suffix_match")); + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "unique_name")); + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "field_type_hint")); + ASSERT_TRUE(cbm_suppress_weak_local_binding_call(true, true, "fuzzy")); + PASS(); +} + +TEST(local_binding_suppress_keeps_unshadowed_and_strong_strategies) { + /* THE RECALL PIN. A bare call to a genuine module-level function is NOT + * locally bound, so it is never suppressed — whatever the callee is spelled. + * This is the assertion a name-keyed guard (get/run/execute) would fail: it + * would drop these purely because of how the callee reads. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "suffix_match")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "unique_name")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "field_type_hint")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, false, "fuzzy")); + /* Every receiver-/import-aware strategy is kept even when shadowed. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "same_module")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "import_map")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "import_map_suffix")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "qualified_suffix")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "callee_suffix")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "service_pattern")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "lsp_cross")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "lsp_py_method")); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "lsp_direct")); + /* Languages outside the caller's gate are never affected. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(false, true, "suffix_match")); + /* No match (NULL/empty strategy) → nothing to suppress. */ + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, NULL)); + ASSERT_FALSE(cbm_suppress_weak_local_binding_call(true, true, "")); + PASS(); +} + +TEST(weak_call_guards_share_one_drop_list) { + /* The member guard and the local-binding guard must agree on what "weak" + * means. They share a single static predicate for exactly this reason; if + * someone re-inlines one of the lists and edits only that copy, the two + * guards start disagreeing and this test catches it at the contract level + * rather than in a corpus months later. */ + static const char *const strategies[] = {"suffix_match", + "unique_name", + "field_type_hint", + "fuzzy", + "same_module", + "import_map", + "import_map_suffix", + "qualified_suffix", + "callee_suffix", + "service_pattern", + "lsp_cross", + "lsp_ts_method", + "lsp_py_method", + "lsp_direct", + "", + NULL}; + for (int i = 0; strategies[i] != NULL; i++) { + bool member = cbm_suppress_weak_member_match(true, true, strategies[i]); + bool binding = cbm_suppress_weak_local_binding_call(true, true, strategies[i]); + if (member != binding) { + printf(" drop-list divergence on strategy \"%s\": member=%d binding=%d\n", + strategies[i], member, binding); + } + ASSERT_EQ(member, binding); + } + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* Method call THROUGH an imported symbol that is itself an indexed node @@ -998,4 +1069,7 @@ SUITE(registry) { RUN_TEST(go_bare_ref_never_binds_field); 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); }