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..44286e988 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" @@ -1186,11 +1190,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 +1321,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 +1450,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 +1687,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 4f06bebb7..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 { @@ -292,6 +296,10 @@ typedef struct { uint32_t site_start_byte; // exact reference-token span; end > start when present uint32_t site_end_byte; // exclusive byte offset in the source file CBMSourceOrigin source_origin; // raw source or C-family preprocessed buffer + bool is_member_access; // token is the member half of a selector/attribute + // (Go x.f — field_identifier). The extractor strips + // the receiver, so this is the only surviving record + // of selector shape (#1962). Default false. } CBMUsage; typedef struct { @@ -303,6 +311,9 @@ typedef struct { const char *var_name; // variable name const char *enclosing_func_qn; // QN of enclosing function bool is_write; // true = write, false = read + bool is_member_access; // var_name is the field half of a selector/member LHS + // (`t.err = x` → "err"); the receiver is stripped here, + // so this is the only record of selector shape (#1962) } CBMReadWrite; typedef struct { @@ -582,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 6bb4a32bc..6f5ad8fad 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -21,8 +21,6 @@ enum { LEAN_MAX_PARENT_DEPTH = 20 }; /* Max positional args to scan for URL/string. */ enum { MAX_POSITIONAL_SCAN = 3 }; -/* Max positional args to scan for handler ref. */ -enum { MAX_HANDLER_SCAN = 4 }; /* Max string arg length before rejection. */ enum { MAX_STRING_ARG_LEN = CBM_SZ_512 }; /* Min printable ASCII (space). */ @@ -2349,8 +2347,17 @@ static const char *normalize_string_handler(CBMArena *a, const char *raw) { } static const char *extract_handler_arg(CBMExtractCtx *ctx, TSNode args) { + /* The LAST eligible argument wins, and every argument is examined. + * Express, Fastify, gin and Laravel all put middleware between the route + * path and the handler, so the first function-shaped argument is usually a + * middleware. Taking the first one pointed the HANDLES edge at the + * middleware; stopping the scan early missed the handler outright when the + * middleware was written inline, because an arrow function matches none of + * the kinds below. Nothing that follows a handler matches them either — an + * options argument is an object node — so the last match is the handler. */ + const char *handler = NULL; uint32_t nc = ts_node_named_child_count(args); - for (uint32_t ai = HANDLER_START_IDX; ai < nc && ai < MAX_HANDLER_SCAN; ai++) { + for (uint32_t ai = HANDLER_START_IDX; ai < nc; ai++) { TSNode arg2 = ts_node_named_child(args, ai); /* PHP wraps each argument in an `argument` node — unwrap to the value. */ if (strcmp(ts_node_type(arg2), "argument") == 0 && ts_node_named_child_count(arg2) > 0) { @@ -2362,17 +2369,18 @@ static const char *extract_handler_arg(CBMExtractCtx *ctx, TSNode args) { if (strcmp(ak2, "identifier") == 0 || strcmp(ak2, "member_expression") == 0 || strcmp(ak2, "selector_expression") == 0 || strcmp(ak2, "attribute") == 0 || strcmp(ak2, "field_expression") == 0 || strcmp(ak2, "name") == 0) { - return cbm_node_text(ctx->arena, arg2, ctx->source); + handler = cbm_node_text(ctx->arena, arg2, ctx->source); + continue; } if (is_string_like(ak2)) { const char *h = normalize_string_handler(ctx->arena, cbm_node_text(ctx->arena, arg2, ctx->source)); if (h && h[0]) { - return h; + handler = h; } } } - return NULL; + return handler; } // Extract JSX component refs (uppercase tags) as CALLS edges. @@ -2965,6 +2973,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; } @@ -3589,11 +3632,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 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 2e359af67..6e6cd0c60 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -1450,8 +1450,12 @@ static const char *find_route_path_literal(CBMArena *a, TSNode node, const char // Extract route path from decorator arguments (first string that starts with /). static const char *extract_route_path_from_args(CBMArena *a, TSNode args, const char *source) { + /* Every argument is checked. Java and Kotlin put no order on annotation + * attributes, so `path` can sit anywhere in the list. Stopping early left + * a real route unread and formed no Route node. Each argument's own + * subtree walk stays bounded by find_route_path_literal below. */ uint32_t nc = ts_node_named_child_count(args); - for (uint32_t ai = 0; ai < nc && ai < DECORATOR_SCAN_LIMIT; ai++) { + for (uint32_t ai = 0; ai < nc; ai++) { TSNode arg = ts_node_named_child(args, ai); /* Spring/Kotlin frequently uses named or array-valued annotation args: * @RequestMapping(value = ["/internal/v1"]) @@ -5312,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); @@ -5341,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); } } } @@ -6281,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); @@ -6306,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); } } } @@ -7140,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); @@ -7158,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); } } } @@ -7274,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 @@ -7290,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; } @@ -7876,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; } @@ -7902,6 +7906,89 @@ void cbm_extract_definitions_without_module(CBMExtractCtx *ctx) { extract_variables(ctx, ctx->root, spec); } +/* True when rel_path names a Blazor component file. */ +static bool cbm_path_is_razor(const char *rel_path) { + if (!rel_path) { + return false; + } + size_t len = strlen(rel_path); + static const char suffix[] = ".razor"; + size_t slen = sizeof(suffix) - 1U; + return len > slen && strcmp(rel_path + (len - slen), suffix) == 0; +} + +/* Match `@page "/route"` on ONE line; returns the route text or NULL. + * + * Deliberately strict: the directive must be the first token on the line and be + * followed by whitespace and a double-quoted path beginning with '/', so + * neither `@pageSize` nor a `@page` mentioned in markup prose can match. + * + * A blank line is rejected up front rather than falling through the length + * check, which keeps every later comparison reachable on some path — the + * all-whitespace case would otherwise leave `line_end - p` provably zero. */ +static const char *razor_page_route_on_line(CBMArena *a, const char *line, const char *line_end) { + static const char directive[] = "@page"; + const size_t dlen = sizeof(directive) - 1U; + + const char *p = line; + while (p < line_end && (*p == ' ' || *p == '\t')) { + p++; + } + if (p == line_end) { + return NULL; /* blank line — nothing can follow */ + } + if ((size_t)(line_end - p) <= dlen || strncmp(p, directive, dlen) != 0) { + return NULL; + } + p += dlen; + if (*p != ' ' && *p != '\t') { + return NULL; /* `@pageSize` and friends */ + } + while (p < line_end && (*p == ' ' || *p == '\t')) { + p++; + } + if (p == line_end || *p != '"') { + return NULL; + } + p++; + const char *route = p; + while (p < line_end && *p != '"') { + p++; + } + if (p == line_end || p == route || *route != '/') { + return NULL; /* unterminated, empty, or not a rooted path */ + } + return cbm_arena_strndup(a, route, (size_t)(p - route)); +} + +/* Blazor route directive: `@page "/counter"` lives in MARKUP above the `@code` + * block. Tree-sitter's C# grammar recovers `@code` but never parses the + * directive, so there is no AST node to read it from — this scans the raw + * source instead. That is why routes need no Razor grammar. + * + * A component may declare several routes; the first is taken, because + * CBMDefinition carries a single route_path. */ +static const char *cbm_razor_page_route(CBMArena *a, const char *source, int source_len) { + if (!source || source_len <= 0) { + return NULL; + } + const char *end = source + source_len; + const char *line = source; + + while (line < end) { + const char *nl = memchr(line, '\n', (size_t)(end - line)); + const char *route = razor_page_route_on_line(a, line, nl ? nl : end); + if (route) { + return route; + } + if (!nl) { + break; + } + line = nl + 1; + } + return NULL; +} + void cbm_extract_definitions(CBMExtractCtx *ctx) { const CBMLangSpec *spec = cbm_lang_spec(ctx->language); if (!spec) { @@ -7923,6 +8010,17 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) { mod.is_test = ctx->result->is_test_file; // #519: index what a config file declares itself to be, not only its path. mod.docstring = extract_config_module_description(ctx); + /* A routable Blazor component carries its route on the module def: the + * component's class is implicit in a .razor file, so there is no class node + * to hang it on, and the module QN already is the component's identity. + * insert_def_into_gbuf creates Route+HANDLES for any def with route_path. */ + if (ctx->language == CBM_LANG_CSHARP && cbm_path_is_razor(ctx->rel_path)) { + const char *route = cbm_razor_page_route(a, ctx->source, ctx->source_len); + if (route) { + mod.route_path = route; + mod.route_method = "GET"; /* a routable page is reached by navigation */ + } + } cbm_defs_push(&ctx->result->defs, a, mod); cbm_extract_definitions_without_module(ctx); 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 9379f7171..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)); } } } @@ -146,7 +146,10 @@ static void walk_throws(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec // - field/member/selector access (`self.total = ...`, `obj.Field = ...` → // write the trailing field name `total`/`Field`) // Returns NULL if no simple write target can be determined. -static char *resolve_lhs_write_name(CBMExtractCtx *ctx, TSNode left) { +// Sets *is_member_out when the written name is the field half of a +// member/selector LHS — the receiver is stripped from the returned name, so +// this out-param is the only surviving record of the selector shape (#1962). +static char *resolve_lhs_write_name(CBMExtractCtx *ctx, TSNode left, bool *is_member_out) { // Unwrap a single-element expression_list (Go). if (strcmp(ts_node_type(left), "expression_list") == 0) { if (ts_node_named_child_count(left) != 1) { @@ -184,6 +187,9 @@ static char *resolve_lhs_write_name(CBMExtractCtx *ctx, TSNode left) { fld = ts_node_child_by_field_name(left, TS_FIELD("name")); } if (!ts_node_is_null(fld)) { + if (is_member_out) { + *is_member_out = true; + } return cbm_node_text(ctx->arena, fld, ctx->source); } return NULL; @@ -251,20 +257,22 @@ static void try_emit_assignment_write(CBMExtractCtx *ctx, TSNode node, const cha if (ts_node_is_null(left)) { return; } - char *name = resolve_lhs_write_name(ctx, left); + bool is_member = false; + char *name = resolve_lhs_write_name(ctx, left, &is_member); if (name && name[0] && !cbm_is_keyword(name, ctx->language)) { CBMReadWrite rw; rw.var_name = name; rw.is_write = true; rw.enclosing_func_qn = func_qn; + rw.is_member_access = is_member; cbm_rw_push(&ctx->result->rw, ctx->arena, rw); } } static void walk_readwrites(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNodeStack stack; - ts_nstack_init(&stack, ctx->arena, CBM_SZ_512); - ts_nstack_push(&stack, ctx->arena, root); + ts_nstack_init(&stack, ctx, CBM_SZ_512); + ts_nstack_push(&stack, root); while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); if (cbm_kind_in_set(node, spec->assignment_node_types)) { @@ -272,7 +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)); } } } @@ -329,12 +337,14 @@ void handle_readwrites(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, TSNode left = resolve_write_lhs_node(node); if (!ts_node_is_null(left)) { - char *name = resolve_lhs_write_name(ctx, left); + bool is_member = false; + char *name = resolve_lhs_write_name(ctx, left, &is_member); if (name && name[0] && !cbm_is_keyword(name, ctx->language)) { CBMReadWrite rw; rw.var_name = name; rw.is_write = true; rw.enclosing_func_qn = state->enclosing_func_qn; + rw.is_member_access = is_member; cbm_rw_push(&ctx->result->rw, ctx->arena, rw); } } 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/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index 4e3218fd1..fd83864ac 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -2673,6 +2673,10 @@ void handle_usages(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Wal usage.ref_name = name; usage.enclosing_func_qn = state->enclosing_func_qn; usage.lexical_scope_id = usage_lexical_scope_id_for_node(ctx, state, node); + /* The member half of a selector is its own reference node + * (field_identifier); record that shape — the name alone cannot carry + * it, and the Go Field guard keys on it (#1962). */ + usage.is_member_access = strcmp(ts_node_type(node), "field_identifier") == 0; stamp_usage_site(ctx, &usage, node, name, state); cbm_usages_push(&ctx->result->usages, ctx->arena, usage); } diff --git a/internal/cbm/vendored/grammars/MANIFEST.md b/internal/cbm/vendored/grammars/MANIFEST.md index 7dd9688b4..4025b5f50 100644 --- a/internal/cbm/vendored/grammars/MANIFEST.md +++ b/internal/cbm/vendored/grammars/MANIFEST.md @@ -77,7 +77,9 @@ Guarded by the `contract_all_grammars_in_graph` graph-breadth test in The grammars below carry a small local patch to their vendored sources, on top of the pinned upstream commit recorded in the vendoring table below. -Re-vendoring from upstream must re-apply these. +Re-vendoring from upstream must re-apply these, unless the reason column +names an upstream commit that already carries the change — then drop the +row instead. | grammar | location | patch | reason | |---|---|---|---| @@ -85,6 +87,7 @@ Re-vendoring from upstream must re-apply these. | rescript | `rescript/scanner.c`, deserialize | guard `memcpy(state, buffer, n_bytes)` with `if (n_bytes > 0)` | UBSan: zero-length `memcpy` with a NULL `buffer` / `n_bytes == 0` on empty-state deserialize (formal UB, harmless). The sibling serialize copies a fixed `sizeof(ScannerState)` (always > 0, non-NULL src) and needs no guard. | | purescript | `purescript/scanner.c`, serialize | guard `memcpy(buffer, indents->data, to_copy)` with `if (to_copy > 0)` | UBSan: zero-length `memcpy` with a NULL/0-size source when the indent vector is empty (formal UB, harmless) | | plsql | `plsql/parser.c`, include | `#include ` → `#include "tree_sitter/parser.h"` | The older ABI-14 generator emits angle brackets; every other vendored grammar uses the quoted form, which resolves the per-grammar `tree_sitter/` header from the including file's directory | +| swift | `swift/scanner.c`, `OP_SYMBOL_SUPPRESSOR` + `eat_operators` | `1UL <<` / `1 <<` → `1ULL <<` | UBSan: `1 << suppressor` shifts an `int` by up to `TOKEN_COUNT` bits, undefined once the index reaches 31, while the mask it feeds is `uint64_t`. `1UL << FAKE_TRY_BANG` is the same defect on Windows, where `unsigned long` is 32 bits and `FAKE_TRY_BANG` is 32; the CLANGARM64 leg runs UBSan in trap mode, so there it is an illegal instruction rather than a log line. Upstream already carries both changes: `fb63a7004f07` (2026-04-06, upstream #558) for `eat_operators`, `6ab8d1d74ebd` (2026-08-10) for the `OP_SYMBOL_SUPPRESSOR` entry. Our pin `8abb3e8b3325` (2026-03-20) predates both, so this is a backport rather than a local invention — a re-vendor past 2026-08-10 should delete this row, not re-apply it | ## Vendored from verified upstream diff --git a/internal/cbm/vendored/grammars/swift/scanner.c b/internal/cbm/vendored/grammars/swift/scanner.c index 2615afe43..357350282 100644 --- a/internal/cbm/vendored/grammars/swift/scanner.c +++ b/internal/cbm/vendored/grammars/swift/scanner.c @@ -128,7 +128,7 @@ const uint64_t OP_SYMBOL_SUPPRESSOR[OPERATOR_COUNT] = { 0, // EQ_EQ, 0, // PLUS_THEN_WS, 0, // MINUS_THEN_WS, - 1UL << FAKE_TRY_BANG, // BANG, + 1ULL << FAKE_TRY_BANG, // BANG, 0, // THROWS_KEYWORD, 0, // RETHROWS_KEYWORD, 0, // DEFAULT_KEYWORD, @@ -511,7 +511,7 @@ static bool eat_operators( uint64_t suppressing_symbols = OP_SYMBOL_SUPPRESSOR[full_match]; if (suppressing_symbols) { for (uint64_t suppressor = 0; suppressor < TOKEN_COUNT; suppressor++) { - if (!(suppressing_symbols & 1 << suppressor)) { + if (!(suppressing_symbols & 1ULL << suppressor)) { continue; } diff --git a/scripts/vendored-checksums.txt b/scripts/vendored-checksums.txt index 4b15db072..c4b809dc6 100644 --- a/scripts/vendored-checksums.txt +++ b/scripts/vendored-checksums.txt @@ -5,7 +5,7 @@ c5cfb43042b6b72045f4ba997834d0a7786d2793d91680868b5815b39f14fc78 internal/cbm/v b29c1c9fb7cc82f58c84b376df1297d6e2737a1d655fd356db0859e3c29c2fea internal/cbm/vendored/common/tree_sitter/alloc.h 31e60a1bff6f715afacce03b5b70efe42b58371b4f9595dd4af52a577ff9608c internal/cbm/vendored/common/tree_sitter/array.h 180b893c8734778fd32f372dfbc27bd6ad1cd2221f26150b31256ff6716320d2 internal/cbm/vendored/common/tree_sitter/parser.h -e1233d81f21d868b8f2d9e8fca10e15f2ec3ae4db4a7ba0301161cc011deef65 internal/cbm/vendored/grammars/MANIFEST.md +00a84162eb34fb58bcdd38c08922d4fea4c87b7c2219dd64b773dd15ba18de71 internal/cbm/vendored/grammars/MANIFEST.md ad8425038de519f8c4e3e9339feebf99dfad8a6002dcf79227d91402780a32cc internal/cbm/vendored/grammars/ada/LICENSE 02805ec13939b749c891567be36bf024b09034e04c80683a1ae667272458d549 internal/cbm/vendored/grammars/ada/parser.c 115a75d000bef9c70c4de7dfbb7f2a80b90cbb14ba265056dc7abb1ef3b9b6db internal/cbm/vendored/grammars/ada/tree_sitter/alloc.h @@ -791,7 +791,7 @@ c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 internal/cbm/v 2db740ea1be6b71014d2be1385491f01bf2d15b607e61c5b30b4128535efe68a internal/cbm/vendored/grammars/sway/tree_sitter/parser.h 3533cec129bb4bba015c0d61d86dd7c3b7e82110e4d2ff7837a01eff5bad5ccc internal/cbm/vendored/grammars/swift/LICENSE 93e6b39fc5b16ef9d5869862ec8d56380a838355c362481942942d59ca666de2 internal/cbm/vendored/grammars/swift/parser.c -b835c1ded068e902944fe82c3770b9dcf85f67dcaab8fbf772f1606007da7373 internal/cbm/vendored/grammars/swift/scanner.c +8a14f26c9011a02298a076674461beb48625a4ca7cea4a0f63947930d93631fd internal/cbm/vendored/grammars/swift/scanner.c b29c1c9fb7cc82f58c84b376df1297d6e2737a1d655fd356db0859e3c29c2fea internal/cbm/vendored/grammars/swift/tree_sitter/alloc.h 5bdf6ed1a78e3409fd443e085ca967a64c188a5d082aaf7f819bccd53a471c94 internal/cbm/vendored/grammars/swift/tree_sitter/array.h a1f6ef161fbaf48a0e10fca90ef5290a062462b307b3898aa562993853b9f80a internal/cbm/vendored/grammars/swift/tree_sitter/parser.h diff --git a/src/cli/cli.c b/src/cli/cli.c index 56af212d1..1e1c71f15 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7464,6 +7464,12 @@ static const char *detect_arch(void) { /* ── Agent config install/refresh (shared by install + update) ── */ +/* Set by `install --clients=...` after validation and consumed by both the + * legacy detector and the registry-backed client path (#1558, #1798). */ +static const char *g_client_selection = NULL; +static bool cli_clients_apply_selection(const char *spec, cbm_detected_agents_t *detected); +static bool cli_clients_selects_registry_client(const char *spec, cbm_agent_client_id_t client_id); +static void cli_clients_print_list(FILE *out); static void print_detected_registry_agents(const char *home, bool *any); /* Print detected agent names on a single line. */ @@ -8285,7 +8291,10 @@ static void print_detected_registry_agents(const char *home, bool *any) { cbm_init_agent_registry_context(home, ®istry); for (size_t index = 0U; index < cbm_agent_client_count(); index++) { const cbm_agent_client_profile_t *profile = cbm_agent_client_at(index); - if (profile && cbm_agent_client_detect(profile->id, ®istry.options)) { + if (profile && + (!g_client_selection || + cli_clients_selects_registry_client(g_client_selection, profile->id)) && + cbm_agent_client_detect(profile->id, ®istry.options)) { printf(" %s", profile->display_name); *any = true; } @@ -8671,7 +8680,10 @@ static void install_agent_client_registry(const char *home, const char *binary_p cbm_init_agent_registry_context(home, ®istry); for (size_t index = 0U; index < cbm_agent_client_count(); index++) { const cbm_agent_client_profile_t *profile = cbm_agent_client_at(index); - if (!profile || !cbm_agent_client_detect(profile->id, ®istry.options)) { + if (!profile || + (g_client_selection && + !cli_clients_selects_registry_client(g_client_selection, profile->id)) || + !cbm_agent_client_detect(profile->id, ®istry.options)) { continue; } if (!g_install_plan) { @@ -9545,13 +9557,6 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents } } -/* #1558: set by `install --clients=...` after validation, consumed at the one - * place detection happens. Validation runs in cbm_cmd_install so an unknown - * token fails before anything is written, not midway through configuring. */ -static const char *g_client_selection = NULL; -static bool cli_clients_apply_selection(const char *spec, cbm_detected_agents_t *detected); -static void cli_clients_print_list(FILE *out); - int cbm_install_agent_configs(const char *home, const char *binary_path, bool force, bool dry_run) { g_agent_install_errors = 0; cbm_detected_agents_t agents = cbm_detect_agents(home); @@ -9679,9 +9684,9 @@ int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_r * Codex had to revert the OpenCode and Cursor integrations by hand, and the * next install silently recreated them. `--clients=claude,codex` restricts it. * - * The table is the flag's vocabulary AND its documentation: 26 clients ship - * here, with tokens nobody would guess (factory-droid, mistral-vibe, - * copilot-cli), so `--clients=help` prints every token. A selector whose + * The tables are the flag's vocabulary AND its documentation, with tokens + * nobody would guess (factory-droid, mistral-vibe, copilot-cli), so + * `--clients=help` prints every token. A selector whose * accepted values can only be learned from the source is not a usable * selector, and an unrecognised token fails loudly with the list rather than * silently configuring nothing. */ @@ -9730,10 +9735,40 @@ static void cli_clients_print_list(FILE *out) { for (size_t i = 0; i < CLI_CLIENT_COUNT; i++) { (void)fprintf(out, " %-16s %s\n", CLI_CLIENTS[i].token, CLI_CLIENTS[i].display); } + for (size_t i = 0; i < cbm_agent_client_count(); i++) { + const cbm_agent_client_profile_t *profile = cbm_agent_client_at(i); + if (profile) { + (void)fprintf(out, " %-16s %s\n", profile->stable_id, profile->display_name); + } + } (void)fprintf(out, "\nExample: --clients=claude,codex\n" "Omit --clients to configure every detected client.\n"); } +static bool cli_clients_spec_contains(const char *spec, const char *wanted_token) { + char buf[CLI_BUF_1K]; + snprintf(buf, sizeof(buf), "%s", spec ? spec : ""); + char *save = NULL; + for (char *tok = strtok_r(buf, ",", &save); tok; tok = strtok_r(NULL, ",", &save)) { + while (*tok == ' ') { + tok++; + } + size_t len = strlen(tok); + while (len > 0 && tok[len - 1] == ' ') { + tok[--len] = '\0'; + } + if (strcmp(tok, wanted_token) == 0) { + return true; + } + } + return false; +} + +static bool cli_clients_selects_registry_client(const char *spec, cbm_agent_client_id_t client_id) { + const cbm_agent_client_profile_t *profile = cbm_agent_client_by_id(client_id); + return profile && cli_clients_spec_contains(spec, profile->stable_id); +} + /* Restrict `detected` to the comma-separated token list. Returns false (after * printing the vocabulary) when a token is unknown, so a typo can never be * mistaken for "that client was not installed". */ @@ -9762,6 +9797,9 @@ static bool cli_clients_apply_selection(const char *spec, cbm_detected_agents_t break; } } + if (!matched) { + matched = cbm_agent_client_by_stable_id(tok) != NULL; + } if (!matched) { (void)fprintf(stderr, "error: unknown client: %s\n\n", tok); cli_clients_print_list(stderr); @@ -9787,6 +9825,9 @@ size_t cbm_cli_clients_count_for_testing(void) { const char *cbm_cli_clients_token_for_testing(size_t index) { return index < CLI_CLIENT_COUNT ? CLI_CLIENTS[index].token : NULL; } +void cbm_cli_set_client_selection_for_testing(const char *spec) { + g_client_selection = spec; +} #endif /* ── Subcommand: install ──────────────────────────────────────── */ @@ -9884,6 +9925,27 @@ static const char *cli_external_manager_name(const char *self_path) { if (strstr(self_path, "/.cargo/bin/")) { return "cargo"; } +#ifdef __FreeBSD__ + /* FreeBSD ports/pkg install under ${LOCALBASE} (default /usr/local). pkg owns + * that file, so install must not drop a second copy in ~/.local/bin or edit a + * shell rc, and update must defer to pkg(8). Anchor at the start so a manual + * `install --dir=/opt/...` elsewhere is still treated as ours; --force-binary + * is the escape hatch for anyone who really does self-manage that prefix. + * + * LOCALBASE is configurable, so the port passes its real PREFIX via + * -DCBM_PKG_PREFIX; when it is absent we fall back to the documented default. */ +#ifdef CBM_PKG_PREFIX + if (strncmp(self_path, CBM_PKG_PREFIX "/bin/", sizeof(CBM_PKG_PREFIX "/bin/") - 1) == 0 || + strncmp(self_path, CBM_PKG_PREFIX "/sbin/", sizeof(CBM_PKG_PREFIX "/sbin/") - 1) == 0) { + return "FreeBSD pkg"; + } +#else + if (strncmp(self_path, "/usr/local/bin/", 15) == 0 || + strncmp(self_path, "/usr/local/sbin/", 16) == 0) { + return "FreeBSD pkg"; + } +#endif +#endif return NULL; } @@ -10204,7 +10266,7 @@ int cbm_cmd_install(int argc, char **argv) { } } else if (strcmp(argv[i], "--clients") == 0) { /* Bare `--clients` (and `--clients=help`/`list`) print the - * vocabulary. 26 clients ship with tokens nobody would guess. */ + * vocabulary, including tokens users would not readily guess. */ cli_clients_print_list(stdout); return 0; } else if (strcmp(argv[i], "--skip-binary") == 0) { @@ -12467,6 +12529,8 @@ int cbm_cmd_update(int argc, char **argv) { (void)fprintf(stderr, " update it with: mise upgrade codebase-memory-mcp\n"); } else if (manager && strcmp(manager, "Homebrew") == 0) { (void)fprintf(stderr, " update it with: brew upgrade codebase-memory-mcp\n"); + } else if (manager && strcmp(manager, "FreeBSD pkg") == 0) { + (void)fprintf(stderr, " update it with: pkg upgrade codebase-memory-mcp\n"); } else { (void)fprintf(stderr, " update it through whichever tool installed it.\n"); } diff --git a/src/cli/cli.h b/src/cli/cli.h index da2c4512a..c94a8044f 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -209,6 +209,7 @@ int cbm_install_agent_configs(const char *home, const char *binary_path, bool fo bool cbm_cli_clients_apply_selection_for_testing(const char *spec, cbm_detected_agents_t *detected); size_t cbm_cli_clients_count_for_testing(void); const char *cbm_cli_clients_token_for_testing(size_t index); +void cbm_cli_set_client_selection_for_testing(const char *spec); #endif #ifdef CBM_CLI_ENABLE_TEST_API diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 36c07ea4c..17840b04c 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1992,6 +1992,13 @@ static int parse_post_where(parser_t *p, cbm_query_t *q, // NOLINT(misc-no-recur q->union_next = sub.query; sub.query = NULL; cbm_parse_free(&sub); + /* The branch after UNION was parsed by a SEPARATE parser over a slice + * of these tokens, so this parser's cursor never moved past the UNION + * keyword. That sub-parse now refuses to succeed with anything left + * over, so everything from here to the end is accounted for. Move the + * cursor to the end to say so, or cbm_parse's end-of-input check reads + * a fully parsed UNION query as unfinished. */ + p->pos = p->count; } return 0; } @@ -2055,6 +2062,36 @@ int cbm_parse(const cbm_token_t *tokens, int token_count, // NOLINT(misc-no-recu return CBM_NOT_FOUND; } + /* Every token must be consumed. The grammar accepts at most one WITH and + * treats RETURN as optional, so a query with a second WITH stage — or any + * typo after RETURN — used to stop parsing there and succeed anyway. The + * dropped tail took the filter and the RETURN with it, and the engine + * answered from the fragment it had parsed, using its default projection. + * That reported success and returned wrong rows, which is worse than a + * refusal because nothing tells the caller to look. Refuse instead. */ + if (peek(&p)->type != TOK_EOF) { + /* Only mention the one-WITH limit when a standalone WITH really is + * sitting in the part we could not read. Saying it every time points + * a reader at WITH when the problem is a typo. A WITH straight after + * STARTS is the STARTS WITH operator rather than a clause, the same + * guard parse_post_where uses. */ + const char *hint = ""; + for (int i = p.pos; i < p.count; i++) { + if (p.tokens[i].type == TOK_WITH && + (i == 0 || p.tokens[i - SKIP_ONE].type != TOK_STARTS)) { + hint = " Note that only one WITH clause is supported."; + break; + } + } + snprintf(p.error, sizeof(p.error), + "unexpected input at pos %d ('%s') — the query was not fully " + "parsed.%s", + peek(&p)->pos, peek(&p)->text ? peek(&p)->text : "", hint); + out->error = heap_strdup(p.error); + cbm_query_free(q); + return CBM_NOT_FOUND; + } + out->query = q; return 0; } diff --git a/src/daemon/application.c b/src/daemon/application.c index 12543808e..69ff52ec7 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -374,6 +374,11 @@ static bool application_regular_db_exists(const char *project) { return stat(path, &status) == 0 && S_ISREG(status.st_mode); } +static bool application_canonical_directory_exists(const char *path) { + cbm_path_info_t info = {0}; + return cbm_path_info_utf8(path, &info) == 0 && info.is_directory; +} + static cbm_daemon_application_watch_t *application_find_watch_locked( cbm_daemon_application_t *application, const char *project) { for (cbm_daemon_application_watch_t *watch = application->watches; watch; watch = watch->next) { @@ -2354,9 +2359,7 @@ static cbm_daemon_runtime_application_status_t application_set_context( if (canonical && allowed_present) { canonical = cbm_canonical_path(allowed, canonical_allowed, sizeof(canonical_allowed)); } - struct stat root_status; - canonical = - canonical && stat(canonical_root, &root_status) == 0 && S_ISDIR(root_status.st_mode); + canonical = canonical && application_canonical_directory_exists(canonical_root); bool set = canonical && cbm_mcp_server_set_session_context(session->mcp, canonical_root, allowed_present ? canonical_allowed : NULL); @@ -3369,9 +3372,8 @@ static int application_background_index(cbm_daemon_application_t *application, return -1; } char canonical_root[APPLICATION_PATH_CAP]; - struct stat root_status; if (!cbm_canonical_path(root_path, canonical_root, sizeof(canonical_root)) || - stat(canonical_root, &root_status) != 0 || !S_ISDIR(root_status.st_mode)) { + !application_canonical_directory_exists(canonical_root)) { return -1; } yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); diff --git a/src/discover/language.c b/src/discover/language.c index 666db536f..a9b49b863 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -50,6 +50,11 @@ static const ext_entry_t EXT_TABLE[] = { /* C# */ {".cs", CBM_LANG_CSHARP}, + /* Blazor components. The C# grammar recovers the @code block; the + * surrounding markup parses as ERROR regions and is reported via + * parse_partial, which is why this is a best-effort mapping rather + * than a dedicated grammar. */ + {".razor", CBM_LANG_CSHARP}, /* Clojure */ {".clj", CBM_LANG_CLOJURE}, diff --git a/src/foundation/compat.c b/src/foundation/compat.c index f0c4d3571..74c25bd8e 100644 --- a/src/foundation/compat.c +++ b/src/foundation/compat.c @@ -17,6 +17,20 @@ #include #endif +int cbm_nanosleep_full(const struct timespec *req) { +#ifdef _WIN32 + return cbm_nanosleep(req, NULL); +#else + struct timespec remaining = *req; + while (nanosleep(&remaining, &remaining) != 0) { + if (errno != EINTR) { + return -1; + } + } + return 0; +#endif +} + /* ── strndup (Windows lacks it) ───────────────────────────────── */ #ifdef _WIN32 diff --git a/src/foundation/compat.h b/src/foundation/compat.h index 055a45687..175d775e4 100644 --- a/src/foundation/compat.h +++ b/src/foundation/compat.h @@ -96,6 +96,9 @@ static inline int cbm_nanosleep(const struct timespec *req, struct timespec *rem #define cbm_nanosleep nanosleep #endif +/* Sleeps for the full requested duration even when POSIX signals interrupt it. */ +int cbm_nanosleep_full(const struct timespec *req); + /* ── gmtime_r (Windows lacks it) ─────────────────────────────── */ #ifdef _WIN32 static inline struct tm *cbm_gmtime_r(const time_t *timep, struct tm *result) { diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index 9174391f4..196165cea 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -970,7 +970,7 @@ static void cbm_spawn_backoff(int attempt) { int shift = attempt < CBM_SPAWN_BACKOFF_MAX_SHIFT ? attempt : CBM_SPAWN_BACKOFF_MAX_SHIFT; long ms = (long)CBM_SPAWN_BACKOFF_BASE_MS << shift; struct timespec delay = {ms / 1000L, (ms % 1000L) * 1000L * 1000L}; - (void)cbm_nanosleep(&delay, NULL); + (void)cbm_nanosleep_full(&delay); } /* fork() fails with EAGAIN under the same pressure posix_spawn does, and the diff --git a/src/graph_buffer/graph_buffer.c b/src/graph_buffer/graph_buffer.c index b173267ff..939cc8cdb 100644 --- a/src/graph_buffer/graph_buffer.c +++ b/src/graph_buffer/graph_buffer.c @@ -1045,7 +1045,18 @@ static double edge_props_confidence(const char *props_json) { if (!p) { return CBM_EDGE_CONF_ABSENT; } - return strtod(p + sizeof(conf_key) - SKIP_ONE, NULL); + /* strtod answers 0.0 for text it cannot read, and 0.0 is a real + * confidence that beats CBM_EDGE_CONF_ABSENT. So a blob carrying + * "confidence":null used to outrank a clean blob that carries no + * confidence at all, and displace it. Ask strtod where it stopped: an + * end pointer that never moved means it read nothing. */ + const char *value = p + sizeof(conf_key) - SKIP_ONE; + char *end = NULL; + double conf = strtod(value, &end); + if (end == value) { + return CBM_EDGE_CONF_ABSENT; + } + return conf; } /* Decide whether an incoming property blob replaces the stored one on a diff --git a/src/main.c b/src/main.c index 1e06d86ff..67eced2f2 100644 --- a/src/main.c +++ b/src/main.c @@ -902,6 +902,8 @@ static void print_help(void) { printf(" Run one tool locally, then exit\n"); printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] " "[--dir=] [--skip-config]\n"); + printf(" [--clients=] Run " + "'install --clients' to list tokens\n"); printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run]\n"); printf(" codebase-memory-mcp update [-y|-n]\n"); printf(" codebase-memory-mcp config \n"); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7d81b7448..af2092a82 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6498,7 +6498,16 @@ static bool bfs_edge_evidence_for_hop(cbm_traverse_result_t *tr, int64_t hop_nod if (conf) { const char *colon = strchr(conf, ':'); if (colon) { - *confidence_out = strtod(colon + 1, NULL); + /* strtod answers 0.0 for text it cannot read, and the caller + * publishes any value >= 0 as a recorded confidence. So a + * malformed value used to print as 0.00 — the one number the + * surrounding code works to keep meaningful. Keep the -1 when + * the end pointer never moved: nothing was read. */ + char *end = NULL; + double parsed = strtod(colon + 1, &end); + if (end != colon + 1) { + *confidence_out = parsed; + } } } return true; @@ -9747,6 +9756,36 @@ bool cbm_search_code_file_pattern_can_prefilter(const char *file_pattern) { return true; } +bool cbm_search_code_windows_path_matches_prefilter(const char *path, const char *file_pattern) { + if (!path || !cbm_search_code_file_pattern_can_prefilter(file_pattern)) { + return false; + } + + const char *suffix = file_pattern + 1; + size_t path_len = strlen(path); + size_t suffix_len = strlen(suffix); + if (path_len < suffix_len) { + return false; + } + + const unsigned char *candidate = (const unsigned char *)path + path_len - suffix_len; + const unsigned char *expected = (const unsigned char *)suffix; + for (size_t i = 0; i < suffix_len; i++) { + unsigned char left = candidate[i]; + unsigned char right = expected[i]; + if (left >= 'A' && left <= 'Z') { + left = (unsigned char)(left - 'A' + 'a'); + } + if (right >= 'A' && right <= 'Z') { + right = (unsigned char)(right - 'A' + 'a'); + } + if (left != right) { + return false; + } + } + return true; +} + /* Build the grep/search command string based on scoped vs recursive mode. * On Windows, uses PowerShell Select-String with tab-delimited output. * On POSIX, uses grep with colon-delimited output. */ @@ -9767,30 +9806,16 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo const char *sm = use_regex ? "" : " -SimpleMatch"; if (scoped) { if (file_pattern) { - if (cbm_search_code_file_pattern_can_prefilter(file_pattern)) { - snprintf( - cmd, cmd_sz, - "powershell -Command \"" CBM_PS_UTF8_PRELUDE - "$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; " - "Get-Content -Encoding UTF8 -LiteralPath '%s'" - " | Where-Object { $_ -like '%s' }" - " | ForEach-Object { Select-String -LiteralPath $_ -Pattern $pat%s " - "-ErrorAction SilentlyContinue }" - " | Where-Object { $_.Path -like '*%s' }" - " | ForEach-Object { $_.Path + [char]9 + $_.LineNumber + [char]9 + $_.Line }\"", - tmpfile, filelist, file_pattern, sm, file_pattern); - } else { - snprintf( - cmd, cmd_sz, - "powershell -Command \"" CBM_PS_UTF8_PRELUDE - "$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; " - "Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String " - "-LiteralPath $_ -Pattern $pat%s " - "-ErrorAction SilentlyContinue }" - " | Where-Object { $_.Path -like '*%s' }" - " | ForEach-Object { $_.Path + [char]9 + $_.LineNumber + [char]9 + $_.Line }\"", - tmpfile, filelist, sm, file_pattern); - } + snprintf( + cmd, cmd_sz, + "powershell -Command \"" CBM_PS_UTF8_PRELUDE + "$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; " + "Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String " + "-LiteralPath $_ -Pattern $pat%s " + "-ErrorAction SilentlyContinue }" + " | Where-Object { $_.Path -like '*%s' }" + " | ForEach-Object { $_.Path + [char]9 + $_.LineNumber + [char]9 + $_.Line }\"", + tmpfile, filelist, sm, file_pattern); } else { snprintf( cmd, cmd_sz, @@ -10433,8 +10458,8 @@ static void classify_all_grep_hits(grep_match_t *gm, int gm_count, cbm_store_t * * created inside the private scratch directory; this function never opens or * closes it, so the list is never reachable through a predictable pathname. */ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, const char *root_path, - FILE *fl, bool has_path_filter, cbm_regex_t *path_regex, - int *out_written) { + FILE *fl, const char *file_pattern, bool has_path_filter, + cbm_regex_t *path_regex, int *out_written) { *out_written = 0; cbm_store_t *pre_store = resolve_store(srv, project); if (!pre_store) { @@ -10471,6 +10496,12 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co continue; } } +#ifdef _WIN32 + if (cbm_search_code_file_pattern_can_prefilter(file_pattern) && + !cbm_search_code_windows_path_matches_prefilter(indexed_files[fi], file_pattern)) { + continue; + } +#endif size_t root_len = strlen(root_path); size_t file_len = strlen(indexed_files[fi]); if (root_len > SIZE_MAX - file_len - 2) { @@ -10954,8 +10985,9 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { uint64_t scope_t0 = metrics.include_phase_timings ? cbm_now_ms() : 0; if (!scan_cancellation_latched && !scan_deadline_latched) { - scoped = write_scoped_filelist(srv, project, root_path, scratch.filelist, has_path_filter, - has_path_filter ? &path_regex : NULL, &scoped_written); + scoped = write_scoped_filelist(srv, project, root_path, scratch.filelist, file_pattern, + has_path_filter, has_path_filter ? &path_regex : NULL, + &scoped_written); } /* Close before grep runs: this is what flushes the records the helper wrote * through the descriptor. Clearing the field hands ownership to diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 8355bb390..673fd0c5d 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -67,6 +67,7 @@ bool cbm_detect_node_in_hunks(const cbm_node_t *node, const cbm_changed_hunk_t * * PowerShell -like contract. Exposed for * direct boundary tests only. */ bool cbm_search_code_file_pattern_can_prefilter(const char *file_pattern); +bool cbm_search_code_windows_path_matches_prefilter(const char *path, const char *file_pattern); /* Internal command builder exposed so tests can pin the PowerShell pipeline * ordering without 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 1ce766927..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 @@ -2670,9 +2675,10 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } - /* #1942: a bare Go reference can never denote a struct field. */ - if (tgt && - cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, usage->ref_name, tgt->label)) { + /* #1942/#1962: a bare Go reference can never denote a struct + * field; the member half of a selector may. */ + if (tgt && cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, usage->is_member_access, + tgt->label)) { continue; } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || @@ -2760,8 +2766,9 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } - /* #1942: a bare Go reference can never denote a struct field. */ - if (cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, rw->var_name, tgt->label)) { + /* #1942/#1962: a bare Go reference can never denote a struct field; + * a selector-LHS write (`t.err = x`) may bind it. */ + if (cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, rw->is_member_access, tgt->label)) { continue; } const char *etype = rw->is_write ? "WRITES" : "READS"; diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 163e5a1fc..b1fac1670 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -480,10 +480,19 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun /* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges. */ static void ensure_decorator_routes(cbm_gbuf_t *gb) { - const char *labels[] = {"Function", "Method"}; + /* "Module" is here for Blazor: a .razor component's class is implicit, so + * its @page route is carried by the file's Module def. Extraction's own + * insert_def_into_gbuf is label-agnostic and creates the Route either way — + * this backstop is what runs on an INCREMENTAL re-index, so leaving Module + * out would make a component's Route appear on a full index and vanish the + * next time that one file changed. + * Bound comes from the array, not the unrelated RN_STRIP_PASSES it used to + * borrow, so adding a label cannot silently skip it. */ + const char *labels[] = {"Function", "Method", "Module"}; + const int label_count = (int)(sizeof(labels) / sizeof(labels[0])); int created = 0; - for (int li = 0; li < RN_STRIP_PASSES; li++) { + for (int li = 0; li < label_count; li++) { const cbm_gbuf_node_t **nodes = NULL; int count = 0; if (cbm_gbuf_find_by_label(gb, labels[li], &nodes, &count) != 0) { diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 690b45ad9..9738f7198 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -216,9 +216,10 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } - /* #1942: a bare Go reference can never denote a struct field. */ - if (tgt && - cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, usage->ref_name, tgt->label)) { + /* #1942/#1962: a bare Go reference can never denote a struct + * field; the member half of a selector may. */ + if (tgt && cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, usage->is_member_access, + tgt->label)) { continue; } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || @@ -309,8 +310,9 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } - /* #1942: a bare Go reference can never denote a struct field. */ - if (cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, rw->var_name, tgt->label)) { + /* #1942/#1962: a bare Go reference can never denote a struct field; + * a selector-LHS write (`t.err = x`) may bind it. */ + if (cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, rw->is_member_access, tgt->label)) { continue; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index d174bb6d6..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 @@ -302,7 +314,7 @@ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target * Field when the reference text carries no '.'. Go only: other OO languages * legitimately reference their own members bare inside method bodies. Pure; * unit-tested in test_registry.c. */ -bool cbm_go_suppress_bare_field_ref(bool is_go, const char *ref_name, const char *target_label); +bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const char *target_label); /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index eefdb7596..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; @@ -546,22 +583,21 @@ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target return true; } -bool cbm_go_suppress_bare_field_ref(bool is_go, const char *ref_name, const char *target_label) { - /* #1942: a bare (dot-less) Go reference can never denote a struct field — - * field access is always a selector expression (x.f), and selector - * references resolve through the LSP join, never through the bare-name - * registry fallback. Every Field-targeted reference edge in the field - * census carried dot-less text, so dropping the bind loses nothing real. - * Go-gated: a C#/Java/C++/Python method body legitimately references its - * own members bare (cp_reads_writes_cs_static_field pins that shape as - * required), so a global veto would break those languages. */ - if (!is_go || !ref_name || !ref_name[0] || !target_label) { +bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const char *target_label) { + /* #1942/#1962: a bare Go identifier can never denote a struct field — + * field access is always a selector expression (x.f). The extractor + * strips the receiver before the resolver runs (resolve_lhs_write_name + * records the trailing field name; is_reference_node records the inner + * field_identifier), so the reference TEXT is always dot-less and cannot + * carry the distinction — the recorded is_member_access shape can. Only a + * reference that was never the member half of a selector is refused a + * Field bind. Go-gated: a C#/Java/C++/Python method body legitimately + * references its own members bare (cp_reads_writes_cs_static_field pins + * that shape as required), so a global veto would break those languages. */ + if (!is_go || is_member_access || !target_label) { return false; } - if (strcmp(target_label, "Field") != 0) { - return false; - } - return strchr(ref_name, '.') == NULL; + return strcmp(target_label, "Field") == 0; } /* ── Lifecycle ──────────────────────────────────────────────────── */ diff --git a/src/ui/http_server.c b/src/ui/http_server.c index b187438a5..b806b10c6 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -63,9 +63,17 @@ #ifdef __APPLE__ #include #endif +#ifdef __FreeBSD__ +#include +#include +#endif /* ── Constants ────────────────────────────────────────────────── */ +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif + /* Max JSON-RPC request body size (1 MB) — transport enforces the same cap. */ #define MAX_BODY_SIZE CBM_HTTP_MAX_BODY @@ -143,8 +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 ─────────────────────────────────────────────── */ @@ -1003,6 +1013,15 @@ static bool resolve_self_executable(char *out, size_t outsz) { return copy_path(out, outsz, buf); } return false; +#elif defined(__FreeBSD__) + /* No /proc by default on FreeBSD; ask the kernel for our own path. */ + char buf[1024]; + int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PATHNAME, -1}; + size_t cb = sizeof(buf); + if (sysctl(mib, 4, buf, &cb, NULL, 0) == 0 && cb > 0) { + return copy_path(out, outsz, buf); + } + return false; #else char buf[1024]; ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1); diff --git a/tests/test_cli.c b/tests/test_cli.c index a72ed6474..b2b543d71 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -13481,8 +13481,16 @@ TEST(cli_external_manager_detection_needs_positive_evidence_issue1566) { ASSERT_NULL( cbm_cli_external_manager_name_for_testing("/Users/x/.local/bin/codebase-memory-mcp")); ASSERT_NULL(cbm_cli_external_manager_name_for_testing("/opt/cbm/codebase-memory-mcp")); - ASSERT_NULL(cbm_cli_external_manager_name_for_testing("/usr/local/bin/codebase-memory-mcp")); ASSERT_NULL(cbm_cli_external_manager_name_for_testing("build/c/test-runner")); +#ifdef __FreeBSD__ + /* On FreeBSD the port/pkg owns ${LOCALBASE}/bin, so a binary there IS + * externally managed; elsewhere the same path is unremarkable. */ + ASSERT_NOT_NULL( + cbm_cli_external_manager_name_for_testing("/usr/local/bin/codebase-memory-mcp")); + ASSERT_NULL(cbm_cli_external_manager_name_for_testing("/opt/local/bin/codebase-memory-mcp")); +#else + ASSERT_NULL(cbm_cli_external_manager_name_for_testing("/usr/local/bin/codebase-memory-mcp")); +#endif ASSERT_NULL(cbm_cli_external_manager_name_for_testing("")); ASSERT_NULL(cbm_cli_external_manager_name_for_testing(NULL)); PASS(); @@ -13492,7 +13500,7 @@ TEST(cli_external_manager_detection_needs_positive_evidence_issue1566) { * Claude and Codex had to revert OpenCode and Cursor by hand — and the next * install silently recreated them. The selector restricts it. * - * The vocabulary is the part that makes it usable: 26 clients ship, with tokens + * The vocabulary is the part that makes it usable: clients ship with tokens * nobody would guess (factory-droid, mistral-vibe, copilot-cli). A selector * whose accepted values can only be learned by reading our source is not a * usable selector, so this pins that every client is listed and that an unknown @@ -13521,6 +13529,12 @@ TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558) { cbm_detected_agents_t typo = all; ASSERT_FALSE(cbm_cli_clients_apply_selection_for_testing("claude,codx", &typo)); + /* Registry-backed clients share the public selector vocabulary. */ + cbm_detected_agents_t registry = all; + ASSERT_TRUE(cbm_cli_clients_apply_selection_for_testing("qoder", ®istry)); + ASSERT_FALSE(registry.claude_code); + ASSERT_FALSE(registry.cursor); + /* Every token in the table must resolve — a client added to detection but * forgotten here is invisible to the selector. */ for (size_t i = 0; i < cbm_cli_clients_count_for_testing(); i++) { @@ -13532,6 +13546,34 @@ TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558) { PASS(); } +TEST(cli_clients_selector_filters_registry_installs_issue1798) { + char *tmpdir = th_mktempdir("cbm_cli_clients_registry"); + ASSERT_NOT_NULL(tmpdir); + + char qoder_dir[512]; + char settings_path[512]; + ASSERT(snprintf(qoder_dir, sizeof(qoder_dir), "%s/.qoder", tmpdir) > 0); + ASSERT(snprintf(settings_path, sizeof(settings_path), "%s/settings.json", qoder_dir) > 0); + ASSERT_EQ(test_mkdirp(qoder_dir), 0); + + cbm_cli_set_client_selection_for_testing("cursor"); + int excluded_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + struct stat settings_state; + bool excluded = stat(settings_path, &settings_state) != 0; + + cbm_cli_set_client_selection_for_testing("qoder"); + int included_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool included = stat(settings_path, &settings_state) == 0; + cbm_cli_set_client_selection_for_testing(NULL); + + test_rmdir_r(tmpdir); + ASSERT_EQ(excluded_rc, 0); + ASSERT_TRUE(excluded); + ASSERT_EQ(included_rc, 0); + ASSERT_TRUE(included); + PASS(); +} + TEST(cli_update_accepts_retired_variant_flags_issue1544) { char *ui_argv[] = {"--dry-run", "--ui", "--yes"}; char *standard_argv[] = {"--dry-run", "--standard", "--yes"}; @@ -13679,6 +13721,7 @@ SUITE(cli) { RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); + RUN_TEST(cli_clients_selector_filters_registry_installs_issue1798); RUN_TEST(cli_update_accepts_retired_variant_flags_issue1544); RUN_TEST(cli_update_agent_configs_finish_before_guard_release); RUN_TEST(cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index); diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 7882be82f..b674da15d 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -209,6 +209,71 @@ TEST(cypher_parse_simple_node) { PASS(); } +/* Trailing input must be an error, never a silent drop. The parser used to + * stop at the first thing it did not understand and report success, so the + * engine answered from the fragment it had parsed. */ +TEST(cypher_parse_rejects_trailing_tokens) { + cbm_query_t *q = NULL; + char *err = NULL; + int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name AS n BANANA SPLIT 99", &q, &err); + ASSERT_NEQ(rc, 0); + ASSERT_NOT_NULL(err); + ASSERT_NULL(q); + + /* The message must name what actually stopped the parse, and must not + * mention WITH: this query has no WITH in it anywhere. */ + ASSERT(strstr(err, "BANANA") != NULL); + ASSERT(strstr(err, "WITH") == NULL); + + free(err); + PASS(); +} + +/* Only one WITH is supported. A second one used to take the rest of the + * query with it — the filter and the RETURN both vanished, and every row + * came back unfiltered under the default projection. */ +TEST(cypher_parse_rejects_second_with_clause) { + cbm_query_t *q = NULL; + char *err = NULL; + int rc = cbm_cypher_parse("MATCH (f:Function) " + "OPTIONAL MATCH (a)-[:CALLS]->(f) " + "WITH f, count(a) AS calls " + "OPTIONAL MATCH (b)-[:USAGE]->(f) " + "WITH f, calls, count(b) AS usages " + "WHERE calls = 0 AND usages = 0 " + "RETURN f.name AS n", + &q, &err); + ASSERT_NEQ(rc, 0); + ASSERT_NOT_NULL(err); + ASSERT_NULL(q); + + /* Here the note earns its place. The parse stops at OPTIONAL, and the + * reason is the second WITH further along, which the reader cannot see + * from the stopping point alone. */ + ASSERT(strstr(err, "only one WITH clause is supported") != NULL); + + free(err); + PASS(); +} + +/* The guard must not reject a query that is simply finished. One WITH, a + * WHERE after it and a RETURN is the shape the grammar does support. */ +TEST(cypher_parse_accepts_single_with_clause) { + cbm_query_t *q = NULL; + char *err = NULL; + int rc = cbm_cypher_parse("MATCH (f:Function) " + "WITH f, f.name AS n " + "WHERE n = 'buildTree' " + "RETURN n", + &q, &err); + ASSERT_EQ(rc, 0); + ASSERT_NULL(err); + ASSERT_NOT_NULL(q); + + cbm_query_free(q); + PASS(); +} + TEST(cypher_parse_relationship_outbound) { cbm_query_t *q = NULL; char *err = NULL; @@ -4102,6 +4167,9 @@ SUITE(cypher) { RUN_TEST(cypher_lex_full_query); /* Parser */ RUN_TEST(cypher_parse_simple_node); + RUN_TEST(cypher_parse_rejects_trailing_tokens); + RUN_TEST(cypher_parse_rejects_second_with_clause); + RUN_TEST(cypher_parse_accepts_single_with_clause); RUN_TEST(cypher_parse_relationship_outbound); RUN_TEST(cypher_parse_relationship_inbound); RUN_TEST(cypher_parse_relationship_any); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index e0ef6b459..fceeb7130 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -714,6 +714,45 @@ TEST(daemon_application_requires_immutable_explicit_context) { PASS(); } +TEST(daemon_application_accepts_utf8_session_context_root) { + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-context-caf\xC3\xA9-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 311); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool context_ok = root_ok && app_test_context_request(root, root, &context, &context_length); + cbm_daemon_runtime_application_status_t status = + context_ok ? app_test_request(&callbacks, session, context, context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + + free(context); + free(response); + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + if (root_ok) { + (void)cbm_rmdir(root); + } + + ASSERT_TRUE(root_ok); + ASSERT_NOT_NULL(application); + ASSERT_NOT_NULL(session); + ASSERT_TRUE(context_ok); + ASSERT_EQ(status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(response_length, 0); + ASSERT_TRUE(stopped); + PASS(); +} + TEST(daemon_application_mcp_notification_has_no_response) { cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); cbm_daemon_runtime_application_callbacks_t callbacks = @@ -5348,6 +5387,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_new_session_does_not_retain_initial_store); RUN_TEST(daemon_application_request_cancel_is_scoped_to_exact_token); RUN_TEST(daemon_application_requires_immutable_explicit_context); + RUN_TEST(daemon_application_accepts_utf8_session_context_root); RUN_TEST(daemon_application_ui_config_updates_are_masked_and_serialized); RUN_TEST(daemon_application_ui_config_rejects_noncanonical_frames); RUN_TEST(daemon_application_ui_readiness_proof_is_generation_bound_before_context); diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index f71091ae4..84d7713ee 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -416,6 +416,30 @@ TEST(handles_spring_java) { PASS(); } +/* Spring (Java) — the path attribute may sit anywhere in the annotation. + * Java puts no order on annotation attributes, so `path` after `name`, + * `produces` and `consumes` is ordinary source. The argument scan stopped + * after the third attribute, so the path was never read and no Route node + * formed. A HANDLES count alone cannot catch that, because the class-level + * @RequestMapping still produces one route on its own. */ +TEST(handles_spring_java_path_attribute_fourth) { + static const char *routes[] = {"/api/orders", NULL}; + static const EtFile f[] = { + {"OrderController.java", + "package com.example;\n\n" + "import org.springframework.web.bind.annotation.RequestMapping;\n" + "import org.springframework.web.bind.annotation.GetMapping;\n\n" + "@RequestMapping(\"/api\")\npublic class OrderController {\n" + " @GetMapping(name = \"listOrders\",\n" + " produces = \"application/json\",\n" + " consumes = \"application/json\",\n" + " path = \"/orders\")\n" + " public String listOrders() {\n" + " return \"orders\";\n }\n}\n"}}; + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + /* Spring (Kotlin) — same prefix contract, including Kotlin's named array form * for class-level RequestMapping values. */ TEST(handles_spring_kotlin) { @@ -1578,6 +1602,7 @@ SUITE(edge_types_probe) { RUN_TEST(handles_fastify_js); RUN_TEST(handles_gin_go); RUN_TEST(handles_spring_java); + RUN_TEST(handles_spring_java_path_attribute_fourth); RUN_TEST(handles_spring_kotlin); RUN_TEST(handles_jaxrs_java); RUN_TEST(handles_aspnet_csharp); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5b8d16f61..ea949d25f 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); @@ -1810,6 +1861,24 @@ TEST(swift_chained_call) { PASS(); } +/* A Swift force-unwrap is the one thing that reaches the scanner's suppressor + * path -- the rule that stops `try!` emitting its `!` as a token of its own. + * That path shifted an int by up to TOKEN_COUNT bits, which runs past the + * width of the type once the index reaches 31. + * + * This test cannot go red here. The normal test build prints the UBSan + * message and carries on, which is why the bug survived. The Windows + * CLANGARM64 leg runs UBSan in trap mode, where the same shift is an + * illegal-instruction crash, so parsing this file at all is the check. */ +TEST(swift_force_unwrap_scanner_shift) { + CBMFileResult *r = + extract("func load() { let u = cached! }\n", CBM_LANG_SWIFT, "t", "Load.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + cbm_free_result(r); + PASS(); +} + /* --- Objective-C --- */ TEST(objc_interface) { CBMFileResult *r = @@ -2202,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")); @@ -3276,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); @@ -3735,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")); @@ -3770,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")); @@ -3815,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")); @@ -3906,6 +3976,75 @@ TEST(extract_java_jaxrs_path_composition_issue1005) { PASS(); } +/* Return the file's Module definition (extraction pushes it first), or NULL. */ +static const CBMDefinition *find_module_def(CBMFileResult *r) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Module") == 0) { + return &r->defs.items[i]; + } + } + return NULL; +} + +/* Blazor: a routable component declares its route with a `@page` directive in + * MARKUP, above the `@code` block. The C# grammar recovers `@code` (that is why + * .razor already yields methods via extra_extensions) but never sees the + * directive, so a routable page contributes no Route node and + * get_architecture(routes) is empty for a whole Blazor app. + * + * The route hangs off the file's Module definition, not off a class: a .razor + * component's class is implicit — it is never written in the source — so there + * is no class node to carry it. The Module's qualified name already IS the + * component's identity (t.Pages.Counter), and insert_def_into_gbuf creates + * Route+HANDLES for any definition carrying route_path, whatever its label. */ +TEST(extract_blazor_page_directive_routes_component) { + CBMFileResult *r = extract("@page \"/counter\"\n" + "@inject NavigationManager Nav\n" + "\n" + "

Counter

\n" + "\n" + "\n" + "@code {\n" + " private int count;\n" + " private void Increment() { count++; }\n" + "}\n", + CBM_LANG_CSHARP, "t", "Pages/Counter.razor"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* The markup must not cost us the @code block we already extract today. */ + ASSERT_NOT_NULL(find_def_by_name(r, "Increment")); + const CBMDefinition *mod = find_module_def(r); + ASSERT_NOT_NULL(mod); + ASSERT_NOT_NULL(mod->route_path); + ASSERT_STR_EQ(mod->route_path, "/counter"); + /* A routable Blazor page is reached by navigation, i.e. GET. */ + ASSERT_NOT_NULL(mod->route_method); + ASSERT_STR_EQ(mod->route_method, "GET"); + cbm_free_result(r); + PASS(); +} + +/* The directive scan must not fire on every .razor file. A non-routable + * component (no @page) has to stay route-free, or every shared component in the + * tree becomes a bogus Route node. */ +TEST(extract_blazor_component_without_page_has_no_route) { + CBMFileResult *r = extract("@inject IJSRuntime JS\n" + "\n" + "
@Title
\n" + "\n" + "@code {\n" + " private void Refresh() { }\n" + "}\n", + CBM_LANG_CSHARP, "t", "Shared/Card.razor"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *mod = find_module_def(r); + ASSERT_NOT_NULL(mod); + ASSERT_NULL(mod->route_path); + cbm_free_result(r); + PASS(); +} + /* A comment between decorators must not drop the decorators above it. * Comments are NAMED tree-sitter nodes, so the prev-sibling walk used to stop * at one — a documented route (@Post + @HttpCode above an explanatory comment) @@ -3970,6 +4109,49 @@ TEST(extract_ts_url_builder_issue1009) { PASS(); } +/* A route registration names its middleware before its handler, and every + * framework here puts the handler last. The handler scan took the FIRST + * argument that looked like a function reference, so a named middleware won + * and the HANDLES edge pointed at the middleware instead of the handler. */ +TEST(extract_ts_route_handler_after_named_middleware) { + CBMFileResult *r = extract("function requireAuth(req: any, res: any, next: any) { next(); }\n" + "function rateLimit(req: any, res: any, next: any) { next(); }\n" + "function listUsers(req: any, res: any) { res.json([]); }\n" + "routerGet(\"/users\", requireAuth, rateLimit, listUsers);\n", + CBM_LANG_TYPESCRIPT, "t", "routes.ts"); + ASSERT_NOT_NULL(r); + const CBMCall *c = find_call_by_callee(r, "routerGet"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/users"); + ASSERT_NOT_NULL(c->second_arg_name); + ASSERT_STR_EQ(c->second_arg_name, "listUsers"); + cbm_free_result(r); + PASS(); +} + +/* The same route with its middleware written inline. An arrow function is not + * one of the kinds the handler scan accepts, so three of them pushed the real + * handler past the scan bound and no handler came back at all. */ +TEST(extract_ts_route_handler_after_inline_middleware) { + CBMFileResult *r = extract("function listOrders(req: any, res: any) { res.json([]); }\n" + "routerGet(\"/orders\",\n" + " (req: any, res: any, next: any) => { next(); },\n" + " (req: any, res: any, next: any) => { next(); },\n" + " (req: any, res: any, next: any) => { next(); },\n" + " listOrders);\n", + CBM_LANG_TYPESCRIPT, "t", "orders.ts"); + ASSERT_NOT_NULL(r); + const CBMCall *c = find_call_by_callee(r, "routerGet"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/orders"); + ASSERT_NOT_NULL(c->second_arg_name); + ASSERT_STR_EQ(c->second_arg_name, "listOrders"); + cbm_free_result(r); + PASS(); +} + /* Issue #1009 (composed builders): a builder whose template inlines an earlier * builder's call plus a query string: `return \`${basePath(id)}?${params}\``. * The known-substitution is inlined and the query string is truncated, so the @@ -4824,6 +5006,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 @@ -5232,8 +5598,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 @@ -6291,16 +6657,6 @@ TEST(iris_export_xml_multi_class) { * question asked in words could not reach either. Both now carry the prose in * `docstring`, which is what nodes_fts indexes into its `body` column. */ -/* First Module-labelled definition, or NULL. */ -static const CBMDefinition *find_module_def(CBMFileResult *r) { - for (int i = 0; i < r->defs.count; i++) { - if (strcmp(r->defs.items[i].label, "Module") == 0) { - return &r->defs.items[i]; - } - } - return NULL; -} - TEST(markdown_section_body_becomes_docstring_issue518) { CBMFileResult *r = extract("# Installation\n" "Run the bootstrap script to provision a workstation.\n" @@ -6503,6 +6859,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); @@ -6545,6 +6903,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); @@ -6661,6 +7021,7 @@ SUITE(extraction) { RUN_TEST(swift_method_call); RUN_TEST(swift_constructor_call); RUN_TEST(swift_chained_call); + RUN_TEST(swift_force_unwrap_scanner_shift); RUN_TEST(objc_interface); RUN_TEST(objc_implementation); RUN_TEST(dart_top_level_function); @@ -6808,10 +7169,14 @@ SUITE(extraction) { RUN_TEST(arkts_lazy_import); RUN_TEST(arkts_ts_compat); RUN_TEST(extract_java_jaxrs_path_composition_issue1005); + RUN_TEST(extract_blazor_page_directive_routes_component); + RUN_TEST(extract_blazor_component_without_page_has_no_route); RUN_TEST(extract_ts_template_string_url_issue1006); RUN_TEST(extract_go_binary_concat_url_issue1249); RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249); RUN_TEST(extract_ts_url_builder_issue1009); + RUN_TEST(extract_ts_route_handler_after_named_middleware); + RUN_TEST(extract_ts_route_handler_after_inline_middleware); RUN_TEST(extract_ts_url_builder_composed_issue1009); RUN_TEST(extract_c_url_builder_gated_issue1009); RUN_TEST(extract_ts_url_builder_mixed_returns_issue1009); diff --git a/tests/test_graph_buffer.c b/tests/test_graph_buffer.c index c4aa5c270..232286045 100644 --- a/tests/test_graph_buffer.c +++ b/tests/test_graph_buffer.c @@ -236,6 +236,34 @@ TEST(gbuf_edge_props_merge_prefers_higher_confidence) { PASS(); } +/* A confidence the code cannot read is not evidence of anything, so it must + * not outrank an edge that simply carries no confidence at all. + * + * edge_props_confidence answers -1 for "absent" so that any real confidence + * beats it. strtod answers 0.0 for text it cannot read, so an unreadable + * value used to come back as a real confidence of zero -- which beats -1 and + * displaced the stored blob. The function's own comment already promised + * that "absent/unparseable reads as -1"; only the absent half was true. */ +TEST(gbuf_edge_props_unreadable_confidence_does_not_displace_absent) { + const char *no_conf = "{\"callee\":\"f\",\"strategy\":\"lsp\"}"; + const char *bad_conf = "{\"callee\":\"f\",\"confidence\":null,\"strategy\":\"registry\"}"; + + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + cbm_gbuf_insert_edge(gb, a, b, "CALLS", no_conf); + cbm_gbuf_insert_edge(gb, a, b, "CALLS", bad_conf); /* unreadable, arrives last */ + + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_type(gb, "CALLS", &edges, &count); + ASSERT_EQ(count, 1); + ASSERT_TRUE(strstr(edges[0]->properties_json, "\"strategy\":\"lsp\"") != NULL); + + cbm_gbuf_free(gb); + PASS(); +} + /* An empty incoming blob must never displace real stored properties. */ TEST(gbuf_edge_props_merge_keeps_existing_on_empty) { const char *lsp = "{\"callee\":\"f\",\"confidence\":0.95,\"strategy\":\"lsp\"}"; @@ -1152,6 +1180,7 @@ SUITE(graph_buffer) { /* Edge property merge determinism */ RUN_TEST(gbuf_edge_props_merge_is_order_independent); RUN_TEST(gbuf_edge_props_merge_prefers_higher_confidence); + RUN_TEST(gbuf_edge_props_unreadable_confidence_does_not_displace_absent); RUN_TEST(gbuf_edge_props_merge_keeps_existing_on_empty); /* Shared ID tests */ 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_language.c b/tests/test_language.c index 2c8d6576b..1bcd5e040 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -89,6 +89,14 @@ TEST(lang_ext_csharp) { ASSERT_EQ(cbm_language_for_extension(".cs"), CBM_LANG_CSHARP); PASS(); } +/* Blazor components were unmapped, so a .razor file was never discovered at + * all: indexing a Blazor app produced no nodes for any component, and reaching + * them required an undocumented extra_extensions entry in a per-project + * .codebase-memory.json. */ +TEST(lang_ext_razor) { + ASSERT_EQ(cbm_language_for_extension(".razor"), CBM_LANG_CSHARP); + PASS(); +} TEST(lang_ext_php) { ASSERT_EQ(cbm_language_for_extension(".php"), CBM_LANG_PHP); PASS(); @@ -1197,6 +1205,7 @@ SUITE(language) { RUN_TEST(lang_ext_h); RUN_TEST(lang_ext_ixx); RUN_TEST(lang_ext_csharp); + RUN_TEST(lang_ext_razor); RUN_TEST(lang_ext_php); RUN_TEST(lang_ext_lua); RUN_TEST(lang_ext_scala); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 7d2728b3d..8db63b1ed 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -3629,6 +3629,69 @@ TEST(trace_evidence_strategy_class_vocabulary_is_closed) { PASS(); } +/* A confidence the code cannot read must be reported as "not recorded", not + * as a recorded zero. + * + * The emitter reserves ev_conf < 0 for "no confidence on this edge" and + * prints "-" (text) or null (json). The reader set ev_conf with + * strtod(colon + 1, NULL), and strtod answers 0.0 for text it cannot read -- + * so a malformed value passed the ev_conf >= 0.0 test and printed 0.00, the + * one value the surrounding code goes out of its way to keep meaningful. + * A caller then cannot tell "the resolver was certain this is wrong" from + * "nobody wrote a number here". */ +TEST(tool_trace_path_unreadable_confidence_reports_not_recorded) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "badconf-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/badconf"); + cbm_node_t caller = {.project = proj, + .label = "Function", + .name = "caller", + .qualified_name = "badconf-proj.src.caller", + .file_path = "src/a.c", + .start_line = 1, + .end_line = 5}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "target", + .qualified_name = "badconf-proj.src.target", + .file_path = "src/a.c", + .start_line = 10, + .end_line = 20}; + int64_t id_caller = cbm_store_upsert_node(st, &caller); + int64_t id_callee = cbm_store_upsert_node(st, &callee); + ASSERT_GT(id_caller, 0); + ASSERT_GT(id_callee, 0); + /* The strategy reads fine; only the confidence is malformed. */ + cbm_edge_t e = {.project = proj, + .source_id = id_caller, + .target_id = id_callee, + .type = "CALLS", + .properties_json = "{\"callee\":\"target\",\"confidence\":null," + "\"strategy\":\"lsp_trait_dispatch\",\"candidates\":1}"}; + ASSERT_GT(cbm_store_insert_edge(st, &e), 0); + + char *ev = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"caller\"," + "\"project\":\"badconf-proj\",\"direction\":\"outbound\",\"include_evidence\":true}}}"); + ASSERT_NOT_NULL(ev); + char *ev_txt = extract_text_content(ev); + ASSERT_NOT_NULL(ev_txt); + /* Positive controls: the hop and its readable class still come through, so + * a failure below is about the confidence and not a broken request. */ + ASSERT_NOT_NULL(strstr(ev_txt, "target")); + ASSERT_NOT_NULL(strstr(ev_txt, "lsp")); + /* The claim: an unreadable confidence is never published as 0.00. */ + ASSERT_NULL(strstr(ev_txt, "0.00")); + free(ev_txt); + free(ev); + + cbm_mcp_server_free(srv); + PASS(); +} + /* Distilled from #559 (@vvenegasv). The indexer already records * {strategy, confidence} on every CALLS edge (pass_calls.c:355) and the store * reads it back, but no tool ever surfaced it — an agent could see THAT A->B @@ -5345,22 +5408,25 @@ TEST(search_code_file_pattern_prefilter_boundaries) { ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("src\\*.pas")); ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("*.c++")); ASSERT_FALSE(cbm_search_code_file_pattern_can_prefilter("*R&D*.go")); + + ASSERT_TRUE(cbm_search_code_windows_path_matches_prefilter("src/UnitMain.PAS", "*.pas")); + ASSERT_TRUE(cbm_search_code_windows_path_matches_prefilter("types/index.D.TS", "*.d.ts")); + ASSERT_FALSE(cbm_search_code_windows_path_matches_prefilter("src/UnitMain.pas.bak", "*.pas")); + ASSERT_FALSE(cbm_search_code_windows_path_matches_prefilter("types/index.ts", "*.d.ts")); PASS(); } -TEST(search_code_windows_prefilter_precedes_content_scan) { +TEST(search_code_windows_scope_prefilter_removes_pipeline_filter) { #ifdef _WIN32 char command[CBM_SZ_4K]; cbm_search_code_build_grep_cmd(command, sizeof(command), false, true, "*.go", "C:/tmp/pattern", "C:/tmp/filelist", "C:/tmp/root"); - const char *prefilter = strstr(command, "Where-Object { $_ -like '*.go' }"); const char *content_scan = strstr(command, "ForEach-Object { Select-String"); const char *postfilter = strstr(command, "Where-Object { $_.Path -like '**.go' }"); - ASSERT_NOT_NULL(prefilter); + ASSERT_NULL(strstr(command, "Where-Object { $_ -like '*.go' }")); ASSERT_NOT_NULL(content_scan); ASSERT_NOT_NULL(postfilter); - ASSERT_TRUE(prefilter < content_scan); ASSERT_TRUE(content_scan < postfilter); cbm_search_code_build_grep_cmd(command, sizeof(command), false, true, "*handler*.go", @@ -13969,6 +14035,7 @@ SUITE(mcp) { RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed); RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped); RUN_TEST(tool_trace_path_evidence_columns_match_header_issue1542); + RUN_TEST(tool_trace_path_unreadable_confidence_reports_not_recorded); RUN_TEST(tool_trace_call_path_depth_clamped); RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned); RUN_TEST(tool_trace_call_path_dts_stub_unions_with_impl); @@ -14003,7 +14070,7 @@ SUITE(mcp) { RUN_TEST(search_code_path_filter_prefilter_keeps_matches); RUN_TEST(search_code_path_filter_matches_nothing); RUN_TEST(search_code_file_pattern_prefilter_boundaries); - RUN_TEST(search_code_windows_prefilter_precedes_content_scan); + RUN_TEST(search_code_windows_scope_prefilter_removes_pipeline_filter); RUN_TEST(search_code_cancel_cleans_supervised_scan); RUN_TEST(search_code_output_limit_fails_closed_and_cleans_scan); RUN_TEST(search_code_scan_deadline_fails_closed_and_resets); 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_pipeline.c b/tests/test_pipeline.c index d62a5572f..00f7604ba 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4857,6 +4857,17 @@ static void write_go_bare_field_fixture(const char *tmp, int pad_files) { "\terr := errors.New(\"x\")\n" "\treturn err\n" "}\n"); + /* #1962: genuine selector references from a sibling file of the same + * package. `t.err = nil` writes the field through a selector; `t.n` reads + * it. The extractor strips the receiver on both paths, so only the + * is_member_access signal can distinguish these from Run's bare local. */ + write_temp_file(tmp, "state/reset.go", + "package state\n" + "\n" + "func (t *Tracker) Reset() int {\n" + "\tt.err = nil\n" + "\treturn t.n\n" + "}\n"); for (int i = 0; i < pad_files; i++) { char name[64]; char body[128]; @@ -4896,6 +4907,11 @@ TEST(pipeline_go_bare_ref_never_binds_field) { ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "WRITES")); ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "READS")); ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "USAGE")); + /* #1962, reproduce-first: RED while the guard is a blanket veto — genuine + * selector references must reach the field (write via `t.err = nil`, + * value use via `t.n`). */ + ASSERT_TRUE(cross_file_edge_exists(s, project, "Reset", "err", "WRITES")); + ASSERT_TRUE(cross_file_edge_exists(s, project, "Reset", "n", "USAGE")); cbm_store_close(s); cbm_pipeline_free(p); @@ -4927,6 +4943,10 @@ TEST(pipeline_go_bare_ref_never_binds_field_parallel) { ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "WRITES")); ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "READS")); ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "USAGE")); + /* #1962 parallel twin: resolve_file_rw / resolve_file_usages must honour + * the member-access signal exactly like the sequential resolvers. */ + ASSERT_TRUE(cross_file_edge_exists(s, project, "Reset", "err", "WRITES")); + ASSERT_TRUE(cross_file_edge_exists(s, project, "Reset", "n", "USAGE")); cbm_store_close(s); cbm_pipeline_free(p); @@ -5082,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 @@ -5179,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 @@ -13121,6 +13267,8 @@ 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); diff --git a/tests/test_registry.c b/tests/test_registry.c index 3e1d604af..247ca61ad 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -840,22 +840,23 @@ TEST(cross_language_ref_drops_go_vs_c) { } TEST(go_bare_ref_never_binds_field) { - /* #1942: a bare (dot-less) Go reference can never denote a struct field — - * field access is always a selector expression. */ - ASSERT_TRUE(cbm_go_suppress_bare_field_ref(true, "err", "Field")); - ASSERT_TRUE(cbm_go_suppress_bare_field_ref(true, "config", "Field")); - /* A selector-shaped reference may bind a field. */ - ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "t.err", "Field")); + /* #1942/#1962: a bare Go identifier can never denote a struct field — + * field access is always a selector expression. The extractor strips the + * receiver before the resolver runs (resolve_lhs_write_name writes the + * trailing name; is_reference_node records the inner field_identifier), + * so the selector-vs-bare distinction arrives as the recorded + * is_member_access signal, never as a dot in the reference text. */ + ASSERT_TRUE(cbm_go_suppress_bare_field_ref(true, false, "Field")); + /* The member half of a selector may bind a field. */ + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, true, "Field")); /* Bare references to non-fields are untouched. */ - ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "err", "Variable")); - ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "err", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, false, "Variable")); + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, false, "Function")); /* Other languages reference their own members bare inside methods — * never suppressed (cp_reads_writes_cs_static_field pins the C# shape). */ - ASSERT_FALSE(cbm_go_suppress_bare_field_ref(false, "_count", "Field")); - /* Degenerate inputs → nothing to judge. */ - ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, NULL, "Field")); - ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "", "Field")); - ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "err", NULL)); + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(false, false, "Field")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, false, NULL)); PASS(); } @@ -901,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 @@ -997,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); } diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index c9932bf2d..1554821c6 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -21,6 +21,7 @@ #ifndef _WIN32 #include #include +#include #include #else #include @@ -194,6 +195,52 @@ TEST(subprocess_retries_transient_spawn_refusal) { #endif } +#ifndef _WIN32 +static volatile sig_atomic_t g_spawn_backoff_alarm_count = 0; + +static void spawn_backoff_alarm_handler(int signal_number) { + (void)signal_number; + g_spawn_backoff_alarm_count++; +} +#endif + +TEST(subprocess_spawn_backoff_resumes_after_eintr) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX signal interruption"); +#else + struct sigaction action = {0}; + struct sigaction previous_action = {0}; + action.sa_handler = spawn_backoff_alarm_handler; + (void)sigemptyset(&action.sa_mask); + bool handler_installed = sigaction(SIGALRM, &action, &previous_action) == 0; + + struct itimerval timer = { + .it_interval = {.tv_sec = 0, .tv_usec = 1000}, + .it_value = {.tv_sec = 0, .tv_usec = 1000}, + }; + g_spawn_backoff_alarm_count = 0; + bool timer_started = handler_installed && setitimer(ITIMER_REAL, &timer, NULL) == 0; + + uint64_t started_at = cbm_now_ms(); + cbm_subprocess_force_spawn_eagain_for_testing(3); + cbm_proc_result_t result = run_sh("exit 0", 0); + uint64_t elapsed_ms = cbm_now_ms() - started_at; + + struct itimerval disabled = {0}; + (void)setitimer(ITIMER_REAL, &disabled, NULL); + if (handler_installed) { + (void)sigaction(SIGALRM, &previous_action, NULL); + } + + ASSERT_TRUE(handler_installed); + ASSERT_TRUE(timer_started); + ASSERT_TRUE(g_spawn_backoff_alarm_count > 0); + ASSERT_TRUE(elapsed_ms >= 50); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + PASS(); +#endif +} + TEST(subprocess_gives_up_after_the_retry_budget) { #ifdef _WIN32 SKIP_PLATFORM("POSIX fork/EAGAIN path"); @@ -1172,6 +1219,7 @@ SUITE(subprocess) { RUN_TEST(subprocess_run_crash_is_crash); RUN_TEST(subprocess_run_hang_is_hang); RUN_TEST(subprocess_retries_transient_spawn_refusal); + RUN_TEST(subprocess_spawn_backoff_resumes_after_eintr); RUN_TEST(subprocess_gives_up_after_the_retry_budget); RUN_TEST(subprocess_run_spawn_failure); RUN_TEST(subprocess_run_null_bin_rejected); diff --git a/tests/windows/test_non_ascii_path.py b/tests/windows/test_non_ascii_path.py index bd7de22a4..bb181688f 100644 --- a/tests/windows/test_non_ascii_path.py +++ b/tests/windows/test_non_ascii_path.py @@ -358,9 +358,9 @@ def no_project_error(index_txt, repo, cache): def index_and_count(binary, repo, cache): - """Index `repo` into an isolated cache and return label-resolved counts.""" + """Start in and index `repo`, then return label-resolved counts.""" os.makedirs(cache, exist_ok=True) - with McpServer(binary, cache_dir=cache) as s: + with McpServer(binary, cache_dir=cache, cwd=repo) as s: s.initialize() resp = s.call_tool("index_repository", {"repo_path": repo}, timeout=180) index_txt, err = s.tool_text(resp)