Merge origin/main into fix/adr-reindex-race

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-31 16:38:41 +01:00
commit 8cef96dd88
30 files changed
+1935 -208

No files matched your search

+2 -2
View File
@@ -255,7 +255,7 @@ codebase-memory-mcp config set auto_index true
`auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched through `auto_watch` (on by default), subject to its global setting and session ownership.
Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. Under a daemon started with `daemon start` a watch outlives its last owning session: the project stays watched and is re-indexed in the background when it changes, until that daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
Watcher registration is controlled globally by `auto_watch` (default `true`). On the next refresh or registration request, `true` allows MCP sessions to register their root on connect and their successful `index_repository` targets; `false` removes all watches. CLI indexing and failed MCP indexes do not add watches.
@@ -904,7 +904,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a
| Tool | Description |
|------|-------------|
| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected, subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected (under `daemon start`, also after it disconnects), subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. |
| `delete_project` | Remove a project and all its graph data, including its ADR sidecar (`<db>.adr.db`). |
| `index_status` | Check indexing status of a project. |
+1 -1
View File
@@ -89,7 +89,7 @@ Current keys:
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
| `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. |
| `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. |
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; `index_status` reports the current state in its `watch` object. |
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. |
| `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. |
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
+1 -1
View File
@@ -3924,7 +3924,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
uint64_t lsp_start = now_ns();
if (!result->lsp_skipped) {
if (language == CBM_LANG_GO) {
cbm_run_go_lsp(a, result, source, source_len, root);
cbm_run_go_lsp(a, result, source, source_len, root, rel_path);
}
if (language == CBM_LANG_C || language == CBM_LANG_CPP || language == CBM_LANG_CUDA) {
if (!cbm_run_c_lsp_with_test_owners(a, result, source, source_len, root,
+8 -2
View File
@@ -4557,8 +4557,14 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
call.first_string_arg = route_path_with_group_prefix(ctx, node, call.callee_name,
call.first_string_arg);
/* A slashless registration's string action would only be
* guessed at (#1146): mint its Route without a handler. */
if (call.first_string_arg && call.first_string_arg[0] == '/' &&
* guessed at (#1146): mint its Route without a handler. A Go
* ServeMux pattern names its method or host before the slash
* ("POST /v1/x", #2537) and keeps its handler. */
if (call.first_string_arg &&
(call.first_string_arg[0] == '/' ||
(ctx->language == CBM_LANG_GO &&
cbm_service_pattern_mux_route_path(call.callee_name, call.first_string_arg) !=
NULL)) &&
!(laravel_uri && php_route_action_is_string(node))) {
call.second_arg_name = extract_handler_arg(ctx, args);
}
+18
View File
@@ -7061,6 +7061,24 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
}
}
/* Go allows any number of init() functions per package — even several in
* one file — and they all run at start-up. On the flat QN they all
* collided and the graph upsert kept ONE node per package, silently
* dropping the rest (#1910). Disambiguate with the #495 cfg-twin pattern:
* fold the file basename and the init's per-file ordinal into the QN.
* The ordinal (not the line) keeps the QN stable when code is edited
* above the function — the QN is node identity, and a line suffix would
* churn nodes on every unrelated insertion. Nothing ever joins on the
* plain QN — calling init explicitly is illegal in Go — and the
* call-scope side (compute_func_qn in extract_unified.c) and the Go LSP
* (process_function in lsp/go_lsp.c) name it through the same
* cbm_go_init_qn, so init-body calls keep their attribution. */
if (ctx->language == CBM_LANG_GO && strcmp(def.label, "Function") == 0 &&
strcmp(name, "init") == 0) {
def.qualified_name =
cbm_go_init_qn(a, def.qualified_name, ctx->rel_path, node, ctx->source);
}
// C++/CUDA: out-of-line method definition (`Foo::bar` in a .cc/.cpp). The
// class body in the header is declaration-only, so without this the
// definition is recorded as a free Function. Promote it to a Method whose QN
+17
View File
@@ -1133,6 +1133,23 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan
}
}
/* Go init(): the def extractor folds the file basename and the init's
* per-file ordinal into the QN so every init in a package survives the
* upsert (#1910, the #495 cfg-twin pattern; ordinal, not line, so the QN
* is stable under edits above the function). Name it through the same
* cbm_go_init_qn, or init-body calls fall back to File-node attribution
* (calls_find_source). */
if (ctx->language == CBM_LANG_GO && strcmp(name, "init") == 0 &&
strcmp(ts_node_type(node), "function_declaration") == 0) {
const char *base_qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path,
name, ctx->language);
/* NULL only on allocation failure: fall through to the flat QN, as
* the receiver branch above does. */
if (base_qn) {
return cbm_go_init_qn(ctx->arena, base_qn, ctx->rel_path, node, ctx->source);
}
}
/* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def
* extractor bakes it into the def QN. Compose it identically here — otherwise
* an in-body call sources to a QN one or more segments short of the def, and
+59
View File
@@ -2330,6 +2330,65 @@ int cbm_classify_string(const char *str, int len) {
return NOT_FOUND;
}
/* 1-based source-order ordinal of a top-level Go `init` function_declaration
* among the file's `init`s. Unlike a line number, the ordinal is stable under
* edits above the function — QNs are node identity, and a line-based suffix
* would churn nodes on every unrelated insertion (the #495 cfg suffix and the
* GoogleTest derivation are both edit-stable for the same reason).
* One cursor pass over the top-level declarations: ts_node_named_child(root, i)
* is O(i), so an indexed loop is O(n^2) per init on a wide file, and generated
* Go (.pb.go, tables) carries thousands of top-level declarations next to its
* init(). */
static int go_init_ordinal(TSNode fn_node, const char *source) {
TSNode root = fn_node;
for (TSNode p = ts_node_parent(root); !ts_node_is_null(p); p = ts_node_parent(p)) {
root = p;
}
int ordinal = 0;
TSTreeCursor cur = ts_tree_cursor_new(root);
if (ts_tree_cursor_goto_first_child(&cur)) {
do {
TSNode child = ts_tree_cursor_current_node(&cur);
if (strcmp(ts_node_type(child), "function_declaration") != 0) {
continue;
}
TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name"));
if (ts_node_is_null(name)) {
continue;
}
uint32_t start = ts_node_start_byte(name);
if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) {
continue;
}
ordinal++;
if (ts_node_eq(child, fn_node)) {
break;
}
} while (ts_tree_cursor_goto_next_sibling(&cur));
}
ts_tree_cursor_delete(&cur);
return ordinal;
}
/* Qualified name of a Go `init` function (#1910). Go allows any number of
* init() per package — even several in one file — so the flat `pkg.init`
* collides and the graph upsert keeps one node. The QN folds in the file
* basename and the init's per-file ordinal: `pkg.init#file.go:N`. Every side
* that names an init calls this one function — the definition extractor, the
* call-scope side (compute_func_qn) and the Go LSP's enclosing-function QN —
* so the three cannot drift. A NULL base QN or path keeps base_qn as is. */
const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node,
const char *source) {
if (!base_qn || !rel_path) {
return base_qn;
}
const char *base = strrchr(rel_path, '/');
base = base ? base + 1 : rel_path;
const char *qn =
cbm_arena_sprintf(a, "%s#%s:%d", base_qn, base, go_init_ordinal(fn_node, source));
return qn ? qn : base_qn;
}
/* Flatten a JS/TS `template_string` node into plain text (issue #1006).
* String fragments are kept verbatim; each ${...} substitution becomes the
* "{}" placeholder so client URLs built from template literals share the
+7
View File
@@ -287,6 +287,13 @@ bool cbm_is_module_level(TSNode node, CBMLanguage lang);
// node being classified.
bool cbm_is_module_level_p(TSNode parent, CBMLanguage lang);
/* QN of a Go `init` function_declaration (#1910): base_qn + "#<file basename>:<N>",
* N = its 1-based ordinal among the file's top-level `init`s. The ONE formula
* for the def extractor, compute_func_qn and the Go LSP's enclosing-function
* QN. A NULL base_qn or rel_path returns base_qn unchanged. */
const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node,
const char *source);
// --- FQN computation ---
// Compute qualified name: project.rel_path_parts.name
+18 -10
View File
@@ -1994,6 +1994,8 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) {
// resolver. Since #1909 receiver-qualified Go method QNs, that shared
// form is package_qn.ReceiverType.method for methods (the ONE-formula
// contract: cbm_go_receiver_type_name) and package_qn.func for functions.
// A package may hold any number of init() functions, so each one is
// package_qn.init#<file>:<N> (#1910, cbm_go_init_qn — the same formula).
ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name);
if (strcmp(ts_node_type(func_node), "method_declaration") == 0) {
TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8);
@@ -2004,6 +2006,9 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) {
recv_type, func_name);
}
}
} else if (strcmp(func_name, "init") == 0) {
ctx->enclosing_func_qn = cbm_go_init_qn(ctx->arena, ctx->enclosing_func_qn, ctx->rel_path,
func_node, ctx->source);
}
// Push function scope
@@ -2261,7 +2266,7 @@ static void go_registry_with_stdlib(CBMTypeRegistry *reg, CBMArena *arena) {
// --- Entry point: build registry from file defs + run LSP ---
void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len,
TSNode root) {
TSNode root, const char *rel_path) {
CBMTypeRegistry reg;
cbm_registry_init(&reg, arena);
@@ -2619,6 +2624,7 @@ void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source,
// Phase 2: Build LSP context and run
GoLSPContext lsp_ctx;
go_lsp_init(&lsp_ctx, arena, source, source_len, &reg, module_qn, &result->resolved_calls);
lsp_ctx.rel_path = rel_path;
// Add imports
for (int i = 0; i < result->imports.count; i++) {
@@ -3239,9 +3245,9 @@ static void extract_type_params_from_ast(CBMArena *arena, CBMTypeRegistry *reg,
extern const TSLanguage *tree_sitter_go(void);
void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len,
const char *module_qn, CBMLSPDef *defs, int def_count,
const char **import_names, const char **import_qns, int import_count,
TSTree *cached_tree, CBMResolvedCallArray *out) {
const char *module_qn, const char *rel_path, CBMLSPDef *defs,
int def_count, const char **import_names, const char **import_qns,
int import_count, TSTree *cached_tree, CBMResolvedCallArray *out) {
if (!source || source_len <= 0)
return;
@@ -3505,6 +3511,7 @@ void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len,
// 4. Build LSP context and run
GoLSPContext ctx;
go_lsp_init(&ctx, arena, source, source_len, &reg, module_qn, out);
ctx.rel_path = rel_path;
for (int i = 0; i < import_count; i++) {
if (import_names[i] && import_qns[i]) {
@@ -3647,10 +3654,10 @@ CBMTypeRegistry *cbm_go_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i
}
void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int source_len,
const char *module_qn, CBMTypeRegistry *reg,
const char **import_names, const char **import_qns,
int import_count, TSTree *cached_tree,
CBMResolvedCallArray *out) {
const char *module_qn, const char *rel_path,
CBMTypeRegistry *reg, const char **import_names,
const char **import_qns, int import_count,
TSTree *cached_tree, CBMResolvedCallArray *out) {
if (!source || source_len <= 0 || !reg)
return;
@@ -3676,6 +3683,7 @@ void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int
GoLSPContext ctx;
go_lsp_init(&ctx, arena, source, source_len, reg, module_qn, out);
ctx.rel_path = rel_path;
for (int i = 0; i < import_count; i++) {
if (import_names[i] && import_qns[i]) {
go_lsp_add_import(&ctx, import_names[i], import_qns[i]);
@@ -3825,8 +3833,8 @@ void cbm_batch_go_lsp_cross(CBMArena *arena, CBMBatchGoLSPFile *files, int file_
// Delegate to existing per-file function
cbm_run_go_lsp_cross(&file_arena, file->source, file->source_len, file->module_qn,
file->defs, file->def_count, file->import_names, file->import_qns,
file->import_count, file->cached_tree, &file_out);
file->rel_path, file->defs, file->def_count, file->import_names,
file->import_qns, file->import_count, file->cached_tree, &file_out);
// Copy results to output arena (must outlive per-file arena)
if (file_out.count > 0) {
+9 -1
View File
@@ -22,6 +22,7 @@ typedef struct {
// Current function context
const char* enclosing_func_qn;
const char* package_qn; // module QN for this file
const char* rel_path; // repo-relative file path: init() QN suffix (#1910); NULL = flat
// Output: resolved calls accumulate here
CBMResolvedCallArray* resolved_calls;
@@ -63,8 +64,10 @@ const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx,
// Entry point: build registry from file's own defs + run LSP resolution.
// Called from cbm_extract_file() after definitions and imports are extracted.
// rel_path is the file's repo-relative path: it names the file's init()
// functions exactly as the def extractor does (cbm_go_init_qn, #1910).
void cbm_run_go_lsp(CBMArena* arena, CBMFileResult* result,
const char* source, int source_len, TSNode root);
const char* source, int source_len, TSNode root, const char* rel_path);
// Register Go stdlib types and functions into a registry.
// Auto-generated by scripts/gen-go-stdlib.go.
@@ -112,11 +115,14 @@ typedef struct {
// Re-parses source with tree-sitter internally.
// defs includes both file-local and cross-file definitions.
// import_names/import_qns are parallel arrays of length import_count.
// rel_path: the file's repo-relative path, for its init() QNs (#1910); NULL
// leaves init callers flat (they then miss the caller join).
// Results written to out (arena-allocated).
void cbm_run_go_lsp_cross(
CBMArena* arena,
const char* source, int source_len,
const char* module_qn,
const char* rel_path,
CBMLSPDef* defs, int def_count,
const char** import_names, const char** import_qns, int import_count,
TSTree* cached_tree, // NULL = parse internally
@@ -135,6 +141,7 @@ void cbm_run_go_lsp_cross_with_registry(
CBMArena* arena,
const char* source, int source_len,
const char* module_qn,
const char* rel_path, // see cbm_run_go_lsp_cross
CBMTypeRegistry* reg, // pre-built, finalized, READ-ONLY
const char** import_names, const char** import_qns, int import_count,
TSTree* cached_tree, // NULL = parse internally
@@ -160,6 +167,7 @@ typedef struct {
const char* source;
int source_len;
const char* module_qn;
const char* rel_path; // repo-relative path (init() QNs, #1910); NULL = flat
TSTree* cached_tree; // from TSTree caching (NULL = parse internally)
CBMLSPDef* defs; // combined file-local + cross-file defs
int def_count;
+59
View File
@@ -1183,6 +1183,65 @@ const char *cbm_service_pattern_route_method(const char *callee_name) {
return NULL;
}
static bool mux_is_blank(char ch) {
return ch == ' ' || ch == '\t';
}
const char *cbm_service_pattern_mux_split(const char *pattern, char *method, size_t method_sz) {
if (method && method_sz > 0) {
method[0] = '\0';
}
if (!pattern || pattern[0] == '/') {
return NULL;
}
/* METHOD: Go cuts it at the first space or tab and trims the rest. */
const char *rest = pattern;
size_t method_len = 0;
while (pattern[method_len] >= 'A' && pattern[method_len] <= 'Z') {
method_len++;
}
if (method_len > 0 && mux_is_blank(pattern[method_len])) {
rest = pattern + method_len;
while (mux_is_blank(*rest)) {
rest++;
}
} else {
method_len = 0;
}
/* HOST: everything before the path's slash, with no blank or line break. */
const char *slash = rest;
while (*slash && *slash != '/' && !mux_is_blank(*slash) && *slash != '\n' && *slash != '\r') {
slash++;
}
if (*slash != '/' || (method_len == 0 && slash == rest)) {
return NULL;
}
if (method_len > 0 && method) {
if (method_len >= method_sz) {
method[0] = '\0';
return NULL;
}
memcpy(method, pattern, method_len);
method[method_len] = '\0';
}
return slash;
}
const char *cbm_service_pattern_mux_route_path(const char *callee_name, const char *pattern) {
static const char *const mux_callees[] = {".Handle", ".HandleFunc"};
if (!callee_name || !pattern || strstr(pattern, "://") != NULL) {
return NULL;
}
size_t clen = strlen(callee_name);
for (size_t i = 0; i < sizeof(mux_callees) / sizeof(mux_callees[0]); i++) {
size_t slen = strlen(mux_callees[i]);
if (clen >= slen && strcmp(callee_name + clen - slen, mux_callees[i]) == 0) {
return cbm_service_pattern_mux_split(pattern, NULL, 0);
}
}
return NULL;
}
const char *cbm_service_pattern_broker(const char *resolved_qn) {
if (!resolved_qn) {
return NULL;
+19
View File
@@ -84,6 +84,25 @@ const char *cbm_service_pattern_http_method(const char *callee_name);
* Returns NULL if not a known route registration method. */
const char *cbm_service_pattern_route_method(const char *callee_name);
/* Go 1.22+ net/http ServeMux pattern "[METHOD ][HOST]/[PATH]" (#2537):
* "POST /v1/x", "GET api.example.com/v1/x", "api.example.com/v1/x". Returns a
* pointer to PATH's leading slash inside `pattern`, or NULL when the pattern
* names neither a method nor a host before a slash (a plain "/v1/x" is NULL
* too: there is nothing to split). METHOD is a run of uppercase letters ended
* by spaces or tabs; it is copied to `method` (NUL-terminated, "" when the
* pattern names none) when `method` is non-NULL; a method that does not fit
* `method_sz` then makes the pattern NULL. The grammar is Go's: callers gate
* on the file language. */
const char *cbm_service_pattern_mux_split(const char *pattern, char *method, size_t method_sz);
/* The path of a ServeMux registration's pattern (#2537): only a callee ending
* ".Handle" or ".HandleFunc" registers one, and never with a URL ("://").
* Another route-verb call names no pattern: `store.Get("users/123")` is no
* host "users" + path "/123", `client.Get("https://...")` no host "https:".
* The split of cbm_service_pattern_mux_split for such a registration, else
* NULL. The grammar is Go's: callers gate on the file language. */
const char *cbm_service_pattern_mux_route_path(const char *callee_name, const char *pattern);
/* Get the broker name for an async QN (e.g., "pubsub" from a Pub/Sub QN).
* Returns NULL if not an async pattern. */
const char *cbm_service_pattern_broker(const char *resolved_qn);
+55 -20
View File
@@ -11011,7 +11011,20 @@ void cbm_cli_set_client_selection_for_testing(const char *spec) {
/* Detect the running binary's path at runtime. The return value distinguishes
* OS-reported identity (safe ownership evidence) from the ~/.local/bin fallback
* used only as an install copy source. */
#ifdef CBM_CLI_ENABLE_TEST_API
static const char *g_cli_self_path_override_for_test = NULL;
void cbm_cli_set_self_path_override_for_test(const char *self_path) {
g_cli_self_path_override_for_test = self_path;
}
#endif
static bool cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) {
#ifdef CBM_CLI_ENABLE_TEST_API
if (g_cli_self_path_override_for_test) {
snprintf(buf, buf_sz, "%s", g_cli_self_path_override_for_test);
return true;
}
#endif
buf[0] = '\0';
bool exact = false;
#ifdef _WIN32
@@ -11273,6 +11286,7 @@ char *cbm_build_install_plan_json(const char *home, const char *binary_path) {
typedef struct {
const char *bin_target;
const char *config_binary;
const char *bin_dir;
const char *home;
const char *shell_rc;
@@ -11297,6 +11311,8 @@ static int cli_install_activate(void *opaque) {
!activation->shell_rc) {
return CLI_TRUE;
}
const char *config_binary =
activation->config_binary ? activation->config_binary : activation->bin_target;
char previous_managed_binary[CLI_BUF_1K] = {0};
bool previous_managed_binary_exact = cbm_detect_self_path(
previous_managed_binary, sizeof(previous_managed_binary), activation->home);
@@ -11345,10 +11361,13 @@ static int cli_install_activate(void *opaque) {
* including same-binary and non-force installs. */
int agent_config_rc = CLI_OK;
if (!activation->skip_config) {
const char *prev = previous_managed_binary_exact ? previous_managed_binary : NULL;
if (config_binary != activation->bin_target &&
(!prev || strcmp(prev, config_binary) == 0)) {
prev = activation->bin_target;
}
agent_config_rc = cbm_install_agent_configs_with_previous(
activation->home, activation->bin_target,
previous_managed_binary_exact ? previous_managed_binary : NULL, activation->force,
activation->dry_run);
activation->home, config_binary, prev, activation->force, activation->dry_run);
}
if (agent_config_rc != CLI_OK) {
cli_activation_transaction_finalize_committed_or_fail_stop(
@@ -11495,11 +11514,40 @@ int cbm_cmd_install(int argc, char **argv) {
return CLI_TRUE;
}
char self_path[CLI_BUF_1K] = {0};
bool self_path_exact = cbm_detect_self_path(self_path, sizeof(self_path), home);
/* #1566: a binary owned by mise/Homebrew/nix is not ours to relocate. Infer
* it, and let either flag override the inference in either direction. */
bool externally_managed =
!force_binary && cli_binary_is_externally_managed(self_path, self_path_exact, bin_dir);
bool explicit_skip_binary = skip_binary;
if (externally_managed) {
skip_binary = true;
}
/* NOT stat(): on Windows it goes through the ANSI code page, so an
* extended-length or non-ASCII target reports "absent" and a non-force
* install silently overwrites bytes the user asked to keep. */
cbm_path_info_t target_status;
bool target_exists = cbm_path_info_utf8(bin_target, &target_status) == 0;
bool same_binary = cbm_same_file(self_path, bin_target);
/* #2722: when a binary is managed externally (mise, brew, nix, etc.),
* do not write the non-existent default target into agent configurations.
* Use the running managed binary path instead. */
const char *config_binary = bin_target;
if (externally_managed && self_path_exact && self_path[0]) {
config_binary = self_path;
} else if (skip_binary && !target_exists && self_path_exact && self_path[0]) {
config_binary = self_path;
}
/* --plan: emit the machine-readable install receipt and exit WITHOUT
* mutating anything (no config writes, no index deletion, no network) so
* an agent can inspect exactly what install would touch first (#388). */
if (plan) {
char *json = cbm_build_install_plan_json_options(home, bin_target, skip_config);
char *json = cbm_build_install_plan_json_options(home, config_binary, skip_config);
if (!json) {
(void)fprintf(stderr, "error: failed to build install plan\n");
return CLI_TRUE;
@@ -11521,16 +11569,9 @@ int cbm_cmd_install(int argc, char **argv) {
printf("codebase-memory-mcp install %s\n\n", CBM_VERSION);
char self_path[CLI_BUF_1K] = {0};
bool self_path_exact = cbm_detect_self_path(self_path, sizeof(self_path), home);
/* #1566: a binary owned by mise/Homebrew/nix is not ours to relocate. Infer
* it, and let either flag override the inference in either direction. */
bool externally_managed =
!force_binary && cli_binary_is_externally_managed(self_path, self_path_exact, bin_dir);
if (externally_managed || skip_binary) {
if (externally_managed || explicit_skip_binary) {
const char *manager = cli_external_manager_name(self_path);
if (skip_binary) {
if (explicit_skip_binary) {
printf("Skipping binary placement (--skip-binary); configuring agents only.\n\n");
} else {
printf("Binary is managed elsewhere%s%s:\n %s\n"
@@ -11538,15 +11579,8 @@ int cbm_cmd_install(int argc, char **argv) {
" (use --force-binary to install a copy into %s anyway)\n\n",
manager ? " by " : "", manager ? manager : "", self_path, bin_dir);
}
skip_binary = true;
}
/* NOT stat(): on Windows it goes through the ANSI code page, so an
* extended-length or non-ASCII target reports "absent" and a non-force
* install silently overwrites bytes the user asked to keep. */
cbm_path_info_t target_status;
bool target_exists = cbm_path_info_utf8(bin_target, &target_status) == 0;
bool same_binary = cbm_same_file(self_path, bin_target);
bool do_copy = !skip_binary && !same_binary && (!target_exists || force);
/* (#607) Default: preserve existing indexes. `--reset-indexes` opts into
@@ -11713,6 +11747,7 @@ int cbm_cmd_install(int argc, char **argv) {
#endif
cli_install_activation_t activation = {
.bin_target = bin_target,
.config_binary = config_binary,
.bin_dir = bin_dir,
.home = home,
.shell_rc = shell_rc,
+2
View File
@@ -250,6 +250,8 @@ void cbm_cli_set_client_selection_for_testing(const char *spec);
* not recognisably foreign. Detection must be positive evidence — see the
* implementation comment. */
const char *cbm_cli_external_manager_name_for_testing(const char *self_path);
/* #2722: override runtime binary self-path detection for testing. */
void cbm_cli_set_self_path_override_for_test(const char *self_path);
/* #1558: expose the config-key table so a test can prove a key is discoverable
* (`config list`/`set` both walk it). */
size_t cbm_cli_config_key_count_for_testing(void);
+86 -27
View File
@@ -196,11 +196,11 @@ struct cbm_daemon_application_index_record {
cbm_daemon_application_index_record_t *next;
};
/* A watcher-triggered physical job is owned by the exact live sessions that
* currently subscribe to its project/root watch. The callback waiting for the
* job is only a storage waiter; it is deliberately not an ownership
* subscription, so the worker is cancelled when the last matching session
* disconnects even while unrelated daemon sessions remain alive. */
/* A watcher-triggered physical job is normally owned by the exact live
* sessions subscribed to its project/root watch. In permanent daemon mode the
* watcher itself additionally owns the physical job so background indexing can
* continue while no client session is connected. The callback remains a
* storage waiter in both modes and releases daemon ownership on completion. */
struct cbm_daemon_application_watch_job_subscription {
cbm_daemon_application_session_t *session;
cbm_daemon_application_job_t *job;
@@ -564,8 +564,26 @@ static bool application_watches_disabled_locked(cbm_daemon_application_t *applic
return true;
}
static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) {
static void application_release_session_watch_locked(cbm_daemon_application_session_t *session,
bool session_ending) {
application_watch_job_unsubscribe_session_locked(session, NULL);
/*
* A permanent daemon owns the physical project watch independently of
* individual client sessions. Session disconnect releases only that
* session's logical subscription; the watcher remains available for
* background change detection while the daemon generation is alive.
*
* Non-permanent applications retain the historical session-scoped
* lifecycle and remove the physical watch after the final subscriber.
* A release decided by workspace policy (session_ending false: the
* session's root is no longer allowed) is not a disconnect and removes
* the watch in both modes. Daemon shutdown and auto_watch=false remove
* persistent watches through their own paths; a deleted project's watch
* ends at its next watcher callback
* (application_permanent_watch_orphaned_locked).
*/
bool daemon_keeps_watch = session_ending && session->application->permanent;
while (session->watches) {
application_watch_membership_t *membership = session->watches;
cbm_daemon_application_watch_t *watch = membership->watch;
@@ -573,7 +591,7 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess
cbm_free(CBM_MEM_CLASS_OTHER, membership);
if (watch->subscribers > 0) {
watch->subscribers--;
if (watch->subscribers == 0) {
if (!daemon_keeps_watch && watch->subscribers == 0) {
application_remove_watch_locked(session->application, watch);
}
}
@@ -721,7 +739,7 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s
return;
}
if (!application_session_workspace_allowed(session, "watch")) {
application_release_session_watch_locked(session);
application_release_session_watch_locked(session, false);
return;
}
application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp),
@@ -806,7 +824,9 @@ static const char *application_watch_strategy_name(cbm_watcher_strategy_t strate
/* index_status watch visibility (#2167). The physical watcher is the truth for
* "watched"; session roots and successful explicit targets stay watched while
* at least one owning MCP session remains open. Takes the
* at least one owning MCP session remains open (under `daemon start`, also
* after the last one closes, until the daemon stops, the project is deleted,
* its root disappears, or auto_watch is switched off). Takes the
* application mutex and the watcher lock one after the other, never nested. */
static void application_watch_status(void *context, const char *project,
cbm_mcp_watch_status_t *out) {
@@ -3649,7 +3669,7 @@ static void application_session_cancel(void *context,
reap_update || (session->background_eligible && application->update_thread_started &&
application->update_owners == 0);
application_index_watch_unsubscribe_locked(session, NULL);
application_release_session_watch_locked(session);
application_release_session_watch_locked(session, true);
bool final_live_session = newly_cancelled;
for (cbm_daemon_application_session_t *other = application->sessions;
final_live_session && other; other = other->next) {
@@ -3709,7 +3729,7 @@ static void application_session_close(void *context,
reap_update || (session->background_eligible && application->update_thread_started &&
application->update_owners == 0);
application_index_watch_unsubscribe_locked(session, NULL);
application_release_session_watch_locked(session);
application_release_session_watch_locked(session, true);
cbm_mutex_unlock(&application->mutex);
application_auto_index_cancel_join(application, join_auto_index);
application_jobs_reap_completed(application);
@@ -4256,11 +4276,27 @@ static void application_watch_encode_index_args_locked(const cbm_daemon_applicat
*args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
}
/* Under `daemon start` a watch outlives its last session, so delete_project
* from another session or the CLI can remove the project's DB while nothing
* owns the watch any more: daemon MCP sessions carry no watcher pointer and
* never unwatch it. Re-indexing such an orphan would resurrect the deleted
* project. A full reindex also unlinks the DB while it rebuilds it, so a
* missing DB means "deleted" only while no job for the project is running.
* Caller holds application->mutex. */
static bool application_permanent_watch_orphaned_locked(cbm_daemon_application_t *application,
const cbm_daemon_application_watch_t *watch,
const char *project_key) {
return application->permanent && watch->subscribers == 0 &&
!application_find_active_job_locked(application, project_key) &&
!application_regular_db_exists(watch->project);
}
static cbm_daemon_application_job_t *application_background_index_admit(
cbm_daemon_application_t *application, const char *project_name, const char *canonical_root,
const char *project_key, yyjson_mut_doc *document, yyjson_mut_val *root, char **args,
bool require_live_watch, int *no_job_result) {
bool require_live_watch, bool *watcher_owns_job, int *no_job_result) {
application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE;
*watcher_owns_job = false;
application_jobs_reap_completed(application);
cbm_mutex_lock(&application->mutex);
if (require_live_watch) {
@@ -4268,9 +4304,15 @@ static cbm_daemon_application_job_t *application_background_index_admit(
}
cbm_daemon_application_watch_t *watch =
require_live_watch ? application_find_watch_locked(application, project_name) : NULL;
bool watch_live =
!require_live_watch || (watch && watch->subscribers > 0 &&
application_watch_root_matches(watch->root, canonical_root));
if (watch && application_permanent_watch_orphaned_locked(application, watch, project_key)) {
/* End the orphan the way a session refresh ends a deleted project's
* watch; the callback below becomes a harmless skip. */
application_remove_watch_locked(application, watch);
watch = NULL;
}
bool watch_live = !require_live_watch ||
(watch && application_watch_root_matches(watch->root, canonical_root) &&
(watch->subscribers > 0 || application->permanent));
if (require_live_watch && watch_live) {
application_watch_encode_index_args_locked(watch, document, root, args);
}
@@ -4284,16 +4326,28 @@ static cbm_daemon_application_job_t *application_background_index_admit(
if (job && require_live_watch) {
watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked(
application, watch, job, &watch_owner_count);
if (watch_subscriptions_ok && watch_owner_count > 0) {
job->watcher_waiters++;
} else if (!watch_subscriptions_ok) {
if (!watch_subscriptions_ok) {
subscribe_status = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED;
}
/* application_job_subscribe_locked() lends the caller one ordinary
* subscriber. A watcher callback is only a storage waiter: exact live
* session subscriptions above own the physical work. */
application_job_unsubscribe_locked(job);
if (!watch_subscriptions_ok || watch_owner_count == 0) {
application_job_unsubscribe_locked(job);
job = NULL;
} else if (application->permanent) {
/*
* The ordinary subscription acquired above is retained as daemon
* ownership. This allows the physical watcher to run indexing even
* while no client session currently owns the project watch.
*/
*watcher_owns_job = true;
job->watcher_waiters++;
} else if (watch_owner_count > 0) {
/*
* Historical non-permanent behavior: exact live sessions own the
* physical work; the watcher callback itself is only a waiter.
*/
job->watcher_waiters++;
application_job_unsubscribe_locked(job);
} else {
application_job_unsubscribe_locked(job);
job = NULL;
}
}
@@ -4318,7 +4372,7 @@ static cbm_daemon_application_job_t *application_background_index_admit(
static int application_background_index_wait(cbm_daemon_application_t *application,
cbm_daemon_application_job_t *job,
bool require_live_watch) {
bool require_live_watch, bool watcher_owns_job) {
bool successful = false;
bool cancelled = false;
for (;;) {
@@ -4331,6 +4385,9 @@ static int application_background_index_wait(cbm_daemon_application_t *applicati
if (job->watcher_waiters > 0) {
job->watcher_waiters--;
}
if (watcher_owns_job) {
application_job_unsubscribe_locked(job);
}
} else {
application_job_unsubscribe_locked(job);
}
@@ -4387,13 +4444,15 @@ static int application_background_index(cbm_daemon_application_t *application,
}
int no_job_result = APPLICATION_BACKGROUND_INDEX_FAILED;
bool watcher_owns_job = false;
cbm_daemon_application_job_t *job = application_background_index_admit(
application, project_name, canonical_root, project_key, document, root, &args,
require_live_watch, &no_job_result);
require_live_watch, &watcher_owns_job, &no_job_result);
free(project_key);
free(args);
yyjson_mut_doc_free(document);
return job ? application_background_index_wait(application, job, require_live_watch)
return job ? application_background_index_wait(application, job, require_live_watch,
watcher_owns_job)
: no_job_result;
}
+120 -106
View File
@@ -4036,6 +4036,8 @@ enum {
* and then joining/filtering/re-ranking those, we bound all work to O(N) where
* N = BM25_INNER_LIMIT rather than the full match set size. */
BM25_INNER_LIMIT = 2000,
/* SQLite reads a negative LIMIT as "no limit": rank every candidate. */
BM25_INNER_ALL = -1,
/* Longest label spliced into the MATCH pre-filter; real labels are short
* identifiers, so anything longer simply skips the pre-filter. */
BM25_LABEL_MAX = 64,
@@ -4391,6 +4393,96 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total,
return json;
}
/* The MATCH expression and the filters the ranked and the count query share.
* One binder for both keeps them on identical parameters. */
typedef struct {
const char *fts_query;
const char *project;
const char *file_like;
const char *label;
const char *kind;
const char *ns;
double label_weight;
} bm25_filters_t;
static void bm25_bind_opt_text(sqlite3_stmt *st, int idx, const char *value) {
if (value && value[0]) {
sqlite3_bind_text(st, idx, value, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(st, idx);
}
}
static void bm25_bind_filters(sqlite3_stmt *st, const bm25_filters_t *f) {
sqlite3_bind_text(st, BM25_BIND_QUERY, f->fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
sqlite3_bind_text(st, BM25_BIND_PROJECT, f->project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
bm25_bind_opt_text(st, BM25_BIND_FILE, f->file_like);
bm25_bind_opt_text(st, BM25_BIND_LABEL, f->label);
bm25_bind_opt_text(st, BM25_BIND_KIND, f->kind);
bm25_bind_opt_text(st, BM25_BIND_NS, f->ns);
sqlite3_bind_double(st, BM25_BIND_LABEL_WEIGHT, f->label_weight);
}
/* The top-candidate window is a performance ceiling, not an exact-total
* boundary. Probe one candidate beyond it so a broad query never presents a
* window-local count as the complete match count. The probe shares the
* label-scoped MATCH but is otherwise global to the FTS table; saturation is
* therefore conservatively reported even when later project/path filters
* might discard the hidden candidates. */
static bool bm25_window_saturated(sqlite3 *db, const char *fts_query) {
enum { PROBE_BIND_OFFSET = 2 };
const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 "
"ORDER BY bm25(nodes_fts), rowid LIMIT 1 OFFSET ?2";
sqlite3_stmt *probe = NULL;
if (sqlite3_prepare_v2(db, probe_sql, BM25_SQL_AUTO_LEN, &probe, NULL) != SQLITE_OK) {
return true;
}
sqlite3_bind_text(probe, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
sqlite3_bind_int(probe, PROBE_BIND_OFFSET, BM25_INNER_LIMIT);
bool saturated = sqlite3_step(probe) != SQLITE_DONE;
sqlite3_finalize(probe);
return saturated;
}
/* Count the candidates that pass the filters among the top `inner_limit`
* (BM25_INNER_ALL: among all of them), with the ranked query's subquery shape
* so FTS5 drives the scan here too. */
static int bm25_count_candidates(sqlite3 *db, const bm25_filters_t *f, int inner_limit) {
const char *count_sql =
"SELECT COUNT(*) FROM ("
" SELECT fts.rowid FROM ("
" SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1"
" ORDER BY " BM25_WEIGHTS " LIMIT ?3"
" ) fts "
" JOIN nodes n ON n.id = fts.rowid "
" WHERE n.project = ?2 "
/* MIRRORS the ranked query's filter verbatim — same weights, same
* label exclusions. Changing one alone reports a total that does
* not describe the rows returned. */
" AND " BM25_UNLABELLED_EXCLUSION " "
" AND (?6 IS NULL OR n.file_path LIKE ?6)"
" AND (?7 IS NULL OR n.label = ?7)"
" AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) "
" THEN json_extract(n.properties,'$.kind') END) = ?9)"
" AND (?10 IS NULL OR (CASE WHEN json_valid(n.properties) "
" THEN json_extract(n.properties,'$.obj_key') END) "
" LIKE ('%/%/' || replace(replace(replace(?10,'\\','\\\\'),'%','\\%'),"
" '_','\\_') || '/%') ESCAPE '\\')"
")";
int total = 0;
sqlite3_stmt *cs = NULL;
if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) != SQLITE_OK) {
return 0;
}
bm25_bind_filters(cs, f);
sqlite3_bind_int(cs, BM25_BIND_LIMIT, inner_limit);
if (sqlite3_step(cs) == SQLITE_ROW) {
total = sqlite3_column_int(cs, 0);
}
sqlite3_finalize(cs);
return total;
}
/* Run the BM25 full-text search path and return the JSON result string.
* Returns NULL if FTS5 is unavailable or the query produced no usable tokens,
* in which case the caller falls back to the regex-based search path. */
@@ -4478,117 +4570,39 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
free(file_like);
return NULL;
}
sqlite3_bind_text(stmt, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
const bm25_filters_t filters = {.fts_query = fts_query,
.project = project,
.file_like = file_like,
.label = label,
.kind = kind,
.ns = ns,
.label_weight = label_weight};
bm25_bind_filters(stmt, &filters);
sqlite3_bind_int(stmt, BM25_BIND_LIMIT, limit > 0 ? limit : BM25_DEFAULT_LIMIT);
sqlite3_bind_int(stmt, BM25_BIND_OFFSET, offset > 0 ? offset : 0);
sqlite3_bind_int(stmt, BM25_BIND_INNER, BM25_INNER_LIMIT);
if (file_like) {
sqlite3_bind_text(stmt, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, BM25_BIND_FILE);
}
if (label && label[0]) {
sqlite3_bind_text(stmt, BM25_BIND_LABEL, label, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, BM25_BIND_LABEL);
}
sqlite3_bind_text(stmt, BM25_BIND_EXACT, query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
if (kind && kind[0]) {
sqlite3_bind_text(stmt, BM25_BIND_KIND, kind, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, BM25_BIND_KIND);
}
if (ns && ns[0]) {
sqlite3_bind_text(stmt, BM25_BIND_NS, ns, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, BM25_BIND_NS);
}
sqlite3_bind_double(stmt, BM25_BIND_LABEL_WEIGHT, label_weight);
/* Count hits within the same inner-limit window — capped at BM25_INNER_LIMIT.
* Uses the identical subquery structure so the FTS5 early-exit applies here too. */
int total = 0;
{
const char *count_sql =
"SELECT COUNT(*) FROM ("
" SELECT fts.rowid FROM ("
" SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1"
" ORDER BY " BM25_WEIGHTS " LIMIT ?3"
" ) fts "
" JOIN nodes n ON n.id = fts.rowid "
" WHERE n.project = ?2 "
/* MIRRORS the ranked query's filter verbatim — same weights, same
* label exclusions. Changing one alone reports a total that does
* not describe the rows returned. */
" AND " BM25_UNLABELLED_EXCLUSION " "
" AND (?6 IS NULL OR n.file_path LIKE ?6)"
" AND (?7 IS NULL OR n.label = ?7)"
" AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) "
" THEN json_extract(n.properties,'$.kind') END) = ?9)"
" AND (?10 IS NULL OR (CASE WHEN json_valid(n.properties) "
" THEN json_extract(n.properties,'$.obj_key') END) "
" LIKE ('%/%/' || replace(replace(replace(?10,'\\','\\\\'),'%','\\%'),"
" '_','\\_') || '/%') ESCAPE '\\')"
")";
sqlite3_stmt *cs = NULL;
if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) == SQLITE_OK) {
sqlite3_bind_text(cs, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
sqlite3_bind_text(cs, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
sqlite3_bind_int(cs, BM25_BIND_LIMIT, BM25_INNER_LIMIT);
sqlite3_bind_double(cs, BM25_BIND_LABEL_WEIGHT, label_weight);
if (file_like) {
sqlite3_bind_text(cs, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(cs, BM25_BIND_FILE);
}
if (label && label[0]) {
sqlite3_bind_text(cs, BM25_BIND_LABEL, label, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(cs, BM25_BIND_LABEL);
}
if (kind && kind[0]) {
sqlite3_bind_text(cs, BM25_BIND_KIND, kind, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(cs, BM25_BIND_KIND);
}
if (ns && ns[0]) {
sqlite3_bind_text(cs, BM25_BIND_NS, ns, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(cs, BM25_BIND_NS);
}
if (sqlite3_step(cs) == SQLITE_ROW) {
total = sqlite3_column_int(cs, 0);
}
sqlite3_finalize(cs);
}
}
/* The top-candidate window is a performance ceiling, not an exact-total
* boundary. Probe one candidate beyond it so a broad query never presents
* a window-local count as the complete match count. The probe shares the
* label-scoped MATCH but is otherwise global to the FTS table; saturation
* is therefore conservatively reported even when later project/path
* filters might discard the hidden candidates. */
bool candidate_window_saturated = true;
{
const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 "
"ORDER BY bm25(nodes_fts), rowid LIMIT 1 OFFSET ?2";
sqlite3_stmt *probe = NULL;
if (sqlite3_prepare_v2(db, probe_sql, BM25_SQL_AUTO_LEN, &probe, NULL) == SQLITE_OK) {
sqlite3_bind_text(probe, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
sqlite3_bind_int(probe, BM25_BIND_PROJECT, BM25_INNER_LIMIT);
int probe_step = sqlite3_step(probe);
candidate_window_saturated = probe_step != SQLITE_DONE;
sqlite3_finalize(probe);
}
/* file_pattern, kind and namespace cannot be put into the MATCH the way
* the label is, so they filter AFTER the candidate window. When more
* candidates exist beyond it, a narrow filter can find none of its rows in
* the top BM25_INNER_LIMIT and the search answered 0 rows although
* matching nodes exist. A saturated window under such a filter -- or one
* that held no row passing the filters at all -- is therefore ranked over
* every candidate instead: the FTS-first subquery plan stays, the extra
* work is linear in the token's match count (the saturation probe already
* orders every match), and the total becomes exact. The decision depends
* only on the query, never on the page, so offset pages stay consistent.
* Unfiltered and label-only queries that find rows keep the window. */
bool saturated = bm25_window_saturated(db, fts_query);
bool post_window_filter = file_like || (kind && kind[0]) || (ns && ns[0]);
int inner_limit = saturated && post_window_filter ? BM25_INNER_ALL : BM25_INNER_LIMIT;
int total = bm25_count_candidates(db, &filters, inner_limit);
if (saturated && total == 0 && inner_limit != BM25_INNER_ALL) {
inner_limit = BM25_INNER_ALL;
total = bm25_count_candidates(db, &filters, inner_limit);
}
bool candidate_window_saturated = saturated && inner_limit != BM25_INNER_ALL;
sqlite3_bind_int(stmt, BM25_BIND_INNER, inner_limit);
int row_cap = limit > 0 ? limit : BM25_DEFAULT_LIMIT;
bm25_output_row_t *rows =
+22 -5
View File
@@ -577,6 +577,22 @@ cbm_svc_kind_t cbm_pipeline_external_import_service(cbm_external_import_index_t
return CBM_SVC_NONE;
}
/* The path a route registration's first argument names: the argument itself
* when it opens with a slash, or in Go the path of a ServeMux pattern that
* names a method or host first ("POST /v1/x", #2537; Handle / HandleFunc
* only). NULL when it names no path. MUST match pass_parallel.c
* find_route_path_in_args. */
static const char *route_reg_path(const CBMCall *call, CBMLanguage lang) {
const char *arg = call->first_string_arg;
if (!arg) {
return NULL;
}
if (arg[0] == '/') {
return arg;
}
return cbm_pipeline_mux_route_path(call, lang);
}
/* Handle a route registration call: create Route node + HANDLES edge.
* route_mount is the framework mount of the registering file ("/api" for a
* Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */
@@ -584,10 +600,11 @@ static bool handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca
const cbm_gbuf_node_t *source_node, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count,
const char *route_mount, CBMLanguage lang) {
const char *method = cbm_service_pattern_route_method(call->callee_name);
char mux_method[CBM_SZ_16];
const char *method = cbm_pipeline_route_reg_method(call, lang, mux_method, sizeof(mux_method));
char mounted[CBM_SZ_256];
const char *path =
cbm_laravel_mount_route(route_mount, call->first_string_arg, mounted, sizeof(mounted));
cbm_laravel_mount_route(route_mount, route_reg_path(call, lang), mounted, sizeof(mounted));
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY",
@@ -824,7 +841,7 @@ static bool emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
bool suppress_plain_calls, const char *route_mount,
CBMLanguage lang) {
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') {
if (svc == CBM_SVC_ROUTE_REG && route_reg_path(call, lang) != NULL) {
return handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals,
imp_count, route_mount, lang);
}
@@ -924,8 +941,8 @@ static bool calls_emit_route_call(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const char **imp_keys, const char **imp_vals, int imp_count,
const char *route_mount, CBMLanguage lang,
CBMCallEvidence *evidence) {
if (cbm_service_pattern_route_method(call->callee_name) == NULL || !call->first_string_arg ||
call->first_string_arg[0] != '/') {
if (cbm_service_pattern_route_method(call->callee_name) == NULL ||
route_reg_path(call, lang) == NULL) {
return false;
}
evidence->calls_emitted |= handle_route_registration(
+14 -12
View File
@@ -2082,14 +2082,15 @@ static CBMRustLSPDef *pxc_lspdefs_to_rust(CBMArena *arena, const CBMLSPDef *defs
void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int source_len,
const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names,
const char **imp_qns, int imp_count) {
cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, defs, def_count,
cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, NULL, defs, def_count,
imp_names, imp_qns, imp_count, NULL);
}
void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source,
int source_len, const char *module_qn, CBMLSPDef *defs,
int def_count, const char **imp_names, const char **imp_qns,
int imp_count, const CBMPHPImportBindings *bindings) {
int source_len, const char *module_qn, const char *rel_path,
CBMLSPDef *defs, int def_count, const char **imp_names,
const char **imp_qns, int imp_count,
const CBMPHPImportBindings *bindings) {
TSTree *tree = r->cached_tree; /* may be NULL — LSP re-parses then */
CBMArena scratch;
@@ -2101,8 +2102,8 @@ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const
switch (lang) {
case CBM_LANG_GO:
cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, defs, def_count, imp_names,
imp_qns, imp_count, tree, &out);
cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, rel_path, defs, def_count,
imp_names, imp_qns, imp_count, tree, &out);
break;
case CBM_LANG_C:
case CBM_LANG_CPP:
@@ -2296,9 +2297,9 @@ void cbm_pxc_dispatch_file_with_php_bindings(
* lsp_unresolved entries -- no parse, no AST walk -- then the
* AST walk against the overlay (chained to the sealed base). */
cbm_go_fast_resolve_qualified_calls(result, prebuilt, imp_keys, imp_vals, imp_count);
cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, &overlay,
imp_keys, imp_vals, imp_count, result->cached_tree,
&out);
cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, rel,
&overlay, imp_keys, imp_vals, imp_count,
result->cached_tree, &out);
used_prebuilt = true;
break;
case CBM_LANG_PYTHON: {
@@ -2432,7 +2433,7 @@ void cbm_pxc_dispatch_file_with_php_bindings(
pxc_append_synthetic_calls(&result->arena, &result->calls, &synthetic_calls);
pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch);
} else {
cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module,
cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel,
file_defs, file_def_count, imp_keys, imp_vals,
imp_count, bindings);
}
@@ -2444,8 +2445,9 @@ void cbm_pxc_dispatch_file_with_php_bindings(
cbm_pxc_run_one_ts(result, source, source_len, def_module, file_defs, file_def_count,
imp_keys, imp_vals, imp_count, js, jsx, dts);
} else {
cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, file_defs,
file_def_count, imp_keys, imp_vals, imp_count, bindings);
cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel,
file_defs, file_def_count, imp_keys, imp_vals, imp_count,
bindings);
}
free(filtered);
}
+7 -3
View File
@@ -216,10 +216,14 @@ void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int
const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names,
const char **imp_qns, int imp_count);
/* rel_path: the file's repo-relative path. Go needs it to name the file's
* init() functions as the def extractor does (#1910); cbm_pxc_run_one passes
* NULL (Go init() callers then stay flat). */
void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source,
int source_len, const char *module_qn, CBMLSPDef *defs,
int def_count, const char **imp_names, const char **imp_qns,
int imp_count, const CBMPHPImportBindings *bindings);
int source_len, const char *module_qn, const char *rel_path,
CBMLSPDef *defs, int def_count, const char **imp_names,
const char **imp_qns, int imp_count,
const CBMPHPImportBindings *bindings);
/* TS / JS / JSX / TSX variant with explicit dialect flags. */
void cbm_pxc_run_one_ts(CBMFileResult *r, const char *source, int source_len, const char *module_qn,
+34 -4
View File
@@ -2272,13 +2272,42 @@ static bool is_route_path_shaped(const char *val) {
return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val);
}
static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler) {
const char *cbm_pipeline_mux_route_path(const CBMCall *call, CBMLanguage lang) {
const char *arg = call->first_string_arg;
if (lang != CBM_LANG_GO || !arg || cbm_service_pattern_is_comment_text(arg)) {
return NULL;
}
return cbm_service_pattern_mux_route_path(call->callee_name, arg);
}
const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, char *buf,
size_t buf_sz) {
const char *method = cbm_service_pattern_route_method(call->callee_name);
if (method && strcmp(method, "ANY") != 0) {
return method;
}
if (cbm_pipeline_mux_route_path(call, lang) &&
cbm_service_pattern_mux_split(call->first_string_arg, buf, buf_sz) && buf[0] != '\0') {
return buf;
}
return method;
}
static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler,
CBMLanguage lang) {
*out_handler = NULL;
/* 1. First string arg starting with / */
if (is_route_path_shaped(call->first_string_arg)) {
*out_handler = call->second_arg_name;
return call->first_string_arg;
}
/* 1b. Go ServeMux pattern with a method or host before the path (#2537),
* read for Handle / HandleFunc only. MUST match pass_calls.c route_reg_path. */
const char *mux_path = cbm_pipeline_mux_route_path(call, lang);
if (mux_path) {
*out_handler = call->second_arg_name;
return mux_path;
}
/* 2. Keyword args (prefix=, path=, route=, etc.) */
const char *found = NULL;
for (int ai = 0; ai < call->arg_count && !found; ai++) {
@@ -2438,7 +2467,8 @@ static bool emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
const char **ik, const char **iv, int ic,
const char *route_mount, CBMLanguage lang) {
const char *method = cbm_service_pattern_route_method(call->callee_name);
char mux_method[CBM_SZ_16];
const char *method = cbm_pipeline_route_reg_method(call, lang, mux_method, sizeof(mux_method));
char mounted[CBM_SZ_256];
route_path = cbm_laravel_mount_route(route_mount, route_path, mounted, sizeof(mounted));
char rqn[CBM_ROUTE_QN_SIZE];
@@ -2824,7 +2854,7 @@ static bool emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
if (svc == CBM_SVC_ROUTE_REG) {
const char *handler_ref = NULL;
const char *route_path = find_route_path_in_args(call, &handler_ref);
const char *route_path = find_route_path_in_args(call, &handler_ref, lang);
if (route_path) {
return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
registry, main_gbuf, imp_keys, imp_vals, imp_count,
@@ -2888,7 +2918,7 @@ static bool emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *so
return false;
}
const char *handler_ref = NULL;
const char *route_path = find_route_path_in_args(call, &handler_ref);
const char *route_path = find_route_path_in_args(call, &handler_ref, lang);
if (route_path) {
return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
registry, main_gbuf, imp_keys, imp_vals, imp_count,
+16
View File
@@ -797,6 +797,22 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje
* behind both passes' HTTP_CALLS / ASYNC_CALLS props (pass_parallel.c). */
void cbm_pipeline_close_call_props(char *props, int n, const CBMCall *call, bool with_line);
/* Go 1.22 ServeMux registrations may name a method and a host before the path
* ("POST /v1/x", "GET api.example.com/v1/x", #2537). For a Go `Handle` /
* `HandleFunc` call, the path inside its first argument; NULL for any other
* language or callee (`store.Get("users/123")`), for a URL argument, and for an
* argument that is no such pattern (a plain "/v1/x" included). The one reader
* of that grammar behind both passes (pass_parallel.c). */
const char *cbm_pipeline_mux_route_path(const CBMCall *call, CBMLanguage lang);
/* The method of a route registration's Route: the verb its callee names
* (`router.GET`), else the method its Go ServeMux pattern names
* (`mux.HandleFunc("POST /v1/x", h)`, #2537), else the callee's own answer
* (ANY or NULL). `buf` (CBM_SZ_16) holds a pattern method. Shared by both
* passes so their Routes stay byte-identical (pass_parallel.c). */
const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, char *buf,
size_t buf_sz);
/* Per-file index of the names a file's imports bind onto EXTERNAL modules:
* local name -> the binding import. Built lazily, on the first call that
* asks, from the file's extraction imports and its IMPORTS edges; one hash
+2 -2
View File
@@ -16000,8 +16000,8 @@ TEST(seal_go_shared_registry_readonly) {
const char *src = "package main\nfunc helper(x int) int { return x + 1 }\n"
"func caller() int { return helper(1) }\n";
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", reg, NULL, NULL,
0, NULL, &out);
cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", NULL, reg, NULL,
NULL, 0, NULL, &out);
ASSERT_EQ(reg->func_count, fb);
ASSERT_EQ(reg->type_count, tb);
cbm_arena_destroy(&arena);
+60
View File
@@ -18673,6 +18673,65 @@ TEST(cli_external_manager_detection_needs_positive_evidence_issue1566) {
PASS();
}
/* #2722: when a binary is managed externally (e.g. mise, brew, nix), `install`
* skips placing a binary at ~/.local/bin, but must write the running managed
* binary path into agent configuration files instead of the non-existent
* default target. */
TEST(cli_install_managed_binary_config_path_issue2722) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-install-managed-XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
FAIL("cbm_mkdtemp failed");
}
char *old_home = NULL;
char *old_cache = NULL;
cli_activation_save_env(&old_home, &old_cache);
cbm_setenv("HOME", tmpdir, 1);
char cache_dir[512];
snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir);
cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
test_mark_claude_code_installed(tmpdir);
const char *managed_path =
"/Users/x/.local/share/mise/installs/cbm/0.12.1/bin/codebase-memory-mcp";
cbm_cli_set_self_path_override_for_test(managed_path);
cli_activation_fake_t fake = {
.mutation_reserve_result = 1,
};
cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake);
cbm_cli_set_activation_ops_for_test(&ops);
char *argv[] = {"--clients=claude"};
int rc = cli_test_cmd_install(1, argv);
cbm_cli_set_activation_ops_for_test(NULL);
cbm_cli_set_self_path_override_for_test(NULL);
char bin_target[512];
snprintf(bin_target, sizeof(bin_target), "%s/.local/bin/codebase-memory-mcp", tmpdir);
struct stat target_st;
bool target_created = stat(bin_target, &target_st) == 0;
char claude_json_path[512];
snprintf(claude_json_path, sizeof(claude_json_path), "%s/.claude.json", tmpdir);
char *claude_json = read_test_file_alloc(claude_json_path);
bool has_managed_path = claude_json && strstr(claude_json, managed_path) != NULL;
bool has_target_path =
claude_json && strstr(claude_json, ".local/bin/codebase-memory-mcp") != NULL;
free(claude_json);
cli_activation_restore_env(old_home, old_cache);
test_rmdir_r(tmpdir);
ASSERT_EQ(rc, 0);
ASSERT_FALSE(target_created);
ASSERT_TRUE(has_managed_path);
ASSERT_FALSE(has_target_path);
PASS();
}
/* #1558: `install` configured EVERY detected client, so a user who wanted
* Claude and Codex had to revert OpenCode and Cursor by hand — and the next
* install silently recreated them. The selector restricts it.
@@ -19090,6 +19149,7 @@ SUITE(cli) {
RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558);
RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554);
RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566);
RUN_TEST(cli_install_managed_binary_config_path_issue2722);
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);
+366
View File
@@ -2822,6 +2822,122 @@ TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) {
PASS();
}
/* Under `daemon start` a session that ENDS leaves its watch to the daemon, but
* a workspace-policy release is not a disconnect: once the session's root is
* no longer allowed (here its approval is revoked), the next request's
* refresh must still end the watch in a permanent generation too. */
TEST(daemon_application_permanent_workspace_release_still_ends_watch) {
app_env_backup_t cache_environment;
app_env_backup_t home_environment;
bool environments_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR") &&
app_env_backup_capture(&home_environment, "HOME");
char sensitive_raw[APP_TEST_PATH_CAP];
char cache_raw[APP_TEST_PATH_CAP];
(void)snprintf(sensitive_raw, sizeof(sensitive_raw),
"%s/cbm-app-permanent-release-home-XXXXXX", cbm_tmpdir());
(void)snprintf(cache_raw, sizeof(cache_raw), "%s/cbm-app-permanent-release-cache-XXXXXX",
cbm_tmpdir());
bool dirs_ok = cbm_mkdtemp(sensitive_raw) != NULL && cbm_mkdtemp(cache_raw) != NULL;
char sensitive[APP_TEST_PATH_CAP] = {0};
char cache[APP_TEST_PATH_CAP] = {0};
bool canonical = dirs_ok && cbm_canonical_path(sensitive_raw, sensitive, sizeof(sensitive)) &&
cbm_canonical_path(cache_raw, cache, sizeof(cache));
bool environments_set = environments_saved && canonical &&
cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 &&
cbm_setenv("HOME", sensitive, 1) == 0;
cbm_config_t *stored_config = environments_set ? cbm_config_open(cache) : NULL;
bool config_ready = stored_config &&
cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "false") == 0 &&
cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "true") == 0;
char *project = canonical ? cbm_project_name_from_path(sensitive) : NULL;
char db_path[APP_TEST_PATH_CAP] = {0};
if (project) {
(void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
}
bool db_ready = config_ready && project && app_test_create_empty_file(db_path);
/* The root is $HOME, so only an explicit sensitive approval allows it. */
char grant_error[APP_TEST_PATH_CAP];
bool approved = db_ready && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false,
grant_error, sizeof(grant_error));
char grant_path[APP_TEST_PATH_CAP] = {0};
bool grant_path_ok =
approved && cbm_workspace_grant_path(cache, grant_path, sizeof(grant_path));
cbm_store_t *store = cbm_store_open_memory();
cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL);
cbm_daemon_application_config_t config = {
.watcher = watcher,
.config = stored_config,
};
cbm_daemon_application_t *application =
grant_path_ok && watcher ? cbm_daemon_application_new(&config) : NULL;
if (application) {
cbm_daemon_application_set_permanent(application, true);
}
cbm_daemon_runtime_application_callbacks_t callbacks =
cbm_daemon_application_runtime_callbacks(application);
cbm_daemon_runtime_application_session_t *session =
application ? app_test_open(&callbacks, 4036) : NULL;
bool initialized = app_test_initialize_profile(&callbacks, session, sensitive,
CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
int watch_while_approved = watcher ? cbm_watcher_watch_count(watcher) : -1;
/* Revoke the approval. Every request ends with a synchronous watch
* refresh, so one ping is the point where the policy is re-read. */
bool revoked = initialized && cbm_unlink(grant_path) == 0;
uint8_t *ping = NULL;
uint32_t ping_length = 0;
uint8_t *response = NULL;
uint32_t response_length = 0;
bool pinged = revoked &&
app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP,
"{\"jsonrpc\":\"2.0\",\"id\":4136,\"method\":\"ping\"}",
&ping, &ping_length) &&
app_test_request(&callbacks, session, ping, ping_length, &response,
&response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
free(response);
free(ping);
int watch_after_revoke = watcher ? cbm_watcher_watch_count(watcher) : -1;
if (session) {
callbacks.session_cancel(callbacks.context, 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 (watcher) {
cbm_watcher_stop(watcher);
cbm_watcher_free(watcher);
}
cbm_store_close(store);
cbm_config_close(stored_config);
free(project);
(void)th_rmtree(sensitive);
(void)th_rmtree(cache);
bool cache_restored = app_env_backup_restore(&cache_environment);
bool home_restored = app_env_backup_restore(&home_environment);
ASSERT_TRUE(environments_saved);
ASSERT_TRUE(dirs_ok);
ASSERT_TRUE(canonical);
ASSERT_TRUE(environments_set);
ASSERT_TRUE(config_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(approved);
ASSERT_TRUE(grant_path_ok);
ASSERT_TRUE(initialized);
ASSERT_EQ(watch_while_approved, 1);
ASSERT_TRUE(revoked);
ASSERT_TRUE(pinged);
/* A policy release, not a disconnect: the permanent watch ends as well. */
ASSERT_EQ(watch_after_revoke, 0);
ASSERT_TRUE(stopped);
ASSERT_TRUE(cache_restored);
ASSERT_TRUE(home_restored);
PASS();
}
TEST(daemon_application_programmatic_index_injects_resource_policy) {
char *root = th_mktempdir("cbm_app_policy_root");
char *cache = th_mktempdir("cbm_app_policy_cache");
@@ -4588,6 +4704,252 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) {
PASS();
}
TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init(&fixture, 47);
if (fixture_ready) {
cbm_daemon_application_set_permanent(fixture.application, true);
}
int watch_before_disconnect =
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (fixture.session) {
fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session);
}
int watch_after_cancel =
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (fixture.session) {
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int watch_after_close =
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
/*
* No client session remains at this point. A permanent daemon-owned
* physical watch must nevertheless be allowed to launch its background
* indexing job.
*/
app_watcher_index_thread_t request = {
.application = fixture.application,
.project = fixture.project,
.root = fixture.root,
.pause_before_subscribe = false,
.result = -1,
};
atomic_init(&request.ready, false);
atomic_init(&request.proceed, true);
atomic_init(&request.done, false);
cbm_thread_t thread;
bool thread_started =
fixture_ready &&
watch_after_close == 1 &&
cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0;
bool worker_started =
thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1);
int watch_during_job =
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
/*
* The fake worker deliberately waits until the test releases it.
* Let the daemon-owned watcher job finish normally.
*/
if (worker_started) {
atomic_store(&fixture.fake.allow_completion, true);
}
bool request_done =
thread_started && app_wait_for_atomic_bool(&request.done, true);
bool thread_joined =
request_done && cbm_thread_join(&thread) == 0;
int watch_after_job =
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
int destroys_before_cleanup =
fixture_ready ? atomic_load(&fixture.fake.destroys) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
ASSERT_TRUE(fixture_ready);
ASSERT_EQ(watch_before_disconnect, 1);
/* Permanent mode: disconnect must not unwatch the project. */
ASSERT_EQ(watch_after_cancel, 1);
ASSERT_EQ(watch_after_close, 1);
/* No session exists, yet the watcher still owns background indexing. */
ASSERT_TRUE(thread_started);
ASSERT_TRUE(worker_started);
ASSERT_EQ(watch_during_job, 1);
ASSERT_TRUE(request_done);
ASSERT_TRUE(thread_joined);
ASSERT_EQ(request.result, 0);
ASSERT_EQ(destroys_before_cleanup, 1);
/* Completing the background job must not consume the persistent watch. */
ASSERT_EQ(watch_after_job, 1);
ASSERT_TRUE(cleaned);
PASS();
}
/* A permanent daemon keeps a project watched after its last session closes.
* delete_project from another session or the CLI removes the project's DB but
* owns no watch, so the watch itself must notice: a later change must end the
* orphaned watch, never re-create the deleted index. */
TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init(&fixture, 48);
if (fixture_ready) {
cbm_daemon_application_set_permanent(fixture.application, true);
/* Should a job be admitted, it completes at once: the verdict is
* read from the start counter, never from a timeout. */
atomic_store(&fixture.fake.allow_completion, true);
}
if (fixture.session) {
fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session);
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool db_removed = fixture_ready && cbm_unlink(fixture.db_path) == 0;
int result = db_removed ? cbm_daemon_application_watcher_index(fixture.project, fixture.root,
fixture.application)
: -2;
int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1;
int watch_after_index = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
ASSERT_TRUE(fixture_ready);
ASSERT_EQ(watch_after_close, 1);
ASSERT_TRUE(db_removed);
/* A harmless skip: nothing admitted, the deleted index stays deleted. */
ASSERT_EQ(result, 1);
ASSERT_EQ(starts, 0);
/* The orphaned physical watch is torn down. */
ASSERT_EQ(watch_after_index, 0);
ASSERT_TRUE(cleaned);
PASS();
}
/* The second watcher callback either joins the running job (its retained
* ownership is the job's second subscriber) or returns. Both states latch, so
* whichever happens decides the wait; the deadline only catches "neither". */
static bool app_wait_for_watcher_join_or_return(cbm_daemon_application_t *application,
const char *project, atomic_bool *returned) {
uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS;
while (cbm_now_ms() < deadline) {
if (cbm_daemon_application_job_subscribers(application, project) == 2 ||
atomic_load(returned)) {
return true;
}
cbm_usleep(1000);
}
return false;
}
/* A full reindex unlinks the project's DB while it rebuilds it, so a missing
* DB alone does not mean the project was deleted. While the permanent watch's
* own job runs, a second change in its root must join that job and keep the
* watch; once the job ends, both callbacks hand their ownership back. */
TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init(&fixture, 49);
if (fixture_ready) {
cbm_daemon_application_set_permanent(fixture.application, true);
}
if (fixture.session) {
fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session);
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
app_watcher_index_thread_t first = {
.application = fixture.application,
.project = fixture.project,
.root = fixture.root,
.pause_before_subscribe = false,
.result = -1,
};
atomic_init(&first.ready, false);
atomic_init(&first.proceed, true);
atomic_init(&first.done, false);
app_watcher_index_thread_t second = {
.application = fixture.application,
.project = fixture.project,
.root = fixture.root,
.pause_before_subscribe = false,
.result = -1,
};
atomic_init(&second.ready, false);
atomic_init(&second.proceed, true);
atomic_init(&second.done, false);
/* The fake worker keeps the first job running until allow_completion. */
cbm_thread_t first_thread;
bool first_started = fixture_ready && watch_after_close == 1 &&
cbm_thread_create(&first_thread, 0, app_watcher_index_thread, &first) == 0;
bool job_running = first_started && app_wait_for_atomic_int(&fixture.fake.starts, 1);
bool db_removed = job_running && cbm_unlink(fixture.db_path) == 0;
cbm_thread_t second_thread;
bool second_started =
db_removed && cbm_thread_create(&second_thread, 0, app_watcher_index_thread, &second) == 0;
bool settled = second_started && app_wait_for_watcher_join_or_return(
fixture.application, fixture.project, &second.done);
size_t subscribers_while_running =
fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project)
: 0;
bool second_returned_while_running = atomic_load(&second.done);
int watch_while_running = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (fixture_ready) {
atomic_store(&fixture.fake.allow_completion, true);
}
bool first_joined = first_started && app_wait_for_atomic_bool(&first.done, true) &&
cbm_thread_join(&first_thread) == 0;
bool second_joined = second_started && app_wait_for_atomic_bool(&second.done, true) &&
cbm_thread_join(&second_thread) == 0;
int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1;
size_t subscribers_after =
fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project)
: SIZE_MAX;
int watch_after = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
ASSERT_TRUE(fixture_ready);
ASSERT_EQ(watch_after_close, 1);
ASSERT_TRUE(job_running);
ASSERT_TRUE(db_removed);
ASSERT_TRUE(settled);
/* The second change joined the running job instead of ending the watch. */
ASSERT_EQ(subscribers_while_running, 2);
ASSERT_FALSE(second_returned_while_running);
ASSERT_EQ(watch_while_running, 1);
ASSERT_TRUE(first_joined);
ASSERT_TRUE(second_joined);
ASSERT_EQ(first.result, 0);
ASSERT_EQ(second.result, 0);
ASSERT_EQ(starts, 1);
/* Each callback released the ownership it retained at admission. */
ASSERT_EQ(subscribers_after, 0);
ASSERT_EQ(watch_after, 1);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_late_watcher_session_owns_active_watcher_job) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init(&fixture, 45);
@@ -7660,6 +8022,7 @@ SUITE(daemon_application) {
RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions);
RUN_TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls);
RUN_TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls);
RUN_TEST(daemon_application_permanent_workspace_release_still_ends_watch);
RUN_TEST(daemon_application_ui_index_accepts_non_ascii_directory);
RUN_TEST(daemon_application_index_args_compare_repo_path_separator_equivalently);
RUN_TEST(daemon_application_async_index_returns_before_completion_and_status_polls);
@@ -7693,6 +8056,9 @@ SUITE(daemon_application) {
RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission);
RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job);
RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners);
RUN_TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session);
RUN_TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted);
RUN_TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs);
RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job);
RUN_TEST(daemon_application_watch_roots_match_windows_separators_only);
RUN_TEST(daemon_application_async_completion_disables_all_watches);
+98
View File
@@ -6431,6 +6431,40 @@ TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249) {
PASS();
}
/* Issue #2537: a Go 1.22 ServeMux pattern may open with a method and/or a host
* ("POST /v1/payments", "GET api.example.com/v1/x"). The written pattern stays
* the first string argument, and its handler is captured exactly as for a
* plain "/v1/payments"; it was dropped because the pattern does not open with
* a slash. */
TEST(extract_go_servemux_method_pattern_handler_issue2537) {
CBMFileResult *r = extract("package main\n"
"import \"net/http\"\n"
"func setup(mux *http.ServeMux) {\n"
" mux.HandleFunc(\"POST /v1/payments\", createPayment)\n"
" mux.Handle(\"GET api.example.com/v1/x\", hostHandler)\n"
"}\n",
CBM_LANG_GO, "t", "routes.go");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
const CBMCall *method = find_call_by_callee(r, "mux.HandleFunc");
ASSERT_NOT_NULL(method);
ASSERT_NOT_NULL(method->first_string_arg);
ASSERT_STR_EQ(method->first_string_arg, "POST /v1/payments");
ASSERT_NOT_NULL(method->second_arg_name);
ASSERT_STR_EQ(method->second_arg_name, "createPayment");
const CBMCall *host = find_call_by_callee(r, "mux.Handle");
ASSERT_NOT_NULL(host);
ASSERT_NOT_NULL(host->first_string_arg);
ASSERT_STR_EQ(host->first_string_arg, "GET api.example.com/v1/x");
ASSERT_NOT_NULL(host->second_arg_name);
ASSERT_STR_EQ(host->second_arg_name, "hostHandler");
cbm_free_result(r);
PASS();
}
/* Issue #2291: the JS/TS twin of #1249. A BFF that builds its backend URL as
* `${process.env.BACKEND_URL}/wallet/topup` from a configurable base must
* index the literal path after the base, exactly as `BACKEND_URL + "/x"`
@@ -8143,6 +8177,68 @@ TEST(extract_templ_method_qn_matches_call_scope) {
PASS();
}
TEST(extract_go_multiple_init_disambiguated) {
/* Go allows several init() per package (even per file); each gets a
* file+ordinal-suffixed QN (#495 cfg-twin pattern) so none is lost to the
* same-QN upsert. The ordinal — unlike a line number — must not move when
* code is inserted above the function: the QN is node identity, and a
* line-based suffix would churn nodes on every unrelated edit. */
CBMFileResult *r = extract("package m\n"
"func init() { a() }\n"
"func init() { b() }\n"
"func a() {}\n"
"func b() {}\n",
CBM_LANG_GO, "t", "x.go");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
const char *first = NULL;
const char *second = NULL;
for (int i = 0; i < r->defs.count; i++) {
const CBMDefinition *d = &r->defs.items[i];
if (!d->name || strcmp(d->name, "init") != 0) {
continue;
}
if (!first) {
first = d->qualified_name;
} else {
second = d->qualified_name;
}
}
ASSERT_NOT_NULL(first);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(first, "t.init#x.go:1");
ASSERT_STR_EQ(second, "t.init#x.go:2");
/* Stability: an insertion ABOVE both inits must not change either QN. */
CBMFileResult *r2 = extract("package m\n"
"import \"fmt\"\n"
"var shifted = fmt.Sprint(\"pad\")\n"
"func init() { a() }\n"
"func init() { b() }\n"
"func a() {}\n"
"func b() {}\n",
CBM_LANG_GO, "t", "x.go");
ASSERT_NOT_NULL(r2);
ASSERT_FALSE(r2->has_error);
int seen = 0;
for (int i = 0; i < r2->defs.count; i++) {
const CBMDefinition *d = &r2->defs.items[i];
if (!d->name || strcmp(d->name, "init") != 0) {
continue;
}
seen++;
if (seen == 1) {
ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:1");
} else {
ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:2");
}
}
ASSERT_EQ(seen, 2);
cbm_free_result(r2);
cbm_free_result(r);
PASS();
}
/* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above).
* A member call x.foo() with a non-this/super receiver is flagged is_method so
* the resolver can suppress a weak short-name match (`re.test()` must not bind a
@@ -13536,6 +13632,7 @@ SUITE(extraction) {
RUN_TEST(extract_go_selector_call_flags_is_method);
RUN_TEST(extract_go_method_receiver_qualified_qn);
RUN_TEST(extract_templ_method_qn_matches_call_scope);
RUN_TEST(extract_go_multiple_init_disambiguated);
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);
@@ -13907,6 +14004,7 @@ SUITE(extraction) {
RUN_TEST(extract_ts_config_object_url_issue2235);
RUN_TEST(extract_go_binary_concat_url_issue1249);
RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249);
RUN_TEST(extract_go_servemux_method_pattern_handler_issue2537);
RUN_TEST(extract_ts_template_base_url_issue2291);
RUN_TEST(extract_ts_template_base_url_no_literal_path_issue2291);
RUN_TEST(extract_py_concat_const_url_issue706);
+142 -11
View File
@@ -1063,7 +1063,7 @@ TEST(golsp_crossfile_method_dispatch) {
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names,
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names,
imp_qns, 1, NULL, &out);
ASSERT_GTE(find_resolved_arr(&out, "doQuery", "Connect"), 0);
@@ -1109,7 +1109,7 @@ TEST(golsp_crossfile_return_type_chain) {
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names,
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names,
imp_qns, 1, NULL, &out);
ASSERT_GTE(find_resolved_arr(&out, "showUser", "Name"), 0);
@@ -1149,7 +1149,7 @@ TEST(golsp_crossfile_interface_dispatch) {
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names,
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names,
imp_qns, 1, NULL, &out);
int idx = find_resolved_arr_confident(&out, "handler", "Bind");
@@ -1205,7 +1205,7 @@ TEST(golsp_crossfile_interface_field_chain) {
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 6, imp_names,
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 6, imp_names,
imp_qns, 1, NULL, &out);
int idx = find_resolved_arr_confident(&out, "process", "Bind");
@@ -1245,7 +1245,7 @@ TEST(golsp_crossfile_map_index) {
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 3, imp_names,
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 3, imp_names,
imp_qns, 1, NULL, &out);
int idx = find_resolved_arr_confident(&out, "dispatch", "ServeHTTP");
@@ -1275,7 +1275,7 @@ TEST(golsp_crossfile_stdlib_interface) {
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 1, imp_names,
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 1, imp_names,
imp_qns, 1, NULL, &out);
ASSERT_GTE(find_resolved_arr_confident(&out, "process", "Done"), 0);
@@ -1349,7 +1349,7 @@ TEST(golsp_crossfile_aliased_field_requal) {
CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, defs, 6);
ASSERT_NOT_NULL(reg);
cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg,
cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", NULL, reg,
imp_names, imp_qns, 2, NULL, &out);
int svc_idx = find_resolved_arr_confident(&out, "callSvc", "Svc.Ping");
@@ -1469,7 +1469,7 @@ TEST(golsp_crossfile_type_alias_method) {
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, go_crossfile_alias_source, (int)strlen(go_crossfile_alias_source),
"test.main", go_crossfile_alias_defs, n, NULL, NULL, 0, NULL, &out);
"test.main", NULL, go_crossfile_alias_defs, n, NULL, NULL, 0, NULL, &out);
if (go_crossfile_alias_check(&out) != 0) {
cbm_arena_destroy(&arena);
return 1;
@@ -1488,8 +1488,8 @@ TEST(golsp_crossfile_type_alias_method_prebuilt) {
CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, go_crossfile_alias_defs, n);
ASSERT_NOT_NULL(reg);
cbm_run_go_lsp_cross_with_registry(&arena, go_crossfile_alias_source,
(int)strlen(go_crossfile_alias_source), "test.main", reg,
NULL, NULL, 0, NULL, &out);
(int)strlen(go_crossfile_alias_source), "test.main", NULL,
reg, NULL, NULL, 0, NULL, &out);
if (go_crossfile_alias_check(&out) != 0) {
cbm_arena_destroy(&arena);
return 1;
@@ -1538,7 +1538,7 @@ TEST(golsp_crossfile_local_interface_single_impl) {
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 5, imp_names,
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 5, imp_names,
imp_qns, 1, NULL, &out);
int idxGet = find_resolved_arr_confident(&out, "process", "Get");
@@ -1554,6 +1554,133 @@ TEST(golsp_crossfile_local_interface_single_impl) {
PASS();
}
/* ── Go init(): per-file caller QN (#1910) ──────────────────────── */
/* Every init() of a package is its own node, pkg.init#<file>:<N>
* (cbm_go_init_qn). The LSP's enclosing-function QN must be exactly that QN:
* the pipeline joins LSP rows to textual calls by caller QN, so a flat
* pkg.init loses every LSP resolution inside an init body. Two inits in one
* file pin the ordinal; the method name exists on both types, so only the
* typed receiver tells the targets apart. */
TEST(golsp_init_caller_qn_matches_def) {
const char *source = "package main\n\n"
"type Codec struct{}\n\n"
"func (c *Codec) register() {}\n\n"
"type Store struct{}\n\n"
"func (s *Store) register() {}\n\n"
"func init() {\n\tc := &Codec{}\n\tc.register()\n}\n\n"
"func init() {\n\ts := &Store{}\n\ts.register()\n}\n";
CBMFileResult *r = extract_go(source);
ASSERT_NOT_NULL(r);
const char *init1 = NULL;
const char *init2 = NULL;
for (int i = 0; i < r->defs.count; i++) {
const CBMDefinition *d = &r->defs.items[i];
if (d->name && strcmp(d->name, "init") == 0) {
if (!init1) {
init1 = d->qualified_name;
} else {
init2 = d->qualified_name;
}
}
}
ASSERT_NOT_NULL(init1);
ASSERT_NOT_NULL(init2);
ASSERT_NOT_NULL(strstr(init1, ".init#main.go:1"));
ASSERT_NOT_NULL(strstr(init2, ".init#main.go:2"));
int a = require_resolved(r, "init", "Codec.register");
ASSERT_GTE(a, 0);
ASSERT_STR_EQ(r->resolved_calls.items[a].caller_qn, init1);
int b = require_resolved(r, "init", "Store.register");
ASSERT_GTE(b, 0);
ASSERT_STR_EQ(r->resolved_calls.items[b].caller_qn, init2);
/* The join itself: each textual register() call (raw callee text
* "c.register" / "s.register") finds its own init's row. */
int joined = 0;
for (int i = 0; i < r->calls.count; i++) {
const CBMCall *c = &r->calls.items[i];
if (!c->callee_name || !strstr(c->callee_name, ".register")) {
continue;
}
const CBMResolvedCall *rc = cbm_pipeline_find_lsp_resolution(&r->resolved_calls, c, false);
ASSERT_NOT_NULL(rc);
ASSERT_STR_EQ(rc->caller_qn, c->enclosing_func_qn);
ASSERT_NOT_NULL(
strstr(rc->callee_qn, strcmp(c->enclosing_func_qn, init1) == 0 ? "Codec" : "Store"));
joined++;
}
ASSERT_EQ(joined, 2);
cbm_free_result(r);
PASS();
}
/* Cross-file twin: both cross entry points (per-file defs, and the shared
* Tier-2 registry the pipeline uses) name the init caller from rel_path. */
TEST(golsp_crossfile_init_caller_qn_per_file) {
const char *source = "package reg\n\n"
"func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n"
"func init() {\n\ts := NewStore()\n\ts.Register()\n}\n";
CBMLSPDef defs[] = {
{.qualified_name = "test.reg.NewCodec",
.short_name = "NewCodec",
.label = "Function",
.def_module_qn = "test.reg",
.return_types = "*Codec"},
{.qualified_name = "test.reg.Codec",
.short_name = "Codec",
.label = "Class",
.def_module_qn = "test.reg"},
{.qualified_name = "test.reg.Codec.Register",
.short_name = "Register",
.label = "Method",
.def_module_qn = "test.reg",
.receiver_type = "test.reg.Codec"},
{.qualified_name = "test.reg.NewStore",
.short_name = "NewStore",
.label = "Function",
.def_module_qn = "test.reg",
.return_types = "*Store"},
{.qualified_name = "test.reg.Store",
.short_name = "Store",
.label = "Class",
.def_module_qn = "test.reg"},
{.qualified_name = "test.reg.Store.Register",
.short_name = "Register",
.label = "Method",
.def_module_qn = "test.reg",
.receiver_type = "test.reg.Store"},
};
CBMArena arena;
cbm_arena_init(&arena);
CBMResolvedCallArray out = {0};
cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.reg", "reg/b.go", defs, 6, NULL,
NULL, 0, NULL, &out);
int c1 = find_resolved_arr_confident(&out, "init", "Codec.Register");
ASSERT_GTE(c1, 0);
ASSERT_STR_EQ(out.items[c1].caller_qn, "test.reg.init#b.go:1");
int s1 = find_resolved_arr_confident(&out, "init", "Store.Register");
ASSERT_GTE(s1, 0);
ASSERT_STR_EQ(out.items[s1].caller_qn, "test.reg.init#b.go:2");
CBMResolvedCallArray shared_out = {0};
CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, defs, 6);
ASSERT_NOT_NULL(reg);
cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.reg", "reg/b.go",
reg, NULL, NULL, 0, NULL, &shared_out);
int c2 = find_resolved_arr_confident(&shared_out, "init", "Codec.Register");
ASSERT_GTE(c2, 0);
ASSERT_STR_EQ(shared_out.items[c2].caller_qn, "test.reg.init#b.go:1");
int s2 = find_resolved_arr_confident(&shared_out, "init", "Store.Register");
ASSERT_GTE(s2, 0);
ASSERT_STR_EQ(shared_out.items[s2].caller_qn, "test.reg.init#b.go:2");
cbm_arena_destroy(&arena);
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────── */
SUITE(go_lsp) {
@@ -1668,4 +1795,8 @@ SUITE(go_lsp) {
RUN_TEST(golsp_crossfile_map_index);
RUN_TEST(golsp_crossfile_stdlib_interface);
RUN_TEST(golsp_crossfile_local_interface_single_impl);
/* Go init(): per-file caller QN (#1910) */
RUN_TEST(golsp_init_caller_qn_matches_def);
RUN_TEST(golsp_crossfile_init_caller_qn_per_file);
}
+49
View File
@@ -809,12 +809,61 @@ TEST(infrascan_data_flows_test_handlers_pair_only_within_their_file) {
PASS();
}
/* Issue #2537: the Go 1.22 ServeMux grammar "[METHOD ][HOST]/[PATH]". The path
* is found inside the written pattern; a plain path, a lowercase word, a
* pattern without a path and a method too long for the buffer are no split.
* Only a Handle / HandleFunc registration is read with it. */
TEST(infrascan_servemux_pattern_split_issue2537) {
char m[16];
const char *pat = "POST /v1/payments";
ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 5);
ASSERT_STR_EQ(m, "POST");
pat = "GET \t /v1/payments/{id}";
ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 6);
ASSERT_STR_EQ(m, "GET");
pat = "GET api.example.com/v1/x";
ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 19);
ASSERT_STR_EQ(m, "GET");
pat = "api.example.com:8443/v1/x";
ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 20);
ASSERT_STR_EQ(m, "");
pat = "DELETE /";
ASSERT_TRUE(cbm_service_pattern_mux_split(pat, NULL, 0) == pat + 7);
ASSERT_NULL(cbm_service_pattern_mux_split("/v1/x", m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split("get /v1/x", m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split("POST", m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split("POST ", m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split("GET host", m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split("GET a b/x", m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split("", m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split(NULL, m, sizeof(m)));
ASSERT_NULL(cbm_service_pattern_mux_split("ABCDEFGHIJKLMNOP /x", m, sizeof(m)));
ASSERT_STR_EQ(m, "");
/* only Handle / HandleFunc register a pattern, and never with a URL */
pat = "POST /v1/payments";
ASSERT_TRUE(cbm_service_pattern_mux_route_path("mux.HandleFunc", pat) == pat + 5);
pat = "api.example.com/v1/x";
ASSERT_TRUE(cbm_service_pattern_mux_route_path("s.mux.Handle", pat) == pat + 15);
ASSERT_NULL(cbm_service_pattern_mux_route_path("st.Get", "users/123"));
ASSERT_NULL(cbm_service_pattern_mux_route_path("c.Get", "https://api.example.com/v1/x"));
ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.Handle", "https://api.example.com/v1/x"));
ASSERT_NULL(cbm_service_pattern_mux_route_path("PatchCollector.Delete", "storage.k8s.io/v1"));
ASSERT_NULL(cbm_service_pattern_mux_route_path("r.Route", "GET /v1/x"));
ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.HandleFunc", "/v1/x"));
ASSERT_NULL(cbm_service_pattern_mux_route_path(NULL, "GET /v1/x"));
ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.HandleFunc", NULL));
PASS();
}
SUITE(infrascan) {
RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths);
RUN_TEST(infrascan_http_route_literal_guard_rejects_document_extensions);
RUN_TEST(infrascan_client_navigation_is_js_family_only);
RUN_TEST(infrascan_service_pattern_match_rejects_ids_inside_words);
RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns);
RUN_TEST(infrascan_servemux_pattern_split_issue2537);
RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths);
RUN_TEST(infrascan_http_calls_join_matching_handler_route);
RUN_TEST(infrascan_decorator_route_metadata_keeps_its_escapes);
+152
View File
@@ -4787,6 +4787,156 @@ TEST(tool_search_graph_bm25_zero_hint_names_the_label) {
PASS();
}
/* file_pattern, kind and namespace filter after the BM25 candidate window
* (they cannot be put into the MATCH like the label). 2001 same-label nodes
* with a short path fill the window ahead of 3 that rank strictly lower (long
* path) and are the only ones passing each filter, so a windowed search
* answered 0 rows. A saturated window under such a filter is ranked over
* every candidate: all 3 rows, an exact total, consistent offset pages. */
static void bm25_post_window_fixture(cbm_store_t *store, const char *project) {
cbm_store_upsert_project(store, project, "/tmp/bm25-post-window");
for (int i = 0; i < 2004; i++) {
bool rare = i >= 2001;
char qn[64];
snprintf(qn, sizeof(qn), "bm25-post-window.%s_%04d", rare ? "rare" : "fn", i);
cbm_node_t node = {
.project = project,
.label = "Function",
.name = "postneedle",
.qualified_name = qn,
.file_path = rare ? "deeply/nested/package/layout/registry.c" : "many.c",
.start_line = i + 1,
.end_line = i + 1,
.properties_json =
rare ? "{\"kind\":\"Deployment\",\"obj_key\":\"apps/Deployment/prod/web\"}" : NULL};
cbm_store_upsert_node(store, &node);
}
cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');");
cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, "
"file_path) SELECT id, cbm_camel_split(name), qualified_name, "
"label, file_path FROM nodes;");
}
static char *bm25_post_window_search(cbm_mcp_server_t *srv, const char *filter_json, int limit,
int offset) {
char req[512];
snprintf(req, sizeof(req),
"{\"jsonrpc\":\"2.0\",\"id\":2424,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"bm25-post-window\",\"query\":\"postneedle\"%s,"
"\"limit\":%d,\"offset\":%d,\"format\":\"json\"}}}",
filter_json, limit, offset);
char *resp = cbm_mcp_server_handle(srv, req);
char *inner = resp ? extract_text_content(resp) : NULL;
free(resp);
return inner;
}
TEST(tool_search_graph_bm25_post_window_filters_rank_every_candidate) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
cbm_mcp_server_set_project(srv, "bm25-post-window");
bm25_post_window_fixture(store, "bm25-post-window");
static const char *const filters[] = {
",\"file_pattern\":\"deeply/nested\"",
",\"kind\":\"Deployment\"",
",\"namespace\":\"prod\"",
};
for (size_t i = 0; i < sizeof(filters) / sizeof(filters[0]); i++) {
char *inner = bm25_post_window_search(srv, filters[i], 10, 0);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\""));
ASSERT_NOT_NULL(strstr(inner, "\"bm25-post-window.rare_2001\""));
ASSERT_NOT_NULL(strstr(inner, "\"bm25-post-window.rare_2003\""));
ASSERT_NULL(strstr(inner, "\"bm25-post-window.fn_"));
ASSERT_NOT_NULL(strstr(inner, "\"total\":3,"));
ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\""));
ASSERT_NULL(strstr(inner, "candidate_window_saturated"));
free(inner);
}
/* Offset pages over the exhaustive ranking are disjoint and complete. */
char *page1 = bm25_post_window_search(srv, filters[0], 2, 0);
char *page2 = bm25_post_window_search(srv, filters[0], 2, 2);
ASSERT_NOT_NULL(page1);
ASSERT_NOT_NULL(page2);
ASSERT_NOT_NULL(strstr(page1, "\"returned\":2,"));
ASSERT_NOT_NULL(strstr(page2, "\"returned\":1,"));
ASSERT_NOT_NULL(strstr(page2, "\"total\":3,"));
for (int i = 2001; i < 2004; i++) {
char qn[64];
snprintf(qn, sizeof(qn), "\"bm25-post-window.rare_%04d\"", i);
ASSERT_TRUE((strstr(page1, qn) != NULL) != (strstr(page2, qn) != NULL));
}
free(page1);
free(page2);
/* Without a post-window filter the window stays and says so. */
char *inner = bm25_post_window_search(srv, "", 10, 0);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"total\":2000,"));
ASSERT_NOT_NULL(strstr(inner, "\"candidate_window_saturated\":true"));
free(inner);
cbm_mcp_server_free(srv);
PASS();
}
/* An unlabelled query whose whole candidate window is File/Folder/Variable/
* Project rows (excluded from unlabelled ranking) found no row at all, while
* a matching definition sat just beyond the window: 2001 short Folder rows
* outrank one Function with a long path. A window with no row passing the
* filters is ranked over every candidate instead. */
TEST(tool_search_graph_bm25_empty_window_ranks_every_candidate) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
const char *project = "bm25-empty-window";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/bm25-empty-window"), CBM_STORE_OK);
for (int i = 0; i < 2002; i++) {
bool def = i == 2001;
char qn[64];
snprintf(qn, sizeof(qn), "bm25-empty-window.%s_%04d", def ? "def" : "dir", i);
cbm_node_t node = {.project = project,
.label = def ? "Function" : "Folder",
.name = "foldneedle",
.qualified_name = qn,
.file_path = def ? "deeply/nested/package/layout/registry.c" : "d",
.start_line = i + 1,
.end_line = i + 1};
ASSERT_GT(cbm_store_upsert_node(store, &node), 0);
}
ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, "
"file_path) SELECT id, cbm_camel_split(name), qualified_name, "
"label, file_path FROM nodes;"),
CBM_STORE_OK);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":2425,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"bm25-empty-window\",\"query\":\"foldneedle\",\"limit\":10,"
"\"format\":\"json\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
free(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"bm25-empty-window.def_2001\",\"Function\""));
ASSERT_NULL(strstr(inner, "\"Folder\""));
ASSERT_NOT_NULL(strstr(inner, "\"total\":1,"));
ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\""));
free(inner);
cbm_mcp_server_free(srv);
PASS();
}
/* The definition whose NAME is the query ranks first: the `Table` class above
* its own methods and above test methods that repeat "table" three times; the
* `get_object_or_404` function above the test methods that contain it. */
@@ -26352,6 +26502,8 @@ SUITE(mcp) {
RUN_TEST(tool_search_graph_bm25_label_scope_keeps_rank_order);
RUN_TEST(tool_search_graph_bm25_serves_unranked_labels_when_named);
RUN_TEST(tool_search_graph_bm25_zero_hint_names_the_label);
RUN_TEST(tool_search_graph_bm25_post_window_filters_rank_every_candidate);
RUN_TEST(tool_search_graph_bm25_empty_window_ranks_every_candidate);
RUN_TEST(tool_search_graph_bm25_ranks_exact_name_first);
RUN_TEST(tool_search_graph_rejects_bm25_and_semantic_query_together);
RUN_TEST(tool_search_graph_semantic_ceiling_never_emits_unusable_continuation);
+1 -1
View File
@@ -5597,7 +5597,7 @@ TEST(parallel_php_external_row_replaces_only_its_exact_occurrence) {
const char *keys[] = {"LocalRequest"};
const char *vals[] = {project_type};
cbm_pxc_run_one_with_php_bindings(CBM_LANG_PHP, r, source, (int)strlen(source), "p.main",
defs, 2, keys, vals, 1, &bindings);
NULL, defs, 2, keys, vals, 1, &bindings);
int external_count = 0, project_count = 0;
for (int i = 0; i < r->resolved_calls.count; i++) {
const CBMResolvedCall *row = &r->resolved_calls.items[i];
+491
View File
@@ -11234,6 +11234,243 @@ TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge) {
PASS();
}
TEST(pipeline_go_multi_init_nodes_survive) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
/* Two files, one package, one init() each — Go runs both at start-up.
* Pre-fix both collapsed onto one QN and the upsert kept one node
* (reproduce-first: RED asserts node count == 2). */
write_temp_file(tmp, "go.mod", "module example.com/reg\n\ngo 1.22\n");
write_temp_file(tmp, "reg/a.go",
"package reg\n"
"\n"
"var handlers = map[string]func(){}\n"
"\n"
"func init() { handlers[\"a\"] = handleA }\n"
"\n"
"func handleA() {}\n");
write_temp_file(tmp, "reg/b.go",
"package reg\n"
"\n"
"func init() { handlers[\"b\"] = handleB }\n"
"\n"
"func handleB() {}\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/go_init.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);
/* Both init functions survive as distinct nodes. */
cbm_node_t *inits = NULL;
int ic = 0;
cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic);
ASSERT_EQ(ic, 2);
cbm_store_free_nodes(inits, ic);
cbm_store_close(s);
cbm_pipeline_free(p);
th_rmtree(tmp);
PASS();
}
/* Edges of `edge_type` whose source and target QNs end with the given
* suffixes (go_calls_between for any edge type, without the properties). */
static int go_edges_between(cbm_store_t *s, const char *project, const char *edge_type,
const char *caller_suffix, const char *callee_suffix) {
cbm_edge_t *edges = NULL;
int edge_count = 0;
cbm_store_find_edges_by_type(s, project, edge_type, &edges, &edge_count);
size_t cs = strlen(caller_suffix);
size_t ts = strlen(callee_suffix);
int hits = 0;
for (int i = 0; i < edge_count; i++) {
cbm_node_t src = {0};
cbm_node_t tgt = {0};
if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK &&
cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK &&
src.qualified_name && tgt.qualified_name) {
size_t sl = strlen(src.qualified_name);
size_t tl = strlen(tgt.qualified_name);
if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 &&
strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0) {
hits++;
}
}
cbm_node_free_fields(&src);
cbm_node_free_fields(&tgt);
}
if (edges) {
cbm_store_free_edges(edges, edge_count);
}
return hits;
}
/* Fixture for the #1910 Go init() LSP-join probes (sequential and parallel
* twins). Three init() in two files of one package -- two in b.go, so the
* per-file ordinal is pinned too -- each calling a method that only the Go
* LSP can bind: the receiver's type comes from a constructor in another
* file, and the method name also exists on a second package type and on
* other.Decoy, so no short-name fallback lands on the right target. a.go's
* init also passes a handler by value (add("a", handleA): a CALL_REFERENCE,
* which exists only as an LSP row). pad_files > 0 adds filler Go files past
* MIN_FILES_FOR_PARALLEL. */
static void write_go_init_lsp_fixture(const char *tmp, int pad_files) {
write_temp_file(tmp, "go.mod", "module example.com/fxinit\n\ngo 1.22\n");
write_temp_file(tmp, "reg/codec.go",
"package reg\n"
"\n"
"type Codec struct{ n int }\n"
"\n"
"func NewCodec() *Codec { return &Codec{} }\n"
"\n"
"func (c *Codec) register() { c.n++ }\n");
write_temp_file(tmp, "reg/store.go",
"package reg\n"
"\n"
"type Store struct{ open bool }\n"
"\n"
"func NewStore() *Store { return &Store{} }\n"
"\n"
"func (s *Store) register() { s.open = true }\n"
"\n"
"func (s *Store) warm() {}\n");
write_temp_file(tmp, "reg/a.go",
"package reg\n"
"\n"
"var handlers = map[string]func(){}\n"
"\n"
"func handleA() {}\n"
"\n"
"func add(name string, fn func()) { handlers[name] = fn }\n"
"\n"
"func init() {\n"
"\tc := NewCodec()\n"
"\tc.register()\n"
"\tadd(\"a\", handleA)\n"
"}\n");
write_temp_file(tmp, "reg/b.go",
"package reg\n"
"\n"
"func init() {\n"
"\ts := NewStore()\n"
"\ts.register()\n"
"}\n"
"\n"
"func init() {\n"
"\ts := NewStore()\n"
"\ts.warm()\n"
"}\n");
write_temp_file(tmp, "other/other.go",
"package other\n"
"\n"
"type Decoy struct{}\n"
"\n"
"func (d *Decoy) register() {}\n"
"\n"
"func (d *Decoy) warm() {}\n");
for (int i = 0; i < pad_files; i++) {
char name[64];
char body[128];
snprintf(name, sizeof(name), "pad/filler%d.go", i);
snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i);
write_temp_file(tmp, name, body);
}
}
/* Each init's LSP-resolved edges start at that init's own node. */
static int assert_go_init_lsp_edges(cbm_store_t *s, const char *project) {
const char *lsp = "\"strategy\":\"lsp_";
ASSERT_GTE(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Codec.register", lsp), 1);
ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Store.register", lsp), 1);
ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.warm", lsp), 1);
ASSERT_GTE(go_edges_between(s, project, "CALL_REFERENCE", ".reg.init#a.go:1", ".reg.handleA"),
1);
/* No cross-wiring between the inits, and nothing onto the decoy. */
ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Store.register", NULL), 0);
ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Codec.register", NULL), 0);
ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.register", NULL), 0);
ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".other.Decoy.register", NULL), 0);
ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".other.Decoy.register", NULL), 0);
ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".other.Decoy.warm", NULL), 0);
return 0;
}
/* #1910: the def extractor and compute_func_qn name each init
* pkg.init#<file>:<N>; the Go LSP must name its caller the same way
* (cbm_go_init_qn), or every LSP resolution inside an init body loses the
* exact caller-QN join and falls back to the registry resolver. */
TEST(pipeline_go_init_lsp_edges_per_file) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_go_init_lsp_fixture(tmp, 0);
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/go_init_lsp.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
const char *project = cbm_pipeline_project_name(p);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
ASSERT_EQ(assert_go_init_lsp_edges(s, project), 0);
cbm_store_close(s);
cbm_pipeline_free(p);
th_rmtree(tmp);
PASS();
}
/* Parallel twin: >= MIN_FILES_FOR_PARALLEL (50) files plus CBM_WORKERS=4
* take the parallel resolver (resolve_worker's Tier-2 dispatch). */
TEST(pipeline_go_init_lsp_edges_per_file_parallel) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_par_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_go_init_lsp_fixture(tmp, 52);
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/go_init_lsp_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);
ASSERT_EQ(assert_go_init_lsp_edges(s, project), 0);
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();
}
/* Fixture for the #1928 cross-language reference-guard probes (sequential and
* parallel twins). pad_files > 0 adds filler files to push the index over the
* parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per
@@ -15096,6 +15333,208 @@ TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing) {
PASS();
}
/* Route nodes whose qualified name contains `part`. */
static int route_nodes_containing(cbm_store_t *s, const char *project, const char *part) {
cbm_node_t *routes = NULL;
int route_count = 0;
if (cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count) != CBM_STORE_OK) {
return -1;
}
int matches = 0;
for (int i = 0; i < route_count; i++) {
if (routes[i].qualified_name && strstr(routes[i].qualified_name, part)) {
matches++;
}
}
if (routes) {
cbm_store_free_nodes(routes, route_count);
}
return matches;
}
/* CALLS edges out of the function `source_name` into a Route node: the edge a
* route registration emits beside its Route (an HTTP client call's edge to a
* Route is HTTP_CALLS instead). */
static int calls_into_route_count(cbm_store_t *s, const char *project, const char *source_name) {
cbm_edge_t *edges = NULL;
int edge_count = 0;
if (cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count) != CBM_STORE_OK) {
return -1;
}
int matches = 0;
for (int i = 0; i < edge_count; i++) {
cbm_node_t source = {0};
cbm_node_t target = {0};
int source_ok = cbm_store_find_node_by_id(s, edges[i].source_id, &source) == CBM_STORE_OK;
int target_ok = cbm_store_find_node_by_id(s, edges[i].target_id, &target) == CBM_STORE_OK;
if (source_ok && target_ok && source.name && target.label &&
strcmp(source.name, source_name) == 0 && strcmp(target.label, "Route") == 0) {
matches++;
}
cbm_node_free_fields(&source);
cbm_node_free_fields(&target);
}
if (edges) {
cbm_store_free_edges(edges, edge_count);
}
return matches;
}
/* Issue #2537: since Go 1.22 a ServeMux pattern may name a method and a host
* before the path ("POST /v1/payments", "GET api.example.com/v1/x"). Such a
* registration minted no Route and no HANDLES edge, because every registration
* gate wanted the first argument to open with a slash, while the same file
* written with "/v1/payments" had both. The method now comes from the pattern
* and a host is dropped from the path, in the sequential and the parallel pass
* alike. Only Go reads that grammar: a Python registration of "GET /py" is still
* no route. Only a ServeMux registration (Handle / HandleFunc) is read with it:
* another route-verb call names no pattern and mints no Route, neither
* `st.Get("users/123")` on a project method (which keeps its CALLS edge) nor
* `c.Get("https://api.example.com/v1/x")` on a client. */
TEST(pipeline_go_servemux_method_patterns_issue2537) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_servemux_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_temp_file(tmp, "go.mod", "module example.com/srv\n\ngo 1.22\n");
write_temp_file(tmp, "main.go",
"package main\n"
"\n"
"import \"net/http\"\n"
"\n"
"func createPayment(w http.ResponseWriter, r *http.Request) {}\n"
"func getPayment(w http.ResponseWriter, r *http.Request) {}\n"
"func hostOnly(w http.ResponseWriter, r *http.Request) {}\n"
"func legacyList(w http.ResponseWriter, r *http.Request) {}\n"
"\n"
"type Store struct{ m map[string]string }\n"
"\n"
"func (s *Store) Get(k string) string { return s.m[k] }\n"
"\n"
"func lookup(st *Store) string { return st.Get(\"users/123\") }\n"
"\n"
"func fetchRemote(c *http.Client) {\n"
"\t_, _ = c.Get(\"https://api.example.com/v1/x\")\n"
"}\n"
"\n"
"func main() {\n"
"\tmux := http.NewServeMux()\n"
"\tmux.HandleFunc(\"POST /v1/payments\", createPayment)\n"
"\tmux.HandleFunc(\"GET /v1/payments/{id}\", getPayment)\n"
"\tmux.HandleFunc(\"api.example.com/v1/hosted\", hostOnly)\n"
"\tmux.HandleFunc(\"/v1/legacy\", legacyList)\n"
"\t_ = http.ListenAndServe(\":8080\", mux)\n"
"}\n");
write_temp_file(tmp, "web/site.py",
"from flask import Flask\n"
"app = Flask(__name__)\n"
"\n"
"def py_view():\n"
" return \"ok\"\n"
"\n"
"app.add_url_rule(\"GET /py\", \"py_view\", py_view)\n");
/* past MIN_FILES_FOR_PARALLEL, so the threaded run takes the parallel pass */
for (int i = 0; i < 52; i++) {
char name[64];
char body[128];
snprintf(name, sizeof(name), "pad/padding%d.go", i);
snprintf(body, sizeof(body), "package pad\n\nfunc Padding%d() int { return %d }\n", i, i);
write_temp_file(tmp, name, body);
}
const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
char *saved_single = old_single ? strdup(old_single) : NULL;
const char *old_workers = getenv("CBM_WORKERS");
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
int run_rc[2] = {-1, -1};
bool opened[2] = {false, false};
int post[2] = {-1, -1};
int get_id[2] = {-1, -1};
int any_post[2] = {-1, -1};
int host[2] = {-1, -1};
int legacy[2] = {-1, -1};
int py_any[2] = {-1, -1};
int py_get[2] = {-1, -1};
int store_calls[2] = {-1, -1};
int store_route[2] = {-1, -1};
int lookup_reg[2] = {-1, -1};
int remote_route[2] = {-1, -1};
int remote_reg[2] = {-1, -1};
for (int mode = 0; mode < 2; mode++) {
if (mode == 0) {
cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1);
} else {
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
cbm_setenv("CBM_WORKERS", "4", 1);
}
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/servemux_%d.db", tmp, mode);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
run_rc[mode] = p ? cbm_pipeline_run(p) : -1;
const char *project = p ? cbm_pipeline_project_name(p) : NULL;
cbm_store_t *s = cbm_store_open_path(db_path);
opened[mode] = s != NULL;
if (s && project) {
post[mode] =
handles_to_route(s, project, "createPayment", "__route__POST__/v1/payments");
get_id[mode] =
handles_to_route(s, project, "getPayment", "__route__GET__/v1/payments/{}");
any_post[mode] =
handles_to_route(s, project, "createPayment", "__route__ANY__/v1/payments");
host[mode] = handles_to_route(s, project, "hostOnly", "__route__ANY__/v1/hosted");
legacy[mode] = handles_to_route(s, project, "legacyList", "__route__ANY__/v1/legacy");
py_any[mode] = handles_to_route(s, project, "py_view", "__route__ANY__/py");
py_get[mode] = handles_to_route(s, project, "py_view", "__route__GET__/py");
store_calls[mode] = named_edge_count(s, project, "CALLS", "lookup", "Get");
store_route[mode] = route_nodes_containing(s, project, "__route__GET__/123");
lookup_reg[mode] = calls_into_route_count(s, project, "lookup");
remote_route[mode] = route_nodes_containing(s, project, "//api.example.com");
remote_reg[mode] = calls_into_route_count(s, project, "fetchRemote");
}
if (s) {
cbm_store_close(s);
}
cbm_pipeline_free(p);
}
if (saved_single) {
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
free(saved_single);
} else {
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
}
if (saved_workers) {
cbm_setenv("CBM_WORKERS", saved_workers, 1);
free(saved_workers);
} else {
cbm_unsetenv("CBM_WORKERS");
}
th_rmtree(tmp);
for (int mode = 0; mode < 2; mode++) {
ASSERT_EQ(run_rc[mode], 0);
ASSERT_TRUE(opened[mode]);
/* the method is the pattern's, not ANY */
ASSERT_EQ(post[mode], 1);
ASSERT_EQ(get_id[mode], 1);
ASSERT_EQ(any_post[mode], 0);
/* a host names no method and is not part of the path */
ASSERT_EQ(host[mode], 1);
/* control: a plain path is unchanged */
ASSERT_EQ(legacy[mode], 1);
/* another language's "GET /py" is not read as a ServeMux pattern */
ASSERT_EQ(py_any[mode], 0);
ASSERT_EQ(py_get[mode], 0);
/* a project method named Get keeps its CALLS edge; "users/123" is no
* host + path */
ASSERT_EQ(store_calls[mode], 1);
ASSERT_EQ(store_route[mode], 0);
ASSERT_EQ(lookup_reg[mode], 0);
/* a client Get on a URL registers no route: "https:" is no host */
ASSERT_EQ(remote_route[mode], 0);
ASSERT_EQ(remote_reg[mode], 0);
}
PASS();
}
TEST(pipeline_swift_http_call_makes_route_issue1892) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX");
@@ -17376,6 +17815,53 @@ TEST(pipeline_python_init_import_probe_failure_falls_back) {
PASS();
}
/* #1910: with no shared Go registry (the incremental route can pass NULL
* cross_registries) the dispatch takes the per-file fallback, which must name
* each init() caller from the file path exactly as the def extractor does. */
TEST(pipeline_go_init_dispatch_fallback_names_init_caller) {
const char *source = "package reg\n\n"
"func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n"
"func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n";
CBMLSPDef defs[] = {
{.qualified_name = "test.reg.NewCodec",
.short_name = "NewCodec",
.label = "Function",
.def_module_qn = "test.reg",
.return_types = "*Codec",
.lang = CBM_LANG_GO},
{.qualified_name = "test.reg.Codec",
.short_name = "Codec",
.label = "Class",
.def_module_qn = "test.reg",
.lang = CBM_LANG_GO},
{.qualified_name = "test.reg.Codec.Register",
.short_name = "Register",
.label = "Method",
.def_module_qn = "test.reg",
.receiver_type = "test.reg.Codec",
.lang = CBM_LANG_GO},
};
CBMFileResult result = {0};
cbm_arena_init(&result.arena);
cbm_pxc_dispatch_file(CBM_LANG_GO, &result, source, (int)strlen(source), "reg/b.go", "test.reg",
NULL, NULL, defs, 3, NULL, NULL, 0, NULL, NULL);
int first = 0;
int second = 0;
for (int i = 0; i < result.resolved_calls.count; i++) {
const CBMResolvedCall *rc = &result.resolved_calls.items[i];
if (!rc->caller_qn || !rc->callee_qn || rc->confidence <= 0 ||
strcmp(rc->callee_qn, "test.reg.Codec.Register") != 0) {
continue;
}
first += strcmp(rc->caller_qn, "test.reg.init#b.go:1") == 0;
second += strcmp(rc->caller_qn, "test.reg.init#b.go:2") == 0;
}
cbm_arena_destroy(&result.arena);
ASSERT_EQ(first, 1);
ASSERT_EQ(second, 1);
PASS();
}
/* #1277: typed instance fields of a Python class imported from another file.
* Counts CALLS edges caller -> callee (QN suffixes) whose properties name an
* LSP strategy. */
@@ -29607,6 +30093,7 @@ SUITE(pipeline) {
RUN_TEST(pipeline_python_init_register_scope_failure_parallel);
RUN_TEST(pipeline_python_init_import_filter_precedence);
RUN_TEST(pipeline_python_init_import_probe_failure_falls_back);
RUN_TEST(pipeline_go_init_dispatch_fallback_names_init_caller);
RUN_TEST(pipeline_issue1527_python_raw_memo_failure_sequential);
RUN_TEST(pipeline_issue1527_python_raw_memo_failure_parallel);
RUN_TEST(pipeline_issue1527_python_cross_memo_failure_sequential);
@@ -29733,6 +30220,9 @@ SUITE(pipeline) {
RUN_TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge);
RUN_TEST(pipeline_go_multi_return_receiver_is_typed);
RUN_TEST(pipeline_go_multi_return_receiver_is_typed_parallel);
RUN_TEST(pipeline_go_multi_init_nodes_survive);
RUN_TEST(pipeline_go_init_lsp_edges_per_file);
RUN_TEST(pipeline_go_init_lsp_edges_per_file_parallel);
RUN_TEST(pipeline_go_crossfile_type_alias_method);
RUN_TEST(pipeline_go_crossfile_type_alias_method_parallel);
RUN_TEST(pipeline_go_rw_usage_never_cross_into_c);
@@ -29779,6 +30269,7 @@ SUITE(pipeline) {
RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892);
RUN_TEST(pipeline_swift_http_call_makes_route_issue1892);
RUN_TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing);
RUN_TEST(pipeline_go_servemux_method_patterns_issue2537);
RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls);
RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http);
RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin);